Compare commits
561
Commits
33fcc502e4
...
v0.345.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3204024e2 | ||
|
|
6bfb784c4d | ||
|
|
26191911c4 | ||
|
|
3d711ee611 | ||
|
|
0a59044105 | ||
|
|
9c1dfdd71f | ||
|
|
fbec5cb499 | ||
|
|
7ea942a118 | ||
|
|
838c834169 | ||
|
|
b5863a96eb | ||
|
|
a79a292d72 | ||
|
|
fe9837708a | ||
|
|
e2a976198d | ||
|
|
935e624f10 | ||
|
|
3a1f09d29f | ||
|
|
1ee0353dbd | ||
|
|
35232de80d | ||
|
|
2149a13378 | ||
|
|
7720439440 | ||
|
|
e8d2151a77 | ||
|
|
1f8295918e | ||
|
|
1f9c863f79 | ||
|
|
bd9f4f7b74 | ||
|
|
2d71270858 | ||
|
|
b680db85cb | ||
|
|
02daebe9c5 | ||
|
|
e1613aea8d | ||
|
|
c9b4b82dc7 | ||
|
|
518a13592d | ||
|
|
062f9b41d9 | ||
|
|
bb2809dc68 | ||
|
|
2449223f10 | ||
|
|
b01bb4b88d | ||
|
|
321df1f87a | ||
|
|
046b808792 | ||
|
|
bddc477e22 | ||
|
|
42cda07b42 | ||
|
|
ed1011b088 | ||
|
|
60e487db51 | ||
|
|
a9c1997fbc | ||
|
|
277e99839d | ||
|
|
63dcccfeec | ||
|
|
32e61d5d1d | ||
|
|
caaa86eb3c | ||
|
|
23f77f93ba | ||
|
|
1babfe94f6 | ||
|
|
9d9e4041c9 | ||
|
|
943c517550 | ||
|
|
912e59210d | ||
|
|
66a69d4954 | ||
|
|
df78b6caae | ||
|
|
ae394670c1 | ||
|
|
03c0a693b8 | ||
|
|
d5f88a2182 | ||
|
|
6a17c95815 | ||
|
|
cbc944c3c0 | ||
|
|
fc5a20f40a | ||
|
|
add310c80a | ||
|
|
08fa440d04 | ||
|
|
7900a32695 | ||
|
|
1de8a559ed | ||
|
|
f0abdd0eda | ||
|
|
ba22f9b2d9 | ||
|
|
5776d54901 | ||
|
|
28b05d6d68 | ||
|
|
8c1e290f63 | ||
|
|
37b79caad9 | ||
|
|
d582feb245 | ||
|
|
7848d4aaac | ||
|
|
5929b8e345 | ||
|
|
8f5bea0148 | ||
|
|
01c967e49e | ||
|
|
7ca59e2de3 | ||
|
|
aa9e09e817 | ||
|
|
93fb251ea9 | ||
|
|
ae2bfb2f7a | ||
|
|
a03c25aa1f | ||
|
|
c4b3b0a039 | ||
|
|
15ea002458 | ||
|
|
db980ff2cc | ||
|
|
546eef37bd | ||
|
|
e1a2abf1e3 | ||
|
|
a6fa3c01d2 | ||
|
|
c809dd3bc6 | ||
|
|
a741bc1d0f | ||
|
|
601bdba253 | ||
|
|
9bdd8f8fe8 | ||
|
|
ba4a6f060b | ||
|
|
8ae9178138 | ||
|
|
772ef36f5f | ||
|
|
615940de35 | ||
|
|
6c6bd87664 | ||
|
|
03ab121b25 | ||
|
|
2fe634141f | ||
|
|
5d67041d6d | ||
|
|
e30091bf27 | ||
|
|
99f9117296 | ||
|
|
09ac87a843 | ||
|
|
36c4aa4e69 | ||
|
|
24d937d6e4 | ||
|
|
6d90374f63 | ||
|
|
71fbef6bbe | ||
|
|
0c6ce9500c | ||
|
|
a4c0d546e5 | ||
|
|
67a4b452e2 | ||
|
|
8f029aa70b | ||
|
|
411d7761d9 | ||
|
|
e3bb775ac5 | ||
|
|
3e8d027677 | ||
|
|
334df7db12 | ||
|
|
30956aabd2 | ||
|
|
3f0127778c | ||
|
|
55868cb3e0 | ||
|
|
85d60a1930 | ||
|
|
68af0e97f2 | ||
|
|
88aa65d595 | ||
|
|
6ed223886d | ||
|
|
94f44931dd | ||
|
|
7d41c2973c | ||
|
|
7600e6a8b7 | ||
|
|
b69585789a | ||
|
|
f75b3aa778 | ||
|
|
00a0d91976 | ||
|
|
8587198781 | ||
|
|
fbcc3cf042 | ||
|
|
29a3ce8cf2 | ||
|
|
e9d0d723c1 | ||
|
|
52619e89bd | ||
|
|
fc77fcb89b | ||
|
|
f037be7a6a | ||
|
|
8fe46a1313 | ||
|
|
7e791d9b5d | ||
|
|
c02c9d833a | ||
|
|
1296e7aa46 | ||
|
|
f30a80841e | ||
|
|
39bf56298d | ||
|
|
0f82eba7c6 | ||
|
|
7adbbb17c0 | ||
|
|
d170a2aecf | ||
|
|
8d44416610 | ||
|
|
36da4089ca | ||
|
|
a43d0423d5 | ||
|
|
123d2d553f | ||
|
|
a86c864b06 | ||
|
|
e7b64af1f0 | ||
|
|
0626b9fc8a | ||
|
|
c6a567122e | ||
|
|
4261a38bf0 | ||
|
|
f9151f0875 | ||
|
|
ec438479e2 | ||
|
|
8c7aa10a6e | ||
|
|
9e68e526e9 | ||
|
|
02252556b3 | ||
|
|
f908eee2a3 | ||
|
|
006ceab58a | ||
|
|
48d8674876 | ||
|
|
7f3e58ff0a | ||
|
|
9cc7ffcf3b | ||
|
|
83392064ae | ||
|
|
db52f5a4cf | ||
|
|
fe0228c877 | ||
|
|
e66d4669d6 | ||
|
|
85f65c70d8 | ||
|
|
97c992ad44 | ||
|
|
17863fe8bc | ||
|
|
e209a8d724 | ||
|
|
cac92a407e | ||
|
|
f327bc53a9 | ||
|
|
372d6e5994 | ||
|
|
37ffa3beb5 | ||
|
|
a09cab124a | ||
|
|
f580d88a14 | ||
|
|
78c894234c | ||
|
|
331be17e1e | ||
|
|
8bc7ec54cd | ||
|
|
9b95e8e09b | ||
|
|
3b85a0c29a | ||
|
|
8d862e4e69 | ||
|
|
acbbf4b305 | ||
|
|
e24430cce5 | ||
|
|
201f936e9a | ||
|
|
1b2f74c52f | ||
|
|
7626a28de3 | ||
|
|
402fa41e45 | ||
|
|
462196e29b | ||
|
|
68294ecb6f | ||
|
|
faef8f8a91 | ||
|
|
fd28d70bff | ||
|
|
dcd89364a4 | ||
|
|
fc6466a50a | ||
|
|
9e5b88146e | ||
|
|
c255195e37 | ||
|
|
1cb43a21a2 | ||
|
|
b3a879b50e | ||
|
|
ec9b997151 | ||
|
|
13eeb484ab | ||
|
|
d7bfdccf57 | ||
|
|
3f84f82c3d | ||
|
|
d3e17e9b2e | ||
|
|
e06680b797 | ||
|
|
63441f0a69 | ||
|
|
740339567a | ||
|
|
1040cfe225 | ||
|
|
fcd9f09927 | ||
|
|
423b6d3404 | ||
|
|
e37e9b50c6 | ||
|
|
d5f2e47ea1 | ||
|
|
05e129267a | ||
|
|
a40729a698 | ||
|
|
a0370b4ed8 | ||
|
|
75b3b39254 | ||
|
|
6d07ca2be4 | ||
|
|
4d3a0246df | ||
|
|
29bebbb937 | ||
|
|
431da7ebbc | ||
|
|
191836946b | ||
|
|
e2392935a3 | ||
|
|
a3e85e5a40 | ||
|
|
9acc3adb96 | ||
|
|
0629c16a4b | ||
|
|
1de22a166a | ||
|
|
3d6ba2852f | ||
|
|
f65ace0ca6 | ||
|
|
5e7522e023 | ||
|
|
0b1b8d3445 | ||
|
|
66aee8939b | ||
|
|
ce2e31f4b2 | ||
|
|
5b8656974b | ||
|
|
9c945688c0 | ||
|
|
9d2d62831a | ||
|
|
d720495cd5 | ||
|
|
d8c2634472 | ||
|
|
31242786af | ||
|
|
36088fd82e | ||
|
|
871d05b84d | ||
|
|
c393d8529a | ||
|
|
2d63714eca | ||
|
|
13bda270c3 | ||
|
|
ff4a99a0ab | ||
|
|
bee2c2d815 | ||
|
|
c67b26be28 | ||
|
|
4c3c2037fd | ||
|
|
2fc314d491 | ||
|
|
0a960ba58d | ||
|
|
7044b11804 | ||
|
|
2e5ef7e410 | ||
|
|
e4774e6a06 | ||
|
|
0f2eab7a3e | ||
|
|
ff1758a21c | ||
|
|
84d6ef706d | ||
|
|
ef199c5705 | ||
|
|
c4f4019721 | ||
|
|
d7efa7b54e | ||
|
|
ca89e70d53 | ||
|
|
b05be0ac11 | ||
|
|
b36a85ac04 | ||
|
|
1991c742a3 | ||
|
|
4347983a72 | ||
|
|
05a447ef02 | ||
|
|
28a5203d01 | ||
|
|
6fac245d24 | ||
|
|
33c397380e | ||
|
|
034a2b7363 | ||
|
|
daeed27223 | ||
|
|
8ce496f124 | ||
|
|
391967bcf1 | ||
|
|
074ddda352 | ||
|
|
7b8216abac | ||
|
|
d15ad105be | ||
|
|
9271f33359 | ||
|
|
f7c39c1700 | ||
|
|
1355391c78 | ||
|
|
0b349e2415 | ||
|
|
800b32ceec | ||
|
|
f885100d29 | ||
|
|
4867ec1804 | ||
|
|
0b93e1ad1e | ||
|
|
27b373b93c | ||
|
|
6f1ba1fe43 | ||
|
|
1453cfc69b | ||
|
|
114ff2761a | ||
|
|
e563733282 | ||
|
|
df85a07c4c | ||
|
|
7690c27f86 | ||
|
|
ff69074514 | ||
|
|
477e2548db | ||
|
|
635c33d381 | ||
|
|
e730a629fd | ||
|
|
7861bf9dde | ||
|
|
69e914534f | ||
|
|
2a7f6c6c42 | ||
|
|
09e634de31 | ||
|
|
badc9c2a0c | ||
|
|
fc796dd95e | ||
|
|
99a1497560 | ||
|
|
c1a73b24b3 | ||
|
|
c4bf730637 | ||
|
|
2a2af2018d | ||
|
|
55bb6c3d32 | ||
|
|
09453325d1 | ||
|
|
580b656914 | ||
|
|
9821690980 | ||
|
|
c8d5ee204d | ||
|
|
977665d8c0 | ||
|
|
a4a753b9e2 | ||
|
|
a28155f858 | ||
|
|
79ef715277 | ||
|
|
d4167420d1 | ||
|
|
1e216d3468 | ||
|
|
b53721db34 | ||
|
|
1e8d045815 | ||
|
|
c1b123c649 | ||
|
|
ac8ea72025 | ||
|
|
a70c398dee | ||
|
|
00fddd3816 | ||
|
|
5a3437669f | ||
|
|
d48da6c7f3 | ||
|
|
29d3c533aa | ||
|
|
6be6c53e29 | ||
|
|
1cfb1244d6 | ||
|
|
4110da50e9 | ||
|
|
6fc3059167 | ||
|
|
149467c795 | ||
|
|
3a12c7341a | ||
|
|
7fa8768cfd | ||
|
|
2548b4c924 | ||
|
|
0c702f834a | ||
|
|
2e9a948cbd | ||
|
|
7cba0bfca7 | ||
|
|
1ee6dbe184 | ||
|
|
6479933e8e | ||
|
|
0c1023e503 | ||
|
|
820e8efde1 | ||
|
|
54bb4da343 | ||
|
|
7fcda8f1a4 | ||
|
|
b6810f14ff | ||
|
|
fb2bcdd5d6 | ||
|
|
64cbefe317 | ||
|
|
064f23896a | ||
|
|
43e99d160c | ||
|
|
5a731b45b9 | ||
|
|
7162f489c9 | ||
|
|
1c5dd07604 | ||
|
|
b6399fdf0b | ||
|
|
2caae38a71 | ||
|
|
3d49df1e5a | ||
|
|
44ae4dea70 | ||
|
|
71accca427 | ||
|
|
cdfc817d5e | ||
|
|
9cf13a3add | ||
|
|
1495ca43fb | ||
|
|
504eae018b | ||
|
|
7c6140d95b | ||
|
|
5b1b191ffc | ||
|
|
3c6b49b31c | ||
|
|
7c3b3a9694 | ||
|
|
206b0357d1 | ||
|
|
80e6ad8c47 | ||
|
|
15e630aa02 | ||
|
|
964ae7538f | ||
|
|
411a43f8ef | ||
|
|
0054d4bd69 | ||
|
|
0a3026180a | ||
|
|
bc278944a3 | ||
|
|
c3a2aba0d2 | ||
|
|
2cd66663f3 | ||
|
|
5d38573a1a | ||
|
|
8fc2b4a1a9 | ||
|
|
b9deec1907 | ||
|
|
3d41758be0 | ||
|
|
b793638484 | ||
|
|
93cee16843 | ||
|
|
811f75736e | ||
|
|
d0d431b42b | ||
|
|
bdeb1c0d8e | ||
|
|
8f8a64cad7 | ||
|
|
19ef0329ab | ||
|
|
0fe04bb6d5 | ||
|
|
f6909492cc | ||
|
|
7c4a33b6d1 | ||
|
|
ecefdf2dc5 | ||
|
|
eb806f5780 | ||
|
|
60f0a86bd4 | ||
|
|
e81ad613ae | ||
|
|
bef39598d0 | ||
|
|
8fb2f9ef9d | ||
|
|
2e9d40255b | ||
|
|
0b1486df8f | ||
|
|
48f3336956 | ||
|
|
eb6aa58aa7 | ||
|
|
7c05b59708 | ||
|
|
5270bad76e | ||
|
|
736f54b496 | ||
|
|
0194342264 | ||
|
|
f806baf4c7 | ||
|
|
c00fed6db3 | ||
|
|
e236dca486 | ||
|
|
be2efe5624 | ||
|
|
ac149c2aa4 | ||
|
|
0555091f83 | ||
|
|
11b790e314 | ||
|
|
f8ebc47728 | ||
|
|
ff68b0b433 | ||
|
|
6be55a0eb3 | ||
|
|
1af89a54af | ||
|
|
cc2ad41516 | ||
|
|
612c417024 | ||
|
|
89dd3e94b1 | ||
|
|
29e2acb5f8 | ||
|
|
0fe315b759 | ||
|
|
714d5bce09 | ||
|
|
ad398b60d9 | ||
|
|
2f8ff2414c | ||
|
|
383a30b3c0 | ||
|
|
d3eacbb7cc | ||
|
|
843b319f35 | ||
|
|
406755fa8f | ||
|
|
d698ce343b | ||
|
|
c1f62ddae8 | ||
|
|
3e813307cc | ||
|
|
3013a1cc93 | ||
|
|
3b15ce101f | ||
|
|
21a9d35f70 | ||
|
|
24d7c54c79 | ||
|
|
bdcbd50b42 | ||
|
|
0e3d831030 | ||
|
|
b93c1543da | ||
|
|
f946b0d0ca | ||
|
|
cf8a371c39 | ||
|
|
cbcca03061 | ||
|
|
129201abab | ||
|
|
0d402f711d | ||
|
|
1552716722 | ||
|
|
7410f1c841 | ||
|
|
42a73e667a | ||
|
|
bab82c471e | ||
|
|
2a56f557d0 | ||
|
|
8a0e0a59ad | ||
|
|
998aa31958 | ||
|
|
38d28b5b62 | ||
|
|
32da46cd64 | ||
|
|
50312b63bc | ||
|
|
2fb558552a | ||
|
|
8025304acc | ||
|
|
960d29b061 | ||
|
|
22983885f1 | ||
|
|
670def61b4 | ||
|
|
681cc663ef | ||
|
|
3db62fc6b2 | ||
|
|
13dd00bcff | ||
|
|
a4444088ad | ||
|
|
a017367f9f | ||
|
|
4e13fdaaa0 | ||
|
|
62c6a8a98a | ||
|
|
8b55de734c | ||
|
|
fcef8e069c | ||
|
|
9aea86cd48 | ||
|
|
79f853fbbd | ||
|
|
303129e3af | ||
|
|
e43b5ec07d | ||
|
|
2d802d75e8 | ||
|
|
1cfdde968f | ||
|
|
b48a7fa326 | ||
|
|
5429d651ee | ||
|
|
2358e561b7 | ||
|
|
fed272e62a | ||
|
|
0957f43d10 | ||
|
|
8aa95b5831 | ||
|
|
4c8f0d2919 | ||
|
|
0f9b796615 | ||
|
|
c732006d26 | ||
|
|
430fb4448d | ||
|
|
04abeb20c0 | ||
|
|
0989bb83c3 | ||
|
|
b670da748d | ||
|
|
3c49dc8ea4 | ||
|
|
300d7e87d7 | ||
|
|
45770f2282 | ||
|
|
0d52a42c17 | ||
|
|
e64c84aef8 | ||
|
|
c476ae51a5 | ||
|
|
36c13cfa0a | ||
|
|
c0c8fe67bf | ||
|
|
e4e0aa8f46 | ||
|
|
b8af72764d | ||
|
|
e5eee501b5 | ||
|
|
45b52b6ed5 | ||
|
|
92cebb8c95 | ||
|
|
f8c9390946 | ||
|
|
1da2c9c6c6 | ||
|
|
9832760027 | ||
|
|
14137efac5 | ||
|
|
5da11c4480 | ||
|
|
da75603553 | ||
|
|
f7881787f4 | ||
|
|
810b18ab8e | ||
|
|
968c968559 | ||
|
|
2024ed9982 | ||
|
|
1a2405e86f | ||
|
|
5b52a5964d | ||
|
|
2c724c9283 | ||
|
|
0f3cf0dbb2 | ||
|
|
c1dbb05ad6 | ||
|
|
cbc3fa589c | ||
|
|
08eb1a6e3a | ||
|
|
2da259af38 | ||
|
|
5ce3a44645 | ||
|
|
f94543ee5c | ||
|
|
985388c6e9 | ||
|
|
2fa1efc5e5 | ||
|
|
330e4a051e | ||
|
|
90f2545679 | ||
|
|
8144a70a72 | ||
|
|
34d83f5a02 | ||
|
|
c24f1920d9 | ||
|
|
bb50e1293c | ||
|
|
3e3ee94b7b | ||
|
|
ae10f64806 | ||
|
|
3ed5e3e770 | ||
|
|
3168a78935 | ||
|
|
89712563a0 | ||
|
|
1b66010298 | ||
|
|
68f3e12398 | ||
|
|
f87be3575f | ||
|
|
38f4535bfa | ||
|
|
397d62136f | ||
|
|
86a78c6767 | ||
|
|
b762a37097 | ||
|
|
c732fe1283 | ||
|
|
fcffaf573a | ||
|
|
37b5ba08a7 | ||
|
|
27d1165962 | ||
|
|
62998aab4f | ||
|
|
8dbbc98ff2 | ||
|
|
3d3b4496f3 | ||
|
|
0a9158d53e | ||
|
|
72368654e4 | ||
|
|
de39e47f53 | ||
|
|
a5d90ff801 | ||
|
|
a491abef6c | ||
|
|
763de3a025 | ||
|
|
c6b69d888e | ||
|
|
53e9bf0224 | ||
|
|
4d349d1106 | ||
|
|
9dc26459ea | ||
|
|
66d80efb9f | ||
|
|
7db42c5fec | ||
|
|
a62bb3874b | ||
|
|
7534ea203d | ||
|
|
c7446f2d6a | ||
|
|
1e759a16ec | ||
|
|
05cf352a2f | ||
|
|
a3499d1807 | ||
|
|
a315d623b8 | ||
|
|
62b85ecf13 | ||
|
|
636c51e542 | ||
|
|
be3c5fa7f6 | ||
|
|
992803c10b | ||
|
|
a91f055960 | ||
|
|
1214bae0a2 | ||
|
|
68f195676b |
No files matched your search
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
paths: ["controller/internal/agentapi/**"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Coupling to the host agent — felhom-controller
|
||||||
|
|
||||||
|
`internal/agentapi` is **the disk seam**: the pinned-TLS client to the host agent's per-guest local
|
||||||
|
API. The controller holds no Proxmox credentials; everything disk/host/Proxmox goes through here.
|
||||||
|
|
||||||
|
## Declaring a coupled feature
|
||||||
|
|
||||||
|
Controller behaviour that depends on a specific agent version needs **all three**, or it ships broken
|
||||||
|
on an older box:
|
||||||
|
|
||||||
|
1. a `featureProbes` table row in `internal/agentapi/features.go`
|
||||||
|
2. a `Supports` gate call **at the feature's entry point** — not somewhere on the path to it
|
||||||
|
3. `MinAgent: X.Y.Z` in the CHANGELOG entry header
|
||||||
|
|
||||||
|
Rules: `felhom.eu/documentation/runbooks/publish-train-rules.md`.
|
||||||
|
|
||||||
|
## Never push a controller past the agent it depends on
|
||||||
|
|
||||||
|
The R-216 guard compared the box's agent against the **golden's** MinAgent while serving a **floor**
|
||||||
|
that could point elsewhere. Raise a floor above the vouched golden — which the day-0 runbook
|
||||||
|
recommends and a per-customer override makes trivial — and the guard checks a version it is not
|
||||||
|
serving. A box then landed on a controller needing a newer agent, and its customer was told a correct
|
||||||
|
recovery code was wrong.
|
||||||
|
|
||||||
|
**A floor above the vouched golden is HELD, with its own reason** (hub v0.97.0).
|
||||||
|
|
||||||
|
## Distinguish "could not reach" from "wrong answer"
|
||||||
|
|
||||||
|
A failed bundle FETCH must not be reported to a customer as a bad recovery code. Classify by **value**
|
||||||
|
(`ErrBundleFetch` → HTTP 502), never by error string — a string is not something a caller can branch
|
||||||
|
on. Unknown class → neutral message, never the typing message.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
R-224, measured live 2026-08-05 (CAMPAIGN-11 F3/F4) with a correct current code: 0.0556 s with the
|
||||||
|
hub firewalled off and 0.0299 s with the agent stopped, against ~1.0 s for a genuine unseal — the
|
||||||
|
machine accused the customer of something it had not attempted. A green test named this exact
|
||||||
|
consequence since v0.125.0 and did not prevent it, because it asserted this package's error STRING
|
||||||
|
one layer below where the merge happened. Fixed agent v0.126.0 + controller v0.202.0.
|
||||||
|
-->
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
paths: ["controller/internal/backup/**", "controller/internal/appbackup/**", "controller/internal/recovery/**", "controller/internal/appexport/**", "controller/internal/quiesce/**"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backup, recovery units and export — felhom-controller
|
||||||
|
|
||||||
|
## Assert the consequence across the whole run, not the mechanism inside one function
|
||||||
|
|
||||||
|
The R-181 recovery-unit refusal claimed *"the previous unit is untouched and NOTHING was deleted"*.
|
||||||
|
*Nothing deleted* held; **untouched was measured false** — the floor was checked ONLY in
|
||||||
|
`captureAllRecoveryUnits`, while the two dump legs wrote the bulk into the same tree first and
|
||||||
|
unguarded, so a 182,272 B tar became 2,147,666,432 B under a manifest that had not moved. A full
|
||||||
|
green suite plus three of its own red-proofs missed it, because every one asserted the mechanism
|
||||||
|
inside `captureAllRecoveryUnits`.
|
||||||
|
|
||||||
|
**The test that catches this class: fingerprint the tree before and after the whole backup run, and
|
||||||
|
compare.** Full doctrine and the other eight instances: the `felhom-testing` skill.
|
||||||
|
|
||||||
|
## Presence is not success
|
||||||
|
|
||||||
|
A timestamp recording an **attempt** must never be read as evidence of a **result**. Where a status
|
||||||
|
field travels alongside a timestamp, the verdict consults both — or the timestamp records only
|
||||||
|
successes. Ask of any timestamp: *what exactly must have happened for this to be set?* If the answer
|
||||||
|
is "we tried", it cannot answer "did it work".
|
||||||
|
|
||||||
|
**Corollary:** when a verdict changes which field it counts from, the alarm text has to change with
|
||||||
|
it. `last run 8h ago` while alarming on a six-day-old success turns a true alarm into one the
|
||||||
|
operator dismisses.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Two instances. F-CRIT-2: a phantom snapshot's ctime set tier freshness — an aborted 1-byte upload
|
||||||
|
made the tier look backed up. R-100: LastRun is written on failure, so a nightly-failing offsite
|
||||||
|
tier kept the staleness clock fresh forever.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Storage keys and paths
|
||||||
|
|
||||||
|
- Never guess a persisted key — it is `offbox`, not `offbox_target` (R-7b).
|
||||||
|
- `.fab` export/import uses strict segment validation; bundles from controller ≤0.124.0 are hollow.
|
||||||
|
- Recovery-unit restore and tier-2 copies share `appbackup`'s path primitives — change them there,
|
||||||
|
once, not per caller.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
paths: ["controller/**/*.go", "controller/**/*.html", "controller/**/*.css", "controller/scripts/**"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gates and logging — felhom-controller
|
||||||
|
|
||||||
|
## The ONE entry point
|
||||||
|
|
||||||
|
**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
|
||||||
|
except `gate-decoys` (the decoy suite, ~22 s, edits real files in this tree for a moment; R-1116), which
|
||||||
|
runs in the full run only — so the hook and CI do not run it.
|
||||||
|
- **A missing gate script is a FAILURE, never a skip.**
|
||||||
|
- **The shared `reuse_refs_check.py`, `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
|
||||||
|
when this clone is unarmed. `git push --no-verify` bypasses it deliberately; **say so in the session
|
||||||
|
report when you use it** — CI re-runs the same entry point on every push and **emails the operator
|
||||||
|
on failure**, so a bypass is noticed even though it is not blocked (R-168, CLOSED 2026-08-02).
|
||||||
|
|
||||||
|
<!--
|
||||||
|
WHY A RUNNER AND NOT SEVEN INVOCATIONS (2026-08-02, R-29) — rationale, not a directive.
|
||||||
|
A census of all thirteen gates across the four repos found that every check a CLAUDE.md named was
|
||||||
|
passing, and two of the four nobody is told to run were failing. This repo's CLAUDE.md used to name
|
||||||
|
two of the seven; the other five were reachable only through a line in REUSE.md, and
|
||||||
|
docker_run_volume_path_gate.py was RED. The single-entry-point shape is the only one that
|
||||||
|
demonstrably gets run. app-catalog-felhom.eu/scripts/catalog_gates.py is the canonical version of
|
||||||
|
the runner (R-161); repo_gates.py copies it. site_gates.py is a *gate*, not a runner — do not model
|
||||||
|
new work on it.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
New leveled lines use `internal/logx` — DEBUG always reaches the debug ring; stdout respects
|
||||||
|
`logging.level`. English, keys-never-values, durations on outcomes. Full rules:
|
||||||
|
`felhom.eu/documentation/runbooks/logging-conventions.md`.
|
||||||
|
|
||||||
|
## Health checks issue no block I/O
|
||||||
|
|
||||||
|
A probe that touches a wedged device enters uninterruptible sleep, survives `SIGKILL`, and cannot be
|
||||||
|
recovered until the device returns or the host reboots — so `systemctl restart` hangs too. A timeout
|
||||||
|
protects the caller's control flow and nothing else: the blocked thread remains. Liveness is decided
|
||||||
|
from `/proc` and kernel state, never by reading or writing the filesystem.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Measured, R-117 spike §6.3 (felhom.eu/documentation/audits/SPIKE-r117-bind-liveness-2026-07-30.md):
|
||||||
|
a probe stayed in D state 3m50s after kill -9; a buffered write with no fsync blocked too (O_CREAT
|
||||||
|
needs journal access); and statfs/getdents returned HEALTHY on a namespace that EIOs every byte —
|
||||||
|
fast, and wrong.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## A gate ships with a decoy test that has been seen to fail (R-421)
|
||||||
|
|
||||||
|
**A decoy is the LABEL without the FACT** — a directory with the right name and no bake log, a
|
||||||
|
handler case that exists only in a comment, a note whose prose mentions the marker it lacks. Write
|
||||||
|
one for every new gate, run it, and watch it convict. `felhom.eu/scripts/decoy_coverage_gate.py` (run by felhom.eu's `repo_gates.py`, for all four repos) refuses a gate
|
||||||
|
registered without one, or without a named exemption carrying its row.
|
||||||
|
|
||||||
|
**Earned by five instances, every one found by accident:** R-410, R-400, R-378, R-419, R-94. The
|
||||||
|
2026-09-01 sweep read all 29 gate scripts and fooled 16 of them. The four shapes to test against:
|
||||||
|
|
||||||
|
1. **name-for-fact** — it matches a path or directory NAME while the fact lives inside the file.
|
||||||
|
2. **substring-for-field** — it matches a token anywhere in a body instead of in the field carrying it.
|
||||||
|
3. **declaration-for-reachability** — it checks a thing is declared, not that it RESOLVES.
|
||||||
|
4. **constant-for-measurement** — it compares a value against itself.
|
||||||
|
|
||||||
|
**Scope is a fact too.** Eight of the sixteen were `os.listdir` (one level) where `os.walk` was meant:
|
||||||
|
green and correct today, blind the moment anyone adds a subdirectory. Prefer `os.walk`, and prefer a
|
||||||
|
glob over a hand-maintained list of files.
|
||||||
|
|
||||||
|
**A decoy nobody would write proves nothing** — say the gate is sound and move on. Five of mine were
|
||||||
|
withdrawn as illegitimate and are named in `felhom.eu/documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
paths: ["controller/internal/web/templates/**", "controller/internal/web/**/*.go", "**/*.css", "**/*.html"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI and Hungarian copy — felhom-controller
|
||||||
|
|
||||||
|
- **All UI text is Hungarian**, Budapest timezone.
|
||||||
|
- Design tokens, badge/colour rules, the 2px/no-shadow/no-emoji/BOM hard rules and the mechanical
|
||||||
|
gate to run after each surface: **use the `felhom-ui-design` skill.**
|
||||||
|
- Template methods need **value receivers** — pointer receivers compile, pass `go vet`, pass the
|
||||||
|
suite, and then 500 at render time.
|
||||||
|
|
||||||
|
## Grep fetched pages with ASCII-only substrings
|
||||||
|
|
||||||
|
Accented Hungarian patterns get mangled through the `ssh → pct exec → bash -c` chain and return a
|
||||||
|
false `0` — which reads exactly like the banner or string being gone. Use `kezel`, `Utols`,
|
||||||
|
`Biztons`. **Never let an accented pattern gate a conclusion.**
|
||||||
|
|
||||||
|
<!--
|
||||||
|
From the 2026-07-20 remediation: an accented grep nearly produced a wrong "banner cleared" claim.
|
||||||
|
This is the "an absent line is not evidence" rule aimed at a UTF-8 transport, not at a log.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Credentials containing `!` or `'` break in heredoc-built helper scripts
|
||||||
|
|
||||||
|
History expansion eats `!!`. Use the proven inline `-d "password=$PW"` form for authed curl, and
|
||||||
|
delete any credential-bearing helper from `/tmp` (host AND guest) when done.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
unconditional: true
|
||||||
|
---
|
||||||
|
# Unprompted work — rules for any session without a task file
|
||||||
|
|
||||||
|
> Goal sessions, nightly sessions, "work the register" sessions. **A session that starts from
|
||||||
|
> `/goal` or a standing brief inherits these rules exactly as it inherits the gates.** They are the
|
||||||
|
> part of `PROMPT-TEMPLATE.md` that a task file used to carry and a goal does not. Same wording lives
|
||||||
|
> in `felhom.eu`, `felhom-controller`, `felhom-agent` and `app-catalog-felhom.eu` `.claude/rules/`, and in the workspace
|
||||||
|
> root's unversioned `.claude/rules/`; change all five or none.
|
||||||
|
|
||||||
|
## 1. What you may pick up on your own
|
||||||
|
|
||||||
|
- A register row **you or another CC session filed**, with owner CC, at P3 or a bounded P2, that
|
||||||
|
needs **no operator decision**, touches **no customer data by design**, and introduces **no
|
||||||
|
mechanism nobody has measured**. Smallest first.
|
||||||
|
- A defect you find while exercising the product, filed as a row **before** you fix it — **unless it is small**:
|
||||||
|
a small finding is fixed in the session and never filed (the size rule, `OPEN-ITEMS.md` „How a row is filed").
|
||||||
|
- Hygiene: register compression, stale citations, rows with no owner, documents that contradict
|
||||||
|
live source.
|
||||||
|
|
||||||
|
**Not yours, ever, without a task file or an operator word:** money; anything that changes risk to
|
||||||
|
customer data; anything that changes a promise the product makes to a customer; anything that
|
||||||
|
reverses a documented design decision (`documentation/architecture/` — a design decision is not a
|
||||||
|
defect, R-370); anything on DooPlex or ep0; baking or vouching a golden; promoting a
|
||||||
|
catalog version; a new external dependency; **a hub image build or hub deploy in a session the operator does not
|
||||||
|
attend** (operator ruling 2026-10-07, `09` §3 decision 162).
|
||||||
|
|
||||||
|
## 2. When you may decide instead of ask (operator grant, 2026-09-14)
|
||||||
|
|
||||||
|
You may take a decision yourself when **all** of these hold: the architecture folder and the register
|
||||||
|
give a clear direction; your choice follows that direction; it is reversible without customer-data
|
||||||
|
risk; and you can write it in the `09-update-architecture.md` §3 shape — one answerable sentence, the
|
||||||
|
options, what each costs, why this one. **Then record it** as a dated decision in `CONTEXT.md` and
|
||||||
|
the owning architecture document, tagged *decided by CC unattended — operator may reverse*, and put
|
||||||
|
it **first** in the morning note. A decision you cannot write in that shape is one you do not take.
|
||||||
|
|
||||||
|
## 3. The discipline a task file used to carry
|
||||||
|
|
||||||
|
1. **Baselines first.** Read each repo's `main` hash and version from live source before touching it.
|
||||||
|
2. **Read the architecture document for the area, and name it** in the report, before any claim.
|
||||||
|
3. **Red-proof every correctness fix.** A test never seen failing has not been shown to test anything.
|
||||||
|
4. **Live-validate on a Tier-0 box** through the endpoints the UI invokes. `demo-hp` is `ssh hp`.
|
||||||
|
Throwaway apps only; the standing apps and `bentopdf` stay.
|
||||||
|
5. **Evidence off the machine at the end of each phase**, before any revert (R-320).
|
||||||
|
6. **One release per repo per session**, with a CHANGELOG entry (controller: with its `MinAgent`
|
||||||
|
line) and REPORT overwritten. **A controller release needs no golden** (operator ruling 2026-10-15
|
||||||
|
11:26, `09` §3 decision 250; decision 7 stands): push the image only when it is ready for EVERY box (the
|
||||||
|
household's update button offers the highest tag); raise the floor, MinAgent declared, only after it ran
|
||||||
|
on Tester 1, demo-hp and demo-felhom through the normal update path and the box walk's checks are green
|
||||||
|
on all three — not at night unless the night brief says so. **Agent releases still stop for the
|
||||||
|
operator's vouch.** The golden is baked weekly and vouched by the operator; CC never vouches. **No `--no-verify`.**
|
||||||
|
**A version number is pushed once** — `felhom-controller/controller/build.sh` now refuses a second
|
||||||
|
push of a tag that exists, and a registry it cannot reach is a refusal too. This is a FACT about
|
||||||
|
what the build does, not a new rule: a fix for something already pushed is the next patch version,
|
||||||
|
and a box on a bad image is moved by the normal update path, never by re-tagging. Earned on
|
||||||
|
2026-10-15, when 0.315.0 was pushed three times from three different commits and a cache kept
|
||||||
|
serving the first one (`09-update-architecture.md` §0).
|
||||||
|
7. **An enumerated gap becomes a row in the same session — or, if it is small, is fixed in it** (the size rule).
|
||||||
|
Prose is not a record.
|
||||||
|
8. **Hungarian text is searched with ASCII fragments**, with a positive and a negative control.
|
||||||
|
9. **Never leave a half-state.** If time runs out, revert to clean and say what was reverted.
|
||||||
|
10. **Teardown, three layers, stated** — machine, host, hub — or "provisioned nothing".
|
||||||
|
11. **Every helper prompt carries the brief's fences in full** (operator ruling 2026-10-07). A helper session (a
|
||||||
|
subagent, a fork, a workflow agent) gets the brief's fence list word for word — every protected machine, every
|
||||||
|
„no", every delivery and Docker limit — not a summary and not „the usual fences". Earned on 2026-10-06 night: two
|
||||||
|
helpers whose prompts carried only part of the fences ran `docker volume prune` on the bench and a Docker-using
|
||||||
|
gate on DooPlex.
|
||||||
|
|
||||||
|
## 4. The morning note
|
||||||
|
|
||||||
|
One screen, plain language, in this order: **decisions you took** (§2) first; what you exercised;
|
||||||
|
what broke and whether you fixed it; rows opened and closed with the register size before and after;
|
||||||
|
what needs the operator, each with what happens if they do nothing. No file paths, no function
|
||||||
|
names, no row numbers as the subject of a sentence.
|
||||||
|
|
||||||
|
## 5. Instruction files
|
||||||
|
|
||||||
|
**Instruction files (`CLAUDE.md`, `.claude/rules/*`) are kept true by the session that finds them wrong**
|
||||||
|
(operator ruling 2026-10-06, `09` §3 decision 150). A session MAY, without asking: correct a stale fact (a command, a
|
||||||
|
count, a version, a path, a description of what a gate does), add a fact it proved, and remove a reference to something
|
||||||
|
that no longer exists. Each edit is named in the report (file, line, before, after, why). A session MAY NOT, without the
|
||||||
|
operator's word: loosen a safety rule, a fence, a „never", a protected machine, a secret rule, or a review step; or
|
||||||
|
remove a rule. When in doubt, it is a rule change, and it goes to the operator. If Claude Code's own permission check
|
||||||
|
asks before such an edit, wait for the operator's click; if it refuses, record that and file the exact line.
|
||||||
|
|
||||||
|
## 6. The system poster stays true
|
||||||
|
|
||||||
|
**A session that changes a fact listed in `architecture/felhom-system-poster.facts.md` (a machine, a
|
||||||
|
role, a traffic path, a backup tier, a time, a retention, a key, a known gap) updates that file in
|
||||||
|
the same commit.** If the change is text only, it also edits the matching text in
|
||||||
|
`felhom-system-poster.html`. If it needs a new drawing, it adds the line
|
||||||
|
**"System poster needs a refresh: <what changed>"** to `STATUS.md`'s "waiting on the operator" list.
|
||||||
|
**The session report names which of the three it did.**
|
||||||
|
|
||||||
|
Why the three-way split: the poster is a drawing made in Claude Design, and **nothing in this
|
||||||
|
repository renders it** — unlike `where-felhom-stands.html`, which has `render_stands.py`. So the
|
||||||
|
facts file is the source of truth and the drawing trails it. `scripts/poster_facts_gate.py` warns
|
||||||
|
when the facts file has a newer commit than the poster; it **never fails a push**, because a refresh
|
||||||
|
needs the operator and another tool, and a gate nobody can clear is a gate people learn to route
|
||||||
|
around.
|
||||||
@@ -16,7 +16,16 @@
|
|||||||
# A failing run must reach a person — a detector nobody hears is the defect R-29 filed, rebuilt one
|
# A failing run must reach a person — a detector nobody hears is the defect R-29 filed, rebuilt one
|
||||||
# layer up. That is the last step, and it runs ONLY on failure.
|
# layer up. That is the last step, and it runs ONLY on failure.
|
||||||
name: gates
|
name: gates
|
||||||
on: [push]
|
# R-1129: `workflow_dispatch` is the manual PROOF that the failure mail can be sent. A push run is
|
||||||
|
# unchanged by it: the proof step below is skipped on a push, and `force_alarm` is then empty.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
force_alarm:
|
||||||
|
description: "Fail this run on purpose so the alarm step sends its real mail (a proof, not a gate result)"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
gates:
|
gates:
|
||||||
@@ -43,8 +52,28 @@ jobs:
|
|||||||
- name: Run the gate entry point
|
- name: Run the gate entry point
|
||||||
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy. The
|
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy. The
|
||||||
# exit code IS the result: no `|| true`, no pipe that could swallow it.
|
# exit code IS the result: no `|| true`, no pipe that could swallow it.
|
||||||
|
env:
|
||||||
|
# R-986: the security-scan gate fetches the PINNED gitleaks here (sha256-checked); no Go, so govulncheck says
|
||||||
|
# NOT CHECKED in CI, out loud. Warn-first either way: the scan lists, it never fails this job.
|
||||||
|
FELHOM_SCAN_FETCH: "1"
|
||||||
run: cd ws/felhom-controller/controller && python3 scripts/controller_gates.py --fast
|
run: cd ws/felhom-controller/controller && python3 scripts/controller_gates.py --fast
|
||||||
|
|
||||||
|
- name: Manual proof - alarm key present, optional forced failure (dispatch only, R-1129)
|
||||||
|
# Runs ONLY on a manual dispatch, never on a push. It prints the key's LENGTH, never the key,
|
||||||
|
# so "CI can see the secret" is an observable rather than an assumption. With
|
||||||
|
# force_alarm=true it then fails on purpose so the alarm step below sends its real mail;
|
||||||
|
# the commit itself stays green, because its push run skips this step.
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
env:
|
||||||
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
FORCE_ALARM: ${{ github.event.inputs.force_alarm }}
|
||||||
|
run: |
|
||||||
|
python3 -c 'import os;print("RESEND_API_KEY length: %d" % len(os.environ.get("RESEND_API_KEY","")))'
|
||||||
|
if [ "$FORCE_ALARM" = "true" ]; then
|
||||||
|
echo "force_alarm=true - failing on purpose so the alarm step runs (R-1129 proof)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Alarm on failure
|
- name: Alarm on failure
|
||||||
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
|
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
|
||||||
# notification row and NO log line from Gitea itself — a red tick in a web UI nobody watches
|
# notification row and NO log line from Gitea itself — a red tick in a web UI nobody watches
|
||||||
@@ -58,6 +87,8 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
# Empty on a push, so a push run's mail is exactly what it was before R-1129.
|
||||||
|
FORCE_ALARM: ${{ github.event.inputs.force_alarm }}
|
||||||
run: |
|
run: |
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json, os, sys, urllib.request, urllib.error
|
import json, os, sys, urllib.request, urllib.error
|
||||||
@@ -69,13 +100,23 @@ jobs:
|
|||||||
|
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "?")
|
repo = os.environ.get("GITHUB_REPOSITORY", "?")
|
||||||
sha = os.environ.get("GITHUB_SHA", "?")
|
sha = os.environ.get("GITHUB_SHA", "?")
|
||||||
run = os.environ.get("GITHUB_RUN_NUMBER", "?")
|
# R-1135: the link is for a PERSON, so it is the public address and the run ID.
|
||||||
srv = os.environ.get("GITHUB_SERVER_URL", "https://gitea.dooplex.hu")
|
# NOT GITHUB_SERVER_URL: the runner registers at the in-cluster address, so that variable
|
||||||
|
# reads http://gitea.gitea-system.svc.cluster.local:3000, which nobody outside the cluster
|
||||||
|
# can open (forced-test mail, job 1997). NOT GITHUB_RUN_NUMBER: Gitea 1.26 reads
|
||||||
|
# /actions/runs/<n> as a run id first, so a run number that is also an older run's id
|
||||||
|
# opens THAT run (measured 2026-10-18: felhom.eu /actions/runs/1116 opened the run whose
|
||||||
|
# id is 1116 = run number 613). The link is printed too, so the job log carries it.
|
||||||
|
run = os.environ.get("GITHUB_RUN_ID", "?")
|
||||||
|
srv = "https://gitea.dooplex.hu"
|
||||||
|
print("ALARM-LINK %s/%s/actions/runs/%s" % (srv, repo, run))
|
||||||
|
|
||||||
body = json.dumps({
|
body = json.dumps({
|
||||||
"from": "Felhom CI <monitoring@felhom.eu>",
|
"from": "Felhom CI <monitoring@felhom.eu>",
|
||||||
"to": ["admin@felhom.eu"],
|
"to": ["admin@felhom.eu"],
|
||||||
"subject": "[felhom CI] gates FAILED in %s" % repo,
|
"subject": ("[felhom CI] FORCED TEST, not a real failure - gates FAILED in %s"
|
||||||
|
if os.environ.get("FORCE_ALARM") == "true"
|
||||||
|
else "[felhom CI] gates FAILED in %s") % repo,
|
||||||
"text": (
|
"text": (
|
||||||
"The gate entry point exited non-zero.\n\n"
|
"The gate entry point exited non-zero.\n\n"
|
||||||
"Repository : %s\n"
|
"Repository : %s\n"
|
||||||
|
|||||||
@@ -29,6 +29,41 @@ root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|||||||
}
|
}
|
||||||
cd "$root" || exit 1
|
cd "$root" || exit 1
|
||||||
|
|
||||||
|
# ── WORKSPACE-ROOT ASSERTION (2026-08-05, R-204 rider) ───────────────────────────────────────────
|
||||||
|
# Refuse a push from a clone outside the felhom workspace.
|
||||||
|
#
|
||||||
|
# WHY THIS IS A HOOK AND NOT A LINE IN A DOCUMENT: the workspace root is ALREADY written down, in
|
||||||
|
# documentation/runbooks/workspace-CLAUDE.md and in the workspace-root CLAUDE.md ("stay inside it"),
|
||||||
|
# and work drifted into a home directory anyway. A rule that has failed once as a reminder is not
|
||||||
|
# fixed by writing it down again — it has to be asserted where it can bite.
|
||||||
|
#
|
||||||
|
# A PUSH IS THE RIGHT TRIGGER, deliberately: throwaway clones under /tmp for probes and red-proofs
|
||||||
|
# never push, so nothing legitimate breaks. Reads and builds elsewhere stay unaffected.
|
||||||
|
#
|
||||||
|
# Symlinks are resolved on BOTH sides before comparison, so a symlinked path neither falsely passes
|
||||||
|
# nor falsely fails. If the workspace root does not exist on this machine the check is SKIPPED, not
|
||||||
|
# failed — this hook must not brick a legitimate clone on a different host.
|
||||||
|
#
|
||||||
|
# The only bypass is the documented `git push --no-verify`, whose use is already reportable.
|
||||||
|
FELHOM_WORKSPACE_ROOT=/mnt/5_hdd/felhom.eu
|
||||||
|
if [ -d "$FELHOM_WORKSPACE_ROOT" ]; then
|
||||||
|
ws_real=$(cd "$FELHOM_WORKSPACE_ROOT" 2>/dev/null && pwd -P) || ws_real=""
|
||||||
|
root_real=$(pwd -P) || root_real=""
|
||||||
|
if [ -n "$ws_real" ] && [ -n "$root_real" ]; then
|
||||||
|
case "$root_real/" in
|
||||||
|
"$ws_real"/*) : ;; # inside the workspace — proceed
|
||||||
|
*)
|
||||||
|
echo "pre-push: PUSH REFUSED - this clone is OUTSIDE the felhom workspace." >&2
|
||||||
|
echo " clone: $root_real" >&2
|
||||||
|
echo " expected: under $ws_real (repos live in $ws_real/git/<repo>)" >&2
|
||||||
|
echo " Work in the workspace clone, or bypass with 'git push --no-verify'" >&2
|
||||||
|
echo " and state that you did in the session report." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v python3 >/dev/null 2>&1; then
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
echo "pre-push: FAIL - python3 not found, so the gates CANNOT run. This is a failure, never a" >&2
|
echo "pre-push: FAIL - python3 not found, so the gates CANNOT run. This is a failure, never a" >&2
|
||||||
echo " pass by default. Install python3, or push with --no-verify and say so." >&2
|
echo " pass by default. Install python3, or push with --no-verify and say so." >&2
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
# Python bytecode from the gate scripts and their tests (R-404 added test_golden_notice.py,
|
||||||
|
# which imports controller_gates.py and so writes controller/scripts/__pycache__).
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# .security-accepted — hits security_scan_gate.py (felhom.eu/scripts, R-986) lists as ACCEPTED, not WARN.
|
||||||
|
# One per line: <tool> <id> <where> # reason. govulncheck: OSV id + module dir; gitleaks: rule@<sha256[:12] of the line> + file.
|
||||||
|
# The first scan's verdicts (2026-10-19): felhom.eu documentation/audits/night-2026-10-19/P7/triage.md
|
||||||
|
gitleaks generic-api-key@93610586e88d controller/internal/stacks/samba_test.go # false: a test constant or a decoy value, no live secret
|
||||||
|
gitleaks generic-api-key@a882b8f2ea6b controller/internal/backup/offbox_offer_shapec_r241_test.go # false: a test constant or a decoy value, no live secret
|
||||||
|
gitleaks generic-api-key@b3c473a524c2 controller/scripts/test_gate_decoys.py # false: a test constant or a decoy value, no live secret
|
||||||
|
gitleaks generic-api-key@c9c6aa2fcae6 controller/scripts/test_gate_decoys.py # false: a test constant or a decoy value, no live secret
|
||||||
+7197
-1
File diff suppressed because it is too large.
Load diff
@@ -1,215 +1,110 @@
|
|||||||
# CLAUDE.md — Project Instructions for Claude Code (`felhom-controller`)
|
# CLAUDE.md — `felhom-controller`
|
||||||
|
|
||||||
> Read automatically at session start. Stable orientation only — **current state lives in
|
> Stable orientation only — **current state lives in `CONTEXT.md` and the top of `CHANGELOG.md`**,
|
||||||
> `CONTEXT.md` and the top of `CHANGELOG.md`**, never here. Cross-repo orientation: workspace-root
|
> never here. Cross-repo conventions (clean-tree gate, secrets, trunk-based, artifact taxonomy):
|
||||||
> `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`.
|
> workspace-root `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`. Path-scoped detail: `.claude/rules/`.
|
||||||
|
|
||||||
!!! IMPORTANT !!!
|
## What this repo is
|
||||||
- Always update CHANGELOG.md whenever you modified the code, and pushed to git!!
|
|
||||||
- IF controller feature changed (new/modify/remove) always update the relevant part of controller/README.md with the architectural change!!
|
|
||||||
|
|
||||||
## Project overview
|
The **in-guest controller** — one per customer LXC, Docker-only, **holds NO Proxmox credentials**. It
|
||||||
|
owns the app domain: stack/deploy management, the Hungarian web UI, app-data backup, metrics,
|
||||||
Felhom is a managed home-server business for Hungarian customers. This repo contains the
|
integrations, git-sync, notifications. Disk/host/Proxmox concerns are delegated to the host agent via
|
||||||
**felhom-controller** — the Go application that manages Docker Compose stacks inside each customer
|
`internal/agentapi`. Whole-guest backup (PBS vzdump) is the agent's, not ours.
|
||||||
LXC guest via a Hungarian-language web dashboard.
|
|
||||||
|
|
||||||
Read in this order:
|
|
||||||
- **`REUSE.md`** — before writing new code (canonical helpers, patterns, traps, seams).
|
|
||||||
- `CONTEXT.md` — current project state, decisions, roadmap (update after each session).
|
|
||||||
- `controller/README.md` — full feature/architecture reference (update when features change).
|
|
||||||
- `TASK.md` — the current task to implement (if it exists).
|
|
||||||
|
|
||||||
## System context — the three-component model
|
|
||||||
|
|
||||||
The project runs **on Proxmox**, with a locked three-component model:
|
|
||||||
- **Hub** (`felhom.eu/hub/`) — operator backend on k3s.
|
|
||||||
- **Host agent** (`felhom-agent/`) — one per Proxmox host; operator-tier; owns ALL Proxmox interaction.
|
|
||||||
- **In-guest controller** (THIS repo) — one per customer LXC; **Docker-only; holds NO Proxmox
|
|
||||||
credentials**. De-privileged: disk/host/Proxmox concerns are delegated to the host agent via the
|
|
||||||
pinned local-API client (`internal/agentapi`); the controller keeps the app domain — stack/deploy
|
|
||||||
management, the Hungarian web UI, app-data backup, metrics/telemetry, integrations, git-sync,
|
|
||||||
notifications. Whole-guest backup (PBS vzdump) is the agent's.
|
|
||||||
|
|
||||||
> **Authoritative maps:** `felhom.eu/documentation/architecture/01/02/03-*.md` (topology/trust,
|
|
||||||
> controller module map, host agent) + the code-verified feature docs in
|
|
||||||
> `felhom.eu/documentation/controller/`. Match the current code, not summaries, if they drift.
|
|
||||||
|
|
||||||
**Don't confuse the two ex-"controllers":** `felhom-agent` (host, operator-tier, was
|
**Don't confuse the two ex-"controllers":** `felhom-agent` (host, operator-tier, was
|
||||||
`proxmox-controller`) vs this `felhom-controller` (in-guest, was `deploy-felhom-compose`).
|
`proxmox-controller`) vs this repo (in-guest, was `deploy-felhom-compose`).
|
||||||
|
|
||||||
## Layout (verified against the tree)
|
## Doing X → read Y
|
||||||
|
|
||||||
```
|
| Doing | Read |
|
||||||
controller/cmd/controller/ entry point + startup wiring (scheduler block, init-only setters)
|
|---|---|
|
||||||
controller/internal/
|
| writing any new code | `REUSE.md` — canonical helpers, patterns, traps, seams |
|
||||||
agentapi/ pinned-TLS client to the host agent's per-guest local API (THE disk seam)
|
| needing current state / roadmap | `CONTEXT.md` |
|
||||||
api/ REST /api/* router (writeJSON envelope, limitBody, config writes)
|
| needing a feature or architecture reference | `controller/README.md` |
|
||||||
appbackup/ felhom-data paths/namespaces, DB dumps, userdata skeleton (shared primitives)
|
| build, deploy, publish, verify a version | the **`felhom-build-deploy`** skill |
|
||||||
appexport/ .fab export/import bundles (password crypto, strict segment validation)
|
| writing or reviewing a test, fixing a bug | the **`felhom-testing`** skill |
|
||||||
assets/ app logo/screenshot sync from the hub
|
| UI, tokens, badges, Hungarian copy | the **`felhom-ui-design`** skill |
|
||||||
backup/ app-data backup manager, recovery units, tier-2 copies, offbox restic
|
| which box may I break | `felhom.eu/documentation/runbooks/target-selection.md` |
|
||||||
bootstrap/ bootstrap.json ingest → controller.yaml (Day-0 + refresh)
|
| host addresses, break-glass, node facts | `felhom.eu/documentation/operations/nodes.md` |
|
||||||
channelhealth/ agent-channel health checker (debounce + born-down alerting)
|
| what version is live anywhere | ask the hub (`/hosts`, `/configs`) or the box — **never a doc** |
|
||||||
cloudflare/ geo-enforcement remnant (agent-delegated)
|
| the authoritative design | `felhom.eu/documentation/architecture/NN-*.md` (01–03 for this repo; 07 backup, 09 updates, 10 language) |
|
||||||
config/ controller.yaml load/validate (LoadPermissive = setup-mode only)
|
|
||||||
crypto/ AES-256-GCM app.yaml secret encryption (ENC: prefix)
|
|
||||||
infra/ traefik/cloudflared/filebrowser base-stack templates
|
|
||||||
integrations/ app-to-app integrations (e.g. OnlyOffice)
|
|
||||||
mailrelay/ app-email SMTP shim → hub relay
|
|
||||||
metrics/ telemetry collection
|
|
||||||
monitor/ health checks, protected containers
|
|
||||||
notify/ hub event push (typed Notify* wrappers)
|
|
||||||
quiesce/ quiesce loop for whole-guest backup (marker + recover)
|
|
||||||
recovery/ recovery-unit restore
|
|
||||||
report/ hub report builder/pusher + pull-based config refresh
|
|
||||||
scheduler/ background jobs (Every/Daily, Budapest DST-safe)
|
|
||||||
selftest/ startup self-checks
|
|
||||||
selfupdate/ controller image self-update via the agent swap
|
|
||||||
settings/ settings.json persistence (registry, flags, corruption recovery)
|
|
||||||
setup/ first-boot setup wizard (own CSRF)
|
|
||||||
stacks/ compose ops: deploy/delete/migrate/state (THE app domain core)
|
|
||||||
sync/ git-sync of the app catalog
|
|
||||||
system/ mounts/probes (linux + permissive _other stubs)
|
|
||||||
util/ small shared helpers
|
|
||||||
web/ dashboard UI: server, auth/CSRF, handlers, funcmap, templates (Hungarian)
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-package helpers/seams/traps: **`REUSE.md`** (maintained same-commit as helper changes).
|
## Session-critical invariants
|
||||||
|
|
||||||
## Conventions & cardinal rules
|
The rest live in `REUSE.md`. These cost incidents to learn:
|
||||||
|
|
||||||
- **Trunk-based — no branches.** All shippable work commits directly to `main`; `main` equals what is
|
|
||||||
deployed. Report-only artifacts → `felhom.eu/documentation/` (`audits/`, `backlog/`). Risky fixes
|
|
||||||
are implemented during the supervised session itself, on `main`; if a fix can't be verified/shipped,
|
|
||||||
revert + report — never park on a branch.
|
|
||||||
- Code quality: double-check for bugs/edge cases; add debug logging; **ask rather than guess**.
|
|
||||||
- All UI text is Hungarian (Budapest timezone). Design tokens/gates: use the `felhom-ui-design` skill.
|
|
||||||
- **Run `python3 controller/scripts/controller_gates.py` (from `controller/`) after ANY change in
|
|
||||||
this repo.** It is the ONE entry point and runs all seven local gates — `template_id_gate`,
|
|
||||||
`emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`,
|
|
||||||
`docker_run_volume_path_gate` — plus `reuse_refs_check` on the repo root, streaming each gate's
|
|
||||||
own output and exiting non-zero if any fails. `--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.
|
|
||||||
**Why a runner and not seven invocations** (2026-08-02, R-29): a census of all thirteen gates
|
|
||||||
across the four repos found that every check a `CLAUDE.md` names was passing, and two of the four
|
|
||||||
nobody is told to run were failing. This file used to name two of the seven; the other five were
|
|
||||||
reachable only through a line in `REUSE.md`, and `docker_run_volume_path_gate.py` was RED.
|
|
||||||
**The shared `reuse_refs_check.py` lives in `felhom.eu/scripts/` and is never copied here** —
|
|
||||||
a copy would recreate the drift it detects; an absent sibling clone FAILS the gate.
|
|
||||||
**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 when this clone is unarmed. `git push --no-verify` bypasses it deliberately; **say so in the
|
|
||||||
session report when you use it.** Both facts are why CI is still owed (`OPEN-ITEMS.md` R-168).
|
|
||||||
- Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill.
|
|
||||||
- **Logging**: new leveled lines use `internal/logx` (DEBUG always reaches the debug ring; stdout
|
|
||||||
respects `logging.level`); English, keys-never-values, durations on outcomes — full rules in
|
|
||||||
`felhom.eu/documentation/runbooks/logging-conventions.md`.
|
|
||||||
- Update `REUSE.md` if you added/changed/deprecated a shared helper or pattern (same commit).
|
|
||||||
- **Coupled features** (controller behavior that depends on a specific agent version): add a
|
|
||||||
`featureProbes` table row in `internal/agentapi/features.go` + a `Supports` gate call at the
|
|
||||||
feature's entry point; declare `MinAgent: X.Y.Z` in the CHANGELOG entry header. Rules:
|
|
||||||
`felhom.eu/documentation/runbooks/publish-train-rules.md`.
|
|
||||||
|
|
||||||
> **In every repository where you make a change, update both files in that repo:**
|
|
||||||
> - **`CHANGELOG.md`** — cumulative log, newest on top.
|
|
||||||
> - **`REPORT.md`** — **overwrite** with the most recent implementation/validation summary only.
|
|
||||||
>
|
|
||||||
> **Never write secrets** into any committed file — reference them as "stored out-of-band".
|
|
||||||
|
|
||||||
## Live validation
|
|
||||||
|
|
||||||
Exercise the SERVER-SIDE PIPELINE a real user triggers, end-to-end (connect → enroll → deploy). The
|
|
||||||
forbidden shortcut is BYPASSING that pipeline (the F9 episode: raw agent guest-attach + hand-set
|
|
||||||
state). **`claude-in-chrome` is NOT available in the DooPlex environment** — the standard method is
|
|
||||||
endpoint-level: invoke the exact endpoint the UI invokes (no server logic is skipped, only
|
|
||||||
rendering) and say which method was used. Strict end-to-end UI coverage is a manual click-through.
|
|
||||||
|
|
||||||
Two traps in that method, both from the 2026-07-20 remediation:
|
|
||||||
- **Grep the fetched page with ASCII-only substrings.** Accented Hungarian patterns get mangled
|
|
||||||
through the `ssh → pct exec → bash -c` chain and return a false `0` — which reads exactly like the
|
|
||||||
banner/string being gone. Use `kezel`, `Utols`, `Biztons`; never let an accented pattern gate a
|
|
||||||
conclusion (it nearly produced a wrong "banner cleared" claim).
|
|
||||||
- **Credentials with `!` or `'` break in heredoc-built helper scripts** (history expansion eats
|
|
||||||
`!!`). Use the proven inline `-d "password=$PW"` form for authed curl, and delete any
|
|
||||||
credential-bearing helper from `/tmp` (host AND guest) when done.
|
|
||||||
|
|
||||||
## Environment & access
|
|
||||||
|
|
||||||
Claude Code runs **on DooPlex (192.168.0.180, Debian 13, user `kisfenyo`)**; repos in
|
|
||||||
`/mnt/5_hdd/felhom.eu/git/`, build dirs in `/mnt/5_hdd/felhom.eu/build/`. All repos hosted at
|
|
||||||
`gitea.dooplex.hu/admin/`. Builds are local commands; felhom-pve is one SSH hop.
|
|
||||||
|
|
||||||
| Host | Access | Role |
|
|
||||||
|------|--------|------|
|
|
||||||
| **DooPlex (this host)** | local — `/mnt/5_hdd/felhom.eu/{git,build}/` | build + push images, `sudo kubectl` |
|
|
||||||
| Demo Proxmox host `demo-felhom` | `ssh felhom-pve` (root@192.168.0.162) | `pct` into guests; live validation |
|
|
||||||
| Demo guest 9201 | `ssh felhom-pve "pct exec 9201 -- ..."` | the live demo controller (golden/bootstrap-managed) |
|
|
||||||
| Demo host `demo-hp` (HP t740) | `ssh demo-hp` (TS `100.76.96.79` / LAN `192.168.0.87`); **no baked key** — break-glass root via hub `host_recovery/demo-hp-bb76ea` + `sshpass` (recipe in `felhom.eu/documentation/operations/nodes.md`) | 2nd demo node (guest 9201 `demo-hp`, agent 0.93.0). **Designated drill+build VM host** per the 2026-07-25 ruling — but **no drill VM is provisioned there yet** (forward-looking); the drill `drill.qcow2` still lives on DooPlex (off). See nodes.md. |
|
|
||||||
| felhotest (legacy) | `ssh -p 33022 kisfenyo@router.abonet.hu` | OLD /opt/docker compose mechanism |
|
|
||||||
|
|
||||||
> **Legacy: Windows workstation.** Until 2026-07-19 CC ran on Windows 11 with repos in `E:\git\`,
|
|
||||||
> and every remote command needed `SSH=/c/Windows/System32/OpenSSH/ssh.exe` (Git Bash's ssh lacks
|
|
||||||
> the Windows agent and fails silently — see `docs/vscode-ssh-fix.md`), plus `MSYS_NO_PATHCONV=1`
|
|
||||||
> for `pct exec`. Retained in case that environment is revived.
|
|
||||||
|
|
||||||
> **felhom-pve is back on the home LAN (as of 2026-07-25).** The host holds `192.168.0.162` again and
|
|
||||||
> the **agent is UP** — `localapi` binds `192.168.0.162:8443`, the service is `active`, capabilities
|
|
||||||
> self-check clean, and all agent-backed features (storage, PBS backup, quiesce, restore-test, DR) are
|
|
||||||
> reachable. `ssh felhom-pve` remains a **Tailscale** alias (`100.70.170.35`, location-independent, the
|
|
||||||
> N100 is travel-portable) with `Host felhom-pve-lan` as the direct-LAN fallback; both work. The earlier
|
|
||||||
> "remote site — agent DOWN (binds a stale `.162`)" block was the 2026-07-20→24 vacation window and is
|
|
||||||
> now retired; the historical record stays in
|
|
||||||
> `felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md`.
|
|
||||||
|
|
||||||
External access via Cloudflare Tunnel → Traefik; Pi-hole forwards `*.demo-felhom.eu` → .162 locally.
|
|
||||||
|
|
||||||
## Build & deploy — MANDATORY after code changes
|
|
||||||
|
|
||||||
**Full runbook: use the `felhom-build-deploy` skill.** Summary (guest 9201 is bootstrap-managed —
|
|
||||||
**no compose file**; `felhom-controller-bootstrap.service` runs the tag in `/etc/felhom-controller-image`):
|
|
||||||
|
|
||||||
> **Clean-tree gate before any build:** `git status --porcelain` must be empty and
|
|
||||||
> `git rev-parse HEAD` must equal `git rev-parse origin/main` in the repo being built. An unpushed
|
|
||||||
> change does not exist — never build a dirty or unpushed tree. The `git pull` in the build step
|
|
||||||
> stays (it is a no-op when you work in this tree, and load-bearing if anything was pushed from
|
|
||||||
> elsewhere).
|
|
||||||
|
|
||||||
| Step | Command |
|
|
||||||
|------|---------|
|
|
||||||
| 1. Commit + push | `git add <explicit paths> && git commit -m "..." && git push` |
|
|
||||||
| 2. Build + push image | `cd /mnt/5_hdd/felhom.eu/build/felhom-controller && git -C /mnt/5_hdd/felhom.eu/git/felhom-controller pull && ./build.sh <VER> --push` (build.sh does NOT pull — the explicit pull is load-bearing) |
|
|
||||||
| 3. Deploy (9201) | `ssh felhom-pve "pct exec 9201 -- bash -c 'docker pull gitea.dooplex.hu/admin/felhom-controller:<VER> && echo gitea.dooplex.hu/admin/felhom-controller:<VER> > /etc/felhom-controller-image && systemctl restart felhom-controller-bootstrap.service'"` |
|
|
||||||
| 4. Verify | `ssh felhom-pve "pct exec 9201 -- docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"` + container logs |
|
|
||||||
|
|
||||||
Hub build/deploy lives in `felhom.eu` (GitOps) — see that repo's CLAUDE.md / the skill. Catalog
|
|
||||||
changes (`app-catalog-felhom.eu`): commit+push; controller sync picks them up ≤15 min or via the
|
|
||||||
"Sablonok frissítése" button.
|
|
||||||
|
|
||||||
## Session-critical invariants (the rest live in REUSE.md)
|
|
||||||
|
|
||||||
- `docker compose restart` does NOT pick up new images/env — always `up -d` (`RedeployFromEnv`).
|
- `docker compose restart` does NOT pick up new images/env — always `up -d` (`RedeployFromEnv`).
|
||||||
- Docker's `.State` says "running" even for unhealthy containers — `.Status` parse is the truth.
|
- Docker's `.State` says "running" even for unhealthy containers — the `.Status` parse is the truth.
|
||||||
- In-memory `Deployed` flag is set BEFORE `compose up -d` (slow-pull race); reverted on failure.
|
- In-memory `Deployed` is set BEFORE `compose up -d` (slow-pull race); reverted on failure.
|
||||||
- `compose up -d` exits 0 on crash-loops — post-start status check is the detection.
|
- `compose up -d` exits 0 on crash-loops — the post-start status check is the detection.
|
||||||
- Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller)
|
- Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller,
|
||||||
can't be stopped from the UI.
|
and always samba) cannot be stopped from the UI.
|
||||||
- Verify a container image HAS the healthcheck tool before using it (BusyBox wget / python3 / curl —
|
- Verify a container image HAS the healthcheck tool before using it (BusyBox wget / python3 / curl —
|
||||||
catalog REUSE.md maps the families).
|
the catalog `REUSE.md` maps the families).
|
||||||
|
- `IsRunning()` is CONCURRENCY, false during a verification restore — display MUST use
|
||||||
|
`RestoreStatus()`.
|
||||||
|
|
||||||
|
## Live validation — the fence
|
||||||
|
|
||||||
|
Exercise the SERVER-SIDE PIPELINE a real user triggers, end-to-end (connect → enroll → deploy). **The
|
||||||
|
forbidden shortcut is BYPASSING that pipeline** — the F9 episode was a raw agent guest-attach with
|
||||||
|
hand-set state, and it proved nothing.
|
||||||
|
|
||||||
|
`claude-in-chrome` is NOT available on DooPlex. The standard method is endpoint-level: invoke the
|
||||||
|
exact endpoint the UI invokes (no server logic is skipped, only rendering) and **say which method was
|
||||||
|
used**. Strict end-to-end UI coverage is a manual click-through by the operator.
|
||||||
|
|
||||||
|
Two traps in that method live in `.claude/rules/ui-hungarian.md` (ASCII-only greps; `!` in
|
||||||
|
credentials) — they load when you touch a template or stylesheet.
|
||||||
|
|
||||||
|
## Commands — one per surface
|
||||||
|
|
||||||
|
| Surface | Command |
|
||||||
|
|---|---|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
Guest 9201 is **bootstrap-managed — there is no compose file**;
|
||||||
|
`felhom-controller-bootstrap.service` runs the tag written in `/etc/felhom-controller-image`. Catalog
|
||||||
|
changes (`app-catalog-felhom.eu`) are picked up by controller sync ≤15 min, or via the "Sablonok
|
||||||
|
frissítése" button.
|
||||||
|
|
||||||
## Working with CHANGELOG.md
|
## Working with CHANGELOG.md
|
||||||
|
|
||||||
**DO NOT read the full file** — it is large and will waste context.
|
**DO NOT read the full file** — it is large and will waste context.
|
||||||
- Session start: use `CONTEXT.md` + `controller/README.md` for current state.
|
|
||||||
|
- Session start: `CONTEXT.md` + `controller/README.md` for current state.
|
||||||
- Adding an entry: Read only the top ~30 lines for format, then Edit-insert after line 1.
|
- Adding an entry: Read only the top ~30 lines for format, then Edit-insert after line 1.
|
||||||
- History: Grep for topics instead of reading.
|
- History: Grep for topics instead of reading.
|
||||||
|
|
||||||
## End-of-session checklist
|
## End-of-session checklist
|
||||||
|
|
||||||
1. **Commit and push** all code changes
|
1. **Commit and push** all code changes (explicit paths; no `git add -A`).
|
||||||
2. **Build, push, and deploy** the new controller image (if controller code changed)
|
2. **Build, push, and deploy** the new controller image, if controller code changed.
|
||||||
3. **Update CHANGELOG.md** with what was done
|
3. **`CHANGELOG.md`** — always, whenever code changed and was pushed.
|
||||||
4. **Update CONTEXT.md** with decisions made, state and what's next
|
4. **`CONTEXT.md`** — decisions made, state, what is next.
|
||||||
5. **Update controller/README.md** if architecture or features changed
|
5. **`controller/README.md`** — whenever a feature was added, modified or removed.
|
||||||
6. **Verify** the deployment is working (check `docker ps` and logs)
|
6. **`REPORT.md`** — overwrite with this run's summary only.
|
||||||
7. **Update REUSE.md** if you added/changed/deprecated a shared helper or pattern (same commit)
|
7. **`REUSE.md`** — if a shared helper or pattern was added/changed/deprecated (same commit).
|
||||||
|
8. **Verify** the deployment (`docker ps` + logs).
|
||||||
|
|
||||||
|
<!--
|
||||||
|
WHY THIS FILE IS SHORT (2026-08-06, instruction-trim task).
|
||||||
|
Removed from here and rehomed, not lost:
|
||||||
|
- the `## Layout (verified against the tree)` block -> derivable by `ls internal/`; REUSE.md
|
||||||
|
carries the per-package seams and traps that the annotations were really for.
|
||||||
|
- the `!!! IMPORTANT !!!` header -> its two requirements are checklist items 3 and 5. One voice,
|
||||||
|
one place; a rule stated twice in one file is a rule that gets edited in one of them.
|
||||||
|
- the host/access table -> documentation/operations/nodes.md is the single home. The copy here
|
||||||
|
had drifted: it gave demo-felhom as plain root@192.168.0.162 (the LAN fallback, not the route),
|
||||||
|
pinned "agent 0.93.0" against the project's own no-versions-in-docs rule, and claimed no drill
|
||||||
|
VM was provisioned on demo-hp. Measured 2026-08-06: `qm list` on demo-hp shows VM 300
|
||||||
|
`drill-r50` present. felhom-agent/CLAUDE.md was right; this file was wrong.
|
||||||
|
- the "felhom-pve is back on the home LAN" block -> it was bookkeeping about a retired block; the
|
||||||
|
record is in documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md.
|
||||||
|
- the "Legacy: Windows workstation" block -> the workspace-root CLAUDE.md carries the full version.
|
||||||
|
- the gates/logging/coupling/UI paragraphs -> .claude/rules/*.md, which load when a matching file
|
||||||
|
is read instead of in every session.
|
||||||
|
Full per-block accounting: felhom.eu/documentation/audits/LEDGER-instruction-trim-2026-08-06.md
|
||||||
|
-->
|
||||||
+1729
-214
File diff suppressed because it is too large.
Load diff
@@ -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,56 +1,30 @@
|
|||||||
# REPORT — controller v0.197.0: R-203, both halves (2026-08-04)
|
# REPORT — controller v0.344.0 released (2026-10-26, attended release run)
|
||||||
|
|
||||||
Deployed to demo-hp guest 9201. No hub change. Nothing deleted, wiped or moved; the R-201 drill was
|
**Plain words first.** Every box runs 0.344.0. The global floor was set at 09:07:47 UTC (MinAgent 0.158.1) on the
|
||||||
not resumed.
|
operator's yes, after the three test boxes moved through their per-box floors and the release-build checks passed. New
|
||||||
|
installs start on it too: golden 0.344.0 was baked and vouched the same day, and a fresh install came up on 0.344.0
|
||||||
|
with no update step. The file manager restarted once at the update, as the release notes say.
|
||||||
|
|
||||||
## What shipped
|
## Release
|
||||||
|
|
||||||
**Part 1 — one resolver, five callers.** `appbackup.NamespaceRootFor` / `IsEnrolledDrive` are now the
|
- Image `felhom-controller:0.344.0`, sha256 `9435165dab85b1200d94157ec49d8be065889139e1831a5476dc68f4b9474b8a`, built
|
||||||
single expression of the drive-kind rule; `backup.Manager.namespaceRoot` and `stacks.Manager.inGuest`
|
from `0a59044` (CI 2355 ✓). The rc `0.344.0-rc.4` (same commit) ran on 9202 first; 9202 is back on 0.343.0.
|
||||||
delegate (they were two copies that already differed over `filepath.Clean`). Routed through it:
|
- Per-box floors 0.344.0 for Tester 1, demo-hp, demo-felhom (08:15 UTC; each moved by the normal update path, healthy),
|
||||||
`stacks/deploy.go` `withPathVars` (the live defect), `appexport/fabplan.go` + `export.go` via a new
|
then the global floor (read back from the hub DB with its WAL and from the hub log), then the per-box floors cleared.
|
||||||
`GetStackNamespaceRoot` provider method, `web/handlers.go`'s FileBrowser mounts (the fifth site, which
|
- Fleet after the floor: demo-felhom, demo-hp, tester-1 report 0.344.0; Tester-2 has no box since 2026-10-04 (R-862).
|
||||||
the spec did not name — latent today), and `stacks/delete.go`'s `ExportDataMounts` (its own commit).
|
|
||||||
`ComputeFabBuckets` now receives the namespace root, matching `ComputeCaptureSet`.
|
|
||||||
|
|
||||||
**Part 2 — the verdict.** `last_status` gains `incomplete`; the capture gaps are recorded structurally
|
## Checks on the release build (felhom.eu `documentation/audits/release-monday-2026-10-26/`)
|
||||||
so the verdict has something to act on; the operator hears it through the existing per-run digest.
|
|
||||||
|
|
||||||
## Proven live on demo-hp
|
| Row | Check | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| R-1281 | FileBrowser's networks; a forged header from apps; the household's files; OnlyOffice linked | on all three boxes FileBrowser only on `felhom-filebrowser` + `felhom-files`; qBittorrent/Sonarr/Radarr/Prowlarr → 000 (control: controller 404, traefik 301); Tester 1: dashboard → pass → cookie → listing 200, no cookie 401; OnlyOffice joined `felhom-files`, editor config 200, the OnlyOffice container fetched the document 200 — closed |
|
||||||
|
| R-1282 | „Később", then a controller restart | the stored 03:30 kept (start-up line + settings.json) — closed |
|
||||||
|
| R-1278 | install page with a kept backup | hint + link shown; none before or after — closed |
|
||||||
|
| R-1286 | Kimai's install id; the hold sentences | id written 07:01:55Z; `holdWhy` released — closed |
|
||||||
|
| R-1288 | released | proven live on the night's rig — closed |
|
||||||
|
| R-1261 | released | the live line test is Tuesday (R-1250's dated check) — stays VERIFY |
|
||||||
|
| D47 | upgrade count | 0 set aside on all three boxes; every app has an install id |
|
||||||
|
|
||||||
```
|
**Observation (NOT-A-FINDING):** on 9202's rc start the first FileBrowser round logged a WARN „could not join felhom-files
|
||||||
bind before: /mnt/sys_drive/userdata/media/books
|
(files.<domain> stays unreachable)" because FileBrowser was being re-created in the same second; it joined 2 s later.
|
||||||
bind after: /mnt/sys_drive/felhom-data/userdata/media/books
|
Log only, no event; the three release boxes logged no WARN.
|
||||||
capture log: 0 mandatory path(s) -> 1 mandatory path(s)
|
|
||||||
|
|
||||||
$ restic ls -l latest --tag calibre-web
|
|
||||||
-rw-r--r-- 1000 1000 181 2026-08-04 12:53:06 .../media/books/DRILL-SENTINEL.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
The file, by name and size, inside the snapshot — not a green status.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
`go build && go vet && go test ./...` rc=0; `controller_gates.py --fast` OK. Table-driven over BOTH
|
|
||||||
drive kinds, because this defect survived by being invisible on the kind that already worked.
|
|
||||||
|
|
||||||
**One red-proof PASSED and the test was wrong, not the code:** the first Scenario-C test reached only
|
|
||||||
`offboxCaptureSet` while the mutation lives in `runOffboxInternal`. Replaced with a run-level test
|
|
||||||
driving `RunOffboxBackup`; it now fails under both an unreachable gap-recording and an unconditional
|
|
||||||
`ok`. Scenario D's proof also could not fail by construction (the tier filter already excludes
|
|
||||||
optional paths) — the two-part mutation that does is recorded.
|
|
||||||
|
|
||||||
## Corrections to the spec
|
|
||||||
|
|
||||||
- **Five call sites, not four.** The fifth is the FileBrowser mount builder.
|
|
||||||
- **`ExportDataMounts` is export-only despite living in `delete.go`.** Its only caller is the `.fab`
|
|
||||||
adapter, and the delete path's own `ProtectedHDDPaths` guard is layout-agnostic by construction, so
|
|
||||||
the destructive-path risk the spec flagged does not exist.
|
|
||||||
|
|
||||||
## Observations
|
|
||||||
|
|
||||||
`resolveAbs` resolves `RootHDD` and `RootUserdata` against one root — fine on enrolled drives, and now
|
|
||||||
consistent between export and backup, but whether `${HDD_PATH}` should mean the namespace root on the
|
|
||||||
system drive is a separate decision affecting every deployed app. The deploy API is
|
|
||||||
first-deployment-only (409 after), so an existing app picks up the corrected path via start/redeploy.
|
|
||||||
The CSRF form field is `_csrf`, not `csrf_token` — second session running that this cost time.
|
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
| Symbol | File | Short signature | Use for | Gotchas |
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `NamespaceRoot` | controller/internal/appbackup/paths.go | `(drivePath string, inGuestDrive bool) string` | Resolve felhom-data root for a drive | `inGuestDrive=true` returns path AS-IS (Model A: guest mount IS the ns root); false appends `felhom-data`. Never double-nest |
|
| `NamespaceRoot` | controller/internal/appbackup/paths.go | `(drivePath string, inGuestDrive bool) string` | Resolve felhom-data root for a drive | `inGuestDrive=true` returns path AS-IS (Model A: guest mount IS the ns root); false appends `felhom-data`. Never double-nest |
|
||||||
| `PrimaryBackupPath` / `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` | controller/internal/appbackup/paths.go | `(nsRoot[, stackName]) string` | All backup dir layout | Take the NAMESPACE ROOT, not a bare drive path |
|
| `PrimaryBackupPath` / `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` | controller/internal/appbackup/paths.go | `(nsRoot[, stackName]) string` | All backup dir layout | Take the NAMESPACE ROOT, not a bare drive path. Since v0.229.0 the last three are thin wrappers over the unit-directory-relative primitives below and return byte-identical strings (`TestR102_PathWrappersAreByteIdenticalToToday`) |
|
||||||
|
| `UnitComposeDir` / `UnitManifestFile` / `UnitDBDumpDir` / `UnitVolumeDumpDir` (R-102, v0.229.0) | controller/internal/appbackup/paths.go (re-exported by `backup/appbackup_bridge.go`) | `(unitDir string) string` | naming a leg inside a recovery unit that is NOT under `backups/primary/` | **These take the recovery-unit DIRECTORY itself.** The `(nsRoot, stackName)` form joins a hard-coded `primary`, which was the MECHANISM of R-102: the Tier-2 mirror at `<dest>/backups/secondary/<app>/recovery-unit/` was written nightly and readable by nothing. Use these whenever the unit's location is an argument; do NOT add a second copy of a layout join |
|
||||||
| `AppDBDumpPath` / `AppVolumeDumpPath` / `AppDataDir` | controller/internal/appbackup/paths.go | `(nsRoot, stackName) string` | Per-app dump/data dirs | Same nsRoot contract. `AppDataDir`'s final segment is the app's real appdata dir NAME — NOT always the stack name (paperless-ngx → `paperless`); resolve via `AppDataDirNames` first (F-S2/F-S3) |
|
| `AppDBDumpPath` / `AppVolumeDumpPath` / `AppDataDir` | controller/internal/appbackup/paths.go | `(nsRoot, stackName) string` | Per-app dump/data dirs | Same nsRoot contract. `AppDataDir`'s final segment is the app's real appdata dir NAME — NOT always the stack name (paperless-ngx → `paperless`); resolve via `AppDataDirNames` first (F-S2/F-S3) |
|
||||||
| `AppDataDirNames` / `AppDataBindsPresent` | controller/internal/appbackup/paths.go | `(hddPath, stackName string, hddMounts []string) []string` / `(hddPath, hddMounts) bool` | Resolve the real `appdata/<name>` dir(s) from compose `${HDD_PATH}` binds (F-S2/F-S3) | `hddMounts` = ParseComposeHDDMounts shape. Deduped+sorted; falls back to `[stackName]` when no appdata bind. Tier-2 (`backup.Manager.tier2AppDataName`) refuses N>1; migrate (`stacks.Manager.ResolveAppDataDirNames`) loops N. `BindsPresent` drives the WARN-on-missing-declared-dir |
|
| `AppDataDirNames` / `AppDataBindsPresent` | controller/internal/appbackup/paths.go | `(hddPath, stackName string, hddMounts []string) []string` / `(hddPath, hddMounts) bool` | Resolve the real `appdata/<name>` dir(s) from compose `${HDD_PATH}` binds (F-S2/F-S3) | `hddMounts` = ParseComposeHDDMounts shape. Deduped+sorted; falls back to `[stackName]` when no appdata bind. Tier-2 (`backup.Manager.tier2AppDataName`) refuses N>1; migrate (`stacks.Manager.ResolveAppDataDirNames`) loops N. `BindsPresent` drives the WARN-on-missing-declared-dir |
|
||||||
| `UserdataDir` / `ImportDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(nsRoot)` / `(nsRoot, dirs []string)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention. **R-75:** `ImportDir` is the CANONICAL drop-zone (`<nsRoot>/userdata/import`) and callers MUST resolve it against the SYSTEM namespace, never an app's HDD_PATH — use `stacks.Manager.GetImportRoot()`. `EnsureUserdataSkeleton` now takes the dir set: build it with `BuildUserdataSkeleton(DeriveUserdataDirs(stacksDir))`, or via `Manager.EnsureUserdataSkeleton` / `web.Server.ensureUserdataSkeleton`. | Linux-only chown via build-tag twin userdata_linux.go. **The set MUST stay sorted** — `fbNeedsRecreate` force-recreates FileBrowser on any byte diff and the naive map-order derivation measured 20/20 distinct (SPIKE P6). `UserdataSkeletonCarry()` is the old hardcoded list, retained forever so derivation can only ADD (zero removals). |
|
| `UserdataDir` / `ImportDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(nsRoot)` / `(nsRoot, dirs []string)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention. **R-75:** `ImportDir` is the CANONICAL drop-zone (`<nsRoot>/userdata/import`) and callers MUST resolve it against the SYSTEM namespace, never an app's HDD_PATH — use `stacks.Manager.GetImportRoot()`. `EnsureUserdataSkeleton` now takes the dir set: build it with `BuildUserdataSkeleton(DeriveUserdataDirs(stacksDir))`, or via `Manager.EnsureUserdataSkeleton` / `web.Server.ensureUserdataSkeleton`. | Linux-only chown via build-tag twin userdata_linux.go. **The set MUST stay sorted** — `fbNeedsRecreate` force-recreates FileBrowser on any byte diff and the naive map-order derivation measured 20/20 distinct (SPIKE P6). `UserdataSkeletonCarry()` is the old hardcoded list, retained forever so derivation can only ADD (zero removals). |
|
||||||
@@ -19,10 +20,27 @@
|
|||||||
| `appbackup.ValidateRelPath` / `ValidRoot` | controller/internal/appbackup/classify.go | `(root, path)` / `(root)` | THE single path-safety refusal set for every `${VAR}`-relative catalog path | Shared by `backup:` and `data_paths:`. **Do not write a second path validator.** |
|
| `appbackup.ValidateRelPath` / `ValidRoot` | controller/internal/appbackup/classify.go | `(root, path)` / `(root)` | THE single path-safety refusal set for every `${VAR}`-relative catalog path | Shared by `backup:` and `data_paths:`. **Do not write a second path validator.** |
|
||||||
| `stacks.ValidateDataPaths` | controller/internal/stacks/datapaths.go | `(entries, binds, appName, logger)` | `data_paths:` annotation validation | ASYMMETRIC on purpose (Fork-3): malformed PATH ⇒ whole-block reject (data handling, `backup:` precedent); unknown ROLE ⇒ fails OPEN, one WARN (presentation, `Lifecycle` precedent). |
|
| `stacks.ValidateDataPaths` | controller/internal/stacks/datapaths.go | `(entries, binds, appName, logger)` | `data_paths:` annotation validation | ASYMMETRIC on purpose (Fork-3): malformed PATH ⇒ whole-block reject (data handling, `backup:` precedent); unknown ROLE ⇒ fails OPEN, one WARN (presentation, `Lifecycle` precedent). |
|
||||||
| `web.fileBrowserLink` / `importFolderLink` | controller/internal/web/filebrowser_link.go | `(domain, sourceName, relPath)` | FileBrowser Quantum deep link | Template read out of the shipped router (SPIKE P2). **`url.PathEscape` per segment — NEVER `QueryEscape`** (space→`+` is a literal plus in a path). Let `html/template` do the attribute escaping; do not pre-escape. |
|
| `web.fileBrowserLink` / `importFolderLink` | controller/internal/web/filebrowser_link.go | `(domain, sourceName, relPath)` | FileBrowser Quantum deep link | Template read out of the shipped router (SPIKE P2). **`url.PathEscape` per segment — NEVER `QueryEscape`** (space→`+` is a literal plus in a path). Let `html/template` do the attribute escaping; do not pre-escape. |
|
||||||
|
| `web.fileBrowserOpenLink` · `web.ServeFilesOpen` / `ServeFilesAuth` · `infra.LoadOrCreateFileBrowserSSOHeader` / `FileBrowserSSOFor` (v0.332.0, R-1100) | controller/internal/web/filebrowser_link.go · controller/internal/web/files_sso.go · controller/internal/infra/filebrowser_sso.go | `(domain, sourceName, relPath)` · handlers · `(dataDir[, domain])` | A dashboard link to a FileBrowser folder that opens it signed in (the one sign-in) | **Every dashboard folder link uses `fileBrowserOpenLink`** (through `/__files/open`), never `fileBrowserLink` raw. The header NAME is the box's secret: only FileBrowser's config and traefik's route carry it — never a log, a page or a report. Never widen the dashboard cookie; the files cookie is derived per host from the session fingerprint (the R-976 shape) |
|
||||||
| `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) |
|
| `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 |
|
| `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) |
|
| `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) |
|
||||||
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, legacy felhom-data) | Consult before ANY recursive delete under a drive |
|
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, kept, legacy felhom-data) | Consult before ANY recursive delete under a drive |
|
||||||
|
| `stacks.OldAppDataPaths` / `Manager.ListKept` / `KeepAside` / `DeleteKept` / `FindKept` | controller/internal/stacks/kept.go | `(composePath, hdd)` / `(drives)` / … | Kept data (`09` §3 decision 36): what counts as an app's old data (ONLY `<hdd>/appdata/…` binds), the list, start-fresh, the household's delete | **An action names a kept item by path only through `FindKept`** — `DeleteKept` refuses anything not listed. `KeepAside` is a rename on one drive; never copy, never `RemoveAll` in a rollback (`removeEmptyDirs`) |
|
||||||
|
| `stacks.RunAfterInstall` / `expandAfterInstall` / `web.defaultLoginInEffect` (v0.279.0, decision 45) | controller/internal/stacks/after_install.go · controller/internal/web/known_login.go | `(name, wait)` / `(cmd, allowed, env)` / `(meta, cfg, installed)` | A fresh install replaces a known default login; the page says when a default is still in effect | **Only from the deploy-done hook** — never after a restore/kept load (R-694). A `success:` marker is required (exit 0 lies). Never log the expanded command |
|
||||||
|
| `stacks.OpenSetupGate` / `SetupGateTick` / `SetupGateHost` · `web.ServeGateAuth` / `ServeGateStart` (v0.280.0, decision 46) | controller/internal/stacks/setup_gate.go · controller/internal/web/setup_gate.go | `(name, by)` / `()` / `(host)` · handlers | The setup gate: a `setup_gate: true` install is closed to everyone but the household until its probe or the household's press opens it | **Write the gate BEFORE the first start** (spike F2). Open = record first, then remove the file. Never widen the dashboard cookie — the handshake mints a host-bound one-use token |
|
||||||
|
| `stacks.OpenSignupWindow` / `SignupBlocked` / `SetupGateProbe` · `web.ServeSignupClosed` (v0.281.0, decision 47) | controller/internal/stacks/signup_block.go · controller/internal/web/setup_gate.go | `(name)` | Sign-up closed at the app's own address once the gate opens; the household's 15-minute window; the press asks the probe | **The block goes up BEFORE the gate comes down** (a failed write keeps the gate closed). Never on an app this box did not gate |
|
||||||
|
| `family.Store` · `stacks.FamilyGateHost` / `familyGateTick` / `FamilyExceptRegexp` · `web.ServeFamilyGateAuth` / `ServeFamilyStart` / `ServeFamilyLogin` / `ServeFamilyLogout` (v0.287.0, decisions 63/64) | controller/internal/family/family.go · controller/internal/stacks/family_gate.go · controller/internal/web/family_gate.go | store `Add/AddPerson/People/Reset/Remove/Verify/NewSession/HouseholdSession/Valid/EndSession/Find/EndAll` + `family.Fingerprint` + `family.LoginFrom` · `(host)` / `()` / `(prefix)` · handlers | The PERMANENT family gate: family members with their own logins in front of a `family_gate: true` app | **A family cookie never opens the dashboard** (RequireAuth reads only `felhom_session`); the app cookie carries a token DERIVED per app host from a STORE session (`familyAppToken`, R-976 — never the session id itself), so the store is asked on every request (reset/remove/logout end access at once); **every exception goes through `FamilyExceptRegexp`** (anchored — never a hand-written PathPrefix); door written before the first start, like the setup gate. `familyStoreOverride` is the test seam. Fifth atomic-write helper (family.json, fsync) — see §6. **R-1112: a session is stored as its FINGERPRINT (`Session.FP`, the R-35 shape), never the cookie value** — `NewSession` returns the value once; `Valid`/`EndSession` take the value and hash it; `Find` and `familyAppToken` work on the fingerprint. An old file (`"id"` = the value) is converted and rewritten in `Open`; a failed rewrite is an error. **R-1113: the household path uses `HouseholdSession`** (its live session, a new one only past half its life) — never `NewSession("")` per pass. **W12: a typed name goes through `AddPerson` (keeps the display, makes the login with `LoginFrom`, numbers a taken login); `Add` stays the strict login-only primitive. Any place that turns a typed member name into a login calls `LoginFrom` — never a second fold table** |
|
||||||
|
| `stacks.OpenInstallHold` / `installHoldTick` (v0.284.0, R-741) | controller/internal/stacks/install_hold.go | `(name, by)` / `()` | An `after_install` app held behind the setup gate's door until its known login is replaced | **Written before the first start**, like the gate; opens on `after_install` success or the household's "I changed it"; the door (`SetupGateHost`) reads holds first |
|
||||||
|
| `stacks.RetainImagesAfterUpdate` / `RetainImagesAfterRemove` / `RunImageRetentionOnce` (→ `(deleted, done)` since v0.294.0) / `RunImageRetentionOnceUntilDone` · seam `imageDocker` (v0.284.0, decision 53) | controller/internal/stacks/image_retention.go | `(name, previous)` / `(name, repos)` / `()` | Deletes an app's images older than its running + previous one | **The keep set is box-wide and read at delete time** (containers, installed composes, installed/previous records); exact id, never forced or pruned; skipped while any update runs AND while any image-pulling compose command runs (R-863, `dockerexec.TryImageCleanup`); the one-time pass writes its marker only after a pass that RAN; tests use the `imageDocker` seam, never Docker |
|
||||||
|
| `dockerexec.BeginImageWork(args)` / `TryImageCleanup()` / `ImagePulling(args)` (v0.294.0, R-863) | controller/internal/dockerexec/imagework.go | `defer dockerexec.BeginImageWork(args)()` | **Every NEW place that runs `compose up/pull/create/run` must hold it** (the stacks compose helpers, appexport's restore and the FileBrowser sync already do) | An image pulled by compose is named by no container until compose creates one; a clean-up pass in that window deleted it (measured, R-863). The pass takes the lock exclusively without waiting; pulling commands wait for a running pass (seconds) |
|
||||||
|
| `nightchain.Ledger` / `CatchUp` / `ResumeWatch` / `ComputeBanner` (v0.295.0, R-871) | controller/internal/nightchain/ | `Open(path, now)`, `MarkEnded(leg, t)`, `Missed(now, W, loc)`, `Evaluate(ctx, why)` | "was a night missed?" and making it up; the missed-backup banner's rule | The ledger is an ATTEMPT record (ran to its end) — never evidence a backup exists; a NEW nightly backup leg must be wrapped by `withLeg` in main.go and listed in `nightchain.Order`, or the catch-up never makes it up; never add the update leg to `catchUpLegs` |
|
||||||
|
| `scheduler.DailyLateLimit` (v0.295.0) | controller/internal/scheduler/scheduler.go | — | a daily timer firing > 60 min late (host suspend) is skipped | do not "fix" a late fire by running it: the app-update leg would run at noon |
|
||||||
|
| `stacks.RetainControllerImages(ControllerImageRecord)` · `selfupdate.UpdateState.RecordedPrevious` (v0.285.0, decision 56) | controller/internal/stacks/controller_image_retention.go | `({Repo, Running, Previous})` | Deletes controller images older than the running + previous one | The previous comes from the SWAP RECORD (success onto the running version), version order only as the fallback; versions above the running one and non-version tags are kept; skipped while the controller swaps itself; same `imageDocker` seam |
|
||||||
|
| `stacks.CloseSignupNow` / `CloseSignupOffered` / `applyNativeLock` (v0.282.0, decisions 47/49) | controller/internal/stacks/after_setup.go | `(name)` | The app's own sign-up switch after the setup; "close sign-up now" for an app installed before the rule | **Check the installed compose reads the variable** (an old install carries the old compose until its next update) — never record a lock that is not there. One run per app at a time (`nativeLockBusy`) |
|
||||||
|
| `backup.judgeCopy` / `HollowCopies` / `SetHollowCopyNotify` (Part D, v0.279.0) | controller/internal/backup/hollow_watch.go | `(app, tier, unitDir)` | A RUNNING app whose newest copy holds no data → operator digest once/day + page sentence | Uses `unitCarriesData` (the manifest, never size); a stopped held app is never flagged |
|
||||||
|
| `web.nightChain` (R-705, v0.279.0) | controller/internal/web/night_chain.go | `POST /api/debug/backup/night-chain` | The night's four legs now, in order | Refuses while any op/update/chain runs; the leg uses `RunUpdateLegNow` |
|
||||||
|
| `Router.dropLeftoverHold` + `settings.ClearUpdateHold` (R-704, v0.278.0) | controller/internal/api/router.go · controller/internal/settings/settings.go | `(name, why)` / `(stack) (bool, error)` | A new install (plain or "use my kept data") and a removal clear the update / crash-loop hold of the app's install | **A hold belongs to an INSTALL; the name is all the next install shares with it.** Never clears an R-379 restore hold (operator-only) |
|
||||||
|
| `backup.KeptBestCopy` / `KeptDBCopy` / `KeptOffsiteCopies` / `KeptCopyAt` / `LoadKeptApp` / `LoadKeptOffsite` / `KeptCopyKey` | controller/internal/backup/kept_load.go | `(ctx, app, drive)` / `(app, drive)` / `(ctx, apps)` / `(unitDir, drive, tier)` / … | Which copy can load kept files (local tiers + since v0.277.0 the off-site copy, R-691 (2)), the load as a restore op, the copy's name for the page | A copy counts only with data (DB dump or volume tar) AND its app.yaml `HDD_PATH` = this drive. Installed apps are never offered. **The off-site copy is judged only after its unit is downloaded** — `LoadKeptOffsite` refuses an unversioned unit (`07` §6.6) BEFORE `prepare` (a dated folder's move-back); ask the repository once per page (`KeptOffsiteCopies`), never per row. Both pages name a copy through `KeptCopyKey` |
|
||||||
|
|
||||||
### Subprocess + timeout + exit-code discipline
|
### Subprocess + timeout + exit-code discipline
|
||||||
|
|
||||||
@@ -36,6 +54,19 @@
|
|||||||
| `parseWWWAuthenticate` + `fetchAnonymousToken` | controller/internal/selfupdate/updater.go | Bearer-challenge parse + anonymous Docker v2 token | Any credential-free registry API access | realm comes FROM THE HEADER (never hardcode a token URL); denial = errAnonymousDenied, never "credentials missing" |
|
| `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` |
|
| `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
|
### HTTP/JSON envelopes + flash messages
|
||||||
|
|
||||||
| Symbol | File | Short signature | Use for | Gotchas |
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
@@ -48,6 +79,28 @@
|
|||||||
| `offboxRedirectTo` | controller/internal/web/offbox_handlers.go | `(w, r, page, msg string, isErr bool)` | Same, to an EXPLICIT page | **TRAP (fixed v0.154.0): the separator is chosen, not `"?"`.** Targets may already carry a query — the R-48 wizard is `/backups/restore/app?name=<app>` — and a hardcoded `"?"` buries the flash inside the previous parameter's value |
|
| `offboxRedirectTo` | controller/internal/web/offbox_handlers.go | `(w, r, page, msg string, isErr bool)` | Same, to an EXPLICIT page | **TRAP (fixed v0.154.0): the separator is chosen, not `"?"`.** Targets may already carry a query — the R-48 wizard is `/backups/restore/app?name=<app>` — and a hardcoded `"?"` buries the flash inside the previous parameter's value |
|
||||||
| `restoreOpInFlight` + `hasRecentRestoreResult` | controller/internal/web/restore_wizard.go | `(backup.RestoreOpStatus) bool` / `(st, app, now) bool` | THE "is a restore running / did one just finish" display reads | **TRAP (v0.154.0 shipped this bug): `Manager` has TWO running flags.** `IsRunning()` reads the CONCURRENCY flag, acquired inside the goroutine — and `RestoreOffboxScratch` never acquires it, so it is false for the whole verification restore. Display must read `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`). Read the status ONCE per render or the strip and the suppression can disagree. `hasRecentRestoreResult` is app-bound and window-bounded — a process-wide result must not light another app's „Eredmény" |
|
| `restoreOpInFlight` + `hasRecentRestoreResult` | controller/internal/web/restore_wizard.go | `(backup.RestoreOpStatus) bool` / `(st, app, now) bool` | THE "is a restore running / did one just finish" display reads | **TRAP (v0.154.0 shipped this bug): `Manager` has TWO running flags.** `IsRunning()` reads the CONCURRENCY flag, acquired inside the goroutine — and `RestoreOffboxScratch` never acquires it, so it is false for the whole verification restore. Display must read `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`). Read the status ONCE per render or the strip and the suppression can disagree. `hasRecentRestoreResult` is app-bound and window-bounded — a process-wide result must not light another app's „Eredmény" |
|
||||||
| `restoreWizardPath` / `deriveWizardStep` / `resolveWizardApp` | controller/internal/web/restore_wizard.go | `(app) string` / `(restoreWizardInput) restoreWizardView` / `([]OffboxAppRow, name) *OffboxAppRow` | R-48 offsite restore wizard: URL builder + the PURE step/unlock derivation + the app-resolution refusals | The step is **never** taken from the request. Precedence is load-bearing: op-running outranks a stale `?full_prep=`, else a commit button reappears mid-restore. Truth table + red-proof: `restore_wizard_test.go`. Adding a form here that posts anywhere new breaks `TestRestoreWizard_NoNewMutationEndpoints` **by design** — R-48 adds no mutation surface |
|
| `restoreWizardPath` / `deriveWizardStep` / `resolveWizardApp` | controller/internal/web/restore_wizard.go | `(app) string` / `(restoreWizardInput) restoreWizardView` / `([]OffboxAppRow, name) *OffboxAppRow` | R-48 offsite restore wizard: URL builder + the PURE step/unlock derivation + the app-resolution refusals | The step is **never** taken from the request. Precedence is load-bearing: op-running outranks a stale `?full_prep=`, else a commit button reappears mid-restore. Truth table + red-proof: `restore_wizard_test.go`. Adding a form here that posts anywhere new breaks `TestRestoreWizard_NoNewMutationEndpoints` **by design** — R-48 adds no mutation surface |
|
||||||
|
| `restoreOpBlocked` | controller/internal/web/restore_wizard.go | `() (msg string, blocked bool)` | THE refusal gate before starting ANY restore | **Use this, never a bare `IsRunning()`.** It reads BOTH flags: `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`, true for the whole off-box restore) and `IsRunning()` (the concurrency flag, the only one the nightly backup holds). **R-351: all seven handlers read only `IsRunning()`, which the goroutine acquires AFTER the handler returns — a second press started a second run and was told „…elindult".** Returns the Hungarian refusal, which names the running app and a route | **R-360 (v0.226.0): `offboxVerifyCopyDeleteHandler` was the LAST holdout and its doc comment claimed it already did this — the sentence is why nobody looked. `IsRunning()` is FALSE for the whole of a verification restore, so the copy a restore was writing into could be deleted from the UI (observed live 2026-08-21 22:35). No app-name comparison: refusing during ANY restore is stronger and uniform.**
|
||||||
|
| `Manager.RestoreFromRecoveryUnitAt` / `RestoreFromRecoveryUnit` + `UnitRestoreResult` (R-353 v0.226.0; R-102 v0.229.0) | controller/internal/backup/restore_unit.go | `(stack, unitDir string) (UnitRestoreResult, error)` / `(stack string) (…)` | THE local recovery-unit restore, and the facts its surface must state | **Returns a RESULT, not just an error** — volumes replayed, DBs replayed, and what the manifest LISTED. The Manifest* counts are load-bearing: zero-replayed has two causes (the backup held no data / the backup listed data that did not come back) and they are opposite news. Pair it with `unitRestoreOutcomeMsg`; do NOT write a new sentence. **A claim about the APP is forbidden on this path** — it has no `SafetyDump` discriminator, unlike the off-site twin (CONTEXT.md ruling, 07-backup-architecture §6.3). `restoreDockerVolumes` now returns `(int, error)`; `restoreDockerVolumesFrom` is unchanged and still the shared implementation. **R-102: `…At` holds the whole body and takes the unit DIRECTORY; the one-argument form is the thin caller naming the primary unit.** THE SOURCE MOVES; THE DESTINATION DOES NOT — `GetAppDrivePath` still resolves where the data lands. The R-47 mutation order, the unit-over-guest secret precedence, the fail-closed data-key gate and the no-unit `RestoreApp` fallback are all pinned and unchanged. The volume leg goes through the R-354 `volumeReplayFrom` seam so the source directory is assertable without Docker |
|
||||||
|
| `Manager.putBackAfterFailedRestore` + `liveVolumeCopy` (R-893 option A, v0.336.0) | controller/internal/backup/offbox_restore_undo.go | `(stack, cause, live, copy, volumesMoved, filesPlaced, restart, holdKey) error` | THE restore undo — off-site (v0.336.0) and, since D22 (unreleased), the LOCAL unit restore (`RestoreFromRecoveryUnitAtWith`, so also the second drive's and a kept load): the live volumes it will replace, measured before the stop (`planLiveVolumeCopy`), copied right after it (`copyLiveVolumes`), poured back on any failure | One copy per restore under `<ns>/backups/restore-undo/<app>/` (no capture set reads `backups/`); the put-back uses the restore's own `volumeReplayFrom`/`restoreDockerVolumesFrom`, so there is one volume-replay implementation. A put-back that fails sets `keep` and HOLDS (option C) — never start on volumes that are not known to be back. Seams for tests without Docker: `undoVolumeProbeFn`, `undoVolumeTarFn`, `undoVolumeRemoveFn`; operator notice `SetRestorePutBackNotify` (leg `restore-put-back`). Pinned by `r893_putback_test.go` and `r1177_local_putback_test.go` |
|
||||||
|
| `CrashPutBack` + `Manager.persistUndoForCrash` + `AppStopGuard.SetUndo` (D22 / R-1195, v0.338.0) | controller/internal/backup/restore_crash_putback.go | `NewCrashPutBack(stop, writeDef, logger)`; `PutBackInterruptedRestore(stack, undoDir) error` | A restore cut by a power loss is put back at start: the copy (volumes + `definition/` + `undo.json`) is recorded in the app-stop marker once whole; `Recover` pours it back BEFORE the restart | Call `persistUndoForCrash` right after `copyLiveVolumes` in any new restore path. `Recover` runs before the backup manager exists, so the put-back is wired in main.go with `stackMgr.StopStack` and the stack adapter's definition writer. A failed put-back = a FAILED recovery (app not started, marker kept). The household reads `note.restore.interrupted_put_back` (`LoadRestoreRecordAfterRecovery`). Pinned by `TestR1195_*` |
|
||||||
|
| `tier2CaptureSetFull(...) (legs, missing, warns, classified)` + `keptCopyRels` + `dropKeptCopiesUnder` (R-1252, unreleased) | controller/internal/backup/tier2_capture.go, tier2_kept.go | `classified` = the legs come from a VALID backup block | **The second-drive reconcile deletes only under a valid classification**; a rejected or absent block keeps every dest dir; kept copies (D25) are always in the keep set; a mirror deleted by Remove drops its kept records | A new reconcile caller must take `classified` and the kept rels too — never reconcile against the legacy legs. A rejected block is told by `Manager.BackupBlockRejected` (stacks; via the adapter's optional `BackupBlockRejected` method) and signalled once a day (`SetTier2SettingsUnreadableNotify`). Pinned by `r1252_rejected_block_keeps_copies_test.go` |
|
||||||
|
| `AppStopGuard.ResumePutBack` / `ResumeWaitingPutBacks` + `HoldReasonPutBackPending` (R-1251, unreleased) | controller/internal/backup/appstop_putback_wait.go | `ResumePutBack(stack) bool` (true = nothing owed, the caller may start); `ResumeWaitingPutBacks() []string` (puts back, then starts through the gated starter) | A cut restore's copy on a drive that is not bound is WAITING: Recover writes the restore hold `putback_pending` (copy in `SafetyDump`) and starts nothing; the put-back runs when the drive binds, before any start | **Any new path that starts an app when its drive arrives calls `ResumePutBack` first** (the boot recreate and `restartStacks` do, in web via `Server.resumePutBack`). Every other start path is covered by the hold store it already honours — do not add a second check. A failed put-back (at start or on resume) writes the D20 hold (`restore_mixed`) and drops the app from the marker. Needs `SetHolds` + `SetUndoReachable` before `Recover` (main.go). Pinned by `r1251_putback_waits_test.go`, `TestR1251_DriveStartPathsPutBackBeforeTheyStart`, `TestR1251_MainWiresThePutBackWait` |
|
||||||
|
| `Manager.RestoreTier2Unit` + `Tier2Coverage.CanRestoreUnit` / `Tier2CopyDate` (R-102/R-103, v0.229.0) | controller/internal/backup/tier2_restore.go | `(stack string) (UnitRestoreResult, error)` / `() bool` / `() (string, bool)` | THE full restore from the SECOND DRIVE's mirror, and the predicate that gates it | **Do NOT widen `CanRestore()`** — it answers only "can the additive file restore run?" and one predicate answering two questions is R-356. Fail-closed: `tier2UnitIsOpenable` requires a parseable manifest, because a directory is not a package. The single-writer flag is taken INSIDE `RestoreFromRecoveryUnitAt` — a second `acquireRunning()` here would refuse the restore it guards. `Tier2CopyDate` prefers the SUCCESS anchor over the attempt clock (R-101) and reports which it returned |
|
||||||
|
| `unitRestoreOutcomeMsg` + its three message constants (R-353, v0.226.0) | controller/internal/web/handlers.go | `(app string, res backup.UnitRestoreResult) string` | THE customer sentence for a completed LOCAL restore | Twin of `reconstituteOutcomeMsg`; copy its SHAPE (clauses earned by having done the thing, no filesystem path, base names only), never its text. The constants are named because `r353_unit_outcome_test.go` asserts them verbatim — a silent edit is how an honest message drifts back into a comforting one, which is the documented history of the sentence it replaces |
|
||||||
|
| `offsiteNoSpaceMsgFmt` + `offsiteSizeUnknownMsg` (R-357, v0.226.0) | controller/internal/backup/offbox_restore.go | two consts | EVERY headroom refusal on the off-site restore surface | **All four gates share these** (prepare, scratch, place, and the destructive reconstitute). A customer meeting one wording on one path and a different one on another has to work out whether it is the same problem. **The reconstitute gate uses NO ×1.1 margin** — it copies a measured tree; `OffboxRestorePrepareFull`'s ×1.1 predicts a download. **Fail closed when either probe reads ≤ 0**: `free < need` with `need == 0` is FALSE, so an unmeasurable input sails through — a gate present and inert |
|
||||||
|
| `Manager.OffboxFullScratchReady` + the scratch marker (R-358, v0.226.0) | controller/internal/backup/offbox_restore.go | `(stack) bool`; `.felhom-restore-complete.json` | THE gate for place-to-live and reconstitute | **It answers "did the run FINISH and was it FULL", not "are there files".** The old non-empty check passed a part-copy from a failed restic run, and the old doc comment ("PlaceOffsiteRestore re-validates per-path completeness") is what made it look adequate — that call stats top-level placements, not files. Marker written 0600 atomically AFTER restic returns nil; stale one cleared BEFORE it starts; both orders pinned by an AST test because `resticStep` is not a seam. Anything else — absent, unreadable, wrong schema, `full:false` — is NOT ready, with a WARN naming which. **Unit-only and full restores write the SAME directory**, so `full` is the only separator (R-396) |
|
||||||
|
| `JudgeRestoredUnit` + `UnitProofResult` (R-87, v0.231.0) | controller/internal/backup/r403_hollow.go | `(unitDir string) UnitProofResult` | THE question "does this app's backup contain what THIS APP should have" | **The rule has TWO parts and part 1 alone is the trap:** "everything declared is present" passes a HOLLOW unit, because a hollow unit declares nothing — the exact shape it exists to catch. Part 2 is the expectation, and it comes from the unit's **own** captured compose file (`UnitComposeDir(unitDir)` + the compose filename), NEVER from live Docker (`GetDockerVolumes` describes the running app; the snapshot may predate it). Database half is `DBServiceNames`, the same discriminator `RestoreFromRecoveryUnit` uses, so this cannot disagree with the restore path about what an app is. **The volume half is an EXISTENCE check, not a name match** — `ResolveDockerVolumeNames` derives the project from the compose file's parent dir, which inside a unit is the literal string `compose`. **THREE outcomes:** pass / fail / **cannot judge**, and the third is never collapsed. Size is never consulted (`TestR87_SizeIsNeverConsulted`) |
|
||||||
|
| `Manager.ProveOffboxUnit` + `ProofResult` (R-87, v0.231.0) | controller/internal/backup/offbox_proof.go | `(ctx) ProofResult` | THE nightly off-site content proof — one app, its newest snapshot, restored read-only and judged | **It NEVER writes to the repository and that is asserted on the ARGV:** `--no-lock`, no `unlockStale`, and `m.runner()` rather than `resticStep` so the `unlock --remove-all` escalation is unreachable. **It takes `acquireRunning` ITSELF** because `RestoreOffboxScratch` does not (R-408) — do not remove that. **Due-ness is per SNAPSHOT** (`ProvedSnapshots[stack]`), never a timestamp: a timestamp re-proves the same snapshot forever AND breaks the rotation. **Its scratch is a SEPARATE root** (`offsiteProofRootFor`, `backups/offsite-proof`) — sharing the customer's `offsite-restore` root would let a nightly job delete a copy the customer is looking at. A skip, a missing snapshot and a restore error reach NO verdict and do not advance due-ness |
|
||||||
|
| `unitOnlyHeadroom` + `offboxScratchDirIn` (R-87, v0.231.0) | controller/internal/backup/offbox_restore.go | `(free int64) error`; `(stack, rootFor)` | The unit-only free-space gate and the drive-preference resolver, **shared** by the customer restore and the nightly proof | Extracted rather than forked so the two paths cannot drift on the parts that must not differ — the floor, the Hungarian refusal (`offsiteNoSpaceMsgFmt`), the network-storage refusal and the R-252 wording. `rootFor` is the ONLY difference between the two scratch paths. **Fail-closed on an unmeasurable probe:** `offboxFree` returns 0 when it cannot read, and `0 < floor` refuses — the inverse of the R-357 shape where a gate went inert |
|
||||||
|
| `Manager.CheckOffboxIntegrity` + `IntegrityResult` + `IntegrityDue` (R-359, v0.227.0) | controller/internal/backup/offbox_integrity.go | `(ctx) IntegrityResult`; `(now) (due bool, last time.Time)` | THE off-site integrity check, and the only place `restic check` is run | **It TAKES `acquireRunning` and SKIPS rather than waits — never remove that guard.** `resticStep` escalates to `unlock --remove-all` on a lock error and is only safe because every caller holds the single-flight mutex; a check without it can strip a LIVE prune's lock. **THREE outcomes, not two:** `Skipped`, `Unreachable` and failed are different facts — a timeout is unreachable, NEVER damage, and only a failure notifies. A skip and an unreachable repo do **not** advance due-ness; a failure does. **DUE-NESS, NOT A WEEKDAY** (R-341). **`looksLikeRepositoryDamage` matches PHRASES, not words** — bare `pack `/`tree `/`snapshot ` appear in restic's ordinary progress output and made a healthy run look corrupt |
|
||||||
|
| `runOffsiteIntegrityCheck` + `integrityFailedMsg` / `integrityOKMsg` (R-397, v0.227.0) | controller/cmd/controller/main.go | `(ctx, mgr, notifier, logger, force) backup.IntegrityResult` | THE one caller of the check — the scheduled job AND the debug button both go through it | ONE function so the hand-run cannot drift from the scheduled one; `force` skips due-ness and **nothing else**. Wired to `NotifyIntegrityOK` / `NotifyIntegrityFailed`, which existed with no caller since the notifier did (sixth built-but-never-wired instance). **`ok` is severity `info` and therefore mails NOBODY by design** — a weekly success e-mail is how alerts stop being read. The customer gets a sentence; restic's output goes to the log truncated (R-379) |
|
||||||
|
| **`SetOffboxRunner` / `m.runner()` — the restic exec seam, and it has ALWAYS existed** | controller/internal/backup/offbox.go (~L52, ~L386) | `offboxRunner func(ctx, env, args...) ([]byte, error)` | Driving ANY restic-backed path under test | **R-398 claimed there was no such seam and was WRONG — do not re-file it.** `resticStep` is not itself overridable, but the layer it calls is, and tests have driven restic paths through it since the off-site tier shipped. **Use this rather than adding a `resticStepFn`:** replacing `resticStep` would hide its `unlock --remove-all` escalation from exactly the assertions that must see it (R-359's lock-safety tests assert `unlock` never appears in any argv) |
|
||||||
|
| `Manager.SetOffboxLatestSnapshotFn` (R-357, v0.226.0) | controller/internal/backup/offbox_restore.go | `(fn func(ctx, stack) (id string, paths []string, err error))` INIT/TEST-ONLY | Overriding the restic snapshot lookup in tests | Exists because R-357's gate could not otherwise be tested at the level that matters: reaching it requires getting past `offboxLatestSnapshot`, which shells to restic. **The assertion the seam enables is `StopStack` call count == 0** — a gate placed after the stop returns the right sentence and still takes the outage. Nil in production |
|
||||||
|
| `CheckPlacement` + `PlacementMismatchMessage` | controller/internal/backup/offbox_placement.go | `(*RecoveryManifest, liveDrive, liveNS) PlacementCheck` / `(stack, PlacementCheck) string` | Comparing where a backup SAYS the data lived against where a restore is about to write | Pure and total — nil/empty/blank manifest all give the same honest "not known, no mismatch". **An UNKNOWN is never a mismatch** (refusing on an absence strands every pre-field unit). Compares the DRIVE only (the namespace root is derived from it), Cleaned, so a trailing slash is not a difference. The message names BOTH values on purpose |
|
||||||
|
| `RecordedUnitForStack` + `RecordedAddress` | controller/internal/backup/offbox_placement.go | `(stack) (RecordedPlacement, RecordedAddress, bool)` | Reading back the address + data folder a backup recorded, for a reinstall prefill | Local file reads over every readable namespace root — **no network, no restic, no restore**; it exists for the NOT-INSTALLED case where `GetStackHDDPath` is `""`. **`RecordedAddress.Known()` requires BOTH halves:** an absent `SUBDOMAIN` makes the live deploy path fall back to the CATALOG default (`stacks/deploy.go:88-90`), and offering that back as "what your backup says" is a fabricated fact |
|
||||||
|
| **`Metadata.For(lang)` + `LocalizeStacks` / `LocalizeStackPtr` / `Stack.MetaFor` (R-560, v0.257.0)** | controller/internal/stacks/metadata_i18n.go | `(lang string) Metadata`; `([]Stack, lang) []Stack`; `(*Stack, lang) *Stack` | THE only way a page may reach catalog COPY — the app description, tagline, use cases, first steps, prerequisites, deploy-field labels and descriptions, optional-config text, integration labels, data-path labels and the initial-credentials note | Reading `stack.Meta.<copy>` off the manager renders HUNGARIAN to an English household, with no error and nothing wrong-looking on the page — `TestNoDirectMetaCopyReadOnPages` keeps the named, reasoned allow-list of every direct read in `internal/web`. **`For("hu")` is the parsed struct with `I18n` cleared** and nothing else (pinned over all 53 real catalog files), so a translation cannot change the Hungarian product. **Fallback is FIELD BY FIELD** — absent or blank English shows Hungarian, which is what makes a half-translated app shippable. **Lists replace WHOLE; every other list is matched by its own key** (`env_var`, option `value`, `match_group`, `target`, `path`) — position matching mistranslates silently the first time a field is inserted. **It never writes through:** the metadata is shared by concurrent requests, so an in-place merge leaks one household's language into another's page |
|
||||||
|
| `AppInfo.WithAddress` (R-498) | controller/internal/stacks/appinfo_address.go | `(subdomain, domain string) AppInfo` | Filling the catalog copy's `<label>.DOMAIN` / bare `DOMAIN` placeholder with the household's real address (effective subdomain + customer domain) | Returns a COPY (the metadata is shared); `\bDOMAIN\b` leaves `SUBDOMAIN`/`MAIL_DOMAIN` alone; no domain → unchanged. Apply AFTER `LocalizeStackPtr`. The deploy page still fills its post-deploy steps in JS (`deploy.html`), `knownLoginLine` its own line |
|
||||||
|
| `Metadata.HasDeployField` | controller/internal/stacks/metadata.go | `(envVar string) bool` | "Does this app have somewhere to PUT a recorded value?" | **13 of 53 templates declare `HDD_PATH`; 40 do not** (measured 2026-08-21). For the 40 a recorded placement is a FACT TO STATE, never a value to write into a field that does not exist |
|
||||||
| `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention |
|
| `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention |
|
||||||
| `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
|
| `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
|
||||||
| `ValidateSegment` | controller/internal/appexport/validate.go | `(kind, s string) error` | Any attacker-controlled path segment (.fab manifest fields) | CTRL-001 guard; deliberately NOT for dotfile ConfigFiles |
|
| `ValidateSegment` | controller/internal/appexport/validate.go | `(kind, s string) error` | Any attacker-controlled path segment (.fab manifest fields) | CTRL-001 guard; deliberately NOT for dotfile ConfigFiles |
|
||||||
@@ -57,10 +110,17 @@
|
|||||||
|
|
||||||
| Symbol | File | Short signature | Use for | Gotchas |
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
|
| `Manager.OffboxReportStatus` + `OffboxReportStatus.StatsKnown` (R-331, v0.225.0) | controller/internal/backup/offbox.go (~L1627) | `() *OffboxReportStatus` | THE off-site summary the hub reports on — snapshots, repo size, quota, last SUCCESSFUL run, declared state | **This object, not `report.BackupReport`, is where the hub gets live backup numbers.** `BackupReport`'s `SnapshotCount`/`RepoSizeMB`/`IntegrityOK` have had no producer since slice 8C; the hub rendered them anyway and told every operator every customer had `Snapshots 0` (R-331, measured on demo-hp over a repository holding 67). **`StatsKnown` must ride with the counts or they are unreadable**: zero is what an unread repository and a genuinely empty one both look like, and they are opposite news (R-225 measured that live). It is `omitempty`, so an older controller sends no key and a reader MUST degrade to "unknown", never to "empty". nil = nothing to say, EXCEPT the declared `needs_credential`/`awaiting_recovery_key` states, which report an object while disabled |
|
||||||
|
| `backup.ErrOffboxSealedPackageHeld` + `IsOffboxSealedPackageHeld` + `sealedPackageHeld` + `OffboxAwaitingRecoveryKey` (R-241, v0.206.0) | controller/internal/backup/offbox.go | sentinel; `(error) bool`; `() bool`; `() bool` | **THE MINT GUARD** — a box never creates a repository key while the hub holds a sealed package for it | **The guard is a CONJUNCTION** (package held AND no key present). Widening it to "never mint" leaves a first-time box unable to start, waiting for a package that will never exist — pinned by `TestR241_ScenarioB_FirstTimeBoxStillMints`. **The refusal is a HOLDING state, not a failure:** `ApplyOffsiteTarget` catches the sentinel and still writes the transport, so `/recovery`'s synchronous tier-up (R-219) can bring the tier up the instant the key arrives; returning the error instead leaves `needsOffsiteCredential` true and the hub re-staging a consumed credential for ever. `OffboxAwaitingRecoveryKey` is **DERIVED, never stored** — and **`t.Enabled` is load-bearing in it**: a customer who switched off-site OFF is not awaiting anything (the Scenario-E carve-out `needsOffsiteCredential` makes two functions above; the first draft omitted it and an existing test caught it). A nil settings store reads as "no package held" — a transient read failure must never become a permanently-held tier |
|
||||||
|
| `settings.HubEscrowKeySHA256` + `SetHubEscrowKeySHA256` / `GetHubEscrowKeySHA256`, and `OffsiteRecoveryOffer` **shape (c)** (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/backup/offbox.go | `(sha, checkedAt string) error` / `() (string, string)` | **THE DISCRIMINATOR the recovery screen asks** — does the hub hold a package for a key other than the one we use? | **The comparison was ALREADY computed on every ACK since SLICE 3 and persisted nowhere** — that is R-241's second half. Wire the recorder in `main.go`'s `EscrowAutoConfirmer` literal or shape (c) reads an empty hash for ever and the fix ships INERT (pinned by `TestMainWiresRecordEscrowKeyHash`). **§7.2 staleness, decided:** a KNOWN DIFFERENCE offers **however old the reading** — age is deliberately NOT gated on, because gating makes a box offline from the hub silently stop offering; an **ABSENT hash falls back to (a)/(b)** and does NOT offer, because `""` is the hub positively saying its package seals no key (legacy hash-less escrow), not an unknown. `CheckedAt` is for diagnosis, never a gate |
|
||||||
|
| `backup.AbandonStatus` / `AbandonSweep` / `CancelAbandon` / `ClearAbandonPurgeIfConfirmed` / `ExtendAbandon` / `StopAbandon` + `AbandonGraceDays` (R-241, v0.206.0) | controller/internal/backup/offbox_abandon.go | see file | **The 14-day abandonment countdown** — the ONLY thing in the product that deletes a customer's off-site history | **BOTH HALVES OR NEITHER.** The set-aside store and the sealed package that protects it are two halves of one thing; removing only one leaves a package that opens nothing, or ciphertext nobody can decrypt. Not atomic across two machines, so it is a **two-phase commit**: delete the store, set `AbandonPurgeRequested`, and keep declaring it until the hub's ACK stops reporting a superseded package — the confirmation rides the SAME ACK as the request. **The countdown starts in `ResetOrphanedRepo`, NOT in the shared `resetOrphanedRepo`** — the helper is also the UNCLAIMED auto-reset, where nobody decided anything. **The recovery offer stays reachable for the whole grace** (a grace in which recovery is impossible is decorative). **Drive it with `SetOffboxClock`, never a shortened live timer** (§7.4). A transport failure leaves the countdown DUE so tomorrow retries; the operator levers REFUSE rather than no-op when nothing is running or the store is already gone |
|
||||||
|
| `settings.SyncRecoveryOfferEpoch` / `PostponeRecoveryNoticeForEpoch` / `OptOutRecoveryRemindersForEpoch` + `web.recoveryBannerCookie` (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/web/recovery_handlers.go | `(offered bool, now) (RecoveryOfferView, error)` | **The offer EPOCH** — "once per entry into the offered state", not once ever | **Sync the epoch FIRST and UNCONDITIONALLY in `recoveryInterrupts`.** The first draft returned early when the offer was false, so the FALLING edge was never recorded, `RecoveryOfferActive` stayed true through a settled period, and the next entry counted as a continuation — **the exact defect the epoch exists to fix, reintroduced inside the fix**. Dismissals are recorded against the epoch they were made in, so a fresh entry resets them **by arithmetic**, with nothing to clear. **Three levers, three scopes, and NONE removes the entry point on `/backups/remote`:** the banner cookie is a browser SESSION cookie (no MaxAge — cleared on login) and persists nothing; the reminder opt-out is durable but silences the BANNER ONLY; "most nem" suppresses the full page only |
|
||||||
|
| `Manager.guardUnitWrite` / `guardUnitDir` + `unitOwnerVerdict` + `setAsideUnit` (D47, R-1269, unreleased) | controller/internal/backup/unit_owner.go | `guardUnitWrite(stack, nsRoot, what) error` (nil = write) | **The one question every unit writer asks first**: is this unit this install's? Another install's unit is moved whole to `backups/set-aside/<app>/<stamp>` and recorded as a kept copy (kind `unit`, reason `earlier_install`); a move that fails refuses the write | **A new writer of `backups/primary/<app>` (or of a second-drive unit copy) calls it before its first byte** — never write and check later. Identity: `stacks.AppConfig.InstallID` via `RecoveryInfo.InstallID`; `EnsureInstallID` (optional provider method) mints one for an older app. Restore of a set-aside unit: `RestoreSetAsideUnit(id)` by its record only. Pinned by `unit_owner_test.go` |
|
||||||
|
| `Manager.keepCopy` / `releaseKeptCopy` / `RemoveKeptCopy` / `KeptCopies` + `settings.KeptCopy` (D25, R-1175/R-1181, 2026-10-21) | controller/internal/backup/tier2_kept.go, controller/internal/settings/kept_copies.go | see files | **A second-drive copy whose SOURCE is gone** (drive away/decommissioned, declared folder missing) — recorded, listed on the restore page, removed only by the household's „Eltávolítás" | **Never remove a kept copy by size, name or pattern** — `RemoveKeptCopy` takes the RECORD and checks path-inside-tree + device/inode; a swapped directory refuses. A share/leg reconcile walks ONLY its own key/legs (R-1175: walking every key with one group's legs deleted the other groups' copies). `emptiedSourceRefused` stops `rsync --delete` from emptying a full copy |
|
||||||
|
| `stacks.SaveImportedAppConfig` + `PrepareImportedApp` (R-1191, 2026-10-21) | controller/internal/stacks/import_record.go | `(stackDir, env) (fresh bool, error)`; `(stackDir, env, fresh) error` | The `.fab` import's app record + doors (family gate, sign-up lock) BEFORE the start | Reuses `carryLifeRecords` / `prepareFamilyGate` / `restoreSignupLock` — do not copy their logic. `PrepareImportedApp` must also update the IN-MEMORY record, or the gate reconcile removes the file on its next tick |
|
||||||
| `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback |
|
| `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback |
|
||||||
| `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) |
|
| `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) |
|
||||||
| `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) |
|
| `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) |
|
||||||
| `atomicWriteFile` | controller/internal/setup/handlers.go | `(path, data, perm) error` | setup-wizard writes | Same bind-mount fallback caveat |
|
|
||||||
| `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand |
|
| `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand |
|
||||||
| `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops |
|
| `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops |
|
||||||
| `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
|
| `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
|
||||||
@@ -68,34 +128,60 @@
|
|||||||
| `Manager.buildSharesPayload` / `classifiedShares` | controller/internal/backup/shares_payload.go | `() (dir, passdbOK, error)` / `() []classifiedShare` | the definitions+credential payload and the availability-filtered share set both tiers read | payload is SECRET-BEARING (0600 passdb.tar) — never log its bytes/name at INFO. `classifiedShares` is the single place a dead mount is dropped, so both jobs agree |
|
| `Manager.buildSharesPayload` / `classifiedShares` | controller/internal/backup/shares_payload.go | `() (dir, passdbOK, error)` / `() []classifiedShare` | the definitions+credential payload and the availability-filtered share set both tiers read | payload is SECRET-BEARING (0600 passdb.tar) — never log its bytes/name at INFO. `classifiedShares` is the single place a dead mount is dropped, so both jobs agree |
|
||||||
| `Manager.selectTier2TargetFrom` | controller/internal/backup/tier2.go | `(stack, sourceDrive, fullSize, stateOnlySize) (*Tier2Target, error)` | tier-2 target choice with the source drive supplied EXPLICITLY | the seam the shares job reuses — NEVER fork the headroom math; `selectTier2Target` is now a thin wrapper over it |
|
| `Manager.selectTier2TargetFrom` | controller/internal/backup/tier2.go | `(stack, sourceDrive, fullSize, stateOnlySize) (*Tier2Target, error)` | tier-2 target choice with the source drive supplied EXPLICITLY | the seam the shares job reuses — NEVER fork the headroom math; `selectTier2Target` is now a thin wrapper over it |
|
||||||
| `Manager.tier2ReconcileRoots` | controller/internal/backup/tier2.go | `(destBase, roots, legRels)` | staleness pruning with explicit dest roots | pure extraction from `tier2Reconcile` (which now calls it with `hdd`/`userdata`); reuse it rather than writing a second pruner |
|
| `Manager.tier2ReconcileRoots` | controller/internal/backup/tier2.go | `(destBase, roots, legRels)` | staleness pruning with explicit dest roots | pure extraction from `tier2Reconcile` (which now calls it with `hdd`/`userdata`); reuse it rather than writing a second pruner |
|
||||||
|
| `backup.Tier2CopyBlocksRestore` / `ClearLeftoverTier2Copies` / `MarkTier2CopyForTest` + the `tier2Docker` seam (the stray-copy fix, v0.340.0) | controller/internal/backup/tier2_copy_lifecycle.go | `(m) Tier2CopyBlocksRestore() (app, blocked)`; `ClearLeftoverTier2Copies(ctx) error`; `MarkTier2CopyForTest(app) func()` | a restore's refusal while a capped second-drive copy runs or a stray may run; the start-up removal of labelled copy containers; the test marker; `tier2Docker` is the docker seam (tests never reach real docker) | Row added 2026-10-24 (review round 2). The capped copy runs `rsync --delete` in its own container — never call `docker` directly for it; go through `tier2Docker`. |
|
||||||
| `Manager.liveShareRootOK` / `scratchJoin` | controller/internal/backup/shares_restore.go | `(dst) bool` / `(scratch, abs) string` | THE place guard for shares restore + scratch path reconstruction | a snapshot is UNTRUSTED layout input: require a STRICT descendant of a live registered root, refuse `..` and the drive root itself. `scratchJoin` strips the volume name — plain `filepath.Join` splices a drive letter mid-path |
|
| `Manager.liveShareRootOK` / `scratchJoin` | controller/internal/backup/shares_restore.go | `(dst) bool` / `(scratch, abs) string` | THE place guard for shares restore + scratch path reconstruction | a snapshot is UNTRUSTED layout input: require a STRICT descendant of a live registered root, refuse `..` and the drive root itself. `scratchJoin` strips the volume name — plain `filepath.Join` splices a drive letter mid-path |
|
||||||
| `infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount` | controller/internal/infra/samba.go | consts | single source of truth for the samba container identity | the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (`felhom-samba`) is NOT the stack name (`samba`) — `EffectiveProtected` needs the container one |
|
| `infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount` | controller/internal/infra/samba.go | consts | single source of truth for the samba container identity | the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (`felhom-samba`) is NOT the stack name (`samba`) — `EffectiveProtected` needs the container one |
|
||||||
| `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 |
|
| `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 |
|
||||||
| `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot |
|
| `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot |
|
||||||
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE R-82 multi-tier backup schedule — several whole-guest tiers (local daily + PBS weekly) reconciled into ONE quiesce window | **Both tiers due ⇒ ONE stop/start pair**, never two (two = two app outages for one night). Tiers run SEQUENTIALLY (vzdump holds a guest lock) and the app stays down until the LAST tier snapshots — resuming earlier loses app-consistency on the DR tier. Order is fast-first (agent advertises primary first) or downtime blows up. `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
|
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE multi-tier whole-guest backup schedule (local daily + PBS weekly) | **One stop per tier** (`09` §3 decision 156, v0.301.0 — REVERSES R-82's one window for both tiers): a window backs up only the FIRST due tier and resumes the apps at its `snapshotted`; another due tier waits for a later cycle. A tier that refuses to start (BUSY or an error) lets the next tier try in the same window. Order is agent order (primary first). `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
|
||||||
|
| `quiesce.InFlightProber` + `Loop.anotherTierInFlight` · `agentapi.Client.BackupJobsInFlight` (R-921) | controller/internal/quiesce/precheck.go · controller/internal/agentapi/backup_tiers.go | `BackupJobsInFlight(ctx) ([]string, error)` | Check first, stop second: no app is stopped for a tier the agent will refuse because ANOTHER tier's job is in flight | OPTIONAL interface (fakes without it keep the old path). Fails toward backing up on any read error. **It cannot see the agent's host-wide heavy-op gate** (OS step, restore-test, fstrim — no endpoint serves it): that refusal is met by the BUSY path's immediate resume (`TestR921_BusyRefusalResumesAtOnce`). The window's OWN tier in flight is not skipped (the agent answers with the running job) |
|
||||||
|
| `quiesce` whole-guest ledger — `Loop.pressOwesNight` / `recordWholeGuestSuccess`, `WithManualTrigger` / `IsManualTrigger` (R-899) | controller/internal/quiesce/nightowed.go | `pressOwesNight(led, target) bool`; `WithManualTrigger(ctx) ctx` | **A household press never cancels the night's whole-guest backup** (operator ruling 2026-10-08) | The ledger (`whole-guest-ledger.json` beside the marker) records SUCCESSES only, per tier, press vs scheduled — it decides due-ness and is never evidence that a copy exists (the agent's storage answers that). An „owed" tier never fires the window gate's safety valve (`withoutOwed`/`gateAge`). The press mark rides the context to the agent adapter (`agentapi.StartBackupForTrigger` → `trigger=manual`), so the agent runs no OS leg after a press. |
|
||||||
| `quiesce.failureBreaker` + `Loop.dropBackedOffTiers` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `blocked/recordFailure/recordSuccess(target, now)`; `backoffFor(n) time.Duration` | **R-88** — a tier whose backups keep failing stops re-quiescing. Backoff 15m→30m→1h→2h→4h (cap), reset on success | **It gates the QUIESCE, not the backup** — the harm was never the failing backup, it was the app outage taken to attempt it, so backed-off tiers are dropped from the due set BEFORE any stack is stopped. **Per TARGET** — a broken offsite tier must never suppress a healthy local one (`TestBreaker_OneFailingTierDoesNotSuppressAHealthyOne`). **Never permanent** — the cap bounds the retry INTERVAL, it never stops retrying; a latched breaker is a silent backup outage, worse than the loop it replaces. **`TriggerNow` is never gated** (it already bypasses due-ness and the window gate), though a manual run still RECORDS its outcome. **`stillRunning` is NOT a failure** — a first full offsite snapshot legitimately runs for hours. State is **in-memory on purpose**: a restart forgets the backoff and re-attempts, which is the cheap direction to fail. Log the deferral ONCE when armed, never per tick. |
|
| `quiesce.failureBreaker` + `Loop.dropBackedOffTiers` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `blocked/recordFailure/recordSuccess(target, now)`; `backoffFor(n) time.Duration` | **R-88** — a tier whose backups keep failing stops re-quiescing. Backoff 15m→30m→1h→2h→4h (cap), reset on success | **It gates the QUIESCE, not the backup** — the harm was never the failing backup, it was the app outage taken to attempt it, so backed-off tiers are dropped from the due set BEFORE any stack is stopped. **Per TARGET** — a broken offsite tier must never suppress a healthy local one (`TestBreaker_OneFailingTierDoesNotSuppressAHealthyOne`). **Never permanent** — the cap bounds the retry INTERVAL, it never stops retrying; a latched breaker is a silent backup outage, worse than the loop it replaces. **`TriggerNow` is never gated** (it already bypasses due-ness and the window gate), though a manual run still RECORDS its outcome. **`stillRunning` is NOT a failure** — a first full offsite snapshot legitimately runs for hours. State is **in-memory on purpose**: a restart forgets the backoff and re-attempts, which is the cheap direction to fail. Log the deferral ONCE when armed, never per tick. |
|
||||||
| `quiesce.TierNotifier` + `Loop.SetTierNotifier` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `BackupFailed(tier,msg,err)` / `BackupRecovered(tier,msg)`; `SetTierNotifier(n)` INIT-ONLY | **R-97a** — the whole-guest backup tier reports its outcome to the hub | A **seam, not an import** — quiesce keeps no dependency on `internal/notify` (same reason `windowStartFn` is injected). Wired by a setter because main.go builds the notifier AFTER the loop; `nil` = unprovisioned guest, not an error. **Edge-triggered:** failure fires only when the breaker ARMS (`n == 1`), never per retry — the cadence is 15m/30m/1h/2h/4h and an event per attempt is an inbox nobody reads. Recovery rides `recordSuccess`'s existing bool. **Event types are OPERATOR-ONLY** (`whole_guest_backup_failed`/`_recovered`, hub >= v0.78.0) — NOT `backup_failed`, which has a customerMessages entry AND sits in live `enabled_events`, so it would email the CUSTOMER about a backup they cannot act on. `WholeGuestBackupDetails.Tier` is load-bearing: the hub keys its per-tier cooldown on it. |
|
| `quiesce.TierNotifier` + `Loop.SetTierNotifier` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `BackupFailed(tier,msg,err)` / `BackupRecovered(tier,msg)`; `SetTierNotifier(n)` INIT-ONLY | **R-97a** — the whole-guest backup tier reports its outcome to the hub | A **seam, not an import** — quiesce keeps no dependency on `internal/notify` (same reason `windowStartFn` is injected). Wired by a setter because main.go builds the notifier AFTER the loop; `nil` = unprovisioned guest, not an error. **Edge-triggered:** failure fires only when the breaker ARMS (`n == 1`), never per retry — the cadence is 15m/30m/1h/2h/4h and an event per attempt is an inbox nobody reads. Recovery rides `recordSuccess`'s existing bool. **Event types are OPERATOR-ONLY** (`whole_guest_backup_failed`/`_recovered`, hub >= v0.78.0) — NOT `backup_failed`, which has a customerMessages entry AND sits in live `enabled_events`, so it would email the CUSTOMER about a backup they cannot act on. `WholeGuestBackupDetails.Tier` is load-bearing: the hub keys its per-tier cooldown on it. |
|
||||||
| `quiesce.Loop.SuppressedStacks` + `markQuiesced` / `markUnquiesced` | controller/internal/quiesce/suppress.go | `() map[string]bool` (nil-safe on a nil *Loop) | **R-97b** — an app THIS controller stopped for a backup is not a fault | Consumed at the SINGLE derivation point `classifyRunStates` (which computes both the banner dead-list and the notifier Down-set — keep it one place). **Cycle-keyed, not state-based:** v0.164.0's `!= StateStopped` filter cannot see an app caught MID-RESTART (`starting`/`unhealthy`), which is how BookStack alarmed on 2026-07-27. The window (`quiesceAlarmGrace` = 180 s, derived from the deploy flow's 120 s health timeout and Mealie's 60 s start_period) **EXPIRES** — permanent suppression turns a loud false alarm into a silent real one. Open-ended while the cycle runs (a first offsite snapshot legitimately takes hours). |
|
| `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. |
|
||||||
| `agentapi.BackupTiers` / `BackupDueFor` / `StartBackupFor` / `BackupStatusFor` | controller/internal/agentapi/backup_tiers.go | `(ctx[, target]) (…, error)` | The per-tier agent surface (agent >= v0.97.0) | `targetQuery("")` returns an EMPTY suffix so an untargeted call hits the pre-R-82 route byte-for-byte. `BackupTiers` maps a 404 to `ErrTiersUnsupported` — the documented ROUTE-PROBE capability signal, NOT a `featureProbes` row (the loop needs the tier LIST, not a yes/no). |
|
| `agentapi.BackupTiers` / `BackupDueFor` / `StartBackupFor` / `BackupStatusFor` | controller/internal/agentapi/backup_tiers.go | `(ctx[, target]) (…, error)` | The per-tier agent surface (agent >= v0.97.0) | `targetQuery("")` returns an EMPTY suffix so an untargeted call hits the pre-R-82 route byte-for-byte. `BackupTiers` maps a 404 to `ErrTiersUnsupported` — the documented ROUTE-PROBE capability signal, NOT a `featureProbes` row (the loop needs the tier LIST, not a yes/no). |
|
||||||
|
|
||||||
### Compose ops / stack lifecycle
|
### Compose ops / stack lifecycle
|
||||||
|
|
||||||
| Symbol | File | Short signature | Use for | Gotchas |
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
|
| `stacks.Manager.admitCompose` / `privpolicy.Admit` (v0.328.0, R-971) | controller/internal/stacks/allowlist.go, controller/internal/privpolicy/privpolicy.go | `admitCompose(name, data, env) error` | THE question before ANY write of an app compose (and before a settings change or restore commits an env): the box's catalog allow-list on the RENDERED compose | A new compose writer must call it first — `TestAllowList_IsWiredIntoEveryComposeWriter` fails otherwise; a refusal records the page line + one hub event per definition; the allow-list is `privpolicy/allowlist.json` (exact app+service+setting; EMPTY since v0.329.0) plus `appdata_folders` (an app's `${HDD_PATH}/appdata/<folder>` when the folder is not its name — `privpolicy.AppdataFolder`, R-1142), and the catalog's `scripts/privilege-allowlist.json` must stay byte-identical |
|
||||||
| `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure |
|
| `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.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.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 |
|
| `Manager.StartStackServices` (R-47, v0.153.0) | controller/internal/stacks/manager.go | `(name string, services []string) error` | scoped `compose up -d <svc>...` — the DB-only window a dump is replayed in | **REFUSES an empty list** (argument-less `up -d` is a FULL start — the one silent fall-through that would reintroduce the race). No `logPostStartStatus`: the app containers are absent on purpose. Never `RestartStack` here — it is a full up in disguise |
|
||||||
| `appbackup.DBServiceNames` / `dbTypeForImage` (R-47, v0.153.0) | controller/internal/appbackup/dbservices.go | `(composePath string) ([]string, error)` | naming the compose SERVICE(s) holding a database, sorted | yaml.v3 `services:` MAP parse — **never a line scan** (immich's top-level `immich_ml_cache:` / `immich_postgres_data:` volume keys look exactly like services). `dbTypeForImage` is shared with `DiscoverDatabases`, which is what makes "a dump exists ⇒ a service can be named" hold. An error means CANNOT-TELL, never "no database" — callers refuse when a dump exists |
|
| `appbackup.DBServiceNames` / `dbTypeForImage` (R-47, v0.153.0) | controller/internal/appbackup/dbservices.go | `(composePath string) ([]string, error)` | naming the compose SERVICE(s) holding a database, sorted | yaml.v3 `services:` MAP parse — **never a line scan** (immich's top-level `immich_ml_cache:` / `immich_postgres_data:` volume keys look exactly like services). `dbTypeForImage` is shared with `DiscoverDatabases`, which is what makes "a dump exists ⇒ a service can be named" hold. An error means CANNOT-TELL, never "no database" — callers refuse when a dump exists |
|
||||||
| `Manager.StartStack/StopStack/RestartStack/UpdateStack` | controller/internal/stacks/manager.go | `(name string) error` | Lifecycle | Protected stacks refuse stop; all funnel through composeExec. **NOT writers of desired state (R-166)** — 14 call sites, only 2 are the customer; recording intent here would make a nightly backup indistinguishable from the customer pressing Stop. Use `SetDesiredState` at the intent point instead |
|
| `Manager.StartStack/StopStack/RestartStack` | controller/internal/stacks/manager.go | `(name string) error` | Lifecycle | **`UpdateStack` was DELETED in v0.237.0 — use `StartGuardedUpdate` (update.go).** Protected stacks refuse stop; all funnel through composeExec. **NOT writers of desired state (R-166)** — 14 call sites, only 2 are the customer; recording intent here would make a nightly backup indistinguishable from the customer pressing Stop. Use `SetDesiredState` at the intent point instead |
|
||||||
| `Manager.SetDesiredState` / `DesiredStateOf` / `BackfillDesiredState` (R-166, v0.189.0) | controller/internal/stacks/desiredstate.go | `(name, desired string) error` / `(Stack) string` / `() int` | THE customer-intent record — `app.yaml` `desired_state`, tri-state `""`/`running`/`stopped` | **ONE OWNER: the customer's action.** Writers are the API action switch, `DeployStack`, `UpdateOptionalConfig`'s redeploy branch, and the `.fab` restore adapter — nothing else, ever. **`""` (absent) means UNKNOWN, never "running"**: every pre-v0.189.0 app.yaml reads absent, so treating it as running would start every deliberately-stopped app on upgrade. Write intent BEFORE the act and REFUSE the act if it fails (§8.2). Backfill is **running-only** — never infer `stopped` from zero containers, that inference IS the defect |
|
| `Manager.SetDesiredState` / `DesiredStateOf` / `BackfillDesiredState` (R-166, v0.189.0) | controller/internal/stacks/desiredstate.go | `(name, desired string) error` / `(Stack) string` / `() int` | THE customer-intent record — `app.yaml` `desired_state`, tri-state `""`/`running`/`stopped` | **ONE OWNER: the customer's action.** Writers are the API action switch, `DeployStack`, `UpdateOptionalConfig`'s redeploy branch, and the `.fab` restore adapter — nothing else, ever. **`""` (absent) means UNKNOWN, never "running"**: every pre-v0.189.0 app.yaml reads absent, so treating it as running would start every deliberately-stopped app on upgrade. Write intent BEFORE the act and REFUSE the act if it fails (§8.2). Backfill is **running-only** — never infer `stopped` from zero containers, that inference IS the defect |
|
||||||
| `Manager.DriveLive` (R-171, v0.190.0) | controller/internal/stacks/deploy.go | `(hddPath string) bool` | is an app's data drive a live mountpoint RIGHT NOW | Wraps the **same** `isMountPoint` seam the userdata belt uses (`manager.go`) — never write a second liveness check, the two would drift invisibly. The system/local path is legitimately not a mountpoint and returns true |
|
| `Manager.DriveLive` (R-171, v0.190.0) | controller/internal/stacks/deploy.go | `(hddPath string) bool` | is an app's data drive a live mountpoint RIGHT NOW | Wraps the **same** `isMountPoint` seam the userdata belt uses (`manager.go`) — never write a second liveness check, the two would drift invisibly. The system/local path is legitimately not a mountpoint and returns true |
|
||||||
| `bootrecon.StartGate` (R-171, v0.190.0) | controller/internal/bootrecon/bootrecon.go | `MayStart(stack) (bool, reason)` | THE one question the boot sweep asks before starting anything | **Fail-safe: cannot determine ⇒ return FALSE.** One seam for all three holders (absent drive · quiesce · an in-flight app-data operation) because they differ only in the reason string. Implemented in `main.go` (`bootDriveGate`) reusing `quiesce.SuppressedStacks()`, `AppStopGuard.HeldStacks()` and `Manager.DriveLive` — never re-derive any of them. Held apps go to `Result.HeldByDrive`, **never** `StillDown` (that is the dead-app alarm's bucket) |
|
| `bootrecon.StartGate` (R-171, v0.190.0) | controller/internal/bootrecon/bootrecon.go | `MayStart(stack) (bool, reason)` | THE one question the boot sweep asks before starting anything | **Fail-safe: cannot determine ⇒ return FALSE.** One seam for all three holders (absent drive · quiesce · an in-flight app-data operation) because they differ only in the reason string. Implemented in `main.go` (`bootDriveGate`) reusing `quiesce.SuppressedStacks()`, `AppStopGuard.HeldStacks()` and `Manager.DriveLive` — never re-derive any of them. Held apps go to `Result.HeldByDrive`, **never** `StillDown` (that is the dead-app alarm's bucket) |
|
||||||
| the boot settle window (R-157 A, v0.190.0) | controller/cmd/controller/main.go | `bootReconcileSample` / `StableFor` / `Budget` | sample the fleet until it stops changing, then sweep ONCE | **settle + budget + one `DefaultRetryDelay` must stay under `deadAppBootGrace`** — pinned by `TestBootWindow_CommonCaseFitsInsideTheDeadAppGrace`, which is why the budget is 50 s and not 60 s. Sampling is READ-ONLY; sweeping per sample would never see a settled fleet (the sweep's own StartStack changes it). A late recovery is REPORTED (`recordLateRecovery`), never hidden by widening the grace |
|
| the boot settle window (R-157 A, v0.190.0) | controller/cmd/controller/main.go | `bootReconcileSample` / `StableFor` / `Budget` | sample the fleet until it stops changing, then sweep ONCE | **settle + budget + one `DefaultRetryDelay` must stay under `deadAppBootGrace`** — pinned by `TestBootWindow_CommonCaseFitsInsideTheDeadAppGrace`, which is why the budget is 50 s and not 60 s. Sampling is READ-ONLY; sweeping per sample would never see a settled fleet (the sweep's own StartStack changes it). A late recovery is REPORTED (`recordLateRecovery`), never hidden by widening the grace |
|
||||||
| `backup.AppStopGuard` (`Begin`/`End`/`Recover`) (R-166, v0.189.0) | controller/internal/backup/appstop_marker.go | `(opID, reason, stacks) error` / `()` / `() *AppStopRecovery` | THE crash marker for stop→work→start windows (volume dump, offbox reconstitute, `.fab` export) | Its **own** file (`appstop-state.json`), never quiesce's — one file, one writer. **A `defer` is NOT the mechanism** (Campaign 8 fault 10: SIGKILL runs no defer); the marker is. Written BEFORE the stop, cleared ONLY after a restart that succeeded; a FAILED restart deliberately KEEPS it. `Recover` RETURNS its outcome rather than notifying, because it must complete before the boot reconciler while the notifier does not exist yet |
|
| `backup.AppStopGuard` (`Begin`/`End`/`Recover`) (R-166, v0.189.0) | controller/internal/backup/appstop_marker.go | `(opID, reason, stacks) error` / `()` / `() *AppStopRecovery` | THE crash marker for stop→work→start windows (volume dump, offbox reconstitute, `.fab` export) | Its **own** file (`appstop-state.json`), never quiesce's — one file, one writer. **A `defer` is NOT the mechanism** (Campaign 8 fault 10: SIGKILL runs no defer); the marker is. Written BEFORE the stop, cleared ONLY after a restart that succeeded; a FAILED restart deliberately KEEPS it. `Recover` RETURNS its outcome rather than notifying, because it must complete before the boot reconciler while the notifier does not exist yet |
|
||||||
|
| `backup.AppStopGuard.SuppressedStacks` + `markStopped` / `releaseStarted` / `ReleaseFailed` (R-330, v0.224.0) | controller/internal/backup/appstop_suppress.go | `() map[string]bool` (nil-safe on a nil *AppStopGuard); `ReleaseFailed(stacks ...string)` | **R-330** — an app a PER-APP operation is holding stopped (nightly volume dump, offbox reconstitute, `.fab` export) is not a fault | The **twin** of `quiesce.Loop.SuppressedStacks` above, and the two are unioned by `unionSuppressed` in main.go before `classifyRunStates` — **consult BOTH or the bug comes back**: R-330 shipped because the alarm read only the quiesce set while the per-app legs stopped apps through a different path. Rides `Begin`/`End`, so all three call sites got it with no call-site change. Grace is `appStopAlarmGrace` = 180 s, deliberately the SAME constant and derivation as quiesce's — two windows over one alarm that disagreed would be a bug on whichever path used the shorter one. **It must never latch**, and unlike quiesce's loop `End()` runs ONLY on a restart that succeeded: (1) every failure path calls `ReleaseFailed`, which drops the entry IMMEDIATELY so the app alarms on the next scan; (2) `Begin` REPLACES the set (one marker file = one operation); (3) `appStopMaxHold` (6 h) caps an open-ended hold and logs at WARN. **Deliberately NOT persisted** — after a crash the guard holds nothing and a down app must alarm. `ReleaseFailed` drops the suppression and KEEPS the durable marker; the two are independent and a test pins that |
|
||||||
| `backup.ErrStartRefused` + `AppStopRecovery.Refused`/`Alarming()` (R-174, v0.191.0) | controller/internal/backup/appstop_marker.go | `errors.Is(err, ErrStartRefused)` / `() bool` | THE refusal-vs-failure split in the app-stop crash recovery | **A gated starter's refusal is NOT a restart failure.** `Recover`'s starter MUST be the gated `gatedAppStopStarter` (cmd/controller/main.go), never the raw `stacks.Manager` — that was the v0.189.0 defect, which started apps onto ABSENT drives at boot (R-171 one path over). A refusal goes to `Refused` (marker KEPT, silent), a real error to `Failed` (marker kept, ALARMS). Collapsing them routes a deliberate hold into `NotifyBackupFailed`, a customer-enabled type — the R-171 false alarm again. `main.go` must guard the notify with `Alarming()`, not `!= nil` |
|
| `backup.ErrStartRefused` + `AppStopRecovery.Refused`/`Alarming()` (R-174, v0.191.0) | controller/internal/backup/appstop_marker.go | `errors.Is(err, ErrStartRefused)` / `() bool` | THE refusal-vs-failure split in the app-stop crash recovery | **A gated starter's refusal is NOT a restart failure.** `Recover`'s starter MUST be the gated `gatedAppStopStarter` (cmd/controller/main.go), never the raw `stacks.Manager` — that was the v0.189.0 defect, which started apps onto ABSENT drives at boot (R-171 one path over). A refusal goes to `Refused` (marker KEPT, silent), a real error to `Failed` (marker kept, ALARMS). Collapsing them routes a deliberate hold into `NotifyBackupFailed`, a customer-enabled type — the R-171 false alarm again. `main.go` must guard the notify with `Alarming()`, not `!= nil` |
|
||||||
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll |
|
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never a global (`cfg.Paths.HDDPath` was deleted in R-492); a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** |
|
||||||
| `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix |
|
| `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix |
|
||||||
|
| `Manager.recordInstalledImages` (v0.233.0) | controller/internal/stacks/installed.go | `(name, stackDir string, env []string)` | writing down what each compose SERVICE is ACTUALLY running, into `app.yaml.installed_images` | Called after a successful compose up from `StartStack`/`RestartStack`/the deploy path/the end of the guarded update and of the undo (`UpdateStack` was deleted in v0.237.0). **Reads the CONTAINER, never `docker-compose.yml`** — that file is the value the syncer has already moved (spike §3: 25 minutes of disagreement). **A failed write NEVER refuses the action** — the deliberate OPPOSITE of `SetDesiredState`: intent refused, observation logged at ERROR. **NOT from `StartStackServices`** (the R-47 DB-only window would overwrite a complete record with a partial one). Skips the write when ref+digest are unchanged, and carries `at` forward so it means "running since". Its OWN seam (`installedExecFn`) with a **context + 30 s timeout** — the two existing exec helpers have neither |
|
||||||
|
| `Manager.SetPin` / `AdoptPins` / `RenderPlanFor` / `AppliedComposePath` (v0.235.0) | controller/internal/stacks/pin.go | `SetPin(name, stackDir, pin, composeSrc) error` | THE version freeze — `app.yaml.pinned_images` + the stored `applied-compose.yml` | **`PinnedImages` is INTENT, `InstalledImages` is an OBSERVATION — never feed one from the other** (the R-166 category error, one field over). Four writers only: deploy, the guarded update (via `advancePinToCatalog`, which advances the pin and re-renders BEFORE the pull, and REFUSES the update if the pin cannot be written; a failed pull puts the pin BACK via `SetPin`), the restore adapter (this is what closes R-441), and `AdoptPins`. `AdoptPins` reuses `observationCoversTemplate` — do NOT write a second completeness rule — and skips loudly rather than inventing a pin. Absent pin = pre-v0.235.0 behaviour |
|
||||||
|
| `Manager.UpdatePreflight` / `StartGuardedUpdate` / `RecoverUpdates` / `ResumeInterruptedUpdates` (v0.237.0) | controller/internal/stacks/update.go | `UpdatePreflight(name) *UpdateRefusal`; `StartGuardedUpdate(name) error` | THE update — refusals, then a 202 job with phases on `Stack.Updating/UpdatePhase/UpdateError` | **Never report an update complete before health is known (R-443).** Every cheap refusal runs BEFORE the intent write. Safety dump BEFORE the pin moves; pin BEFORE pull; pull failure → pin back; health failure → stop + HOLD, pin stays. Journal-before-mutate (`update-journal.json`); `RecoverUpdates` MUST run before the boot sweep and `ResumeInterruptedUpdates` AFTER `SetUpdateGuards`. Seams: `updateComposeFn`, `updateHealthFn`, `updateMemoryFn`, `updateDiskFreeFn`, `updateNowFn` (R-457: the age check and the test read ONE clock). Unwired guards ⇒ every update refused |
|
||||||
|
| `stacks.UpdateGuards` + `updateGuardsAdapter` (v0.237.0; tiers v0.239.0) | controller/internal/stacks/update.go, controller/cmd/controller/main.go | `HoldFor`, `Busy`, `RestorePoints`, `CanBackUp`, `BackupNow`, `SafetyDump`, `HoldAfterFailedUpdate` | the ONLY bridge from the update job to the backup side (stacks cannot import backup) | Wired by `stackMgr.SetUpdateGuards` — pinned by `TestSlice4_UpdateGuardsAreWiredAtStartup`. Add a guard HERE, never by importing backup into stacks |
|
||||||
|
| `backup.Manager.Tier2UnitRestorePoint` + `Tier2RestorePoint.ProvenCopyTime` (v0.237.0) | controller/internal/backup/update_guard.go | `(stack) (Tier2RestorePoint, error)` | "can this app be restored from Tier 2, and from when" — the predicate that gates BOTH the „Teljes visszaállítás" action and an update | **ONE predicate, two callers** (extracted from `buildAppBackupRows`, not copied). `CopyDate` is what the page NAMES (the package date, R-403); `ProvenCopyTime` is how OLD the data is — the last successful copy, because the manifest's `created_at` moves only when the DEFINITION changes (measured: a fresh dump under a 22-h-older manifest). Do not age a copy by `CopyDate`. **Since v0.239.0 the UPDATE no longer calls it directly** — it goes through `UpdateRestorePoints` (next row); the page still does |
|
||||||
|
| `backup.Manager.UpdateRestorePoints` + `CanBackUpApp` (v0.239.0, R-475) | controller/internal/backup/update_guard.go | `(ctx, stack, accept func(UpdateTierPoint) bool) (UpdateTierPoint, bool, []UpdateTierPoint)` | "which backup can this update lean on" — walks Tier 2, 1, 3 and returns the first copy `accept` admits | **The age rule lives in the caller's `accept`** (stacks' `freshRestorePoint`), so it is ONE rule for every tier. **The ORDER is `UpdateTierOrderFor` (v0.241.0, R-479): 2 → 3 → 1 for an app with classified binds (`DataOutsideUnit`), 2 → 1 → 3 otherwise; `UpdateCopyHolds` is the matching phrase for the hold.** Stops at the first accepted copy, so Tier 3 (restic, 15 s bound, unreachable = absent + WARN) is reached only when needed. Seams: `updateTier2PointFn`, `updateTier1PointsFn`, `updateOffsiteTimesFn` (v0.240.0: Tier 3 reads `OffsiteSnapshotTimes` — snapshots only, never the inventory's per-app `stats`). stacks adds R-478's rule: a copy older than `deployed_at` does not count (`usableRestorePoint`). Tier numbers are pinned equal across stacks/backup by `TestR475_TierConstantsAgree` |
|
||||||
|
| `backup.Manager.Tier2MirrorDirsForApp` / `RemoveTier2Mirrors` + `settings.DeleteAppBackupPrefs` (v0.240.0, R-474 / R-486) | controller/internal/backup/r474_remove_mirrors.go | `(stack) []string`; `(stack, dirs) []string` | deleting an app's backups on removal — the Tier-2 mirror lives on ANOTHER drive, outside RemoveStack's per-app base; the same dirs size the backup card (R-485) | Read the mirror dirs BEFORE the prefs are forgotten. Deletes only `<root>/backups/secondary/<stack>` for a known root; never `_shares`, never a path that merely cleans to it. **The Tier-2 RECORD goes only with `remove_backups`** (R-486) — a removal that keeps the backups must keep the record, or the mirror is unrestorable. Pinned by `TestR474_RemoveHandlerDeletesUnitMirrorAndPrefs` + `TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo` |
|
||||||
|
| `appbackup.dbTypeForImage` (R-484, v0.240.0) | controller/internal/appbackup/dbservices.go | `(image) (DBType, bool)` | the ONE place an image is judged a database — nightly dumps, pre-update safety dump, DB-only replay | Derived Postgres images (`postgis`, `pgvector`, `timescaledb`) are Postgres. A new engine image goes HERE and in `dbservices_test.go`'s table, never in a second matcher |
|
||||||
|
| `backup.Manager.HoldAfterFailedUpdate` / `RunAppBackupNow` / `WriteUpdateSafetyDump` / `UpdateBusy` (v0.237.0) | controller/internal/backup/update_guard.go | see file | the update's hold, per-app backup-now, safety dump, busy check | The hold is `settings.RestoreHold` with `Reason: update_failed` — SAME store and gate as R-379, never a second map. `RunAppBackupNow` composes the nightly legs for ONE app (admission, DB dump, volume dump, capture, Tier-2) — do not write a second backup orchestration. A successful unit restore lifts an UPDATE hold only. **`isHeld` is ALSO true while a guarded update is moving the app (`SetUpdatingCheck`, v0.238.1)** — found live: the periodic capture overwrote a primary unit during a health wait. **R-645: every night leg (DB dump, volume dump, capture, Tier-2) also skips an app whose pin is not what it runs (`versionSkip`, wired by `SetVersionCheck` → `stacks.Manager.PinNotRunning`); unknown never skips. The page line is `VersionSkipFor`** |
|
||||||
|
| `stacks.Manager.memoryVerdict` (v0.237.0) | controller/internal/stacks/deploy.go | `(newReq, newLimit, releasedReq, releasedLimit int) (refusal, warning string)` | the deploy's memory check, shared with the update | An update RELEASES the app's current request first. Deploy passes `0, 0` and is byte-identical in wording and log line |
|
||||||
|
| `Syncer.SetRenderPlanFn` + `renderSource` (v0.235.0) | controller/internal/sync/sync.go | `func(appName string) stacks.RenderPlan` | the catalog render table | **NIL-SAFE: no seam = copy verbatim = the old product.** Catalog images == pin → verbatim (fixes flow + self-healing, both deliberately kept); differ → the WHOLE stored definition, **never a ref substitution into a newer template** (`wger 2.6`). `.felhom.yml` always verbatim (R-458). The syncer must NEVER read app.yaml. Re-reads the applied file before writing it — a test caught it writing an empty compose over a live app |
|
||||||
|
| `Stack.CatalogImages` vs `Stack.TemplateImages` (v0.235.0) | controller/internal/stacks/manager.go | both `map[string]string` | badge input vs "what the next `up -d` gives this app" | **THE TRAP: same type, same shape, opposite meaning after the freeze.** `TemplateImages` reads the LIVE (possibly frozen) compose file; `CatalogImages` reads the syncer's clone. `web.compareInstalledToTemplate` MUST use `CatalogImages` or it answers „Naprakész" on exactly the apps that are behind, with every test green. Red-proved |
|
||||||
|
| `Manager.BackfillInstalledImages` (v0.234.0) | controller/internal/stacks/installed.go | `() int` | seeding `installed_images` for apps that have NO record — call ONCE at startup | Beside `BackfillDesiredState` in `cmd/controller/main.go`, after it and BEFORE the boot reconciler (pinned by an AST-walking test that asserts the ORDER). **READS only** — starts nothing, writes no compose file. **Never overwrites an existing record** (an app that has one is not even observed). **REFUSES a partial observation** (`observationCoversTemplate`): `web.compareInstalledToTemplate` reads a service-count mismatch as BEHIND, so seeding a degraded app from what is visible renders „Frissítés elérhető" over an app that is current. The bring-up paths may write a partial because they follow a SUCCESSFUL `up -d` where a gap is real news; a backfill meets any state and must be stricter |
|
||||||
|
| `stacks.ParseComposeImages` (v0.233.0) | controller/internal/stacks/installed.go | `(composePath string) (map[string]string, error)` | compose SERVICE name -> the image the FILE pins; feeds `Stack.TemplateImages` and the update badge | yaml.v3 `services:` MAP parse, never a line scan (same reason as `DBServiceNames`). An error means CANNOT-TELL — `ScanStacks` leaves `TemplateImages` nil and the badge renders NOTHING, never "current" |
|
||||||
|
| `stacks.Manager.AnyUpdating` / `SetSelfUpdatingCheck` + `selfupdate.Updater.SetAppUpdatingCheck` (v0.261.0, R-608) | controller/internal/stacks/update.go, internal/selfupdate/updater.go, wired in cmd/controller/main.go | `() bool` callbacks, both directions | THE two-way lock between the CONTROLLER's own swap and a guarded APP update | **Wire BOTH halves or neither** — they are wired together in `main.go`, the only place holding both objects; **`stacks` must never import `selfupdate`**. The app-update side is a sibling of `SetBackupRunningCheck` and is consulted in the SAME three places (dry run, `TriggerUpdate`, `maybeAutoUpdate`) — do not add a fourth pattern. **`AnyUpdating` MUST answer false for a HELD app** (`Stack.Updating` is cleared on done/failed/held): a latching gate would block the controller's own updates for ever, including the one that fixes the hold. Nil callbacks are SAFE and mean pre-v0.261.0 behaviour — never fail closed on an unwired gate |
|
||||||
|
| `stacks.CatalogOrder` / `CompareImageRefs` (v0.260.0, R-524) | controller/internal/stacks/updateorder.go | `(Stack) UpdateOrder` — Unknown/Current/Behind/**Ahead** | THE one "how does this app stand against the catalog?" verdict | **Both the badge AND `Manager.UpdatePreflight`'s `downgrade` refusal read it — never re-implement the comparison.** `web.compareInstalledToTemplate` is a thin wrapper. Ahead is NARROW: every differing service must be orderable AND newer, else Behind. Ordering is `util.Version.Compare` behind a tag normaliser (`X.Y`/`X.Y.Z`, optional `v`, suffix must be IDENTICAL on both sides) — **never add a second comparator**. Queries NO registry; absent record = Unknown, never „Naprakész" |
|
||||||
|
| `web.updateBadge` / `updateBadgeAt` / `Metadata.CatalogSince` + `CatalogSinceAge` (v0.233.0) | controller/internal/web/updatebadge.go, controller/internal/stacks/metadata.go | `(stacks.Stack) *MetaBadge` | THE "is this app current?" label — „Naprakész" / „Frissítés elérhető — N napja" | The SECOND `*MetaBadge` user the type was built for: existing `meta_badge` partial, **no new markup or CSS**. **NO RECORD RENDERS NOTHING — absent means UNKNOWN, never current** (R-166 applied to an observation; red-proved). **No version number reaches the customer** and **no registry is queried**. `catalog_since` is tolerant in the `lifecycle` style — absent/empty/malformed/**future** all degrade to a badge with no age + one WARN. LIMITATION: for the **10** floating pins (recounted 2026-09-21; the old "23" matched no definition the catalog supports) the ref can match while the image has moved — 6 of the 7 measurable ones HAVE moved — so those read „Naprakész" when they may not be |
|
||||||
| `Manager.logPostStartStatus` | controller/internal/stacks/manager.go | `(name, stackDir, env)` | Async post-start verification | compose up exits 0 on crash-loops; this is the detection. Goroutine + 3s, never blocks |
|
| `Manager.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 |
|
| `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 |
|
| `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 |
|
||||||
@@ -104,9 +190,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) |
|
| `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.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.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.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 |
|
| `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 |
|
| `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
|
### Secrets hygiene
|
||||||
|
|
||||||
@@ -123,6 +213,13 @@
|
|||||||
| `EncryptFile` / `DecryptFile` / `IsEncryptedFAB` | controller/internal/appexport/crypto.go | password-based file crypto | .fab export bundles | scrypt-derived AES+HMAC keys |
|
| `EncryptFile` / `DecryptFile` / `IsEncryptedFAB` | controller/internal/appexport/crypto.go | password-based file crypto | .fab export bundles | scrypt-derived AES+HMAC keys |
|
||||||
| `maskRepoURL` | controller/internal/sync/sync.go | `(url) string` | Logging git URLs | Strips embedded credentials |
|
| `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 |
|
| `metrics.RedactLine` | controller/internal/metrics/redact.go | `(s string) string` | ANY log line shipped off-box (issue context, log tails) | Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative |
|
||||||
|
| `settingsRetrievalPasswordRevealHandler` | controller/internal/web/handlers.go | `POST /settings/retrieval-password/reveal` | **THE PATTERN for showing a secret in the UI** — an XHR that returns only the value | **Never template a secret into a page and hide it with CSS.** `display:none` / `hidden` / `type="password"` stop a browser DRAWING the value; the plaintext is still in the response body, so a `curl` of the page returns it, and it reaches caches, history and any screen-share of the source. R-249 shipped exactly that for two months and was found by it landing in a transcript. The page carries a **boolean** (`HasRetrievalPassword`); the value comes from a POST (CSRF-covered, uncacheable) and the reveal is **logged as an act**. `escrow_handlers.go` states the same rule for R. **Test on the RESPONSE BODY** — a test asserting what the customer *sees* cannot see this class at all. **Both R-254 sites are now FIXED the same way** — `POST /apps/<slug>/initial-credentials/reveal` (re-reads the container, never a cached copy) and `POST /stacks/<name>/auto-field/reveal` (authorised on the field being a `type: secret` auto-field of that stack). **Per-secret, never one generic reveal-any-named-secret endpoint.** The PRE-DEPLOY hidden input is deliberate and untouched — a form must carry what it submits (README §318). Enforced by `scripts/secret_in_markup_gate.py`, whose measured blind spot (a secret under a neutral page-data key) is in its docstring; runtime body-assertion covers 4 of 27 pages — R-255. |
|
||||||
|
| `storedValuePresent` / `appLoginFields` | controller/internal/web/app_login.go | the settings tab's login panel and „Cím és kulcsok" rows (v0.319.0) | **A field the box holds no value for is a sentence, never an eye.** `storedValuePresent` is the ONE definition of „stored", read by the page AND by `appAutoFieldRevealHandler`'s 404 branch — so the page cannot offer a reveal the endpoint refuses. The page gets a boolean per login field, never the value (R-254/R-709). Pinned by `TestAppPage_NoStoredValueShowsSentence`. |
|
||||||
|
| `loginAnswerFor` | controller/internal/web/app_login.go | the settings tab's ONE login answer (v0.320.0) | `fields` · `initial` · the catalog's `app_info.login_kind` via `AppInfo.EffectiveLoginKind` (`first_visit` default; an unknown value reads as it). An infra stack with no login gets no panel. Pinned by `TestAppPage_EveryInstalledAppHasOneLoginAnswer`. |
|
||||||
|
| `filesButtonLink` / `mediaFolderCards` / `drivePresent` | controller/internal/web/datapath_card.go | the „Fájlok ↗" target and the media folder rows (v0.320.0) | **A folder whose drive is not bound into FileBrowser now is TEXT, never a link** — `drivePresent` reads `stacks.PresentMediaDrives` (the same gates `buildFileBrowserPaths` applies); tests seam it with `Server.presentDrivesFn`. Links only through `fileBrowserOpenLink` (R-1100; the folder path built by `fileBrowserLink`). Pinned by `TestAppPage_FilesButtonTarget`, `_UnknownSourceIsTextNotLink`. |
|
||||||
|
| `infraMetaIn` | controller/internal/web/inframeta.go | an infra stack's curated name/description in a language | The ONE place for both the `infraMeta` template func and the app page. |
|
||||||
|
| `Manager.FileBrowserStoredPasswordRefused` | controller/internal/stacks/filebrowser_password.go | is the stored FileBrowser password still valid (v0.321.0, R-1101) | TRUE only on FileBrowser's own 401/403 in the `generated` state; FileBrowser not answering is FALSE (an unproven refusal must not tell the household its password changed). 3 s bound. Web seam: `Server.fbStoredRefused`. |
|
||||||
|
| `Manager.ResetFileBrowserAdminPassword` | controller/internal/stacks/filebrowser_password.go | FileBrowser's admin password without the old one (v0.320.0) | Only in `operator`; holds `infraMu` (the base tick must not start the stopped server), and the web handler holds `fileBrowserMu` (the mount sync must not recreate it). Stop → `docker run --rm --volumes-from filebrowser … set -u admin,<pw>` → start (detached from the request's cancel) → verify → store encrypted. TRAP: the CLI opens the DB with no lock timeout — never run it against a running server. Seam: `fbSetPassword`. |
|
||||||
|
|
||||||
### Storage registry + mount detection
|
### Storage registry + mount detection
|
||||||
|
|
||||||
@@ -133,6 +230,10 @@
|
|||||||
| `registerStoragePath` | controller/internal/web/storage_handlers.go | `(where, label, setDefault) error` | Post-enroll registration | The single funnel used by init/attach/manual-add |
|
| `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.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`) |
|
| `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 | — |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
@@ -149,48 +250,65 @@
|
|||||||
| `agentapi.New` | controller/internal/agentapi/client.go | `(endpoint, token, fingerprintHex) (*Client, error)` | Pinned-TLS client to felhom-agent | Leaf-DER SHA-256 pin replaces chain verify; fails closed. Bounded idle pool (leak fix) |
|
| `agentapi.New` | controller/internal/agentapi/client.go | `(endpoint, token, fingerprintHex) (*Client, error)` | Pinned-TLS client to felhom-agent | Leaf-DER SHA-256 pin replaces chain verify; fails closed. Bounded idle pool (leak fix) |
|
||||||
| `Server.agentClient` | controller/internal/web/agent_disk_handlers.go | `() (*agentapi.Client, error)` | THE memoized client accessor | Always use this, never a fresh `agentapi.New` per request (§3) |
|
| `Server.agentClient` | controller/internal/web/agent_disk_handlers.go | `() (*agentapi.Client, error)` | THE memoized client accessor | Always use this, never a fresh `agentapi.New` per request (§3) |
|
||||||
| `Server.ProbeAgentChannel` | controller/internal/web/agent_disk_handlers.go | `(ctx) (constructionErr bool, err error)` | Channel health probe | Probes via the PRODUCTION client on purpose (self-heals, mirrors UI) |
|
| `Server.ProbeAgentChannel` | controller/internal/web/agent_disk_handlers.go | `(ctx) (constructionErr bool, err error)` | Channel health probe | Probes via the PRODUCTION client on purpose (self-heals, mirrors UI) |
|
||||||
| `Client.FormatDisk` | controller/internal/agentapi/client.go | `(ctx, device, fstype, confirmed, durableID)` | ONLY format/wipe entry | Sentinels: `ErrNeedsConfirmation` (user-data, resubmit confirmed+durableID) / `ErrFormatRefused` (system/backup — operator opsign only). Agent re-checks role server-side |
|
| `Client.FormatDisk` | controller/internal/agentapi/client.go | `(ctx, device, fstype, confirmed, durableID)` | ONLY format/wipe entry | Sentinels: `ErrNeedsConfirmation` (user-data, resubmit confirmed+durableID) / `ErrFormatRefused` (system/backup — operator opsign only) / `ErrNoDurableID` (v0.329.0, R-1069: a blank disk with no durable id, e.g. virtio without a serial — show `disk.err.no_durable_id`). Agent re-checks role server-side. `Client.Decommission` returns `*DecommissionRefusedError{Status 403|409}` for the agent's two policy refusals (R-1063) — ask the agent BEFORE marking anything |
|
||||||
| `Client.EjectDisk` / `Decommission` / `AssignDisk` / `GuestAttach` / `ListCandidates` | controller/internal/agentapi/client.go | disk lifecycle | Delegate ALL disk ops to agent | Controller holds no Proxmox creds — never shell out to disk tools in-guest |
|
| `Client.EjectDisk` / `Decommission` / `AssignDisk` / `GuestAttach` / `ListCandidates` | controller/internal/agentapi/client.go | disk lifecycle | Delegate ALL disk ops to agent | Controller holds no Proxmox creds — never shell out to disk tools in-guest |
|
||||||
| `Client.AddNetStorage/ListNetStorage/RemoveNetStorage` | controller/internal/agentapi/client.go | NAS mounts (A1) | Network storage | Password passes through to agent's 0600 cred file; controller NEVER persists it |
|
| `Client.AddNetStorage/ListNetStorage/RemoveNetStorage` | controller/internal/agentapi/client.go | NAS mounts (A1) | Network storage | Password passes through to agent's 0600 cred file; controller NEVER persists it |
|
||||||
| `agentapi.StatusError` | controller/internal/agentapi/client.go | `{Path, Code}` typed non-2xx GET error | Distinguishing HTTP statuses from transport errors (`errors.As`) | NEVER string-match agent error text — the capability probe keys on `Code==404` |
|
| `agentapi.StatusError` | controller/internal/agentapi/client.go | `{Path, Code}` typed non-2xx GET error | Distinguishing HTTP statuses from transport errors (`errors.As`) | NEVER string-match agent error text — the capability probe keys on `Code==404` |
|
||||||
| `SupportCache.Supports` / `Client.Supports` | controller/internal/agentapi/features.go | `(ctx, prober, Feature) SupportState` | Agent-capability gate for COUPLED features (route probe, TTL 5m) | 404 ⇒ No; transport/5xx ⇒ Unknown (NEVER refuse on Unknown). New coupled feature = new `featureProbes` row + gate call at the entry point + `MinAgent:` in the CHANGELOG header (publish-train-rules.md). Web layer: `Server.netFeatures` through the `netAgent` seam |
|
| `SupportCache.Supports` / `Client.Supports` | controller/internal/agentapi/features.go | `(ctx, prober, Feature) SupportState` | Agent-capability gate for COUPLED features (route probe, TTL 5m) | 404 ⇒ No; transport/5xx ⇒ Unknown (NEVER refuse on Unknown). New coupled feature = new `featureProbes` row + gate call at the entry point + `MinAgent:` in the CHANGELOG header (publish-train-rules.md). Web layer: `Server.netFeatures` through the `netAgent` seam |
|
||||||
| `agentapi.DiskVerdictFor` / `DiskVerdict.Label` / `DegradedAttributes` | controller/internal/agentapi/diskverdict.go | `(*SmartSummary) DiskVerdict` | THE shared disk-health verdict (card chip + 6h check) — v0.169.0 | Pure; nil/UNKNOWN → `DiskVerdictUnknown` (Nincs adat, NEVER alarms); percentage_used threshold is **≥90**. Feature-detects the agent's `DiskInfo.Smart` (nil = old agent). Do NOT recompute the verdict inline anywhere |
|
| `agentapi.DiskVerdictFor` / `DiskVerdict.Label` / `DegradedAttributes` / `UncorrectableSectors` / `DiskPrior` / `TemperatureFailC` | controller/internal/agentapi/diskverdict.go | `(*SmartSummary, DiskPrior) DiskVerdict` | THE shared disk-health verdict (card chip + hourly check) — v0.169.0, 14-row ladder v0.215.0 | Pure — no clock, no I/O; history arrives as `DiskPrior`. nil/UNKNOWN → `DiskVerdictUnknown` (Nincs adat, NEVER alarms, row 1 is first for that reason). **Never trust `smart_status.passed`**: attrs 187/197/198 carry `thresh: 0`, so it cannot fail on unreadable sectors. A zero `DiskPrior` is the fail-safe (first sighting can only reach Figyelmeztetés). **Four labels, no fifth** — predicted failure is „Hiba". Do NOT recompute the verdict inline anywhere, and do NOT re-literal 60 °C — use `TemperatureFailC` |
|
||||||
| `Server.resolveBackupTargetState` / `backupTargetView` | controller/internal/web/backup_target_offer.go | `(ctx)` → state / `*BackupTargetView` (nil = render nothing) | The whole-system backup-target answer: healthy · degraded-never-configured · **TargetAbsent** (configured, drive gone) · unknown | Test seams `Server.tiersFn` + `Server.disksFn` (nil → the real client). **`degradedMessageFor` is the ONE place that decides customer copy** — add a state there, never in a template. `backupTargetView` returns **nil** for healthy AND unknown so a template typo cannot decorate a working box. R-112: this state had NO consumer for two releases; the render is server-side on `backups.html`, and the seam test drives `backupsHandler` and asserts rendered HTML |
|
| `Server.resolveBackupTargetState` / `backupTargetView` | controller/internal/web/backup_target_offer.go | `(ctx)` → state / `*BackupTargetView` (nil = render nothing) | The whole-system backup-target answer: healthy · degraded-never-configured · **TargetAbsent** (configured, drive gone) · unknown | Test seams `Server.tiersFn` + `Server.disksFn` (nil → the real client). **`degradedMessageFor` is the ONE place that decides customer copy** — add a state there, never in a template. `backupTargetView` returns **nil** for healthy AND unknown so a template typo cannot decorate a working box. R-112: this state had NO consumer for two releases; the render is server-side on `backups.html`, and the seam test drives `backupsHandler` and asserts rendered HTML |
|
||||||
| `Server.cachedDisks` / `RunDiskHealthCheck` | controller/internal/web/disk_health.go | `(ctx)` | Card fetch (60s TTL) / the 6h degradation check | Card uses the 60s TTL cache (anti-smartctl-storm); the CHECK fetches FRESH (`fetchDisks`). Test seams: `Server.disksFn` (source) + `Server.diskNotifyFn` (sink). Baseline is in-memory (restart re-baselines) |
|
| `Server.cachedDisks` / `RunDiskHealthCheck` | controller/internal/web/disk_health.go | `(ctx)` | Card fetch (60s TTL) / the hourly degradation check | Card uses the 60s TTL cache (anti-smartctl-storm); the CHECK fetches FRESH (`fetchDisks`). Test seams: `Server.disksFn` (source) + `Server.diskNotifyFn(notify.DiskAlert)` (sink). State is PERSISTED (v0.215.0) — a restart no longer re-baselines |
|
||||||
|
| `diskAlertDecision` / `diskAlertKindFor` / `Server.priorFor` / `Server.cardPriorFor` | controller/internal/web/disk_health_state.go | pure + `(key) agentapi.DiskPrior` | Whether an observation emits, and which message shape | Compares against the **last ALERTED** verdict, not the last observed — that is what collapses a flap to one alert. Re-alert needs doubling **AND** 24h (an AND). **`priorFor` is for the CHECK, `cardPriorFor` for the CARD** — they differ by one observation and mixing them makes the chip read one level more severe than the email |
|
||||||
|
| `diskRecord` / `writeDiskState` / `Server.loadDiskStateLocked` | controller/internal/web/disk_health_state.go | `disk-health-state.json` in `cfg.Paths.DataDir` | Persisted per-disk observation + alert history | Atomic tmp+rename (the `selfupdate.SaveState` shape, copied not imported). Missing file = normal; corrupt = LOG and fall back to no-prior, **never fatal**. Written ONCE per check run. Keyed by `diskKey`. **One record per disk, NOT a sample series** — history is Phase 2/3 in `metrics.MetricsStore` |
|
||||||
|
|
||||||
### Notifications / hub sync
|
### Notifications / hub sync
|
||||||
|
|
||||||
| Symbol | File | Short signature | Use for | Gotchas |
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `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; hub only emails `warning`/`error` from this path |
|
| `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 |
|
| `Notifier.Notify*` convenience methods | controller/internal/notify/notifier.go | typed wrappers (backup/DB/storage/channel/DR…) | Standard events | Add a typed wrapper rather than raw PushEvent calls |
|
||||||
| `report.BuildReport` / `Pusher.Push` | controller/internal/report/builder.go + pusher.go | periodic hub report | Box→hub reporting | ACK carries `config_version` → `ConfigRefresher.Reconcile` |
|
| `report.BuildReport` / `Pusher.Push` | controller/internal/report/builder.go + pusher.go | periodic hub report | Box→hub reporting | ACK carries `config_version` → `ConfigRefresher.Reconcile` |
|
||||||
| `report.Trigger` (`NewTrigger`/`Fire`/`Run`) | controller/internal/report/trigger.go | `Fire()` after a hub-relevant user action | THE out-of-cycle report push (v0.139.0) — fire via `api.Router.reportPushNow` / `web.Server.reportTriggerNow`, both nil-safe | Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the `internal/sync` REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit |
|
| `report.Trigger` (`NewTrigger`/`Fire`/`Run`) | controller/internal/report/trigger.go | `Fire()` after a hub-relevant user action | THE out-of-cycle report push (v0.139.0) — fire via `api.Router.reportPushNow` / `web.Server.reportTriggerNow`, both nil-safe | Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the `internal/sync` REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit |
|
||||||
| `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 |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `offsiteapply.SettleProvider` / `SettleFunc` / `Bridge.AwaitSettle` / `ReconcileWhenSettled` (R-71a, v0.162.0) | controller/internal/offsiteapply/offsiteapply.go + seams.go | `SettleState() (version, floor string, updateRunning, floorKnown bool)` | THE settle-gate: defers the offsite one-time-password consume past a managed day-0 floor-update (the F10 race). Wire the `SettleFunc` adapter over `updater.GetFloor()`/`IsUpdateRunning()` — **the updater's knowledge is the ONE floor source; never fetch the floor a second way**. Gate ONLY the bridge goroutine, and only when an updater exists (nil `Settle` = reconcile immediately). Bounds `settlePoll`/`settleFloorSubBound`/`settleOverallBound`; the floor is in-memory (report-ACK-derived, ~5–10 s), NOT persisted → unknown until the first ACK on any restart. Inject `Now`/`Sleep` in tests (no real sleeps). B′: at/above-floor GOes on the first poll, zero wait. Do NOT touch the consume/persist order or the 404 contract — ordering only |
|
||||||
| `bootstrap.MaybeIngest` / `RefreshConfig` | controller/internal/bootstrap/bootstrap.go | bootstrap.json → controller.yaml | Day-0 + refresh | Overwrites controller.yaml, NEVER settings.json |
|
| `bootstrap.MaybeIngest` / `RefreshConfig` | controller/internal/bootstrap/bootstrap.go | bootstrap.json → controller.yaml | Day-0 + refresh | Overwrites controller.yaml, NEVER settings.json |
|
||||||
| `api.GracefulSelfRestart` | controller/internal/api/selfrestart.go | `(logger)` | Controller self-restart | Detached exit; bootstrap unit re-runs the image |
|
| `api.GracefulSelfRestart` | controller/internal/api/selfrestart.go | `(logger)` | Controller self-restart | Detached exit; bootstrap unit re-runs the image |
|
||||||
|
| `settings.NotificationGroups` + `NotificationGroupState` / `NotificationPrefs.GroupState` (R-388 slice 1, v0.309.0) | controller/internal/settings/notif_groups.go | `(enabled []string, key) "on"\|"off"\|"custom"` | THE `08` §8.1 group membership of every customer-switchable detector — the notification page's three switches | A group is a VIEW over `enabled_events`, never a stored field. A new detector with a page switch MUST join exactly one group (`TestR388_EveryPageSwitchIsInExactlyOneGroup` fails otherwise). The save rule lives in `Server.applyNotificationGroupSwitches`: only a MOVED group switch overrides the per-detector switches — an untouched „egyedi" group is saved back unchanged |
|
||||||
| `Settings.AddPendingEvent/DrainPendingEvents` | controller/internal/settings/settings.go | offline event queue | Events while hub unreachable | — |
|
| `Settings.AddPendingEvent/DrainPendingEvents` | controller/internal/settings/settings.go | offline event queue | Events while hub unreachable | — |
|
||||||
| `Manager.SetUnitNotify` + `UnitSpace` (R-158/R-167, v0.191.0) | controller/internal/backup/recovery_unit.go | `(func(stack string, err error, *UnitSpace))` | THE per-app Tier-1 recovery-unit capture failure alert — fires PER APP from `captureAllRecoveryUnits`, loop continues | **OPERATOR-TIER** (`recovery_unit_capture_failed`, in the hub's `operatorOnlyEvents`). **NEVER route it to `backup_failed`** — that type is in `DefaultEnabledEvents` and carries Hungarian copy, so it emails the CUSTOMER about a failure they cannot act on (D-c; R-158's own proposal said `backup_failed` and D-c overrides it). `UnitSpace` is **nil when the target filesystem is unreadable** and renders as *"unavailable"*, never as zeros — "0 GB free" and "we could not look" are opposite diagnoses. No controller-side cooldown: the hub owns it |
|
| `Manager.SetUnitNotify` + `UnitSpace` (R-158/R-167, v0.191.0) | controller/internal/backup/recovery_unit.go | `(func(stack string, err error, *UnitSpace))` | THE per-app Tier-1 recovery-unit capture failure alert — fires PER APP from `captureAllRecoveryUnits`, loop continues | **OPERATOR-TIER** (`recovery_unit_capture_failed`, in the hub's `operatorOnlyEvents`). **NEVER route it to `backup_failed`** — that type is in `DefaultEnabledEvents` and carries Hungarian copy, so it emails the CUSTOMER about a failure they cannot act on (D-c; R-158's own proposal said `backup_failed` and D-c overrides it). `UnitSpace` is **nil when the target filesystem is unreadable** and renders as *"unavailable"*, never as zeros — "0 GB free" and "we could not look" are opposite diagnoses. No controller-side cooldown: the hub owns it |
|
||||||
| `Manager.beginRunSummary` / `noteFailure` / `noteAttempted` / `emitRunSummary` / `SetRunSummaryNotify` (R-182, v0.194.0) | controller/internal/backup/runsummary.go | `(kind, runID) func()` / `(app, leg, reason)` / `(RunSummary)` | **THE per-run operator digest.** One `backup_run_failures` event at the end of a run listing every failed app, its leg and its reason — emitted ONLY when something failed | **The RECORD and the NOTIFICATION are different things and must stay so.** The per-app `recovery_unit_capture_failed` event is the record (hub routes it *record-only*, stored + logged every time); this digest is the notification. Before R-182 one event was both, and did neither: nine arrived, two were mailed, seven vanished before `LogNotification`. **Lifetime is `admissionSet`'s exactly** — absent collector means "no run in flight", never a stale answer. **A refusal is noted ONCE, inside `admitApp` where the verdict is taken**, not at the three legs that consult it: R-181's one-verdict-covers-all-three contract makes per-leg noting produce "2 of 1 apps failed". **Deliberate skips (disconnected / decommissioned) must NEVER be noted** — they have their own alert and a nightly digest about an unplugged drive is an ignored digest. **A clean run emits NOTHING**; silence is safe only because the hub's deadline check (`monitor/deadline.go:396,417`) raises a missed backup from report freshness independently — if that is ever weakened this design loses its footing. **`run_id` is unique per real run** (so the hub's 1-h cooldown cannot collapse a manual run into the nightly one) and **deliberately EMPTY on the periodic refresh sweep**, which must stay under that cooldown or a polled status page becomes a mail flood |
|
| `Manager.beginRunSummary` / `noteFailure` / `noteAttempted` / `emitRunSummary` / `SetRunSummaryNotify` (R-182, v0.194.0) | controller/internal/backup/runsummary.go | `(kind, runID) func()` / `(app, leg, reason)` / `(RunSummary)` | **THE per-run operator digest.** One `backup_run_failures` event at the end of a run listing every failed app, its leg and its reason — emitted ONLY when something failed | **The RECORD and the NOTIFICATION are different things and must stay so.** The per-app `recovery_unit_capture_failed` event is the record (hub routes it *record-only*, stored + logged every time); this digest is the notification. Before R-182 one event was both, and did neither: nine arrived, two were mailed, seven vanished before `LogNotification`. **Lifetime is `admissionSet`'s exactly** — absent collector means "no run in flight", never a stale answer. **A refusal is noted ONCE, inside `admitApp` where the verdict is taken**, not at the three legs that consult it: R-181's one-verdict-covers-all-three contract makes per-leg noting produce "2 of 1 apps failed". **Deliberate skips (disconnected / decommissioned) must NEVER be noted** — they have their own alert and a nightly digest about an unplugged drive is an ignored digest. **A clean run emits NOTHING**; silence is safe only because the hub's deadline check (`monitor/deadline.go:396,417`) raises a missed backup from report freshness independently — if that is ever weakened this design loses its footing. **`run_id` is unique per real run** (so the hub's 1-h cooldown cannot collapse a manual run into the nightly one) and **deliberately EMPTY on the periodic refresh sweep**, which must stay under that cooldown or a polled status page becomes a mail flood |
|
||||||
| `Manager.admitApp` / `beginAdmissionRun` / `decideAdmission` / `estimatedWriteBytes` (R-181, v0.193.0) | controller/internal/backup/admission.go | `(stackName) bool` / `() func()` | **THE reserve gate. Call it before ANY per-app backup write** — one verdict per app per run, covering the DB dump, the volume dump and the unit capture (all three write under one per-app root) | **Decided LAZILY at the app's first write, never once at run start** — app A's dump can put app B under the reserve, so a run-start verdict reads a disk that no longer exists. **Never re-decided between an app's own legs**: that is exactly the split R-181 closed (bulk written, capture refused). **Reset per run** via the closer `beginAdmissionRun` returns. **Must sit ahead of `DumpAppVolumesSafe`**, which stops the stack as its first act — a refusal decided inside it has already bounced the app. Fires **exactly one** `unitNotify` per refused app per run. Nil admission set (periodic status refresh) → decides fresh, which is still once per app per sweep. Wiring pinned by an **AST walk** in `TestAdmission_IsWiredIntoEveryProductionWriteLeg`, not `strings.Contains` |
|
| `Manager.admitApp` / `beginAdmissionRun` / `decideAdmission` / `estimatedWriteBytes` (R-181, v0.193.0) | controller/internal/backup/admission.go | `(stackName) bool` / `() func()` | **THE reserve gate. Call it before ANY per-app backup write** — one verdict per app per run, covering the DB dump, the volume dump and the unit capture (all three write under one per-app root) | **Decided LAZILY at the app's first write, never once at run start** — app A's dump can put app B under the reserve, so a run-start verdict reads a disk that no longer exists. **Never re-decided between an app's own legs**: that is exactly the split R-181 closed (bulk written, capture refused). **Reset per run** via the closer `beginAdmissionRun` returns. **Must sit ahead of `DumpAppVolumesSafe`**, which stops the stack as its first act — a refusal decided inside it has already bounced the app. Fires **exactly one** `unitNotify` per refused app per run. Nil admission set (periodic status refresh) → decides fresh, which is still once per app per sweep. Wiring pinned by an **AST walk** in `TestAdmission_IsWiredIntoEveryProductionWriteLeg`, not `strings.Contains` |
|
||||||
| `Manager.floorVerdict` + `FloorUsedPercent`/`FloorFreeGiB` / `ErrCaptureFloor` / `floorReason` (R-165 B2 v0.192.0, size term R-181 v0.193.0) | controller/internal/backup/recovery_unit.go | `(*UnitSpace, estGiB float64) (*UnitSpace, floorReason)` | The pure two-question predicate behind `admitApp`: is the filesystem already below the reserve (`floorHeadroom`), and would THIS app's write take it below (`floorSize`)? | **Headroom is about the FILESYSTEM, never a per-unit cap** — a size cap is R-163 rebuilt inside one volume; the size term bounds the *delta*, not the unit. **REFUSES, never deletes:** nothing here is generational (a unit is one fixed path per app, a DB dump one fixed name), so pruning could only destroy a DIFFERENT app's only local copy — **never repurpose `pruneStalePrimaryDirs`**, which removes ORPHANED dirs from an app that moved drives and has no notion of age. Two terms (97% / 1 GiB) in `fillwatch`'s shape, deliberately BEYOND its critical band (95% / 2 GiB) so the customer is always warned first — pinned by `TestFloorSitsBelowTheCriticalWarningBand`. **`estGiB == 0` degrades to headroom-only on purpose** — refusing an app with no history makes the FIRST backup the one that can never happen. A nil reading neither refuses nor warns (§8.4). Inject `unitSpaceFn` in tests rather than manufacturing occupancy on a real disk |
|
| `Manager.floorVerdict` + `FloorUsedPercent`/`FloorFreeGiB` / `ErrCaptureFloor` / `floorReason` (R-165 B2 v0.192.0, size term R-181 v0.193.0) | controller/internal/backup/recovery_unit.go | `(*UnitSpace, estGiB float64) (*UnitSpace, floorReason)` | The pure two-question predicate behind `admitApp`: is the filesystem already below the reserve (`floorHeadroom`), and would THIS app's write take it below (`floorSize`)? | **Headroom is about the FILESYSTEM, never a per-unit cap** — a size cap is R-163 rebuilt inside one volume; the size term bounds the *delta*, not the unit. **REFUSES, never deletes:** nothing here is generational (a unit is one fixed path per app, a DB dump one fixed name), so pruning could only destroy a DIFFERENT app's only local copy — **never repurpose `pruneStalePrimaryDirs`**, which removes ORPHANED dirs from an app that moved drives and has no notion of age. Two terms (97% / 1 GiB) in `fillwatch`'s shape, deliberately BEYOND its critical band (95% / 2 GiB) so the customer is always warned first — pinned by `TestFloorSitsBelowTheCriticalWarningBand`. **`estGiB == 0` degrades to headroom-only on purpose** — refusing an app with no history makes the FIRST backup the one that can never happen. A nil reading neither refuses nor warns (§8.4). Inject `unitSpaceFn` in tests rather than manufacturing occupancy on a real disk |
|
||||||
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space |
|
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space. **Both languages (v0.309.0, R-521 F7):** the sentence is the bundle's `event.fill.*`, the household's rendering rides as `message_customer` via `Notifier.PushEventHousehold` (`SetLang`; `SetExtra` takes the language); a built-in area carries a `LabelKey`, a household's own drive label is used as written. Hungarian pinned byte-for-byte by `TestMessageHungarianWireText` (v0.309.0 dropped the formal „Kérjük," — R-516) |
|
||||||
|
|
||||||
### Scheduler / time / UI
|
### Scheduler / time / UI
|
||||||
|
|
||||||
| Symbol | File | Short signature | Use for | Gotchas |
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
|
| `Scheduler.RunNow` / `scheduler.OnDemand` | controller/internal/scheduler/scheduler.go | `RunNow(name) error`; `OnDemand(ctx) bool` | Run a registered job once now (operator action `run_job`, decision 185) | Refuses unknown and already-running jobs; a job that checks due-ness reads `OnDemand(ctx)` to skip that check on demand |
|
||||||
|
| `report.OperatorActions` | controller/internal/report/opactions.go | `Reconcile(reply)` → results in the next report | Hub-requested actions in the report reply — the `selftail.go` pattern plus results | The action table is CLOSED and pinned by `TestOpActions_ClosedList`; once per id in memory; every entry must be safe to repeat |
|
||||||
|
| `web.sessionFingerprint` / `loadSessions` / `saveSessionsLocked` / `saveSessionsRevokingLocked` | controller/internal/web/session_store.go | dashboard sessions on disk (R-35) | Any session lookup | The map is keyed by sha256(cookie), NEVER the cookie — hash with `sessionFingerprint` first; a path that ENDS sessions uses the revoking save |
|
||||||
|
| `web.endEverySession` / `rotateGateKey` (R-976, v0.324.0) | controller/internal/web/auth.go · controller/internal/web/setup_gate.go | `(why string)` / `()` | A dashboard password change or claim/reset — ANY act that must end every way in | Ends the dashboard sessions, every family session (`family.Store.EndAll`) AND every setup-gate pass / family app cookie (the gate key is replaced; a failed save REMOVES the old key file so a restart cannot bring old passes back). Call this, never `invalidateAllSessions` alone — that ends only the dashboard's. Pinned by `TestR976_PasswordChangeEndsFamilyAndGateSessions` / `TestR976_ClaimResetEndsFamilyAndGateSessions` |
|
||||||
|
| `web.reserveLoginAttempt` / `reserveAttempt` / `shareReserve` / `familyReserve` / `allowResetRequest` (R-983, v0.324.0; R-1111) | controller/internal/web/auth.go · share.go · family_gate.go · claim.go | `(key) → may go ahead` | Any limiter in front of a slow check (bcrypt, a hub call) | **Check and count in ONE locked step, BEFORE the slow work** — a count taken after the compare lets N parallel tries all pass the check (the shape R-983 removed from the dashboard and family sign-ins, and R-1111 from the share password and the escrow re-auth). A success clears the count afterwards. `reserveAttempt(mu, &map, key)` is the one body of the 5-per-minute password limiters — a new one passes its OWN map to it, never a third shape; the escrow re-auth counts on the login map (`reserveLoginAttempt`), the share password on its own (`shareReserve`). The compare goes through `comparePassword`, so `passwordCompare` (the slow-compare seam) reaches all three |
|
||||||
| `Scheduler.Every` / `Daily` | controller/internal/scheduler/scheduler.go | `(name, interval/"HH:MM", fn)` | ALL background jobs | Daily is Europe/Budapest, DST-safe (`nextDailyRun` avoids Add(24h)); register in main.go block (§5) |
|
| `Scheduler.Every` / `Daily` | controller/internal/scheduler/scheduler.go | `(name, interval/"HH:MM", fn)` | ALL background jobs | Daily is Europe/Budapest, DST-safe (`nextDailyRun` avoids Add(24h)); register in main.go block (§5) |
|
||||||
| `Scheduler.UpdateDaily` | controller/internal/scheduler/scheduler.go | `(name, "HH:MM") bool` | Retime a daily job at runtime (no restart) | Per-job buffered `resched` chan + select case in `runDailyJob`; false (WARN) on invalid time / unknown-or-non-daily name; read `Schedule` under the mutex in the loop |
|
| `Scheduler.UpdateDaily` | controller/internal/scheduler/scheduler.go | `(name, "HH:MM") bool` | Retime a daily job at runtime (no restart) | Per-job buffered `resched` chan + select case in `runDailyJob`; false (WARN) on invalid time / unknown-or-non-daily name; read `Schedule` under the mutex in the loop |
|
||||||
| `backupwindow.*` (LegTimes / GateWindow / EffectiveWindow / ParseHHMM / FmtHHMM / Valid) | controller/internal/backupwindow/backupwindow.go | pure `string`↔`int` | Backup-window arithmetic (v0.168.0) | Offsets (W+60m/W+105m, gate W+2h..W+6h) are CONSTANTS — derived, never stored; wrap-safe modulo 1440; `EffectiveWindow(settings, yaml)` = settings>yaml>"02:30" |
|
| `backupwindow.*` (LegTimes / GateWindow / EffectiveWindow / ParseHHMM / FmtHHMM / Valid) | controller/internal/backupwindow/backupwindow.go | pure `string`↔`int` | Backup-window arithmetic (v0.168.0) | Offsets (W+60m/W+105m, gate W+2h..W+6h) are CONSTANTS — derived, never stored; wrap-safe modulo 1440; `EffectiveWindow(settings, yaml)` = settings>yaml>"02:30" |
|
||||||
| `getBudapestLocation` | controller/internal/scheduler/scheduler.go | `() *time.Location` | Local-time math | web has its own `getTimezone` (§6); quiesce has its own `budapestLocation` (window gate) — 3rd copy, see §6 |
|
| `getBudapestLocation` | controller/internal/scheduler/scheduler.go | `() *time.Location` | Local-time math | web has its own `getTimezone` (§6); quiesce has its own `budapestLocation` (window gate) — 3rd copy, see §6 |
|
||||||
| `Server.templateFuncMap` | controller/internal/web/funcmap.go | template.FuncMap | ALL template functions | `stateColor` outputs v2 suffixes `run/progress/warn/neutral/off`; stopped = NEUTRAL not red (operator-approved); `stateLabel` copy is frozen byte-identical (unit-tested) |
|
| `Server.templateFuncMap` | controller/internal/web/funcmap.go | template.FuncMap | ALL template functions | `stateColor` outputs v2 suffixes `run/progress/warn/neutral/off`; stopped = NEUTRAL not red (operator-approved); `stateLabel` copy is frozen byte-identical (unit-tested) |
|
||||||
|
| `i18n.Bundle` (`Shared` / `Msg` / `Plural` / `Expand`) | controller/internal/i18n/i18n.go | `Msg(lang, key) string` | ALL customer copy that has been converted (v0.247.0) | hu.json is authoritative; a key missing in en shows Hungarian (counted), never blank; `Expand` runs BEFORE template parse — never add a runtime `T` func (escaping would change Hungarian bytes). A template edit to converted copy goes in hu.json + en.json, then `TestI18nParity` must still pass |
|
||||||
|
| `huTemplateSource` (test) | controller/internal/web/i18n_wiring_test.go | `(name) ([]byte, error)` | A test that asserts a template's Hungarian WORDING | Reads the expansion, not the raw file — since slice 1 the wording lives in hu.json. Never use `templateFS.ReadFile` for a wording assertion |
|
||||||
|
| `Server.langFor` / `templatesFor` / `addLanguageData` | controller/internal/web/server.go + i18n_web.go | request → lang → template set | Rendering a page in the household's language | Only `executeTemplate` is language-aware; `s.tmpl` is the Hungarian set; Go-side titles via `data["TitleKey"]` |
|
||||||
|
| `bootstrap.Unconfigured` / `WaitForConfig` / `WaitingHandler` (v0.308.0, R-554) | controller/internal/bootstrap/waiting.go | `WaitForConfig(ctx, ln, configPath, cfg, logger, pull, every, version) *config.Config` | The ONLY unconfigured-box path: the bilingual waiting page + a retry of `MaybeIngest` every minute; returns the configured config (nil on stop) | Replaces the deleted setup wizard. `/api/health` stays 200 while waiting; a nil listener still retries |
|
||||||
|
| `i18n.FormatDateTime` / `FormatDate` / `FormatMonthDayTime` / `FormatDecimal` / `LocalizeDecimals` | controller/internal/i18n/format.go | `(lang, …) string` | EVERY date or decimal number shown on the dashboard (R-562, v0.308.0): hu „2026. 10. 10. 15:04" + decimal comma, en „2026-10-10 15:04" + point | Pass the time already in Budapest; `LocalizeDecimals` is only for a size another package already formatted („1.5 GB") — new code calls `FormatDecimal`. Templates use the bound names in `formatFuncs(lang)` (funcmap.go: `fmtTime`, `fmtTimeStr`, `fmtGB`, `fmtBytes`, `numText`, …); Go page data uses `fmtLocalTime(lang, t)` / `fmtRFC3339Local(lang, s)` / `fmtSizeBytes(lang, n)`; page scripts use `fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` (layout.html). Never a `"2006-01-02 15:04"` literal for display. CSS widths keep `printf "%.1f"` |
|
||||||
| `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) |
|
| `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) |
|
||||||
| `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` |
|
| `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` |
|
||||||
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input; setup wizard has its OWN csrf (§6) |
|
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input |
|
||||||
| `LogBuffer` + `Lines(maxBytes)` | controller/internal/web/logbuffer.go | ring buffer io.Writer | In-memory log capture for the debug UI + the report `controller_log_tail` source | v0.116.0: ALWAYS constructed (any logging.level) — the logger is `MultiWriter(LevelFilterWriter(stdout, level), ring)`; `Lines` drops OLDEST to honor the byte budget |
|
| `LogBuffer` + `Lines(maxBytes)` | controller/internal/web/logbuffer.go | ring buffer io.Writer | In-memory log capture for the debug UI + the report `controller_log_tail` source | v0.116.0: ALWAYS constructed (any logging.level) — the logger is `MultiWriter(LevelFilterWriter(stdout, level), ring)`; `Lines` drops OLDEST to honor the byte budget |
|
||||||
| `logx.Debugf/Infof/Warnf/Errorf` | controller/internal/logx/logx.go | `(l *log.Logger, format, args…)` | ALL NEW leveled log lines (the v0.116.0 sweep standard) | routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3) |
|
| `logx.Debugf/Infof/Warnf/Errorf` | controller/internal/logx/logx.go | `(l *log.Logger, format, args…)` | ALL NEW leveled log lines (the v0.116.0 sweep standard) | routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3) |
|
||||||
| `web.LevelFilterWriter` | controller/internal/web/levelfilter.go | `NewLevelFilterWriter(w, minLevel)` | stdout leveling under the always-on ring | untagged lines parse INFO; always reports full length written |
|
| `web.LevelFilterWriter` | controller/internal/web/levelfilter.go | `NewLevelFilterWriter(w, minLevel)` | stdout leveling under the always-on ring | untagged lines parse INFO; always reports full length written |
|
||||||
@@ -214,6 +332,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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 3. Dangerous lookalikes — do NOT reuse
|
||||||
|
|
||||||
@@ -225,9 +344,11 @@
|
|||||||
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` |
|
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` |
|
||||||
| `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) |
|
| `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) |
|
||||||
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
|
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
|
||||||
| `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else |
|
| `config.LoadPermissive` | Skips validation — first boot only (customer.id/domain may be unset until `bootstrap.WaitForConfig` gets the configuration, R-554) | `config.Load` everywhere else |
|
||||||
| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
|
| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
|
||||||
|
| any settings getter/setter or helper that ends in one (`m.note`, `boxLang`) INSIDE a `settings.UpdateOffboxStatus`/`UpdateCrossDriveStatus` callback (R-578) | The callback runs under the settings WRITE lock and `sync.RWMutex` is not reentrant — it DEADLOCKS holding the lock and wedges settings.json for the box | Resolve the value BEFORE the callback (`lang := m.boxLang()`); `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` (controller/internal/settings/r578_callback_lock_gate_test.go) convicts it in every package |
|
||||||
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
|
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
|
||||||
|
| `backup.WholeOnTier` vs `UpdateCopyHolds` (R-659) | `UpdateCopyHolds` says what a copy HOLDS; it does not say the restore will ACCEPT it — a file app's second-drive copy holds the files and its unit restore still refuses | `WholeOnTier` (asks `DeclaredDriveFileLegs`, the refusal's own predicate) before a sentence names a copy as a way back |
|
||||||
|
|
||||||
## 4. Seams & interfaces (testing + cross-repo)
|
## 4. Seams & interfaces (testing + cross-repo)
|
||||||
|
|
||||||
@@ -235,10 +356,14 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
|
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
|
||||||
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
|
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
|
||||||
|
| `healthSettle` / `healthInterval` / `healthTimeout` (package vars, R-488) | controller/internal/backup/restore.go | production 3 s / 5 s / 90 s — `waitForHealthy`'s clock, used by every restore path | shortened ONCE in controller/internal/backup/main_test.go `TestMain` (0 / 2 ms / 50 ms; the package went 444 s → ~2 s); `TestR488_HealthWaitDefaultsAreProduction` pins the production values. A new real-clock wait in this package goes behind the same kind of var, not a per-test sleep. Also: `newOffboxManager` installs a failing `SetOffboxSSH` fake, so no backup test spawns a real `ssh` |
|
||||||
| `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) |
|
| `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) |
|
||||||
| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated |
|
| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated |
|
||||||
| `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) |
|
| `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) |
|
||||||
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
@@ -247,6 +372,7 @@
|
|||||||
| `Server.guestGatewayFn` / `guestNetFn` (func seams) | controller/internal/web/server.go (fields) + sharing_handlers.go accessors | nil → `stackMgr.GuestGateway` / `stackMgr.GuestNetSnapshot` | network_card_test.go — the counted-fn freshness test (2 renders ⇒ 2 resolves) is what stops anyone memoizing a DHCP lease; the Hálózati név row is gated on `smb.Enabled` (red-proven: gate dropped ⇒ \\FELHOM rendered while samba is down) |
|
| `Server.guestGatewayFn` / `guestNetFn` (func seams) | controller/internal/web/server.go (fields) + sharing_handlers.go accessors | nil → `stackMgr.GuestGateway` / `stackMgr.GuestNetSnapshot` | network_card_test.go — the counted-fn freshness test (2 renders ⇒ 2 resolves) is what stops anyone memoizing a DHCP lease; the Hálózati név row is gated on `smb.Enabled` (red-proven: gate dropped ⇒ \\FELHOM rendered while samba is down) |
|
||||||
| `sambaEnsureState.consumeIfRunning()` | controller/internal/web/samba_ensure_job.go | serve-once `snapshot()` for terminal `running` only | `/sharing/status` carries a job EDGE (`phase`) and a service LEVEL (`running`) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers `phase=="running"` with `location.reload()`, so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). `failed`/`needs_password`/in-flight are NOT consumed |
|
| `sambaEnsureState.consumeIfRunning()` | controller/internal/web/samba_ensure_job.go | serve-once `snapshot()` for terminal `running` only | `/sharing/status` carries a job EDGE (`phase`) and a service LEVEL (`running`) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers `phase=="running"` with `location.reload()`, so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). `failed`/`needs_password`/in-flight are NOT consumed |
|
||||||
| `infra.SambaHostInterface` | controller/internal/infra/samba.go | the guest LAN nic name (`eth0`) | Single source for smb.conf's `interfaces =`, the container's `FELHOM_IFACE`, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart |
|
| `infra.SambaHostInterface` | controller/internal/infra/samba.go | the guest LAN nic name (`eth0`) | Single source for smb.conf's `interfaces =`, the container's `FELHOM_IFACE`, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart |
|
||||||
|
| `Manager.installedExecFn` (func seam, v0.233.0) | controller/internal/stacks/manager.go (field) + installed.go | nil → `defaultExecRunner` (`exec.CommandContext`, 30 s) | the installed-images recorder's ONLY process boundary — `docker compose ps` + `docker inspect` + `docker image inspect` | Deliberately NOT `execFn`/`composeExecCustomEnv`: those are already load-bearing elsewhere and **neither carries a context or a timeout**, and a bookkeeping read must never wedge a lifecycle action. Injected in controller/internal/stacks/installed_test.go, which FAILS the test on an argv it does not recognise, so a change to the commands issued cannot pass silently. The wiring test stubs the compose binary on PATH instead, so `RestartStack` is reached for real |
|
||||||
| `Manager.sambaImgFn` (func seam) | controller/internal/stacks/manager.go (field) + samba.go | nil → `docker image inspect <infra.SambaImage>` | drives the 4b card's pulling-vs-starting decision, which MUST be taken before `compose up` (afterwards the image is always present) |
|
| `Manager.sambaImgFn` (func seam) | controller/internal/stacks/manager.go (field) + samba.go | nil → `docker image inspect <infra.SambaImage>` | drives the 4b card's pulling-vs-starting decision, which MUST be taken before `compose up` (afterwards the image is always present) |
|
||||||
| `Manager.offboxStreamRunner` + `SetOffboxStreamRunner` | controller/internal/backup/offbox_progress.go | nil → `defaultOffboxStreamRunner` (real `restic`, stdout scanned live) | streaming sibling of `offboxRunner`; fakes emit canned `--json` status lines in offbox_progress_test.go, so the whole progress path runs with no restic, network or repo |
|
| `Manager.offboxStreamRunner` + `SetOffboxStreamRunner` | controller/internal/backup/offbox_progress.go | nil → `defaultOffboxStreamRunner` (real `restic`, stdout scanned live) | streaming sibling of `offboxRunner`; fakes emit canned `--json` status lines in offbox_progress_test.go, so the whole progress path runs with no restic, network or repo |
|
||||||
| `Manager.offsitePreDumpFn` + `SetOffsitePreDumpFn` (R-44, v0.148.0) | controller/internal/backup/offbox_reconstitute.go (seam) + offbox.go (call site) | nil → `runDBDumpsInternal` under the SAME running flag | THE dumps-before-capture ordering seam. Extracted so the order is observable without Docker/restic — an ordering guarantee no test can see is one refactor from silently reverting to the DIAG-immich-restore-2026-07-19 behaviour. Red-proof: moving the capture first yields `[capture dump]` |
|
| `Manager.offsitePreDumpFn` + `SetOffsitePreDumpFn` (R-44, v0.148.0) | controller/internal/backup/offbox_reconstitute.go (seam) + offbox.go (call site) | nil → `runDBDumpsInternal` under the SAME running flag | THE dumps-before-capture ordering seam. Extracted so the order is observable without Docker/restic — an ordering guarantee no test can see is one refactor from silently reverting to the DIAG-immich-restore-2026-07-19 behaviour. Red-proof: moving the capture first yields `[capture dump]` |
|
||||||
@@ -259,9 +385,16 @@
|
|||||||
| `Manager.execFn` (func seam) + `restartPolicyLookup` / `inspectRestartPolicyFn` (R-51, v0.156.0) | controller/internal/stacks/manager.go | nil → real `exec.Command` / `docker inspect -f {{.HostConfig.RestartPolicy.Name}}` | `scriptedDocker` in controller/internal/stacks/degraded_test.go drives the WHOLE production path (docker ps → aggregateState → docker inspect) — an aggregateState-only test proves the function, not the caller. Policy answers are cached per container+state and pruned to the live `docker ps` set; a FAILED inspect is deliberately never cached (a hiccup must not pin a container to "unknown") and reads as SUPERVISED, i.e. fail-closed — the opposite of `IsDownState`'s fail-open, because there the state is ambiguous while here a member is known dead |
|
| `Manager.execFn` (func seam) + `restartPolicyLookup` / `inspectRestartPolicyFn` (R-51, v0.156.0) | controller/internal/stacks/manager.go | nil → real `exec.Command` / `docker inspect -f {{.HostConfig.RestartPolicy.Name}}` | `scriptedDocker` in controller/internal/stacks/degraded_test.go drives the WHOLE production path (docker ps → aggregateState → docker inspect) — an aggregateState-only test proves the function, not the caller. Policy answers are cached per container+state and pruned to the live `docker ps` set; a FAILED inspect is deliberately never cached (a hiccup must not pin a container to "unknown") and reads as SUPERVISED, i.e. fail-closed — the opposite of `IsDownState`'s fail-open, because there the state is ambiguous while here a member is known dead |
|
||||||
| `bootrecon.StackProvider` (R-52, v0.156.0) | controller/internal/bootrecon/bootrecon.go | `*stacks.Manager` (GetStacks/StartStack/RefreshStatus) | `fakeStacks` counts StartStack per app; the load-bearing assertion is the NEGATIVE — a zero-container stack (a UI Stop = `compose down` = containers removed) must record **0** starts, while a boot orphan (containers present, Exited) records exactly 1. `Reconciler.sleep` is injected so the 30 s gap costs nothing |
|
| `bootrecon.StackProvider` (R-52, v0.156.0) | controller/internal/bootrecon/bootrecon.go | `*stacks.Manager` (GetStacks/StartStack/RefreshStatus) | `fakeStacks` counts StartStack per app; the load-bearing assertion is the NEGATIVE — a zero-container stack (a UI Stop = `compose down` = containers removed) must record **0** starts, while a boot orphan (containers present, Exited) records exactly 1. `Reconciler.sleep` is injected so the 30 s gap costs nothing |
|
||||||
| `bootReconcileFn` + `runBootReconcile` (package-main seam, v0.156.0) | controller/cmd/controller/main.go | `bootrecon.New(mgr, logger).Run` | controller/cmd/controller/bootrecon_wiring_test.go. **The wiring itself is asserted by an AST walk** over `func main()`, not a `strings.Contains` — the substring version passed its own red-proof because a commented-out call still contains the string. Comments are not callers |
|
| `bootReconcileFn` + `runBootReconcile` (package-main seam, v0.156.0) | controller/cmd/controller/main.go | `bootrecon.New(mgr, logger).Run` | controller/cmd/controller/bootrecon_wiring_test.go. **The wiring itself is asserted by an AST walk** over `func main()`, not a `strings.Contains` — the substring version passed its own red-proof because a commented-out call still contains the string. Comments are not callers |
|
||||||
| `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`.** 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 |
|
| `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 |
|
| `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go |
|
||||||
| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
|
| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
|
||||||
|
| `util.ParseVersionPre` | controller/internal/util/version.go | the same comparator, reading `X.Y.Z-rc.N` as BELOW `X.Y.Z` (v0.314.0, R-1053) — ONLY for „is this box below the floor?" | never use it to pick a tag to offer: the button must not see a test build |
|
||||||
|
| `upwatch.Source` / `upwatch.Explainer` / `Watcher.Now` (v0.342.0, the upload watch) | controller/internal/upwatch/ | `DockerAPI` (Engine API on the socket, exact counters) / the qBittorrent, Transmission and Plex clients | `fakeSource`, `fakeApp` and a minute clock in watch_test.go drive the WHOLE judge path; the app clients are tested against httptest servers that behave as measured on 9202. **Exact counters come from the Engine API, never from `docker stats` text** (three significant digits) |
|
||||||
|
| `upwatch.QBittorrent.SetUploadLimit` + `ApplyQBLimit` | controller/internal/upwatch/qblimit.go | the one writer of qBittorrent's global upload limit | compare against the value qBittorrent STORED (KiB/s rounding), never the requested one; a limit the household touched is theirs for good |
|
||||||
|
| `upwatch.Service.Measured` / `RefreshMeasured` / `MeasuredLine` + `agentapi.Client.LineTest` (R-1247) | controller/internal/upwatch/service.go · controller/internal/agentapi/linetest.go · cmd/controller/upwatch_wiring.go `agentLineTest` | GET /host/line-test (agent ≥ 0.164.0): the host's weekly iperf3 line test | **qBittorrent's limit reads `MeasuredLine` only — never `Line()`** (the push floor is a far lower bound; D41). `Line()` = the higher of the two, for the watch. Read at start + every 15 min; a failed read KEEPS the last value; 404 / present:false = not measured. Pinned by `TestService_QBLimitFromTheTestedLineOnly` |
|
||||||
|
| `mediawire.PlexURL` | controller/internal/mediawire/plexaddr.go | Plex's base URL BY ADDRESS, resolved at each call (v0.314.0, R-1060) — every controller call to Plex | Plex refuses the Host name `plex` as non-local (401); never cache the address |
|
||||||
|
| `stacks.GateCapture` / `stacks.ReadGateAtBackup` | controller/internal/stacks/restore_gate.go | the setup gate a per-app backup records, and the restore's read of it (v0.314.0, R-1057) | the unit `app.yaml` key is `setup_gate:` — the live file's keys, so either reads the same |
|
||||||
| `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
@@ -288,6 +421,8 @@ Cross-repo edges:
|
|||||||
## 5. Extension points (where new features plug in)
|
## 5. Extension points (where new features plug in)
|
||||||
|
|
||||||
- **New storage web endpoint**: switch in `ServeStorageAPI` (controller/internal/web/storage_handlers.go); disk ops in `ServeDiskAPI` (controller/internal/web/agent_disk_handlers.go); backup in `ServeBackupAPI`; export in `ServeExportAPI`; debug in `handleDebugAPI` (debug-mode gated).
|
- **New storage web endpoint**: switch in `ServeStorageAPI` (controller/internal/web/storage_handlers.go); disk ops in `ServeDiskAPI` (controller/internal/web/agent_disk_handlers.go); backup in `ServeBackupAPI`; export in `ServeExportAPI`; debug in `handleDebugAPI` (debug-mode gated).
|
||||||
|
- **A gate that must REPORT and never REFUSE (R-404, 2026-09-01)**: register it in `controller/scripts/controller_gates.py` with the fifth field `blocking=False` — its exit code then never reaches the runner's verdict and it prints as `ADVISORY`. `golden_notice.py` is the only one, and `test_golden_notice.py` asserts it stays the only one. **Use this instead of giving a notice the power to refuse a push**: the golden notice must fire at the moment a release is committed, when the golden legitimately cannot exist yet. A cross-repo gate IMPORTS the sibling's script (see `golden_notice.py` loading `felhom.eu/scripts/golden_currency_gate.py`) and never copies it — a copy recreates the drift these gates exist to detect.
|
||||||
|
- **New debug-page control (R-400, v0.228.0)**: the control in `controller/internal/web/templates/debug.html` AND the `subpath == "…"` case in `controller/internal/web/handler_debug.go` are ONE change — `controller/scripts/debug_route_gate.py` fails on either half alone, in both directions (a reference with no case, and a case with no reference). Keep the dispatcher's EXACT-match switch with its `http.NotFound` default; a prefix match is what would have hidden the original defect. Handler shape: `debugTriggerDBDump` for a fire-and-forget trigger, `debugRunIntegrityCheck` for one the operator pressed to learn an ANSWER (synchronous). **Before this gate the page referenced 24 addresses and 17 were answered, and three of the seven dead ones fetched on page LOAD** — those panels were permanently blank on the page an operator opens when something is already wrong.
|
||||||
- **New REST endpoint**: path dispatch in `Router.ServeHTTP` (controller/internal/api/router.go); use `writeJSON` + `limitBody`.
|
- **New REST endpoint**: path dispatch in `Router.ServeHTTP` (controller/internal/api/router.go); use `writeJSON` + `limitBody`.
|
||||||
- **New background job**: `sched.Every`/`sched.Daily` registration block in controller/cmd/controller/main.go.
|
- **New background job**: `sched.Every`/`sched.Daily` registration block in controller/cmd/controller/main.go.
|
||||||
- **New template function**: `Server.templateFuncMap` (controller/internal/web/funcmap.go) — obey v2 state-suffix vocabulary.
|
- **New template function**: `Server.templateFuncMap` (controller/internal/web/funcmap.go) — obey v2 state-suffix vocabulary.
|
||||||
@@ -300,21 +435,24 @@ Cross-repo edges:
|
|||||||
- **New hub event**: typed `Notify*` wrapper on Notifier + hub allowlist entry (cross-repo).
|
- **New hub event**: typed `Notify*` wrapper on Notifier + hub allowlist entry (cross-repo).
|
||||||
- **New app integration**: `integrations.Manager.RegisterHandler` with `IntegrationKey(provider, target)`.
|
- **New app integration**: `integrations.Manager.RegisterHandler` with `IntegrationKey(provider, target)`.
|
||||||
- **New startup self-check**: append check fn in `selftest.Run` (controller/internal/selftest/selftest.go).
|
- **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.
|
- **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)
|
## 6. Known duplication (observed — NOT fixed)
|
||||||
|
|
||||||
| Duplication | Locations |
|
| Duplication | Locations |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Atomic write ×4 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/setup/handlers.go `atomicWriteFile`; controller/internal/api/router.go `writeConfig0600` |
|
| Atomic write ×3 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/api/router.go `writeConfig0600` (the setup wizard's `atomicWriteFile` went with it, R-554) |
|
||||||
| String truncation ×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` |
|
| String truncation ×4 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based HEAD, widely used) and `tailStr` (rune-safe TAIL, v0.294.0 R-864 — use it for a command's stderr: the error is printed last); controller/internal/backup/offbox.go `truncate` |
|
||||||
| humanizeBytes ×3 | controller/internal/appbackup/appdata.go `HumanizeBytes` (canonical) + private twin; controller/internal/appexport/estimate.go `humanizeBytes`; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge) |
|
| humanizeBytes ×3 | controller/internal/appbackup/appdata.go `HumanizeBytes` (canonical) + private twin; controller/internal/appexport/estimate.go `humanizeBytes`; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge) |
|
||||||
| copyFile ×2 | controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go |
|
| copyFile ×2 | controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go |
|
||||||
| dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` |
|
| dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` |
|
||||||
| timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) |
|
| timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) |
|
||||||
| CSRF ×2 | controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled |
|
| Budapest timezone loader ×4 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` vs controller/internal/api/kept_install.go `budapest` (v0.277.0, the kept choice's dates) (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
|
||||||
| Budapest timezone loader ×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) |
|
|
||||||
| JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` |
|
| JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` |
|
||||||
| Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) |
|
| Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) |
|
||||||
| DB wait/import ×2 | controller/internal/appbackup/dbdump.go `waitDBReady`/`ImportDump` vs controller/internal/appexport/restore.go `waitForDB`/`importDBDump` |
|
| DB wait/import ×2 | controller/internal/appbackup/dbdump.go `waitDBReady`/`ImportDump` vs controller/internal/appexport/restore.go `waitForDB`/`importDBDump` |
|
||||||
|
| qBittorrent Web API login ×2 | controller/internal/mediawire/mediawire.go `qbLogin` (writes the drive wiring) vs controller/internal/upwatch/explain.go `QBittorrent.login` (reads every minute; keeps its session cookie, which mediawire's per-run login does not need) |
|
||||||
| compose exec ×2 | controller/internal/stacks/manager.go `composeExecCustomEnv` vs controller/internal/appexport/restore.go `composeExecEnv` (the latter has ctx+timeout; the former has the userdata belt) |
|
| compose exec ×2 | controller/internal/stacks/manager.go `composeExecCustomEnv` vs controller/internal/appexport/restore.go `composeExecEnv` (the latter has ctx+timeout; the former has the userdata belt) |
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
# REPORT — controller v0.215.0 → v0.216.0: disk-health severity ladder, escalation, and the alert that never sent
|
||||||
|
|
||||||
|
**Date:** 2026-08-14 · **Task class:** Implementation · **Repos touched:** `felhom-controller` (code),
|
||||||
|
`felhom.eu` (documentation only — no hub code, no manifest bump, no ArgoCD sync)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Confirmed baselines used (as read at the start of the run)
|
||||||
|
|
||||||
|
| Repo | `main` @ start | Version | → Shipped |
|
||||||
|
|------|----------------|---------|-----------|
|
||||||
|
| felhom-controller | `3e3ee94b7bbe6b66663c468e22aa86616365a45a` | v0.214.0 | **v0.215.0**, then **v0.216.0** (a defect found live in v0.215.0 — §14) |
|
||||||
|
| felhom.eu | `e0b56c976f8e4a7352309d78754fec448dd55f99` | n/a (docs only) | n/a |
|
||||||
|
|
||||||
|
Both trees verified clean (`git status --porcelain` empty, `HEAD == origin/main`) before any build.
|
||||||
|
The controller hash matched the spec's stated baseline exactly. `MinAgent` stays **0.129.0** — no
|
||||||
|
agent change; every field read here has been on the wire since agent v0.94.0/v0.95.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Files created / modified
|
||||||
|
|
||||||
|
**felhom-controller**
|
||||||
|
- `controller/internal/agentapi/diskverdict.go` — modified (14-row ladder, `DiskPrior`, `UncorrectableSectors`, `TemperatureFailC`)
|
||||||
|
- `controller/internal/agentapi/diskverdict_test.go` — modified
|
||||||
|
- `controller/internal/agentapi/diskverdict_ladder_test.go` — **created**
|
||||||
|
- `controller/internal/notify/notifier.go` — modified (severity, `DiskAlert`, `DiskAlertKind`, `Severity()`, 5 message shapes)
|
||||||
|
- `controller/internal/notify/disk_health_test.go` — rewritten
|
||||||
|
- `controller/internal/web/disk_health_state.go` — **created** (persistence + decision)
|
||||||
|
- `controller/internal/web/disk_health.go` — modified
|
||||||
|
- `controller/internal/web/disk_health_test.go` — rewritten
|
||||||
|
- `controller/internal/web/server.go` — modified (seam signature)
|
||||||
|
- `controller/cmd/controller/main.go` — modified (6h → 1h)
|
||||||
|
- *(v0.216.0)* `controller/internal/web/disk_health.go` + `disk_health_test.go` — the R-335 dedup fix and its test
|
||||||
|
- `CHANGELOG.md`, `CONTEXT.md`, `REUSE.md`, `controller/README.md`, `REPORT.md`
|
||||||
|
|
||||||
|
**felhom.eu** (documentation only)
|
||||||
|
- `documentation/audits/DIAG-smart-passed-trap-2026-08-14.md` — **created**
|
||||||
|
- `documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json` — **created** (raw `smartctl -a -j`, verbatim)
|
||||||
|
- `documentation/audits/fixtures/smartd-history-sdg-2026-08-14.txt` — **created** (406 `smartd` journal lines)
|
||||||
|
- `documentation/architecture/00-capability-map.md`, `documentation/backlog/ROADMAP.md`, `documentation/backlog/OPEN-ITEMS.md` — modified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Commits pushed to `main`
|
||||||
|
|
||||||
|
| Repo | Hash | What |
|
||||||
|
|------|------|------|
|
||||||
|
| felhom.eu | `848de81` | Part 0 — fixtures + findings doc |
|
||||||
|
| felhom-controller | `bb50e12` | Parts 1–3 — ladder, severity, persisted state + tests |
|
||||||
|
| felhom-controller | `c24f192` | Group L strengthened to two post-restart checks |
|
||||||
|
| felhom-controller | `34d83f5` | Part 4 — cadence 6h → 1h (measured) |
|
||||||
|
| felhom-controller | `8144a70` | Part 5 — CHANGELOG / CONTEXT / README / REUSE |
|
||||||
|
| felhom.eu | `767960b` | Part 5 — capability map, ROADMAP, register rows R-328…R-334 |
|
||||||
|
| felhom-controller | `90f2545` | **v0.216.0** — R-335, one physical disk evaluated once per run |
|
||||||
|
| felhom.eu | `fa4748d` | R-335 register row |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Per-test results — all twelve groups
|
||||||
|
|
||||||
|
| Group | Scenario | Test | Result |
|
||||||
|
|-------|----------|------|--------|
|
||||||
|
| A | real drive, 2nd observation | `TestDiskCheck_RealDrive_HibaAndOneCriticalEvent` + `TestLadder_RealDrive_ReachesHiba` | **PASS** |
|
||||||
|
| B | the transient that cleared | `TestDiskCheck_FirstSightingIsWarnOnly` | **PASS** |
|
||||||
|
| C | sustained → Hiba | `TestDiskLadder_SustainDrivesTheEscalation` + `TestLadder_SustainIsWhatFires` | **PASS** |
|
||||||
|
| D | recovered, silent | `TestDiskCheck_RecoveryIsSilentAndClearsState` | **PASS** |
|
||||||
|
| E | flap damping | `TestDiskCheck_FlapDamping` | **PASS** |
|
||||||
|
| F | escalation beats damping | `TestDiskCheck_EscalationBeatsDamping` | **PASS** |
|
||||||
|
| G | still getting worse | `TestDiskCheck_RealertWhenStillWorsening` | **PASS** |
|
||||||
|
| H | below both bars | `TestDiskCheck_NoRealertBelowBothBars` | **PASS** |
|
||||||
|
| I | heat | `TestLadder_Temperature` + `TestDiskCheck_TemperatureShape` | **PASS** |
|
||||||
|
| J | no data never alarms | `TestDiskCheck_UnknownNeverAlarmsNorErasesPrior` + `TestLadder_UnknownNeverAlarms` | **PASS** |
|
||||||
|
| K | severity routes | `TestNotifyDiskHealthDegraded_SeverityRoutes` | **PASS** |
|
||||||
|
| L | state survives restart (seam) | `TestDiskCheck_StateSurvivesRestart_ProductionPath` | **PASS** |
|
||||||
|
|
||||||
|
Supporting: `TestLadder_CountBackstopBoundary`, `TestLadder_ZeroPriorIsFailSafe`,
|
||||||
|
`TestUncorrectableSectors`, `TestDegradedAttributes_NamesFailCounters`,
|
||||||
|
`TestNotifyDiskHealthDegraded_{WarnShape,FailShapes,CopyDiscipline}`,
|
||||||
|
`TestDiskAlertDecision_Table`, `TestDiskState_CorruptFileFallsBackToNoPrior`,
|
||||||
|
`TestDiskCheck_DisappearedDiskIsForgotten`, `TestDiskCheck_UnreachableAgentIsInert` — all PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Red-proof outcomes — all twelve, individually
|
||||||
|
|
||||||
|
Each mutation was applied by script, **asserted present in the source before the run** (the harness
|
||||||
|
aborts with `MUTATION-NOT-APPLIED` if the target text is absent), the named test run, and the file
|
||||||
|
reverted with `git checkout --`. The tree was confirmed clean after the sweep.
|
||||||
|
|
||||||
|
| # | Mutation applied | Target test | Outcome |
|
||||||
|
|---|------------------|-------------|---------|
|
||||||
|
| A | remove truth-table row 6 (the sustain rule) | `TestDiskCheck_RealDrive_HibaAndOneCriticalEvent` | **RED-PROOF PASSED — FINDING, see below** |
|
||||||
|
| B | make row 9 return `Fail` | `TestDiskCheck_FirstSightingIsWarnOnly` | failed as required |
|
||||||
|
| C | pass a zero `DiskPrior` in `RunDiskHealthCheck` | `TestDiskLadder_SustainDrivesTheEscalation` | failed as required |
|
||||||
|
| D | let Rendben fall through the silence guard | `TestDiskCheck_RecoveryIsSilentAndClearsState` | failed as required |
|
||||||
|
| E | compare against last **observed** verdict, not last **alerted** | `TestDiskCheck_FlapDamping` | failed as required |
|
||||||
|
| F | let damping cover escalations | `TestDiskCheck_EscalationBeatsDamping` | failed as required |
|
||||||
|
| G | remove the re-alert branch | `TestDiskCheck_RealertWhenStillWorsening` | failed as required |
|
||||||
|
| H | make cooldown/doubling an **OR** instead of an AND | `TestDiskCheck_NoRealertBelowBothBars` | failed as required |
|
||||||
|
| I | remove truth-table rows 3 **and** 13 | `TestLadder_Temperature`, `TestDiskCheck_TemperatureShape` | failed as required |
|
||||||
|
| J | let UNKNOWN delete the prior record | `TestDiskCheck_UnknownNeverAlarmsNorErasesPrior` | failed as required |
|
||||||
|
| K | restore `severity := "warn"` | `TestNotifyDiskHealthDegraded_SeverityRoutes` | failed as required |
|
||||||
|
| L | skip loading the persisted state | `TestDiskCheck_StateSurvivesRestart_ProductionPath` | failed as required |
|
||||||
|
|
||||||
|
### A thirteenth red-proof, added after the deploy (R-335)
|
||||||
|
|
||||||
|
| # | Mutation applied | Target test | Outcome |
|
||||||
|
|---|------------------|-------------|---------|
|
||||||
|
| M | delete the `if seen[key] { continue }` dedup guard | `TestDiskCheck_SameDiskTwiceIsEvaluatedOnce` | failed as required |
|
||||||
|
|
||||||
|
Observed failure: `first sighting of an aliased disk must be silent, got 1: [{Label:felhom-backup … Kind:2 Sectors:8}]`
|
||||||
|
— i.e. `Kind:2` is `DiskAlertFailSectors`, a **Hiba on a first sighting of 8 sectors**. Reverted.
|
||||||
|
|
||||||
|
### FINDING — red-proof A passed, and it is the spec's mutation that is at fault, not the code
|
||||||
|
|
||||||
|
The task specified group A's red-proof as *"remove truth-table row 6 → verdict is Warn"*. **That
|
||||||
|
mutation cannot fail a test built on the real drive's values**: the real drive carries **352**
|
||||||
|
unreadable sectors, so with row 6 deleted it still reaches Hiba via **row 8** (count ≥ 64). The test
|
||||||
|
correctly stayed green, so the mutation proves nothing about row 6.
|
||||||
|
|
||||||
|
This was anticipated while writing the tests and is documented in the test's own comment rather than
|
||||||
|
discovered afterwards. **Row 6 is genuinely pinned**, by two tests that hold the counters at **8**
|
||||||
|
(far below the 64 backstop) and vary *only* the prior:
|
||||||
|
|
||||||
|
- `TestLadder_SustainIsWhatFires` (agentapi) — same `SmartSummary`, `DiskPrior{}` → Warn,
|
||||||
|
`DiskPrior{SawUncorrectable:true}` → Fail.
|
||||||
|
- `TestDiskLadder_SustainDrivesTheEscalation` (web) — the event-level twin.
|
||||||
|
|
||||||
|
Both were run under the row-6-deleted mutation and **both failed**, as recorded:
|
||||||
|
`SAME 8 sectors, now sustained = 2 (Figyelmeztetés), want Fail/Hiba` and
|
||||||
|
`severity = "warning", want critical` / `chip = "Figyelmeztetés", want Hiba`. So the invariant is
|
||||||
|
covered; only the spec's chosen mutation was invalid.
|
||||||
|
|
||||||
|
### A second finding, from building red-proof L
|
||||||
|
|
||||||
|
The first version of the Group L seam test ran **one** check after the restart and **passed under the
|
||||||
|
mutation** — because a controller that has forgotten its state is also silent on its first check. The
|
||||||
|
test was strengthened to run **two** checks (commit `c24f192`), after which the mutation fails. This
|
||||||
|
is the exact shape §10 warns about, caught by running the red-proof rather than assuming it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Test count and suite state
|
||||||
|
|
||||||
|
- **Before:** 1391 test functions (at `3e3ee94`) · **After:** 1414 (+23)
|
||||||
|
- `go build ./... && go vet ./... && go test ./...` — **all green**, no failures, no skips introduced.
|
||||||
|
- `python3 controller/scripts/controller_gates.py` — **all 11 gates OK.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cadence measurement (Part 4)
|
||||||
|
|
||||||
|
Measured on **demo-hp** (Tier 0, disposable), through `fetchDisks`' real path — the agent local API
|
||||||
|
`GET /disks`, not the 60 s card cache. Ten consecutive calls, all **HTTP 200**:
|
||||||
|
|
||||||
|
```
|
||||||
|
0.840558 0.817000 0.815783 0.831992 0.809066
|
||||||
|
0.832899 0.824788 0.804886 0.812539 0.833547 (seconds)
|
||||||
|
```
|
||||||
|
|
||||||
|
| min | median | max | disk count |
|
||||||
|
|-----|--------|-----|------------|
|
||||||
|
| **0.804886 s** | **0.820894 s** | **0.840558 s** | **3 physical rows** across 2 devices (SanDisk X600 M.2 SATA SSD; Toshiba KXG50PNV1T02 NVMe, counted twice as `c11-scratch` + `felhom-backup`) |
|
||||||
|
|
||||||
|
**Branch taken: median < 5 s → `6*time.Hour` → `1*time.Hour`.** The median is ~6× under the bar. The
|
||||||
|
detection argument is the real one: the observed benign excursion lasted about **one hour**, so a
|
||||||
|
6-hourly sampler can land either side of it and then catch the terminal run half a day late.
|
||||||
|
|
||||||
|
No spin-up signature appeared in the timings (uniform ~0.82 s; demo-hp is all-flash), so the
|
||||||
|
measurement did not suggest the spun-down-drive concern. That question is recorded as an Observation
|
||||||
|
below and deliberately **not acted on**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Live validation
|
||||||
|
|
||||||
|
Deployed to **demo-hp guest 9201** via the bootstrap path (`docker pull` →
|
||||||
|
`/etc/felhom-controller-image` → `systemctl restart felhom-controller-bootstrap.service`).
|
||||||
|
|
||||||
|
```
|
||||||
|
gitea.dooplex.hu/admin/felhom-controller:0.215.0 Up 19 seconds (healthy) # 06:23Z
|
||||||
|
gitea.dooplex.hu/admin/felhom-controller:0.216.0 Up 6 seconds (healthy) # after the R-335 fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Leg 1 — no over-correction (the load-bearing check)
|
||||||
|
|
||||||
|
Method: **endpoint-level** — authenticated `GET /dashboard` on the real controller
|
||||||
|
(`https://felhom.enkisfelhom.hu/dashboard`, HTTP 200, 44 036 bytes), i.e. the exact endpoint the UI
|
||||||
|
invokes; only rendering is skipped. No browser is available on DooPlex.
|
||||||
|
|
||||||
|
Card contents, parsed from the response body:
|
||||||
|
|
||||||
|
| Disk | Chip | Class | Temp |
|
||||||
|
|------|------|-------|------|
|
||||||
|
| KXG50PNV1T02 NVMe TOSHIBA 1024GB | **Rendben** | `state-text-run` | 53 °C |
|
||||||
|
| KXG50PNV1T02 NVMe TOSHIBA 1024GB | **Rendben** | `state-text-run` | 53 °C |
|
||||||
|
| SanDisk X600 M.2 2280 SATA 128GB | **Rendben** | `state-text-run` | 44 °C |
|
||||||
|
|
||||||
|
`Figyelmeztetés` = 0, `Hiba` = 0, `Nincs adat` = 0, `state-text-warn` = 0, `state-text-crit` = 0.
|
||||||
|
**No healthy disk was over-corrected.**
|
||||||
|
|
||||||
|
**Positive observable, at deploy:**
|
||||||
|
`[INFO] [scheduler] Registered periodic job: disk-health-check (every 1h0m0s)` — the new cadence is
|
||||||
|
in force, not merely compiled.
|
||||||
|
|
||||||
|
**Positive observable, per cycle** — two full hourly cycles observed after the deploy, from the
|
||||||
|
container log:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026/08/14 06:23:13 [INFO] [scheduler] Registered periodic job: disk-health-check (every 1h0m0s)
|
||||||
|
2026/08/14 07:23:13 [INFO] [scheduler] Running job: disk-health-check
|
||||||
|
2026/08/14 07:23:14 [INFO] [web] disk-health check complete: 3 disk(s) evaluated, 0 alert(s)
|
||||||
|
2026/08/14 07:23:14 [INFO] [scheduler] Job disk-health-check completed (took 849ms)
|
||||||
|
2026/08/14 08:23:13 [INFO] [scheduler] Running job: disk-health-check
|
||||||
|
2026/08/14 08:23:14 [INFO] [web] disk-health check complete: 3 disk(s) evaluated, 0 alert(s)
|
||||||
|
2026/08/14 08:23:14 [INFO] [scheduler] Job disk-health-check completed (took 843ms)
|
||||||
|
```
|
||||||
|
|
||||||
|
`grep -c disk_health_degraded` over the whole container log: **0**. Both cycles ran (849 ms / 843 ms,
|
||||||
|
matching the §7 measurement), evaluated every disk, and emitted nothing. **Zero alerts from a check
|
||||||
|
that demonstrably ran** — not silence.
|
||||||
|
|
||||||
|
Persisted state written by the first cycle (`/opt/docker/felhom-controller/data/disk-health-state.json`,
|
||||||
|
428 bytes, on the `felhom-controller-data` docker volume, so it survives container recreation):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"version": 1, "disks": {
|
||||||
|
"path:/var/lib/vz": {"verdict": 1, "saw_uncorrectable": false, ...},
|
||||||
|
"uuid:91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae": {"verdict": 1, "saw_uncorrectable": false, ...}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`verdict: 1` is `DiskVerdictOK` for both, `saw_uncorrectable: false`, never alerted.
|
||||||
|
|
||||||
|
**Reading those two artefacts against each other is what exposed R-335** — see §14.
|
||||||
|
|
||||||
|
### Leg 2 — the severity fix arrives (the point of the task)
|
||||||
|
|
||||||
|
Two synthetic `disk_health_degraded` events pushed for customer `demo-hp` **through the real hub
|
||||||
|
event endpoint** (`POST https://hub.felhom.eu/api/v1/event`), from the guest's own controller using
|
||||||
|
its own hub credentials — the genuine controller→hub path, not a hand-crafted operator call. Both
|
||||||
|
returned `HTTP 200 {"ok":true}`. The hub DB was read with its `-wal` and `-shm` copied alongside
|
||||||
|
`hub.db` (a `hub.db`-only read is stale).
|
||||||
|
|
||||||
|
**As STORED by the hub (`events`):**
|
||||||
|
|
||||||
|
| id | severity pushed | severity STORED |
|
||||||
|
|----|-----------------|-----------------|
|
||||||
|
| 2964 | `warning` | **`warning`** |
|
||||||
|
| 2965 | `warn` | **`info`** ← coerced |
|
||||||
|
|
||||||
|
**`notification_log` rows for those two events:**
|
||||||
|
|
||||||
|
| id | event_type | severity | channel | status | error |
|
||||||
|
|----|-----------|----------|---------|--------|-------|
|
||||||
|
| 689 | `disk_health_degraded` | `warning` | `operator` | **`sent`** | *(none)* |
|
||||||
|
| — | *(the `"warn"` push)* | — | — | **NO ROW EXISTS** | — |
|
||||||
|
|
||||||
|
**That pair is the proof.** The identical event, differing only in one word of the severity string,
|
||||||
|
is the difference between *delivered to the operator* and *stored as an informational notice and
|
||||||
|
delivered to nobody*. This is the first time this leg has been observed end to end.
|
||||||
|
|
||||||
|
Only the operator leg fired because **demo-hp has no `customer_notifications` row at all** (no
|
||||||
|
customer email, no `enabled_events`), so no customer row was possible for either push — verified
|
||||||
|
directly, not assumed. **One real email was sent to the operator**, as the task anticipated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. NOT yet live-validated — stated explicitly
|
||||||
|
|
||||||
|
**The Fail-from-counters path has never fired on real hardware.** Everything in §4/§5 exercises it
|
||||||
|
against the committed fixture's values in unit tests only. The live legs above prove the *negative*
|
||||||
|
(no false alert on three healthy disks) and the *severity wire* (end to end, through the hub) — they
|
||||||
|
do **not** prove a live disk reaching Hiba. The fixture tests must not be read as a live proof.
|
||||||
|
|
||||||
|
Tracked as **R-332 (WATCHING)**. Closing condition: a live disk reaching Hiba from counters, or a
|
||||||
|
deliberate injection through the real pipeline (agent `/disks` → controller check → hub event) — not
|
||||||
|
a hand-set verdict.
|
||||||
|
|
||||||
|
**One item originally listed here has since been proven live** and is no longer part of this gap: the
|
||||||
|
**persisted state surviving a controller restart**. The v0.215.0 → v0.216.0 redeploy destroyed and
|
||||||
|
rebuilt the container, and the new one read back a `changed_at` written by the previous version rather
|
||||||
|
than re-baselining — see §14. What remains unproven is the stronger half: an already-**alerted** disk
|
||||||
|
not re-alerting after a restart, which needs a disk that has actually alerted. The drive that produced the fixture lives in DooPlex, which is Tier 2 and never a
|
||||||
|
drill target; the demo boxes are all-flash and healthy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Teardown
|
||||||
|
|
||||||
|
**This run provisioned nothing** — no VM, no guest, no hub customer record, no storage. Nothing was
|
||||||
|
formatted, mounted, unmounted, repaired or written on any monitored disk; the only write is the
|
||||||
|
controller's own `disk-health-state.json` inside its data volume.
|
||||||
|
|
||||||
|
Disposition of what the run did create:
|
||||||
|
|
||||||
|
- **Two synthetic hub events (`events` id 2964, 2965) and one `notification_log` row (id 689)** on the
|
||||||
|
live hub. **Left in place deliberately.** Both messages are self-labelling
|
||||||
|
(`"R-328 severity probe (…) - synthetic, no real disk fault"`), and deleting rows from the
|
||||||
|
production hub DB is a riskier act than leaving two clearly-marked probe rows. Named here so they
|
||||||
|
are not mistaken later for a real disk fault on demo-hp.
|
||||||
|
- **One real operator email** resulting from row 689.
|
||||||
|
- A local copy of `hub.db`/`-wal`/`-shm` in the session scratchpad only (not committed, not exported).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Register rows
|
||||||
|
|
||||||
|
| Row | State | Owner |
|
||||||
|
|-----|-------|-------|
|
||||||
|
| **R-328** — the severity drop: `"warn"` coerced to `info`, emailed to nobody | **CLOSED** (controller v0.215.0), proven live side by side | CC |
|
||||||
|
| **R-329** — `app_start_failed` carries the identical defect | READY — **not fixed here**; needs a decision on whether it should notify at all | Viktor |
|
||||||
|
| **R-330** — Phase 2: collect SMART attrs 187/199/188 + persist samples | READY — a declared wire change, hub models it in the same session under G-1 | CC |
|
||||||
|
| **R-331** — Phase 3: growth-rate detection; revisit the static 64 | READY, blocked on R-330 | CC |
|
||||||
|
| **R-332** — the Fail path has never fired on real hardware | **WATCHING** | CC |
|
||||||
|
| **R-333** — NVMe temperature bands; agent `smartctl` has no `-n standby` | READY (S each) | Viktor decides (a); CC does (b) |
|
||||||
|
| **R-334** — released with no golden carrying it (gate waiver) | READY — now applies to **v0.216.0** | CC bakes; **Viktor vouches** |
|
||||||
|
| **R-335** — one physical disk walked twice per run, sustaining against itself | **CLOSED** (controller v0.216.0) | CC |
|
||||||
|
|
||||||
|
`smartd`-on-DooPlex-alerts-nobody is recorded in `DIAG-smart-passed-trap-2026-08-14.md` §8 as the
|
||||||
|
same shape one layer out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Observations — noticed, NOT acted on
|
||||||
|
|
||||||
|
1. **`app_start_failed` has the identical severity defect** (`notifier.go` ~L546, `"warn"`). Left
|
||||||
|
untouched per scope. It needs a prior decision — should a stopped app email the customer at all? —
|
||||||
|
because flipping the string alone converts a silent event into a mail flood on a crash-looping box.
|
||||||
|
**R-329.**
|
||||||
|
|
||||||
|
2. **The 55/60 °C bands are spinning-disk bands being applied to NVMe, and this is close to biting.**
|
||||||
|
Adopted unchanged from the operator's Prometheus config by explicit decision — but demo-hp's
|
||||||
|
**healthy** Toshiba NVMe idles at **53 °C**, i.e. **2 °C below Figyelmeztetés and 7 °C below Hiba**,
|
||||||
|
and NVMe routinely passes 60 °C under sustained write with no fault. As shipped, a healthy customer
|
||||||
|
NVMe under load can be reported as **Hiba** — the single worst outcome this feature can produce, and
|
||||||
|
the one leg 1 exists to guard. Not changed here because the threshold is a stated, settled operator
|
||||||
|
decision; flagged rather than overridden. **R-333(a) — recommend splitting the bands by device
|
||||||
|
class, or dropping them for NVMe and relying on `critical_warning`.**
|
||||||
|
|
||||||
|
3. **The agent runs bare `smartctl -a -j` with no `-n standby`**
|
||||||
|
(`felhom-agent/internal/storage/hostops.go:368`), so every poll wakes a spun-down drive, and 6h → 1h
|
||||||
|
multiplies that by six. Recorded, not acted on, per the task's instruction. demo-hp is all-flash so
|
||||||
|
the measurement could not reveal it. Mitigating datum from the fixture: the failing drive logged
|
||||||
|
only **3375 load cycles in 60505 power-on hours** (~one per 18 h), so this duty cycle barely spins
|
||||||
|
down at all. **R-333(b).**
|
||||||
|
|
||||||
|
4. **`source ~/.config/credentials` prints two recovery codes to the terminal.** The file contains
|
||||||
|
hyphenated keys (`R_DEMO-FELHOM`, `R_DEMO-HP`) that bash cannot assign, so sourcing it emits
|
||||||
|
`command not found` errors **containing the secret values**. Anything that sources that file leaks
|
||||||
|
them into logs, scrollback and transcripts. Not a code defect and out of scope; worth quoting
|
||||||
|
values from it by other means, or renaming the keys.
|
||||||
|
|
||||||
|
5. **`golden_currency_gate.py` has no waiver parser.** Its own failure text says *"record a waiver in
|
||||||
|
`OPEN-ITEMS.md` — never a bypass"*, but nothing reads such a waiver, so the only way past it is the
|
||||||
|
bypass it warns against. See §13.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Deviations, stated plainly
|
||||||
|
|
||||||
|
- **`git push --no-verify` was used once**, on the `felhom.eu` docs push (`767960b`), and only there.
|
||||||
|
Cause: `golden_currency_gate.py` correctly convicts the fact that controller **v0.215.0 is released
|
||||||
|
and no golden carries it** (newest bake 0.214.0), so a *newly installed* machine would receive
|
||||||
|
0.214.0 — without the severity fix. A golden bake was out of the task's scope, and its second half
|
||||||
|
(vouching in the hub's day-0 artifact manifest) is operator-password-gated, so CC cannot complete it;
|
||||||
|
a baked-but-unvouched golden is worse than none. Recorded as **R-334** with the bake+vouch owners
|
||||||
|
named. CI re-runs the same entry point and will mail the operator. The running fleet is unaffected.
|
||||||
|
- **One pre-existing test changed meaning by design:** `TestDiskVerdictFor`'s
|
||||||
|
`critical_warning>0 → warn` case is now `→ fail` (truth-table row 4 — NVMe's own critical flag is a
|
||||||
|
device declaration, not a drifting counter). `TestDiskHealthCheck_DegradationOnce` and its siblings
|
||||||
|
were rewritten into the scenario groups because they encoded the pre-v0.215.0 single-alert behaviour
|
||||||
|
the task deliberately replaces (Scenario C).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. R-335 — a defect in v0.215.0, found live, fixed as v0.216.0
|
||||||
|
|
||||||
|
**How it was found.** Not by a test and not by review: by reading the release's own **positive
|
||||||
|
observable** against the release's own **persisted artefact**. The hourly check logged *"3 disk(s)
|
||||||
|
evaluated"*; `disk-health-state.json` held **two** records. Two artefacts that should have agreed did
|
||||||
|
not.
|
||||||
|
|
||||||
|
**Cause.** demo-hp's `c11-scratch` and `felhom-backup` are the same physical NVMe (`/dev/nvme0n1`) and
|
||||||
|
resolve to the same `diskKey`, so one disk was walked twice in a single run.
|
||||||
|
|
||||||
|
**Why it mattered.** `RunDiskHealthCheck` writes a disk's new record before the next entry reads it, so
|
||||||
|
the **second** copy of an aliased disk consumed the **first** copy's write as its prior. The disk
|
||||||
|
therefore **sustained against itself and reached Hiba on a first sighting** — defeating truth-table
|
||||||
|
row 6, the single rule separating a one-hour benign excursion from a false critical alert — and would
|
||||||
|
have emitted **two identical events** for one drive.
|
||||||
|
|
||||||
|
**Severity in practice: latent, not active.** Nothing fired on demo-hp because all three entries are
|
||||||
|
healthy with zero counters. But any aliased disk developing one pending sector would have gone
|
||||||
|
straight to Hiba, which is precisely the outcome §8 leg 1 exists to prevent. Aliasing is not exotic —
|
||||||
|
it is the *normal* shape whenever a box has two PVE storage entries on one physical device.
|
||||||
|
|
||||||
|
**Fix (v0.216.0, `90f2545`).** Each `diskKey` is evaluated once per run. Both entries stay marked
|
||||||
|
`seen`, so neither is mistaken for a disappeared disk, and the card still renders **both** storage
|
||||||
|
rows — the dedup is about state and alerts, not display. Pinned by
|
||||||
|
`TestDiskCheck_SameDiskTwiceIsEvaluatedOnce`, red-proof run and reverted (§5).
|
||||||
|
|
||||||
|
**Deployed:** `gitea.dooplex.hu/admin/felhom-controller:0.216.0 Up 6 seconds (healthy)`.
|
||||||
|
|
||||||
|
**Confirming cycle on v0.216.0 — CONFIRMED LIVE, 09:31:35Z:**
|
||||||
|
|
||||||
|
```
|
||||||
|
live image: gitea.dooplex.hu/admin/felhom-controller:0.216.0 Up About an hour (healthy)
|
||||||
|
2026/08/14 09:31:35 [INFO] [web] disk-health check complete: 2 disk(s) evaluated, 0 alert(s)
|
||||||
|
grep -c disk_health_degraded: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**`2 disk(s) evaluated` now matches the 2 persisted records.** The count and the artefact agree, which
|
||||||
|
is the disagreement that exposed R-335 in the first place. Still zero alerts, still both card rows.
|
||||||
|
|
||||||
|
### The redeploy also proved persistence live — a gap §9 had listed as unproven
|
||||||
|
|
||||||
|
The 0.215.0 → 0.216.0 redeploy **replaced the container**, and the state file came back intact:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"path:/var/lib/vz": {"verdict": 1, "changed_at": "2026-08-14T07:23:14.640216851Z", ...}
|
||||||
|
"uuid:91d2dc2d-…": {"verdict": 1, "changed_at": "2026-08-14T07:23:14.640216851Z", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
That `changed_at` was written by **v0.215.0's first cycle at 07:23Z**, before the container was
|
||||||
|
destroyed and rebuilt. The v0.216.0 container read it back and preserved it rather than stamping a
|
||||||
|
fresh time — so the new container **loaded the pre-restart record instead of silently re-baselining**.
|
||||||
|
That is Scenario L observed on real hardware, not just through the production-path unit test, and it
|
||||||
|
is exactly the behaviour that was impossible before v0.215.0 (the baseline was in-memory).
|
||||||
|
|
||||||
|
It also incidentally confirms the unchanged-verdict path: `changed_at` is preserved across four checks
|
||||||
|
and two controller versions because the verdict never changed, rather than being churned every cycle.
|
||||||
|
|
||||||
|
**What this still does NOT prove:** these disks are healthy and were never alerted, so the stronger
|
||||||
|
half — *an already-ALERTED disk not re-alerting after a restart* — remains unit-tested only. R-332
|
||||||
|
stands.
|
||||||
|
|
||||||
|
**Process note, recorded because it nearly cost the fix.** The red-proof harness reverts with
|
||||||
|
`git checkout --`, which restores to `HEAD`. Running a red-proof against an **uncommitted** fix
|
||||||
|
therefore *deletes the fix* along with the mutation — which happened here and was caught only by
|
||||||
|
re-grepping the source afterwards. Commit the fix before red-proofing it, or snapshot outside git.
|
||||||
@@ -41,4 +41,8 @@ restic-password
|
|||||||
# App assets (synced from felhom.eu website at build time)
|
# App assets (synced from felhom.eu website at build time)
|
||||||
assets/*.svg
|
assets/*.svg
|
||||||
assets/*.png
|
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 ---
|
# --- 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 TARGETOS=linux
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG VERSION=dev
|
|
||||||
ARG GIT_COMMIT=unknown
|
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -24,6 +24,13 @@ COPY . .
|
|||||||
# Generate go.sum and ensure all deps are fetched
|
# Generate go.sum and ensure all deps are fetched
|
||||||
RUN go mod tidy
|
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
|
# Build static binary
|
||||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||||
-ldflags="-s -w \
|
-ldflags="-s -w \
|
||||||
@@ -33,48 +40,73 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
|||||||
-o /build/felhom-controller \
|
-o /build/felhom-controller \
|
||||||
./cmd/controller/
|
./cmd/controller/
|
||||||
|
|
||||||
# --- Runtime stage ---
|
# --- restic: the official release binary, pinned by version AND checksum (R-1105, v0.323.0) ---
|
||||||
FROM debian:bookworm-slim
|
# Never again „whatever the OS ships": Debian 12 → 13 would have swapped restic 0.14.0 → 0.18.0 under the off-site code
|
||||||
|
# without anyone choosing it. The version below is a decision; moving it is a re-proof (07-backup-architecture.md §6.8).
|
||||||
|
# Three checks, each fails the build: restic's SHA256SUMS carries a good signature from restic's release key (vendored
|
||||||
|
# in third_party/restic/, fingerprint CF8F 18F2 8445 7597 3F79 D4E1 91A6 868B D3F7 A907 — no keyserver at build
|
||||||
|
# time); the download matches the published sum; and it matches the sum pinned HERE, so even a re-signed release
|
||||||
|
# cannot swap the binary. Pinned by TestR1105_ResticIsPinnedOutsideTheOS.
|
||||||
|
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f AS restic
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG RESTIC_VERSION=0.19.1
|
||||||
|
ARG RESTIC_SHA256_AMD64=f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c
|
||||||
|
ARG RESTIC_SHA256_ARM64=a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465
|
||||||
|
COPY third_party/restic/restic-release-key.asc /tmp/restic-release-key.asc
|
||||||
|
RUN set -eu; \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg gpgv bzip2; \
|
||||||
|
case "${TARGETARCH}" in \
|
||||||
|
amd64) want="${RESTIC_SHA256_AMD64}" ;; \
|
||||||
|
arm64) want="${RESTIC_SHA256_ARM64}" ;; \
|
||||||
|
*) echo "no pinned restic checksum for architecture '${TARGETARCH}'" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
f="restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2"; \
|
||||||
|
url="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}"; \
|
||||||
|
cd /tmp; \
|
||||||
|
curl -fsSL -o "$f" "$url/$f"; curl -fsSL -o SHA256SUMS "$url/SHA256SUMS"; curl -fsSL -o SHA256SUMS.asc "$url/SHA256SUMS.asc"; \
|
||||||
|
gpg --dearmor < /tmp/restic-release-key.asc > /tmp/restic-release-key.gpg; \
|
||||||
|
gpgv --keyring /tmp/restic-release-key.gpg SHA256SUMS.asc SHA256SUMS; \
|
||||||
|
grep " ${f}\$" SHA256SUMS | sha256sum -c -; \
|
||||||
|
echo "${want} ${f}" | sha256sum -c -; \
|
||||||
|
bunzip2 -c "$f" > /usr/local/bin/restic; chmod 0755 /usr/local/bin/restic; \
|
||||||
|
/usr/local/bin/restic version | grep -q "^restic ${RESTIC_VERSION} "
|
||||||
|
|
||||||
# Install runtime dependencies:
|
# --- Runtime stage ---
|
||||||
# - docker-cli: for "docker compose" commands
|
# Debian 13 (R-1105, v0.323.0), pinned by point release AND digest. It waited on Debian 12 until the off-site code's
|
||||||
# - ca-certificates: for HTTPS (healthchecks pings, git)
|
# restic assumptions were re-proven on 0.18 (07-backup-architecture.md §6.8); restic itself no longer comes from here.
|
||||||
# - postgresql-client: for pg_dump
|
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f
|
||||||
# - default-mysql-client: for mysqldump
|
|
||||||
# - sqlite3: for SQLite backup
|
# Install runtime dependencies — each one is a program the controller binary runs (R-1105 re-checked every exec):
|
||||||
# - git: for stack sync from Gitea
|
# - docker-cli + compose plugin: every stack operation (below)
|
||||||
# - curl: for health pings and debugging
|
# - ca-certificates: for HTTPS (healthchecks pings, git, the hub)
|
||||||
# - util-linux: lsblk, blkid, mount (storage init)
|
# - curl: the image's HEALTHCHECK, and the docker repo key below
|
||||||
# - fdisk: sfdisk (partition table writing — separate from util-linux on Debian)
|
# - gnupg: dearmors docker's repo key below
|
||||||
# - e2fsprogs: mkfs.ext4 (filesystem formatting)
|
# - git: catalog sync
|
||||||
# - rsync: for data migration between storage paths
|
# - util-linux: findmnt (mount reads)
|
||||||
# - parted: partprobe (partition table re-read after sfdisk)
|
# - rsync: data migration between storage paths
|
||||||
# - restic: encrypted off-box (NAS) backup over SFTP (Part B; version pinned by the Debian release)
|
# - openssh-client: restic's sftp/rclone transports shell out to `ssh`; the off-site key probe
|
||||||
# - openssh-client: restic's sftp backend shells out to `ssh` (the SFTP transport); also ssh-copy-id/sftp
|
# v0.323.0 (R-1105) REMOVED, because the binary never runs them: postgresql-client and default-mysql-client (every
|
||||||
# for the SLICE-2 offsite apply-bridge key install
|
# dump and import runs INSIDE the app's own database container — `docker exec`, appbackup/dbdump.go), sqlite3,
|
||||||
# - sshpass: feeds the one-time storage-box password to ssh-copy-id -s -f (offsite apply-bridge)
|
# fdisk, e2fsprogs, parted (disk work moved to the host agent in v0.37.0), sshpass (the ssh-copy-id key install it
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
# 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 \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
gnupg \
|
gnupg \
|
||||||
git \
|
git \
|
||||||
postgresql-client \
|
|
||||||
default-mysql-client \
|
|
||||||
sqlite3 \
|
|
||||||
util-linux \
|
util-linux \
|
||||||
fdisk \
|
|
||||||
e2fsprogs \
|
|
||||||
rsync \
|
rsync \
|
||||||
parted \
|
|
||||||
restic \
|
|
||||||
openssh-client \
|
openssh-client \
|
||||||
sshpass \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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 \
|
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] \
|
&& 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 update \
|
||||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
+1698
-217
File diff suppressed because it is too large.
Load diff
+106
-13
@@ -12,11 +12,18 @@
|
|||||||
# ./build.sh 0.1.0 # Build with version tag
|
# ./build.sh 0.1.0 # Build with version tag
|
||||||
# ./build.sh 0.1.0 --push # Build + push to Gitea registry
|
# ./build.sh 0.1.0 --push # Build + push to Gitea registry
|
||||||
# ./build.sh 0.1.0 --multiarch # Build amd64+arm64 + push
|
# ./build.sh 0.1.0 --multiarch # Build amd64+arm64 + push
|
||||||
|
# ./build.sh 0.1.0 --print-tags # Print the tags a build would carry, and stop (the R-1143 test)
|
||||||
|
#
|
||||||
|
# :latest is carried ONLY by a release tag (x.y.z). An -rc, dev or any other tag never moves it (R-1143).
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# --- Configuration (edit these if your paths differ) ---
|
# --- 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"
|
CONTROLLER_SRC="${REPO_DIR}/controller"
|
||||||
WEBSITE_ASSETS_DIR="/mnt/5_hdd/felhom.eu/git/felhom.eu/website/assets"
|
WEBSITE_ASSETS_DIR="/mnt/5_hdd/felhom.eu/git/felhom.eu/website/assets"
|
||||||
REGISTRY="gitea.dooplex.hu/admin"
|
REGISTRY="gitea.dooplex.hu/admin"
|
||||||
@@ -37,6 +44,21 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|||||||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||||
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
|
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
|
||||||
|
|
||||||
|
# --- The tags this build carries (R-1143, 2026-10-18) ---
|
||||||
|
# Until v0.328.0 every build moved :latest, release candidates included (rc.1–rc.5 each did on 2026-10-17). Nothing
|
||||||
|
# current pulls :latest — the update button takes the highest x.y.z tag — but a legacy-shaped box following it would
|
||||||
|
# have taken an rc. So :latest moves with a RELEASE only. Pinned by scripts/test_build_push_once.sh.
|
||||||
|
TAGS=("${IMAGE}:${VERSION}")
|
||||||
|
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
TAGS+=("${IMAGE}:latest")
|
||||||
|
fi
|
||||||
|
TAG_ARGS=()
|
||||||
|
for t in "${TAGS[@]}"; do TAG_ARGS+=(-t "$t"); done
|
||||||
|
if [[ "${ACTION}" == "--print-tags" ]]; then
|
||||||
|
printf '%s\n' "${TAGS[@]}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Pre-flight checks ---
|
# --- Pre-flight checks ---
|
||||||
if [[ ! -d "${CONTROLLER_SRC}" ]]; then
|
if [[ ! -d "${CONTROLLER_SRC}" ]]; then
|
||||||
error "Controller source not found: ${CONTROLLER_SRC}"
|
error "Controller source not found: ${CONTROLLER_SRC}"
|
||||||
@@ -61,11 +83,90 @@ info "║ felhom-controller image builder ║"
|
|||||||
info "╚══════════════════════════════════════╝"
|
info "╚══════════════════════════════════════╝"
|
||||||
info "Version: ${VERSION}"
|
info "Version: ${VERSION}"
|
||||||
info "Commit: ${GIT_COMMIT}"
|
info "Commit: ${GIT_COMMIT}"
|
||||||
|
info "Repo: ${REPO_DIR}"
|
||||||
info "Source: ${CONTROLLER_SRC}"
|
info "Source: ${CONTROLLER_SRC}"
|
||||||
info "Build dir: ${BUILD_DIR}"
|
info "Build dir: ${BUILD_DIR}"
|
||||||
info "Image: ${IMAGE}:${VERSION}"
|
info "Image: ${IMAGE}:${VERSION}"
|
||||||
echo ""
|
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
|
# Step 1: Prepare clean build workspace
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -160,14 +261,10 @@ BUILD_ARGS=(
|
|||||||
case "${ACTION}" in
|
case "${ACTION}" in
|
||||||
--push)
|
--push)
|
||||||
info "Building for current platform + pushing..."
|
info "Building for current platform + pushing..."
|
||||||
docker build "${BUILD_ARGS[@]}" \
|
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
|
||||||
-t "${IMAGE}:${VERSION}" \
|
|
||||||
-t "${IMAGE}:latest" \
|
|
||||||
.
|
|
||||||
|
|
||||||
info "Pushing..."
|
info "Pushing..."
|
||||||
docker push "${IMAGE}:${VERSION}"
|
for t in "${TAGS[@]}"; do docker push "$t"; done
|
||||||
docker push "${IMAGE}:latest"
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
--multiarch)
|
--multiarch)
|
||||||
@@ -183,18 +280,14 @@ case "${ACTION}" in
|
|||||||
|
|
||||||
docker buildx build "${BUILD_ARGS[@]}" \
|
docker buildx build "${BUILD_ARGS[@]}" \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
-t "${IMAGE}:${VERSION}" \
|
"${TAG_ARGS[@]}" \
|
||||||
-t "${IMAGE}:latest" \
|
|
||||||
--push \
|
--push \
|
||||||
.
|
.
|
||||||
;;
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
info "Building for current platform (local only)..."
|
info "Building for current platform (local only)..."
|
||||||
docker build "${BUILD_ARGS[@]}" \
|
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
|
||||||
-t "${IMAGE}:${VERSION}" \
|
|
||||||
-t "${IMAGE}:latest" \
|
|
||||||
.
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v0.279.0 (decision 45): the deploy-done hook — the ONE place a fresh install ends — runs after_install, and
|
||||||
|
// the hook is set on every box, not only inside `if notifier != nil` (a box with no hub still installs apps).
|
||||||
|
// COMPANION RED-PROOF: move SetDeployDoneHook back inside `if notifier != nil` → the second assertion fails;
|
||||||
|
// drop the RunAfterInstall call → the first fails.
|
||||||
|
func TestAfterInstall_IsWiredToEveryFreshInstall(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var hook *ast.CallExpr
|
||||||
|
var insideNotifierIf bool
|
||||||
|
var walk func(n ast.Node, underNotifier bool)
|
||||||
|
walk = func(n ast.Node, underNotifier bool) {
|
||||||
|
ast.Inspect(n, func(x ast.Node) bool {
|
||||||
|
if ifs, ok := x.(*ast.IfStmt); ok {
|
||||||
|
if be, ok := ifs.Cond.(*ast.BinaryExpr); ok {
|
||||||
|
if id, ok := be.X.(*ast.Ident); ok && id.Name == "notifier" && be.Op == token.NEQ {
|
||||||
|
walk(ifs.Body, true)
|
||||||
|
if ifs.Else != nil {
|
||||||
|
walk(ifs.Else, underNotifier)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c, ok := x.(*ast.CallExpr); ok {
|
||||||
|
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && hook == nil {
|
||||||
|
hook, insideNotifierIf = c, underNotifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
walk(f, false)
|
||||||
|
if hook == nil {
|
||||||
|
t.Fatal("SetDeployDoneHook is never called")
|
||||||
|
}
|
||||||
|
calls := false
|
||||||
|
ast.Inspect(hook, func(x ast.Node) bool {
|
||||||
|
if s, ok := x.(*ast.SelectorExpr); ok && s.Sel.Name == "RunAfterInstall" {
|
||||||
|
calls = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !calls {
|
||||||
|
t.Fatal("the deploy-done hook does not run RunAfterInstall")
|
||||||
|
}
|
||||||
|
if insideNotifierIf {
|
||||||
|
t.Fatal("SetDeployDoneHook is set only when a notifier exists — a box with no hub would never run after_install")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
|||||||
body := mainBody(t)
|
body := mainBody(t)
|
||||||
names := callsInMain(t, body)
|
names := callsInMain(t, body)
|
||||||
|
|
||||||
if indexOfCall(names, "NotifyBackupFailed") < 0 {
|
if indexOfCall(names, "NotifyInterruptedOperation") < 0 {
|
||||||
t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " +
|
t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " +
|
||||||
"controller died mid-backup and nobody is told (§2.4)")
|
"controller died mid-backup and nobody is told (§2.4)")
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// R-174 STRENGTHENED THIS. `!= nil` alone is no longer sufficient, because Recover now returns a
|
// R-174 STRENGTHENED THIS. `!= nil` alone is no longer sufficient, because Recover now returns a
|
||||||
// non-nil result for a recovery that merely REFUSED starts (an absent data drive) — the drive
|
// non-nil result for a recovery that merely REFUSED starts (an absent data drive) — the drive
|
||||||
// gate working as designed. `NotifyBackupFailed` sends `backup_failed`, which is customer-enabled
|
// gate working as designed. `NotifyInterruptedOperation` (R-585; was `NotifyBackupFailed`) sends `backup_failed`, which is customer-enabled
|
||||||
// by default (settings.DefaultEnabledEvents), so a nil-only guard would email the customer
|
// by default (settings.DefaultEnabledEvents), so a nil-only guard would email the customer
|
||||||
// "A biztonsági mentés sikertelen!" about an app nothing is wrong with. The guard must consult
|
// "A biztonsági mentés sikertelen!" about an app nothing is wrong with. The guard must consult
|
||||||
// Alarming().
|
// Alarming().
|
||||||
@@ -156,7 +156,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
carries := false
|
carries := false
|
||||||
for _, name := range callsInMain(t, ifst.Body) {
|
for _, name := range callsInMain(t, ifst.Body) {
|
||||||
if name == "NotifyBackupFailed" {
|
if name == "NotifyInterruptedOperation" {
|
||||||
carries = true
|
carries = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
|||||||
if !guardedByAlarming {
|
if !guardedByAlarming {
|
||||||
t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " +
|
t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " +
|
||||||
"recovery that only REFUSED starts (drive absent) would be reported through " +
|
"recovery that only REFUSED starts (drive absent) would be reported through " +
|
||||||
"NotifyBackupFailed, a customer-enabled event type, telling the customer their backup " +
|
"NotifyInterruptedOperation (backup_failed), a customer-enabled event type, telling the customer their backup " +
|
||||||
"failed when the drive gate was simply doing its job (R-174)")
|
"failed when the drive gate was simply doing its job (R-174)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,3 +493,30 @@ func assignedIdentsIn(body *ast.BlockStmt) []string {
|
|||||||
})
|
})
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R-1251: the guard's hold store and drive question are wired BEFORE Recover (a waiting put-back would otherwise be
|
||||||
|
// read as „gone" and nothing would hold the app), and the waiting put-backs are resumed after it. COMPANION
|
||||||
|
// RED-PROOF (2026-10-24): delete SetHolds or SetUndoReachable, or move either below Recover → this fails.
|
||||||
|
func TestR1251_MainWiresThePutBackWait(t *testing.T) {
|
||||||
|
names := callsInMain(t, mainBody(t))
|
||||||
|
rec := indexOfCall(names, "Recover")
|
||||||
|
for _, n := range []string{"SetHolds", "SetUndoReachable"} {
|
||||||
|
if i := indexOfCall(names, n); i < 0 || i > rec {
|
||||||
|
t.Errorf("func main() must call appStopGuard.%s before Recover (at %d, Recover at %d)", n, i, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i := indexOfCall(names, "ResumeWaitingPutBacks"); i < rec {
|
||||||
|
t.Errorf("func main() must resume the waiting put-backs after Recover (at %d, Recover at %d)", i, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-1252: the second-drive copy asks the stack adapter (by an optional interface) whether a block was REJECTED. An
|
||||||
|
// adapter that lost the method would make every rejected block read as absent — quiet, with no alarm. Pinned at
|
||||||
|
// compile time here; the decision is tested in internal/backup.
|
||||||
|
var _ interface{ BackupBlockRejected(string) string } = (*stackAdapter)(nil)
|
||||||
|
|
||||||
|
func TestR1252_MainWiresTheUnreadableSettingsSignal(t *testing.T) {
|
||||||
|
if indexOfCall(callsInMain(t, mainBody(t)), "SetTier2SettingsUnreadableNotify") < 0 {
|
||||||
|
t.Fatal("func main() no longer wires SetTier2SettingsUnreadableNotify — a rejected block would keep the copies silently")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestConvert_ReleaseIsWiredAtStartup — v0.273.0 (`09` §6.4 part 10, B5): the kept pre-conversion datadir
|
||||||
|
// copy is released only by ReleaseConversionCopies, so main.go must CALL it (the seam-built-but-never-wired
|
||||||
|
// class). COMPANION RED-PROOF (REPORT.md): delete the `conversion-copy-release` job — this fails.
|
||||||
|
func TestConvert_ReleaseIsWiredAtStartup(t *testing.T) {
|
||||||
|
lines, _, _ := slice4CallLines(t)
|
||||||
|
if len(lines["ReleaseConversionCopies"]) == 0 {
|
||||||
|
t.Fatal("ReleaseConversionCopies is never called — a converted app's old datadir copy would stay on disk for ever")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// `09` §3 decision 28 (v0.269.0) — the box stops a crash loop / OOM storm; never inside a backup's,
|
||||||
|
// a quiesce's or an update's own stop, never twice, never an app already held.
|
||||||
|
func TestD28_StopHoldTellInOrder_AndTheSkips(t *testing.T) {
|
||||||
|
var calls []string
|
||||||
|
d := unhealthyDeps{
|
||||||
|
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
|
||||||
|
return []stacks.UnhealthyVerdict{
|
||||||
|
{Stack: "gokapi", Kind: stacks.UnhealthyCrashLoop, Count: 7, Window: 10 * time.Minute},
|
||||||
|
{Stack: "quiesced", Kind: stacks.UnhealthyCrashLoop, Count: 7},
|
||||||
|
{Stack: "busy", Kind: stacks.UnhealthyCrashLoop, Count: 7},
|
||||||
|
{Stack: "held", Kind: stacks.UnhealthyOOMStorm, Count: 20},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
suppressed: func() map[string]bool { return map[string]bool{"quiesced": true} },
|
||||||
|
busy: func(n string) (bool, string) { return n == "busy", "a restore is running" },
|
||||||
|
holdKind: func(n string) string {
|
||||||
|
if n == "held" {
|
||||||
|
return "update_failed"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
stop: func(n string) error { calls = append(calls, "stop:"+n); return nil },
|
||||||
|
hold: func(n, k string, _ time.Time) (int, error) { calls = append(calls, "hold:"+n+":"+k); return 1, nil },
|
||||||
|
notify: func(v stacks.UnhealthyVerdict, trip int) { calls = append(calls, "notify:"+v.Stack) },
|
||||||
|
}
|
||||||
|
stopped := stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
|
||||||
|
if strings.Join(calls, ",") != "stop:gokapi,hold:gokapi:crash_loop,notify:gokapi" {
|
||||||
|
t.Fatalf("calls = %v — want gokapi stopped, held, told; the quiesced, busy and held apps untouched", calls)
|
||||||
|
}
|
||||||
|
if len(stopped) != 1 || stopped[0] != "gokapi" {
|
||||||
|
t.Fatalf("stopped = %v", stopped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed stop is neither held nor announced (the household would read "we stopped it" over a running app).
|
||||||
|
func TestD28_AFailedStopIsNotAnnounced(t *testing.T) {
|
||||||
|
var calls []string
|
||||||
|
d := unhealthyDeps{
|
||||||
|
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
|
||||||
|
return []stacks.UnhealthyVerdict{{Stack: "x", Kind: "crash_loop"}}
|
||||||
|
},
|
||||||
|
suppressed: func() map[string]bool { return nil },
|
||||||
|
busy: func(string) (bool, string) { return false, "" },
|
||||||
|
holdKind: func(string) string { return "" },
|
||||||
|
stop: func(string) error { return io.ErrUnexpectedEOF },
|
||||||
|
hold: func(string, string, time.Time) (int, error) { calls = append(calls, "hold"); return 1, nil },
|
||||||
|
notify: func(stacks.UnhealthyVerdict, int) { calls = append(calls, "notify") },
|
||||||
|
}
|
||||||
|
stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Fatalf("calls = %v after a failed stop", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wiring: main's dead-app loop calls stopUnhealthyApps with the detector.
|
||||||
|
func TestD28_TheLoopStopsUnhealthyApps(t *testing.T) {
|
||||||
|
found, withDetector := false, false
|
||||||
|
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "stopUnhealthyApps" {
|
||||||
|
found = true
|
||||||
|
ast.Inspect(call, func(m ast.Node) bool {
|
||||||
|
if sel, ok := m.(*ast.SelectorExpr); ok && sel.Sel.Name == "ObserveUnhealthy" {
|
||||||
|
withDetector = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !found || !withDetector {
|
||||||
|
t.Fatalf("main calls stopUnhealthyApps=%v with ObserveUnhealthy=%v — decision 28 is built and never run", found, withDetector)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v0.317.0 (`16` §4): the agent's same-disk verdict reaches the backup side. A seam built and never wired
|
||||||
|
// has shipped in this repo seven times; this walks main.go for the call AND for the agent call inside it.
|
||||||
|
// COMPANION RED-PROOF: delete the SetDiskJudge block → "not wired"; replace ac.SameDisk with a constant →
|
||||||
|
// "never asks the agent".
|
||||||
|
func TestDiskJudge_IsWiredAtStartup(t *testing.T) {
|
||||||
|
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var judge *ast.CallExpr
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
if c, ok := n.(*ast.CallExpr); ok {
|
||||||
|
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDiskJudge" {
|
||||||
|
judge = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if judge == nil {
|
||||||
|
t.Fatal("backupMgr.SetDiskJudge is not wired in main.go — Tier-2 would keep the st_dev rule for ever")
|
||||||
|
}
|
||||||
|
asks := false
|
||||||
|
ast.Inspect(judge, func(n ast.Node) bool {
|
||||||
|
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "SameDisk" {
|
||||||
|
asks = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !asks {
|
||||||
|
t.Fatal("SetDiskJudge is wired but never asks the agent (no SameDisk call inside it)")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1979
-230
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v0.283.0 (decision 50, R-720): the deploy-done hook's SUCCESS branch — the one place a fresh install ends —
|
||||||
|
// calls sett.DefaultOffboxOnForNewApp. The rule itself is pinned in internal/settings/offbox_default_test.go;
|
||||||
|
// this pins that it is reached.
|
||||||
|
// COMPANION RED-PROOF: delete the call from the hook → this test fails.
|
||||||
|
func TestDecision50_DefaultIsWiredIntoTheDeployDoneHook(t *testing.T) {
|
||||||
|
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
c, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && len(c.Args) == 1 {
|
||||||
|
ast.Inspect(c.Args[0], func(m ast.Node) bool {
|
||||||
|
if cc, ok := m.(*ast.CallExpr); ok {
|
||||||
|
if ss, ok := cc.Fun.(*ast.SelectorExpr); ok && ss.Sel.Name == "DefaultOffboxOnForNewApp" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Fatal("the deploy-done hook does not call DefaultOffboxOnForNewApp — a new app would start with off-site OFF (R-720)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// opActionReportFire holds the out-of-cycle report trigger once main has built it (func()). The
|
||||||
|
// executor is built earlier than the trigger, so it reads it through this holder.
|
||||||
|
var opActionReportFire atomic.Value
|
||||||
|
|
||||||
|
// operatorActionHandlers wires the four operator actions (R-314/R-279/R-177, `09` §3 decision 185)
|
||||||
|
// to the running process. ONE wiring site, called from main and from the wiring test, so the test
|
||||||
|
// exercises the doors production uses (the seam-built-but-never-wired trap).
|
||||||
|
//
|
||||||
|
// offsite_backup_now makes the SAME four checks as the household's „back up now"
|
||||||
|
// (offboxRunHandler) and then runs the same call, so an operator press can never do what the
|
||||||
|
// household's own press cannot.
|
||||||
|
func operatorActionHandlers(backupMgr *backup.Manager, sched *scheduler.Scheduler, resultReady func(), logger *log.Logger) report.OperatorActionHandlers {
|
||||||
|
h := report.OperatorActionHandlers{ResultReady: resultReady}
|
||||||
|
if sched != nil {
|
||||||
|
h.RunJob = sched.RunNow
|
||||||
|
}
|
||||||
|
if backupMgr == nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
h.OffsiteBackupNow = func() (string, func(ctx context.Context) error) {
|
||||||
|
switch {
|
||||||
|
case !backupMgr.OffboxConfigured():
|
||||||
|
return "no off-site target is set on this box", nil
|
||||||
|
case !backupMgr.OffboxRunnable():
|
||||||
|
return "the off-site key step (escrow) is not done yet — the run is refused until it is", nil
|
||||||
|
case backupMgr.OffboxOrphaned():
|
||||||
|
return "the off-site store is orphaned (written with an older key) — a run cannot write to it", nil
|
||||||
|
case backupMgr.IsRunning():
|
||||||
|
return "a backup run is already in flight", nil
|
||||||
|
}
|
||||||
|
return "", func(ctx context.Context) error {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Printf("[INFO] [opaction] off-site backup started at the operator's request")
|
||||||
|
}
|
||||||
|
err := backupMgr.RunOffboxBackupWithProgress(ctx)
|
||||||
|
if errors.Is(err, backup.ErrOffboxRunInFlight) {
|
||||||
|
return fmt.Errorf("%w: %v", report.ErrActionRefused, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.AbandonStop = backupMgr.StopAbandon
|
||||||
|
h.AbandonExtend = func(days int) (time.Time, error) { return backupMgr.ExtendAbandon(days) }
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// `09` §3 decision 185 (D1), the design's red test (1): a reply carrying abandon_stop during a
|
||||||
|
// countdown stops it IN THE RUNNING MANAGER — and settings.json on disk agrees. This is the reason
|
||||||
|
// the action rides the report reply instead of the CLI lever: the CLI runs a SECOND process, whose
|
||||||
|
// write the running controller can overwrite (the lost update, main.go's R-241 block).
|
||||||
|
func TestD1_AbandonStopActsOnTheRunningManagerAndDisk(t *testing.T) {
|
||||||
|
logger := log.New(io.Discard, "", 0)
|
||||||
|
dir := t.TempDir()
|
||||||
|
settingsPath := filepath.Join(dir, "settings.json")
|
||||||
|
sett, err := settings.Load(settingsPath, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Paths.DataDir = dir
|
||||||
|
cfg.Paths.SystemDataPath = filepath.Join(dir, "sys")
|
||||||
|
mgr := backup.NewManager(cfg, sett, logger)
|
||||||
|
// No test reaches a real ssh or restic (R-488 / R-650).
|
||||||
|
mgr.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
|
||||||
|
t.Fatal("abandon_stop must issue no remote command")
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
|
||||||
|
t.Fatal("abandon_stop must run no restic")
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
due := time.Now().Add(10 * 24 * time.Hour).UTC()
|
||||||
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||||
|
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
||||||
|
EscrowState: "escrowed",
|
||||||
|
AbandonRepoPath: "/srv/repo.orphaned-20261001",
|
||||||
|
AbandonStartedAt: time.Now().Add(-4 * 24 * time.Hour).UTC().Format(time.RFC3339),
|
||||||
|
AbandonAt: due.Format(time.RFC3339),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !mgr.AbandonStatus().Active {
|
||||||
|
t.Fatal("fixture: a countdown must be running")
|
||||||
|
}
|
||||||
|
|
||||||
|
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
|
||||||
|
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpAbandonStop}})
|
||||||
|
|
||||||
|
if mgr.AbandonStatus().Active {
|
||||||
|
t.Fatal("the running manager still counts down after abandon_stop")
|
||||||
|
}
|
||||||
|
onDisk, err := settings.Load(settingsPath, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tg := onDisk.GetOffboxTarget(); tg == nil || tg.AbandonAt != "" || tg.AbandonRepoPath == "" {
|
||||||
|
t.Fatalf("settings.json disagrees: %+v (want no due date, the set-aside path kept)", tg)
|
||||||
|
}
|
||||||
|
rs := o.Results()
|
||||||
|
if len(rs) != 1 || rs[0].Outcome != report.OutcomeDone {
|
||||||
|
t.Fatalf("result = %+v", rs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control: the same countdown, a second press → nothing to stop, reported as such (not „done").
|
||||||
|
o.Reconcile([]report.OperatorAction{{ID: 2, Action: report.OpAbandonStop}})
|
||||||
|
if r := o.Results(); len(r) != 1 || r[0].ID != 2 || r[0].Outcome != report.OutcomeFailed {
|
||||||
|
t.Fatalf("second stop: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// offsite_backup_now refuses exactly where the household's „back up now" refuses: no target here.
|
||||||
|
func TestD1_OffsiteBackupNowRefusesWithoutATarget(t *testing.T) {
|
||||||
|
logger := log.New(io.Discard, "", 0)
|
||||||
|
dir := t.TempDir()
|
||||||
|
sett, err := settings.Load(filepath.Join(dir, "settings.json"), logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Paths.DataDir = dir
|
||||||
|
mgr := backup.NewManager(cfg, sett, logger)
|
||||||
|
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
|
||||||
|
t.Fatal("no restic may run")
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
|
||||||
|
o.Reconcile([]report.OperatorAction{{ID: 9, Action: report.OpOffsiteBackupNow}})
|
||||||
|
if r := o.Results(); len(r) != 1 || r[0].Outcome != report.OutcomeRefused {
|
||||||
|
t.Fatalf("result = %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run_job reaches the scheduler's RunNow: a registered job runs; a job not registered on this box is
|
||||||
|
// refused.
|
||||||
|
func TestD1_RunJobUsesTheScheduler(t *testing.T) {
|
||||||
|
logger := log.New(io.Discard, "", 0)
|
||||||
|
sched := scheduler.New(logger)
|
||||||
|
ran := make(chan bool, 1)
|
||||||
|
sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { ran <- scheduler.OnDemand(ctx); return nil })
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
sched.Start(ctx)
|
||||||
|
defer sched.Stop()
|
||||||
|
o := report.NewOperatorActions(ctx, operatorActionHandlers(nil, sched, nil, logger), logger)
|
||||||
|
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpRunJob, Arg: "fill-watch"}, {ID: 2, Action: report.OpRunJob, Arg: "offsite-proof"}})
|
||||||
|
select {
|
||||||
|
case od := <-ran:
|
||||||
|
if !od {
|
||||||
|
t.Fatal("the job did not see itself as on-demand")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("fill-watch never ran")
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for len(o.Results()) < 2 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
got := map[int64]string{}
|
||||||
|
for _, r := range o.Results() {
|
||||||
|
got[r.ID] = r.Outcome
|
||||||
|
}
|
||||||
|
if got[1] != report.OutcomeDone || got[2] != report.OutcomeRefused {
|
||||||
|
t.Fatalf("outcomes = %v, want 1:done 2:refused (offsite-proof not registered here)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// main.go hands the reply's list to the executor and the trigger to its holder — one call each
|
||||||
|
// (the seam-built-but-never-wired trap: a green executor that main never calls proves nothing).
|
||||||
|
func TestD1_MainWiresTheExecutor(t *testing.T) {
|
||||||
|
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := map[string]int{}
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch fn := call.Fun.(type) {
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
if x, ok := fn.X.(*ast.Ident); ok {
|
||||||
|
found[x.Name+"."+fn.Sel.Name]++
|
||||||
|
}
|
||||||
|
case *ast.Ident:
|
||||||
|
found[fn.Name]++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
for _, want := range []string{"opActions.Reconcile", "report.SetOperatorActions", "operatorActionHandlers", "opActionReportFire.Store"} {
|
||||||
|
if found[want] != 1 {
|
||||||
|
t.Errorf("main.go calls %s %d time(s), want exactly 1", want, found[want])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1199 (rehearsal 2026-10-20) and R-1199 REOPENED (2026-10-22). A fresh box shows the household its DEFAULT
|
||||||
|
// notification list, but the hub mails only by its OWN stored list. v0.338.0 pushed that default at every start when
|
||||||
|
// nothing was saved on the box — and on a REBUILT box that overwrote the list the household had saved on its old box
|
||||||
|
// (demo-felhom, 07:54:55 UTC 2026-10-22: its own 13 events became the 17 defaults), again at every start, because the
|
||||||
|
// push created no local save. `08` „the household decides".
|
||||||
|
//
|
||||||
|
// Now, at start:
|
||||||
|
// - a list saved on this box: as before (R-922 — re-pushed when it carries an address or a clear), as the household's;
|
||||||
|
// - nothing saved here: READ the hub's list first. One the household saved (on any box) is ADOPTED as this box's
|
||||||
|
// list — nothing is pushed; otherwise the box's default is pushed once, as a default, and saved here, so the next
|
||||||
|
// start sends nothing. A hub that cannot be read (an older hub, no network) gets nothing; the next start asks again.
|
||||||
|
//
|
||||||
|
// The hub refuses a default over a household's list on its own side too (felhom.eu hub household_prefs.go), which also
|
||||||
|
// stops a box still on v0.338.0. Pinned by TestR1199Reopened_* (prefs_sync_wiring_test.go).
|
||||||
|
|
||||||
|
type prefsHub interface {
|
||||||
|
FetchPreferences() (*notify.HubPrefs, error)
|
||||||
|
SyncPreferencesAs(source, email string, enabledEvents []string, cooldownHours int, emailCleared bool) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type prefsStore interface {
|
||||||
|
GetNotificationPrefs() *settings.NotificationPrefs
|
||||||
|
NotificationPrefsSaved() bool
|
||||||
|
SetNotificationPrefs(*settings.NotificationPrefs) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// startupPrefsSync runs the start-up step above and returns what it did (for the log and the tests): "pushed",
|
||||||
|
// "adopted", "default-pushed", "default-kept", "nothing", "no-hub" or "error".
|
||||||
|
func startupPrefsSync(hub prefsHub, st prefsStore, logger *log.Logger) string {
|
||||||
|
logf := func(f string, a ...any) {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Printf(f, a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs := st.GetNotificationPrefs()
|
||||||
|
if st.NotificationPrefsSaved() {
|
||||||
|
if !prefs.SyncOnStartup() {
|
||||||
|
return "nothing"
|
||||||
|
}
|
||||||
|
if _, err := hub.SyncPreferencesAs(notify.PrefsSourceHousehold, prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil {
|
||||||
|
logf("[WARN] Failed to sync notification preferences on startup: %v", err)
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
return "pushed"
|
||||||
|
}
|
||||||
|
hp, err := hub.FetchPreferences()
|
||||||
|
if err != nil {
|
||||||
|
logf("[WARN] notification prefs: no list saved on this box and the hub's could not be read (%v) — nothing pushed; asked again at the next start (R-1199)", err)
|
||||||
|
return "no-hub"
|
||||||
|
}
|
||||||
|
if hp.Exists && hp.HouseholdSaved {
|
||||||
|
adopted := &settings.NotificationPrefs{Email: hp.Email, EnabledEvents: hp.EnabledEvents, CooldownHours: hp.CooldownHours}
|
||||||
|
if adopted.EnabledEvents == nil {
|
||||||
|
adopted.EnabledEvents = []string{}
|
||||||
|
}
|
||||||
|
if err := st.SetNotificationPrefs(adopted); err != nil {
|
||||||
|
logf("[ERROR] notification prefs: adopting the household's list from the hub failed: %v", err)
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
logf("[INFO] notification prefs: adopted the household's saved list from the hub (%d events, address set=%t) — nothing pushed (R-1199)", len(adopted.EnabledEvents), adopted.Email != "")
|
||||||
|
return "adopted"
|
||||||
|
}
|
||||||
|
status, err := hub.SyncPreferencesAs(notify.PrefsSourceDefault, "", prefs.EnabledEvents, prefs.CooldownHours, false)
|
||||||
|
if err != nil {
|
||||||
|
logf("[WARN] notification prefs: pushing the default list failed: %v", err)
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
if status == "kept" {
|
||||||
|
logf("[INFO] notification prefs: the hub kept the household's own list over this box's default — adopted at the next start (R-1199)")
|
||||||
|
return "default-kept"
|
||||||
|
}
|
||||||
|
local := &settings.NotificationPrefs{EnabledEvents: prefs.EnabledEvents, CooldownHours: prefs.CooldownHours}
|
||||||
|
if err := st.SetNotificationPrefs(local); err != nil {
|
||||||
|
logf("[ERROR] notification prefs: saving the pushed default on this box failed: %v", err)
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
logf("[INFO] notification prefs: no household list anywhere — pushed this box's default once and saved it here (R-1199)")
|
||||||
|
return "default-pushed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1199 REOPENED (2026-10-22) — the brief's four cases, against the real settings file and a hub that behaves like
|
||||||
|
// hub ≥ 0.165.0 (it keeps a household's list over a default push).
|
||||||
|
// RED-PROOF: with v0.338.0's rule restored (no hub read: a box with nothing saved pushes its default at every start)
|
||||||
|
// case A fails (the default reaches the hub) and case D fails (a second push) — seen failing 2026-10-22.
|
||||||
|
|
||||||
|
type fakeHub struct {
|
||||||
|
stored *notify.HubPrefs
|
||||||
|
readErr error
|
||||||
|
pushes []string // the source of every push
|
||||||
|
lastEvents []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeHub) FetchPreferences() (*notify.HubPrefs, error) {
|
||||||
|
if f.readErr != nil {
|
||||||
|
return nil, f.readErr
|
||||||
|
}
|
||||||
|
if f.stored == nil {
|
||||||
|
return ¬ify.HubPrefs{Exists: false}, nil
|
||||||
|
}
|
||||||
|
cp := *f.stored
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeHub) SyncPreferencesAs(source, email string, ev []string, cd int, cleared bool) (string, error) {
|
||||||
|
f.pushes = append(f.pushes, source)
|
||||||
|
if source != notify.PrefsSourceHousehold && f.stored != nil && f.stored.HouseholdSaved {
|
||||||
|
return "kept", nil
|
||||||
|
}
|
||||||
|
if f.stored == nil {
|
||||||
|
f.stored = ¬ify.HubPrefs{Exists: true}
|
||||||
|
}
|
||||||
|
f.stored.EnabledEvents, f.lastEvents = ev, ev
|
||||||
|
if email != "" {
|
||||||
|
f.stored.Email = email
|
||||||
|
}
|
||||||
|
if source == notify.PrefsSourceHousehold {
|
||||||
|
f.stored.HouseholdSaved = true
|
||||||
|
}
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSettings(t *testing.T) *settings.Settings {
|
||||||
|
t.Helper()
|
||||||
|
s, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var familyList = []string{"backup_failed", "disk_warning", "node_down"}
|
||||||
|
|
||||||
|
// A: the household saved on an earlier box; this box has no local save → nothing reaches the hub, the box adopts the
|
||||||
|
// list and its page (GetNotificationPrefs) shows it.
|
||||||
|
func TestR1199Reopened_A_ARebuiltBoxAdoptsTheHouseholdsList(t *testing.T) {
|
||||||
|
hub := &fakeHub{stored: ¬ify.HubPrefs{Exists: true, HouseholdSaved: true, Email: "family@example.hu", EnabledEvents: familyList, CooldownHours: 12}}
|
||||||
|
st := newSettings(t)
|
||||||
|
if got := startupPrefsSync(hub, st, nil); got != "adopted" {
|
||||||
|
t.Fatalf("startup = %q, want adopted", got)
|
||||||
|
}
|
||||||
|
if len(hub.pushes) != 0 {
|
||||||
|
t.Fatalf("a box with no list of its own pushed %v over the household's list", hub.pushes)
|
||||||
|
}
|
||||||
|
p := st.GetNotificationPrefs()
|
||||||
|
if !st.NotificationPrefsSaved() || len(p.EnabledEvents) != 3 || p.Email != "family@example.hu" || p.CooldownHours != 12 {
|
||||||
|
t.Fatalf("the page must show the household's list: %+v", p)
|
||||||
|
}
|
||||||
|
// D after A: the next start re-pushes the adopted list as the household's (it carries an address, R-922) — the
|
||||||
|
// same list, never the default.
|
||||||
|
startupPrefsSync(hub, st, nil)
|
||||||
|
if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceHousehold || len(hub.lastEvents) != 3 {
|
||||||
|
t.Fatalf("after adoption: pushes %v events %v", hub.pushes, hub.lastEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B: no household ever saved (a seeded row, or none) → the default goes once, is saved here, and the hub mails by it.
|
||||||
|
func TestR1199Reopened_B_NoHouseholdListAnywhereTakesTheDefaultOnce(t *testing.T) {
|
||||||
|
for _, stored := range []*notify.HubPrefs{nil, {Exists: true, Email: "seed@example.hu", EnabledEvents: []string{"node_down"}}} {
|
||||||
|
hub := &fakeHub{stored: stored}
|
||||||
|
st := newSettings(t)
|
||||||
|
if got := startupPrefsSync(hub, st, nil); got != "default-pushed" {
|
||||||
|
t.Fatalf("startup = %q, want default-pushed", got)
|
||||||
|
}
|
||||||
|
if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceDefault || len(hub.stored.EnabledEvents) != len(settings.DefaultEnabledEvents) {
|
||||||
|
t.Fatalf("the default must reach the hub once, as a default: %v %v", hub.pushes, hub.stored.EnabledEvents)
|
||||||
|
}
|
||||||
|
if !st.NotificationPrefsSaved() {
|
||||||
|
t.Fatal("the pushed default must be saved on the box")
|
||||||
|
}
|
||||||
|
// D: a restart sends nothing (a local save exists, with no address).
|
||||||
|
if got := startupPrefsSync(hub, st, nil); got != "nothing" || len(hub.pushes) != 1 {
|
||||||
|
t.Fatalf("restart after B: %q, pushes %v — want nothing", got, hub.pushes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C: the household saves on the box → the page save pushes as the household's (web handler, unchanged path); at start
|
||||||
|
// a saved list with an address is re-pushed as the household's (R-922).
|
||||||
|
func TestR1199Reopened_C_ASavedListIsTheHouseholds(t *testing.T) {
|
||||||
|
hub := &fakeHub{stored: ¬ify.HubPrefs{Exists: true, HouseholdSaved: true, EnabledEvents: familyList}}
|
||||||
|
st := newSettings(t)
|
||||||
|
st.SetNotificationPrefs(&settings.NotificationPrefs{Email: "new@example.hu", EnabledEvents: []string{"node_down"}})
|
||||||
|
if got := startupPrefsSync(hub, st, nil); got != "pushed" || hub.pushes[0] != notify.PrefsSourceHousehold {
|
||||||
|
t.Fatalf("a saved list: %q %v", got, hub.pushes)
|
||||||
|
}
|
||||||
|
if len(hub.stored.EnabledEvents) != 1 {
|
||||||
|
t.Fatalf("the household's new choice must reach the hub: %v", hub.stored.EnabledEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hub that cannot be read gets nothing (never a blind default); a hub that keeps its list over a race is not
|
||||||
|
// overwritten and the box saves nothing (it adopts at the next start).
|
||||||
|
func TestR1199Reopened_AnUnreadableHubGetsNothing(t *testing.T) {
|
||||||
|
hub := &fakeHub{readErr: errors.New("hub answered HTTP 404")}
|
||||||
|
st := newSettings(t)
|
||||||
|
if got := startupPrefsSync(hub, st, nil); got != "no-hub" || len(hub.pushes) != 0 || st.NotificationPrefsSaved() {
|
||||||
|
t.Fatalf("unreadable hub: %q pushes %v saved %t", got, hub.pushes, st.NotificationPrefsSaved())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default list the page shows carries the three events the rehearsal household did not get (kept from R-1199).
|
||||||
|
func TestR1199_TheDefaultListHoldsTheAlarmsTheHouseholdMissed(t *testing.T) {
|
||||||
|
want := map[string]bool{"disk_warning": false, "storage_disconnected": false, "crossdrive_failed": false}
|
||||||
|
for _, e := range settings.DefaultEnabledEvents {
|
||||||
|
if _, ok := want[e]; ok {
|
||||||
|
want[e] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for e, ok := range want {
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s is not in the default list", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1105: restic is pinned OUTSIDE the OS. Until v0.323.0 it came from the runtime's apt list, so the
|
||||||
|
// version was „whatever Debian ships" — a Debian 12 → 13 base move would have swapped restic 0.14.0 for
|
||||||
|
// 0.18.0 under the off-site code with nobody choosing it. The consequences pinned here:
|
||||||
|
// - the version is one explicit ARG, and each architecture's checksum is pinned beside it;
|
||||||
|
// - the fetch checks restic's signature (vendored key, no keyserver) AND both checksums;
|
||||||
|
// - the runtime takes restic ONLY from that stage — no apt package can bring a second one;
|
||||||
|
// - every base image is pinned by digest, and the Docker repo's codename is read from the image.
|
||||||
|
func TestR1105_ResticIsPinnedOutsideTheOS(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../Dockerfile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := string(raw)
|
||||||
|
// Stages, split on FROM, with `\`-continued lines joined so an apt list reads as one command.
|
||||||
|
joined := strings.ReplaceAll(src, "\\\n", " ")
|
||||||
|
var stages []string
|
||||||
|
for _, part := range regexp.MustCompile(`(?m)^FROM `).Split(joined, -1)[1:] {
|
||||||
|
stages = append(stages, "FROM "+part)
|
||||||
|
}
|
||||||
|
if len(stages) < 3 {
|
||||||
|
t.Fatalf("expected a builder, a restic and a runtime stage, found %d stages", len(stages))
|
||||||
|
}
|
||||||
|
var fetch string
|
||||||
|
for _, s := range stages {
|
||||||
|
if regexp.MustCompile(`^FROM \S+ AS restic\b`).MatchString(s) {
|
||||||
|
fetch = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fetch == "" {
|
||||||
|
t.Fatal("no `AS restic` stage: restic is no longer fetched as a pinned release binary")
|
||||||
|
}
|
||||||
|
runtime := stages[len(stages)-1]
|
||||||
|
|
||||||
|
if m := regexp.MustCompile(`ARG RESTIC_VERSION=(\d+\.\d+\.\d+)\s`).FindStringSubmatch(fetch); m == nil {
|
||||||
|
t.Error("ARG RESTIC_VERSION=X.Y.Z is missing — the version must be an explicit decision")
|
||||||
|
}
|
||||||
|
for _, arch := range []string{"AMD64", "ARM64"} {
|
||||||
|
if !regexp.MustCompile(`ARG RESTIC_SHA256_` + arch + `=[0-9a-f]{64}\s`).MatchString(fetch) {
|
||||||
|
t.Errorf("no pinned sha256 for %s", arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"COPY third_party/restic/restic-release-key.asc",
|
||||||
|
"gpgv --keyring", // SHA256SUMS is restic's, signed
|
||||||
|
`SHA256SUMS | sha256sum -c -`, // the download matches the published sum
|
||||||
|
`echo "${want} ${f}" | sha256sum -c -`, // ...and the sum pinned in this file
|
||||||
|
} {
|
||||||
|
if !strings.Contains(fetch, want) {
|
||||||
|
t.Errorf("the restic stage no longer does %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("../../third_party/restic/restic-release-key.asc"); err != nil {
|
||||||
|
t.Errorf("the vendored release key is missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(runtime, "COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic") {
|
||||||
|
t.Error("the runtime does not take restic from the pinned stage")
|
||||||
|
}
|
||||||
|
aptRe := regexp.MustCompile(`apt-get install[^&]*`)
|
||||||
|
for _, inst := range aptRe.FindAllString(runtime, -1) {
|
||||||
|
if regexp.MustCompile(`\srestic(\s|$)`).MatchString(inst) {
|
||||||
|
t.Errorf("the runtime's apt list installs restic — a second, OS-chosen restic: %q", strings.Join(strings.Fields(inst), " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, from := range regexp.MustCompile(`(?m)^FROM (\S+)`).FindAllStringSubmatch(src, -1) {
|
||||||
|
if !strings.Contains(from[1], "@sha256:") {
|
||||||
|
t.Errorf("base image %s is not pinned by digest", from[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`download\.docker\.com/linux/debian (bookworm|trixie|forky)`).MatchString(src) {
|
||||||
|
t.Error("the Docker apt repo codename is hard-coded — it must come from /etc/os-release (VERSION_CODENAME)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(runtime, "${VERSION_CODENAME}") {
|
||||||
|
t.Error("the Docker apt repo line does not read VERSION_CODENAME")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1176 (2026-10-21): the off-site apply-bridge waits only until the FLOOR is known (offsiteapply.AwaitSettle), and
|
||||||
|
// then may mint a repository password unless the box has recorded that the hub holds the household's sealed package
|
||||||
|
// (R-241's mint guard reads that record). Both facts arrive in the same report ACK, so inside OnPushResponse the
|
||||||
|
// escrow must be recorded BEFORE updater.SetFloor makes the floor known — otherwise a bridge poll landing between the
|
||||||
|
// two mints a fresh key on a rebuilt box and the household's history is set aside on its first night.
|
||||||
|
// Walks main.go's AST with comments dropped, so a commented-out call cannot satisfy it.
|
||||||
|
// COMPANION RED-PROOF: move escrowConfirmer.Reconcile below updater.SetFloor → this fails.
|
||||||
|
func TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var escrowPos, floorPos token.Pos
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
as, ok := n.(*ast.AssignStmt)
|
||||||
|
if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := as.Lhs[0].(*ast.SelectorExpr)
|
||||||
|
if !ok || sel.Sel.Name != "OnPushResponse" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ast.Inspect(as.Rhs[0], func(m ast.Node) bool {
|
||||||
|
call, ok := m.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s, ok := call.Fun.(*ast.SelectorExpr); ok {
|
||||||
|
if x, ok := s.X.(*ast.Ident); ok {
|
||||||
|
switch {
|
||||||
|
case x.Name == "escrowConfirmer" && s.Sel.Name == "Reconcile" && escrowPos == 0:
|
||||||
|
escrowPos = call.Pos()
|
||||||
|
case x.Name == "updater" && s.Sel.Name == "SetFloor" && floorPos == 0:
|
||||||
|
floorPos = call.Pos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if escrowPos == 0 || floorPos == 0 {
|
||||||
|
t.Fatalf("OnPushResponse no longer calls both escrowConfirmer.Reconcile (%v) and updater.SetFloor (%v) — the order is unasserted", escrowPos, floorPos)
|
||||||
|
}
|
||||||
|
if escrowPos > floorPos {
|
||||||
|
t.Fatalf("escrowConfirmer.Reconcile (%s) runs AFTER updater.SetFloor (%s): a rebuilt box can mint a fresh key before it learns the hub holds the household's package",
|
||||||
|
fset.Position(escrowPos), fset.Position(floorPos))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1189 (d): the catch-up in main.go carries the After hook that runs the off-site proof and the due-gated integrity
|
||||||
|
// check (internal/nightchain TestR1189d_* pins that After runs; this pins that production wires it).
|
||||||
|
// RED-PROOF: the After block removed from main.go → this fails.
|
||||||
|
func TestR1189d_TheCatchUpIsWiredToTheChecks(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(src)
|
||||||
|
i := strings.Index(s, "catchUp := &nightchain.CatchUp{")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("the catch-up literal is gone from main.go")
|
||||||
|
}
|
||||||
|
lit := s[i:]
|
||||||
|
lit = lit[:strings.Index(lit, "\n\t\t\t}\n")]
|
||||||
|
a := strings.Index(lit, "After: func(ctx context.Context) {")
|
||||||
|
if a < 0 || !strings.Contains(lit[a:], "runOffsiteProof(") || !strings.Contains(lit[a:], "runOffsiteIntegrityCheck(ctx, backupMgr, notifier, logger, false)") {
|
||||||
|
t.Fatal("the catch-up must run the off-site proof and the due-gated (force=false) integrity check after its legs")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1253 (review round 2, HUNT-1 H3): an export with „stop the app first" during another app's restore replaced the
|
||||||
|
// ONE app-stop marker — the restore's crash record and its Undo were lost, and a power cut then left the restored app
|
||||||
|
// on mixed data with no put-back. The test is the review's mechanism with two Begins on one guard, through the
|
||||||
|
// exporter's real adapter. RED-PROOF (2026-10-24): with exportStopGuard.Begin calling Begin instead of BeginExclusive,
|
||||||
|
// this fails on „the restore's Undo is gone from the marker".
|
||||||
|
func TestR1253_AnExportNeverReplacesARestoresMarker(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
marker := filepath.Join(dir, "appstop-state.json")
|
||||||
|
g := backup.NewAppStopGuard(marker, log.New(io.Discard, "", 0))
|
||||||
|
if err := g.Begin("unit-restore:bookstack", backup.ReasonUnitRestore, []string{"bookstack"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
undo := filepath.Join(dir, "backups", "restore-undo", "bookstack", "20261024T200000Z")
|
||||||
|
if err := g.SetUndo("bookstack", undo); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ex := exportStopGuard{g: g}
|
||||||
|
if busy := ex.BusyWith(); busy != "unit-restore:bookstack" {
|
||||||
|
t.Fatalf("the export must see the restore holding the marker, got %q", busy)
|
||||||
|
}
|
||||||
|
err := ex.Begin("app-export:immich", []string{"immich"})
|
||||||
|
if !errors.Is(err, backup.ErrAppStopBusy) {
|
||||||
|
t.Fatalf("the export's Begin must be refused while the restore holds the marker, got %v", err)
|
||||||
|
}
|
||||||
|
b, _ := os.ReadFile(marker)
|
||||||
|
if !strings.Contains(string(b), undo) || strings.Contains(string(b), "immich") {
|
||||||
|
t.Fatalf("the restore's Undo is gone from the marker (or the export replaced it): %s", b)
|
||||||
|
}
|
||||||
|
// the restore ends → the export may run
|
||||||
|
g.End()
|
||||||
|
if err := ex.Begin("app-export:immich", []string{"immich"}); err != nil {
|
||||||
|
t.Fatalf("after the restore ended the export must start: %v", err)
|
||||||
|
}
|
||||||
|
ex.End()
|
||||||
|
// a marker a CRASH left (a new process over the same file) does not block: nothing in this process will End it
|
||||||
|
if err := g.Begin("volume-dump:paperless", backup.ReasonVolumeDump, []string{"paperless"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fresh := exportStopGuard{g: backup.NewAppStopGuard(marker, log.New(io.Discard, "", 0))}
|
||||||
|
if busy := fresh.BusyWith(); busy != "" {
|
||||||
|
t.Fatalf("a crash-left marker is not a live operation, got %q", busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1261 — a hold of an app removed while held is dropped only if main gives the watch the installed check; without
|
||||||
|
// it TorrentInstalled is nil, which means „assume installed", and the record waits for an app that never runs again.
|
||||||
|
func TestR1261_MainWiresTheInstalledCheck(t *testing.T) {
|
||||||
|
b, err := os.ReadFile("upwatch_wiring.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), "TorrentInstalled: func(stack string) bool") {
|
||||||
|
t.Fatal("upwatch_wiring.go must wire TorrentInstalled (R-1261)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1279 — the `.fab` import downloads first only if main.go passes the restores' helper in; an unwired exporter keeps
|
||||||
|
// the old order (volumes removed before the pull). Seam-built-but-never-wired is a recorded class (REUSE.md).
|
||||||
|
func TestR1279_MainWiresTheImportsDownloadFirst(t *testing.T) {
|
||||||
|
b, err := os.ReadFile("main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), "appExporter.SetImagePreparer(backupMgr.PrepareRestoreImages)") {
|
||||||
|
t.Fatal("main.go must wire the import's download-first helper (SetImagePreparer)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), "backupMgr.SetUnitSetAsideNotify(") {
|
||||||
|
t.Fatal("main.go must wire the D47 set-aside operator line")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), "func (a *stackAdapter) ContainerSamples(") {
|
||||||
|
t.Fatal("main.go's stack adapter must read container samples for the stable-start rule (fix A)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix A: the sampler reads a container with NO healthcheck (no State.Health) — measured on 9202: a -f template stopped
|
||||||
|
// at „map has no entry for key Health", so every such app read „not started".
|
||||||
|
func TestFixA_ParseContainerSamples(t *testing.T) {
|
||||||
|
out := []byte(`[{"Id":"a1","RestartCount":0,"State":{"Status":"running"}},
|
||||||
|
{"Id":"b2","RestartCount":3,"State":{"Status":"running","Health":{"Status":"healthy"}}},
|
||||||
|
{"Id":"c3","RestartCount":1,"State":{"Status":"restarting"}}]`)
|
||||||
|
got, err := parseContainerSamples(out)
|
||||||
|
if err != nil || len(got) != 3 {
|
||||||
|
t.Fatalf("parse: %v %+v", err, got)
|
||||||
|
}
|
||||||
|
if !got[0].Running || got[0].Health != "" || got[1].Health != "healthy" || got[1].RestartCount != 3 || got[2].Running {
|
||||||
|
t.Fatalf("wrong samples: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1286: demo-hp's Kimai was held by the crash-loop guard (reason unhealthy_stop, 2026-10-14) and the boot sweep, the
|
||||||
|
// start gate and `--restore-holds` all said „held after a failed restore whose rollback also failed" for 11 days.
|
||||||
|
// Each reason now says what it is and what lifts it.
|
||||||
|
// RED-PROOF: holdWhy back to the old two-way branch → the unhealthy_stop, restore_mixed and putback_pending cases fail.
|
||||||
|
func TestR1286_EveryHoldReasonSaysWhatItIs(t *testing.T) {
|
||||||
|
sett := holdTestSettings(t)
|
||||||
|
g := driveStartGate{sett: sett}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
hold settings.RestoreHold
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{settings.RestoreHold{Stack: "kimai", At: "2026-10-14T09:06:31Z", Reason: settings.HoldReasonUnhealthyStop, UnhealthyKind: "crash_loop"}, "stopped by the box for a crash loop"},
|
||||||
|
{settings.RestoreHold{Stack: "a", At: "t", Reason: settings.HoldReasonUpdateFailed}, "failed update"},
|
||||||
|
{settings.RestoreHold{Stack: "b", At: "t", Reason: settings.HoldReasonRestoreMixed}, "off-site restore failed its database replay"},
|
||||||
|
{settings.RestoreHold{Stack: "c", At: "t", Reason: settings.HoldReasonPutBackPending}, "pre-restore copy is poured back"},
|
||||||
|
{settings.RestoreHold{Stack: "d", At: "t"}, "failed restore whose rollback also failed"},
|
||||||
|
} {
|
||||||
|
if err := sett.SetRestoreHold(tc.hold); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ok, why := g.MayStart(tc.hold.Stack)
|
||||||
|
if ok || !strings.Contains(why, tc.want) {
|
||||||
|
t.Fatalf("%s (reason %q): the gate says %q, want it to contain %q", tc.hold.Stack, tc.hold.Reason, why, tc.want)
|
||||||
|
}
|
||||||
|
if tc.hold.Reason != "" && strings.Contains(why, "failed restore whose rollback also failed") {
|
||||||
|
t.Fatalf("%s: a %q hold must not read as a failed restore: %q", tc.hold.Stack, tc.hold.Reason, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── R-330 — the per-app backup's suppression must actually REACH the alarm ───────────────────────
|
||||||
|
//
|
||||||
|
// Measured on demo-hp 2026-08-30 (controller 0.223.0): the nightly `db-dump` and `offbox-backup` legs
|
||||||
|
// stop each stack for ~13 s to tar its volumes, the `deadapp-check` job scans every 30 s, and the
|
||||||
|
// customer got `app_start_failed — "Telepített alkalmazás nem fut: <App>"` for apps that were never
|
||||||
|
// broken. Sixty-one e-mails.
|
||||||
|
//
|
||||||
|
// THE SHAPE OF THE ORIGINAL DEFECT IS WHY THESE TESTS EXIST. `quiesce/suppress.go` already solved
|
||||||
|
// this problem, correctly, in v0.179.0 — and the alarm still fired, because `classifyRunStates` read
|
||||||
|
// only the QUIESCE loop's set while a SECOND mechanism (the AppStopGuard's per-app operations) was
|
||||||
|
// stopping apps with nothing telling the alarm. A component that works and is not consulted is this
|
||||||
|
// project's "seam built but never wired" class, hit five times now. So one test asserts the
|
||||||
|
// CONSEQUENCE (a held app is not reported down) and one asserts the WIRING (main.go passes the
|
||||||
|
// guard), because in this defect the component was never the broken part.
|
||||||
|
|
||||||
|
// TestHeldAppIsNotReportedDown is the consequence: whatever produces the suppression set, an app the
|
||||||
|
// backup is holding must not reach the notifier's Down-set or the dashboard banner.
|
||||||
|
func TestHeldAppIsNotReportedDown(t *testing.T) {
|
||||||
|
g := backup.NewAppStopGuard(t.TempDir()+"/appstop.json", log.New(io.Discard, "", 0))
|
||||||
|
if err := g.Begin("volume-dump:bookstack", backup.ReasonVolumeDump, []string{"bookstack"}); err != nil {
|
||||||
|
t.Fatalf("Begin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sts := []stacks.Stack{
|
||||||
|
stack("bookstack", stacks.StateStopped, true, false), // held by the volume dump
|
||||||
|
stack("romm", stacks.StateExited, true, false), // genuinely broken, must still alarm
|
||||||
|
}
|
||||||
|
// The union is exactly what scanDeployedAppRunStates builds. The nil first argument is the normal
|
||||||
|
// state on a box with no whole-guest backup running — quiesce suppresses nothing, and the app-stop
|
||||||
|
// guard's set has to carry the whole answer on its own.
|
||||||
|
dead, states := classifyRunStates(sts, unionSuppressed(nil, g.SuppressedStacks()), nil, time.Now())
|
||||||
|
|
||||||
|
down := downByName(states)
|
||||||
|
if down["bookstack"] {
|
||||||
|
t.Fatal("the app the backup is holding stopped was reported DOWN — this is the false " +
|
||||||
|
"`app_start_failed` e-mail the customer received every night")
|
||||||
|
}
|
||||||
|
if !down["romm"] {
|
||||||
|
t.Fatal("a genuinely exited app stopped alarming — the fix silenced a real fault, which is " +
|
||||||
|
"the over-correction (F-CRIT-1 / R-88 Scenario D) it must never make")
|
||||||
|
}
|
||||||
|
if deadNames(dead)["bookstack"] {
|
||||||
|
t.Fatal("the held app still reached the dashboard dead-app banner")
|
||||||
|
}
|
||||||
|
if !deadNames(dead)["romm"] {
|
||||||
|
t.Fatal("the genuinely exited app vanished from the dead-app banner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnionSuppressed_KeepsBothMechanisms(t *testing.T) {
|
||||||
|
// Two independent mechanisms stop apps on purpose. Dropping either set re-opens one of the two
|
||||||
|
// false-alarm paths, and the bug shipped because only one was being read.
|
||||||
|
got := unionSuppressed(map[string]bool{"quiesced-app": true}, map[string]bool{"dumped-app": true})
|
||||||
|
if !got["quiesced-app"] || !got["dumped-app"] {
|
||||||
|
t.Fatalf("union = %v, want both the quiesce loop's and the app-stop guard's stacks", got)
|
||||||
|
}
|
||||||
|
if got := unionSuppressed(nil, nil); len(got) != 0 {
|
||||||
|
t.Fatalf("union of two empty sets = %v, want empty", got)
|
||||||
|
}
|
||||||
|
// Neither input may be mutated: both callers hold live maps that other code reads.
|
||||||
|
a := map[string]bool{"a": true}
|
||||||
|
b := map[string]bool{"b": true}
|
||||||
|
unionSuppressed(a, b)
|
||||||
|
if len(a) != 1 || len(b) != 1 {
|
||||||
|
t.Fatalf("unionSuppressed mutated an input: a=%v b=%v", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanDeployedAppRunStatesIsGivenTheAppStopGuard walks main.go's AST. A substring search is not
|
||||||
|
// enough — the sibling wiring tests in this package record why at first hand: a commented-out call
|
||||||
|
// still satisfies strings.Contains, so the text version passed the very red-proof it existed to fail.
|
||||||
|
func TestScanDeployedAppRunStatesIsGivenTheAppStopGuard(t *testing.T) {
|
||||||
|
body := mainBody(t)
|
||||||
|
|
||||||
|
var found, withGuard bool
|
||||||
|
ast.Inspect(body, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
id, ok := call.Fun.(*ast.Ident)
|
||||||
|
if !ok || id.Name != "scanDeployedAppRunStates" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
for _, arg := range call.Args {
|
||||||
|
if a, ok := arg.(*ast.Ident); ok && a.Name == "appStopGuard" {
|
||||||
|
withGuard = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Fatal("func main() never calls scanDeployedAppRunStates — the dead-app scan is not wired at all")
|
||||||
|
}
|
||||||
|
if !withGuard {
|
||||||
|
t.Fatal("scanDeployedAppRunStates is called WITHOUT appStopGuard: the suppression set exists " +
|
||||||
|
"but the alarm never reads it, which is precisely how R-330 shipped while R-97b's " +
|
||||||
|
"identical mechanism sat working three lines away")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-356 §10 seam discipline. The restore now decides "is this app installed?" from
|
||||||
|
// ListDeployedStacks. That answer only means anything if the PRODUCTION adapter is the thing
|
||||||
|
// answering it, and if that adapter really filters on Deployed — a component that is correct in a
|
||||||
|
// fake and unreachable in production is the four-times-shipped defect class in this repo.
|
||||||
|
//
|
||||||
|
// AST, never strings.Contains: a commented-out call still satisfies a substring match.
|
||||||
|
|
||||||
|
// funcBody returns the named top-level func's body from main.go, or fails.
|
||||||
|
func funcBodyInMain(t *testing.T, name string) *ast.BlockStmt {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse main.go: %v", err)
|
||||||
|
}
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
fn, ok := decl.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Body == nil || fn.Name.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fn.Body
|
||||||
|
}
|
||||||
|
t.Fatalf("func %s not found in main.go", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainWiresTheStackProviderIntoTheBackupManager: without this call the backup manager's
|
||||||
|
// stackProvider is nil, isStackDeployed fails closed, and EVERY off-site restore refuses.
|
||||||
|
func TestMainWiresTheStackProviderIntoTheBackupManager(t *testing.T) {
|
||||||
|
body := funcBodyInMain(t, "main")
|
||||||
|
var found bool
|
||||||
|
ast.Inspect(body, 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 != "SetStackProvider" || len(call.Args) != 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Fatal("main() never calls SetStackProvider — the restore's deployment check would fail closed for every app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStackAdapterListDeployedFiltersOnDeployed pins the semantics R-356 depends on: the adapter
|
||||||
|
// returns DEPLOYED stacks, not all known ones. Verified by the presence of the `!s.Deployed`
|
||||||
|
// continue-guard in the method body — remove it and an app that was never deployed would be told it
|
||||||
|
// has somewhere to restore to.
|
||||||
|
func TestStackAdapterListDeployedFiltersOnDeployed(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse main.go: %v", err)
|
||||||
|
}
|
||||||
|
var body *ast.BlockStmt
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
fn, ok := decl.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Recv == nil || fn.Name.Name != "ListDeployedStacks" || fn.Body == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body = fn.Body
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
t.Fatal("stackAdapter.ListDeployedStacks not found in main.go")
|
||||||
|
}
|
||||||
|
var guarded bool
|
||||||
|
ast.Inspect(body, func(n ast.Node) bool {
|
||||||
|
un, ok := n.(*ast.UnaryExpr)
|
||||||
|
if !ok || un.Op != token.NOT {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := un.X.(*ast.SelectorExpr)
|
||||||
|
if ok && sel.Sel.Name == "Deployed" {
|
||||||
|
guarded = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !guarded {
|
||||||
|
t.Fatal("ListDeployedStacks does not test !s.Deployed — an undeployed app would be reported installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-359 — the scheduled job must be PROVEN WIRED.
|
||||||
|
//
|
||||||
|
// `func main()` cannot be called from a test, so this walks its AST. A `strings.Contains` would not
|
||||||
|
// do: a commented-out call still contains the string, and this project has a recorded case of a
|
||||||
|
// text-based wiring test passing the very red-proof it existed to fail (2026-07-21).
|
||||||
|
//
|
||||||
|
// This is the check for the class that has now bitten seven times here — a complete mechanism with no
|
||||||
|
// caller. R-397 is one instance (the notifiers), R-400 another (the debug button that posted to
|
||||||
|
// nothing). A job that exists and is never registered would be the third in this one release.
|
||||||
|
func TestR359_JobIsRegisteredInMain(t *testing.T) {
|
||||||
|
body := mainBody(t)
|
||||||
|
|
||||||
|
var found, named bool
|
||||||
|
ast.Inspect(body, 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 != "Daily" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(call.Args) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||||
|
if !ok || lit.Kind != token.STRING {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if lit.Value == `"offsite-integrity"` {
|
||||||
|
found = true
|
||||||
|
// The schedule argument must be a literal time, not a variable that could resolve to "".
|
||||||
|
if len(call.Args) > 1 {
|
||||||
|
if s, ok := call.Args[1].(*ast.BasicLit); ok && s.Kind == token.STRING && len(s.Value) > 2 {
|
||||||
|
named = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Fatal("func main() never registers the `offsite-integrity` daily job — the check exists and " +
|
||||||
|
"nothing would ever run it, which is the built-but-never-wired class this release closes " +
|
||||||
|
"two instances of")
|
||||||
|
}
|
||||||
|
if !named {
|
||||||
|
t.Error("the job is registered without a literal HH:MM schedule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestR359_DebugCallbackIsWired pins the other caller. The debug button posted to a route that did not
|
||||||
|
// exist for its entire life (R-400); a route that exists but whose callback is nil is the same defect
|
||||||
|
// one layer down, and it would answer „Nem bekötött" forever.
|
||||||
|
func TestR359_DebugCallbackIsWired(t *testing.T) {
|
||||||
|
body := mainBody(t)
|
||||||
|
|
||||||
|
var wired bool
|
||||||
|
ast.Inspect(body, func(n ast.Node) bool {
|
||||||
|
as, ok := n.(*ast.AssignStmt)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, lhs := range as.Lhs {
|
||||||
|
sel, ok := lhs.(*ast.SelectorExpr)
|
||||||
|
if ok && sel.Sel.Name == "RunIntegrityCheck" {
|
||||||
|
wired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !wired {
|
||||||
|
t.Fatal("DebugCallbacks.RunIntegrityCheck is never assigned in func main() — the debug route " +
|
||||||
|
"would answer „Nem bekötött" + " forever, which is exactly the shape of the button this " +
|
||||||
|
"release is fixing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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. 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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(sub) > 0 && len(s) >= len(sub) && (func() bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = parser.ParseFile
|
||||||
|
var _ = token.NewFileSet
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// THE DETECTOR'S OWN POSITIVE CONTROL, at the layer it lives in.
|
||||||
|
//
|
||||||
|
// Part 3 of the 2026-08-22 R-361 session measured that a HELD app raises no dead-app alarm. That is
|
||||||
|
// an ABSENCE claim, and an absence claim is worthless unless the detector is shown working. On the
|
||||||
|
// live box it was hard to hold a stack in a down state long enough to see one: `aggregateState`
|
||||||
|
// checked `unhealthy > 0` BEFORE the mixed-case degraded branch (internal/stacks/manager.go), so an
|
||||||
|
// app whose database died went `degraded` for a moment and then `unhealthy` as its own healthcheck
|
||||||
|
// failed — and `unhealthy` is deliberately not in `IsDownState`.
|
||||||
|
//
|
||||||
|
// **That sentence described R-384, and nobody filed it.** It was written here as an inconvenience
|
||||||
|
// while building this control; it was in fact the mechanism by which a dead database raised no alarm
|
||||||
|
// at all. Fixed in v0.222.0 by asking the supervised-down question FIRST — see
|
||||||
|
// `internal/stacks/manager.go` aggregateState and TestR384_DeadSupervisedMemberIsAskedAboutFirst.
|
||||||
|
// The past tense above is deliberate: the ordering it describes is no longer the code's.
|
||||||
|
//
|
||||||
|
// `classifyRunStates` is pure, so the control is exact here rather than a race against health probes.
|
||||||
|
func TestClassifyRunStates_PositiveControl_ADownStackDoesAlarm(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
for _, st := range []stacks.ContainerState{stacks.StateDegraded, stacks.StateExited} {
|
||||||
|
sts := []stacks.Stack{{Name: "victim", Deployed: true, State: st}}
|
||||||
|
dead, states := classifyRunStates(sts, nil, nil, now)
|
||||||
|
if len(dead) != 1 {
|
||||||
|
t.Errorf("%s: the dead-app banner must fire, got %d entries", st, len(dead))
|
||||||
|
}
|
||||||
|
if len(states) != 1 || !states[0].Down {
|
||||||
|
t.Errorf("%s: the run state must be Down, got %+v", st, states)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and the states measured on the live box do NOT alarm, which is why the held app was silent.
|
||||||
|
// This is the other half of the same control: it pins WHY, so the next reader does not re-derive it.
|
||||||
|
func TestClassifyRunStates_TheStatesMeasuredLiveDoNotAlarm(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
cases := []struct {
|
||||||
|
state stacks.ContainerState
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{stacks.StateUnhealthy, "a held app, and any app whose database died, aggregates here — unhealthy is not in IsDownState"},
|
||||||
|
{stacks.StateStopped, "a fully stopped app is whitelisted as a deliberate user stop unless quiesce failed to restart it"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
sts := []stacks.Stack{{Name: "victim", Deployed: true, State: c.state}}
|
||||||
|
dead, states := classifyRunStates(sts, nil, nil, now)
|
||||||
|
if len(dead) != 0 {
|
||||||
|
t.Errorf("%s: expected no banner (%s), got %d", c.state, c.why, len(dead))
|
||||||
|
}
|
||||||
|
if len(states) != 1 || states[0].Down {
|
||||||
|
t.Errorf("%s: expected not-Down (%s), got %+v", c.state, c.why, states)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-363 / R-547: the fill watcher ran once a day (plus once after start), so a filesystem that filled
|
||||||
|
// right after the 03:30 run went unannounced for ~24 h, and a disk held at 96 % for ten minutes raised
|
||||||
|
// nothing. The consequence pinned here: main.go registers the SAME fillWatcher.Check on a periodic
|
||||||
|
// job whose interval is no longer than the shortest measured silent window (10 min), so any such
|
||||||
|
// window contains a check. Edge-triggering (no repeat mails at a faster cadence) is pinned in
|
||||||
|
// internal/fillwatch (TestWarnsOnceThenIsSilent, TestEdgeStateSurvivesARestart).
|
||||||
|
func TestFillWatchRunsOnAnInterval(t *testing.T) {
|
||||||
|
const measuredSilentWindow = 10 * time.Minute // R-547, chaos night 2026-09-17
|
||||||
|
if fillWatchInterval <= 0 || fillWatchInterval > measuredSilentWindow {
|
||||||
|
t.Errorf("fillWatchInterval = %s; it must be >0 and <= %s, or a fill window like R-547's can pass unseen", fillWatchInterval, measuredSilentWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
periodic := 0
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||||
|
if !ok || sel.Sel.Name != "Every" || len(call.Args) != 3 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
iv, ok := call.Args[1].(*ast.Ident)
|
||||||
|
if !ok || iv.Name != "fillWatchInterval" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// The job body must call fillWatcher.Check — the same edge-triggered check, not a new one.
|
||||||
|
ast.Inspect(call.Args[2], func(m ast.Node) bool {
|
||||||
|
if c, ok := m.(*ast.CallExpr); ok {
|
||||||
|
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "Check" {
|
||||||
|
if x, ok := s.X.(*ast.Ident); ok && x.Name == "fillWatcher" {
|
||||||
|
periodic++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if periodic != 1 {
|
||||||
|
t.Errorf("R-363: main.go registers fillWatcher.Check on a periodic (sched.Every, fillWatchInterval) job %d times, want 1 — without it the fill check is daily only", periodic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-379 SCENARIO D — a held app, later. A hold that only one start path honours is not a hold, so
|
||||||
|
// each path is proven rather than asserted.
|
||||||
|
|
||||||
|
func holdTestSettings(t *testing.T) *settings.Settings {
|
||||||
|
t.Helper()
|
||||||
|
s, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SHARED gate refuses a held app — this is the path both the boot sweep (via bootDriveGate) and
|
||||||
|
// the app-stop guard's Recover() reach.
|
||||||
|
//
|
||||||
|
// POSITIVE CONTROL BUILT IN: the same app, same gate, is allowed once the hold is cleared. Without
|
||||||
|
// it "the gate refuses" could be true because the gate refuses everything.
|
||||||
|
func TestR379_DriveStartGate_RefusesAHeldApp_AndAllowsItAfterClearing(t *testing.T) {
|
||||||
|
sett := holdTestSettings(t)
|
||||||
|
g := driveStartGate{sett: sett} // nil mgr on purpose: the hold must be answered BEFORE the drive
|
||||||
|
|
||||||
|
// Before: no hold. The nil manager makes this "cannot determine", which is NOT the hold's reason.
|
||||||
|
if _, why := g.MayStart("docmost"); why == "" {
|
||||||
|
t.Fatal("fixture: expected some reason from the bare gate")
|
||||||
|
} else if strings.Contains(why, "held after a failed restore") {
|
||||||
|
t.Fatalf("no hold exists yet, but the gate cited one: %q", why)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "docmost", At: "2026-08-22T14:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ok, why := g.MayStart("docmost")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("a held app must not be startable")
|
||||||
|
}
|
||||||
|
if !strings.Contains(why, "held after a failed restore") {
|
||||||
|
t.Errorf("the refusal must NAME the hold, got %q", why)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSITIVE CONTROL: clear it, and the gate stops citing the hold.
|
||||||
|
cleared, err := sett.ClearRestoreHold("docmost")
|
||||||
|
if err != nil || !cleared {
|
||||||
|
t.Fatalf("clearing the hold failed: cleared=%v err=%v", cleared, err)
|
||||||
|
}
|
||||||
|
if _, why := g.MayStart("docmost"); strings.Contains(why, "held after a failed restore") {
|
||||||
|
t.Errorf("the hold was cleared but the gate still cites it: %q", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hold is checked ABOVE the `HDD_PATH == ""` early return. The apps this exists for are
|
||||||
|
// DRIVELESS — a failed database restore is the case, and 40 of 53 catalogue apps have no drive — so
|
||||||
|
// a hold placed after that return would never be consulted for the exact class it was built for.
|
||||||
|
func TestR379_HoldIsCheckedBeforeTheDrivelessEarlyReturn(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var body *ast.BlockStmt
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
fn, ok := d.(*ast.FuncDecl)
|
||||||
|
if ok && fn.Recv != nil && fn.Name.Name == "MayStart" && fn.Body != nil {
|
||||||
|
// driveStartGate is the receiver we want; bootDriveGate's MayStart is separate.
|
||||||
|
if st, ok := fn.Recv.List[0].Type.(*ast.Ident); ok && st.Name == "driveStartGate" {
|
||||||
|
body = fn.Body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
t.Fatal("driveStartGate.MayStart not found in main.go")
|
||||||
|
}
|
||||||
|
holdPos, drivelessPos := -1, -1
|
||||||
|
ast.Inspect(body, func(n ast.Node) bool {
|
||||||
|
if c, ok := n.(*ast.CallExpr); ok {
|
||||||
|
if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "GetRestoreHold" && holdPos < 0 {
|
||||||
|
holdPos = fset.Position(c.Pos()).Line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the `hdd == ""` driveless early return
|
||||||
|
if b, ok := n.(*ast.BinaryExpr); ok && b.Op == token.EQL && drivelessPos < 0 {
|
||||||
|
if id, ok := b.X.(*ast.Ident); ok && id.Name == "hdd" {
|
||||||
|
drivelessPos = fset.Position(b.Pos()).Line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if holdPos < 0 {
|
||||||
|
t.Fatal("driveStartGate.MayStart never consults GetRestoreHold — a held app would start from the boot sweep and from Recover()")
|
||||||
|
}
|
||||||
|
if drivelessPos < 0 {
|
||||||
|
t.Fatal("the driveless early return was not found — this test can no longer prove the ordering")
|
||||||
|
}
|
||||||
|
if holdPos > drivelessPos {
|
||||||
|
t.Fatalf("the hold check (line %d) is AFTER the driveless early return (line %d) — it would never fire for the 40 driveless apps this exists for", holdPos, drivelessPos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The production wiring: main() must hand the backup manager its hold notifier, or a held app is
|
||||||
|
// held silently and no operator ever hears. AST, not strings.Contains — a commented-out call still
|
||||||
|
// contains the string.
|
||||||
|
func TestR379_MainWiresTheRestoreHoldNotifier(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
fn, ok := d.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Name.Name != "main" || fn.Body == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
c, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetRestoreHoldNotify" {
|
||||||
|
found = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("main() never calls SetRestoreHoldNotify — an app would be held with nobody told")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-384, the classifier half of the chain.
|
||||||
|
//
|
||||||
|
// `internal/stacks` TestR384_WiresTheDeadDatabaseThroughTheRealPath drives docker ps → aggregateState
|
||||||
|
// and ends at the STATE. This starts from that state and asserts the CONSEQUENCE: the app is reported
|
||||||
|
// down, so the banner shows it and `NotifyAppStartFailures` has something to fire on. Asserting only
|
||||||
|
// the state would repeat case #9 of the invariant table — mechanism pinned, consequence unpinned.
|
||||||
|
//
|
||||||
|
// Measured live on `demo-hp` 2026-08-22: `bookstack-db` stopped at 21:27:01, the stack read
|
||||||
|
// `unhealthy`, and the F-OBS heartbeat printed "0 currently down" throughout.
|
||||||
|
//
|
||||||
|
// RED-PROOF (observed): with the R-384 hoist reverted, the upstream state is `unhealthy`, and feeding
|
||||||
|
// `unhealthy` here (the second subtest) shows exactly what the live box did — no banner, Down=false.
|
||||||
|
func TestR384_ADeadDatabaseBehindAnUnhealthyAppAlarms(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// What v0.222.0 produces for the bookstack shape.
|
||||||
|
sts := []stacks.Stack{{Name: "bookstack", Deployed: true, State: stacks.StateDegraded}}
|
||||||
|
dead, states := classifyRunStates(sts, nil, nil, now)
|
||||||
|
if len(dead) != 1 || dead[0].Name != "bookstack" {
|
||||||
|
t.Fatalf("dead-app banner = %+v, want exactly one entry for bookstack", dead)
|
||||||
|
}
|
||||||
|
if dead[0].State != string(stacks.StateDegraded) {
|
||||||
|
t.Errorf("banner state = %q, want %q — the operator must see WHY", dead[0].State, stacks.StateDegraded)
|
||||||
|
}
|
||||||
|
if len(states) != 1 || !states[0].Down {
|
||||||
|
t.Fatalf("run states = %+v, want Down=true (this is what NotifyAppStartFailures reads)", states)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pre-fix reading, kept as the contrast that makes the fix legible: `unhealthy` reaching this
|
||||||
|
// point is silent, and that silence is correct HERE — it is why the fix had to be upstream, in the
|
||||||
|
// ORDER of the questions, and not by widening IsDownState.
|
||||||
|
preFix := []stacks.Stack{{Name: "bookstack", Deployed: true, State: stacks.StateUnhealthy}}
|
||||||
|
deadPre, statesPre := classifyRunStates(preFix, nil, nil, now)
|
||||||
|
if len(deadPre) != 0 || statesPre[0].Down {
|
||||||
|
t.Fatalf("unhealthy must remain silent at the classifier: dead=%+v states=%+v — R-384 must "+
|
||||||
|
"not have folded unhealthy into the down set", deadPre, statesPre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario E: the quiesce suppression must still hold for a stack that now reads `degraded`.
|
||||||
|
// R-97b's defect was telling a customer their app broke during an outage the BACKUP caused, and this
|
||||||
|
// change must not reopen that door — the suppression is cycle-keyed, so it must be state-blind.
|
||||||
|
func TestR384_QuiesceSuppressionStillHoldsForDegraded(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
sts := []stacks.Stack{{Name: "bookstack", Deployed: true, State: stacks.StateDegraded}}
|
||||||
|
dead, states := classifyRunStates(sts, map[string]bool{"bookstack": true}, nil, now)
|
||||||
|
if len(dead) != 0 {
|
||||||
|
t.Fatalf("a quiesced stack alarmed: %+v — R-97b's exact defect, back through R-384's door", dead)
|
||||||
|
}
|
||||||
|
if states[0].Down {
|
||||||
|
t.Fatalf("a quiesced stack reported Down=true — the customer would be told the backup broke their app")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-386 — the dead-app classifier asked the STATE whether the customer wanted an app stopped, when
|
||||||
|
// the product already records the ANSWER.
|
||||||
|
//
|
||||||
|
// THE DEFECT. `userStopped` was `st.State == StateStopped && !failedRestart[...]` — every stopped
|
||||||
|
// stack was assumed to be a deliberate customer stop. Measured live on `demo-hp` 2026-08-23:
|
||||||
|
// `privatebin` stopped out of band, nine dead-app scans over four minutes, **zero events and zero
|
||||||
|
// banner lines**, against a positive control from the same box seventeen minutes earlier. The comment
|
||||||
|
// beside the code claimed an out-of-band stop "still alerts". It did not.
|
||||||
|
//
|
||||||
|
// THE LAYER. These sit at `classifyRunStates`, which is the single derivation point for "this app is
|
||||||
|
// down" and the only place the guess was made. `classifyRunStates` is pure, so the fixture is exact
|
||||||
|
// rather than a race against docker.
|
||||||
|
//
|
||||||
|
// RED-PROOF (observed, see REPORT.md): restore the pre-fix predicate
|
||||||
|
//
|
||||||
|
// userStopped := st.State == stacks.StateStopped && !failedRestart[st.Name]
|
||||||
|
//
|
||||||
|
// and TestR386_OutOfBandStopWithRunningIntentAlarms fails with `dead-app banner = [], want privatebin`
|
||||||
|
// — which is exactly what the live box reported.
|
||||||
|
|
||||||
|
func stoppedStack(name, intent string) stacks.Stack {
|
||||||
|
st := stacks.Stack{Name: name, Deployed: true, State: stacks.StateStopped}
|
||||||
|
if intent != stacks.DesiredStateUnknown {
|
||||||
|
st.AppConfig = &stacks.AppConfig{DesiredState: intent}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario D — R-386's measured case. Intent says Running; something stopped it anyway.
|
||||||
|
func TestR386_OutOfBandStopWithRunningIntentAlarms(t *testing.T) {
|
||||||
|
dead, states := classifyRunStates(
|
||||||
|
[]stacks.Stack{stoppedStack("privatebin", stacks.DesiredStateRunning)}, nil, nil, time.Now())
|
||||||
|
|
||||||
|
if len(dead) != 1 || dead[0].Name != "privatebin" {
|
||||||
|
t.Fatalf("dead-app banner = %+v, want exactly privatebin — nobody asked for this app to be "+
|
||||||
|
"stopped, so its being stopped is a fault (measured silent on demo-hp 2026-08-23)", dead)
|
||||||
|
}
|
||||||
|
if !states[0].Down {
|
||||||
|
t.Fatalf("run state = %+v, want Down=true (this is what NotifyAppStartFailures reads)", states[0])
|
||||||
|
}
|
||||||
|
if states[0].IntentUnknown {
|
||||||
|
t.Errorf("IntentUnknown set for an app with a RECORDED intent — the log line would name an " +
|
||||||
|
"app that is not actually ambiguous")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario C — the customer pressed Stop. Telling them their own action was a fault is the thing
|
||||||
|
// v0.164.0 was written to stop, and R-386 must not undo it.
|
||||||
|
func TestR386_CustomerStopStaysSilent(t *testing.T) {
|
||||||
|
dead, states := classifyRunStates(
|
||||||
|
[]stacks.Stack{stoppedStack("docmost", stacks.DesiredStateStopped)}, nil, nil, time.Now())
|
||||||
|
|
||||||
|
if len(dead) != 0 {
|
||||||
|
t.Fatalf("a customer's own Stop raised an alarm: %+v", dead)
|
||||||
|
}
|
||||||
|
if states[0].Down {
|
||||||
|
t.Fatalf("run state = %+v, want Down=false — the customer asked for this", states[0])
|
||||||
|
}
|
||||||
|
if states[0].IntentUnknown {
|
||||||
|
t.Errorf("IntentUnknown set for an app whose intent is RECORDED as stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario E — intent absent. §4's ruling: keep today's behaviour, and SAY SO.
|
||||||
|
func TestR386_AbsentIntentSuppressesButIsAnnounced(t *testing.T) {
|
||||||
|
dead, states := classifyRunStates(
|
||||||
|
[]stacks.Stack{stoppedStack("legacy-app", stacks.DesiredStateUnknown)}, nil, nil, time.Now())
|
||||||
|
|
||||||
|
if len(dead) != 0 {
|
||||||
|
t.Fatalf("an app with NO recorded intent alarmed: %+v — on the first cycle after upgrade "+
|
||||||
|
"that is every app any owner ever deliberately stopped, fleet-wide", dead)
|
||||||
|
}
|
||||||
|
if states[0].Down {
|
||||||
|
t.Fatalf("run state = %+v, want Down=false", states[0])
|
||||||
|
}
|
||||||
|
// THE HALF THAT MAKES THE GAP BOUNDED RATHER THAN SILENT.
|
||||||
|
if !states[0].IntentUnknown {
|
||||||
|
t.Fatalf("IntentUnknown = false — the suppression happened but nothing records it, so an " +
|
||||||
|
"operator cannot answer 'how many apps am I blind to?'. A rule without a mechanism is " +
|
||||||
|
"not a rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the log line itself — the mechanism §4 demands, asserted as an OBSERVABLE, not as "the
|
||||||
|
// function ran". The heartbeat cadence is deliberate; drive the counter to a firing scan.
|
||||||
|
func TestR386_UnknownIntentIsLoggedWithTheAppName(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := log.New(&buf, "", 0)
|
||||||
|
|
||||||
|
states := []notify.AppRunState{
|
||||||
|
{Name: "zulip", IntentUnknown: true},
|
||||||
|
{Name: "legacy-app", IntentUnknown: true},
|
||||||
|
{Name: "docmost"}, // recorded intent — must NOT appear
|
||||||
|
}
|
||||||
|
|
||||||
|
saved := deadAppScans
|
||||||
|
t.Cleanup(func() { deadAppScans = saved })
|
||||||
|
|
||||||
|
// A non-firing scan says nothing (the 2880-lines/day lesson).
|
||||||
|
deadAppScans = 1
|
||||||
|
noteUnknownIntentSuppressions(logger, states)
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Fatalf("logged on a non-heartbeat scan: %q", buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A firing scan names every ambiguous app, and only those.
|
||||||
|
deadAppScans = deadAppHeartbeatEvery
|
||||||
|
noteUnknownIntentSuppressions(logger, states)
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "[INFO]") {
|
||||||
|
t.Errorf("not logged at INFO — it must survive a default logging.level box: %q", out)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"zulip", "legacy-app", "2 stopped app(s)", "R-386"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("log line %q does not contain %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "docmost") {
|
||||||
|
t.Errorf("log line names an app with a RECORDED intent: %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing ambiguous → nothing said.
|
||||||
|
buf.Reset()
|
||||||
|
noteUnknownIntentSuppressions(logger, []notify.AppRunState{{Name: "docmost"}})
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Errorf("logged with no ambiguous apps: %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario F — F-CRIT-1 must not re-open. The quiesce loop stops stacks by the same path a customer
|
||||||
|
// does, so a stack it stopped and could NOT restart must alarm WHATEVER the intent says — including
|
||||||
|
// when the intent is `Stopped`, which is the case that would silently swallow it.
|
||||||
|
func TestR386_FailedRestartStillLiftsAStoppedIntent(t *testing.T) {
|
||||||
|
for _, intent := range []string{
|
||||||
|
stacks.DesiredStateStopped, stacks.DesiredStateRunning, stacks.DesiredStateUnknown,
|
||||||
|
} {
|
||||||
|
name := intent
|
||||||
|
if name == "" {
|
||||||
|
name = "(absent)"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
dead, states := classifyRunStates(
|
||||||
|
[]stacks.Stack{stoppedStack("bookstack", intent)},
|
||||||
|
nil, map[string]bool{"bookstack": true}, time.Now())
|
||||||
|
|
||||||
|
if len(dead) != 1 {
|
||||||
|
t.Fatalf("intent %q: a stack the quiesce loop stopped and FAILED to restart did not "+
|
||||||
|
"alarm — this is F-CRIT-1's indefinitely-silent dead app, back through R-386's "+
|
||||||
|
"door: %+v", intent, dead)
|
||||||
|
}
|
||||||
|
if !states[0].Down {
|
||||||
|
t.Fatalf("intent %q: run state = %+v, want Down=true", intent, states[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The quiesce suppression is unchanged and still wins inside its own window, for every intent.
|
||||||
|
func TestR386_QuiesceSuppressionIsUntouched(t *testing.T) {
|
||||||
|
for _, intent := range []string{stacks.DesiredStateRunning, stacks.DesiredStateStopped} {
|
||||||
|
dead, states := classifyRunStates(
|
||||||
|
[]stacks.Stack{stoppedStack("bookstack", intent)},
|
||||||
|
map[string]bool{"bookstack": true}, nil, time.Now())
|
||||||
|
if len(dead) != 0 || states[0].Down {
|
||||||
|
t.Fatalf("intent %q: a quiesced stack alarmed (%+v) — R-97b's exact defect: the customer "+
|
||||||
|
"told their app broke during an outage the backup caused", intent, dead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A NON-stopped down state is unaffected by intent. A `degraded` stack (R-384) alarms regardless —
|
||||||
|
// intent only ever governed the StateStopped whitelist, and widening it would silence R-384.
|
||||||
|
func TestR386_IntentDoesNotReachNonStoppedDownStates(t *testing.T) {
|
||||||
|
st := stacks.Stack{
|
||||||
|
Name: "bookstack", Deployed: true, State: stacks.StateDegraded,
|
||||||
|
AppConfig: &stacks.AppConfig{DesiredState: stacks.DesiredStateStopped},
|
||||||
|
}
|
||||||
|
dead, states := classifyRunStates([]stacks.Stack{st}, nil, nil, time.Now())
|
||||||
|
if len(dead) != 1 || !states[0].Down {
|
||||||
|
t.Fatalf("a degraded stack was silenced by a Stopped intent (%+v) — R-386 must not reach "+
|
||||||
|
"past the StateStopped whitelist, or it undoes R-384", dead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- production wiring (§10) --------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// A correct classifier the scheduler does not call is R-106's defect. This walks the AST of
|
||||||
|
// main.go and proves the real job wires BOTH halves: the classification and the announcement.
|
||||||
|
func TestR386_TheSchedulerWiresBothHalves(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse main.go: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var scan, announce, desiredRead bool
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch fn := call.Fun.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
switch fn.Name {
|
||||||
|
case "scanDeployedAppRunStates":
|
||||||
|
scan = true
|
||||||
|
case "noteUnknownIntentSuppressions":
|
||||||
|
announce = true
|
||||||
|
}
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
// stacks.DesiredStateOf(st) — the intent read itself, inside classifyRunStates
|
||||||
|
if fn.Sel.Name == "DesiredStateOf" {
|
||||||
|
desiredRead = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if !scan {
|
||||||
|
t.Error("scanDeployedAppRunStates is never called from main.go — the detector is not wired")
|
||||||
|
}
|
||||||
|
if !announce {
|
||||||
|
t.Error("noteUnknownIntentSuppressions is never called from main.go — the unknown-intent gap " +
|
||||||
|
"is silent again, which is the half §4 required to make it bounded")
|
||||||
|
}
|
||||||
|
if !desiredRead {
|
||||||
|
t.Error("stacks.DesiredStateOf is never called from main.go — the classifier is back to " +
|
||||||
|
"guessing from the state (R-386)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE FENCE (§12): DesiredState has exactly ONE writer — the customer's own action. This session
|
||||||
|
// must not have added a second. Twelve of StopStack's fourteen callers are machines, so a writer in
|
||||||
|
// the wrong place makes a nightly backup indistinguishable from the customer pressing Stop.
|
||||||
|
func TestR386_NoNewDesiredStateWriterInMain(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetDesiredState" {
|
||||||
|
t.Errorf("%s: SetDesiredState is called from main.go — the field has ONE writer, the "+
|
||||||
|
"customer's own action. Reading it is fine; writing it here is the fenced act",
|
||||||
|
fset.Position(call.Pos()))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
_ = strings.TrimSpace
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// B6 — R-399/R-401: SLOWNESS RAISES NO HUB EVENT. This asserts a NON-EFFECT.
|
||||||
|
//
|
||||||
|
// A "this took a while" event type would cost the severity contract, the grain table and three
|
||||||
|
// registers, and 08 §6.2's coarse-by-default rule points the other way. Worse, this project's
|
||||||
|
// dispatcher coerces any severity outside {info,warning,error,critical} to `info` and mails nobody
|
||||||
|
// while still returning 200 — so an event added carelessly here would be indistinguishable from one
|
||||||
|
// that works.
|
||||||
|
//
|
||||||
|
// AST, not strings.Contains: a commented-out call still contains the string, and this repo has a
|
||||||
|
// recorded case of a text-based wiring test passing the very red-proof it existed to fail.
|
||||||
|
func TestR399_SlownessRaisesNoHubEvent(t *testing.T) {
|
||||||
|
body := funcBody(t, "runOffsiteIntegrityCheck")
|
||||||
|
|
||||||
|
// Every notifier method the one integrity caller invokes.
|
||||||
|
var notifies []string
|
||||||
|
ast.Inspect(body, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(sel.Sel.Name) >= 6 && sel.Sel.Name[:6] == "Notify" {
|
||||||
|
notifies = append(notifies, sel.Sel.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// POSITIVE CONTROL. If this list is empty the test proves nothing — it would pass just as happily
|
||||||
|
// against a file it failed to walk.
|
||||||
|
allowed := map[string]bool{"NotifyIntegrityOK": true, "NotifyIntegrityFailed": true}
|
||||||
|
if len(notifies) == 0 {
|
||||||
|
t.Fatal("no Notify* call was found in runOffsiteIntegrityCheck — the walk found nothing, so " +
|
||||||
|
"the non-effect below would be asserted against an empty set and would pass vacuously")
|
||||||
|
}
|
||||||
|
for _, name := range notifies {
|
||||||
|
if !allowed[name] {
|
||||||
|
t.Errorf("runOffsiteIntegrityCheck calls %s — slowness and depth are OPERATOR notices in "+
|
||||||
|
"the log, and the only two customer-facing notifications this job may raise are the "+
|
||||||
|
"integrity pass and the integrity failure", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// funcBody returns the body of a named top-level function in main.go.
|
||||||
|
func funcBody(t *testing.T, name string) *ast.BlockStmt {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse main.go: %v", err)
|
||||||
|
}
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name && fn.Body != nil {
|
||||||
|
return fn.Body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("func %s not found in main.go", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-475 — the update reads EVERY backup tier, through the one adapter.
|
||||||
|
|
||||||
|
func TestR475_TierConstantsAgree(t *testing.T) {
|
||||||
|
if stacks.UpdateTierLocal != backup.UpdateTierLocal ||
|
||||||
|
stacks.UpdateTierSecondDrive != backup.UpdateTierSecondDrive ||
|
||||||
|
stacks.UpdateTierOffsite != backup.UpdateTierOffsite {
|
||||||
|
t.Fatal("stacks and backup number the tiers differently — a hold would name the wrong copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adapterMethodSelectors returns the selector names used in updateGuardsAdapter.<name>'s body.
|
||||||
|
func adapterMethodSelectors(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
fn, ok := d.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Recv == nil || fn.Name.Name != name || fn.Body == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
star, ok := fn.Recv.List[0].Type.(*ast.StarExpr)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id, ok := star.X.(*ast.Ident); !ok || id.Name != "updateGuardsAdapter" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
if sel, ok := n.(*ast.SelectorExpr); ok {
|
||||||
|
names = append(names, sel.Sel.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return " " + strings.Join(names, " ") + " "
|
||||||
|
}
|
||||||
|
t.Fatalf("updateGuardsAdapter.%s not found in main.go", name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR475_AdapterReadsEveryTier(t *testing.T) {
|
||||||
|
rp := adapterMethodSelectors(t, "RestorePoints")
|
||||||
|
if !strings.Contains(rp, " UpdateRestorePoints ") {
|
||||||
|
t.Errorf("the adapter must read every tier through backup.UpdateRestorePoints; selectors:%s", rp)
|
||||||
|
}
|
||||||
|
if strings.Contains(rp, " Tier2UnitRestorePoint ") {
|
||||||
|
t.Error("the Tier-2-only predicate must not come back into the update path (R-475)")
|
||||||
|
}
|
||||||
|
if cb := adapterMethodSelectors(t, "CanBackUp"); !strings.Contains(cb, " CanBackUpApp ") {
|
||||||
|
t.Errorf("CanBackUp must ask backup.CanBackUpApp; selectors:%s", cb)
|
||||||
|
}
|
||||||
|
// R-659 (v0.268.0) CHANGED THIS ON PURPOSE: the hold no longer names the precondition copy the
|
||||||
|
// adapter is handed (round 11: that copy was one the restore refuses). The tier, and R-479's
|
||||||
|
// "what the copy holds", are now chosen inside backup.HoldAfterFailedUpdateWhole from every tier —
|
||||||
|
// pinned there by TestR659_TheHoldNamesOnlyAWholeCopy (which asserts the holds-phrase too).
|
||||||
|
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " HoldAfterFailedUpdateWhole ") {
|
||||||
|
t.Errorf("the hold must be recorded through HoldAfterFailedUpdateWhole (R-659); selectors:%s", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-490 — /api/system/info reaches the API router; the web layer's /api/system/ prefix used to eat it.
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (REPORT.md): delete the exact-path mount — this fails.
|
||||||
|
func TestR490_SystemInfoIsMountedAheadOfTheWebPrefix(t *testing.T) {
|
||||||
|
src, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var exact, prefix bool
|
||||||
|
ast.Inspect(src, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok || len(call.Args) < 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||||
|
if !ok || sel.Sel.Name != "Handle" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch lit.Value {
|
||||||
|
case `"/api/system/info"`:
|
||||||
|
exact = strings.Contains(fmtNode(call.Args[1]), "apiRouter")
|
||||||
|
case `"/api/system/"`:
|
||||||
|
prefix = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !prefix {
|
||||||
|
t.Fatal("the /api/system/ prefix mount is gone — this test's premise moved")
|
||||||
|
}
|
||||||
|
if !exact {
|
||||||
|
t.Error("/api/system/info must be mounted on the API router ahead of the web layer's /api/system/ prefix (R-490)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtNode(n ast.Node) string {
|
||||||
|
var names []string
|
||||||
|
ast.Inspect(n, func(x ast.Node) bool {
|
||||||
|
if id, ok := x.(*ast.Ident); ok {
|
||||||
|
names = append(names, id.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return strings.Join(names, " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeHDD struct {
|
||||||
|
sts []stacks.Stack
|
||||||
|
hdd map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeHDD) GetStacks() []stacks.Stack { return f.sts }
|
||||||
|
func (f fakeHDD) LoadAppConfigByName(n string) *stacks.AppConfig {
|
||||||
|
return &stacks.AppConfig{Env: map[string]string{"HDD_PATH": f.hdd[n]}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-521: one unplugged drive mailed the operator five times — storage_disconnected plus an
|
||||||
|
// app_start_failed per app on it. The consequence asserted: the NOTIFIER is told the drive's apps are
|
||||||
|
// not down (no per-app mail), while the dashboard's dead list still carries them, and an app on
|
||||||
|
// another drive that is genuinely down still alarms.
|
||||||
|
func TestR521_DriveLostAppsDoNotMailOneByOne(t *testing.T) {
|
||||||
|
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range []string{"/mnt/felhom-drives/usb", "/mnt/felhom-drives/other"} {
|
||||||
|
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: p, Schedulable: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", true, []string{"immich"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sts := []stacks.Stack{
|
||||||
|
stack("immich", stacks.StateExited, true, false), // recorded by the disconnect
|
||||||
|
stack("paperless", stacks.StateExited, true, false), // on the lost drive by HDD_PATH
|
||||||
|
stack("romm", stacks.StateExited, true, false), // another drive, genuinely broken
|
||||||
|
}
|
||||||
|
for i := range sts {
|
||||||
|
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
|
||||||
|
}
|
||||||
|
mgr := fakeHDD{sts: sts, hdd: map[string]string{"immich": "/mnt/felhom-drives/usb", "paperless": "/mnt/felhom-drives/usb", "romm": "/mnt/felhom-drives/other"}}
|
||||||
|
|
||||||
|
dead, states := classifyRunStates(sts, nil, nil, time.Now())
|
||||||
|
notified := downByName(maskDriveLost(states, driveLostApps(sett, mgr)))
|
||||||
|
if notified["immich"] || notified["paperless"] {
|
||||||
|
t.Fatalf("R-521: an app stopped by the lost drive is still reported down to the notifier: %v", notified)
|
||||||
|
}
|
||||||
|
if !notified["romm"] {
|
||||||
|
t.Fatal("a genuinely broken app on another drive stopped alarming — over-correction")
|
||||||
|
}
|
||||||
|
if dn := deadNames(dead); !dn["immich"] || !dn["paperless"] {
|
||||||
|
t.Fatalf("the dashboard's dead list must still carry the drive's apps: %v", dn)
|
||||||
|
}
|
||||||
|
// Reconnected: nothing is masked any more.
|
||||||
|
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", false, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := driveLostApps(sett, mgr); len(got) != 0 {
|
||||||
|
t.Fatalf("with no drive disconnected nothing may be masked, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wiring: the dead-app job hands the notifier the masked states.
|
||||||
|
func TestR521_DeadAppJobMasksDriveLost(t *testing.T) {
|
||||||
|
found := false
|
||||||
|
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||||
|
if !ok || sel.Sel.Name != "NotifyAppStartFailures" || len(call.Args) != 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if inner, ok := call.Args[0].(*ast.CallExpr); ok {
|
||||||
|
if id, ok := inner.Fun.(*ast.Ident); ok && id.Name == "maskDriveLost" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Fatal("R-521: main calls NotifyAppStartFailures without maskDriveLost")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/fillwatch"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fillPushRecord struct {
|
||||||
|
eventType, severity, message, household string
|
||||||
|
details interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeFillPusher struct{ got []fillPushRecord }
|
||||||
|
|
||||||
|
func (f *fakeFillPusher) PushEventHousehold(eventType, severity, message, household string, details interface{}) {
|
||||||
|
f.got = append(f.got, fillPushRecord{eventType, severity, message, household, details})
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-521 F7 (BIGNIGHT 2026-09-14): the guest's own system disk at 95 % produced only `health_degraded`,
|
||||||
|
// whose mail a cooldown swallowed — no disk-specific event reached the hub. The consequence pinned here,
|
||||||
|
// through the REAL target list (fillTargets), the REAL watcher and the REAL push closure (fillEventPush):
|
||||||
|
// the guest's root filesystem at F7's numbers sends the hub's existing `disk_critical` event (severity
|
||||||
|
// critical, a type the hub already allowlists), ONCE (edge-triggered), carrying the Hungarian sentence
|
||||||
|
// AND the household's own (English here).
|
||||||
|
func TestR521F7_RootFilesystemSendsTheDiskEventInBothLanguages(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Paths.SystemDataPath = "/mnt/sys_drive"
|
||||||
|
usage := func(p string) *fillwatch.Usage {
|
||||||
|
if p == system.DockerVolumePath { // the guest's root filesystem as the controller sees it
|
||||||
|
return &fillwatch.Usage{UsedPercent: 95, AvailGB: 3.5, UsedGB: 61.8, TotalGB: 68.7}
|
||||||
|
}
|
||||||
|
return &fillwatch.Usage{UsedPercent: 30, AvailGB: 50, UsedGB: 20, TotalGB: 70}
|
||||||
|
}
|
||||||
|
w := fillwatch.New(filepath.Join(t.TempDir(), "fill.json"), log.New(io.Discard, "", 0),
|
||||||
|
func() []fillwatch.Target { return fillTargets(cfg, nil) }, usage)
|
||||||
|
rec := &fakeFillPusher{}
|
||||||
|
w.SetNotify(fillEventPush(rec))
|
||||||
|
w.SetLang(func() string { return "en" })
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ { // the second check sees the same band: nothing new (edge-triggered)
|
||||||
|
if err := w.Check(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rec.got) != 1 {
|
||||||
|
t.Fatalf("pushes = %d (%+v), want exactly 1 disk event for the root filesystem", len(rec.got), rec.got)
|
||||||
|
}
|
||||||
|
p := rec.got[0]
|
||||||
|
if p.eventType != "disk_critical" || p.severity != "critical" {
|
||||||
|
t.Errorf("pushed %s/%s, want disk_critical/critical", p.eventType, p.severity)
|
||||||
|
}
|
||||||
|
if d, _ := p.details.(map[string]any); d == nil || d["path"] != system.DockerVolumePath {
|
||||||
|
t.Errorf("details = %v, want path %q", p.details, system.DockerVolumePath)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.message, "Alkalmaz") || !strings.Contains(p.message, "kritikusan") {
|
||||||
|
t.Errorf("Hungarian message = %q", p.message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.household, "App area") || !strings.Contains(p.household, "critically full") {
|
||||||
|
t.Errorf("household (English) message = %q", p.household)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-659 (v0.268.0) — the pieces are wired: the adapter records the hold through the WHOLE-copy walk
|
||||||
|
// (never the precondition copy again), and the held-event sink sends app_hold_no_whole_copy.
|
||||||
|
// An AST walk, not a substring (a commented-out call satisfies strings.Contains).
|
||||||
|
func TestR659_HoldAndEventAreWired(t *testing.T) {
|
||||||
|
_, f, _ := slice4CallLines(t)
|
||||||
|
var calls []string
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
fn, ok := d.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Name.Name != "HoldAfterFailedUpdate" || fn.Recv == nil || fn.Body == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st, ok := fn.Recv.List[0].Type.(*ast.StarExpr); !ok || st.X.(*ast.Ident).Name != "updateGuardsAdapter" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
if sel, ok := n.(*ast.SelectorExpr); ok {
|
||||||
|
calls = append(calls, sel.Sel.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
body := strings.Join(calls, " ")
|
||||||
|
if !strings.Contains(body, "HoldAfterFailedUpdateWhole") {
|
||||||
|
t.Fatal("the adapter does not record the hold through HoldAfterFailedUpdateWhole — the precondition copy would be named again (R-659)")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "HoldAfterFailedUpdateHolding") {
|
||||||
|
t.Fatal("the adapter still calls HoldAfterFailedUpdateHolding with the precondition copy")
|
||||||
|
}
|
||||||
|
sends := false
|
||||||
|
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||||
|
if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "NotifyAppHoldNoWholeCopy" {
|
||||||
|
sends = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !sends {
|
||||||
|
t.Fatal("main never calls NotifyAppHoldNoWholeCopy — support would never be told")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-660 (v0.268.0) — an app HELD after a failed update is stopped by the product and has its own
|
||||||
|
// event. Measured on 9202 2026-09-23 night (chaos rounds 8 and 11): `app_update_held` at 20:56:04 and
|
||||||
|
// 21:42:39, then `app_start_failed` 11 s and 13 s later for the same moment — two alarms, one fact.
|
||||||
|
|
||||||
|
type fakeHeld map[string]bool
|
||||||
|
|
||||||
|
func (f fakeHeld) UpdateHeldStacks() map[string]bool { return f }
|
||||||
|
|
||||||
|
// TestR660_UpdateHeldAppIsNotReportedDown — the consequence, through the suppression the scan builds.
|
||||||
|
// A genuinely exited app beside it must still alarm (the over-correction guard).
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (REPORT.md): make updateHeldSet return nil. This test then fails at "a held app
|
||||||
|
// was reported DOWN".
|
||||||
|
func TestR660_UpdateHeldAppIsNotReportedDown(t *testing.T) {
|
||||||
|
sts := []stacks.Stack{
|
||||||
|
stack("nextcloud", stacks.StateStopped, true, false), // held after a failed update
|
||||||
|
stack("romm", stacks.StateExited, true, false), // genuinely broken
|
||||||
|
}
|
||||||
|
// A held app's intent is "running" — the household never asked for the stop.
|
||||||
|
for i := range sts {
|
||||||
|
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
|
||||||
|
}
|
||||||
|
dead, states := classifyRunStates(sts, unionSuppressed(nil, updateHeldSet(fakeHeld{"nextcloud": true})), nil, time.Now())
|
||||||
|
down := downByName(states)
|
||||||
|
if down["nextcloud"] || deadNames(dead)["nextcloud"] {
|
||||||
|
t.Fatal("a held app was reported DOWN — the second, false alarm after app_update_held (R-660)")
|
||||||
|
}
|
||||||
|
if !down["romm"] {
|
||||||
|
t.Fatal("a genuinely exited app stopped alarming — the fix silenced a real fault")
|
||||||
|
}
|
||||||
|
// Control: WITHOUT the held set the same app does alarm — so the suppression is what decides.
|
||||||
|
_, states = classifyRunStates(sts, nil, nil, time.Now())
|
||||||
|
if !downByName(states)["nextcloud"] {
|
||||||
|
t.Fatal("control failed: the fixture's app does not alarm even without the suppression")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wiring: main passes the backup manager, and the scan unions its held set.
|
||||||
|
func TestR660_ScanIsGivenTheHeldSet(t *testing.T) {
|
||||||
|
_, f, _ := slice4CallLines(t)
|
||||||
|
reads := false
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
fn, ok := d.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Name.Name != "scanDeployedAppRunStates" || fn.Body == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
if call, ok := n.(*ast.CallExpr); ok {
|
||||||
|
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "updateHeldSet" {
|
||||||
|
reads = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !reads {
|
||||||
|
t.Fatal("scanDeployedAppRunStates does not read the update-held set (R-660)")
|
||||||
|
}
|
||||||
|
withB := false
|
||||||
|
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "scanDeployedAppRunStates" {
|
||||||
|
for _, a := range call.Args {
|
||||||
|
if x, ok := a.(*ast.Ident); ok && x.Name == "backupMgr" {
|
||||||
|
withB = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !withB {
|
||||||
|
t.Fatal("main calls scanDeployedAppRunStates without backupMgr — the held set is built and never read")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/quiesce"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-721 (v0.283.1) — the seam, not the rule. The rule is pinned in internal/backup and internal/quiesce with
|
||||||
|
// fakes that DO answer WantsStopped; production hands the backup manager a *stackAdapter and the quiesce loop
|
||||||
|
// a *stacks.Manager, and a type that does not answer makes the check silently skip. Measured live on 9202
|
||||||
|
// 2026-09-30 with v0.283.0: the adapter lacked the method and the dump restarted a stopped app.
|
||||||
|
// COMPANION RED-PROOF: delete stackAdapter.WantsStopped → this test fails.
|
||||||
|
func TestR721_TheBackupsStackProviderAnswersWantsStopped(t *testing.T) {
|
||||||
|
var prov interface{} = &stackAdapter{}
|
||||||
|
if _, ok := prov.(interface{ WantsStopped(string) bool }); !ok {
|
||||||
|
t.Fatal("the backup manager's production stack provider does not answer WantsStopped — a household Stop is undone by the volume dump (R-721)")
|
||||||
|
}
|
||||||
|
// The quiesce loop is handed the stacks manager itself (main.go quiesce.Options{Stacks: stackMgr}).
|
||||||
|
// The startup crash recovery is handed gatedAppStopStarter{inner: stackMgr} (main.go SetStarter).
|
||||||
|
var gs interface{} = gatedAppStopStarter{inner: (*stacks.Manager)(nil)}
|
||||||
|
if _, ok := gs.(interface{ WantsStopped(string) bool }); !ok {
|
||||||
|
t.Fatal("the crash recovery's starter does not answer WantsStopped (R-721)")
|
||||||
|
}
|
||||||
|
var mgr *stacks.Manager
|
||||||
|
var _ quiesce.Stacks = mgr
|
||||||
|
if _, ok := interface{}(mgr).(interface{ WantsStopped(string) bool }); !ok {
|
||||||
|
t.Fatal("the quiesce loop's production Stacks does not answer WantsStopped (R-721)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-856 (`09` §3 decision 143): the dead-app check — the source of app_start_failed and
|
||||||
|
// app_stopped_unhealthy — waits on the crash-aware boot grace, not on the fixed 90 s constant.
|
||||||
|
//
|
||||||
|
// Red-proof: put `time.Since(startTime) < deadAppBootGrace` back as the check's gate — this test fails.
|
||||||
|
func TestR856_DeadAppCheckWaitsOnTheCrashAwareGrace(t *testing.T) {
|
||||||
|
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var body *ast.BlockStmt
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok || len(call.Args) < 3 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sel, ok := call.Fun.(*ast.SelectorExpr); !ok || sel.Sel.Name != "Every" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if lit, ok := call.Args[0].(*ast.BasicLit); ok && lit.Value == `"deadapp-check"` {
|
||||||
|
if fl, ok := call.Args[2].(*ast.FuncLit); ok {
|
||||||
|
body = fl.Body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if body == nil {
|
||||||
|
t.Fatal("the deadapp-check schedule is gone from main.go")
|
||||||
|
}
|
||||||
|
within, constant := false, false
|
||||||
|
ast.Inspect(body, func(n ast.Node) bool {
|
||||||
|
switch x := n.(type) {
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
if id, ok := x.X.(*ast.Ident); ok && id.Name == "bootGrace" && x.Sel.Name == "Within" {
|
||||||
|
within = true
|
||||||
|
}
|
||||||
|
case *ast.Ident:
|
||||||
|
if x.Name == "deadAppBootGrace" {
|
||||||
|
constant = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !within || constant {
|
||||||
|
t.Errorf("the dead-app check must gate on bootGrace.Within (found=%v) and not on deadAppBootGrace (found=%v)", within, constant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The normal grace is today's 90 s: a normal boot must keep it exactly (decision 143's other half).
|
||||||
|
func TestR856_NormalGraceIsTheDeadAppBootGrace(t *testing.T) {
|
||||||
|
if crashboot.NormalGrace != deadAppBootGrace {
|
||||||
|
t.Errorf("crashboot.NormalGrace = %s, deadAppBootGrace = %s — a normal boot must keep today's grace", crashboot.NormalGrace, deadAppBootGrace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-1112: main opens the family store at start (webServer.OpenFamilyStore), so an old family.json is converted on every
|
||||||
|
// box — not only on one where someone passes a family gate. Red-proof: remove the call from main.go → this fails.
|
||||||
|
func TestR1112_MainOpensTheFamilyStore(t *testing.T) {
|
||||||
|
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
if call, ok := n.(*ast.CallExpr); ok {
|
||||||
|
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "OpenFamilyStore" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Fatal("main.go does not call OpenFamilyStore — an old family.json keeps its session values until a family request")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"go/ast"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestR871_CatchUpWiring — v0.295.0 (`09` decision 109): the catch-up is built, triggered at start and on a host
|
||||||
|
// resume, and holds the whole-guest cycle — the seam-built-but-never-wired class (four earlier instances).
|
||||||
|
// COMPANION RED-PROOF: delete the `catchUp.Evaluate(ctx, "controller start")` line → this fails.
|
||||||
|
func TestR871_CatchUpWiring(t *testing.T) {
|
||||||
|
lines, _, _ := slice4CallLines(t)
|
||||||
|
for _, call := range []string{"ResumeWatch", "SetCatchUpFn", "catchUpLegs", "NotifyBackupCatchUp"} {
|
||||||
|
if len(lines[call]) == 0 {
|
||||||
|
t.Errorf("%s is never called in main.go — the catch-up is built but not wired", call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both triggers, by their reason argument: the start (a literal) and the resume (inside ResumeWatch's callback).
|
||||||
|
_, f, _ := slice4CallLines(t)
|
||||||
|
start := 0
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
if call, ok := n.(*ast.CallExpr); ok {
|
||||||
|
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Evaluate" && len(call.Args) == 2 {
|
||||||
|
if lit, ok := call.Args[1].(*ast.BasicLit); ok && lit.Value == `"controller start"` {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if start != 1 {
|
||||||
|
t.Errorf("the catch-up's START trigger (Evaluate(ctx, \"controller start\")) is called %d times, want 1", start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catch-up's leg table holds the three BACKUP legs and nothing else, and the off-site body it shares with the
|
||||||
|
// night never calls the update leg (that is chained only around the SCHEDULED off-site job).
|
||||||
|
// COMPANION RED-PROOF: put `stackMgr.RunUpdateLeg` inside offsiteOnly's body → "the update leg is inside".
|
||||||
|
func TestR871_CatchUpRunsNoUpdateLeg(t *testing.T) {
|
||||||
|
noop := func(context.Context) error { return nil }
|
||||||
|
legs := catchUpLegs(noop, noop, noop)
|
||||||
|
if len(legs) != 3 || legs[nightchain.LegDBDump] == nil || legs[nightchain.LegTier2] == nil || legs[nightchain.LegOffsite] == nil {
|
||||||
|
t.Fatalf("catchUpLegs = %v, want exactly db-dump, tier2, offsite", legs)
|
||||||
|
}
|
||||||
|
src, err := os.ReadFile("main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(src)
|
||||||
|
i := strings.Index(s, "offsiteOnly := withLeg(nightchain.LegOffsite,")
|
||||||
|
j := strings.Index(s[i:], "sched.Daily(\"offbox-backup\"")
|
||||||
|
if i < 0 || j < 0 {
|
||||||
|
t.Fatal("the shared off-site leg body was not found in main.go")
|
||||||
|
}
|
||||||
|
if body := s[i : i+j]; strings.Contains(body, "RunUpdateLeg") || strings.Contains(body, "chainUpdateLeg") {
|
||||||
|
t.Fatal("the update leg is inside the off-site body the catch-up runs")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-970 (v0.322.0) — the box's own hub API key opens NO route on the box.
|
||||||
|
//
|
||||||
|
// Until v0.322.0 a Bearer equal to the hub key reached /api/config/ (read the whole controller.yaml with every secret,
|
||||||
|
// and REPLACE it), /api/selfupdate/ and /api/geo/ from the internet, unsigned — against `01` §4 („no inbound control
|
||||||
|
// endpoint") and §10. The test drives the REAL route table (buildMux) with real web.Server and api.Router: a password
|
||||||
|
// is set (so auth is on), the request carries the key and no session, and the planted controller.yaml must be neither
|
||||||
|
// served nor changed.
|
||||||
|
//
|
||||||
|
// RED-PROOF (2026-10-16, REPORT): with an /api/geo/ route mounted without the session check, this failed at
|
||||||
|
// „GET /api/geo/countries with the hub key answered 200".
|
||||||
|
func TestR970_HubKeyOpensNoDoor(t *testing.T) {
|
||||||
|
const hubKey = "TESTONLY-hub-key-5d1b"
|
||||||
|
const secret = "TESTONLY-tunnel-token-q9"
|
||||||
|
dir := t.TempDir()
|
||||||
|
lg := log.New(io.Discard, "", 0)
|
||||||
|
cfgPath := filepath.Join(dir, "controller.yaml")
|
||||||
|
planted := "customer:\n id: t\n domain: example.hu\ninfrastructure:\n cf_tunnel_token: " + secret + "\n"
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(planted), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Customer.Domain = "example.hu"
|
||||||
|
cfg.Hub.APIKey = hubKey
|
||||||
|
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||||
|
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := sett.SetPasswordHash("$2a$10$abcdefghijklmnopqrstuuJ1A9pDq6s9F2x1H3bQb0v8Y3wq6z2Om"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ws := web.NewServer(cfg, nil, nil, nil, nil, sett, nil, nil, nil, lg, "test")
|
||||||
|
ar := api.NewRouter(cfg, cfgPath, sett, nil, nil, nil, nil, nil, nil, nil, lg)
|
||||||
|
mux := buildMux(ar, ws)
|
||||||
|
|
||||||
|
// Control: auth is on — a request with no key and no session is refused, so a refusal below means something.
|
||||||
|
ctl := httptest.NewRequest(http.MethodGet, "/api/stacks", nil)
|
||||||
|
ctl.Host = "felhom.example.hu"
|
||||||
|
cw := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(cw, ctl)
|
||||||
|
if cw.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("control: an anonymous /api/stacks answered %d, want 401 — auth is not on in this harness", cw.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash",
|
||||||
|
"/api/selfupdate/check", "/api/selfupdate/update", "/api/geo/settings", "/api/geo/countries", "/api/geo/sync"}
|
||||||
|
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
|
||||||
|
for _, p := range paths {
|
||||||
|
for _, m := range methods {
|
||||||
|
req := httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: evil.example\n"))
|
||||||
|
req.Host = "felhom.example.hu"
|
||||||
|
req.Header.Set("Authorization", "Bearer "+hubKey)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
if w.Code/100 == 2 {
|
||||||
|
t.Errorf("%s %s with the hub key answered %d — the key opens a door", m, p, w.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), secret) {
|
||||||
|
t.Fatalf("%s %s with the hub key served the settings file", m, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(cfgPath); string(got) != planted {
|
||||||
|
t.Fatal("controller.yaml was replaced through a hub-key request")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-550 — the restore record survives a restart, and an interruption is raised ONCE.
|
||||||
|
//
|
||||||
|
// Two functions so main() makes two plain calls a test can see (TestMainWiresRestoreRecord): the
|
||||||
|
// record must be loaded BEFORE the web server serves a restore page, and the event can only be pushed
|
||||||
|
// AFTER the notifier exists, which main() constructs later.
|
||||||
|
|
||||||
|
// restoreRecordFileName lives in DataDir, beside settings.json — the SSD state dir that survives a
|
||||||
|
// container recreation.
|
||||||
|
const restoreRecordFileName = "restore-status.json"
|
||||||
|
|
||||||
|
// restoreEventPusher is the notifier method main() hands in (an interface so a test can record it).
|
||||||
|
type restoreEventPusher interface {
|
||||||
|
PushEvent(eventType, severity, message string, details interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRestoreRecordAtStartup wires persistence and returns the restore the last stop interrupted.
|
||||||
|
// putBack names the apps the crash recovery put back (AppStopRecovery.PutBack, D22 / R-1195); waiting those whose
|
||||||
|
// put-back waits for their drive (AppStopRecovery.Waiting, R-1251).
|
||||||
|
func loadRestoreRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger, putBack, waiting []string) *backup.RestoreOpResult {
|
||||||
|
if mgr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path := filepath.Join(dataDir, restoreRecordFileName)
|
||||||
|
mgr.SetRestoreRecordPath(path)
|
||||||
|
res := mgr.LoadRestoreRecordAfterRecovery(putBack, waiting)
|
||||||
|
if logger != nil {
|
||||||
|
logger.Printf("[INFO] [backup] restore record wired: %s (interrupted at startup: %t)", path, res != nil)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportInterruptedRestore raises restore_interrupted (warning; for the household — hub v0.117.0).
|
||||||
|
func reportInterruptedRestore(p restoreEventPusher, res *backup.RestoreOpResult) {
|
||||||
|
if p == nil || res == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.PutBack || res.PutBackWaiting {
|
||||||
|
// D22 / R-1195: the data is back as before the restore — the record's own sentence, in the box's language.
|
||||||
|
// R-1251: or it is put back when the drive is there; „start it again" would restore the backup over it.
|
||||||
|
p.PushEvent("restore_interrupted", "warning", res.Stack+": "+res.Message,
|
||||||
|
map[string]any{"stack": res.Stack, "op": res.Op, "put_back": res.PutBack, "put_back_waiting": res.PutBackWaiting})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.PushEvent("restore_interrupted", "warning",
|
||||||
|
fmt.Sprintf("A(z) %s visszaállítása megszakadt, mert a doboz újraindult — indítsd el újra.", res.Stack),
|
||||||
|
map[string]any{"stack": res.Stack, "op": res.Op})
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordedPush struct{ eventType, severity, message string }
|
||||||
|
|
||||||
|
type pushRecorder struct{ got []recordedPush }
|
||||||
|
|
||||||
|
func (r *pushRecorder) PushEvent(et, sev, msg string, _ interface{}) {
|
||||||
|
r.got = append(r.got, recordedPush{et, sev, msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-550 — the functions main() calls: a restore left in flight is found at startup and raised as
|
||||||
|
// restore_interrupted (warning), exactly once.
|
||||||
|
//
|
||||||
|
// RED-PROOF: make loadRestoreRecordAtStartup skip LoadRestoreRecord → nothing is pushed →
|
||||||
|
// "an interrupted restore raised no restore_interrupted".
|
||||||
|
func TestRestoreRecordAtStartup_RaisesInterruptedOnce(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
lg := log.New(io.Discard, "", 0)
|
||||||
|
|
||||||
|
before := new(backup.Manager)
|
||||||
|
before.SetRestoreRecordPath(dir + "/" + restoreRecordFileName)
|
||||||
|
before.BeginRestoreOp("restore", "gokapi") // the box stops here
|
||||||
|
|
||||||
|
rec := &pushRecorder{}
|
||||||
|
reportInterruptedRestore(rec, loadRestoreRecordAtStartup(new(backup.Manager), dir, lg, nil, nil))
|
||||||
|
if len(rec.got) != 1 || rec.got[0].eventType != "restore_interrupted" || rec.got[0].severity != "warning" {
|
||||||
|
t.Fatalf("an interrupted restore raised no restore_interrupted (warning): %+v", rec.got)
|
||||||
|
}
|
||||||
|
|
||||||
|
again := &pushRecorder{}
|
||||||
|
reportInterruptedRestore(again, loadRestoreRecordAtStartup(new(backup.Manager), dir, lg, nil, nil))
|
||||||
|
if len(again.got) != 0 {
|
||||||
|
t.Fatalf("the same interruption was raised again on the next start: %+v", again.got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seam must be CALLED from main(): both functions, in that order.
|
||||||
|
func TestMainWiresRestoreRecord(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse main.go: %v", err)
|
||||||
|
}
|
||||||
|
pos := map[string]token.Pos{}
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
fn, ok := d.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Name.Name != "main" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
if c, ok := n.(*ast.CallExpr); ok {
|
||||||
|
if id, ok := c.Fun.(*ast.Ident); ok {
|
||||||
|
if id.Name == "loadRestoreRecordAtStartup" || id.Name == "reportInterruptedRestore" {
|
||||||
|
if _, seen := pos[id.Name]; !seen {
|
||||||
|
pos[id.Name] = c.Pos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
load, okL := pos["loadRestoreRecordAtStartup"]
|
||||||
|
report, okR := pos["reportInterruptedRestore"]
|
||||||
|
if !okL || !okR {
|
||||||
|
t.Fatalf("main() does not call both restore-record functions (load=%t report=%t) — the persistence is an inert seam", okL, okR)
|
||||||
|
}
|
||||||
|
if load > report {
|
||||||
|
t.Fatalf("main() reports before it loads — nothing would ever be reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-519 (v0.296.0) — an app-data backup run cut off by a power cut or a restart is said on the backup pages until
|
||||||
|
// the next complete run (internal/backup/run_record.go). main() calls this right after the restore record, before
|
||||||
|
// any page or any backup run can read the manager. Pinned by TestMainWiresRunRecord.
|
||||||
|
|
||||||
|
// runRecordFileName lives in DataDir, beside settings.json and the restore record.
|
||||||
|
const runRecordFileName = "appdata-run.json"
|
||||||
|
|
||||||
|
func loadRunRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger) time.Time {
|
||||||
|
if mgr == nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
path := filepath.Join(dataDir, runRecordFileName)
|
||||||
|
mgr.SetRunRecordPath(path)
|
||||||
|
cut := mgr.LoadRunRecord()
|
||||||
|
if logger != nil {
|
||||||
|
if !cut.IsZero() {
|
||||||
|
logger.Printf("[WARN] [backup] the app-data backup run started %s was cut off by the stop — the backup pages say so until the next complete run (R-519)", cut.UTC().Format(time.RFC3339))
|
||||||
|
} else {
|
||||||
|
logger.Printf("[INFO] [backup] app-data run record wired: %s (an earlier cut still shown: %t)", path, !mgr.InterruptedRunAt().IsZero())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cut
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-519: the function main() calls finds a cut run.
|
||||||
|
func TestRunRecordAtStartup_FindsACutRun(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
lg := log.New(io.Discard, "", 0)
|
||||||
|
before := new(backup.Manager)
|
||||||
|
before.SetRunRecordPath(dir + "/" + runRecordFileName)
|
||||||
|
// the record a run leaves when the power goes mid-run (markRunStarted is unexported — write its file shape)
|
||||||
|
writeRunning(t, dir+"/"+runRecordFileName)
|
||||||
|
after := new(backup.Manager)
|
||||||
|
if cut := loadRunRecordAtStartup(after, dir, lg); cut.IsZero() || after.InterruptedRunAt().IsZero() {
|
||||||
|
t.Fatal("a cut run was not found at startup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seam must be CALLED from main() — an AST call, not a string (a comment would match a string).
|
||||||
|
// RED-PROOF: delete the call in main() → this fails.
|
||||||
|
func TestMainWiresRunRecord(t *testing.T) {
|
||||||
|
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
if c, ok := n.(*ast.CallExpr); ok {
|
||||||
|
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "loadRunRecordAtStartup" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Fatal("main() never calls loadRunRecordAtStartup — a cut run is never said on the page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRunning(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
b := []byte(`{"running":true,"started_at":"` + time.Date(2026, 9, 14, 19, 40, 3, 0, time.UTC).Format(time.RFC3339) + `"}`)
|
||||||
|
if err := writeFileForTest(path, b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFileForTest(path string, b []byte) error { return os.WriteFile(path, b, 0o600) }
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update arc slice 4 — the seams are WIRED, in the order that makes them true. A seam built and never
|
||||||
|
// wired has shipped in this project seven times; each assertion here fails if a line moves or goes.
|
||||||
|
|
||||||
|
func slice4CallLines(t *testing.T) (map[string][]int, *ast.File, *token.FileSet) {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := map[string][]int{}
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
if call, ok := n.(*ast.CallExpr); ok {
|
||||||
|
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
|
||||||
|
lines[sel.Sel.Name] = append(lines[sel.Sel.Name], fset.Position(call.Pos()).Line)
|
||||||
|
} else if id, ok := call.Fun.(*ast.Ident); ok {
|
||||||
|
lines[id.Name] = append(lines[id.Name], fset.Position(call.Pos()).Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return lines, f, fset
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlice4_UpdateGuardsAreWiredAtStartup(t *testing.T) {
|
||||||
|
lines, _, _ := slice4CallLines(t)
|
||||||
|
if len(lines["SetUpdateGuards"]) == 0 {
|
||||||
|
t.Fatal("SetUpdateGuards is never called — every update would be refused as unwired")
|
||||||
|
}
|
||||||
|
if len(lines["RecoverUpdates"]) == 0 || len(lines["runBootReconcile"]) == 0 {
|
||||||
|
t.Fatal("RecoverUpdates or the boot reconciler is missing from main.go")
|
||||||
|
}
|
||||||
|
if lines["RecoverUpdates"][0] > lines["runBootReconcile"][0] {
|
||||||
|
t.Errorf("RecoverUpdates (line %d) must run BEFORE the boot reconciler (line %d), or the sweep can start a half-updated app",
|
||||||
|
lines["RecoverUpdates"][0], lines["runBootReconcile"][0])
|
||||||
|
}
|
||||||
|
if len(lines["ResumeInterruptedUpdates"]) == 0 || lines["ResumeInterruptedUpdates"][0] < lines["SetUpdateGuards"][0] {
|
||||||
|
t.Error("ResumeInterruptedUpdates must run AFTER SetUpdateGuards — a resumed update that fails must be able to HOLD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func funcBodySource(t *testing.T, f *ast.File, fset *token.FileSet, recv, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
fn, ok := d.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Name.Name != name || fn.Body == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if recv != "" {
|
||||||
|
if fn.Recv == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, ok := fn.Recv.List[0].Type.(*ast.Ident)
|
||||||
|
if !ok || id.Name != recv {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
if sel, ok := n.(*ast.SelectorExpr); ok {
|
||||||
|
names = append(names, sel.Sel.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return strings.Join(names, " ")
|
||||||
|
}
|
||||||
|
t.Fatalf("func %s.%s not found", recv, name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlice4_BootSweepAndDeadAppAlarmKnowAboutUpdates(t *testing.T) {
|
||||||
|
_, f, fset := slice4CallLines(t)
|
||||||
|
if !strings.Contains(funcBodySource(t, f, fset, "bootDriveGate", "MayStart"), "IsUpdating") {
|
||||||
|
t.Error("the boot sweep must refuse an app a guarded update is moving")
|
||||||
|
}
|
||||||
|
if !strings.Contains(funcBodySource(t, f, fset, "", "scanDeployedAppRunStates"), "UpdatingStacks") {
|
||||||
|
t.Error("the dead-app alarm must not count an app the update itself is recreating")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shared start gate names WHICH hold it is honouring — an operator reading the boot log must not
|
||||||
|
// be told a restore failed when an update did.
|
||||||
|
func TestSlice4_DriveStartGate_NamesAnUpdateHold(t *testing.T) {
|
||||||
|
sett := holdTestSettings(t)
|
||||||
|
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "bookstack", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ok, why := driveStartGate{sett: sett}.MayStart("bookstack")
|
||||||
|
if ok || !strings.Contains(why, "held after a failed update") {
|
||||||
|
t.Errorf("ok=%v why=%q", ok, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.238.1: the backup manager must be told which apps are mid-update, or the nightly legs overwrite a
|
||||||
|
// restore point during an update's health wait (found live, Scenario F).
|
||||||
|
func TestSlice4_UpdatingCheckIsWiredAtStartup(t *testing.T) {
|
||||||
|
lines, _, _ := slice4CallLines(t)
|
||||||
|
if len(lines["SetUpdatingCheck"]) == 0 {
|
||||||
|
t.Fatal("backupMgr.SetUpdatingCheck is never called — the nightly legs cannot see an update in progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-645 (09 §3 decision 142): the backup manager must be told which apps are not running their pinned
|
||||||
|
// version, or the night backup writes a just-failed definition over the good unit after a hand-lifted hold.
|
||||||
|
func TestR645_VersionCheckIsWiredAtStartup(t *testing.T) {
|
||||||
|
lines, _, _ := slice4CallLines(t)
|
||||||
|
if len(lines["SetVersionCheck"]) == 0 {
|
||||||
|
t.Fatal("backupMgr.SetVersionCheck is never called — the night backup cannot see an app off its pin")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The stray-copy fix: main.go removes every leftover second-drive copy container BEFORE the scheduler and the web
|
||||||
|
// server start (either could begin a backup or a restore beside a lost copy). RED-PROOF (2026-10-22 night): the call
|
||||||
|
// removed, or moved below sched.Start, → this fails.
|
||||||
|
func TestStrayCopy_ClearIsWiredBeforeTheScheduler(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(src)
|
||||||
|
c := strings.Index(s, "backup.ClearLeftoverTier2Copies(ctx)")
|
||||||
|
st := strings.Index(s, "sched.Start(ctx)")
|
||||||
|
ws := strings.Index(s, "web.NewServer(")
|
||||||
|
if c < 0 || st < 0 || ws < 0 {
|
||||||
|
t.Fatalf("missing: clear=%d sched.Start=%d web.NewServer=%d", c, st, ws)
|
||||||
|
}
|
||||||
|
if c > st || c > ws {
|
||||||
|
t.Fatal("the leftover-copy removal must run before the scheduler and the web server start")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v0.264.0. The update-event sink and the R-646 backfill are both CALLED from main.go (an AST walk —
|
||||||
|
// a comment naming them would not count). A sink built and never wired would fail silently: no event,
|
||||||
|
// no mail, no error — this project's commonest defect, four recorded instances.
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateEventSink call in main.go — this fails.
|
||||||
|
func TestUpdateEventSinkIsWiredAtStartup(t *testing.T) {
|
||||||
|
lines, _, _ := slice4CallLines(t)
|
||||||
|
if len(lines["SetUpdateEventSink"]) == 0 {
|
||||||
|
t.Fatal("SetUpdateEventSink is never called — an undone or held update would tell nobody")
|
||||||
|
}
|
||||||
|
for _, callee := range []string{"NotifyAppUpdateUndone", "NotifyAppUpdateHeld", "RestoreHoldForLang"} {
|
||||||
|
if len(lines[callee]) == 0 {
|
||||||
|
t.Errorf("main.go must call %s inside the sink", callee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines["BackfillAppliedMeta"]) == 0 || len(lines["AdoptPins"]) == 0 || lines["BackfillAppliedMeta"][0] < lines["AdoptPins"][0] {
|
||||||
|
t.Error("BackfillAppliedMeta (R-646) must be called, after AdoptPins")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-647 (2) — the held event carries the copy_holds KEY, never the Hungarian phrase: the hub prints the
|
||||||
|
// raw details as the household mail's `Note:` line. COMPANION RED-PROOF (REPORT.md): wire
|
||||||
|
// UpdateCopyHolds back — this fails.
|
||||||
|
func TestR647_HeldEventCarriesTheCopyHoldsKey(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), "d.CopyHolds = backupMgr.UpdateCopyHoldsKey(") {
|
||||||
|
t.Fatal("main.go must fill AppUpdateDetails.CopyHolds with UpdateCopyHoldsKey (the key), not the Hungarian phrase")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v0.271.0 — the automatic update leg (`09` §6.4 part 7).
|
||||||
|
|
||||||
|
// TestChainUpdateLeg_EveryPath — the leg runs after the off-site half on EVERY path it can take:
|
||||||
|
// nothing to do (no target), ran ok, returned an error (incl. every RunOffboxBackup early return, which
|
||||||
|
// returns to here), and panicked. The off-site error reaches the scheduler unchanged; a panic becomes one.
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (REPORT.md): call the leg after `return offsite(ctx)` instead of in the defer —
|
||||||
|
// the error and panic cases fail at "the leg did not run after the off-site half".
|
||||||
|
func TestChainUpdateLeg_EveryPath(t *testing.T) {
|
||||||
|
boom := errors.New("sftp: connection refused")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
offsite func(context.Context) error
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"no off-site target (nothing to do)", func(context.Context) error { return nil }, ""},
|
||||||
|
{"off-site ran ok", func(context.Context) error { return nil }, ""},
|
||||||
|
{"off-site failed / an early return", func(context.Context) error { return boom }, "connection refused"},
|
||||||
|
{"off-site panicked", func(context.Context) error { panic("nil map") }, "panicked"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
ran := 0
|
||||||
|
err := chainUpdateLeg(context.Background(), c.offsite, func(context.Context) { ran++ })
|
||||||
|
if ran != 1 {
|
||||||
|
t.Errorf("%s: the leg did not run after the off-site half (ran %d times)", c.name, ran)
|
||||||
|
}
|
||||||
|
if c.wantErr == "" && err != nil {
|
||||||
|
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||||
|
}
|
||||||
|
if c.wantErr != "" && (err == nil || !strings.Contains(err.Error(), c.wantErr)) {
|
||||||
|
t.Errorf("%s: error %v, want one containing %q", c.name, err, c.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateLegIsWiredAtStartup — the leg, its chain and the gate's interlock are CALLED from main.go (an
|
||||||
|
// AST walk; a comment naming them would not count). A seam built and never wired is this project's
|
||||||
|
// commonest defect.
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateLeg call — this fails.
|
||||||
|
func TestUpdateLegIsWiredAtStartup(t *testing.T) {
|
||||||
|
lines, _, _ := slice4CallLines(t)
|
||||||
|
for _, callee := range []string{"SetUpdateLeg", "chainUpdateLeg", "RunUpdateLeg", "FreshWholeCopy"} {
|
||||||
|
if len(lines[callee]) == 0 {
|
||||||
|
t.Errorf("main.go never calls %s — the automatic update leg is not wired", callee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src, err := os.ReadFile("main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), "Enabled: sett.GetAppUpdateUnattended,") {
|
||||||
|
t.Error("the leg's switch must be the household's app_update.unattended (sett.GetAppUpdateUnattended)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), "UpdateLegFn: stackMgr.UpdateLegState") {
|
||||||
|
t.Error("the quiesce loop must be given stackMgr.UpdateLegState (decision 20's interlock)")
|
||||||
|
}
|
||||||
|
// the chain must sit INSIDE the offbox-backup job, not beside it
|
||||||
|
i := strings.Index(string(src), `sched.Daily("offbox-backup"`)
|
||||||
|
if i < 0 || !strings.Contains(string(src)[i:i+600], "chainUpdateLeg(") {
|
||||||
|
t.Error("the offbox-backup job must run through chainUpdateLeg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUndoCopyRemoverIsWiredAtStartup — R-671's seam is CALLED from main.go (AST walk).
|
||||||
|
// COMPANION RED-PROOF (REPORT.md): drop the SetUndoCopyRemover call — this fails.
|
||||||
|
func TestUndoCopyRemoverIsWiredAtStartup(t *testing.T) {
|
||||||
|
lines, _, _ := slice4CallLines(t)
|
||||||
|
if len(lines["SetUndoCopyRemover"]) == 0 {
|
||||||
|
t.Fatal("main.go never calls SetUndoCopyRemover — a lifted hold's undo copies would stay for ever")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/mediawire"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// qbLimitShare is qBittorrent's default share of the measured upload line — CC's pick, open for the operator (decide
|
||||||
|
// row in felhom.eu STATUS): 50 %.
|
||||||
|
const qbLimitShare = 0.5
|
||||||
|
|
||||||
|
// baselineMinMinutes: an app the box cannot ask is judged only once its history holds ~20 hours of minutes.
|
||||||
|
const baselineMinMinutes = 1200
|
||||||
|
|
||||||
|
// newUpwatch builds the upload and CPU watch (internal/upwatch; felhom.eu `08` §9). It does not start it.
|
||||||
|
func newUpwatch(sett *settings.Settings, stackMgr *stacks.Manager, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore,
|
||||||
|
notifier *notify.Notifier, line agentLine, logger *log.Logger) *upwatch.Service {
|
||||||
|
box := mediawire.DockerBox{}
|
||||||
|
qb := &upwatch.QBittorrent{Box: box}
|
||||||
|
tr := &upwatch.Transmission{Box: box}
|
||||||
|
svc := &upwatch.Service{Store: sett, QB: qb, QBRunning: func() bool { return box.Running("qbittorrent") }, QBShare: qbLimitShare,
|
||||||
|
Measured: line.Measured, Log: logger,
|
||||||
|
// R-1250: a box that seeds all day is line-tested by holding its torrent app low for one test on request.
|
||||||
|
Torrents: []upwatch.Holdable{qb, tr}, TorrentRunning: box.Running, LineWaiting: line.Waiting, LineRun: line.Run,
|
||||||
|
// R-1261: a hold of an app removed while held is dropped (else the 50 % rule never runs again).
|
||||||
|
TorrentInstalled: func(stack string) bool {
|
||||||
|
st, ok := stackMgr.GetStack(stack)
|
||||||
|
return ok && st != nil && st.Deployed
|
||||||
|
}}
|
||||||
|
w := &upwatch.Watcher{
|
||||||
|
Source: &upwatch.DockerAPI{},
|
||||||
|
Explainers: map[string]upwatch.Explainer{
|
||||||
|
"qbittorrent": qb,
|
||||||
|
"transmission": tr,
|
||||||
|
"plex": &upwatch.Plex{Token: func() string { return mediawire.PlexToken(box) }, URL: mediawire.PlexURL},
|
||||||
|
},
|
||||||
|
Rules: upwatch.DefaultRules(),
|
||||||
|
Line: svc.Line,
|
||||||
|
Busy: func(stack string) bool {
|
||||||
|
if backupMgr != nil && backupMgr.IsRunning() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if st, ok := stackMgr.GetStack(stack); ok && st != nil && (st.Updating || st.Deploying) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
Emit: notifier.NotifyUnexplained,
|
||||||
|
Log: logger,
|
||||||
|
}
|
||||||
|
if metricsStore != nil {
|
||||||
|
w.Baseline = dailyBaseline(metricsStore)
|
||||||
|
}
|
||||||
|
svc.Watcher = w
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentLine is the host's line test as the watch uses it; every field nil without a local API.
|
||||||
|
type agentLine struct {
|
||||||
|
Measured func(context.Context) (float64, time.Time, bool)
|
||||||
|
Waiting func(context.Context) bool // waiting_for_quiet (agent ≥ 0.165.0, R-1250)
|
||||||
|
Run func(context.Context) (upwatch.LineRunResult, error) // POST /host/line-test/run
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentLineTest reads the host's weekly line test through the agent's local API (agent ≥ 0.164.0; an older agent
|
||||||
|
// answers 404 → not measured, and never waiting). Empty without a local API.
|
||||||
|
func agentLineTest(endpoint, token, fingerprint string, logger *log.Logger) agentLine {
|
||||||
|
if endpoint == "" || token == "" {
|
||||||
|
return agentLine{}
|
||||||
|
}
|
||||||
|
ac, err := agentapi.New(endpoint, token, fingerprint)
|
||||||
|
if err != nil {
|
||||||
|
logger.Printf("[WARN] [upwatch] agent client init failed (%v) — no line test; qBittorrent gets no limit", err)
|
||||||
|
return agentLine{}
|
||||||
|
}
|
||||||
|
read := func(ctx context.Context) (agentapi.LineTestState, error) {
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return ac.LineTest(cctx)
|
||||||
|
}
|
||||||
|
return agentLine{
|
||||||
|
Measured: func(ctx context.Context) (float64, time.Time, bool) {
|
||||||
|
st, err := read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Printf("[DEBUG] [upwatch] line test read: %v", err)
|
||||||
|
return 0, time.Time{}, false
|
||||||
|
}
|
||||||
|
return st.Mbps, st.MeasuredAtTime(), st.Present
|
||||||
|
},
|
||||||
|
Waiting: func(ctx context.Context) bool {
|
||||||
|
st, err := read(ctx)
|
||||||
|
return err == nil && st.WaitingForQuiet
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context) (upwatch.LineRunResult, error) {
|
||||||
|
r, err := ac.LineTestRun(ctx)
|
||||||
|
return upwatch.LineRunResult{OK: r.OK, Mbps: r.Mbps, Refused: r.Refused, Error: r.Error}, err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dailyBaseline caches each app's p95 for a day (a 7-day query is a few hundred thousand rows).
|
||||||
|
func dailyBaseline(ms *metrics.MetricsStore) func(string, []string) (float64, bool) {
|
||||||
|
type entry struct {
|
||||||
|
at time.Time
|
||||||
|
p95 float64
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
cache := map[string]entry{}
|
||||||
|
return func(stack string, containers []string) (float64, bool) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if e, ok := cache[stack]; ok && time.Since(e.at) < 24*time.Hour {
|
||||||
|
return e.p95, e.ok
|
||||||
|
}
|
||||||
|
p95, minutes, err := ms.StackTxP95Mbps(containers, time.Now().Add(-7*24*time.Hour))
|
||||||
|
e := entry{at: time.Now(), p95: p95, ok: err == nil && minutes >= baselineMinMinutes}
|
||||||
|
cache[stack] = e
|
||||||
|
return e.p95, e.ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startUpwatch runs the minute watcher and the 15-minute qBittorrent limit rule until ctx ends. A torrent limit a
|
||||||
|
// stopped controller left at the line-test value is put back first (R-1250), before any rule reads it.
|
||||||
|
func startUpwatch(ctx context.Context, svc *upwatch.Service, logger *log.Logger) {
|
||||||
|
svc.RecoverHold(ctx)
|
||||||
|
svc.RefreshMeasured(ctx)
|
||||||
|
go svc.Watcher.Run(ctx, time.Minute)
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(15 * time.Minute)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
svc.SeedLineTestNow(ctx) // R-1250: holds the torrent app only while the agent waits for quiet
|
||||||
|
svc.QBLimitNow(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
l, ok := svc.Line()
|
||||||
|
logger.Printf("[INFO] [upwatch] upload and CPU watch started (line %s, qBittorrent share %.0f %%)", lineText(l, ok), qbLimitShare*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineText(l float64, ok bool) string {
|
||||||
|
if !ok {
|
||||||
|
return "unknown — no weekly line test yet and no counting off-site push"
|
||||||
|
}
|
||||||
|
return upwatch.MbpsText(int64(l*1e6/8)) + " Mbit/s"
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The upload watch is built, started, handed to the off-site push BEFORE the scheduler can start one, and its view
|
||||||
|
// handed to the pages — a seam built and never wired is this project's commonest defect.
|
||||||
|
// RED-PROOF: delete the SetOffboxPushObserver line → fails; move it below the scheduler's start → fails; pass nil
|
||||||
|
// instead of agentLineTest(...) → the qBittorrent limit never gets a tested line → fails.
|
||||||
|
func TestUpwatchIsWiredAtStartup(t *testing.T) {
|
||||||
|
lines, _, _ := slice4CallLines(t)
|
||||||
|
for _, fn := range []string{"newUpwatch", "agentLineTest", "SetOffboxPushObserver", "startUpwatch", "SetUpwatchView"} {
|
||||||
|
if len(lines[fn]) == 0 {
|
||||||
|
t.Fatalf("main.go never calls %s", fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lines["SetOffboxPushObserver"][0] > lines["Daily"][0] {
|
||||||
|
t.Fatalf("SetOffboxPushObserver (line %d) comes after the first scheduled job (line %d)", lines["SetOffboxPushObserver"][0], lines["Daily"][0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newUpwatch asks the three apps the spike proved, reports through the notifier, and reads exact counters.
|
||||||
|
// RED-PROOF: drop the transmission entry → fails.
|
||||||
|
func TestNewUpwatch_ExplainersAndEmit(t *testing.T) {
|
||||||
|
lg := log.New(io.Discard, "", 0)
|
||||||
|
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := newUpwatch(sett, nil, nil, nil, notify.New("", "", "", sett, lg, false), agentLine{}, lg)
|
||||||
|
for _, a := range []string{"qbittorrent", "transmission", "plex"} {
|
||||||
|
if _, ok := svc.Watcher.Explainers[a]; !ok {
|
||||||
|
t.Errorf("no explainer for %s", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if svc.Watcher.Emit == nil || svc.Watcher.Line == nil || svc.QB == nil || svc.QBShare != 0.5 {
|
||||||
|
t.Fatalf("service not wired: %+v", svc)
|
||||||
|
}
|
||||||
|
if _, ok := svc.Watcher.Source.(*upwatch.DockerAPI); !ok {
|
||||||
|
t.Fatal("the watch must read the Engine API's exact counters, not docker stats' text")
|
||||||
|
}
|
||||||
|
// R-1250: both torrent apps can be held, qBittorrent first; without a local API there is no hold at all.
|
||||||
|
if len(svc.Torrents) != 2 || svc.Torrents[0].Stack() != "qbittorrent" || svc.Torrents[1].Stack() != "transmission" ||
|
||||||
|
svc.TorrentRunning == nil || svc.LineWaiting != nil || svc.LineRun != nil {
|
||||||
|
t.Fatalf("line-test hold not wired: %d torrents, waiting=%v run=%v", len(svc.Torrents), svc.LineWaiting != nil, svc.LineRun != nil)
|
||||||
|
}
|
||||||
|
if svc.Watcher.Baseline != nil {
|
||||||
|
t.Fatal("no metrics store, yet a baseline: an app with no explainer would be judged without history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-1250: the start-up check puts a held limit back BEFORE the first rule reads it, and every tick runs the hold
|
||||||
|
// before the 50 % rule. Asserted on startUpwatch's source order.
|
||||||
|
// RED-PROOF: move svc.RecoverHold below the ticker, or drop SeedLineTestNow → fails.
|
||||||
|
func TestStartUpwatchRecoversTheHoldFirst(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("upwatch_wiring.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := string(src)
|
||||||
|
i := strings.Index(body, "func startUpwatch(")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("no startUpwatch")
|
||||||
|
}
|
||||||
|
body = body[i:]
|
||||||
|
rec, ticker := strings.Index(body, "svc.RecoverHold(ctx)"), strings.Index(body, "time.NewTicker(")
|
||||||
|
seed, rule := strings.Index(body, "svc.SeedLineTestNow(ctx)"), strings.Index(body, "svc.QBLimitNow(ctx)")
|
||||||
|
if rec < 0 || rec > ticker {
|
||||||
|
t.Fatalf("RecoverHold at %d, the ticker at %d: a held limit must go back before anything else runs", rec, ticker)
|
||||||
|
}
|
||||||
|
if seed < 0 || seed > rule {
|
||||||
|
t.Fatalf("SeedLineTestNow at %d, QBLimitNow at %d: the hold must run before the 50 %% rule", seed, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a local API, agentLineTest yields all three halves, and newUpwatch hands the hold both agent calls.
|
||||||
|
// RED-PROOF: drop LineRun from newUpwatch's Service, or Run from agentLineTest → fails.
|
||||||
|
func TestUpwatchHoldGetsBothAgentCalls(t *testing.T) {
|
||||||
|
lg := log.New(io.Discard, "", 0)
|
||||||
|
line := agentLineTest("127.0.0.1:1", "token-canary", strings.Repeat("ab", 32), lg)
|
||||||
|
if line.Measured == nil || line.Waiting == nil || line.Run == nil {
|
||||||
|
t.Fatalf("agentLineTest with a local API: measured=%v waiting=%v run=%v", line.Measured != nil, line.Waiting != nil, line.Run != nil)
|
||||||
|
}
|
||||||
|
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := newUpwatch(sett, nil, nil, nil, notify.New("", "", "", sett, lg, false), line, lg)
|
||||||
|
if svc.Measured == nil || svc.LineWaiting == nil || svc.LineRun == nil {
|
||||||
|
t.Fatalf("newUpwatch dropped an agent call: measured=%v waiting=%v run=%v", svc.Measured != nil, svc.LineWaiting != nil, svc.LineRun != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,6 @@ stacks:
|
|||||||
- "cloudflared"
|
- "cloudflared"
|
||||||
- "felhom-controller"
|
- "felhom-controller"
|
||||||
- "filebrowser"
|
- "filebrowser"
|
||||||
update_window: "03:00-05:00"
|
|
||||||
compose_command: ""
|
compose_command: ""
|
||||||
|
|
||||||
# --- Backup ---
|
# --- Backup ---
|
||||||
@@ -83,6 +82,21 @@ monitoring:
|
|||||||
# ping_uuids: (deprecated — monitoring is now handled by the Hub event system)
|
# ping_uuids: (deprecated — monitoring is now handled by the Hub event system)
|
||||||
system_health_interval: "5m"
|
system_health_interval: "5m"
|
||||||
health_check_schedule: "06:00"
|
health_check_schedule: "06:00"
|
||||||
|
# Off-site (restic) store integrity check — R-359 / R-399.
|
||||||
|
integrity:
|
||||||
|
max_age_days: 7 # A MAX AGE, not a weekday: the job runs daily and asks "is the last
|
||||||
|
# successful check older than this?", so a box that was off on its check
|
||||||
|
# day is checked the next day it is on.
|
||||||
|
read_data_subset: "" # HOW DEEP the check looks. Empty or absent = the default, 100% — every
|
||||||
|
# stored byte is downloaded and re-hashed. Set "off" to check the
|
||||||
|
# structure and index only; set "10%", "1/7" or "50M" for a partial
|
||||||
|
# re-read. A value restic does not accept WARNs and falls back to the
|
||||||
|
# default, never to "off".
|
||||||
|
# The default is 100% because the structure check does NOT detect a
|
||||||
|
# size-preserving pack corruption: measured on a real store 2026-08-30,
|
||||||
|
# `restic check` reported "no errors were found" over a damaged pack that
|
||||||
|
# every read-data form caught. Cost on that 134 MB store: 35.0 s at
|
||||||
|
# structure depth, 39.2 s at 100%.
|
||||||
thresholds:
|
thresholds:
|
||||||
disk_warn_percent: 80
|
disk_warn_percent: 80
|
||||||
disk_crit_percent: 90
|
disk_crit_percent: 90
|
||||||
|
|||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
module gitea.dooplex.hu/admin/felhom-controller
|
module gitea.dooplex.hu/admin/felhom-controller
|
||||||
|
|
||||||
go 1.24.0
|
go 1.27.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||||
github.com/emersion/go-smtp v0.24.0
|
github.com/emersion/go-smtp v0.24.0
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
@@ -18,7 +18,7 @@ require (
|
|||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // 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/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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/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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
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.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
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=
|
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 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
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 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
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=
|
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
|
# is a Finder Settings -> Sidebar toggle). Treat sidebar discovery as an OPEN
|
||||||
# question, not a shipped feature.
|
# question, not a shipped feature.
|
||||||
# Evidence: felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md.
|
# 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);
|
# 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;
|
# wsdd = WS-Discovery daemon; tini = a proper PID1 to reap nmbd/wsdd/avahi and forward signals;
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Package aftercrash runs the apps' after_unclean_boot blocks ONCE per controller start that followed an unclean
|
||||||
|
// host boot (R-1007, `09` §3 decision 236).
|
||||||
|
//
|
||||||
|
// The fact is crashboot's (the host crash guard, read through the agent): the same verdict that holds the app mails
|
||||||
|
// for CrashGrace. Unknown is a normal boot — a repair never runs on a guess. A normal boot never runs it. After a crash
|
||||||
|
// boot it waits CrashGrace from the controller's start (the apps come up slowly after a crash), then runs each app's
|
||||||
|
// block one after the other and reports each app's counts. It never stops an app; a failed run is reported, not
|
||||||
|
// retried. Pinned by aftercrash_test.go.
|
||||||
|
package aftercrash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verdict is crashboot.Grace's Decided (a seam for tests).
|
||||||
|
type Verdict func(now time.Time) (resolved, crash bool, bootAt time.Time)
|
||||||
|
|
||||||
|
// Runner does the one run. Every func field is required; Logger may be nil.
|
||||||
|
type Runner struct {
|
||||||
|
Start time.Time
|
||||||
|
Verdict Verdict
|
||||||
|
Apps func() []string
|
||||||
|
Run func(name string, bootAt time.Time) stacks.AfterUncleanBootResult
|
||||||
|
Report func(name string, bootAt time.Time, res stacks.AfterUncleanBootResult)
|
||||||
|
Logger *log.Logger
|
||||||
|
// Wait is how long after Start a crash-boot run waits (default crashboot.CrashGrace).
|
||||||
|
Wait time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the runner on the controller's crash-boot grace.
|
||||||
|
func New(g *crashboot.Grace, apps func() []string,
|
||||||
|
run func(string, time.Time) stacks.AfterUncleanBootResult,
|
||||||
|
report func(string, time.Time, stacks.AfterUncleanBootResult), logger *log.Logger) *Runner {
|
||||||
|
return &Runner{Start: g.Start(), Verdict: g.Decided, Apps: apps, Run: run, Report: report, Logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done reports whether this start's decision has been taken (ran, or decided not to).
|
||||||
|
func (r *Runner) Done() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick is called periodically (the scheduler). It returns true once the run has happened or been ruled out.
|
||||||
|
func (r *Runner) Tick(now time.Time) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.done {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
resolved, crash, bootAt := r.Verdict(now)
|
||||||
|
if !resolved {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !crash {
|
||||||
|
r.done = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.logf("[INFO] [aftercrash] a normal (or unknown) boot — no app repair after an unclean boot (R-1007)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
wait := r.Wait
|
||||||
|
if wait <= 0 {
|
||||||
|
wait = crashboot.CrashGrace
|
||||||
|
}
|
||||||
|
if now.Sub(r.Start) < wait {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.done = true // set BEFORE the run: a slow or failing run is never started twice
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
apps := r.Apps()
|
||||||
|
r.logf("[INFO] [aftercrash] the host's boot at %s followed an unclean stop — %d app(s) with an after_unclean_boot block (R-1007)",
|
||||||
|
bootAt.UTC().Format(time.RFC3339), len(apps))
|
||||||
|
for _, name := range apps {
|
||||||
|
res := r.Run(name, bootAt)
|
||||||
|
if res.Skipped != "" {
|
||||||
|
r.logf("[WARN] [aftercrash] %s: skipped — %s", name, res.Skipped)
|
||||||
|
} else {
|
||||||
|
r.logf("[INFO] [aftercrash] %s: found %d empty file(s), repaired %d, still broken %d, lost %d (checked=%t) in %s%s",
|
||||||
|
name, res.Found, res.Repaired, res.StillBroken, res.Lost, res.LostChecked, res.Duration.Round(time.Millisecond), problem(res.Problem))
|
||||||
|
}
|
||||||
|
r.Report(name, bootAt, res)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func problem(p string) string {
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " — problem: " + p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) logf(format string, args ...any) {
|
||||||
|
if r.Logger != nil {
|
||||||
|
r.Logger.Printf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package aftercrash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1007 (decision 236): the repair runs ONLY after an unclean host boot that THIS start followed, only after the
|
||||||
|
// crash grace, and only once. A normal boot and an unknown fact never run it. The verdict is crashboot's own (the
|
||||||
|
// real Grace with a fake probe), so these tests assert the consequence: was the app's block run?
|
||||||
|
|
||||||
|
var start = time.Date(2026, 10, 14, 12, 8, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func probe(f crashboot.Fact, err error) crashboot.Probe {
|
||||||
|
return func(context.Context) (crashboot.Fact, error) { return f, err }
|
||||||
|
}
|
||||||
|
|
||||||
|
func runner(p crashboot.Probe) (*Runner, *[]string) {
|
||||||
|
var ran []string
|
||||||
|
g := crashboot.New(start, p, nil)
|
||||||
|
r := New(g, func() []string { return []string{"immich"} },
|
||||||
|
func(name string, _ time.Time) stacks.AfterUncleanBootResult {
|
||||||
|
ran = append(ran, name)
|
||||||
|
return stacks.AfterUncleanBootResult{}
|
||||||
|
},
|
||||||
|
func(string, time.Time, stacks.AfterUncleanBootResult) {}, nil)
|
||||||
|
return r, &ran
|
||||||
|
}
|
||||||
|
|
||||||
|
// RED-PROOF: drop the `now.Sub(r.Start) < wait` check → the run happens at +1 min → fails.
|
||||||
|
// RED-PROOF: drop `r.done = true` before the run → a second tick runs it again → fails.
|
||||||
|
func TestAfterCrash_RunsOnceAfterTheGraceOnAnUncleanBootThisStartFollowed(t *testing.T) {
|
||||||
|
r, ran := runner(probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-40 * time.Second)}, nil))
|
||||||
|
for _, at := range []time.Duration{0, time.Minute, 14 * time.Minute} {
|
||||||
|
r.Tick(start.Add(at))
|
||||||
|
}
|
||||||
|
if len(*ran) != 0 {
|
||||||
|
t.Fatalf("ran inside the crash grace: %v", *ran)
|
||||||
|
}
|
||||||
|
r.Tick(start.Add(crashboot.CrashGrace + time.Second))
|
||||||
|
r.Tick(start.Add(crashboot.CrashGrace + time.Minute))
|
||||||
|
if len(*ran) != 1 {
|
||||||
|
t.Fatalf("ran %d time(s), want exactly once after the grace", len(*ran))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RED-PROOF: in Tick, treat `!crash` like a crash (drop the early return) → a normal boot runs the repair → fails.
|
||||||
|
func TestAfterCrash_NeverOnANormalAnUnknownOrAnOldUncleanBoot(t *testing.T) {
|
||||||
|
cases := map[string]crashboot.Probe{
|
||||||
|
"normal boot": probe(crashboot.Fact{Known: true, Unclean: false, BootAt: start.Add(-time.Minute)}, nil),
|
||||||
|
"unknown (no crash guard)": probe(crashboot.Fact{}, nil),
|
||||||
|
"unknown (agent unreachable)": probe(crashboot.Fact{}, context.DeadlineExceeded),
|
||||||
|
"no agent at all": nil,
|
||||||
|
"an unclean boot days ago": probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-72 * time.Hour)}, nil),
|
||||||
|
"an unclean boot with no time": probe(crashboot.Fact{Known: true, Unclean: true}, nil),
|
||||||
|
}
|
||||||
|
for name, p := range cases {
|
||||||
|
r, ran := runner(p)
|
||||||
|
for at := time.Duration(0); at <= 2*crashboot.CrashGrace; at += 30 * time.Second {
|
||||||
|
r.Tick(start.Add(at))
|
||||||
|
}
|
||||||
|
if len(*ran) != 0 {
|
||||||
|
t.Errorf("%s: the repair ran", name)
|
||||||
|
}
|
||||||
|
if !r.Done() {
|
||||||
|
t.Errorf("%s: the decision was never taken", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ type BackupTierInfo struct {
|
|||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
CadenceSeconds int64 `json:"cadence_seconds"`
|
CadenceSeconds int64 `json:"cadence_seconds"`
|
||||||
Primary bool `json:"primary"`
|
Primary bool `json:"primary"`
|
||||||
|
// Storage (agent >= v0.131.0, R-518): "present" | "absent" | "unknown"; "" on an older agent.
|
||||||
|
// Only "absent" licenses skipping a tier — unknown and legacy are treated as present.
|
||||||
|
Storage string `json:"storage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TiersResponse mirrors the agent's GET /backup/tiers payload.
|
// TiersResponse mirrors the agent's GET /backup/tiers payload.
|
||||||
@@ -61,6 +64,21 @@ func targetQuery(target string) string {
|
|||||||
return "?target=" + url.QueryEscape(target)
|
return "?target=" + url.QueryEscape(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backupStartQuery is targetQuery plus `trigger=manual` for a household press (R-899).
|
||||||
|
func backupStartQuery(target string, manual bool) string {
|
||||||
|
q := url.Values{}
|
||||||
|
if target != "" {
|
||||||
|
q.Set("target", target)
|
||||||
|
}
|
||||||
|
if manual {
|
||||||
|
q.Set("trigger", "manual")
|
||||||
|
}
|
||||||
|
if len(q) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
// BackupDueFor reports whether THIS TIER is due. A fresh backup on another tier must not satisfy it
|
// BackupDueFor reports whether THIS TIER is due. A fresh backup on another tier must not satisfy it
|
||||||
// — that filtering happens agent-side (latestSuccessfulBackupForTarget); this just asks per tier.
|
// — that filtering happens agent-side (latestSuccessfulBackupForTarget); this just asks per tier.
|
||||||
func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse, error) {
|
func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse, error) {
|
||||||
@@ -77,8 +95,15 @@ func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse,
|
|||||||
|
|
||||||
// StartBackupFor enqueues a backup of this guest ON THE GIVEN TIER.
|
// StartBackupFor enqueues a backup of this guest ON THE GIVEN TIER.
|
||||||
func (c *Client) StartBackupFor(ctx context.Context, target string) (BackupResponse, error) {
|
func (c *Client) StartBackupFor(ctx context.Context, target string) (BackupResponse, error) {
|
||||||
|
return c.StartBackupForTrigger(ctx, target, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBackupForTrigger is StartBackupFor that also says whether this is a household press (R-899): a press
|
||||||
|
// carries `trigger=manual`, and an agent that knows it does not start the night's OS leg after it. An older
|
||||||
|
// agent ignores the parameter (it reads only `target`), so the request is safe against any agent.
|
||||||
|
func (c *Client) StartBackupForTrigger(ctx context.Context, target string, manual bool) (BackupResponse, error) {
|
||||||
var out BackupResponse
|
var out BackupResponse
|
||||||
body, err := c.post(ctx, "/backup"+targetQuery(target), struct{}{})
|
body, err := c.post(ctx, "/backup"+backupStartQuery(target, manual), struct{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -102,6 +127,48 @@ func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResp
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BackupJobsInFlight (R-921) lists the tiers whose backup job the agent holds in flight right now — phase
|
||||||
|
// running or snapshotted, the agent's own definition (localapi backupInFlight). A job in flight on another
|
||||||
|
// tier is the agent's first reason to refuse POST /backup (HTTP 409), so the controller asks this BEFORE it
|
||||||
|
// stops any app. It reads only GET /backup/tiers and GET /backup/status?target= (both agent >= v0.97.0); a
|
||||||
|
// pre-R-82 agent (no /backup/tiers) answers nil, nil. Any other error is returned — the caller fails toward
|
||||||
|
// backing up. It does NOT see the agent's host-wide heavy-operation gate; HostBusy does (newer agents).
|
||||||
|
func (c *Client) BackupJobsInFlight(ctx context.Context) ([]string, error) {
|
||||||
|
tiers, err := c.BackupTiers(ctx)
|
||||||
|
if errors.Is(err, ErrTiersUnsupported) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, t := range tiers.Tiers {
|
||||||
|
st, err := c.BackupStatusFor(ctx, t.Target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if st.Phase == PhaseRunning || st.Phase == PhaseSnapshotted {
|
||||||
|
out = append(out, t.Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostBusy (R-921) reads GET /backup/status's host_busy: whether the agent's host-wide heavy-operation gate is held
|
||||||
|
// right now (another guest's backup, the restore test, a night OS step …) — the agent's second reason to refuse
|
||||||
|
// POST /backup. known=false: the agent does not serve the field (older than the agent release that adds it) — no
|
||||||
|
// information, never „free". An error is returned; the caller fails toward backing up.
|
||||||
|
func (c *Client) HostBusy(ctx context.Context) (busy bool, what string, known bool, err error) {
|
||||||
|
st, err := c.BackupStatusFor(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return false, "", false, err
|
||||||
|
}
|
||||||
|
if st.HostBusy == nil {
|
||||||
|
return false, "", false, nil
|
||||||
|
}
|
||||||
|
return st.HostBusy.Busy, st.HostBusy.What, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
|
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
|
||||||
type SetBackupTargetResponse struct {
|
type SetBackupTargetResponse struct {
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
|
|||||||
@@ -199,6 +199,35 @@ type StatusResponse struct {
|
|||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
Backup *BackupRecord `json:"backup,omitempty"`
|
Backup *BackupRecord `json:"backup,omitempty"`
|
||||||
|
// Tiers (agent >= v0.131.0, R-517) — per tier: newest success, last attempt, storage presence.
|
||||||
|
// Absent on an older agent; the page then falls back to the single latest record.
|
||||||
|
Tiers []TierBackupState `json:"tiers,omitempty"`
|
||||||
|
// HostBusy (R-921, agent Unreleased 2026-10-19) — whether the agent's host-wide heavy-operation gate is held
|
||||||
|
// right now (then POST /backup answers 409). nil = an older agent that does not serve it.
|
||||||
|
HostBusy *HostBusyState `json:"host_busy,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostBusyState mirrors the agent's localapi.HostBusyState.
|
||||||
|
type HostBusyState struct {
|
||||||
|
Busy bool `json:"busy"`
|
||||||
|
What string `json:"what,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierBackupState mirrors the agent's localapi.TierBackupState.
|
||||||
|
type TierBackupState struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
Storage string `json:"storage"` // present | absent | unknown
|
||||||
|
LastSuccess *BackupRecord `json:"last_success,omitempty"`
|
||||||
|
LastSuccessSource string `json:"last_success_source,omitempty"` // record | storage
|
||||||
|
LastAttempt *TierAttempt `json:"last_attempt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierAttempt is a tier's newest recorded attempt.
|
||||||
|
type TierAttempt struct {
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The
|
// BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The
|
||||||
@@ -235,6 +264,9 @@ const (
|
|||||||
PhaseRunning = "running"
|
PhaseRunning = "running"
|
||||||
PhaseDone = "done"
|
PhaseDone = "done"
|
||||||
PhaseFailed = "failed"
|
PhaseFailed = "failed"
|
||||||
|
// PhaseSnapshotted (8B.2): the storage snapshot is taken, the upload still runs and still holds the
|
||||||
|
// guest — the agent counts it as IN FLIGHT (felhom-agent localapi backupInFlight).
|
||||||
|
PhaseSnapshotted = "snapshotted"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BackupDue reports whether a policy-scheduled backup is due for this guest (the quiesce trigger).
|
// BackupDue reports whether a policy-scheduled backup is due for this guest (the quiesce trigger).
|
||||||
@@ -252,15 +284,7 @@ func (c *Client) BackupDue(ctx context.Context) (DueResponse, error) {
|
|||||||
|
|
||||||
// StartBackup enqueues a backup of this guest (the agent vzdump) and returns the job to poll.
|
// StartBackup enqueues a backup of this guest (the agent vzdump) and returns the job to poll.
|
||||||
func (c *Client) StartBackup(ctx context.Context) (BackupResponse, error) {
|
func (c *Client) StartBackup(ctx context.Context) (BackupResponse, error) {
|
||||||
var out BackupResponse
|
return c.StartBackupForTrigger(ctx, "", false)
|
||||||
body, err := c.post(ctx, "/backup", struct{}{})
|
|
||||||
if err != nil {
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &out); err != nil {
|
|
||||||
return out, fmt.Errorf("agentapi: decode POST /backup: %w", err)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupStatus reports the current/last backup job phase for this guest.
|
// BackupStatus reports the current/last backup job phase for this guest.
|
||||||
@@ -340,8 +364,18 @@ type DiskInfo struct {
|
|||||||
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
|
// 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.
|
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
|
||||||
Smart *SmartSummary `json:"smart,omitempty"`
|
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
|
// 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).
|
// is not a filesystem UUID (network/lvm targets — not assignable as a host mount).
|
||||||
func (d DiskInfo) FSUUID() string {
|
func (d DiskInfo) FSUUID() string {
|
||||||
@@ -375,6 +409,10 @@ type FormatResult struct {
|
|||||||
DurableID string `json:"durable_id,omitempty"`
|
DurableID string `json:"durable_id,omitempty"`
|
||||||
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
|
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
|
||||||
PendingOp *PendingOp `json:"pending_op,omitempty"`
|
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
|
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
|
||||||
@@ -400,6 +438,36 @@ var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup de
|
|||||||
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
|
// 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")
|
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
|
||||||
|
|
||||||
|
// ErrNoDurableID is the agent's anti-retarget refusal of a blank format (R-1069): the device has no durable id to
|
||||||
|
// bind the format to — a virtual disk passed on a virtio bus without a serial has no /dev/disk/by-id link. The agent's
|
||||||
|
// rule is a safety rule and stays; this only lets the dashboard say it in plain words. Matched on the agent's own
|
||||||
|
// refusal text (felhom-agent internal/localapi/disks.go „format refused: device has no durable id"); if that text
|
||||||
|
// ever changes, the refusal degrades to the raw message it was before. Pinned by TestFormatDisk_NoDurableIDIsTyped.
|
||||||
|
var ErrNoDurableID = fmt.Errorf("agentapi: format refused — the device has no durable id (path-only formats are not permitted)")
|
||||||
|
|
||||||
|
// SameDiskResult is the agent's answer to POST /disks/same-disk (v0.157.0). Act on Proven first:
|
||||||
|
// Proven+Different = a counted second copy; Proven+!Different = the same disk; !Proven = unknown
|
||||||
|
// (Reason vm|network|usb_bridge|weak_wwn|no_serial|unmapped).
|
||||||
|
type SameDiskResult struct {
|
||||||
|
Different bool `json:"different"`
|
||||||
|
Proven bool `json:"proven"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameDisk asks the agent whether two guest paths sit on the same physical disk. Gate it with
|
||||||
|
// Supports(FeatureSameDisk) — an older agent answers 404.
|
||||||
|
func (c *Client) SameDisk(ctx context.Context, a, b string) (SameDiskResult, error) {
|
||||||
|
var out SameDiskResult
|
||||||
|
raw, err := c.post(ctx, "/disks/same-disk", map[string]string{"a": a, "b": b})
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("agentapi: decode /disks/same-disk: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
|
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
|
||||||
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
|
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
|
||||||
var out DisksResponse
|
var out DisksResponse
|
||||||
@@ -424,8 +492,42 @@ type DiskCandidate struct {
|
|||||||
Mountable bool `json:"mountable"`
|
Mountable bool `json:"mountable"`
|
||||||
MountSource string `json:"mount_source,omitempty"`
|
MountSource string `json:"mount_source,omitempty"`
|
||||||
DurableID string `json:"durable_id,omitempty"`
|
DurableID string `json:"durable_id,omitempty"`
|
||||||
|
// AlreadyMounted marks a candidate the CONTROLLER contributed from its own mount table (R-280),
|
||||||
|
// not one the agent scanned. The agent NEVER sets it. Its action is REGISTER the existing
|
||||||
|
// 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
|
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
|
||||||
// unclaimed) and attach (the mountable-FS subset).
|
// unclaimed) and attach (the mountable-FS subset).
|
||||||
type CandidatesResult struct {
|
type CandidatesResult struct {
|
||||||
@@ -485,6 +587,10 @@ type GuestMemoryInfo struct {
|
|||||||
MaxMB int64 `json:"max_mb"`
|
MaxMB int64 `json:"max_mb"`
|
||||||
FloorMB int64 `json:"floor_mb"`
|
FloorMB int64 `json:"floor_mb"`
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
|
// HostSwapMB / GuestSwapMB (agent ≥ the R-1192 swap release): the host's swap and this guest's swap allowance.
|
||||||
|
// nil = an older agent (not judged).
|
||||||
|
HostSwapMB *int64 `json:"host_swap_mb,omitempty"`
|
||||||
|
GuestSwapMB *int64 `json:"guest_swap_mb,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemoryResizeResult mirrors the agent's POST /guest/memory success body.
|
// MemoryResizeResult mirrors the agent's POST /guest/memory success body.
|
||||||
@@ -693,6 +799,11 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
if err := refusalError("/disks/decommission", status, env); err != nil {
|
if err := refusalError("/disks/decommission", status, env); err != nil {
|
||||||
|
if status == http.StatusForbidden || status == http.StatusConflict {
|
||||||
|
// R-1063: the agent's two policy refusals (403 a protected role, 409 the whole-guest backup target) are
|
||||||
|
// typed, so the dashboard can say which in plain words. The text is refusalError's, unchanged.
|
||||||
|
return out, &DecommissionRefusedError{Status: status, Reason: refusalReason(env)}
|
||||||
|
}
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||||
@@ -701,6 +812,17 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DecommissionRefusedError is the agent saying no to a decommission: Status 403 (the mount is system/backup by its
|
||||||
|
// role) or 409 (the mount holds the whole-guest backup target). Nothing was done on the host.
|
||||||
|
type DecommissionRefusedError struct {
|
||||||
|
Status int
|
||||||
|
Reason string // the agent's own words, truncated (never a secret: the agent's refusal text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DecommissionRefusedError) Error() string {
|
||||||
|
return fmt.Sprintf("agentapi: POST /disks/decommission: HTTP %d: %s", e.Status, e.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
// refusalError converts a non-2xx status or an ok:false envelope into an error that CARRIES the
|
// refusalError converts a non-2xx status or an ok:false envelope into an error that CARRIES the
|
||||||
// agent's reason (truncated; never request bodies or secrets). nil on an accepted 200/202+ok=true.
|
// agent's reason (truncated; never request bodies or secrets). nil on an accepted 200/202+ok=true.
|
||||||
func refusalError(path string, status int, env apiResponse) error {
|
func refusalError(path string, status int, env apiResponse) error {
|
||||||
@@ -708,16 +830,21 @@ func refusalError(path string, status int, env apiResponse) error {
|
|||||||
if accepted && env.OK {
|
if accepted && env.OK {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
reason := truncateErr(env.Error, 300)
|
reason := refusalReason(env)
|
||||||
if reason == "" {
|
|
||||||
reason = "(no reason in agent response)"
|
|
||||||
}
|
|
||||||
if accepted { // 2xx but ok:false — business refusal without an HTTP error code
|
if accepted { // 2xx but ok:false — business refusal without an HTTP error code
|
||||||
return fmt.Errorf("agentapi: POST %s: %s", path, reason)
|
return fmt.Errorf("agentapi: POST %s: %s", path, reason)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("agentapi: POST %s: HTTP %d: %s", path, status, reason)
|
return fmt.Errorf("agentapi: POST %s: HTTP %d: %s", path, status, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refusalReason is the agent's refusal text as refusalError carries it.
|
||||||
|
func refusalReason(env apiResponse) string {
|
||||||
|
if r := truncateErr(env.Error, 300); r != "" {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return "(no reason in agent response)"
|
||||||
|
}
|
||||||
|
|
||||||
// truncateErr mirrors stacks.truncateStr for agent refusal reasons.
|
// truncateErr mirrors stacks.truncateStr for agent refusal reasons.
|
||||||
func truncateErr(s string, maxLen int) string {
|
func truncateErr(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
@@ -764,6 +891,9 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme
|
|||||||
// real failure (e.g. the agent's 502 on a mkfs error: "device is mounted"). Returning the zero-value
|
// real failure (e.g. the agent's 502 on a mkfs error: "device is mounted"). Returning the zero-value
|
||||||
// result with a nil error here made a failed destructive format read as a silent SUCCESS in the web
|
// result with a nil error here made a failed destructive format read as a silent SUCCESS in the web
|
||||||
// layer. Surface it as an error so the caller (and the dashboard) report the failure.
|
// layer. Surface it as an error so the caller (and the dashboard) report the failure.
|
||||||
|
if status == http.StatusConflict && strings.Contains(env.Error, "has no durable id") {
|
||||||
|
return out, ErrNoDurableID
|
||||||
|
}
|
||||||
if status < 200 || status >= 300 || !env.OK {
|
if status < 200 || status >= 300 || !env.OK {
|
||||||
msg := strings.TrimSpace(env.Error)
|
msg := strings.TrimSpace(env.Error)
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
@@ -783,6 +913,8 @@ type FormatStatusResult struct {
|
|||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
FSType string `json:"fstype"`
|
FSType string `json:"fstype"`
|
||||||
Error string `json:"error"`
|
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.
|
// FormatStatus fetches the agent's most-recent format-job record.
|
||||||
@@ -1034,6 +1166,13 @@ type SmartSummary struct {
|
|||||||
ReallocatedSectors *int `json:"reallocated_sectors"`
|
ReallocatedSectors *int `json:"reallocated_sectors"`
|
||||||
PendingSectors *int `json:"pending_sectors"`
|
PendingSectors *int `json:"pending_sectors"`
|
||||||
OfflineUncorrectable *int `json:"offline_uncorrectable"`
|
OfflineUncorrectable *int `json:"offline_uncorrectable"`
|
||||||
|
// R-330 (agent v0.150.0+): three more SATA raw counters — 187 Reported_Uncorrect, 188
|
||||||
|
// Command_Timeout (vendor-packed on some drives: carried as reported), 199 UDMA_CRC_Error_Count.
|
||||||
|
// nil = unknown (an older agent, an NVMe/USB device, or not reported), never zero. CARRIED ONLY:
|
||||||
|
// no verdict, banner or mail reads them yet — that would change what a household is told.
|
||||||
|
ReportedUncorrect *int64 `json:"reported_uncorrect,omitempty"`
|
||||||
|
CommandTimeout *int64 `json:"command_timeout,omitempty"`
|
||||||
|
UDMACRCErrors *int64 `json:"udma_crc_errors,omitempty"`
|
||||||
// NVMe attributes.
|
// NVMe attributes.
|
||||||
CriticalWarning *int `json:"critical_warning"`
|
CriticalWarning *int `json:"critical_warning"`
|
||||||
MediaErrors *int `json:"media_errors"`
|
MediaErrors *int `json:"media_errors"`
|
||||||
@@ -1152,6 +1291,18 @@ func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error)
|
|||||||
// post issues an authenticated JSON POST and unwraps the {ok,data,error} envelope. The agent
|
// post issues an authenticated JSON POST and unwraps the {ok,data,error} envelope. The agent
|
||||||
// returns 200 or 202 for accepted requests.
|
// returns 200 or 202 for accepted requests.
|
||||||
func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||||
|
return c.postWith(c.hc, ctx, path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postSlow is post with its own overall timeout, for the few synchronous routes that outlast the client's 15 s
|
||||||
|
// (POST /host/line-test/run, about 55 s). It shares the pinned transport.
|
||||||
|
func (c *Client) postSlow(ctx context.Context, path string, body any, timeout time.Duration) (json.RawMessage, error) {
|
||||||
|
hc := *c.hc
|
||||||
|
hc.Timeout = timeout
|
||||||
|
return c.postWith(&hc, ctx, path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) postWith(hc *http.Client, ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||||
buf, err := json.Marshal(body)
|
buf, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1163,7 +1314,7 @@ func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessa
|
|||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := c.hc.Do(req)
|
resp, err := hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
|
logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
|
||||||
return nil, fmt.Errorf("agentapi: POST %s: %w", path, err)
|
return nil, fmt.Errorf("agentapi: POST %s: %w", path, err)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CrashGuardState mirrors the agent's GET /host/crash-guard (R-856, `09` §3 decision 143): what the
|
||||||
|
// host's crash guard (`felhom-crash-guard`, `11` §5.9) recorded about the most recent HOST boot. The
|
||||||
|
// agent reads /var/lib/felhom-crash-guard/state.json (0644) and passes these fields through.
|
||||||
|
//
|
||||||
|
// Present=false: the host has no crash guard or no state yet. An agent that predates the route answers
|
||||||
|
// 404 (a *StatusError) — both mean UNKNOWN, and the controller then keeps its normal boot grace.
|
||||||
|
type CrashGuardState struct {
|
||||||
|
Present bool `json:"present"`
|
||||||
|
LastBootAt string `json:"last_boot_at,omitempty"` // RFC3339 UTC ("2006-01-02T15:04:05Z")
|
||||||
|
LastBootUnclean bool `json:"last_boot_unclean"`
|
||||||
|
Tripped bool `json:"tripped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrashGuard calls GET /host/crash-guard.
|
||||||
|
func (c *Client) CrashGuard(ctx context.Context) (CrashGuardState, error) {
|
||||||
|
var out CrashGuardState
|
||||||
|
body, err := c.get(ctx, "/host/crash-guard")
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("agentapi: decode /host/crash-guard: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-856: GET /host/crash-guard decodes the crash guard's fields, and an agent that predates the route
|
||||||
|
// surfaces as a typed 404 (the caller reads it as unknown → the normal boot grace).
|
||||||
|
func TestR856_CrashGuardDecodesAndAnOlderAgentIs404(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /host/crash-guard", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":true,"last_boot_at":"2026-10-04T12:00:00Z","last_boot_unclean":true,"tripped":false}}`))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||||
|
st, err := c.CrashGuard(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !st.Present || !st.LastBootUnclean || st.LastBootAt != "2026-10-04T12:00:00Z" || st.Tripped {
|
||||||
|
t.Fatalf("state = %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
old := httptest.NewTLSServer(http.NewServeMux())
|
||||||
|
defer old.Close()
|
||||||
|
_, err = clientFor(t, old, strings.TrimPrefix(old.URL, "https://")).CrashGuard(context.Background())
|
||||||
|
var se *StatusError
|
||||||
|
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("an older agent must answer a typed 404, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-1063: the agent's two decommission refusals come back TYPED (403 a protected role, 409 the whole-guest backup
|
||||||
|
// target), with the same text as before; any other failure is not that type.
|
||||||
|
func TestDecommission_PolicyRefusalsAreTyped(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
code int
|
||||||
|
typed bool
|
||||||
|
}{{http.StatusForbidden, true}, {http.StatusConflict, true}, {http.StatusBadGateway, false}} {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(c.code)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":false,"error":"refused for the test"}`))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).Decommission(context.Background(), "/mnt/x")
|
||||||
|
s.Close()
|
||||||
|
var dr *DecommissionRefusedError
|
||||||
|
if got := errors.As(err, &dr); got != c.typed {
|
||||||
|
t.Fatalf("HTTP %d: typed=%v, want %v (err %v)", c.code, got, c.typed, err)
|
||||||
|
}
|
||||||
|
if c.typed && (dr.Status != c.code || !strings.Contains(err.Error(), "HTTP "+strconv.Itoa(c.code)+": refused for the test")) {
|
||||||
|
t.Fatalf("HTTP %d: %+v / %q", c.code, dr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-1069: the agent's anti-retarget refusal of a blank format (its exact text, a 409) comes back as ErrNoDurableID;
|
||||||
|
// another 409 does not.
|
||||||
|
func TestFormatDisk_NoDurableIDIsTyped(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
body string
|
||||||
|
typed bool
|
||||||
|
}{
|
||||||
|
{`{"ok":false,"error":"format refused: device has no durable id to bind the format to (path-only formats are not permitted)","data":{"formatted":false,"data_bearing":false}}`, true},
|
||||||
|
{`{"ok":false,"error":"format refused (device may have changed since inspection): gone","data":{"formatted":false}}`, false},
|
||||||
|
} {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
_, _ = w.Write([]byte(c.body))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).FormatDisk(context.Background(), "/dev/vdb", "ext4", false, "")
|
||||||
|
s.Close()
|
||||||
|
if got := errors.Is(err, ErrNoDurableID); got != c.typed || err == nil {
|
||||||
|
t.Fatalf("body %s: ErrNoDurableID=%v want %v (err %v)", c.body[:60], got, c.typed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package agentapi
|
package agentapi
|
||||||
|
|
||||||
// DiskVerdict is the customer-facing disk-health verdict derived from a SmartSummary (v0.169.0).
|
// DiskVerdict is the customer-facing disk-health verdict derived from a SmartSummary (v0.169.0).
|
||||||
// It is the SHARED source of truth for both the "Lemezek állapota" dashboard card and the 6-hourly
|
// It is the SHARED source of truth for both the "Lemezek állapota" dashboard card and the periodic
|
||||||
// degradation check — one pure function so the chip and the alert can never disagree.
|
// degradation check — one pure function so the chip and the alert can never disagree.
|
||||||
type DiskVerdict int
|
type DiskVerdict int
|
||||||
|
|
||||||
@@ -9,38 +9,151 @@ const (
|
|||||||
// DiskVerdictUnknown — no SMART data (nil / UNKNOWN / old agent). Renders "Nincs adat"; NEVER
|
// DiskVerdictUnknown — no SMART data (nil / UNKNOWN / old agent). Renders "Nincs adat"; NEVER
|
||||||
// alarms and NEVER participates in degradation transitions (excluded both directions).
|
// alarms and NEVER participates in degradation transitions (excluded both directions).
|
||||||
DiskVerdictUnknown DiskVerdict = iota
|
DiskVerdictUnknown DiskVerdict = iota
|
||||||
DiskVerdictOK // "Rendben" — PASSED, all counters clean
|
DiskVerdictOK // "Rendben" — clean
|
||||||
DiskVerdictWarn // "Figyelmeztetés" — PASSED but a wear/relocation counter is non-zero (or NVMe ≥90%)
|
DiskVerdictWarn // "Figyelmeztetés" — a wear/relocation counter is non-zero, below the Hiba bar
|
||||||
DiskVerdictFail // "Hiba" — FAILING
|
DiskVerdictFail // "Hiba" — FAILING, or failing-but-not-self-reported (v0.215.0)
|
||||||
)
|
)
|
||||||
|
|
||||||
// percentageUsedWarn is the NVMe wear threshold (inclusive) at which a still-PASSED disk warns.
|
// Thresholds. A number without a reason becomes permanent by default, so each carries its provenance.
|
||||||
const percentageUsedWarn = 90
|
// The evidence is committed at felhom.eu/documentation/audits/DIAG-smart-passed-trap-2026-08-14.md
|
||||||
|
// and its two fixtures (ST3000VX010 S/N Z6A07P2G, /dev/sdg on DooPlex, 11-13 Aug 2026).
|
||||||
|
const (
|
||||||
|
// percentageUsedWarn / percentageUsedFail — NVMe wear (%). 100 means the vendor's rated endurance
|
||||||
|
// is spent; that is a declaration, not a trend, so it is Hiba.
|
||||||
|
percentageUsedWarn = 90
|
||||||
|
percentageUsedFail = 100
|
||||||
|
|
||||||
// DiskVerdictFor maps a SmartSummary to a verdict per the v0.169.0 rules:
|
// uncorrectableFailCount — unreadable sectors too numerous to be a blip.
|
||||||
|
//
|
||||||
|
// PROVENANCE: on the one real failing drive observed, the benign excursion peaked at 16 and
|
||||||
|
// cleared COMPLETELY within an hour (11 Aug 12:28 -> 13:28); the terminal run passed 64 at
|
||||||
|
// 13 Aug 11:28 and never came back below it. 64 sits above the one observed transient and below
|
||||||
|
// the observed terminal run. This is a judgement from ONE drive: it is a static BACKSTOP behind
|
||||||
|
// the sustain rule, not the primary signal, and Phase 3 is expected to replace it with
|
||||||
|
// growth-rate detection once the box keeps history.
|
||||||
|
uncorrectableFailCount = 64
|
||||||
|
|
||||||
|
// temperatureWarnC / TemperatureFailC — adopted UNCHANGED from the operator's existing Prometheus
|
||||||
|
// bands on DooPlex, so the two systems cannot disagree about the same drive.
|
||||||
|
temperatureWarnC = 55
|
||||||
|
// TemperatureFailC is exported because the alert-copy layer must pick the "overheated" message
|
||||||
|
// shape from the SAME number the verdict fired on. A second literal elsewhere would be free to
|
||||||
|
// drift, and the drift would show up as a customer told the wrong reason.
|
||||||
|
TemperatureFailC = 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiskPrior is what the previous check observed for THIS SAME disk. It is the only history the
|
||||||
|
// verdict consults, and it is passed in rather than read so the function stays pure — the caller
|
||||||
|
// (internal/web) owns loading it from the persisted per-disk state.
|
||||||
//
|
//
|
||||||
// FAILING → Hiba
|
// Plain value type: no methods, no I/O. A zero DiskPrior means "nothing known", which is the correct
|
||||||
// PASSED + any(reallocated>0, pending>0, offline_unc>0,
|
// fail-safe — a first-ever observation can only reach Figyelmeztetés from counters, never Hiba.
|
||||||
// critical_warning>0, media_errors>0,
|
type DiskPrior struct {
|
||||||
// percentage_used >= 90) → Figyelmeztetés
|
// SawUncorrectable reports whether unreadable sectors (pending OR offline-uncorrectable) were
|
||||||
// PASSED otherwise → Rendben
|
// present at the previous check. It is what turns a one-off excursion into a sustained fault.
|
||||||
// nil / UNKNOWN / empty → Nincs adat
|
SawUncorrectable bool
|
||||||
func DiskVerdictFor(s *SmartSummary) DiskVerdict {
|
}
|
||||||
|
|
||||||
|
// 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 (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
|
||||||
|
// or below the threshold. The real drive stayed PASSED at 352 pending sectors with 1001 reported
|
||||||
|
// uncorrectable reads. A verdict built on the drive's own self-assessment is blind to this whole
|
||||||
|
// class of failure, which is why rows 3-8 read the raw counters instead.
|
||||||
|
//
|
||||||
|
// WHY row 6 sits ABOVE row 8: sustain is the PRIMARY rule and the count is the backstop. On the real
|
||||||
|
// 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.
|
||||||
if s == nil || s.Health == "" || s.Health == SmartUnknown {
|
if s == nil || s.Health == "" || s.Health == SmartUnknown {
|
||||||
return DiskVerdictUnknown
|
return DiskVerdictUnknown
|
||||||
}
|
}
|
||||||
|
// 2 — the drive admits failure.
|
||||||
if s.Health == SmartFailing {
|
if s.Health == SmartFailing {
|
||||||
return DiskVerdictFail
|
return DiskVerdictFail
|
||||||
}
|
}
|
||||||
// Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters.
|
// Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters,
|
||||||
if positive(s.ReallocatedSectors) || positive(s.PendingSectors) || positive(s.OfflineUncorrectable) ||
|
// because the overall verdict is structurally unable to report this class of fault.
|
||||||
positive(s.CriticalWarning) || positive(s.MediaErrors) || atLeast(s.PercentageUsed, percentageUsedWarn) {
|
nvme := IsNVMe(s)
|
||||||
|
switch {
|
||||||
|
case !nvme && atLeast(s.TemperatureC, TemperatureFailC): // 3
|
||||||
|
return DiskVerdictFail
|
||||||
|
case positive(s.CriticalWarning): // 4
|
||||||
|
return DiskVerdictFail
|
||||||
|
case atLeast(s.PercentageUsed, percentageUsedFail): // 5
|
||||||
|
return DiskVerdictFail
|
||||||
|
}
|
||||||
|
unreadable := UncorrectableSectors(s)
|
||||||
|
switch {
|
||||||
|
case unreadable > 0 && prior.SawUncorrectable: // 6 — sustained across two consecutive checks
|
||||||
|
return DiskVerdictFail
|
||||||
|
case unreadable > 0 && positive(s.ReallocatedSectors): // 7 — accumulating and remapping together
|
||||||
|
return DiskVerdictFail
|
||||||
|
case unreadable >= uncorrectableFailCount: // 8 — too large to be a blip
|
||||||
|
return DiskVerdictFail
|
||||||
|
case unreadable > 0: // 9 — first sighting, below the bar
|
||||||
|
return DiskVerdictWarn
|
||||||
|
case positive(s.ReallocatedSectors): // 10
|
||||||
|
return DiskVerdictWarn
|
||||||
|
case positive(s.MediaErrors): // 11
|
||||||
|
return DiskVerdictWarn
|
||||||
|
case atLeast(s.PercentageUsed, percentageUsedWarn): // 12
|
||||||
|
return DiskVerdictWarn
|
||||||
|
case !nvme && atLeast(s.TemperatureC, temperatureWarnC): // 13
|
||||||
return DiskVerdictWarn
|
return DiskVerdictWarn
|
||||||
}
|
}
|
||||||
return DiskVerdictOK
|
return DiskVerdictOK // 14
|
||||||
|
}
|
||||||
|
|
||||||
|
// UncorrectableSectors is the disk's unreadable-sector count: max(pending, offline_uncorrectable).
|
||||||
|
// The two attributes track the same physical defect and on the real drive moved in lockstep, so the
|
||||||
|
// larger is the honest figure. 0 when neither is reported (an old agent or a device without them).
|
||||||
|
// Exported because the alert copy quotes this number and the persisted state remembers it.
|
||||||
|
func UncorrectableSectors(s *SmartSummary) int {
|
||||||
|
if s == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
if s.PendingSectors != nil && *s.PendingSectors > n {
|
||||||
|
n = *s.PendingSectors
|
||||||
|
}
|
||||||
|
if s.OfflineUncorrectable != nil && *s.OfflineUncorrectable > n {
|
||||||
|
n = *s.OfflineUncorrectable
|
||||||
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label is the exact Hungarian customer copy for the verdict (shared by the card chip and the email).
|
// Label is the exact Hungarian customer copy for the verdict (shared by the card chip and the email).
|
||||||
|
//
|
||||||
|
// There are FOUR labels and there will not be a fifth: a predicted failure is "Hiba", the same word a
|
||||||
|
// self-reported failure gets. A fourth word sharing a root with "Figyelmeztetés" would make the MORE
|
||||||
|
// severe state read as the milder one (settled operator decision, v0.215.0).
|
||||||
func (v DiskVerdict) Label() string {
|
func (v DiskVerdict) Label() string {
|
||||||
switch v {
|
switch v {
|
||||||
case DiskVerdictOK:
|
case DiskVerdictOK:
|
||||||
@@ -54,11 +167,15 @@ func (v DiskVerdict) Label() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DegradedAttributes returns the human-readable Hungarian names of the attribute(s) that pushed a
|
// DegradedAttributes returns the human-readable Hungarian names of the attribute(s) behind a
|
||||||
// PASSED disk to Figyelmeztetés (empty for OK/Fail/Unknown) — for the alert body. FAILING is a
|
// degraded verdict, for the alert body.
|
||||||
// whole-disk verdict with no single triggering counter, so it returns nil there.
|
//
|
||||||
|
// v0.215.0: this now also names the attributes behind a Hiba REACHED FROM COUNTERS (truth-table rows
|
||||||
|
// 3 and 6-8), not only a Figyelmeztetés — the alert message needs to say what is wrong, and those
|
||||||
|
// rows do have a triggering counter. It returns nil ONLY for row 2 (the drive self-reports FAILING,
|
||||||
|
// a whole-disk verdict with no single triggering counter) and, naturally, for Nincs adat / Rendben.
|
||||||
func DegradedAttributes(s *SmartSummary) []string {
|
func DegradedAttributes(s *SmartSummary) []string {
|
||||||
if s == nil {
|
if s == nil || s.Health == "" || s.Health == SmartUnknown || s.Health == SmartFailing {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var out []string
|
var out []string
|
||||||
@@ -80,8 +197,21 @@ func DegradedAttributes(s *SmartSummary) []string {
|
|||||||
if atLeast(s.PercentageUsed, percentageUsedWarn) {
|
if atLeast(s.PercentageUsed, percentageUsedWarn) {
|
||||||
out = append(out, "elhasználódás")
|
out = append(out, "elhasználódás")
|
||||||
}
|
}
|
||||||
|
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsNVMe reports whether the summary came from an NVMe drive. The agent fills critical_warning,
|
||||||
|
// percentage_used and media_errors ONLY from smartctl's nvme_smart_health_information_log
|
||||||
|
// (felhom-agent internal/storage/smart.go), so any of them present means NVMe; a SATA or USB drive
|
||||||
|
// carries none of them.
|
||||||
|
func IsNVMe(s *SmartSummary) bool {
|
||||||
|
return s != nil && (s.CriticalWarning != nil || s.PercentageUsed != nil || s.MediaErrors != nil)
|
||||||
|
}
|
||||||
|
|
||||||
func positive(p *int) bool { return p != nil && *p > 0 }
|
func positive(p *int) bool { return p != nil && *p > 0 }
|
||||||
func atLeast(p *int, n int) bool { return p != nil && *p >= n }
|
func atLeast(p *int, n int) bool { return p != nil && *p >= n }
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The v0.215.0 severity ladder, verdict half. The event half (emission, damping, cooldown,
|
||||||
|
// persistence) lives in internal/web — this file pins ONLY what the pure function decides.
|
||||||
|
//
|
||||||
|
// Every value used here is taken from the committed evidence:
|
||||||
|
// felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json
|
||||||
|
// (ST3000VX010-2E3166, S/N Z6A07P2G, /dev/sdg on DooPlex).
|
||||||
|
|
||||||
|
// realDrive is the failing drive AS CAPTURED on 2026-08-14: PASSED, 352 pending, 352 offline
|
||||||
|
// uncorrectable, 0 reallocated, 40 °C. The whole point of the fixture is that Health is PASSED.
|
||||||
|
func realDrive() *SmartSummary {
|
||||||
|
return &SmartSummary{
|
||||||
|
Health: SmartPassed,
|
||||||
|
PendingSectors: ip(352),
|
||||||
|
OfflineUncorrectable: ip(352),
|
||||||
|
ReallocatedSectors: ip(0),
|
||||||
|
TemperatureC: ip(40),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group A (verdict half) — Scenario A. The real drive on its SECOND observation reaches Hiba, and
|
||||||
|
// the chip label is exactly "Hiba".
|
||||||
|
//
|
||||||
|
// Red-proof: delete truth-table row 6 (the `prior.SawUncorrectable` case) from DiskVerdictFor →
|
||||||
|
// the drive still reaches Fail via row 8 (352 >= 64), so this test alone does NOT prove row 6.
|
||||||
|
// TestLadder_SustainIsWhatFires below is the one that isolates it.
|
||||||
|
func TestLadder_RealDrive_ReachesHiba(t *testing.T) {
|
||||||
|
got := DiskVerdictFor(realDrive(), DiskPrior{SawUncorrectable: true})
|
||||||
|
if got != DiskVerdictFail {
|
||||||
|
t.Fatalf("real failing drive verdict = %d (%s), want Fail/Hiba", got, got.Label())
|
||||||
|
}
|
||||||
|
if got.Label() != "Hiba" {
|
||||||
|
t.Errorf("label = %q, want %q", got.Label(), "Hiba")
|
||||||
|
}
|
||||||
|
// The trap this whole change exists for: the drive's own verdict says everything is fine.
|
||||||
|
if realDrive().Health != SmartPassed {
|
||||||
|
t.Fatal("fixture drift: the real drive's Health must be PASSED — that IS the defect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups B + C (verdict half) — Scenarios B and C. The SAME SmartSummary yields Figyelmeztetés on a
|
||||||
|
// first sighting and Hiba once it is sustained. This is the pair that isolates row 6: the counters
|
||||||
|
// are identical and only `prior` differs, so nothing else in the table can be producing the change.
|
||||||
|
//
|
||||||
|
// The values are the 11 August excursion (8 sectors), which cleared completely within an hour — a
|
||||||
|
// count deliberately far below the 64 backstop so row 8 cannot mask row 6.
|
||||||
|
//
|
||||||
|
// Red-proof: remove the `prior.SawUncorrectable` clause from row 6 → the sustained case stays Warn.
|
||||||
|
func TestLadder_SustainIsWhatFires(t *testing.T) {
|
||||||
|
excursion := func() *SmartSummary {
|
||||||
|
return &SmartSummary{Health: SmartPassed, PendingSectors: ip(8), OfflineUncorrectable: ip(8), ReallocatedSectors: ip(0)}
|
||||||
|
}
|
||||||
|
if got := DiskVerdictFor(excursion(), DiskPrior{}); got != DiskVerdictWarn {
|
||||||
|
t.Errorf("first sighting of 8 sectors = %d (%s), want Warn/Figyelmeztetés — a single "+
|
||||||
|
"excursion that clears by itself is normal and must NOT reach Hiba", got, got.Label())
|
||||||
|
}
|
||||||
|
if got := DiskVerdictFor(excursion(), DiskPrior{SawUncorrectable: true}); got != DiskVerdictFail {
|
||||||
|
t.Errorf("SAME 8 sectors, now sustained = %d (%s), want Fail/Hiba", got, got.Label())
|
||||||
|
}
|
||||||
|
if got := DiskVerdictFor(excursion(), DiskPrior{}).Label(); got != "Figyelmeztetés" {
|
||||||
|
t.Errorf("first-sighting label = %q, want Figyelmeztetés", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 8, the backstop — for a box that was powered off or restarted across the sustain window and so
|
||||||
|
// has NO prior. 63 stays Warn, 64 reaches Hiba. The boundary is inclusive, which is what
|
||||||
|
// `uncorrectableFailCount` claims and what the real drive did at 13 Aug 11:28 (exactly 64).
|
||||||
|
//
|
||||||
|
// Red-proof: change `>=` to `>` in row 8 → the "exactly 64" case reads Warn.
|
||||||
|
func TestLadder_CountBackstopBoundary(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
pending int
|
||||||
|
want DiskVerdict
|
||||||
|
}{
|
||||||
|
{63, DiskVerdictWarn},
|
||||||
|
{64, DiskVerdictFail},
|
||||||
|
{352, DiskVerdictFail},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(c.pending)}
|
||||||
|
if got := DiskVerdictFor(s, DiskPrior{}); got != c.want {
|
||||||
|
t.Errorf("%d pending sectors, no prior = %d (%s), want %d", c.pending, got, got.Label(), c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Row 7 — unreadable AND remapping together is Hiba even at a low count with no prior.
|
||||||
|
s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(8), ReallocatedSectors: ip(1)}
|
||||||
|
if got := DiskVerdictFor(s, DiskPrior{}); got != DiskVerdictFail {
|
||||||
|
t.Errorf("row 7 (unreadable + reallocated) = %d, want Fail", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group I — Scenario I, heat. 61 → Hiba, 56 → Figyelmeztetés, 54 → Rendben, with all counters clean.
|
||||||
|
//
|
||||||
|
// Red-proof: remove rows 3 and 13 → all three read Rendben.
|
||||||
|
func TestLadder_Temperature(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
temp int
|
||||||
|
want DiskVerdict
|
||||||
|
}{
|
||||||
|
{54, DiskVerdictOK},
|
||||||
|
{55, DiskVerdictWarn}, // inclusive boundary
|
||||||
|
{56, DiskVerdictWarn},
|
||||||
|
{59, DiskVerdictWarn},
|
||||||
|
{60, DiskVerdictFail}, // inclusive boundary
|
||||||
|
{61, DiskVerdictFail},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
s := &SmartSummary{Health: SmartPassed, TemperatureC: ip(c.temp), PendingSectors: ip(0), ReallocatedSectors: ip(0)}
|
||||||
|
if got := DiskVerdictFor(s, DiskPrior{}); got != c.want {
|
||||||
|
t.Errorf("%d °C = %d (%s), want %d", c.temp, got, got.Label(), c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group J (verdict half) — Scenario J. No data never alarms, and a prior must not manufacture one:
|
||||||
|
// a nil/UNKNOWN SmartSummary reads Nincs adat EVEN WITH SawUncorrectable set. Row 1 is first in the
|
||||||
|
// table for exactly this reason.
|
||||||
|
//
|
||||||
|
// Red-proof: move row 1 below row 6 → the UNKNOWN-with-prior case reads Hiba, i.e. a disk whose
|
||||||
|
// SMART briefly became unreadable would be reported as failing.
|
||||||
|
func TestLadder_UnknownNeverAlarms(t *testing.T) {
|
||||||
|
for _, s := range []*SmartSummary{nil, {Health: ""}, {Health: SmartUnknown}} {
|
||||||
|
if got := DiskVerdictFor(s, DiskPrior{SawUncorrectable: true}); got != DiskVerdictUnknown {
|
||||||
|
t.Errorf("no-data disk with a prior = %d (%s), want Unknown/Nincs adat", got, got.Label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := DiskVerdictFor(&SmartSummary{Health: SmartUnknown}, DiskPrior{}).Label(); got != "Nincs adat" {
|
||||||
|
t.Errorf("label = %q, want Nincs adat", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zero DiskPrior must be the SAFE default: a caller that forgets to load history can only
|
||||||
|
// under-report (Figyelmeztetés), never over-report (Hiba) on a first sighting. This pins the
|
||||||
|
// fail-safe direction the persisted-state loader relies on when its file is missing or corrupt.
|
||||||
|
func TestLadder_ZeroPriorIsFailSafe(t *testing.T) {
|
||||||
|
s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(8)}
|
||||||
|
if got := DiskVerdictFor(s, DiskPrior{}); got != DiskVerdictWarn {
|
||||||
|
t.Fatalf("zero prior must degrade to Warn, not Fail; got %d (%s)", got, got.Label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UncorrectableSectors is max(pending, offline) — the number the alert copy quotes and the persisted
|
||||||
|
// state remembers. A wrong answer here puts a wrong count in a customer's email.
|
||||||
|
func TestUncorrectableSectors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *SmartSummary
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"nil summary", nil, 0},
|
||||||
|
{"neither reported (old agent)", &SmartSummary{Health: SmartPassed}, 0},
|
||||||
|
{"both zero", &SmartSummary{PendingSectors: ip(0), OfflineUncorrectable: ip(0)}, 0},
|
||||||
|
{"pending only", &SmartSummary{PendingSectors: ip(8)}, 8},
|
||||||
|
{"offline only", &SmartSummary{OfflineUncorrectable: ip(24)}, 24},
|
||||||
|
{"pending larger", &SmartSummary{PendingSectors: ip(40), OfflineUncorrectable: ip(24)}, 40},
|
||||||
|
{"offline larger", &SmartSummary{PendingSectors: ip(24), OfflineUncorrectable: ip(40)}, 40},
|
||||||
|
{"the real drive", realDrive(), 352},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := UncorrectableSectors(c.in); got != c.want {
|
||||||
|
t.Errorf("%s: UncorrectableSectors = %d, want %d", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DegradedAttributes must NAME the counters behind a Hiba reached from counters (v0.215.0) — the
|
||||||
|
// alert body is built from this and an empty list produces a message that says nothing is wrong.
|
||||||
|
// It still returns nil for row 2 (drive-reported FAILING), which has no single triggering counter.
|
||||||
|
//
|
||||||
|
// Red-proof: restore the pre-v0.215.0 body (nil for anything at Fail) → the real-drive case returns
|
||||||
|
// an empty list.
|
||||||
|
func TestDegradedAttributes_NamesFailCounters(t *testing.T) {
|
||||||
|
got := DegradedAttributes(realDrive())
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("a Hiba reached from counters must name its attributes, got none")
|
||||||
|
}
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, a := range got {
|
||||||
|
found[a] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"függőben lévő szektorok", "javíthatatlan szektorok"} {
|
||||||
|
if !found[want] {
|
||||||
|
t.Errorf("missing attribute %q in %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Row 2 — the drive self-reports FAILING: no single triggering counter, so nil.
|
||||||
|
if a := DegradedAttributes(&SmartSummary{Health: SmartFailing, PendingSectors: ip(5)}); a != nil {
|
||||||
|
t.Errorf("FAILING (row 2) must return nil attributes, got %v", a)
|
||||||
|
}
|
||||||
|
// Nincs adat must never produce attribute names either.
|
||||||
|
if a := DegradedAttributes(&SmartSummary{Health: SmartUnknown}); a != nil {
|
||||||
|
t.Errorf("UNKNOWN must return nil attributes, got %v", a)
|
||||||
|
}
|
||||||
|
// Temperature is newly able to trigger on its own, so it must be nameable.
|
||||||
|
hot := DegradedAttributes(&SmartSummary{Health: SmartPassed, TemperatureC: ip(61)})
|
||||||
|
if len(hot) != 1 || hot[0] != "hőmérséklet" {
|
||||||
|
t.Errorf("hot disk attributes = %v, want [hőmérséklet]", hot)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,32 +4,40 @@ import "testing"
|
|||||||
|
|
||||||
func ip(v int) *int { return &v }
|
func ip(v int) *int { return &v }
|
||||||
|
|
||||||
// Verdict table (Part 2). Red-proof: change the PercentageUsed boundary from `>= 90` to `> 90` in
|
// Verdict table (Part 2, extended v0.215.0). Red-proof: change the PercentageUsed boundary from
|
||||||
// DiskVerdictFor → the "NVMe percentage_used exactly 90 → Figyelmeztetés" case fails.
|
// `>= 90` to `> 90` in DiskVerdictFor → the "NVMe percentage_used exactly 90 → Figyelmeztetés" case
|
||||||
|
// fails.
|
||||||
|
//
|
||||||
|
// v0.215.0 moved ONE pre-existing case deliberately: critical_warning>0 was Figyelmeztetés and is
|
||||||
|
// now Hiba (truth-table row 4). It is NVMe's own critical flag — a declaration by the device, not a
|
||||||
|
// counter that might drift back — so it belongs with the self-reported failures, not below them.
|
||||||
func TestDiskVerdictFor(t *testing.T) {
|
func TestDiskVerdictFor(t *testing.T) {
|
||||||
|
noPrior := DiskPrior{}
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
in *SmartSummary
|
in *SmartSummary
|
||||||
want DiskVerdict
|
prior DiskPrior
|
||||||
|
want DiskVerdict
|
||||||
}{
|
}{
|
||||||
{"nil → unknown", nil, DiskVerdictUnknown},
|
{"nil → unknown", nil, noPrior, DiskVerdictUnknown},
|
||||||
{"empty health → unknown", &SmartSummary{Health: ""}, DiskVerdictUnknown},
|
{"empty health → unknown", &SmartSummary{Health: ""}, noPrior, DiskVerdictUnknown},
|
||||||
{"UNKNOWN → unknown", &SmartSummary{Health: SmartUnknown}, DiskVerdictUnknown},
|
{"UNKNOWN → unknown", &SmartSummary{Health: SmartUnknown}, noPrior, DiskVerdictUnknown},
|
||||||
{"FAILING → fail", &SmartSummary{Health: SmartFailing}, DiskVerdictFail},
|
{"FAILING → fail", &SmartSummary{Health: SmartFailing}, noPrior, DiskVerdictFail},
|
||||||
{"FAILING beats counters", &SmartSummary{Health: SmartFailing, ReallocatedSectors: ip(0)}, DiskVerdictFail},
|
{"FAILING beats counters", &SmartSummary{Health: SmartFailing, ReallocatedSectors: ip(0)}, noPrior, DiskVerdictFail},
|
||||||
{"PASSED clean → ok", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(0), PendingSectors: ip(0), TemperatureC: ip(30)}, DiskVerdictOK},
|
{"PASSED clean → ok", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(0), PendingSectors: ip(0), TemperatureC: ip(30)}, noPrior, DiskVerdictOK},
|
||||||
{"PASSED nil counters → ok", &SmartSummary{Health: SmartPassed}, DiskVerdictOK},
|
{"PASSED nil counters → ok", &SmartSummary{Health: SmartPassed}, noPrior, DiskVerdictOK},
|
||||||
{"reallocated>0 → warn", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(1)}, DiskVerdictWarn},
|
{"reallocated>0 alone → warn", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(1)}, noPrior, DiskVerdictWarn},
|
||||||
{"pending>0 → warn", &SmartSummary{Health: SmartPassed, PendingSectors: ip(5)}, DiskVerdictWarn},
|
{"pending>0 first sighting → warn", &SmartSummary{Health: SmartPassed, PendingSectors: ip(5)}, noPrior, DiskVerdictWarn},
|
||||||
{"offline_unc>0 → warn", &SmartSummary{Health: SmartPassed, OfflineUncorrectable: ip(2)}, DiskVerdictWarn},
|
{"offline_unc>0 first sighting → warn", &SmartSummary{Health: SmartPassed, OfflineUncorrectable: ip(2)}, noPrior, DiskVerdictWarn},
|
||||||
{"critical_warning>0 → warn", &SmartSummary{Health: SmartPassed, CriticalWarning: ip(1)}, DiskVerdictWarn},
|
{"critical_warning>0 → fail (row 4)", &SmartSummary{Health: SmartPassed, CriticalWarning: ip(1)}, noPrior, DiskVerdictFail},
|
||||||
{"media_errors>0 → warn", &SmartSummary{Health: SmartPassed, MediaErrors: ip(3)}, DiskVerdictWarn},
|
{"media_errors>0 → warn", &SmartSummary{Health: SmartPassed, MediaErrors: ip(3)}, noPrior, DiskVerdictWarn},
|
||||||
{"percentage_used 89 → ok", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(89)}, DiskVerdictOK},
|
{"percentage_used 89 → ok", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(89)}, noPrior, DiskVerdictOK},
|
||||||
{"percentage_used exactly 90 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(90)}, DiskVerdictWarn},
|
{"percentage_used exactly 90 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(90)}, noPrior, DiskVerdictWarn},
|
||||||
{"percentage_used 95 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(95)}, DiskVerdictWarn},
|
{"percentage_used 95 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(95)}, noPrior, DiskVerdictWarn},
|
||||||
|
{"percentage_used exactly 100 → fail (row 5)", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(100)}, noPrior, DiskVerdictFail},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := DiskVerdictFor(c.in); got != c.want {
|
if got := DiskVerdictFor(c.in, c.prior); got != c.want {
|
||||||
t.Errorf("%s: DiskVerdictFor = %d, want %d", c.name, got, c.want)
|
t.Errorf("%s: DiskVerdictFor = %d, want %d", c.name, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package agentapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -91,31 +92,63 @@ func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatus
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EscrowClaim is a claimed recovery code. ConfirmRequired (agent ≥ 0.160.0, R-1151): the code's package reaches the
|
||||||
|
// hub only when EscrowCeremonyConfirm brings back the words at Positions (1-based); an older agent answers without
|
||||||
|
// it — its package is already on the hub, and no confirm is needed.
|
||||||
|
type EscrowClaim struct {
|
||||||
|
Code string
|
||||||
|
ConfirmRequired bool
|
||||||
|
Positions []int
|
||||||
|
}
|
||||||
|
|
||||||
// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's
|
// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's
|
||||||
// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never
|
// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never
|
||||||
// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries
|
// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries
|
||||||
// the agent's reason text, never the code.
|
// the agent's reason text, never the code.
|
||||||
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) {
|
//
|
||||||
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", struct{}{})
|
// R-1151: the claim asks the agent to keep the new package until the household typed the code back
|
||||||
|
// (`defer_upload`). An agent older than 0.160.0 ignores the body and uploads as before.
|
||||||
|
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, error) {
|
||||||
|
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", status, err
|
return EscrowClaim{}, status, err
|
||||||
}
|
}
|
||||||
if status == http.StatusGone {
|
if status == http.StatusGone {
|
||||||
return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
|
return EscrowClaim{}, status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
|
||||||
}
|
}
|
||||||
if err := refusalError("/escrow/ceremony/claim", status, env); err != nil {
|
if err := refusalError("/escrow/ceremony/claim", status, env); err != nil {
|
||||||
return "", status, err
|
return EscrowClaim{}, status, err
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
RecoveryCode string `json:"recovery_code"`
|
RecoveryCode string `json:"recovery_code"`
|
||||||
|
ConfirmRequired bool `json:"confirm_required"`
|
||||||
|
VerifyPositions []int `json:"verify_positions"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||||
return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
|
return EscrowClaim{}, status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
|
||||||
}
|
}
|
||||||
if out.RecoveryCode == "" {
|
if out.RecoveryCode == "" {
|
||||||
return "", status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
|
return EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
|
||||||
}
|
}
|
||||||
return out.RecoveryCode, status, nil
|
if out.ConfirmRequired && len(out.VerifyPositions) != 2 {
|
||||||
|
return EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim asks for a confirmation without two positions")
|
||||||
|
}
|
||||||
|
return EscrowClaim{Code: out.RecoveryCode, ConfirmRequired: out.ConfirmRequired, Positions: out.VerifyPositions}, status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscrowCeremonyConfirm sends the two words the household typed back (R-1151, agent ≥ 0.160.0); on success the agent
|
||||||
|
// has stored the new package on the hub. The status is returned for the caller to classify: 422 the words do not
|
||||||
|
// match (still waiting), 410 the window ran out, 404 nothing waits (superseded, restarted), 502 the hub failed (still
|
||||||
|
// waiting). The words are part of the recovery code — never logged.
|
||||||
|
func (c *Client) EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error) {
|
||||||
|
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words})
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
if err := refusalError("/escrow/ceremony/confirm", status, env); err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the
|
// RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the
|
||||||
@@ -132,8 +165,11 @@ func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode st
|
|||||||
if perr != nil {
|
if perr != nil {
|
||||||
return "", "", perr
|
return "", "", perr
|
||||||
}
|
}
|
||||||
if rerr := refusalError("/escrow/recover-offsite-password", status, env); rerr != nil {
|
// R-224: this route's refusal keeps its STATUS as a value. `refusalError` flattens status into a
|
||||||
return "", "", rerr
|
// sentence, and a sentence is not something a caller can branch on — which is exactly how a failed
|
||||||
|
// fetch and a wrong recovery code came to produce one customer-facing message.
|
||||||
|
if status < 200 || status > 299 || !env.OK {
|
||||||
|
return "", "", &RecoveryRefusal{Status: status, Reason: truncateErr(env.Error, 300)}
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
ResticRepoPassword string `json:"restic_repo_password"`
|
ResticRepoPassword string `json:"restic_repo_password"`
|
||||||
@@ -147,3 +183,144 @@ func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode st
|
|||||||
}
|
}
|
||||||
return out.ResticRepoPassword, out.ResticPwSHA256, nil
|
return out.ResticRepoPassword, out.ResticPwSHA256, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── R-224 — CLASSIFYING A FAILED UNLOCK ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// CAMPAIGN-11 measured what happens without this. On 2026-08-05, with a CORRECT current recovery
|
||||||
|
// code: the hub firewalled off returned the customer "this code does not open your package" in
|
||||||
|
// 0.0556 s, and this agent stopped returned the same in 0.0299 s — against ~1.0 s for a genuine
|
||||||
|
// unseal. Neither attempted one. The failure path had exactly two branches, both of them statements
|
||||||
|
// about the customer's code, and `rerr` was never inspected.
|
||||||
|
//
|
||||||
|
// The rule this type exists to enforce: **the customer is blamed only after a real attempt refused
|
||||||
|
// their code.** Everything else — including anything we cannot classify — says something else.
|
||||||
|
|
||||||
|
// RecoveryRefusal is the agent's refusal of an unlock, carrying the STATUS as a value so callers
|
||||||
|
// classify on it rather than on the sentence. The message keeps `refusalError`'s shape so operator
|
||||||
|
// logs read as they did.
|
||||||
|
type RecoveryRefusal struct {
|
||||||
|
Status int
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RecoveryRefusal) Error() string {
|
||||||
|
reason := e.Reason
|
||||||
|
if reason == "" {
|
||||||
|
reason = "(no reason in agent response)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("agentapi: POST /escrow/recover-offsite-password: HTTP %d: %s", e.Status, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryFailure is what went wrong, as far as it can be known.
|
||||||
|
type RecoveryFailure int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RecoveryUnknown — the cause could not be determined. **The safe default**, and deliberately the
|
||||||
|
// zero value: a new status, a transport shape nobody anticipated, or an agent too old to
|
||||||
|
// distinguish fetch from refusal all land here, and none of them may blame the customer.
|
||||||
|
RecoveryUnknown RecoveryFailure = iota
|
||||||
|
// RecoveryHubUnreachable — the agent answered, and it could not FETCH the sealed package: the hub
|
||||||
|
// refused, was unreachable, or recovery is not configured on this agent. **The code was not used.**
|
||||||
|
RecoveryHubUnreachable
|
||||||
|
// RecoveryAskedAndRefused — the bundle was fetched and the code did not open it. The ONLY class
|
||||||
|
// from which the customer may be told to check their typing.
|
||||||
|
RecoveryAskedAndRefused
|
||||||
|
// RecoveryNoBundle — the hub holds no sealed package for this host at all.
|
||||||
|
RecoveryNoBundle
|
||||||
|
// RecoveryBundleTooOld — the bundle opened but predates the repository-password field.
|
||||||
|
RecoveryBundleTooOld
|
||||||
|
// RecoveryAgentUnreachable — the machine's own in-house service never answered, so there is no
|
||||||
|
// agent verdict at all. **The code was not used.** Distinct from RecoveryHubUnreachable because
|
||||||
|
// it is a different fault, with different words and a different remedy.
|
||||||
|
RecoveryAgentUnreachable
|
||||||
|
// RecoveryCodeOpensRetained — the code was used, it WORKED, and it opened a RETAINED earlier
|
||||||
|
// package rather than the one currently held (R-311, agent >= v0.129.0).
|
||||||
|
//
|
||||||
|
// **The customer is not at fault here and must not be told they might be.** This class exists
|
||||||
|
// because until 2026-08-12 this situation and a mistype were indistinguishable: both fail closed
|
||||||
|
// against the current package, and nothing ever tried the retained ones. The screen said as much
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// ⚠ `trustRefusal` is the agent-version gate and it is not optional. An agent older than v0.126.0
|
||||||
|
// answers **400 for BOTH** a fetch failure and a wrong code, so a 400 from one cannot be read as
|
||||||
|
// "the code was refused" — it means "one of two things, and we cannot tell which". Pass false there
|
||||||
|
// and the 400 degrades to RecoveryUnknown, which is neutral. That degradation is the point: it is
|
||||||
|
// safe, it is silent, and it heals itself when the agent updates.
|
||||||
|
// ⚠ `trustRetained` is the R-311 twin of `trustRefusal` and is separate on purpose: the two gates
|
||||||
|
// name different agent versions (v0.126.0 and v0.129.0) and a box can sit between them. Passing
|
||||||
|
// `trustRefusal` for both would let a v0.126–128 agent's unexpected 422 be read as a verdict it
|
||||||
|
// cannot produce.
|
||||||
|
func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained bool) RecoveryFailure {
|
||||||
|
if err == nil {
|
||||||
|
return RecoveryUnknown
|
||||||
|
}
|
||||||
|
var ref *RecoveryRefusal
|
||||||
|
if !errors.As(err, &ref) {
|
||||||
|
// Not a refusal at all — the request never produced an agent verdict (dial failure, TLS,
|
||||||
|
// timeout, or the channel could not be built). The machine could not even ASK its own service,
|
||||||
|
// which is a different sentence from "the hub was unreachable" and a different thing to fix.
|
||||||
|
return RecoveryAgentUnreachable
|
||||||
|
}
|
||||||
|
switch ref.Status {
|
||||||
|
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||||
|
// 502 is agent >= v0.126.0's "the sealed bundle could not be fetched". 503 is its
|
||||||
|
// "recovery is not configured on this agent (no hub client)". Neither used the code.
|
||||||
|
return RecoveryHubUnreachable
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return RecoveryNoBundle
|
||||||
|
case http.StatusConflict:
|
||||||
|
return RecoveryBundleTooOld
|
||||||
|
case http.StatusUnprocessableEntity:
|
||||||
|
// R-311. Gated on the SAME trust flag as 400, and for the mirror-image reason: an agent that
|
||||||
|
// predates the retained lookup cannot emit 422 at all, so a 422 from anywhere else is a shape
|
||||||
|
// we did not design and must not be read as a statement about the customer's code.
|
||||||
|
if trustRetained {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return RecoveryUnknown
|
||||||
|
default:
|
||||||
|
return RecoveryUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String names the class for the operator log. The customer never sees these words.
|
||||||
|
func (f RecoveryFailure) String() string {
|
||||||
|
switch f {
|
||||||
|
case RecoveryHubUnreachable:
|
||||||
|
return "hub-unreachable"
|
||||||
|
case RecoveryAgentUnreachable:
|
||||||
|
return "agent-unreachable"
|
||||||
|
case RecoveryAskedAndRefused:
|
||||||
|
return "asked-and-refused"
|
||||||
|
case RecoveryNoBundle:
|
||||||
|
return "no-bundle"
|
||||||
|
case RecoveryBundleTooOld:
|
||||||
|
return "bundle-too-old"
|
||||||
|
case RecoveryCodeOpensRetained:
|
||||||
|
return "code-opens-retained"
|
||||||
|
case RecoveryOlderUnchecked:
|
||||||
|
return "older-unchecked"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1151 — the claim asks the agent to keep the package until the words come back, and reads the agent's answer;
|
||||||
|
// the confirm carries the two words. An agent older than 0.160.0 answers without confirm_required.
|
||||||
|
func TestEscrowClaimAndConfirm_R1151(t *testing.T) {
|
||||||
|
var claimBody map[string]any
|
||||||
|
var confirmBody map[string][]string
|
||||||
|
newAgent := true
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /escrow/ceremony/claim", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claimBody = nil
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&claimBody)
|
||||||
|
if newAgent {
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c","confirm_required":true,"verify_positions":[1,3]}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c"}}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /escrow/ceremony/confirm", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&confirmBody)
|
||||||
|
if confirmBody["words"][0] != "a" {
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":false,"error":"the words do not match the recovery code"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"uploaded":true}}`))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||||
|
|
||||||
|
claim, status, err := c.EscrowCeremonyClaim(context.Background())
|
||||||
|
if err != nil || status != http.StatusOK || claim.Code != "a-b-c" || !claim.ConfirmRequired || len(claim.Positions) != 2 || claim.Positions[1] != 3 {
|
||||||
|
t.Fatalf("claim: %+v %d %v", claim, status, err)
|
||||||
|
}
|
||||||
|
if claimBody["defer_upload"] != true {
|
||||||
|
t.Fatalf("the claim did not ask the agent to wait for the words: body %v", claimBody)
|
||||||
|
}
|
||||||
|
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"a", "c"}); err != nil || st != http.StatusOK {
|
||||||
|
t.Fatalf("confirm: %d %v", st, err)
|
||||||
|
}
|
||||||
|
if strings.Join(confirmBody["words"], ",") != "a,c" {
|
||||||
|
t.Fatalf("confirm sent %v", confirmBody)
|
||||||
|
}
|
||||||
|
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"x", "c"}); err == nil || st != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("a wrong word must come back as a 422 error: %d %v", st, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newAgent = false // agent 0.159.2: no confirm request
|
||||||
|
claim, _, err = c.EscrowCeremonyClaim(context.Background())
|
||||||
|
if err != nil || claim.ConfirmRequired || claim.Code != "a-b-c" {
|
||||||
|
t.Fatalf("claim on an old agent: %+v %v", claim, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,64 @@ const FeatureGuestMemoryResize Feature = "guest_memory_resize"
|
|||||||
// an empty field means legacy.
|
// an empty field means legacy.
|
||||||
const FeatureBackupAgeState Feature = "backup_age_state"
|
const FeatureBackupAgeState Feature = "backup_age_state"
|
||||||
|
|
||||||
|
// FeatureOffsiteKeyRecovery is the customer-facing off-site key recovery (agent v0.125.0, R-199
|
||||||
|
// links 7–8): POST /escrow/recover-offsite-password fetches this host's sealed bundle, unseals it
|
||||||
|
// with R and returns the single repository-password field.
|
||||||
|
//
|
||||||
|
// ⚠ THIS GATE FAILS CLOSED, and it is the ONLY feature in this table that does. Read §7.1 of the
|
||||||
|
// R-216 fix before "correcting" it back to the package default.
|
||||||
|
//
|
||||||
|
// The package default is fail-OPEN: SupportUnknown proceeds, because for every other coupled feature
|
||||||
|
// a wrong "unsupported" would block something harmless while a down agent already speaks through the
|
||||||
|
// normal error paths. **That default is what produced R-216.** Measured live on 2026-08-05
|
||||||
|
// (CAMPAIGN-11 Phase 1): an agent 0.120.0 answered the recovery route with 404, the unlock attempt
|
||||||
|
// went ahead anyway, and the customer was told — in Hungarian, on the one screen whose whole purpose
|
||||||
|
// is to be believed about backups — that their perfectly correct recovery code was not accepted and
|
||||||
|
// they should check their typing. A correct code, refused in 0.134 s, blamed on the customer.
|
||||||
|
//
|
||||||
|
// So here: anything other than SupportYes means the screen says THE MACHINE cannot ask yet. The
|
||||||
|
// unlock is never attempted when it cannot complete, because the failure of an attempt that could
|
||||||
|
// never have worked is attributed to the code.
|
||||||
|
const FeatureOffsiteKeyRecovery Feature = "offsite_key_recovery"
|
||||||
|
|
||||||
|
// FeatureRecoveryFailureClass is agent v0.126.0's SPLIT of a failed unlock into distinguishable
|
||||||
|
// statuses (R-224): 502 the sealed bundle could not be FETCHED · 400 it was fetched and the code was
|
||||||
|
// refused · 404 no bundle · 409 the bundle predates the repository-password field.
|
||||||
|
//
|
||||||
|
// ⚠ WHAT THIS GATE ACTUALLY GUARDS is the meaning of **400**, and nothing else. An agent older than
|
||||||
|
// v0.126.0 answers 400 for BOTH a fetch failure and a wrong code — one status, one sentence, two
|
||||||
|
// situations — so on such an agent a 400 cannot be read as "the code was refused". It means "one of
|
||||||
|
// two things and we cannot tell which", which is `RecoveryUnknown`, which is neutral.
|
||||||
|
//
|
||||||
|
// So this gate does not block anything and has no fail-closed behaviour to get wrong: the unlock is
|
||||||
|
// attempted either way (FeatureOffsiteKeyRecovery already decides THAT). It only decides whether the
|
||||||
|
// customer may be told to check their typing. Unknown → they may not. **That is the safe direction,
|
||||||
|
// and it heals itself the moment the agent updates.**
|
||||||
|
const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
|
||||||
|
|
||||||
|
// FeatureRetainedRecoveryClass is agent v0.129.0's FIFTH status on a failed unlock (R-311): 422, the
|
||||||
|
// code is correct and opens a RETAINED earlier package rather than the current one.
|
||||||
|
//
|
||||||
|
// ⚠ WHAT THIS GATE GUARDS is whether the screen may say WHICH of the two causes it is. Before
|
||||||
|
// v0.129.0 nothing ever tried the retained packages, so a correct-but-earlier code and a mistype were
|
||||||
|
// genuinely indistinguishable and the screen said so. That sentence was HONEST then and becomes a
|
||||||
|
// falsehood the moment the agent can tell them apart — so the gate decides which of two true
|
||||||
|
// sentences to print, never whether to attempt the unlock.
|
||||||
|
//
|
||||||
|
// Unknown → the older, hedged sentence. That is the safe direction: it claims less, it was correct
|
||||||
|
// 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
|
// 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).
|
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
||||||
type SupportState int
|
type SupportState int
|
||||||
@@ -92,12 +150,43 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
|
|||||||
_, err := gm.GuestMemory(ctx)
|
_, err := gm.GuestMemory(ctx)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
// The recovery route is a POST that performs work and consumes a recovery code — it cannot be
|
||||||
|
// probed. Like the memory prober's negative case this returns a sentinel that classifies to
|
||||||
|
// SupportUnknown, so the decision falls to the VERSION path above.
|
||||||
|
//
|
||||||
|
// The row must exist even though it cannot probe: SupportsWithSource looks up featureProbes
|
||||||
|
// FIRST and returns "unregistered"/SupportUnknown on a table gap, before the version path runs.
|
||||||
|
// A featureMinAgent row without a featureProbes row is therefore never consulted at all.
|
||||||
|
FeatureOffsiteKeyRecovery: func(ctx context.Context, p SupportProber) error {
|
||||||
|
return errNoRecoveryProbe
|
||||||
|
},
|
||||||
|
// Same POST route, same reason it cannot be probed — the decision falls to the VERSION path.
|
||||||
|
FeatureRecoveryFailureClass: func(ctx context.Context, p SupportProber) error {
|
||||||
|
return errNoRecoveryProbe
|
||||||
|
},
|
||||||
|
// R-311, same route and same reason. The row must exist or SupportsWithSource returns
|
||||||
|
// "unregistered"/SupportUnknown on the table gap and the version row is never consulted.
|
||||||
|
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
|
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
|
||||||
// asked never reads as "unsupported".
|
// asked never reads as "unsupported".
|
||||||
var errNoMemoryProbe = errors.New("agentapi: prober does not support the guest-memory probe")
|
var errNoMemoryProbe = errors.New("agentapi: prober does not support the guest-memory probe")
|
||||||
|
|
||||||
|
// errNoRecoveryProbe classifies to SupportUnknown: the off-site key recovery route is a POST that
|
||||||
|
// consumes a recovery code and so cannot be probed, leaving the VERSION path to decide. Its caller
|
||||||
|
// fails CLOSED on Unknown — see FeatureOffsiteKeyRecovery.
|
||||||
|
var errNoRecoveryProbe = errors.New("agentapi: the offsite key recovery route cannot be probed")
|
||||||
|
|
||||||
// featureMinAgent maps each coupled feature to the MINIMUM agent version that carries its coupled
|
// featureMinAgent maps each coupled feature to the MINIMUM agent version that carries its coupled
|
||||||
// semantics (the CHANGELOG `MinAgent:` header value). Used by Supports when the agent's version is
|
// semantics (the CHANGELOG `MinAgent:` header value). Used by Supports when the agent's version is
|
||||||
// KNOWN (the v0.82.0 X-Felhom-Agent-Version channel) — a direct comparison, no probe traffic. A
|
// KNOWN (the v0.82.0 X-Felhom-Agent-Version channel) — a direct comparison, no probe traffic. A
|
||||||
@@ -107,8 +196,31 @@ var featureMinAgent = map[Feature]string{
|
|||||||
FeatureGuestMemoryResize: "0.90.0",
|
FeatureGuestMemoryResize: "0.90.0",
|
||||||
// R-88 Part 2: /backup/due carries age_state, distinguishing "never backed up" from "cannot tell".
|
// R-88 Part 2: /backup/due carries age_state, distinguishing "never backed up" from "cannot tell".
|
||||||
FeatureBackupAgeState: "0.105.0",
|
FeatureBackupAgeState: "0.105.0",
|
||||||
|
// R-199 links 7–8: POST /escrow/recover-offsite-password. R-216 — this row is the whole reason a
|
||||||
|
// correct recovery code can no longer be reported as wrong on an agent that cannot answer.
|
||||||
|
FeatureOffsiteKeyRecovery: "0.125.0",
|
||||||
|
|
||||||
|
// R-224 — the four-way status split of a failed unlock.
|
||||||
|
FeatureRecoveryFailureClass: "0.126.0",
|
||||||
|
|
||||||
|
// R-311 — the FIFTH status: 422, "your code is correct, it opens an EARLIER package". Before
|
||||||
|
// v0.129.0 the agent never looked at retained packages, so this situation was indistinguishable
|
||||||
|
// from a mistype and arrived as 400. An older agent therefore cannot produce a 422 at all, and the
|
||||||
|
// 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
|
||||||
|
// row). Read-only accessor over featureMinAgent so a refusal can NAME the version it needs instead of
|
||||||
|
// hard-coding the number a second time at the call site.
|
||||||
|
func MinAgentFor(f Feature) string { return featureMinAgent[f] }
|
||||||
|
|
||||||
// AgentVersionReporter is optionally implemented by a SupportProber (*Client is one): it reports
|
// AgentVersionReporter is optionally implemented by a SupportProber (*Client is one): it reports
|
||||||
// the last strictly-validated agent version seen on its traffic ("" = unknown → probe fallback).
|
// the last strictly-validated agent version seen on its traffic ("" = unknown → probe fallback).
|
||||||
type AgentVersionReporter interface {
|
type AgentVersionReporter interface {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── POST /host/poweroff (R-998, agent >= 0.155.0, felhom.eu documentation/architecture/14-host-power-off.md) ──
|
||||||
|
//
|
||||||
|
// The household's „A gép kikapcsolása". The agent answers 202 (it shuts this guest down, then the host) or 409
|
||||||
|
// with a machine-readable reason. Unlike post(), the 409 BODY matters here: the dashboard tells the household
|
||||||
|
// WHY (a backup runs, too soon after start, already asked…), so this call reads it.
|
||||||
|
|
||||||
|
// Agent refusal reasons (stable wire values; mirror felhom-agent internal/localapi/hostpoweroff.go).
|
||||||
|
const (
|
||||||
|
PowerOffTooSoon = "too_soon_after_boot"
|
||||||
|
PowerOffAlreadyAsked = "already_requested_this_boot"
|
||||||
|
PowerOffBackup = "backup_running"
|
||||||
|
PowerOffRestoreTest = "restore_test_running"
|
||||||
|
PowerOffOSUpdate = "os_update_running"
|
||||||
|
PowerOffCtrlUpdate = "controller_update_running"
|
||||||
|
PowerOffDrive = "drive_operation_running"
|
||||||
|
PowerOffEscrow = "escrow_ceremony_running"
|
||||||
|
PowerOffBusy = "host_busy"
|
||||||
|
PowerOffOtherGuests = "other_guests_running"
|
||||||
|
PowerOffUnknown = "state_unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PowerOffRefused is the agent's 409: Reason is one of the constants above (or a newer one), Detail is for the log.
|
||||||
|
type PowerOffRefused struct {
|
||||||
|
Reason string
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PowerOffRefused) Error() string {
|
||||||
|
return "agent refused the power-off: " + e.Reason + " (" + e.Detail + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostPowerOff asks the agent to shut this guest down and power the host off. nil = accepted (the box goes down
|
||||||
|
// within minutes). A *PowerOffRefused = the agent said no, with its reason. A *StatusError 404 = an agent that
|
||||||
|
// predates the route.
|
||||||
|
func (c *Client) HostPowerOff(ctx context.Context) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/host/poweroff", bytes.NewReader([]byte("{}")))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("agentapi: POST /host/poweroff: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
c.noteAgentVersion(resp)
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
logx.Debugf(c.logger, "[agentapi] POST /host/poweroff -> %d (%dms)", resp.StatusCode, time.Since(start).Milliseconds())
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusAccepted, http.StatusOK:
|
||||||
|
return nil
|
||||||
|
case http.StatusConflict:
|
||||||
|
var env struct {
|
||||||
|
Data struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &env) != nil || env.Data.Reason == "" {
|
||||||
|
return &PowerOffRefused{Reason: PowerOffUnknown, Detail: "unreadable 409 body"}
|
||||||
|
}
|
||||||
|
return &PowerOffRefused{Reason: env.Data.Reason, Detail: env.Data.Detail}
|
||||||
|
default:
|
||||||
|
return &StatusError{Method: http.MethodPost, Path: "/host/poweroff", Code: resp.StatusCode}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPowerOffRefused returns the refusal, if err is one.
|
||||||
|
func IsPowerOffRefused(err error) (*PowerOffRefused, bool) {
|
||||||
|
var r *PowerOffRefused
|
||||||
|
ok := errors.As(err, &r)
|
||||||
|
return r, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LineTestState mirrors the agent's GET /host/line-test (agent ≥ 0.164.0; felhom.eu R-1247): the host's weekly upload
|
||||||
|
// line test — a 10-second iperf3 upload to ep0 inside the offsite tunnel, best of three runs, as ep0's server counted
|
||||||
|
// it. Present=false: never measured (no tunnel, not yet due, or disabled). An agent before the route answers 404 (a
|
||||||
|
// *StatusError); all three mean UNKNOWN, and the controller then sets no qBittorrent limit.
|
||||||
|
type LineTestState struct {
|
||||||
|
Present bool `json:"present"`
|
||||||
|
Mbps float64 `json:"mbps,omitempty"`
|
||||||
|
MeasuredAt string `json:"measured_at,omitempty"` // RFC3339 UTC
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
// WaitingForQuiet (agent ≥ 0.165.0, R-1250): a test is due and could start now, but the host's uplink has not been
|
||||||
|
// quiet for 10 minutes. With a seeding torrent app the controller then holds the app's limit and calls LineTestRun.
|
||||||
|
WaitingForQuiet bool `json:"waiting_for_quiet,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LineTestRunResult mirrors POST /host/line-test/run. OK=false with Refused: nothing ran (not-due, outside-window,
|
||||||
|
// tries-used, no-tunnel, heavy-op, not-quiet, server-busy); OK=false without it: the runs failed.
|
||||||
|
type LineTestRunResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Mbps float64 `json:"mbps,omitempty"`
|
||||||
|
RunsMbps []float64 `json:"runs_mbps,omitempty"`
|
||||||
|
Refused string `json:"refused,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LineTestRunTimeout bounds the synchronous request: about 55 s on the agent (5 s settle, 10 s quiet check, three
|
||||||
|
// 10-second runs 5 s apart), each run itself bounded at 25 s.
|
||||||
|
const LineTestRunTimeout = 2 * time.Minute
|
||||||
|
|
||||||
|
// LineTestRun calls POST /host/line-test/run (R-1250): a test on request while the caller holds its torrent app's
|
||||||
|
// upload down. An agent before the route answers 404 (*StatusError).
|
||||||
|
func (c *Client) LineTestRun(ctx context.Context) (LineTestRunResult, error) {
|
||||||
|
var out LineTestRunResult
|
||||||
|
body, err := c.postSlow(ctx, "/host/line-test/run", struct{}{}, LineTestRunTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("agentapi: decode /host/line-test/run: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LineTest calls GET /host/line-test.
|
||||||
|
func (c *Client) LineTest(ctx context.Context) (LineTestState, error) {
|
||||||
|
var out LineTestState
|
||||||
|
body, err := c.get(ctx, "/host/line-test")
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("agentapi: decode /host/line-test: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeasuredAtTime parses MeasuredAt (zero time when absent or garbled).
|
||||||
|
func (s LineTestState) MeasuredAtTime() time.Time {
|
||||||
|
t, _ := time.Parse(time.RFC3339, s.MeasuredAt)
|
||||||
|
return t
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1247: GET /host/line-test decodes the agent's weekly line test, and an agent before the route is a typed 404 (the
|
||||||
|
// controller reads it as „not measured" → no qBittorrent limit).
|
||||||
|
func TestLineTestDecodesAndAnOlderAgentIs404(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /host/line-test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":true,"mbps":90.7,"measured_at":"2026-10-24T06:31:00Z","method":"iperf3-tunnel"}}`))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
defer s.Close()
|
||||||
|
st, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).LineTest(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !st.Present || st.Mbps != 90.7 || !st.MeasuredAtTime().Equal(time.Date(2026, 10, 24, 6, 31, 0, 0, time.UTC)) {
|
||||||
|
t.Fatalf("state = %+v", st)
|
||||||
|
}
|
||||||
|
old := httptest.NewTLSServer(http.NewServeMux())
|
||||||
|
defer old.Close()
|
||||||
|
_, err = clientFor(t, old, strings.TrimPrefix(old.URL, "https://")).LineTest(context.Background())
|
||||||
|
var se *StatusError
|
||||||
|
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("an older agent must answer a typed 404, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-1250: the request decodes its outcome, waiting_for_quiet decodes, and the request outlasts the client's own
|
||||||
|
// timeout (the agent answers after about 55 s; the client's default is 15 s).
|
||||||
|
func TestLineTestRunOutlastsTheClientTimeout(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /host/line-test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":false,"waiting_for_quiet":true}}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /host/line-test/run", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"ok":true,"mbps":93.5,"runs_mbps":[92,93.5,93]}}`))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||||
|
st, err := c.LineTest(context.Background())
|
||||||
|
if err != nil || st.Present || !st.WaitingForQuiet {
|
||||||
|
t.Fatalf("GET = %+v %v, want present:false waiting_for_quiet:true", st, err)
|
||||||
|
}
|
||||||
|
c.hc.Timeout = 100 * time.Millisecond // stands in for the 15 s default against a 55 s answer
|
||||||
|
if _, err := c.post(context.Background(), "/host/line-test/run", struct{}{}); err == nil {
|
||||||
|
t.Fatal("the plain POST should time out here — the seam does not test anything")
|
||||||
|
}
|
||||||
|
res, err := c.LineTestRun(context.Background())
|
||||||
|
if err != nil || !res.OK || res.Mbps != 93.5 || len(res.RunsMbps) != 3 {
|
||||||
|
t.Fatalf("LineTestRun = %+v %v", res, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-330 (disk health Phase 2, the wire only). The agent (v0.150.0+) sends three more SATA raw
|
||||||
|
// counters; the controller must DECODE them (a field the struct lacks is silently dropped by
|
||||||
|
// encoding/json) and must not yet ACT on them.
|
||||||
|
|
||||||
|
// The 2026-08-14 failing drive as the agent now reports it (values from
|
||||||
|
// felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json).
|
||||||
|
const r330AgentJSON = `{"health":"PASSED","temperature_c":40,"power_on_hours":null,
|
||||||
|
"reallocated_sectors":0,"pending_sectors":352,"offline_uncorrectable":352,
|
||||||
|
"reported_uncorrect":1001,"command_timeout":0,"udma_crc_errors":0,
|
||||||
|
"critical_warning":null,"media_errors":null,"percentage_used":null}`
|
||||||
|
|
||||||
|
// COMPANION RED-PROOF (observed): rename the controller tag `reported_uncorrect` → `reported_uncorrectable`
|
||||||
|
// → this fails with "187 must decode from the agent's JSON (raw 1001); got <nil>". Restored.
|
||||||
|
func TestR330_DecodesTheThreeCounters(t *testing.T) {
|
||||||
|
var s SmartSummary
|
||||||
|
if err := json.Unmarshal([]byte(r330AgentJSON), &s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.ReportedUncorrect == nil || *s.ReportedUncorrect != 1001 {
|
||||||
|
t.Fatalf("187 must decode from the agent's JSON (raw 1001); got %v", s.ReportedUncorrect)
|
||||||
|
}
|
||||||
|
// A measured ZERO stays a zero (not nil): 188 and 199 were reported as 0 by this drive.
|
||||||
|
if s.CommandTimeout == nil || *s.CommandTimeout != 0 || s.UDMACRCErrors == nil || *s.UDMACRCErrors != 0 {
|
||||||
|
t.Fatalf("188/199 reported as 0 must decode as a measured 0; got %v %v", s.CommandTimeout, s.UDMACRCErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An older agent omits the keys → nil (unknown), never 0.
|
||||||
|
var old SmartSummary
|
||||||
|
if err := json.Unmarshal([]byte(`{"health":"PASSED","pending_sectors":0}`), &old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if old.ReportedUncorrect != nil || old.CommandTimeout != nil || old.UDMACRCErrors != nil {
|
||||||
|
t.Fatalf("an older agent's report must leave the counters nil (unknown); got %v %v %v",
|
||||||
|
old.ReportedUncorrect, old.CommandTimeout, old.UDMACRCErrors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CARRIED ONLY: tonight the counters change no verdict, label or degraded-attribute list — reading
|
||||||
|
// them is a change to what a household is told and is not taken here (R-330's next slice).
|
||||||
|
func TestR330_CountersChangeNoVerdictYet(t *testing.T) {
|
||||||
|
with := realDrive()
|
||||||
|
big := int64(5000)
|
||||||
|
with.ReportedUncorrect, with.CommandTimeout, with.UDMACRCErrors = &big, &big, &big
|
||||||
|
without := realDrive()
|
||||||
|
for _, prior := range []DiskPrior{{}, {SawUncorrectable: true}} {
|
||||||
|
if a, b := DiskVerdictFor(with, prior), DiskVerdictFor(without, prior); a != b {
|
||||||
|
t.Fatalf("prior %+v: the R-330 counters changed the verdict (%v vs %v) — that is a household-facing change", prior, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a, b := DegradedAttributes(with), DegradedAttributes(without); !reflect.DeepEqual(a, b) {
|
||||||
|
t.Fatalf("the R-330 counters changed the degraded-attribute list: %v vs %v", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-899: a household press tells the agent so (`trigger=manual`), and nothing else does — the agent runs the
|
||||||
|
// night's OS leg only after a backup that is not a press. The request the agent RECEIVES is asserted.
|
||||||
|
func TestR899_PressCarriesTriggerManual(t *testing.T) {
|
||||||
|
var got []string
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /backup", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = append(got, r.URL.RawQuery)
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"job_id":"backup-9201-2","phase":"running"}}`))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := c.StartBackup(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := c.StartBackupFor(ctx, "local"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := c.StartBackupForTrigger(ctx, "local", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := c.StartBackupForTrigger(ctx, "", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"", "target=local", "target=local&trigger=manual", "trigger=manual"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("queries = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("query %d = %q, want %q (all: %q)", i, got[i], want[i], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-921: the pre-check reads the agent's real wire shapes — GET /backup/tiers, then GET /backup/status per
|
||||||
|
// target — and reports exactly the tiers whose job is running or snapshotted (the agent's in-flight set).
|
||||||
|
func TestBackupJobsInFlight_ReportsRunningAndSnapshottedTiers(t *testing.T) {
|
||||||
|
phases := map[string]string{"local": "done", "felhom-pbs": "snapshotted", "extra": "running"}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[` +
|
||||||
|
`{"target":"local","cadence_seconds":86400,"primary":true,"storage":"present"},` +
|
||||||
|
`{"target":"felhom-pbs","cadence_seconds":604800,"primary":false,"storage":"present"},` +
|
||||||
|
`{"target":"extra","cadence_seconds":604800,"primary":false,"storage":"present"}]}}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tg := r.URL.Query().Get("target")
|
||||||
|
ph, ok := phases[tg]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unexpected target "+tg, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"phase":"` + ph + `","target":"` + tg + `"}}`))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||||
|
|
||||||
|
got, err := c.BackupJobsInFlight(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BackupJobsInFlight: %v", err)
|
||||||
|
}
|
||||||
|
if want := []string{"felhom-pbs", "extra"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("in-flight tiers = %v, want %v (done must not count; running and snapshotted must)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pre-R-82 agent (no /backup/tiers → 404) has no per-tier jobs: nil, nil — never an error that would be
|
||||||
|
// read as "cannot tell", and never a busy verdict.
|
||||||
|
func TestBackupJobsInFlight_PreR82AgentIsNotBusy(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
s := httptest.NewTLSServer(mux) // every route 404s
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||||
|
|
||||||
|
got, err := c.BackupJobsInFlight(context.Background())
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Fatalf("pre-R-82 agent: got %v, %v — want nil, nil", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A status read that fails is returned, so the loop can fail toward backing up and say so.
|
||||||
|
func TestBackupJobsInFlight_StatusErrorIsReturned(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[{"target":"local","primary":true}]}}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||||
|
|
||||||
|
if _, err := c.BackupJobsInFlight(context.Background()); err == nil {
|
||||||
|
t.Fatal("a failed status read was swallowed — the caller could not tell it from 'nothing in flight'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-921 part 2: HostBusy reads the agent's real wire shape (untargeted GET /backup/status, `host_busy`); an agent
|
||||||
|
// without the field is „not known" — never „free".
|
||||||
|
func TestHostBusy_ReadsTheAgentsGate(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body string
|
||||||
|
busy, known bool
|
||||||
|
what string
|
||||||
|
}{
|
||||||
|
{"held", `{"ok":true,"data":{"vmid":9201,"phase":"idle","host_busy":{"busy":true,"what":"os-pve-step"}}}`, true, true, "os-pve-step"},
|
||||||
|
{"free", `{"ok":true,"data":{"vmid":9201,"phase":"idle","host_busy":{"busy":false}}}`, false, true, ""},
|
||||||
|
{"older agent", `{"ok":true,"data":{"vmid":9201,"phase":"idle"}}`, false, false, ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
http.Error(w, "want the untargeted status", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(tc.body))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||||
|
busy, what, known, err := c.HostBusy(context.Background())
|
||||||
|
if err != nil || busy != tc.busy || known != tc.known || what != tc.what {
|
||||||
|
t.Fatalf("HostBusy = %v %q known=%v err=%v, want %v %q known=%v", busy, what, known, err, tc.busy, tc.what, tc.known)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestWriteConfig0600 asserts F8: controller.yaml is persisted 0600 (it holds infra secrets), even when
|
|
||||||
// the target file already existed with looser (0644) permissions. POSIX modes only — skipped on Windows.
|
|
||||||
func TestWriteConfig0600(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("POSIX file modes not represented on Windows")
|
|
||||||
}
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "controller.yaml")
|
|
||||||
|
|
||||||
// Pre-create with world-readable 0644 to prove the helper tightens an existing file.
|
|
||||||
if err := os.WriteFile(path, []byte("old: true\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeConfig0600(path, []byte("hub:\n api_key: redacted\n")); err != nil {
|
|
||||||
t.Fatalf("writeConfig0600: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fi, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if mode := fi.Mode().Perm(); mode != 0o600 {
|
|
||||||
t.Fatalf("config mode = %o, want 0600", mode)
|
|
||||||
}
|
|
||||||
// No leftover temp file.
|
|
||||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("temp file not cleaned up")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// D4 (`16` §5): the no-drive refusal is a 400 the household can act on — not a 500.
|
||||||
|
func TestD4_NeedsDriveIs400(t *testing.T) {
|
||||||
|
err := util.MsgErrorf(stacks.ErrNeedsDrive, "err.stacks.needs_drive")
|
||||||
|
if got := deployStatusFor(err); got != http.StatusBadRequest {
|
||||||
|
t.Fatalf("ErrNeedsDrive → %d, want 400", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,11 +57,15 @@ func TestRefuseNetworkStubDeploy_Table(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
r := newDeployGateRouter(t, c.class)
|
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ó") {
|
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)
|
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)
|
t.Errorf("%s: must proceed, got refusal %q", c.name, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
"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 {
|
if err := r.sett.SetGeoRestriction(geo); err != nil {
|
||||||
r.logger.Printf("[ERROR] [api] Failed to save geo settings: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,12 +87,12 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
|
|||||||
// away instead of after the next ~15-min cycle.
|
// away instead of after the next ~15-min cycle.
|
||||||
r.reportPushNow()
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,11 +106,23 @@ func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
|
|||||||
r.reportPushNow()
|
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) {
|
// geoCountries serves the picker's country list in the request's language.
|
||||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: cf.AllCountries()})
|
//
|
||||||
|
// 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) {
|
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}
|
override := &settings.AppGeoOverride{AllowedCountries: body.AllowedCountries}
|
||||||
if err := r.sett.SetGeoAppOverride(appName, override); err != nil {
|
if err := r.sett.SetGeoAppOverride(appName, override); err != nil {
|
||||||
r.logger.Printf("[ERROR] [api] Failed to save geo override for %s: %v", appName, err)
|
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
|
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 == "" {
|
if appName == "" {
|
||||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
|
||||||
return
|
return
|
||||||
@@ -170,7 +183,7 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, ap
|
|||||||
|
|
||||||
if err := r.sett.RemoveGeoAppOverride(appName); err != nil {
|
if err := r.sett.RemoveGeoAppOverride(appName); err != nil {
|
||||||
r.logger.Printf("[ERROR] [api] Failed to remove geo override for %s: %v", appName, err)
|
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
|
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,172 @@
|
|||||||
|
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
|
||||||
|
// UseFuture / UseWarn (R-1271): the only copy is dated in the future — offered, NOT recommended (the page draws
|
||||||
|
// „use" as the plain button), and the sentence says why.
|
||||||
|
UseFuture bool `json:"use_future,omitempty"`
|
||||||
|
UseWarn string `json:"use_warn,omitempty"`
|
||||||
|
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))
|
||||||
|
if cp.c.Future {
|
||||||
|
data.UseFuture = true
|
||||||
|
data.UseWarn = r.msgLang(lang, "kept.choice.use.future", localMinute(cp.c.Time))
|
||||||
|
}
|
||||||
|
} 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, future-dated: %v)", name, old, size, cp.ok, cp.c.Future)
|
||||||
|
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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||||
"go/ast"
|
"go/ast"
|
||||||
"go/parser"
|
"go/parser"
|
||||||
"go/token"
|
"go/token"
|
||||||
@@ -77,21 +78,38 @@ func TestDeployStackWiresTheLifecycleGate(t *testing.T) {
|
|||||||
|
|
||||||
// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via
|
// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via
|
||||||
// showAlert(), so it must be the sentence the spec ruled, not a Go error string.
|
// 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) {
|
func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) {
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
const key = "api.deploy.not_installable"
|
||||||
const want = "Ez az alkalmazás jelenleg nem telepíthető."
|
const want = "Ez az alkalmazás jelenleg nem telepíthető."
|
||||||
found := false
|
found := false
|
||||||
ast.Inspect(f, func(n ast.Node) bool {
|
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
|
found = true
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
if !found {
|
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,86 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-1271 (brief 2026-10-25 Part 4) — a future-dated off-site copy, the ONLY copy of the kept files: still offered (it
|
||||||
|
// may be the household's), but the question says its date is in the future and does not recommend it. Same rig as
|
||||||
|
// TestR691 (the restic runner seam; nothing reaches restic, ssh or Docker).
|
||||||
|
// COMPANION RED-PROOF (REPORT.md): with keptPick ignoring the future date, use_future stays false and this fails.
|
||||||
|
func TestR1271_AFutureCopyAloneIsSaidAndNotRecommended(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.Now().Add(4 * 24 * time.Hour) // the rehearsal box's clock: four days ahead
|
||||||
|
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": "abba81ba", "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{"hu": "a jövőben van", "en": "is in the future"} {
|
||||||
|
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{})
|
||||||
|
warn, _ := data["use_warn"].(string)
|
||||||
|
if data["use_offered"] != true || data["use_future"] != true || !strings.Contains(warn, want) {
|
||||||
|
t.Fatalf("%s: a future-dated only copy is offered, marked and said: offered=%v future=%v warn=%q", lang, data["use_offered"], data["use_future"], warn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 1219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user