Compare commits
906 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e79a20bbed | |||
| 6a82719426 | |||
| bee6848458 | |||
| 8360f940bf | |||
| fb652024ea | |||
| 06cbf8df29 | |||
| 6b5d64c1fa | |||
| aa62449694 | |||
| bdd1a9d130 | |||
| 14d8c00781 | |||
| e3525e62ac | |||
| 7406ac7bbf | |||
| 1806dfa8e9 | |||
| 41dbecb264 | |||
| 6d359a5360 | |||
| 179dd79882 | |||
| 8ef92a3fa7 | |||
| d5774d3189 | |||
| 0fc54e0122 | |||
| 2c35c4204a | |||
| ad28699761 | |||
| 5c97fbc397 | |||
| c04ea4f6c2 | |||
| ca3c8f6784 | |||
| c718aad1bc | |||
| 4cc123809c | |||
| 9530de722d | |||
| f7dbc335ac | |||
| dd13f632c8 | |||
| 3252d51104 | |||
| 666a34da88 | |||
| bbd6231909 | |||
| af2d103880 | |||
| 8d9b78c153 | |||
| 4707be755c | |||
| 9bd1a54d71 | |||
| 2137094799 | |||
| d319ae573e | |||
| e994bf35d2 | |||
| 260a8f6e58 | |||
| b06ea9c877 | |||
| 482af37b7d | |||
| 7efb7a53d3 | |||
| 0afadbdeff | |||
| 5f35aa0346 | |||
| 7ba7c2a271 | |||
| 405a795e32 | |||
| 1931dfcb0c | |||
| 2d64ee7241 | |||
| 3d4c5365c1 | |||
| 7f6b00375b | |||
| 80db2c103a | |||
| 9ca57e591b | |||
| ac6c05bd7b | |||
| 816c59c43a | |||
| 69f896d3cd | |||
| 4691aa1a35 | |||
| e9a74a0019 | |||
| f2fc76ec4b | |||
| 70d034a3f3 | |||
| 52e5cdb86a | |||
| a967da7d2c | |||
| 4ea211f67f | |||
| 246036605a | |||
| 8505954df7 | |||
| 0f22447d15 | |||
| 57b87ec9be | |||
| a4d7dfd336 | |||
| 01a8155c5a | |||
| e787391c0a | |||
| 61e9b55737 | |||
| bb29186d62 | |||
| 19c932a693 | |||
| 5bdd8372f8 | |||
| ea00976403 | |||
| 5825ceeabf | |||
| 80f473999e | |||
| 670ec35ece | |||
| 9e079c7883 | |||
| eb5d05f496 | |||
| 37f7ff69f2 | |||
| e07d90f0f4 | |||
| b4edc087fa | |||
| 37b93dc583 | |||
| edc7dcc9e2 | |||
| 1956e5d390 | |||
| 0a9bd3829d | |||
| d42d90fed7 | |||
| 70f84941d4 | |||
| acfc2b7e95 | |||
| 3d504d58c8 | |||
| 37515cda7c | |||
| e70b5feebe | |||
| c949389c95 | |||
| 29bcfeb214 | |||
| 1a68b53b06 | |||
| 49b627684c | |||
| 376365bb12 | |||
| 772956d214 | |||
| 315c469fc8 | |||
| 1aa1bd17c2 | |||
| e6b5fa1e63 | |||
| 699790b12d | |||
| d56e395a2a | |||
| c3ce4c7b20 | |||
| e87d6b26bb | |||
| 952ebf4862 | |||
| 06d7788392 | |||
| af518ba151 | |||
| 338b2ccf86 | |||
| ca4c8b3afc | |||
| d839ddcb60 | |||
| f3975cf5bc | |||
| 3dff3573f7 | |||
| f3f0d58844 | |||
| 91a1dad0f3 | |||
| de5a3e5765 | |||
| 7383400a23 | |||
| d4c07873ca | |||
| 36d635a4cd | |||
| bcbe2707d6 | |||
| f6aed82940 | |||
| 3696188636 | |||
| 6869a14015 | |||
| 0fd0ca1373 | |||
| 2508788d38 | |||
| 1257014c2b | |||
| b5a73e050b | |||
| 070b0ce2dc | |||
| 0d1cf0f732 | |||
| ff050cf409 | |||
| 955083c0fc | |||
| f47b0a61d7 | |||
| 6b7d516ce5 | |||
| 4f34a9e0ae | |||
| b505ee9125 | |||
| 6369570e8d | |||
| b97623045b | |||
| 5ffdcecb2d | |||
| cba48293f1 | |||
| 8c793955b1 | |||
| b4c528801a | |||
| e168600148 | |||
| d0cec9d3f3 | |||
| 33a7cc51d3 | |||
| 6c57658c1b | |||
| 14b0939060 | |||
| 3401fcdc1c | |||
| a0a1556ce6 | |||
| 2c0e43e0d0 | |||
| 9ea5675950 | |||
| 655b69f37d | |||
| 331193b898 | |||
| 65409aecd1 | |||
| 7826bd7179 | |||
| 72692e1d9c | |||
| a31872ea24 | |||
| a16896af86 | |||
| c60cd4234c | |||
| 2b24c70536 | |||
| ad8057c4e3 | |||
| 5aca709ca1 | |||
| eb3f0b8d3f | |||
| 26f168c22e | |||
| 6d11c54a50 | |||
| 73889e9fdf | |||
| 57ba3c7c8c | |||
| b802a9e7de | |||
| ce4e03dcd8 | |||
| b6d93e3fcb | |||
| c74d752a9a | |||
| a0bdd8eed6 | |||
| adf1d1e619 | |||
| 48daa4fdeb | |||
| b11607b26b | |||
| 945b7818b5 | |||
| 65e30f2e42 | |||
| c73800c269 | |||
| 88b41ec870 | |||
| f5a5e2b911 | |||
| add5b9bbbb | |||
| 9cfa619ec3 | |||
| 9e94479449 | |||
| 89d64f64b3 | |||
| 2d2050c362 | |||
| 0c20c91e85 | |||
| 2d78c283c6 | |||
| a2500532c3 | |||
| 46504938ba | |||
| 485321f694 | |||
| 7fffde3e13 | |||
| 515e0c3cc7 | |||
| 5d56f93755 | |||
| 77119bd1b0 | |||
| 1d65f44205 | |||
| be2d90f243 | |||
| 770ee5a5f6 | |||
| e49f20f581 | |||
| ae42bb5978 | |||
| 5902703dda | |||
| 19dbb02ee3 | |||
| be9edb443c | |||
| f379ba0b34 | |||
| 5df15bea9f | |||
| 8462338ad4 | |||
| 6cc875cc36 | |||
| 1f9c17ca5e | |||
| a2c60edefd | |||
| 6dab0f6a2c | |||
| b620435afe | |||
| eaa8eddf66 | |||
| b03a53ddcf | |||
| 527d81cf70 | |||
| 92681bda6c | |||
| 1133aade73 | |||
| c801cee647 | |||
| b67fce39f1 | |||
| f1da17131b | |||
| c766c8af82 | |||
| 5b35023574 | |||
| e57dd8b0e8 | |||
| 8ecab3cd64 | |||
| a12c6f9730 | |||
| 699325bd8a | |||
| cf1862c8ab | |||
| c043ba9abe | |||
| ad1d26a9fd | |||
| 9e292958c2 | |||
| 1613bc7120 | |||
| 068bce673c | |||
| f4c2c4151b | |||
| 2cf37f263b | |||
| 05dfaa1a10 | |||
| 9b3381be0a | |||
| a1d503be98 | |||
| 5dcb72a542 | |||
| 61dbd870c3 | |||
| f59aa97d0c | |||
| 8ec45f3b42 | |||
| 460cc73bad | |||
| 59226edb3e | |||
| 2143ae2d8d | |||
| 0073691231 | |||
| 3b41a982fe | |||
| 0b2c59852e | |||
| af8a7a58a4 | |||
| ef7d94eb2e | |||
| d4ac52d9e0 | |||
| befaa795e0 | |||
| 863f94b63a | |||
| 3d28862db3 | |||
| 907e5ce65c | |||
| 50a7ffacd2 | |||
| f761f69d46 | |||
| 33116a547e | |||
| 4da74700c9 | |||
| 4a4233059d | |||
| 6bf4bef2be | |||
| 54a4644721 | |||
| e671ba3fd1 | |||
| 107f74ea3c | |||
| c484aa204e | |||
| 11ead4be0e | |||
| c35da9de6a | |||
| ce8c5393f1 | |||
| fb0b8c1d42 | |||
| 0a399e3c02 | |||
| 9c8505649a | |||
| db3378bbf7 | |||
| 1d1d60a737 | |||
| a7d9837976 | |||
| eaa217373f | |||
| 0c4886958e | |||
| 1e1181939b | |||
| 30fd9d3ecd | |||
| 648ee67910 | |||
| b682344471 | |||
| 41060521aa | |||
| 83c47eaf23 | |||
| 97249099d6 | |||
| a771cda67b | |||
| 29b2b8ad7c | |||
| 238472b8ed | |||
| fa8c0d5972 | |||
| a868e3585a | |||
| ee48a48288 | |||
| 13af252907 | |||
| 916d936332 | |||
| 8feed6d1ff | |||
| aed7f33f3c | |||
| f3d1ba3062 | |||
| 317037f8eb | |||
| 3e2078be8a | |||
| 13d8bffa96 | |||
| eee2c85321 | |||
| f5ab70d6e6 | |||
| 90ea253dc2 | |||
| 4529922e6d | |||
| b6d537d86c | |||
| 28811c207b | |||
| d2fd935a71 | |||
| be2fc507bf | |||
| 1f4702fe50 | |||
| b14b5a65d9 | |||
| bcdb04222a | |||
| 00f26c4f06 | |||
| aa9c08f0dd | |||
| bbaa844f8c | |||
| 2eccda4dd7 | |||
| a03e142101 | |||
| 4e3f6c48d4 | |||
| b76dad0c4e | |||
| d3a564cbff | |||
| 7e0370f700 | |||
| d3eb8099a3 | |||
| 900d3041fa | |||
| 9e13eadf00 | |||
| 22d04967b4 | |||
| ee4e061d2b | |||
| b2a1db1363 | |||
| cfdcb50bc2 | |||
| ce1e9c64e0 | |||
| 592818492c | |||
| c6d7a69e6d | |||
| a344dfdd54 | |||
| d66496257c | |||
| a9cd308398 | |||
| ab91e49107 | |||
| 7f11cfb36c | |||
| 3588a31b78 | |||
| f2e51e676b | |||
| a4268c3707 | |||
| 66da0c44d5 | |||
| 4bb2df0dc4 | |||
| 85a14192e7 | |||
| fba94dc73b | |||
| 587fcc27ca | |||
| 50c090eeaa | |||
| 30972d8f54 | |||
| 4c9b0e8706 | |||
| 9964ae6fd4 | |||
| 36c5cd5fdf | |||
| 3172df1927 | |||
| d86d2699c3 | |||
| 7c9647327f | |||
| e144c5e491 | |||
| 4009401f46 | |||
| 6b1fbca51d | |||
| 2752e12acc | |||
| 106c3379b0 | |||
| f7e3d50d5a | |||
| e247dbc1be | |||
| 1c737db4f4 | |||
| 698fac04a3 | |||
| 0c708a3bd0 | |||
| 770eeb6fee | |||
| 9d68d52fea | |||
| 319ba10d9f | |||
| eb227486d0 | |||
| bdb65a80e8 | |||
| 5c8e6494b7 | |||
| 62b73dfa18 | |||
| 59eb1f836e | |||
| 60244727ad | |||
| 10e07f5747 | |||
| a50822af44 | |||
| dd563a0273 | |||
| 7747a16ff1 | |||
| dd961a66bb | |||
| 1fa3250aa3 | |||
| 914d51aa5f | |||
| d202b43b95 | |||
| 4b18cc5208 | |||
| 95853e3dff | |||
| c2b7145cbd | |||
| dee72cd0de | |||
| de5d8ef9b1 | |||
| 65544ae689 | |||
| dc7bcedb42 | |||
| 6218e7919d | |||
| e8f8c441fa | |||
| aff272c4be | |||
| 12a2eee9eb | |||
| 13eb450d9f | |||
| a17d863d3d | |||
| 6acd96debd | |||
| 08fef4872b | |||
| 8d85da73af | |||
| b27931216c | |||
| d5012c64cd | |||
| 0771d58b58 | |||
| eff107d324 | |||
| 82280b1f57 | |||
| deacee1164 | |||
| 790ec845f7 | |||
| 671bd3fd91 | |||
| 3335f2c02d | |||
| eb6b3bba56 | |||
| ffba9eb395 | |||
| 0d1b0494b7 | |||
| d306d2a5aa | |||
| 962273ba8e | |||
| a1d045079f | |||
| ed271fadb2 | |||
| 375cb082e2 | |||
| ac305fe57f | |||
| fa0ecb2cd7 | |||
| 39144a5844 | |||
| 8e69e5edda | |||
| 3616dcbb7c | |||
| 3efb1b1af1 | |||
| 3682303131 | |||
| 80aca3a3a1 | |||
| 36c72138f1 | |||
| 04861a7ed3 | |||
| 2321077800 | |||
| 74fa61c7bc | |||
| a92e18b21f | |||
| 0e05c6966d | |||
| 5d66c62bd6 | |||
| bad9203daa | |||
| 11d0f55303 | |||
| ccf89c0a67 | |||
| fda783bdaf | |||
| 448a68237a | |||
| 007946faf4 | |||
| e205a2d6a1 | |||
| 6040c7b93d | |||
| 8ded485a58 | |||
| 9a14f04819 | |||
| 3ded6cf7c1 | |||
| 6b40eb8619 | |||
| b904477ed9 | |||
| e96e2b2361 | |||
| b1ab0ea0e5 | |||
| 1d94b1a9e9 | |||
| 2e03de1e0c | |||
| e74014730a | |||
| 3e949bc513 | |||
| 0ed87f5dc4 | |||
| 168e86b77d | |||
| c322bac21a | |||
| 0eb9dc2e9b | |||
| d02a216d0f | |||
| 9095981d0c | |||
| d7b3c82144 | |||
| f875ac913d | |||
| 45bff0184f | |||
| 0d73ca579b | |||
| f97f7803c7 | |||
| bed9bd2c5a | |||
| ff7a5d1c56 | |||
| 658401c36d | |||
| 77247f9710 | |||
| 10a7bf09f7 | |||
| 95d71fef2e | |||
| 0daddcd1c4 | |||
| 068427a729 | |||
| bca529be43 | |||
| 146d165c26 | |||
| ae950e5933 | |||
| 9f29bf34c0 | |||
| 8e1a3f0d82 | |||
| 0f37e647e3 | |||
| 78d16b37a1 | |||
| 7c038df63c | |||
| e35b1ae0e6 | |||
| 1ee5559772 | |||
| c358a361d0 | |||
| 9b95f88e8c | |||
| 37222d3e1d | |||
| bbecf0592e | |||
| 37e60b46d2 | |||
| 4eaabb21d4 | |||
| a487ab2fc4 | |||
| 16390d02be | |||
| 789bbd1c8f | |||
| 27e2fb05c0 | |||
| e80e14d674 | |||
| b57f6c1509 | |||
| 8ca1272db6 | |||
| 2440d52e1e | |||
| 3ea2b481da | |||
| 32ca1f60a7 | |||
| ce6a56691e | |||
| 00afadc1fe | |||
| 4703b9ec4e | |||
| cc2b50bc72 | |||
| 8a3afa75b3 | |||
| 3e0c327f16 | |||
| 6eabbcd7ce | |||
| ba762f6d60 | |||
| efdf683ac7 | |||
| 865a4a6afb | |||
| c084046af0 | |||
| 0cc70a7a5a | |||
| 9f1759132d | |||
| 0f28add0bf | |||
| c918baa656 | |||
| f835203bc9 | |||
| a848bd6a83 | |||
| c8d36b07e4 | |||
| 004dabb7ca | |||
| 292a23bf39 | |||
| 09bd7191e0 | |||
| 04826c922e | |||
| fad5573dd3 | |||
| cb26dc7e83 | |||
| c24d4afeee | |||
| 49d1233391 | |||
| e0d1733b85 | |||
| 17d9af5c13 | |||
| 17cc67f7cd | |||
| ecf9185605 | |||
| 6d5b3c1c38 | |||
| 7c70c545d9 | |||
| 0a65f2be5f | |||
| 6e86eb6d51 | |||
| 8cf42f22d7 | |||
| b0bf500967 | |||
| 229650b4ee | |||
| cff3a30996 | |||
| 44ec06b50f | |||
| 996d403248 | |||
| c1cfbe817b | |||
| febdc560f4 | |||
| 463ea369c4 | |||
| 9b250b97c9 | |||
| 3472b90368 | |||
| 0efdc78d75 | |||
| 02c748eb2a | |||
| 844fbfa749 | |||
| b93dd03f0a | |||
| fb8e5111cf | |||
| 18a556a514 | |||
| 1dc86523b5 | |||
| 87ba30ae91 | |||
| 3d91ce8fe3 | |||
| 089fa70bbe | |||
| f29b8ab9cb | |||
| 37fafd04f0 | |||
| 9dfa247e04 | |||
| 6d7061d9f4 | |||
| 7d7f33b488 | |||
| 98bf5a434f | |||
| 4cb70ff88b | |||
| 0640aa06ea | |||
| bd97bc3474 | |||
| e5617969ae | |||
| 5debe02031 | |||
| bd4f735e98 | |||
| f7cc6a720e | |||
| a63cc7151b | |||
| 02d63ed070 | |||
| 1a1e42addb | |||
| 7714f1f6e4 | |||
| 8e26cdc3a5 | |||
| 66b955d054 | |||
| 1b19377f84 | |||
| 3eb31f50d6 | |||
| 658e78d495 | |||
| 61f4898d30 | |||
| b70f2d0763 | |||
| ccb378060d | |||
| f8fc09e5cc | |||
| 0ec7555126 | |||
| a3ee93e97e | |||
| f1d3922fcc | |||
| 012e5f3ecc | |||
| 1bd054602d | |||
| 28ac5f575c | |||
| 05d81810d4 | |||
| 2f97ce31dd | |||
| 2db92c8837 | |||
| b6bad953b5 | |||
| 582917d12d | |||
| 7e42ca64e5 | |||
| b5d92a8e45 | |||
| cf1a9402f2 | |||
| 87f2841692 | |||
| c758c97a1a | |||
| 73e5a003f2 | |||
| 1907dab296 | |||
| a7f954dde7 | |||
| 2028785f5e | |||
| 76ee25e5e8 | |||
| 4fe895eaa4 | |||
| 265f46c0e7 | |||
| 99246c3c7d | |||
| 30d762d520 | |||
| 13203c2452 | |||
| ba52005e61 | |||
| fcf84a0c5c | |||
| 740cc42734 | |||
| d2cc02344c | |||
| 5e1933b166 | |||
| 0fa7ea1da1 | |||
| 6f5fb19a64 | |||
| fbeeacb124 | |||
| b18f6aee1b | |||
| 7fb20d5fb0 | |||
| bf099f6a10 | |||
| 3d7f0505b0 | |||
| 7ad111cfb5 | |||
| 2e33a8b86b | |||
| 0fa79cd90d | |||
| 996526273a | |||
| 639a57ee8b | |||
| 5e3dfcb714 | |||
| 9282d60f96 | |||
| ad61e96abd | |||
| 4a18306f30 | |||
| b5f00509ee | |||
| d331eb26d1 | |||
| b0de6b34f6 | |||
| b56790269e | |||
| d3fdbbc5ad | |||
| 5f230f6e11 | |||
| 3d60b19b89 | |||
| fc79a3aeea | |||
| c136308aad | |||
| bc8d54df6a | |||
| 43189e8972 | |||
| f114f4a7e7 | |||
| ccbb13a3db | |||
| bed8675930 | |||
| dd54e4cdf6 | |||
| a336fe3123 | |||
| 8c8196e82b | |||
| 6dae9d7558 | |||
| e59e3c0ae2 | |||
| d775fda326 | |||
| 850ed78ad6 | |||
| f5e45840e3 | |||
| 7ea36bccad | |||
| 9e5bbc13d5 | |||
| 7c14e64a68 | |||
| 6b594a7551 | |||
| 457a5cdf2d | |||
| 1be1e0d6a3 | |||
| 741f322d6a | |||
| 554845b44b | |||
| b0e6d72bf8 | |||
| 482dbec913 | |||
| 6148e6131c | |||
| 585f12e04f | |||
| 079a2cdd08 | |||
| ce26c9d646 | |||
| f7905b78b5 | |||
| 710afb066b | |||
| 6db68f4e45 | |||
| 83c4f7f794 | |||
| b77df3c761 | |||
| 629196c66a | |||
| 562c0dfeb6 | |||
| 54daaf949a | |||
| f8b8730c53 | |||
| 2289fc907c | |||
| 73b3f6ac71 | |||
| d88b43a6e6 | |||
| a3ac6c9488 | |||
| e51e03bd7b | |||
| fdcbc04d82 | |||
| 34364b47c6 | |||
| e1dd06e77a | |||
| 5a3747f6ab | |||
| 063fb0cfd4 | |||
| 88073ac464 | |||
| cac745ee04 | |||
| 87f0794938 | |||
| 0ff1d3c883 | |||
| 6882d853ed | |||
| 77c61ff494 | |||
| 897997c164 | |||
| f133355e34 | |||
| 06727e3228 | |||
| 83ed127d53 | |||
| c978ea1951 | |||
| f4d88c1ff4 | |||
| c37658ebb0 | |||
| 8f3b247e6e | |||
| 253117b292 | |||
| 27aa15fb93 | |||
| 9c5cf2975f | |||
| dd2bb5fc01 | |||
| cbba20aa73 | |||
| 575f62ab7f | |||
| a69e06354e | |||
| 12f038618e | |||
| a297639c58 | |||
| 26a6dde2fb | |||
| d3661319c8 | |||
| 121405772f | |||
| 0d470b1e05 | |||
| 6b1c2892d6 | |||
| 1141d74532 | |||
| eea3cd4040 | |||
| 2312572411 | |||
| b7b165bff5 | |||
| c6bae9515a | |||
| 66a001de0b | |||
| 1777c89fb9 | |||
| 8254b8823e | |||
| 2de9659592 | |||
| 0719db0d53 | |||
| b4ce6c10b5 | |||
| 576f4ca88b | |||
| 2e749741b8 | |||
| fa3c4f2657 | |||
| 4b97855cdd | |||
| 3044965a9b | |||
| feea06062a | |||
| 35c6632a64 | |||
| 808d0e8756 | |||
| aa3d7c762d | |||
| 6c285ba033 | |||
| 30ecf738c2 | |||
| cc6052ca55 | |||
| d5266ca009 | |||
| 39ef64e128 | |||
| d65b2f74ea | |||
| e6c8ddcf7f | |||
| 30380a59f4 | |||
| ea09ead806 | |||
| 89a2e508a7 | |||
| 15397468df | |||
| 8098237ce1 | |||
| 230980f7a8 | |||
| 347546e597 | |||
| 2c1441f5dd | |||
| cd6715d592 | |||
| de4ff8b39b | |||
| 14e2ac98f4 | |||
| e7517c4185 | |||
| c50bd55392 | |||
| c6cb8a51e7 | |||
| 4baed17e89 | |||
| 8746c0fb7b | |||
| 234e827b21 | |||
| e8a5bdc492 | |||
| add5be5bd5 | |||
| 445db9a625 | |||
| e4f49491a3 | |||
| 58fe93034f | |||
| a9ca5b4e68 | |||
| f70c011749 | |||
| 4a2d72e238 | |||
| 149a3b092e | |||
| 5f5e3c54a1 | |||
| 228dac4c06 | |||
| 691c0208c5 | |||
| 0635640848 | |||
| 2f7acb7d07 | |||
| d105657355 | |||
| 9ee929ba6d | |||
| f1df01b5c5 | |||
| 4fe77fcd1b | |||
| f24125ecdd | |||
| ee3efb0fae | |||
| ebf3c0cbea | |||
| 45052da4dc | |||
| 326aa1ca17 | |||
| b5a961477d | |||
| 1fd6e1a22f | |||
| c1c8fe2a7d | |||
| 98fa8b299a | |||
| 751941ca6a | |||
| 4c0eb2f5d4 | |||
| 280781115d | |||
| e68fe3eb19 | |||
| 21d0e7cf4c | |||
| 4320c01915 | |||
| d59691dd82 | |||
| 3457415117 | |||
| a22b87e6e3 | |||
| a98210ae00 | |||
| 680b1592c5 | |||
| 0c843286a2 | |||
| 8c54775b6f | |||
| e54f882e70 | |||
| f9af3243b9 | |||
| 4590fc0ee0 | |||
| 5dc363771b | |||
| c6dd0ed505 | |||
| d1a3cd0625 | |||
| e436b61368 | |||
| 4a81a96678 | |||
| a500889b00 | |||
| 7eb3772000 | |||
| fe7d0850a5 | |||
| 15c4728e2c | |||
| 33429933af | |||
| e7ed8a8483 | |||
| 464e324c3c | |||
| 4bd0909f2b | |||
| 5268411014 | |||
| 5bc4c3d967 | |||
| 8db15bac16 | |||
| 94a236328b | |||
| 41f2d2b5da | |||
| 0c6ec27054 | |||
| 7b5f860a70 | |||
| 9347fcd3a5 | |||
| 6e05e0ff7c | |||
| aaff268fff | |||
| 2f8658981d | |||
| 7bc27c38de | |||
| aab3e137c5 | |||
| 4be3bdf486 | |||
| 23611c20ef | |||
| 7c0c75457f | |||
| 0d832def7b | |||
| cb1d964620 | |||
| 3d6cde8080 | |||
| 715f644bf0 | |||
| 0f12e17175 | |||
| 7b545c1ec7 | |||
| ea66afa960 | |||
| 87b062e84a | |||
| bd0531e4a8 | |||
| dc64bb2d79 | |||
| 7e6ea9d66c | |||
| a964dc20a4 | |||
| df2a1259d9 | |||
| e363c6594d | |||
| ce80dce497 | |||
| 8aa4104586 | |||
| 276ccda938 | |||
| d65dba63bf | |||
| 5ebf0d5fe4 | |||
| ac43d0cbf5 | |||
| f1212e6ba8 | |||
| f82fa9be2c | |||
| 1eccd4df58 | |||
| 652d567864 | |||
| c3d087bc0f | |||
| 2a83a4e96c | |||
| 7860f96a56 | |||
| 23cb487348 | |||
| 5e2012728f | |||
| f50278e2b0 | |||
| d94ac7b65d | |||
| 906c143aea | |||
| 61ef1a3952 | |||
| bc23141879 | |||
| 1e354cbd41 | |||
| d8790af6bb | |||
| 11924f044e | |||
| 725af801c1 | |||
| feef6d51fc | |||
| d7d6f940ef | |||
| a7acae11fe | |||
| 4636991fcd | |||
| 38f3a1e01e | |||
| 09ad08d511 | |||
| a757bee07a | |||
| 8bed5ec339 | |||
| 3bc31fca2d | |||
| af601a1297 | |||
| 67f53a4ccd | |||
| da991fad57 | |||
| 3690c5028e | |||
| df4d0a2255 | |||
| 1f05f9f866 | |||
| 165c59e84b | |||
| 4ec1b7d712 | |||
| 9048e1adad | |||
| 6dd72f1408 | |||
| 20cd7960bd | |||
| 6bcbaa1574 | |||
| 11428659d1 | |||
| 3217cb4751 | |||
| b4cb92e09f | |||
| 7859e1182e | |||
| 9d0dd658ed | |||
| a7a5304093 | |||
| 920c1a17c4 | |||
| dec8b19ac2 | |||
| 42e0617a6c | |||
| cb425d8086 | |||
| b07132f617 | |||
| 95bb261fa8 | |||
| 0fcc7d8e6f | |||
| 4c8bf63ce3 | |||
| 36a7d1c162 | |||
| d8e1ec44d7 | |||
| f7f605bb2a | |||
| 41e313bf36 | |||
| d3d3044b98 | |||
| fa4713255f | |||
| bd669e7a9d | |||
| c9abc6bb9e | |||
| e531516cfa | |||
| 869ff55fd1 | |||
| aa51df3ab4 | |||
| 7f4e479c63 | |||
| 50d9eb66bf | |||
| 2bd678a5f8 | |||
| a345cb93c3 | |||
| 17b1d36bf9 | |||
| ab05609038 | |||
| f53a4bb44c | |||
| 868e8465ce | |||
| 77b5a4ce4e | |||
| 13c5c874d2 | |||
| 153e3d1867 |
@@ -0,0 +1,97 @@
|
||||
# gates — re-run this repo's gate entry point on every push, on a machine that does not care who
|
||||
# pushed or what they typed.
|
||||
#
|
||||
# *** THIS REPORTS. IT CANNOT REFUSE. ***
|
||||
#
|
||||
# felhom repos push straight to `main` with no pull request, so there is no merge for a status
|
||||
# check to stand at. The refusing half is `.githooks/pre-push`, which is local to a clone and which
|
||||
# `git push --no-verify` skips; this half is what notices when that happened. Neither half is the
|
||||
# whole thing, and both are named in documentation/backlog/OPEN-ITEMS.md R-168.
|
||||
#
|
||||
# NO `uses:` STEP ANYWHERE, deliberately: JavaScript actions need a node runtime in the runner, and
|
||||
# the runner is a host-mode container with python3 and git and nothing else (see
|
||||
# homelab-manifests/gitea-system/act-runner.yaml for why it is not privileged). Probe P3 measured
|
||||
# that a plain `git fetch` of the pushed SHA from the in-cluster Gitea service is enough.
|
||||
#
|
||||
# A failing run must reach a person — a detector nobody hears is the defect R-29 filed, rebuilt one
|
||||
# layer up. That is the last step, and it runs ONLY on failure.
|
||||
name: gates
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
runs-on: felhom-gates
|
||||
steps:
|
||||
- name: Fetch the pushed commit
|
||||
run: |
|
||||
# Shallow, and pinned to the exact SHA that was pushed — not to the branch tip, which can
|
||||
# move under us if two pushes race. Probe P3 proved the two are equal when done this way.
|
||||
git init -q .
|
||||
git remote add origin http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git
|
||||
git fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||
git checkout -q FETCH_HEAD
|
||||
echo "checked out $(git rev-parse HEAD)"
|
||||
|
||||
- name: Run the gate entry point
|
||||
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy — those are either
|
||||
# already reliably run by a person or none of CI's business. The exit code IS the result:
|
||||
# no `|| true`, no pipe that could swallow it.
|
||||
run: python3 scripts/repo_gates.py --fast
|
||||
|
||||
- name: Alarm on failure
|
||||
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
|
||||
# notification row and NO log line from Gitea itself — a red tick in a web UI nobody watches
|
||||
# is exactly the shape R-29 filed against. So the run sends its own alarm, on the project's
|
||||
# existing transactional path (Resend, the same one the hub uses), and prints the provider's
|
||||
# accepted id so "a message left the machine" is an observable, not an assumption.
|
||||
#
|
||||
# Pure python3 and urllib, NOT curl: the runner image carries python3 and git and nothing
|
||||
# else on purpose, and the first version of this step died on `curl: command not found`.
|
||||
# Reaching for a bigger image to send one HTTP request would have been the wrong trade.
|
||||
if: failure()
|
||||
env:
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, sys, urllib.request, urllib.error
|
||||
|
||||
key = os.environ.get("RESEND_API_KEY", "")
|
||||
if not key:
|
||||
sys.exit("ALARM FAILED: RESEND_API_KEY is empty — the alarm cannot be sent, and a "
|
||||
"silent alarm is worse than none. Set the user-level Actions secret.")
|
||||
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "?")
|
||||
sha = os.environ.get("GITHUB_SHA", "?")
|
||||
run = os.environ.get("GITHUB_RUN_NUMBER", "?")
|
||||
srv = os.environ.get("GITHUB_SERVER_URL", "https://gitea.dooplex.hu")
|
||||
|
||||
body = json.dumps({
|
||||
"from": "Felhom CI <monitoring@felhom.eu>",
|
||||
"to": ["admin@felhom.eu"],
|
||||
"subject": "[felhom CI] gates FAILED in %s" % repo,
|
||||
"text": (
|
||||
"The gate entry point exited non-zero.\n\n"
|
||||
"Repository : %s\n"
|
||||
"Commit : %s\n"
|
||||
"Run : %s/%s/actions/runs/%s\n\n"
|
||||
"The failing gate names itself in the run log.\n\n"
|
||||
"If the local pre-push hook was GREEN for this commit, then CI and the hook\n"
|
||||
"disagree - that is a finding about the gates themselves, not about CI, and it\n"
|
||||
"outranks whatever the push was for.\n"
|
||||
) % (repo, sha, srv, repo, run),
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.resend.com/emails", data=body, method="POST",
|
||||
headers={"Authorization": "Bearer %s" % key,
|
||||
"Content-Type": "application/json",
|
||||
# Cloudflare fronts api.resend.com and BLOCKS the default
|
||||
# "Python-urllib/3.x" agent with its own 403 (error 1010) — which looks
|
||||
# exactly like an auth failure and is not one. Measured 2026-08-02.
|
||||
"User-Agent": "felhom-ci/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
print("RESEND-ACCEPTED id=%s" % json.load(r)["id"])
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit("ALARM FAILED: Resend returned HTTP %s: %s" % (e.code, e.read().decode()[:300]))
|
||||
PY
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
# pre-push — refuse a push that carries a broken gate. (2026-08-02, R-29 leg (b) first half.)
|
||||
#
|
||||
# Runs this repo's ONE gate entry point in --fast mode: only checks that touch no network and no
|
||||
# container runtime, so a push stays a push and never pulls images or starts containers. The slow
|
||||
# gates stay deliberate periodic runs; a hook that takes minutes gets bypassed within a week and
|
||||
# the bypass becomes the habit.
|
||||
#
|
||||
# BOTH LINES BELOW ARE DELIBERATE. An absent log line is not evidence a hook ran — a silent pass is
|
||||
# equally consistent with "gates green" and "hook never fired", so a passing push says so out loud.
|
||||
#
|
||||
# HONEST LIMITS, stated so this is not mistaken for enforcement it cannot provide:
|
||||
# * per-clone — core.hooksPath is local config and a clone does not carry it. Arm a clone once:
|
||||
# git config core.hooksPath .githooks
|
||||
# Any manual entry-point run WARNS when the clone is unarmed.
|
||||
# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape
|
||||
# hatch that cannot be reached is one that gets removed the first time it is
|
||||
# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT.
|
||||
# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168.
|
||||
#
|
||||
# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd
|
||||
# is the repo root whether `git push` is issued from the root or from any subdirectory. The
|
||||
# explicit rev-parse below does not depend on that.
|
||||
set -u
|
||||
|
||||
root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||
echo "pre-push: FAIL - cannot resolve the repo root (git rev-parse --show-toplevel)." >&2
|
||||
exit 1
|
||||
}
|
||||
cd "$root" || exit 1
|
||||
|
||||
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 " pass by default. Install python3, or push with --no-verify and say so." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-push [felhom.eu]: running scripts/repo_gates.py --fast ..."
|
||||
python3 "scripts/repo_gates.py" --fast
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "pre-push [felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2
|
||||
echo " 'git push --no-verify' and state that you did in the session report." >&2
|
||||
else
|
||||
echo "pre-push [felhom.eu]: gates OK - push proceeding."
|
||||
fi
|
||||
exit $rc
|
||||
@@ -0,0 +1,38 @@
|
||||
*/*.secret.yaml
|
||||
*.secret.yaml
|
||||
*secret*
|
||||
|
||||
# Go binaries
|
||||
hub/hub
|
||||
hub/hub.exe
|
||||
hub/bin/
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test and coverage
|
||||
*.test
|
||||
*.out
|
||||
coverage.html
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# Python bytecode from the gate scripts + their fixture tests
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,225 @@
|
||||
# CLAUDE.md — Project Instructions for Claude Code (`felhom.eu`)
|
||||
|
||||
> Read automatically when Claude Code works in this repo. Stable orientation only — **current state
|
||||
> lives in `CONTEXT.md` and the tops of `hub/CHANGELOG.md` / `scripts/CHANGELOG.md` /
|
||||
> `website/CHANGELOG.md`**, never here. Cross-repo orientation (the felhom system, artifact
|
||||
> taxonomy, access): workspace-root `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`; this file is
|
||||
> `felhom.eu`-specific. A versioned copy of that workspace file lives at
|
||||
> `documentation/runbooks/workspace-CLAUDE.md`.
|
||||
|
||||
## Project overview
|
||||
|
||||
This repo contains:
|
||||
- **Website** (`website/`) — static HTML at felhom.eu, served via k3s nginx + git-sync sidecar.
|
||||
- **Hub** (`hub/`) — Go application (felhom-hub), the **operator backend**, on k3s at `hub.felhom.eu`.
|
||||
- **K8s manifests** (`manifests/`) — k3s deployment manifests for felhom-system services.
|
||||
- **Architecture docs** (`documentation/`) — the **authoritative design home for the whole Felhom
|
||||
system**: `architecture/01..05-*.md`, `proxmox-platform.md`, `tests/phase*-findings.md`,
|
||||
runbooks, audits. Read these before designing.
|
||||
- **Skills** (`skills/`) — the versioned source of the Claude Code skills
|
||||
(`felhom-build-deploy`, `felhom-ui-design`, `felhom-testing`, `felhom-app-catalog`);
|
||||
install/update with `python3 scripts/install_skills.py` (symlink into `~/.claude/skills/` on
|
||||
POSIX, junction on Windows — either way repo edits are live immediately).
|
||||
|
||||
See `README.md` for full architecture/DNS/email/SEO docs. See `TASK.md` for the current task (if any).
|
||||
See `REUSE.md` before writing new code.
|
||||
|
||||
## The Felhom system (so the hub's role is in context)
|
||||
|
||||
Felhom is **Proxmox-based**, with a locked **three-component model**:
|
||||
- **Hub** (this repo, `hub/`) — operator backend. Authors operator *intent*; mirrors box *reality*;
|
||||
holds **no data-plane role** and never connects inbound to a box.
|
||||
- **Host agent** (repo `felhom-agent/`) — one per Proxmox host; owns all Proxmox interaction.
|
||||
- **In-guest controller** (repo `felhom-controller/`) — one per customer LXC; Docker-only.
|
||||
|
||||
## Hub — architecture (version-free; current version = `manifests/hub.yaml` image tag)
|
||||
|
||||
The hub ingests two report streams — the agent's host-domain report (`POST /api/v1/host-report`, the
|
||||
heartbeat/dead-man's-switch) and the legacy controller report (`POST /api/v1/report`, **frozen until
|
||||
the slice-10 cutover — do not modify**) — plus structured controller events (`POST /api/v1/event`,
|
||||
gated by `allowedEventTypes`). Around them: staleness/disk/storage-fill/leaf/capability monitor
|
||||
checkers, the two-tier notification dispatcher (operator English / customer Hungarian, Resend,
|
||||
cooldowns), the app-mail relay, customer-config + Day-0 artifact-manifest management (the checksum
|
||||
trust root the host bootstrap verifies against), assets serving, and the password-gated operator web
|
||||
UI. Package map, helpers, seams, extension points: **`REUSE.md`** (e.g. new event types must enter
|
||||
`allowedEventTypes` + `customerMessages` together).
|
||||
|
||||
## Code quality rules
|
||||
|
||||
- Always double-check generated code for bugs, logic issues, syntax errors.
|
||||
- Handle edge cases without overcomplicating.
|
||||
- Add debug capabilities (logging, verbose output).
|
||||
- If you need more input or troubleshooting output, **ask first — don't guess**.
|
||||
- Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill.
|
||||
- **Seam-wiring rule — and it covers TEMPLATE GATES (fourth inert seam, hub v0.70.1):** a feature
|
||||
is not shipped until its entry point is reachable. For UI, any conditional affordance
|
||||
(`{{if .Flag}}` around a button/form/script) ships with a render test per branch of the gate —
|
||||
handler tests that POST directly prove nothing about reachability. The v0.70.0 ghost-delete was
|
||||
fully implemented server-side and fully dead UI because the button sat inside the wrong gate.
|
||||
- **A `go test -run` pattern that matches no test prints `ok` and exits 0.** Found 2026-08-02 while
|
||||
red-proofing: `-run TestCustomerUnified` matched nothing in the target file and reported
|
||||
`ok … 0.062s`, which was read as a passing red-proof. **A red-proof that uses `-run` must first
|
||||
prove the filter matched something** (`-v` and look for `=== RUN`). This is the "an absent line is
|
||||
not evidence" rule aimed at the one place a false green costs most — the proof itself. The same
|
||||
class bit twice that day: a `| tail -5` inside a census query silently dropped rows and looked
|
||||
exactly like a real finding. **An instrument that can drop results silently is not a measurement.**
|
||||
- **A health check issues 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 the kernel's own state, never by reading
|
||||
or writing the filesystem. Measured, R-117 spike §6.3
|
||||
(`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 `EIO`s every byte — fast, and wrong.
|
||||
- UI/design work (tokens, gates, copy rules): use the `felhom-ui-design` skill.
|
||||
- **Logging**: levels/English/no-secrets rules per `documentation/runbooks/logging-conventions.md`
|
||||
(DEBUG = flow detail, INFO = state change + duration; logs are operator-tier English; keys never
|
||||
values — the hub's bundle secret-gate blocks violating pulls fail-closed).
|
||||
|
||||
## Workflow & artifacts
|
||||
|
||||
The planning/architecture assistant ("project Claude", in claude.ai) writes specs and validates
|
||||
pushes; **you (Claude Code) implement**. A file being open in the editor is NOT an instruction.
|
||||
|
||||
- **`TASK.md` / `TASK-*.md`** — a spec for you to implement. Then push and update `hub/CHANGELOG.md`
|
||||
and root `REPORT.md` per the convention below.
|
||||
- **`RUNBOOK-*.md`** — an operational procedure. CC executes the steps it has access and capability
|
||||
for, including live validation on the demo nodes and the demo Proxmox host (CC has root@felhom-pve
|
||||
SSH + the felhom-agent token). Mark a step HUMAN only when it genuinely needs physical presence, a
|
||||
real-world decision, or credentials CC truly lacks.
|
||||
- Validation of a push against a spec's criteria is project Claude's job, not yours, unless asked.
|
||||
- **Browser automation is NOT available** in the DooPlex environment (`claude-in-chrome` was a
|
||||
Windows-workstation capability). Validate at the endpoint level — invoke the exact endpoint the UI
|
||||
invokes — and via render tests; say which method was used. The hub UI is operator-password-gated
|
||||
anyway, so render tests were already the method for UI changes. Strict end-to-end UI coverage is a
|
||||
manual click-through by the operator.
|
||||
|
||||
> **In every repository where you make a change, update both files in that repo:**
|
||||
> - **`CHANGELOG.md`** — cumulative log, newest on top (here: per-area `hub/`, `scripts/`, `website/`).
|
||||
> - **`REPORT.md`** — **overwrite** with the most recent implementation/validation summary only.
|
||||
> **Parallel sessions:** `REPORT.md` is overwritten, so two sessions working in this repo at once
|
||||
> will clobber each other. The second session writes **`REPORT-<topic>.md`** instead and never
|
||||
> touches the shared `REPORT.md`.
|
||||
>
|
||||
> **Never write secrets** into any committed file — reference them as "stored out-of-band".
|
||||
|
||||
- Update `REUSE.md` if you added/changed/deprecated a shared helper or pattern (same commit).
|
||||
- **Never `git add -A` in this repo** — parallel sessions share the clone and it sweeps foreign
|
||||
WIP (the v0.47.0 `146d165` incident: a red-proof-mutated guard got swept to `main`). Stage
|
||||
explicit paths only, `git pull --rebase` before every push, and do not run two writing
|
||||
sessions on one clone (use `git worktree` if truly needed).
|
||||
|
||||
## End-of-session checklist
|
||||
|
||||
- **`CHANGELOG.md` + `REPORT.md`** per the rule above, in every repo touched.
|
||||
- **`REUSE.md`**, if a shared helper or pattern moved (same commit).
|
||||
- **The capability map** (`documentation/architecture/00-capability-map.md`), if a capability's status
|
||||
changed — with its new evidence citation.
|
||||
- **The architecture doc** that owns any changed contract (S-1, `CONTEXT.md`).
|
||||
- **Root `STATUS.md`** — *update it at the end of every session in which something shipped, broke, or
|
||||
was decided. It is a view of `documentation/backlog/OPEN-ITEMS.md` — **nothing may exist only
|
||||
there**. One screen; cut items rather than extending it.* It is written for the **operator** in
|
||||
plain language, and is deliberately **not** `CONTEXT.md` — do not consolidate the two.
|
||||
- **A finding goes in `OPEN-ITEMS.md` first**, never only in a report, an audit or `STATUS.md`. Four
|
||||
items in this project were minted in a spike doc and lost (R-153/154/155, R-156/157).
|
||||
- **Confirm your own last push's CI run went green, by run ID.** CI emails on failure, which is a
|
||||
PUSH signal — this is the PULL check that catches a lost, filtered or unread mail. Quote the run
|
||||
id and its conclusion in the session report, e.g.
|
||||
`curl -s "https://gitea.dooplex.hu/api/v1/repos/admin/<repo>/actions/tasks?limit=3"` → match the
|
||||
`head_sha` to your commit. An unchecked green is an assumption, not an observation.
|
||||
|
||||
## Tech stack (Hub)
|
||||
|
||||
- **Language:** Go (stdlib `net/http` + `html/template`, no frameworks). **DB:** SQLite via
|
||||
`modernc.org/sqlite` (pure Go). **Auth:** bcrypt + Bearer tokens + session cookies + CSRF.
|
||||
- **Deploy:** Docker on k3s (`felhom-system` ns). **Storage:** Longhorn PVC at `/data/` (SQLite DB).
|
||||
- **Config:** YAML via ConfigMap at `/etc/felhom-hub/hub.yaml`. Secrets via out-of-band
|
||||
`secretKeyRef` (never inline stringData — REUSE.md §3).
|
||||
|
||||
## Environment & access
|
||||
|
||||
Claude Code runs **on DooPlex (192.168.0.180, Debian 13, user `kisfenyo`)** — the k3s node itself.
|
||||
Repos in `/mnt/5_hdd/felhom.eu/git/`, build dirs in `/mnt/5_hdd/felhom.eu/build/`. `kubectl` and the
|
||||
image build/push are local commands; felhom-pve is one SSH hop.
|
||||
|
||||
| Host | Access | Role | Blast radius |
|
||||
|------|--------|------|--------------|
|
||||
| **DooPlex (this host)** | local — `/mnt/5_hdd/felhom.eu/{git,build}/` | Build + push images, `sudo kubectl` | **Tier 2 — precious.** It *is* the recovery chain (hub, Gitea, registry, PBS, k3s+Longhorn). **Never a drill target** |
|
||||
| Demo Proxmox host (N100) | `ssh felhom-pve` — via Tailscale `100.70.170.35` (location-independent); `felhom-pve-lan` = LAN `192.168.0.162` fallback | pveum/pct + live Proxmox validation | **Tier 0 — disposable** |
|
||||
| Demo Proxmox host (HP t740) | `ssh demo-hp` — via Tailscale `100.76.96.79`; `demo-hp-lan` = LAN `192.168.0.87` (ProxyJump `felhom-pve`). **No baked SSH key** — G1 break-glass password vaulted in the hub | **The designated drill + build VM host** (operator ruling 2026-07-25) | **Tier 0 — disposable. Reach here first** |
|
||||
|
||||
**Which box do I break?** → **`documentation/runbooks/target-selection.md`** — the tiers, and per
|
||||
machine what is freely permitted / needs care / forbidden, each with its reason. Read it before picking
|
||||
a machine for a drill, a destructive test or a throwaway VM.
|
||||
|
||||
The `felhom-pve` transport is Tailscale (the N100 is travel-portable) — topology, the accept-dns
|
||||
rule, the accept-routes spike result, rollback, and the vacation-day checklist live in
|
||||
`documentation/operations/tailscale.md`.
|
||||
|
||||
> **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 fails
|
||||
> silently). Retained in case that environment is revived.
|
||||
|
||||
## Build & deploy — Hub (GitOps via ArgoCD)
|
||||
|
||||
**Full runbook: use the `felhom-build-deploy` skill.** The load-bearing rules:
|
||||
|
||||
The whole cluster is GitOps via a **single ArgoCD app `felhom`** syncing this repo's `manifests/`
|
||||
to `felhom-system`. **Auto-sync is OFF** — deploys are a deliberate manual sync. ArgoCD's source of
|
||||
truth is the **manifest**:
|
||||
|
||||
- **A code change + CHANGELOG bump deploys NOTHING.** The running image changes only when
|
||||
`manifests/hub.yaml`'s `image:` tag changes in git and the app is synced.
|
||||
- **Pin explicit versions, never `:latest`.** Never bare `kubectl set image`/`kubectl apply`
|
||||
(reverted on next sync).
|
||||
- **The live image can lag the CHANGELOG** when a bump was committed but the manifest/sync step never
|
||||
happened — reconcile via the manifest, not the changelog.
|
||||
- Green gate before any hub commit: `go build ./... && go vet ./... && go test ./...` in `hub/`.
|
||||
|
||||
> **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).
|
||||
|
||||
Steps: commit+push code → `cd /mnt/5_hdd/felhom.eu/build/felhom-hub && ./build.sh <VER> --push`
|
||||
(local) → bump `manifests/hub.yaml` tag + push → ArgoCD hard-refresh + sync (kubectl-patch method in
|
||||
the skill, now local `sudo kubectl`) → verify Synced/Healthy + rollout + image + startup log.
|
||||
|
||||
## Gates — ONE entry point
|
||||
|
||||
**Run `python3 scripts/repo_gates.py` after ANY change in this repo.** It is the one entry point
|
||||
and runs every gate — `site_gates.py`, `hostinstall_gates.py`, `hub_confirm_gate.py`,
|
||||
`manifest_bearer_gate.py` and `reuse_refs_check.py` on this root — streaming each gate's own output
|
||||
and exiting non-zero if any fails. `--fast` selects only 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 five 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 — one since 14 July. The single-entry-point shape is the only one that
|
||||
demonstrably gets run here; `app-catalog-felhom.eu/scripts/catalog_gates.py` is the canonical
|
||||
version of it (R-161) and `repo_gates.py` copies it. `site_gates.py` is a *gate*, not a runner —
|
||||
do not model new work on it.
|
||||
|
||||
**The pre-push hook.** `.githooks/pre-push` runs `repo_gates.py --fast` and refuses the push if it
|
||||
fails. It is **per-clone** and switched on once with `git config core.hooksPath .githooks` — a
|
||||
clone does not carry it, and any manual `repo_gates.py` 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 continuous integration is still owed (`OPEN-ITEMS.md` R-168) — this hook is
|
||||
local and skippable, and only CI is neither.
|
||||
|
||||
## Build & deploy — Website / Manifests
|
||||
|
||||
- **Website** auto-deploys via git-sync; just push to `main` (live in 1–2 min). Website changes go
|
||||
through `repo_gates.py` above (it runs `site_gates.py`); new pages go into that gate's `PAGES`
|
||||
list. Emergency edits: https://files.felhom.eu. All `website/` HTML is **UTF-8 with BOM** — preserve it.
|
||||
- **Manifests** are GitOps via the `felhom` app — commit to `main`, then deliberate sync.
|
||||
|
||||
## Key patterns
|
||||
|
||||
- Hub status logic: OK (report < 30m), WARN (30m–1h or health=warn), DOWN (> 1h or health=fail);
|
||||
host liveness thresholds shared between UI and checker (never invent a second definition).
|
||||
- SQLite timestamps vary in format — always `parseSQLiteTime()`.
|
||||
- Dashboard/detail auto-refresh every 60s via meta refresh. Geo-restricted to Hungary via nginx
|
||||
ingress annotation.
|
||||
- Helpers, seams, extension points, traps: **`REUSE.md`** — the map is maintained same-commit.
|
||||
@@ -0,0 +1,229 @@
|
||||
# felhom.eu
|
||||
|
||||
Website, manifests, and infrastructure for **Felhőm.eu** — a managed home-server service for Hungarian households.
|
||||
|
||||
## Overview
|
||||
|
||||
This repository contains:
|
||||
- **Website** (`website/`) — Static HTML pages served at [felhom.eu](https://felhom.eu)
|
||||
- **Kubernetes manifests** (`manifests/`) — All k3s deployments for the felhom.eu ecosystem
|
||||
- **Assets** (`website/assets/`) — Logo, images, OG images
|
||||
|
||||
The website runs on a single-node k3s cluster alongside the rest of the Felhőm management infrastructure (Healthchecks, Umami analytics, contact mailer).
|
||||
|
||||
## Branding
|
||||
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| **Brand name** | Felhőm.eu (with accent: ő) |
|
||||
| **Domain** | felhom.eu (without accent — domain limitation) |
|
||||
| **Tagline** | „Saját felhőd, saját szabályaid" |
|
||||
| **Controller product** | Felhő Felügyelő (customer-facing name) |
|
||||
| **Controller code name** | felhom-controller (backend/repo/container) |
|
||||
| **Language** | Hungarian throughout all customer-facing content |
|
||||
| **Contact email** | info@felhom.eu |
|
||||
| **Admin email** | admin@felhom.eu |
|
||||
|
||||
### Why "Felhőm"?
|
||||
"Felhő" means "cloud" in Hungarian. The "m" suffix makes it possessive — "my cloud" (felhőm). The `.eu` domain is part of the brand identity and appears in the logo. The double meaning of "felhő" (tech cloud + weather cloud) is intentional and used in product naming (e.g., Felhő Felügyelő = "Cloud Supervisor/Inspector").
|
||||
|
||||
## Website Pages
|
||||
|
||||
| File | URL | Purpose |
|
||||
|------|-----|---------|
|
||||
| `index.html` | `/` | Landing page — hero, services, app preview, backup intro, contact |
|
||||
| `alkalmazasok.html` | `/alkalmazasok` | Full application catalog (45+ apps with categories) |
|
||||
| `technologiak.html` | `/technologiak` | Technology stack explanation (Docker, Felhő Felügyelő, Proxmox, Kubernetes) |
|
||||
| `biztonsagimentes.html` | `/biztonsagimentes` | Backup strategy — 3-2-1 rule, monitoring, restore procedures |
|
||||
| `gyik.html` | `/gyik` | FAQ — structured Q&A with JSON-LD schema |
|
||||
| `kapcsolat.html` | `/kapcsolat` | Contact form + email, sends via contact-mailer API |
|
||||
| `szolgaltatasok-nonpublic.html` | `/szolgaltatasok-nonpublic` | Pricing/services page (not linked in nav, robots disallowed) |
|
||||
|
||||
All pages use:
|
||||
- **Clean URLs** — nginx serves `.html` files without extension (`/gyik` → `gyik.html`)
|
||||
- **Unified CSS** — each page contains the full CSS (no external stylesheet, for simplicity)
|
||||
- **Responsive design** — mobile hamburger menu, responsive grids
|
||||
- **UTF-8 with BOM** — all HTML files are saved as UTF-8-BOM for Hungarian character support
|
||||
- **Umami analytics** — privacy-friendly tracking script on every page
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
Cloudflare (DNS only, no proxy)
|
||||
│
|
||||
▼ CNAME → dooplex.hopto.org
|
||||
│
|
||||
Home network (dynamic IP via No-IP DynDNS)
|
||||
│
|
||||
▼ Port forward 80/443
|
||||
│
|
||||
k3s cluster (single node)
|
||||
├── nginx-ingress (TLS termination via cert-manager + Let's Encrypt)
|
||||
│
|
||||
├── felhom-system namespace:
|
||||
│ ├── felhom-webpage (nginx + git-sync sidecar)
|
||||
│ ├── filebrowser (files.felhom.eu — website file management)
|
||||
│ ├── contact-mailer (Go app — /api/contact endpoint)
|
||||
│ ├── umami + umami-db (stats.felhom.eu — web analytics)
|
||||
│ └── healthchecks (status.felhom.eu — monitoring)
|
||||
│
|
||||
└── cert-manager (letsencrypt-prod cluster issuer)
|
||||
```
|
||||
|
||||
### Kubernetes Manifests
|
||||
|
||||
| Manifest | Services | Subdomains |
|
||||
|----------|----------|------------|
|
||||
| `webpage.yaml` | nginx (website), FileBrowser, git-sync | felhom.eu, www.felhom.eu, files.felhom.eu |
|
||||
| `contact-mailer.yaml` | Go HTTP server for contact form | felhom.eu/api/* (path-based routing) |
|
||||
| `umami.yaml` | Umami v3 + PostgreSQL | stats.felhom.eu |
|
||||
| `healthchecks.yaml` | Healthchecks | status.felhom.eu |
|
||||
|
||||
### Website Deployment
|
||||
|
||||
The website uses a **git-sync sidecar** pattern:
|
||||
|
||||
1. `git-sync` container polls this repository (sparse checkout: `/website/` only)
|
||||
2. Syncs to a shared `emptyDir` volume
|
||||
3. `nginx` container serves from the synced content
|
||||
4. Changes pushed to this repo are live within minutes (no manual deployment)
|
||||
|
||||
**FileBrowser** at `files.felhom.eu` provides a web UI for quick edits to website files (emergency fixes, asset uploads) without needing git. It writes to a Longhorn PVC that the website nginx also reads from.
|
||||
|
||||
### Storage
|
||||
|
||||
All persistent data uses **Longhorn** distributed storage:
|
||||
- `filebrowser-files` (1Gi, ReadWriteMany) — website files
|
||||
- `filebrowser-db` (100Mi) — FileBrowser SQLite database
|
||||
- `umami-db-data` (2Gi) — Umami PostgreSQL data
|
||||
- `healthchecks-data` (1Gi) — Healthchecks SQLite data
|
||||
|
||||
## DNS Configuration (Cloudflare)
|
||||
|
||||
Domain: `felhom.eu` — Cloudflare DNS (free plan), **DNS only mode** (no proxy/orange cloud).
|
||||
|
||||
### Records
|
||||
|
||||
| Type | Name | Content | Notes |
|
||||
|------|------|---------|-------|
|
||||
| CNAME | `felhom.eu` | dooplex.hopto.org | Main website |
|
||||
| CNAME | `www` | dooplex.hopto.org | www redirect |
|
||||
| CNAME | `files` | dooplex.hopto.org | FileBrowser |
|
||||
| CNAME | `stats` | dooplex.hopto.org | Umami analytics |
|
||||
| CNAME | `status` | dooplex.hopto.org | Healthchecks |
|
||||
| CNAME | `ntfy` | dooplex.hopto.org | Push notifications |
|
||||
| MX | `felhom.eu` | route{1,2,3}.mx.cloudflare.net | Incoming email → Cloudflare Email Routing |
|
||||
| MX | `send` | feedback-smtp.eu-west-1.amazonses.com | Resend sending domain |
|
||||
| TXT | `felhom.eu` | `v=spf1 include:_spf.mx.clo...` | SPF for Cloudflare |
|
||||
| TXT | `send` | `v=spf1 include:amazonses...` | SPF for Resend |
|
||||
| TXT | `cf2024-1._domainkey` | DKIM for Cloudflare Email Routing | |
|
||||
| TXT | `resend._domainkey` | DKIM for Resend | |
|
||||
| TXT | `_dmarc` | `v=DMARC1; p=none;` | DMARC policy |
|
||||
| TXT | `felhom.eu` | `google-site-verification=...` | Google Search Console |
|
||||
|
||||
## Email
|
||||
|
||||
### Incoming Email
|
||||
|
||||
**Cloudflare Email Routing** (free) handles all incoming mail:
|
||||
- `info@felhom.eu` → forwarded to personal Gmail
|
||||
- `admin@felhom.eu` → forwarded to personal Gmail
|
||||
- Catch-all → not configured
|
||||
|
||||
### Outgoing Email (Transactional)
|
||||
|
||||
**Resend** (free tier) handles outgoing email via API:
|
||||
- Contact form submissions → sends formatted email to `info@felhom.eu`
|
||||
- Healthchecks alerts → sends to `admin@felhom.eu`
|
||||
- Sending domain: `send.felhom.eu` (verified with SPF, DKIM)
|
||||
- From address: `Felhom.eu <info@felhom.eu>`
|
||||
|
||||
### Contact Form Flow
|
||||
|
||||
1. User fills form on `/kapcsolat`
|
||||
2. JavaScript POST to `/api/contact`
|
||||
3. `contact-mailer` (Go, in k3s) validates + calls Resend API
|
||||
4. Email delivered to `info@felhom.eu` via Resend → Cloudflare Email Routing → Gmail
|
||||
|
||||
## SEO
|
||||
|
||||
### Google Search Console
|
||||
|
||||
- Property: `https://felhom.eu`
|
||||
- Verified via DNS TXT record
|
||||
- Sitemap submitted: `https://felhom.eu/sitemap.xml`
|
||||
- 7 pages indexed (all public pages)
|
||||
|
||||
### On-Page SEO
|
||||
|
||||
Every page includes:
|
||||
- `<title>` with Hungarian keywords + brand
|
||||
- `<meta name="description">` with unique content per page
|
||||
- `<meta name="keywords">` with relevant Hungarian terms
|
||||
- `<link rel="canonical">` to prevent duplicate content
|
||||
- **Open Graph** tags (og:title, og:description, og:image, og:locale=hu_HU)
|
||||
- **Twitter Card** tags (summary_large_image)
|
||||
- **JSON-LD** structured data (LocalBusiness on index, Article on technologiak, FAQPage on gyik)
|
||||
|
||||
### Technical SEO
|
||||
|
||||
- `robots.txt` — allows all, disallows `/szolgaltatasok-nonpublic`, includes sitemap URL
|
||||
- `sitemap.xml` — lists all 6 public pages with priority + changefreq
|
||||
- Clean URLs (no `.html` extensions)
|
||||
- Static asset caching (7 day expiry for CSS/JS/images)
|
||||
- Security headers (X-Frame-Options, X-Content-Type-Options)
|
||||
|
||||
## Analytics
|
||||
|
||||
**Umami v3** (self-hosted, privacy-focused):
|
||||
- Dashboard: `https://stats.felhom.eu`
|
||||
- Tracking script: `<script defer src="https://stats.felhom.eu/script.js" data-website-id="d419db57-...">`
|
||||
- Cookie-free, GDPR compliant — no consent banner needed
|
||||
- Backend: dedicated PostgreSQL instance in k3s
|
||||
|
||||
## Monitoring
|
||||
|
||||
**Healthchecks** (self-hosted):
|
||||
- Dashboard: `https://status.felhom.eu`
|
||||
- Monitors backup jobs, service health
|
||||
- Sends email alerts via Resend when checks fail
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Quick content edits
|
||||
1. Log into FileBrowser at `https://files.felhom.eu`
|
||||
2. Edit HTML files directly
|
||||
3. Changes are live immediately
|
||||
|
||||
### Standard workflow
|
||||
1. Clone this repo from Gitea (`gitea.dooplex.hu`)
|
||||
2. Edit files locally
|
||||
3. Push to `main` branch
|
||||
4. `git-sync` sidecar picks up changes automatically (~1-2 min)
|
||||
|
||||
### Adding a new page
|
||||
1. Create `website/newpage.html` (copy structure from existing page)
|
||||
2. Add to navigation in all pages' `<nav>` section
|
||||
3. Add to `sitemap.xml` with appropriate priority
|
||||
4. Push — clean URLs handle `/newpage` automatically
|
||||
|
||||
## Related Repositories
|
||||
|
||||
| Repository | Purpose |
|
||||
|------------|---------|
|
||||
| [app-catalog-felhom.eu](https://gitea.dooplex.hu/admin/app-catalog-felhom.eu) | Docker Compose templates + .felhom.yml metadata for 45+ apps |
|
||||
| [felhom-controller](https://gitea.dooplex.hu/admin/felhom-controller) | felhom-controller Go app + customer deploy scripts |
|
||||
| [deploy-portainer](https://gitea.dooplex.hu/admin/deploy-portainer) | Legacy — Portainer-based deploy scripts (deprecated) |
|
||||
| [homelab-manifests](https://gitea.dooplex.hu/admin/homelab-manifests) | k3s cluster manifests for dooplex.hu services |
|
||||
| [misc-scripts](https://gitea.dooplex.hu/admin/misc-scripts) | Utility scripts (collect-repos.sh, etc.) |
|
||||
|
||||
## File Encoding
|
||||
|
||||
All HTML files in `website/` are **UTF-8 with BOM** (byte order mark). This ensures proper Hungarian character rendering (á, é, í, ó, ö, ő, ú, ü, ű) across all tools and platforms. The BOM is the 3-byte sequence `EF BB BF` at the start of each file.
|
||||
|
||||
When editing files, ensure your editor preserves UTF-8-BOM encoding. VS Code: check "UTF-8 with BOM" in the bottom status bar.
|
||||
@@ -0,0 +1,113 @@
|
||||
# REPORT — Campaign 10, two-storage adversarial soak (2026-07-31)
|
||||
|
||||
Follows `REPORT-campaign7/8/9.md`. Root `REPORT.md` is another session's (hub v0.85.0) and was not
|
||||
clobbered — same shared-clone reasoning as `REPORT-iso-release.md`.
|
||||
|
||||
**Full audit + evidence:** `documentation/audits/CAMPAIGN-10-two-storage-soak-2026-07-31.md`,
|
||||
`documentation/tests/campaign10-evidence-2026-07-31/`.
|
||||
|
||||
## The sentence that matters
|
||||
|
||||
**Phase A passed every gate. Phase B ran to 39 consecutive cycles with the full atom set — past the
|
||||
"drift at the thirty-eighth" depth the brief asked for. 66 restores, 66 correct discriminators. No
|
||||
resource leak over 13.5 h. Two findings: R-156 and R-157.**
|
||||
|
||||
## What was established
|
||||
|
||||
- **Venue** — VM 311 on demo-hp (Tier 0), 200 G system + 2 × 50 G data, scratch storage at the
|
||||
`/mnt/nvme-1tb` **mount root** (a subdirectory would have emitted `storage_disconnected` for demo-hp
|
||||
all night — the exact signal I1/I2 discriminate).
|
||||
- **Baselines, all read fresh.** controller `main` 0.188.0, **golden 0.188.0 (not behind)**, agent
|
||||
0.119.0 published+vouched, hub 0.86.0, ISO **1.26.1** (`f3cc86d5…`, round-trip verified live).
|
||||
The brief's ISO assumption (v1.25.0) was ~90 minutes stale; its "no baked SSH key" claim is R-129.
|
||||
- **Isolation gate — both denials captured, each with a positive control.** The PBS control **failed
|
||||
first**: four clean-looking 403s were worthless because the token was denied on its own datastore
|
||||
too (PBS token privilege separation). Fixed, re-run, denials stand.
|
||||
- **A1** fresh install from the **published** ISO. 1.26.1 is a public release image — verified against
|
||||
its bytes that it has no auto-install path — so it was driven blind via screendump + `sendkey`
|
||||
through the Terminal UI. Caught the Hungarian-keymap trap before typing the root password, which
|
||||
would otherwise have been mangled and locked the box out.
|
||||
- **A2** claimed for real; discriminator flipped `dashboard not yet claimed` → `authentication required`.
|
||||
- **A3** both drives enrolled through the **real** endpoint; `mentes` accepted as backup target via
|
||||
the offer flow, ending `degraded:false / target:felhom-backup` — the I5/I6 healthy baseline.
|
||||
Four apps healthy spanning both sides of D5's split (4 × `type: secret`, 1 × `type: password`).
|
||||
- **A4** discriminators seed and read back across all four apps; rallly's over the path
|
||||
`DATABASE_URL` actually names, not the trusted socket that produced D5's false pass.
|
||||
|
||||
## Phase B — the soak
|
||||
|
||||
Three passes: run 1 (27 cycles, 6 atom families, 0 violations), run 2a (10 cycles, **stopped
|
||||
deliberately** — two violations were harness defects), run 2b (**39 cycles, 12 atom families**).
|
||||
1 461 invariant checks in total.
|
||||
|
||||
- **I7 is the headline: 66 restores across both passes, 66 correct discriminators.** Never stale,
|
||||
never empty. Run 2b added an `I7-SKIP` verdict so a check with unmet preconditions is recorded as
|
||||
skipped rather than silently green — it fired once.
|
||||
- **I2, I3, I4, I5, I6, I10, I11: zero violations in either pass**, including the abort-in-place
|
||||
variants and 330 secret-class assertions covering both sides of D5's split.
|
||||
- **`I1-under-load` 5/5**: the backup target pulled **while a backup was running** still produced
|
||||
`backup_target_absent` and a clean recovery.
|
||||
- **R-117's Q7 case holds** — a filesystem aborted *in place* (device still present) surfaces via
|
||||
`bound_under_parent=false`, the gate stops the app on the dead namespace, and the storage page names
|
||||
it. That is the case R-117's spike called "the worse half".
|
||||
- **RTO, both bands measured.** S: 66 MB → **42.0 s** / **41.4 s** across two passes (66 restores).
|
||||
M: 21.1 GB → **608 s** mean over 2 reps, both returning the correct discriminator. 327× the data
|
||||
cost 14.5× the time, giving **RTO ≈ 40 s + 26.9 s/GB** (backup ≈ 29 s + 17.4 s/GB). The fixed ~40 s
|
||||
dominates below ~1.5 GB — that is the S band, and why its numbers clustered so tightly.
|
||||
- **Capacity ceiling, and the more consequential result:** a DB-backed app's recovery unit is **1.90×**
|
||||
its data (volume tar + SQL dump). The default `/mnt/sys_drive` is **20 GB**, so on a default box the
|
||||
largest locally-backupable app is **≈ 10 GB** — **the M band does not fit at all** without a
|
||||
per-customer `SysDataGrowGB`. **RPO still not measured.**
|
||||
- **No resource leak.** 9 457 samples of 19 metrics over 13.5 h: controller and agent RSS flat, fds
|
||||
flat, and **no orphaned volumes, images or containers** despite dozens of redeploys, kills, reboots
|
||||
and hard resets. The only curve with real slope is the **agent journal, 194 → 463 MB (~20 MB/h)** —
|
||||
bounded by journald, but a lot of logging.
|
||||
- Every atom and invariant was **proven by hand before automation**; the runner asserts nothing that
|
||||
was not first observed live.
|
||||
- **A Phase A gap was caught before the run:** no app had `HDD_PATH`, so all data sat on the system
|
||||
disk and I3 could never have fired. calibre-web was deployed onto `adatok` first — otherwise the
|
||||
soak would have produced green cycles that tested nothing cross-drive.
|
||||
- **Two violations were my harness, not the product**, and run 2a was stopped for them: a seed that
|
||||
never landed became a fake "stale restore", and a real one would have looked identical. Fixed and
|
||||
red-proofed before restarting.
|
||||
|
||||
## Findings
|
||||
|
||||
- **R-156 (new)** — **papra's data is neither persisted nor backed up, and it reports healthy.** The
|
||||
template mounts `papra_data:/app/data`; the app writes `/app/app-data/db/db.sqlite`. The volume is
|
||||
empty and root-owned (the image is `-rootless`, so the app cannot even write there), the real DB
|
||||
sits in the container's writable layer, and the healthcheck only probes the HTTP port. Its
|
||||
Tier-1/Tier-2 backup is real, verifiable, and contains nothing. Not fixed.
|
||||
- **R-157 (new)** — **bootrecon's start-once sweep misses the boot orphan it exists to recover.** Two
|
||||
mechanisms. **A:** the container is left `Exited`, the sweep runs ~5 s after controller start while
|
||||
docker is still restoring, sees "no boot-orphaned apps", and never re-checks (3 occurrences,
|
||||
intermittent ~50%). **B:** the interruption leaves the stack with **zero containers**, which is
|
||||
exactly the signature bootrecon deliberately skips as a user's Stop — and in that state the deadapp
|
||||
check reported **`0 currently down`** while a `deployed: true` app was not running, i.e. silent on
|
||||
every channel. A settle-condition fix closes A and leaves B open. Not fixed.
|
||||
- **Tier 3 could not be isolated, so it was not run.** Offsite hard-requires the DR tier
|
||||
(`configs.go:1300`), and the DR tier only provisions on ep0 (per-endpoint allocation deferred,
|
||||
`hub/README.md:260`). Both are recorded deliberate positions, so **no R-n minted**. The campaign
|
||||
therefore touched neither ep0 nor the Storage Box — stronger isolation than asked for, obtained by
|
||||
not running the tier. Cost: all Tier-3 atoms, I8, and the Tier-3 RTO/RPO rows.
|
||||
|
||||
## What did not run
|
||||
|
||||
**12 of the brief's ~13 atom families ran** (run 1 covered 6; run 2b added abort-fs-in-place,
|
||||
kill-agent-mid-backup, hard-reset-mid-write, reboot-VM, both concurrency atoms and fill-drive).
|
||||
Previously reported as 6 of 12 — that was run 1 only.
|
||||
|
||||
**Superseded detail:** Still not run: **Tier-3 backup/restore** (§3, structurally un-isolatable) and **I8**. **I9** was not
|
||||
automated — cited from the tester-gate run on this same controller 0.188.0, not re-claimed.
|
||||
`kill_controller` is still not literally "mid-backup"; the dedicated concurrent backup+detach atom
|
||||
covers that case properly. The run-1 flaw where `reboot` never interleaved with a detach was fixed.
|
||||
|
||||
**Depth reached: 39 consecutive cycles**, past the brief's "thirty-eighth", with c34–c39 clean on
|
||||
every invariant. Beyond 39 is untested, not proven clean.
|
||||
|
||||
## Teardown — OWED, nothing removed
|
||||
|
||||
Still intact: the rig is reusable for the atoms that did not run. VM 311, `c10-scratch`, PBS datastore
|
||||
`felhom-c10` + user/token, restic subaccount `u629488-sub4`, and **hub customer `c10-soak` (disposition:
|
||||
DELETE)** are all outstanding, with commands in the audit §9. Named explicitly because R-131 is four
|
||||
orphaned scratch customers left by exactly this omission.
|
||||
@@ -0,0 +1,43 @@
|
||||
# REPORT — CAMPAIGN 7 (felhom.eu side: docs only)
|
||||
|
||||
> Written as `REPORT-campaign7.md`, **not** the shared `REPORT.md`, per the convention this run
|
||||
> added to `CLAUDE.md`: `REPORT.md` is overwritten, so a second concurrent session in this repo
|
||||
> would clobber it. This session's implementation work was in `app-catalog-felhom.eu`; here it only
|
||||
> touched documentation.
|
||||
|
||||
**Run:** 2026-07-18 evening → 2026-07-19 morning. **Class:** campaign (record-and-rank + a defined
|
||||
allowed-fix set). **Implementation repo:** `app-catalog-felhom.eu` (see its `REPORT.md`).
|
||||
|
||||
## What changed in this repo
|
||||
|
||||
| file | change |
|
||||
|---|---|
|
||||
| `documentation/audits/CAMPAIGN-7-catalog-sweep-2026-07-19.md` | **new** — method, uninstall-semantics map, trio detail, full 53-app matrix, ranked findings, coverage |
|
||||
| `documentation/backlog/ROADMAP.md` | **+3 items** — R-40 (multi-hop major upgrade path), R-41 (no standing catalog deployability check), R-42 (sidecar-major ruling) |
|
||||
| `CLAUDE.md` | REPORT.md parallel-session rule: the second session writes `REPORT-<topic>.md` |
|
||||
|
||||
No hub/agent/scripts/website code was touched (campaign scope: catalog + docs).
|
||||
|
||||
## Headline for this repo's readers
|
||||
|
||||
1. **Uninstall semantics map row PARTIAL → PROVEN** (campaign doc §2), with live evidence from all
|
||||
three trio apps: remove requires stop first; named docker volumes are **always destroyed**
|
||||
(including the app's database); HDD bind-mount data and `backups/primary/<app>` survive unless
|
||||
explicitly ticked; images are kept; `app.yaml` goes, the template stays; the per-app **offsite
|
||||
toggle survives** the uninstall while tier-2 config is cleared. The confirmation modal does warn
|
||||
about the volumes, so there is **no consent gap**.
|
||||
2. **A lying healthcheck takes an app OFF-LINE, it does not merely mislead.** Traefik will not route
|
||||
to an `unhealthy` container, so a probe that cannot execute → permanent unhealthy → **404 to the
|
||||
customer while the app serves 200 on its own port**. 7 of 53 apps were in that state.
|
||||
3. **The pre-flight gate's own signal is missing:** the 0.145.0 → 0.146.0 floor-lift emitted no
|
||||
`controller_updated` event, though the identical bootstrap path emitted one for 0.143.0 → 0.145.0
|
||||
two hours earlier (§0, finding F1). The box did converge — golden, floor and runtime all agreed —
|
||||
but the event trail under-reports version transitions.
|
||||
|
||||
## Open items owned outside this repo
|
||||
|
||||
- **plant-it / wanderer** — images do not resolve at all (neither the new tag nor the one the
|
||||
catalog already ships). Upstream research needed; recorded as findings, not deletions.
|
||||
- **gokapi** — pinned back to v1.9.6; v2 needs the seeded `config.json` regenerated. Security-
|
||||
relevant, should not sit on a superseded line indefinitely.
|
||||
- **glance** — never had a seeded `glance.yml`; proven pre-existing.
|
||||
@@ -0,0 +1,62 @@
|
||||
# REPORT — CAMPAIGN 8: the backup & restore subsystem (2026-07-27/28)
|
||||
|
||||
Adversarial, destructive, unattended run against `demo-felhom`, `demo-hp` and `ep0`.
|
||||
Full report: `documentation/audits/CAMPAIGN-8-backup-restore-2026-07-27.md`.
|
||||
Evidence: `DooPlex:~/campaign8/evidence/` (103 files, 35 MB, written continuously by 11 collectors).
|
||||
|
||||
**No production code was changed.** Findings are recorded and ranked, never fixed inline, per the
|
||||
campaign's own rules.
|
||||
|
||||
## Scope safety
|
||||
`peti-felhom`, its namespace and `u629488-sub2` were never touched. Phase 0 established with five
|
||||
documented probes that peti has **no data at all** in `felhom-offsite`, which is what made the
|
||||
operator-approved 100% datastore-fill safe. The 13 GB rollback copy `/srv/pbs-felhom` on ep0 is
|
||||
intact.
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Finding | Severity | Class |
|
||||
|---|---|---|---|
|
||||
| F-CRIT-1 | An app that fails to restart after a quiesce **never alarms**, on any channel | **HIGH** | DEFECT |
|
||||
| F-CRIT-2 | A failed offsite backup leaves a phantom snapshot that **resets tier freshness** (up to 7 days silent on real cadences) | **HIGH** | DEFECT |
|
||||
| F-A1 | A restore-test in progress makes a healthy backup report as FAILED, arms the breaker, pages the operator | MEDIUM | DEFECT (behaviour) / ARTIFACT (frequency) |
|
||||
| F-HUB | The hub dropped an event under concurrent load (`SQLITE_BUSY`), no retry, cause unnamed | MEDIUM | DEFECT |
|
||||
| F-LEAK | A **failed** restore-test cannot destroy its own scratch guest (403 `VM.Allocate`); leaks are never reclaimed | MEDIUM | DEFECT (root-caused by fault 18) |
|
||||
| F-REBOOT | A guest rebooted during its backup **does not come back** — shutdown completes, start never happens, no self-heal | MEDIUM | DEFECT |
|
||||
| F-DIAG | Four distinct offsite failure causes collapse into two operator-visible strings | LOW–MED | DEFECT |
|
||||
| F-OBS | `deadapp-check` leaves no positive observable on a default (info-level) box | LOW | DEFECT |
|
||||
| F-OPS | A manual `pct restore` inherits the source guest's binds (live data drive + another guest's credentials) | LOW | Operational |
|
||||
|
||||
Both HIGH findings are in the same place: **the system's ability to tell you a backup did not
|
||||
happen.** Both cite the code and the comment that asserts the property the code does not provide.
|
||||
|
||||
## What is now proven that was not before
|
||||
R-88 breaker arming **and its full ladder** (15m/30m/1h/2h/4h/4h-cap) · per-tier isolation under a
|
||||
real one-tier-fails case · `whole_guest_backup_failed` end-to-end with correct tier attribution ·
|
||||
R-97c operator-only routing verified against the hub DB (zero customer rows with `status='sent'`) ·
|
||||
`whole_guest_backup_recovered` + the R-68 pairing gate firing live · **`age_state=absent`** ·
|
||||
R-97b's suppression half · the crash-recovery unquiesce by an actual SIGKILL (1 s) ·
|
||||
**R-87 — the first restic restore round-trip ever performed**, byte-verified (6/7 sha256 identical,
|
||||
the 7th explained) · R-82 one-quiesce-two-tiers · single-flight on two independent paths.
|
||||
|
||||
## Restore round-trips
|
||||
restic (R-87) · local vzdump → fresh CT · PBS offsite → fresh CT · corrupted snapshot → fails
|
||||
cleanly. `mount_parity` exact on both whole-guest tiers, `unprivileged: 1` preserved.
|
||||
|
||||
## Fleet state
|
||||
**Healthy. Nothing left broken.** All four compression knobs reverted and verified; every fault
|
||||
unwound; no leaked scratch guests, nft rules, ballast files or clock skew; ep0 datastore clean with
|
||||
zero `.bad` chunks. demo-felhom 15/15 containers healthy, demo-hp 8/8.
|
||||
|
||||
## Not tested (with reasons)
|
||||
Fault 4 (restic transport — four injection approaches defeated by guest-bridged networking; **the
|
||||
most valuable follow-up**, because F-CRIT-2 raises the same question for restic), fault 12 (host
|
||||
reboot — reasoned skip), and the agent's own DR bring-up path. Faults 6 and 8 were inconclusive for
|
||||
documented reasons. Faults 11 and 18 WERE run in the campaign's tail and both produced findings.
|
||||
|
||||
**Campaign-caused outage, stated plainly:** fault 11 took demo-hp guest 9201 down for ~9m47s
|
||||
(the guest did not restart after a mid-backup reboot) until manually started. Fleet healthy after.
|
||||
|
||||
## Note on repo conventions
|
||||
This run touched no `hub/`, `scripts/` or `website/` code, so none of the per-area CHANGELOGs has an
|
||||
entry — there is nothing shipped to log. The deliverable is the audit document plus this report.
|
||||
@@ -0,0 +1,85 @@
|
||||
# REPORT — CAMPAIGN 9: the restore paths, proven (2026-07-28)
|
||||
|
||||
**Overwritten** per the standing rule. **No production code shipped** — this was a proof campaign,
|
||||
and findings are recorded, never fixed inline. Full write-up:
|
||||
`documentation/audits/CAMPAIGN-9-restore-proof-2026-07-28.md`.
|
||||
Evidence: `DooPlex:~/campaign9/evidence/` (69 files, 221 MB, 7 collectors, written continuously).
|
||||
|
||||
Fleet unchanged and healthy at close: hub v0.80.0, agent v0.110.0, controller v0.182.0 on both boxes.
|
||||
**`peti-felhom` was never touched.** The ep0 rollback copy `/srv/pbs-felhom` (13 G) is intact.
|
||||
|
||||
## The headline — two never-proven restore paths are now proven
|
||||
|
||||
Driven through the **real endpoints the UI posts to**, over https through traefik with a real session
|
||||
and CSRF token, on live hardware.
|
||||
|
||||
| proof | result |
|
||||
|---|---|
|
||||
| **A1** — Tier-2 restore of ordinary app data (`paperless-ngx`, demo-hp) | 6 deleted files back **byte-identical** (`sha256sum -c` all OK) |
|
||||
| A1 — „A meglévő fájlok NEM módosulnak és NEM törlődnek" | 2 created files survived; 1 locally-edited file **not overwritten** (edit marker intact) |
|
||||
| A1 — app stopped/restarted and healthy | stop→copy→start in 39 s, `paperless-webserver` healthy |
|
||||
| A1 — data **usable by the app**, not just on disk | paperless resolved all 3 docs, checksums matched its own DB, and **served the restored bytes over its own HTTP API** at the exact pre-deletion sha256 |
|
||||
| **A2** — Tier-1 recovery-unit restore is a **distinct** path | `POST /backup/restore` → `RestoreFromRecoveryUnit`; ran end-to-end in 18 s, 1 volume restored, app healthy |
|
||||
| **A3** — restore after **total loss** (whole appdata dir `rm -rf`) | loss proven by doc download going **200 → 404**; restore returned **43/43 files byte-identical**, `documents_ok 16 of 16`, downloads back to 200 |
|
||||
|
||||
The honest boundary A1+A3 together establish: **existing files are untouched; destroyed files return
|
||||
at their last-backup state.**
|
||||
|
||||
## Findings — 3 defects, ranked (none fixed)
|
||||
|
||||
| # | finding | severity |
|
||||
|---|---|---|
|
||||
| **C9-F1** | The Tier-2 restore button is offered for apps it can **never** restore (BookStack, Docmost). It takes a real app outage, restores 0 files, and reports „Nincs hiányzó fájl — minden fájl megvan a helyén." — while 156 MB of that app's data sits unread in the same copy | **HIGH** |
|
||||
| **C9-F2** | An app in a **crash loop never alarms on any channel**. `StateRestarting` is in no down-set, so the dead-app heartbeat printed *"180 scans … 0 currently down"* while the app had been looping for 9 minutes | **HIGH** |
|
||||
| **C9-F3** | An **interrupted offsite run** leaves an exclusive restic lock the existing self-heal cannot reach; the tier is dead until a human unlocks, and the operator is told *"unknown reason"* | **MEDIUM** |
|
||||
|
||||
Two things were deliberately **not** filed as defects: a recovery-unit poisoning that the catalog
|
||||
sync self-healed within ~3 minutes (proven live — reporting it would have been reporting an
|
||||
artifact), and a `snapshot_id` that looked ignored but is documented as logging-only and confirmed
|
||||
so live.
|
||||
|
||||
## Mechanisms confirmed working, live
|
||||
|
||||
R-82's one-quiesce rule under mixed outcomes (2 tiers due, apps stopped **once**, per-target
|
||||
breaker); R-88's breaker (edge-triggered, one WARN, one event, three silent DEBUG skips, **no app
|
||||
thrash**); F-A1's contention deferral (409 → no breaker, no event, prompt restart — both sides of
|
||||
the seam captured in the same second); **F-CRIT-2's size filter against a real 1-byte phantom** on
|
||||
demo-hp, confirmed independently on ep0's filesystem; R-100's success anchor twice; **F-DIAG's
|
||||
sanitiser on the exact bare-hostname case that defeated its first version** (nothing raw reaches the
|
||||
hub event or the report); F-OBS's positive observable — which is precisely what made C9-F2 provable;
|
||||
F-LEAK's fenced destroy (no leaked `990000` guests across ~10 restore-tests).
|
||||
|
||||
## Where it stopped, and what remains
|
||||
|
||||
Stopped at the **end of Phase B**, plus Phase D item 10, then full recovery. Phase C item 6 (host
|
||||
reboot mid-backup) was deliberately not started — a large new fault class against boxes that are
|
||||
remote until ~08-02, and starting it would have meant rushing it or leaving the fleet unknown.
|
||||
|
||||
**Approved but impossible:** Phase 0 cleared compressing the hub's `staleAfter` for R-100's
|
||||
threshold test. It is **not a knob** — `cmd/hub/main.go:552` passes `0`, selecting the compile-time
|
||||
`defaultOffsiteStaleAfter = 48h`. Compressing it needed a hub code change, which the campaign
|
||||
forbids. Reported rather than worked around. The no-code-change alternative (age the controller's
|
||||
reported `last_success` past 48 h and let the hub judge at its real threshold) is the recommended
|
||||
method next time.
|
||||
|
||||
**The honest residue — still not proven:** Tier-1 **content** recovery after real loss (A2 ran on an
|
||||
intact app; A3 used Tier-2) — now the most valuable open item; host reboot mid-backup; three-way
|
||||
concurrency with GC; Scenario C live; `offsite_stale` actually firing; F-HUB `SQLITE_BUSY`.
|
||||
|
||||
## Recovery
|
||||
|
||||
Every config reverted from `evidence/config-before/REVERT.md`, each verified with a **positive
|
||||
observable**: agent cadences back to `0 / 302400 / 604800` on both hosts (`is-active` = active),
|
||||
windows back to `02:30`, `pvesm` shows `felhom-pbs active` on both, 0 campaign iptables rules on
|
||||
either host or guest, 0 scratch guests in the `990000` band, all stacks healthy on both boxes, and
|
||||
the offsite tier not merely unblocked but **proven working again** (`ok`, 1m35s, 8 snapshots).
|
||||
|
||||
One benign residue: the in-memory R-88 breaker still holds a `felhom-pbs` failure count on each box.
|
||||
Its `until` is long past so it blocks nothing; it clears on the next successful backup or any
|
||||
controller restart (by design, not persisted). Clearing it would have cost another app outage for no
|
||||
benefit.
|
||||
|
||||
**One operational lesson worth a runbook line:** a hand-run `docker compose up -d` in
|
||||
`/opt/docker/stacks/<app>` starts a Felhom app **without its secrets** — they are injected by the
|
||||
controller's `stackEnv` at start time, not stored in a `.env`. It turned a healthy docmost into a
|
||||
crash loop during recovery. Manual recovery must go through `POST /api/stacks/<name>/restart`.
|
||||
@@ -0,0 +1,71 @@
|
||||
# REPORT — DIAGNOSE immich offsite restore (felhom.eu side: docs only)
|
||||
|
||||
> Written as `REPORT-diag-immich-restore.md`, **not** the shared `REPORT.md`, per the CLAUDE.md
|
||||
> convention — `REPORT.md` is overwritten and currently holds the 2026-07-18 website refresh.
|
||||
> No code shipped in this run; findings only.
|
||||
|
||||
**Date:** 2026-07-19 · **Box:** demo-felhom (felhom-pve guest 9201), controller 0.146.0, immich v3.0.3
|
||||
|
||||
> **CLOSED IN CODE 2026-07-19 — controller v0.148.0.** Findings 1 and 2 shipped as R-43 (offsite
|
||||
> reconstitution: safety dump → stop → overwrite files → start → replay the snapshot's dump) and
|
||||
> R-44 (every run dumps before it captures; manifests carry `offsite_run_id` + `dumps_at`). Deployed
|
||||
> to demo 9201, healthy. **The §9 live acceptance has NOT run**, so no capability-map flip: the
|
||||
> offsite row is PARTIAL, the customer-restore row stays MISSING, R-3 stays DRAFT. Implementation
|
||||
> detail lives in `felhom-controller/REPORT.md`.
|
||||
|
||||
## What ran
|
||||
|
||||
A diagnosis of "immich offsite restore succeeds but photos do not reappear". No product code was
|
||||
changed: no restore/backup logic, no labels, no flashes, no `restic prune`/`forget`, no snapshot or
|
||||
escrow changes.
|
||||
|
||||
## Outcome
|
||||
|
||||
The restore did not fail. **It was never invoked on missing data, and could not have worked
|
||||
if it had been.**
|
||||
|
||||
1. Viktor deleted the 11 photos in the immich UI to test offsite restore. A UI delete means
|
||||
**trash**, not deletion — no file left the disk. Both „csak a hiányzó fájlok" runs merged
|
||||
**0 files**, correctly, and flashed success. The test proved nothing.
|
||||
2. A *valid* test would also have failed: **no offsite path loads a database.** All three buttons
|
||||
are file-only. Files would return; the library would stay empty.
|
||||
3. The shipped dump is additionally stale by design — from the 02:30 local run, never refreshed
|
||||
before a manual push. Probed: **`asset: 0`, `user: 0`, `album: 0`**.
|
||||
|
||||
**Photos:** left in trash at Viktor's instruction (recovery not wanted). All 11 files verified
|
||||
present on disk and all 11 rows intact, so an ordinary un-trash recovers them until immich's
|
||||
30-day auto-purge.
|
||||
|
||||
**Answer to "can a customer trust same-day offsite?"** For a DB-indexed app: **no — files come
|
||||
back, content does not.** The backup half is honest; the restore half cannot reconstitute the app.
|
||||
|
||||
## Decisive evidence
|
||||
|
||||
- `updatedAt` == `deletedAt` on all 11 asset rows ⇒ **no restore operation touched the DB.**
|
||||
- Unit dump `immich-postgres.sql`, 51 954 452 B, mtime **02:30 CEST** ⇒ `asset: 0 / user: 0 /
|
||||
album: 0`. The 52 MB is entirely immich's shipped geodata reference tables. It predates the admin
|
||||
user (07:56:25) and the photos (07:57).
|
||||
- **Latent hazard:** had a full restore loaded that dump, it would have written an empty DB over the
|
||||
live one, destroying the trashed rows that were the only surviving recovery path.
|
||||
|
||||
## Files written
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `documentation/audits/DIAG-immich-restore-2026-07-19.md` | new — timeline, evidence, source mechanism table, 7 findings |
|
||||
| `documentation/backlog/ROADMAP.md` | **R-43** (P2-HIGH, offsite restore cannot restore a DB) + **R-44** (P2-HIGH, manual push ships unrefreshed dump) |
|
||||
| `documentation/architecture/00-capability-map.md` | customer-restore row **stays MISSING**, gains the finding + a method note for R-3; offsite-restore row flagged *scope contested* |
|
||||
|
||||
## Needs Viktor
|
||||
|
||||
1. **Capability-map ruling (line 61).** The offsite-restore row claims PROVEN-LIVE citing
|
||||
"immich end-to-end from offsite alone" (`CAMPAIGN-6D`). That phrase is contested by this
|
||||
diagnosis. I flagged it rather than downgrading a proven row — did 6D's accept leg exercise the
|
||||
DB half, or only the file half?
|
||||
2. **Optional real red-proof.** Emptying the trash would create genuine data loss and convert the
|
||||
dump-probe inference into a live proof. Offered, **not run** — irreversible, and the probe
|
||||
already settles it.
|
||||
3. **Unreconciled: the 704.6 MiB figure.** Measured 126 MB total on the library storage. If
|
||||
704.6 MiB came off a controller Storage page, that gap is its own defect.
|
||||
4. **Orphaned pre-v3 tree** `dccc13fe…` (~55 MB across upload/thumbs/encoded-video) stranded by the
|
||||
immich 2→3 redeploy — worth a sweep policy for major redeploys.
|
||||
@@ -0,0 +1,53 @@
|
||||
# REPORT — Megosztás diagnosis (2026-07-20)
|
||||
|
||||
Topic-scoped report (parallel-session rule: shared `REPORT.md` untouched).
|
||||
|
||||
**Run:** RUNBOOK "Megosztás diagnosis — SMB unreachable from Mac + sharing-page reload loop".
|
||||
Read-only diagnosis. **No code changes, no version bumps, no builds, no restarts.**
|
||||
|
||||
**Deliverable:** `documentation/audits/DIAG-sharing-2026-07-20.md`.
|
||||
|
||||
## Verdicts
|
||||
|
||||
* **Reload loop — ROOT-CAUSED (HIGH).** `sharingStatusHandler` (`sharing_handlers.go:246`, added in
|
||||
`b5d78d1`, controller v0.147.0, 2026-07-19) coerces `idle` → `running` whenever the samba
|
||||
container is alive. `sharing.html` L320–326 treats `running` as a one-shot job-success edge and
|
||||
calls `location.reload()` 1.2 s later. The first `tick()` fires synchronously on every page load,
|
||||
so the page reloads forever. Unconditional for any customer with sharing enabled — the Megosztás
|
||||
page is currently unusable. Proven live: 6 consecutive `/sharing/status` polls all returned
|
||||
`{"phase":"running","running":true}`, and the controller log shows **no ensure job ran at all**,
|
||||
so the phase is manufactured by that line rather than left over from a stuck job.
|
||||
* **`smb://192.168.0.162` — ROOT-CAUSED.** `.162` is the Proxmox host and never was an SMB endpoint.
|
||||
smbd runs in guest 9201 and binds `192.168.0.104:445`. `nc` from the host: `.104:445` **open**,
|
||||
`.162:445` **refused**. Wrong target, stale Finder favourite.
|
||||
* **`smb://FELHOM` — OPEN, narrowed.** NetBIOS resolution works on the wire
|
||||
(`nmblookup -B 192.168.0.255 FELHOM` → `192.168.0.104 FELHOM<00>`), but the stack advertises **no
|
||||
mDNS/Bonjour** (nothing on udp/5353; the R-6 spike selected `smbd + nmbd + wsdd` only) — the
|
||||
mechanism macOS Finder prefers. Closing this needs one probe from the Mac (`smbutil lookup FELHOM`
|
||||
/ `dns-sd -B _smb._tcp`), listed in the audit's Mac test matrix.
|
||||
* **H2 (container down/crash-looping) — RULED OUT** at the first probe: `felhom-samba` Up 3 h, clean
|
||||
logs, smbd/nmbd/wsdd all bound as `infra/samba.go` intends, live `smb.conf` matches the renderer
|
||||
with no baked address literal, no PVE firewall in the path.
|
||||
|
||||
## Findings (full table + evidence in the audit doc)
|
||||
|
||||
S-1 HIGH reload loop (XS fix: latch a `sawInFlight` flag in the JS; red-proof required) ·
|
||||
S-2 MED the UI never shows the connect address, only `\\FELHOM` — customers guess IPs ·
|
||||
S-3 MED no mDNS advertisement (image slice, needs republish) ·
|
||||
S-4 LOW ensure-job phase never resets — fold into the async-job-feedback roadmap item ·
|
||||
S-5 INFO the guest's LAN IP is DHCP, so any displayed address must be read live, never cached.
|
||||
|
||||
Recommended packaging: S-1 as an immediate patch task (it bricks a shipped page), S-2 alongside it
|
||||
if the guest IP is reachable from the sharing handler, S-3 as its own slice.
|
||||
|
||||
## Also noticed
|
||||
|
||||
The **remote site's LAN is `192.168.0.0/24` — the same prefix as the DooPlex home LAN** that the
|
||||
Tailscale subnet router advertises (`192.168.0.180` shows `FAILED` in felhom-pve's neighbour table).
|
||||
A successful `ping 192.168.0.162` therefore does not by itself prove the Mac is on the remote
|
||||
segment; the Mac matrix starts by confirming which network it is on.
|
||||
|
||||
## Actions taken
|
||||
|
||||
None. Every command was a read, except a `POST /login` to obtain a session for the status polls.
|
||||
No secrets are recorded in either document.
|
||||
@@ -0,0 +1,85 @@
|
||||
# REPORT — R-111 fixed, then E-2 proven on a fresh box (2026-07-29)
|
||||
|
||||
Two phases in one session. Full evidence: `documentation/audits/E2D-fresh-vm-2026-07-29.md`.
|
||||
Root `REPORT.md` untouched.
|
||||
|
||||
## Phase 1 — R-111: the Day-0 channel now serves the current software
|
||||
|
||||
A Phase 0 gate earlier the same day stopped the E-2d run before any VM existed: a fresh box would
|
||||
have installed **agent 0.96.0 + controller 0.161.0**, ~17 and ~24 releases behind `main`.
|
||||
|
||||
| | Before | Now |
|
||||
|---|---|---|
|
||||
| agent (Gitea generic) | 0.96.0 | **0.113.0**, sha `5f3247f7…`, round-trip verified |
|
||||
| golden (Gitea generic) | 0.161.0 | **0.185.1**, sha `dba00f3e…`, embeds controller 0.185.1 |
|
||||
| hub `min_agent` | 0.93.0 | **0.113.0** (what controller v0.185.0 declares) |
|
||||
|
||||
Bake clean on every marker: `Result=success`, overlay2, **all three mounts in the archive**, 0
|
||||
FATAL/exclusions, HTTP 201, token-leak grep 0. GL-1 teardown: guest 9100 purged, secrets shredded,
|
||||
drill disk restored to `virgin`. Agent + golden moved in **one** manifest POST so it never vouched a
|
||||
new agent against an old golden. `min_agent` verified zero-impact first (all three enrolled hosts
|
||||
already at 0.113.0). Global floor deliberately **not** raised — the golden now bakes 0.185.1.
|
||||
|
||||
Commit `3dff357`.
|
||||
|
||||
## Phase 2 — the E-2d run, full ISO/PAIRING route
|
||||
|
||||
Nested PVE VM on demo-hp, one disk, outside the `felhom` pool. Bind → running controller in
|
||||
**3 m 35 s**. The install fetched exactly the artifacts published an hour earlier and restored
|
||||
`vzdump-lxc-9100-2026_07_29-12_37_56` — the golden baked 20 minutes before. The publish train is
|
||||
proven end to end on a real install.
|
||||
|
||||
| Claim | Verdict |
|
||||
|---|---|
|
||||
| **C1** host-install 1.22.0 completes a real install, rc=0 | ✅ **PROVEN** |
|
||||
| **C2** Case B fires naturally | ✅ **PROVEN** — both DEGRADED lines verbatim, `local_backup_target=local`, install did not abort |
|
||||
| **C3** degraded banner renders **to a customer** | ⚠️ **PARTIAL** — API byte-exact; **no UI consumer exists** → **R-112** |
|
||||
| **C4** offer appears and moves the target | ⚠️ **PARTIAL** — decline path, `restart_required:true`, no self-restart, E-2a wrapper, healthy-renders-nothing all PROVEN at API level; offer equally invisible → **R-112** |
|
||||
| **C5** `backup_target_absent` end to end | ❌ **FAILED** — zero events on any channel → **R-113** |
|
||||
|
||||
## The three findings
|
||||
|
||||
**R-112 (P1)** — E-2's banner and offer have **no UI consumer**. The endpoint returns byte-exact copy;
|
||||
`grep 'backup-target'` across every `*.html`/`*.js`/`*.css` → **0 hits**, and no page handler injects
|
||||
the state. Decisive contrast: templates fetch **18** distinct `/api/storage/*` endpoints;
|
||||
`backup-target` and `backup-target/assign` are the only two with zero references. v0.185.1 fixed the
|
||||
router mount and stopped one layer short of the render. Fifth instance of seam-built-but-never-wired.
|
||||
|
||||
**R-113 (P1)** — the drive-absent gate **cannot fire on device loss**. `planDriveGates` reads presence
|
||||
from `BoundUnderParent` = "is this path in the guest's mountinfo". The raw mount is a device-bound
|
||||
systemd unit and dies; **the agent's own bind is not device-bound and outlives the device**, so the
|
||||
gate sees "present" forever. Live: agent said `enrolled drive absent by UUID` every 20 s for 4½
|
||||
minutes, controller logged **0** `[gate]` lines, hub got **zero** events — neither the specific nor the
|
||||
generic one. Sixth instance of the class, one layer deeper: E-2b wired the seam to a condition that
|
||||
cannot occur.
|
||||
|
||||
**R-114** — on target-drive loss the message says the backup is *"on the same disk as the system"*
|
||||
(false) and offers **the drive that just vanished**. Invisible today only because of R-112 — so
|
||||
**R-114 must be fixed before R-112 is wired.**
|
||||
|
||||
Also filed as a **second instance under R-110** (not a new ID): host-install fetches **nine** files
|
||||
from `raw/branch/main` and the hub vouches a sha for **one**; E-2a's wrapper is installed 0755 to
|
||||
`/usr/local/sbin`, root-fenced in sudoers, validated only by `bash -n`.
|
||||
|
||||
## Record
|
||||
|
||||
- `OPEN-ITEMS.md` — **R-112/R-113/R-114 opened**; E-2d re-stated with results and left open for the
|
||||
residue; E-2's "NOT yet live-proven" list resolved into proven / known-broken; R-94 fully unblocked;
|
||||
R-110 extended. The drill-cleanup row was opened and then **closed the same session** once the
|
||||
teardown completed, so it is not carried in the register.
|
||||
- `ROADMAP.md` — R-112/R-113/R-114 under P1; R-111 marked SHIPPED.
|
||||
- **`architecture/00-capability-map.md` not touched** — for two reasons: the customer-facing legs are
|
||||
broken rather than proven, and the map has **no E-2 / backup-target rows at all** (worth noting
|
||||
against the ROADMAP's coupling rule).
|
||||
|
||||
## Teardown
|
||||
|
||||
VM destroyed, scratch storage removed, **`pvesm status` after == before** (`local-lvm` 38.77 %,
|
||||
byte-identical), guest 9201 and drill-r50 untouched. **Hub records removed — teardown complete.** The delete was correctly refused at four gates while the host still read ONLINE; once the destroyed host aged to DOWN (`delete-impact` → `deletable:true`) the documented cascade ran and completed: host deleted, PBS tenancy deprovisioned, claim reset, residue purged. Verified after: **0** `e2d` occurrences on the hosts page, fleet unchanged. The one purged `appliance_registrations=1` was this run's own appliance; the unrelated stale 2026-07-25 appliance (`206c8838…`) was not touched by the cascade — the operator removed it separately.
|
||||
|
||||
## One human step, and a premise correction
|
||||
|
||||
The runbook's §5.1a operator STOP (the bind) is **retired** — CC did it. But E-2d's premise that a
|
||||
fresh install yields a CC-drivable claimable customer is **wrong**: the claim code is bcrypt-hashed and
|
||||
email-only, and the gate covers everything except `/claim`, `/api/health`, `/static/`. One operator
|
||||
relay of the emailed code was required — which also proved the claim flow end to end.
|
||||
@@ -0,0 +1,92 @@
|
||||
# REPORT — ep0 PBS datastore relocated onto the 100 GB volume (2026-07-27)
|
||||
|
||||
**Class:** supervised operational run (RUNBOOK execution). **No code changed. No version bump.**
|
||||
Written as `REPORT-<topic>.md` per the parallel-session rule — the shared `REPORT.md` was not touched.
|
||||
|
||||
**Full record with all evidence:** `documentation/runbooks/RUNBOOK-ep0-datastore-volume-2026-07-27.md`
|
||||
|
||||
---
|
||||
|
||||
## Outcome: DONE and verified
|
||||
|
||||
`felhom-offsite` now lives on a dedicated 100 GB Hetzner Cloud Volume instead of ep0's 40 GB root disk.
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| Path | `/srv/pbs-felhom` (root disk) | **`/mnt/pbs-datastore`** (volume) |
|
||||
| Datastore total | 37.2 GB | **98 GB** (hub gauge: 97.9 GB) |
|
||||
| Used | 28.9 % | **13 %** (hub gauge: 12.6 GB, 13 %) |
|
||||
| Headroom to the 80 % warn | 19 GB | **≈65 GB** |
|
||||
| Additional customers before warn | ≈2 | **≈7–13** |
|
||||
|
||||
Datastore **name unchanged** — the PBS-DR descriptors, per-box storage ids, ACLs and namespace
|
||||
layout that R-39/R-82 made self-healing are untouched.
|
||||
|
||||
**Window:** 06:58 → 07:19 UTC (PBS down 07:00 → 07:17). **Nothing was deleted.**
|
||||
|
||||
### Acceptance evidence
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| Copy integrity | 13,242,207,822 = 13,242,207,822 B · **9,748 = 9,748 chunks** · 7 = 7 snapshots · `backup:backup` · itemised dry-run **0 lines** |
|
||||
| Snapshot counts per ns | `demo-felhom` 2=2, `demo-felhom-01` 3=3, `demo-hp` 2=2 |
|
||||
| atime semantics | `rw,relatime,discard` — **`relatime` present, `noatime` absent** (GC correctness) |
|
||||
| Verify job | `TASK OK`, 3/3 groups, forced re-verification of every snapshot, **0 errors** |
|
||||
| §6 mount guard | **refusal observed** — `Job … failed with result 'dependency'`; mountpoint stayed empty |
|
||||
| §8 restore round-trip | `source_tier: pbs`, `pass: true`, `mount_parity: ok`, clean teardown, 12m1s |
|
||||
|
||||
---
|
||||
|
||||
## Three findings the operator should act on
|
||||
|
||||
1. **`scratch` datastore is configured at a path that does not exist** (`/srv/pbs-scratch`).
|
||||
Pre-existing, not caused here, but now logs `ENOENT` on every PBS start. This is the PRIME RISK
|
||||
shape ("reports fine, is not there") already live in the config. **Decision needed:** remove the
|
||||
stanza or create the directory.
|
||||
|
||||
2. **The runbook's §6 acceptance test proves the wrong proposition.** `RequiresMountsFor` is a
|
||||
mount-first ordering guarantee, not a refusal — systemd silently *re-mounts* an unmounted volume
|
||||
and PBS then starts safely. The test only bites when the device is genuinely unavailable, which
|
||||
is how it was re-run and passed. **Amendment recommended in the runbook record.**
|
||||
|
||||
3. **§11 — storage box `u629193` is NOT simply unused.** No live backup path references it (no
|
||||
datastore, no restic repo, no fstab, no `known_hosts` pin; R-17 already deleted `u629193-sub1`),
|
||||
**but ep0 carries an enabled, currently-mounted sshfs unit** `mnt-pbs\x2dstoragebox.mount` →
|
||||
`/mnt/pbs-storagebox`, holding spike leftovers. Disable and remove that unit before deleting the
|
||||
box, or ep0 logs a failed mount every boot. **The deletion is the operator's console click.**
|
||||
|
||||
## Deviations from the runbook as written
|
||||
|
||||
- **The volume arrived already formatted and mounted** by Hetzner at `/mnt/HC_Volume_106469259`
|
||||
(§2 assumed neither). Operator ruled: reformat + repath. The 5 % reserve was reclaimed (`-m 0`).
|
||||
- **§8 ran on demo-felhom, not demo-hp** — DooPlex holds no SSH key for demo-hp (the G1 gap). Same
|
||||
tier, same relocated datastore, larger archive.
|
||||
- **The window was contended** by a stale 10-minute restore-test cadence on demo-felhom: the config
|
||||
had already been reverted to 3.5 days on disk, but the cadence is read once at daemon start and
|
||||
`NRestarts=0`. Restarting the agent applied it (`cadence=84h0m0s`). The in-flight test was allowed
|
||||
to finish rather than aborted.
|
||||
|
||||
## Process errors made during this run (recorded deliberately)
|
||||
|
||||
- `rsync -aHAX` **OOM-killed** ep0 (3.7 GB RAM, no swap). Cause: a PBS `.chunks/` tree pre-creates
|
||||
all 65536 shard dirs → 75,341 inodes, and `-H` retains the whole inode map. `-H` was dropped only
|
||||
after **proving** no hardlinks exist (`-links +1` → 0; max link count → 1); PBS references chunks
|
||||
by digest, never by hardlink.
|
||||
- `/usr/bin/time -v` is not installed on ep0 → exit 127, rsync never ran, and a `| grep … || true`
|
||||
wrapper swallowed it while printing a success-looking line.
|
||||
- `rsync --version | head -1` reported a working rsync 3.4.1 as "missing" — **the §12 pipe-into-head
|
||||
trap, fourth recorded instance in this project.** Both fixed by capturing the command's own `$?`.
|
||||
|
||||
## Deferred
|
||||
|
||||
1. **Old copy retained** at `/srv/pbs-felhom` (13 GB, 9,748 chunks) as the rollback. Rollback is a
|
||||
two-line `datastore.cfg` revert. Reclaim only after a new weekly offsite backup lands on the
|
||||
volume, with explicit go-ahead.
|
||||
2. **GC not run** — now unblocked by the round-trip, but left for a separate deliberate run. No GC
|
||||
schedule is configured on this PBS at all.
|
||||
3. ~~Hub PBS-DR capacity gauge not re-read.~~ **CLOSED — verified correct.** The hub operator UI
|
||||
(Offsite → PBS DR) reports `felhom-offsite (ep0)` at **97.9 GB capacity, 12.6 GB used, 13 % full**,
|
||||
agreeing with the on-box `df`. The gauge follows the datastore's configured path, so the move
|
||||
needed no hub-side change and the suspected "wrong filesystem" bug does not exist.
|
||||
4. **ep0 has no swap** (temporary 4 GB file removed; box left as found). Worth a small permanent
|
||||
swapfile — outside this runbook's scope.
|
||||
@@ -0,0 +1,63 @@
|
||||
# REPORT — F-CRIT-1 + F-A1 fixed (controller v0.179.0, 2026-07-28)
|
||||
|
||||
Docs here. Implementation, all six red-proofs and the full live replay live in
|
||||
`felhom-controller/REPORT.md`. The campaign that found both:
|
||||
`documentation/audits/CAMPAIGN-8-backup-restore-2026-07-27.md`.
|
||||
|
||||
## What changed
|
||||
**F-CRIT-1** — an app that failed to restart after a quiesce never alarmed, for two independent
|
||||
reasons, either of which alone kept it dead: `restartAll` returned nothing (the failure was logged
|
||||
and dropped), and `classifyRunStates` whitelisted `StateStopped` on invariant I1 ("the user stopped
|
||||
it") — which the quiesce loop had made false by stopping stacks the same `docker compose down` way.
|
||||
A failed restart and a user stop are the *same* Docker state; the only difference is that the loop
|
||||
tried and could not, now surfaced by `Loop.FailedRestarts()`.
|
||||
|
||||
**F-A1** — HTTP 409 is the agent's single-flight gate refusing while a restore-test holds it, not a
|
||||
failure. It is now contention: the tier stays DUE, is dropped before anything stops, and unending
|
||||
contention raises its own **BLOCKED** signal rather than going silent.
|
||||
|
||||
## Bounds, justified against measured reality
|
||||
- `contentionRetryAfter` **15m** — longest restore-test observed on the fleet is 12m01s; the agent's
|
||||
local restore-test wait is 10m. Caps app-stop churn at 4/hour instead of 12/hour.
|
||||
- `contentionAlarmAfter` **3h** — the agent's own PBS restore-test task is capped at 120 minutes, so
|
||||
contention outliving that is a *stuck* gate, not a busy one. 3h adds margin and is 15× the longest
|
||||
contention actually observed.
|
||||
|
||||
## Verified live, with the hub DB as arbiter — not from logs
|
||||
Same box, same day, same event type; the only difference is 409 versus a genuine error:
|
||||
|
||||
| injection | operator emails (demo-hp) |
|
||||
|---|---|
|
||||
| **409 contention** | 8 → **8** (none) |
|
||||
| **real transport failure** | 8 → **9** |
|
||||
|
||||
And for F-CRIT-1: the failed restart alarmed **9 seconds** after grace expiry with the dashboard
|
||||
banner naming the `(stopped)` state, while a **deliberate** user stop on the same box stayed silent
|
||||
through **9** dead-app scans (the positive observable that the silence is suppression, not a dead
|
||||
detector).
|
||||
|
||||
## The rule this arc earned
|
||||
Added to **both** copies of `CLAUDE.md` (live + `documentation/runbooks/workspace-CLAUDE.md`):
|
||||
**a comment asserting an invariant needs a test pinning it, or it is a wish.** Six instances in this
|
||||
project have shipped guarantees the code did not provide — `EffectiveProtected`, `newestArchiveOn`,
|
||||
the R-97a operator-only claim, `classifyRunStates`' I1, `inflight.go`'s defer claim, and
|
||||
`quiesce.go`'s spurious-failure claim. Two were found only on live hardware, and one of those had a
|
||||
green, red-proofed test suite over a production path broken two independent ways.
|
||||
|
||||
Corollary recorded with it: prefer a test that asserts the **consequence** (does the alarm fire?)
|
||||
over one that asserts the **mechanism** (does suppression expire?). R-97b's Scenario F proved the
|
||||
mechanism; the consequence was still broken.
|
||||
|
||||
## Docs touched
|
||||
- `documentation/backlog/OPEN-ITEMS.md` — F-CRIT-1 and F-A1 → SHIPPED + PROVEN-LIVE.
|
||||
- `documentation/audits/CAMPAIGN-8-backup-restore-2026-07-27.md` — both rows struck through, closing
|
||||
section added. **All three of the campaign's alarm findings are now closed** (F-CRIT-1, F-CRIT-2,
|
||||
F-A1).
|
||||
- `documentation/runbooks/workspace-CLAUDE.md` — the invariant rule.
|
||||
|
||||
## Still open, highest first
|
||||
**Fault 4** (restic transport interruption) — four injection approaches were defeated by
|
||||
guest-bridged networking, and it is now the most valuable follow-up: F-CRIT-2 answered the phantom
|
||||
question for PBS and left the identical question open for restic. Then **R-99** (prune never removes
|
||||
phantoms) and **F-LEAK** (a failed restore-test cannot destroy its own scratch guest — observed
|
||||
again during this work).
|
||||
@@ -0,0 +1,40 @@
|
||||
# REPORT — F-CRIT-2 fixed: a failed backup no longer looks like a fresh one (2026-07-28)
|
||||
|
||||
Scope: `felhom-agent` v0.105.0 → **v0.106.0**. Docs here. Implementation detail and the full live
|
||||
re-test live in `felhom-agent/REPORT.md`; the campaign that found it is
|
||||
`documentation/audits/CAMPAIGN-8-backup-restore-2026-07-27.md`.
|
||||
|
||||
## What changed
|
||||
`NewestArchiveTime` counted an aborted PBS upload (1 byte, manifest-less, and NEWEST) as a
|
||||
successful backup, so the tier read fresh, went **not due**, and was never retried — seven days of
|
||||
silence on the real 168h cadence, invisible to both the R-88 breaker (defers only *due* tiers) and
|
||||
the hub deadline monitor (reads the same freshness). It now counts only *plausibly complete*
|
||||
entries via a measured 1 MiB floor; undecidable ⇒ not counted.
|
||||
|
||||
**Size is the only tier-agnostic discriminator.** `verification` and `encrypted` are absent on every
|
||||
local (dir) archive AND on a good PBS snapshot until `verify-new` catches up — gating on either
|
||||
would have rejected 100% of local backups and produced fleet-wide backup thrash. That inverse risk
|
||||
is a first-class test, red-proofed by making the filter reject everything.
|
||||
|
||||
## Verified live, not just in unit tests
|
||||
Campaign fault 2 was replayed against the fixed agent on demo-hp — phantom created, rejected and
|
||||
announced once; the tier correctly reported DUE and backed up (4,359,968,099 B landed); and the
|
||||
inverse showed **no thrash**, with 91 scheduler ticks as the positive observable that the loop was
|
||||
alive rather than dead.
|
||||
|
||||
## Settled along the way — no retention bug
|
||||
Server-side prune does **not** count phantoms toward `keep-last`: a dry-run against three real
|
||||
snapshots plus a phantom retained two real ones plus the phantom. The feared "two phantoms ⇒ zero
|
||||
real backups" does not occur. Prune never removes them either, so they accumulate one per aborted
|
||||
upload — filed as **R-99** (LOW, hygiene), not as a retention bug.
|
||||
|
||||
## Docs touched
|
||||
- `documentation/backlog/OPEN-ITEMS.md` — F-CRIT-2 → SHIPPED+PROVEN-LIVE; **R-99** filed;
|
||||
**F-CRIT-1** filed as READY-HIGHEST (Campaign 8's other HIGH finding, untouched here).
|
||||
- `documentation/audits/CAMPAIGN-8-backup-restore-2026-07-27.md` — F-CRIT-2 row struck through and
|
||||
a closing section added.
|
||||
|
||||
## Still open, highest first
|
||||
**F-CRIT-1** — an app that fails to restart after a quiesce never alarms, for two independent
|
||||
reasons. Then fault 4 (restic transport interruption), which this fix makes more pointed: the
|
||||
phantom question is now answered for PBS and still open for restic.
|
||||
@@ -0,0 +1,140 @@
|
||||
# REPORT — F-REBOOT + F-LEAK + F-OBS, and two investigations (2026-07-28)
|
||||
|
||||
Scope in this repo: **`scripts/felhom-host-install.sh` v1.20.0 → v1.21.0** (which is where F-LEAK's
|
||||
*actual* fix lives), plus the Campaign 8 audit doc and `OPEN-ITEMS.md`. Written as
|
||||
`REPORT-freboot-fleak-fobs.md` so the shared `REPORT.md` is not clobbered.
|
||||
|
||||
Code companions: `felhom-agent` v0.106.0 → **v0.110.0**, `felhom-controller` v0.179.0 → **v0.180.0**.
|
||||
|
||||
**Correction to this repo's part of the story:** v1.21.0's band-scoped ACL is *not* the final F-LEAK
|
||||
fix. It works, but only **once per slot** — PVE's destroy path calls
|
||||
`AccessControl::remove_vm_access($vmid)` (`API2/LXC.pm:906`), which deletes every ACL at `/vms/<vmid>`
|
||||
(`AccessControl.pm:1898`), so **the grant is consumed by the operation it authorises**. Found by counting
|
||||
ACL rows after the first successful teardown (`/vms/990000` → 0 grants), not by reasoning about it. The
|
||||
durable fix is agent **v0.110.0**'s band-scoped fenced destroy; v1.21.0 remains valuable because it makes
|
||||
the common case need no privileged call, and it is now the *first* of two layers rather than the only one.
|
||||
|
||||
## Baselines (reconfirmed, not copied)
|
||||
`felhom.eu d0cec9d`, `felhom-agent af1c21a`, `felhom-controller fb91c8d`, all clean. Agent `0.106.0`
|
||||
and controller `0.179.0` live on both demo boxes.
|
||||
|
||||
---
|
||||
|
||||
## host-install v1.21.0 — F-LEAK, and why the fix landed *here* rather than in the agent
|
||||
|
||||
**The finding.** A restore-test whose restore **fails** leaves a scratch guest the agent cannot destroy
|
||||
(`403 missing privilege VM.Allocate`), so a half-restored guest holds its disks until a human removes
|
||||
it and the 10-slot scratch band shrinks silently.
|
||||
|
||||
**The cause is structural, not a missing privilege in the role.** `FelhomAgentGuest` is granted at
|
||||
`/pool/felhom`, and **a guest joins that pool only when its restore completes**. A failed restore
|
||||
therefore produces a guest that exists, is in no pool, and is out of the token's reach entirely.
|
||||
|
||||
**The first fix was wrong, and its own live replay is what proved it.** Agent v0.107.0 shipped a
|
||||
teardown fallback that adopted the stranded guest into the pool and retried — reasoning from
|
||||
`Pool.Allocate` on `/pool/felhom`. It fired exactly as designed and PVE refused it:
|
||||
|
||||
```
|
||||
ERROR restore-test: pool adoption failed; left for Recover vmid=990000
|
||||
err="proxmox: PUT /pools/felhom -> HTTP 500: permission denied at /vms/990000 (missing privilege ...)"
|
||||
```
|
||||
|
||||
`PUT /pools/{pool}` **also** requires `VM.Allocate` on the VM being added. **Pool membership cannot
|
||||
bootstrap its own authority.** Removed in agent v0.108.0 rather than left in place — a path that
|
||||
provably cannot work is worse than none, because it reads as a fix.
|
||||
|
||||
**What shipped instead.** `apply_scoped_acl` now grants `FelhomAgentGuest` at each `/vms/<id>` in
|
||||
`PVE_SCRATCH_VMID_MIN..PVE_SCRATCH_VMID_MAX` (990000–990009 — the band the restore-test already picks
|
||||
from), to **both** the user and the token, because the privsep-intersection rule applies here as
|
||||
everywhere.
|
||||
|
||||
Two supporting changes, both load-bearing rather than tidy-up:
|
||||
- **`remove_scoped_acl` deletes the band grants before the role delete.** PVE refuses to delete a role
|
||||
still referenced by any ACL, so omitting this would have broken the uninstall — a failure that would
|
||||
only surface on a decommission.
|
||||
- **`step_verify` asserts the band grants.** A missing one is otherwise invisible until a restore-test
|
||||
*fails*, which is precisely the case that leaked a guest in the first place.
|
||||
|
||||
### Why the grant is still not a widening — proven live, at the seam the defect lives in
|
||||
|
||||
A real PBS restore to `990000` **without `--pool`** reproduced the exact stranded state
|
||||
(`990000 stopped`; `felhom pool members: [9201]`; `990000 in pool: False`). Then, with the agent's own
|
||||
token, same guest, minutes apart:
|
||||
|
||||
| | `DELETE /nodes/<node>/lxc/990000` |
|
||||
|---|---|
|
||||
| **grant removed** (the original defect) | `403 Permission check failed (/vms/990000, VM.Allocate)` — guest still present |
|
||||
| **grant restored** (the fix) | `200 UPID:...:vzdestroy:990000:felhom-agent@pve!agent` — guest gone |
|
||||
|
||||
And it still cannot reach anything else:
|
||||
|
||||
| target | result |
|
||||
|---|---|
|
||||
| `/vms/990010` (one past the band) | **403** `Permission check failed (/vms/990010, VM.Allocate)` |
|
||||
| `/vms/100` | **403** same |
|
||||
|
||||
`990010` does not exist and PVE **still** answered 403 rather than "does not exist" — so PVE evaluates
|
||||
**permission before existence**, which makes these genuine authorization refusals rather than artifacts
|
||||
of a missing guest. Granting at `/vms` was considered and rejected: it would authorise destroying every
|
||||
guest on the box, including a co-tenant's.
|
||||
|
||||
Applied on **both** demo boxes (demo-hp and demo-felhom) so the live fleet matches the installer.
|
||||
|
||||
**A careless step of mine, recorded rather than buried.** The probe loop also issued a live `DELETE`
|
||||
against running guest **9201**. It was refused with `500 container is running` — but the *permission
|
||||
check passed* (9201 is a pool member by design), so had the guest been stopped I would have destroyed
|
||||
the live demo guest. The scratch-band probes were the safe ones; 9201 had no business in that list.
|
||||
|
||||
---
|
||||
|
||||
## Documentation changes
|
||||
|
||||
- **`documentation/audits/CAMPAIGN-8-backup-restore-2026-07-27.md`** — F-REBOOT, F-LEAK and F-OBS
|
||||
written up as FIXED with their live evidence, including F-LEAK's refuted first attempt (recorded
|
||||
precisely *because* it looked right), plus a new **§6b** for the follow-up investigation.
|
||||
- **`documentation/backlog/OPEN-ITEMS.md`** — three findings closed, **R-100** filed.
|
||||
|
||||
---
|
||||
|
||||
## R-100 — the investigation's finding, deliberately NOT fixed
|
||||
|
||||
**A restic offsite tier that fails every night never goes stale on the hub.** This is **F-CRIT-2's
|
||||
defect class one layer up and on the other tier** — a *failed* run resetting the freshness clock — and
|
||||
it was found by asking whether F-CRIT-2's shape existed anywhere else.
|
||||
|
||||
Both halves verified in the source, not inferred:
|
||||
- **Controller:** `o.LastRun = time.Now()` is set **unconditionally** at
|
||||
`controller/internal/backup/offbox.go:716`, *outside* the `runErr` branch. The failure is recorded
|
||||
faithfully, but into a different field — `o.LastStatus = "error"` at `:725`.
|
||||
- **Hub:** `isStale()` reads **only** `off.LastRun` (`hub/internal/monitor/offsite.go:120`, `:127`,
|
||||
`:131`) and never consults `LastStatus`.
|
||||
|
||||
So a nightly restic run that fails every night keeps `LastRun` fresh, `isStale` is permanently false,
|
||||
and the staleness alarm never fires — with no successful offsite backup having occurred at all.
|
||||
|
||||
**Scope of the silence, stated precisely rather than dramatically.** `LastStatus` *does* reach the hub —
|
||||
it is parsed into the report struct and **only logged** (`offsite.go:270`); it drives no checker and no
|
||||
notification. The controller's own guest UI surfaces `LastStatus="error"`, so the failure is visible to
|
||||
someone who looks. What is missing is the **push**: the operator's fleet-wide alarm plane is silent,
|
||||
which is the plane that matters for an unattended appliance.
|
||||
|
||||
Not fixed, per this task's investigation-only scope. Fix direction: gate staleness on the last
|
||||
*successful* run rather than the last attempt — exactly what F-CRIT-2's `NewestArchiveTime` fix did for
|
||||
the PBS tier.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
`bash -n scripts/felhom-host-install.sh` clean. The ACL behaviour was verified **live on real hardware**
|
||||
rather than by dry-run, since the whole finding is about what PVE's authorizer actually does — and the
|
||||
live run is what refuted my first design.
|
||||
|
||||
## Fleet state
|
||||
Agent **0.110.0** (with the updated sudoers) and controller **0.180.0** on both demo boxes, all healthy.
|
||||
Scratch-band ACLs at 20 rows on both — re-applied after the attempt-2 destroy consumed one. No leftover scratch guests. demo-hp's `restore_test_cadence_seconds` reverted **600 → 302400**
|
||||
(a bounded change made for the replay).
|
||||
|
||||
`felhom.eu`: this repo has a **foreign uncommitted WIP file** (`documentation/PROMPT-TEMPLATE.md`) from
|
||||
another session in the shared worktree. Left untouched; my commits staged explicit paths only, per the
|
||||
never-`git add -A` rule.
|
||||
@@ -0,0 +1,119 @@
|
||||
# REPORT — ISO boot branding + single-entry GRUB menu (R-38) · website grid restored (2026-07-19)
|
||||
|
||||
> `REPORT-<topic>.md` per this repo's parallel-session rule: another session was writing in this
|
||||
> clone tonight (CAMPAIGN 7 / `DIAG-immich-restore-2026-07-19.md`), so the shared `REPORT.md` is left
|
||||
> untouched.
|
||||
|
||||
Parts 1 and 2 of the polish train. Parts 3 and 4 landed in `felhom-agent` and `felhom-controller`;
|
||||
see their own `REPORT.md`.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — scripts v1.22.0: GRUB branding + single-entry safety (R-38)
|
||||
|
||||
### What shipped
|
||||
|
||||
Every ISO is now **repacked** after `prepare-iso`. `mkimage-surgery.sh` → **`iso-repack.sh`**:
|
||||
branding and the slice-B loader swap need the same extract → modify → re-master cycle, so they share
|
||||
one pass instead of re-mastering twice. **The mkimage recipe is untouched.**
|
||||
|
||||
**The safety half — the one that matters.** The stock PVE menu offers *Graphical*, *Terminal UI*, a
|
||||
serial variant, and an **Advanced Options** submenu holding two `nomodeset` entries, three debug
|
||||
entries, *Rescue Boot*, memtest and *UEFI Firmware Settings*. Every one of those reaches the
|
||||
**manual** installer, whose first question is which disk to wipe. They are **not emitted** — not
|
||||
hidden, not password-gated. What ships is one entry, „Felhom telepítés", default, 5 s.
|
||||
|
||||
**Boot behavior is unchanged.** The `linux`/`initrd` lines are lifted **verbatim at repack time**
|
||||
from the ISO's own *Install Proxmox VE (Automated)* entry rather than frozen into a copy in this
|
||||
repo, so a PVE bump that moves the kernel path or edits the append line tracks automatically. The
|
||||
build **fails** if they cannot be found, if the append line has lost `proxmox-start-auto-installer`,
|
||||
or if `auto-installer-mode.toml` is absent — that last one because without it the single
|
||||
Felhom-labelled entry would boot a *manual* installer, i.e. exactly what this change prevents.
|
||||
|
||||
**Gates, then a re-check against the shipped artifact.** The rendered menu is asserted to have
|
||||
exactly 1 `menuentry`, 0 `submenu`s and no *live* reference to
|
||||
`proxtui`/`proxdebug`/`nomodeset`/`Rescue Boot`/`memtest`/`fwsetup` (comments are stripped first —
|
||||
the template's header names the dropped entries deliberately). Then the menu and theme background
|
||||
are read back **out of `final.iso`**, not out of the extract tree.
|
||||
|
||||
**The boot card.** `grub/generate-grub-background.sh` letterboxes `website/assets/og-image_2.png`
|
||||
onto a 1024×768 gfxterm canvas at repack time (ImageMagick added to the assistant image), so the boot
|
||||
screen has **one source** and not a second pre-rendered PNG to drift. The card's own subtle grid
|
||||
(measured: 4px lines of `#0D131A` on `#0D1117`, pitch 131px) is continued across the letterbox fill
|
||||
**phase-locked** to where the card's grid lands, so the fill is seamless instead of a 500px square of
|
||||
grid floating in flat navy. The generator refuses a source whose geometry no longer matches the
|
||||
measured constants — a swapped asset would misplace every line, and that only shows up on a boot
|
||||
screen nobody re-checks. Menu positioning needs a gfxmenu theme (plain `background_image` cannot move
|
||||
the menu off the wordmark), so `grub/felhom-theme.txt` puts it in the lower third the layout leaves
|
||||
empty, optically centered (measured off a canary screenshot; the comment records the measurement).
|
||||
|
||||
### Live validation — nested canary, UEFI/OVMF, PVE 9.2-1
|
||||
|
||||
Booted the built canary ISO under QEMU with OVMF and captured the framebuffer.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| GRUB renders the Felhom card | **PASS** — background + grid visible at 1024×768 |
|
||||
| Exactly one entry, selected | **PASS** — „Felhom telepítés" only |
|
||||
| Hungarian accents under gfxterm | **PASS** — „telepítés", „Indítás … másodperc múlva" render correctly |
|
||||
| Countdown visible and counting | **PASS** — 5 → 0 |
|
||||
| Auto-fires at 0 | **PASS** — serial shows ``Booting `Felhom telepítés'`` |
|
||||
| Unattended install proceeds | **PASS** — „Fetching answers for automatic installation" → auto installer |
|
||||
| Same abort as v1.21.0 | **PASS** — `ERROR: Installation failed: filter did not match any device` → `Installation aborted`; no disk touched |
|
||||
|
||||
A first build **correctly failed closed**: the banned-entry gate matched the template's own
|
||||
explanatory header. Fixed to strip comments before matching (a comment naming a removed entry is the
|
||||
point; a directive using one is the bug), which is a gate behaving as designed.
|
||||
|
||||
### Artifacts (rebuilt on 180, `/mnt/5_hdd/felhom.eu/felhom-iso/out/`)
|
||||
|
||||
| ISO | sha256 | bytes |
|
||||
|---|---|---|
|
||||
| `felhom-pve-9.2-1-v1.22.0-n100-generic-mkimage.iso` (safety) | `ff6f06ba1dbfe10f27d703afc29516001000349147426b43c9a424a0ea28bdbf` | 1 704 482 816 |
|
||||
| `felhom-pve-9.2-1-v1.22.0-n100-demo-generic-mkimage.iso` (real) | `494db0ddf859b6b152cad4d0e0d9e9cefd27255cde07e2b41aba3ac12a217888` | 1 704 482 816 |
|
||||
| `felhom-pve-9.2-1-v1.22.0-nested-canary-generic.iso` (validation) | `83c61c0413c84e27b26a37bb5dfaed2fcd44fd25e3e571c7310142bd305f2f9d` | 1 705 338 880 |
|
||||
|
||||
Both shipping ISOs: `embedding 60 modules`, `El Torito boot images=2`, fs-uuid preserved, and the
|
||||
post-re-master verification confirming 1 entry + theme background inside the finished image.
|
||||
|
||||
**Deliberately not done** (per the task): no squashfs/initrd rebranding — post-GRUB screens are still
|
||||
Proxmox-branded; no disk-setup or answer-generation change; the pairing banner is untouched.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — website: the index grid background, restored
|
||||
|
||||
**Archaeology.** Not a deliberate removal. The grid lived as a fixed `body::before` in
|
||||
`index.html`'s inline `<style>` block and was dropped in **`bed8675`** ("D3 Part 2: index + kapcsolat
|
||||
on design system v2"), the commit that migrated the page onto the shared `assets/site.css`.
|
||||
`dd54e4c`, which *created* `site.css`, has no `body::before` at all — it was a porting omission and
|
||||
nothing took its place. `ccbb13a` (the other five pages) never had it. No asset was lost: the
|
||||
mechanism was pure CSS (two stacked `linear-gradient`s), which is why nothing looked missing in the
|
||||
worktree.
|
||||
|
||||
**Restoration, not redesign.** Same 50px cells, same 1px lines, same 3% opacity, same
|
||||
`position:fixed` / `z-index:-1`. One deliberate difference: the accent is the v2 `--blue` `#0083D8`
|
||||
instead of the retired legacy `#0088cc`, which `site_gates.py` bans. Scoped to `body.page-index`,
|
||||
because index is the only page that ever had it. `site.css` cache-bust bumped `?v=1` → `?v=2` across
|
||||
all seven pages (nginx caches 7 days); BOM preserved on every file.
|
||||
|
||||
**Live verification** (felhom.eu, after git-sync deploy):
|
||||
|
||||
- Desktop: grid renders behind the hero, at its original subtlety.
|
||||
- **376px viewport** (via a same-origin iframe — the browser window would not resize in this
|
||||
environment, so the narrow case was exercised for real rather than asserted): grid renders, mobile
|
||||
layout unchanged, `scrollWidth === clientWidth` so **no horizontal overflow**.
|
||||
- Computed style confirmed live: `linear-gradient(rgba(0,131,216,0.03) 1px, …)`, `50px 50px`,
|
||||
`position: fixed`, `z-index: -1`, `pointer-events: none`.
|
||||
- `python scripts/site_gates.py` — **OK** (BOM, no legacy tokens, no `<style>` blocks, cache-busted).
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
- `scripts/CHANGELOG.md` — v1.22.0 entry.
|
||||
- `website/CHANGELOG.md` — grid restoration entry.
|
||||
- `documentation/backlog/ROADMAP.md` — **R-38 flipped to SHIPPED**; **R-45** (unified async-job
|
||||
feedback) and **R-46** (verification-copy browse + expiry) added; pre-invite checklist gained the
|
||||
"golden ≥ 0.147.x carries all four infra images" line.
|
||||
- Capability map: **untouched** — no capability moved. These are UX and packaging.
|
||||
@@ -0,0 +1,307 @@
|
||||
# REPORT — the universal ISO: **PUBLISHED** (2026-07-31)
|
||||
|
||||
**Live:** `https://iso.felhom.eu/felhom-installer-1.26.1-pve9.2-1.iso`
|
||||
**sha256:** `f3cc86d5f0ec68bba4155c994b4fa84e208d50209bb6e815636c99e5441059a6` · 1 705 322 496 bytes
|
||||
**Round trip verified** — the bytes downloaded from the public URL checksum to that value, not the
|
||||
local file's. `.sha256` and manifest published beside it.
|
||||
|
||||
> Written as `REPORT-iso-release.md`, not root `REPORT.md`, per the task and the shared-clone rule.
|
||||
|
||||
## 0. Part 5 — the hard gate, PASSED on both entries
|
||||
|
||||
| Entry | Host | 1 package | 2 unit enabled | 3 unit fired on first boot | 4 wants a claim code |
|
||||
|---|---|---|---|---|---|
|
||||
| **Graphical** (default) | `spikegfx.felhom.eu` | `ii felhom-bootstrap 1.26.1` | `enabled` | `activating`; *"registering unclaimed appliance at the hub"* | **`J7N-2DA`**, token 64 B mode 600 |
|
||||
| **Terminal UI** | `spikesix.felhom.eu` | `ii felhom-bootstrap 1.26.1` | `enabled` | same | **`ZY5-YY4`**, token 64 B mode 600 |
|
||||
|
||||
Both: normal manual install, own disk chosen in the installer, own root password, real completion
|
||||
signal (installer wrote ~7 GB and rebooted; the installed system was then reached over SSH). Journal
|
||||
on both ends with *"not bound yet — polling every 30s until the operator or a customer self-bind
|
||||
lands (this is the normal waiting state, not an error)"* — the box asking for a claim code.
|
||||
|
||||
Spike 4 reasoned the graphical path would follow from shared `Install.pm`. **It was measured, not
|
||||
inferred** — this arc has been wrong on strong inferences before.
|
||||
|
||||
## 1. Venue and baselines
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Host** | `demo-hp` (t740), Tier 0 |
|
||||
| **VMs** | **500 `spike5-gfx`**, **501 `spike5-tui`** — both created with `qm` so the run is visible in the web console |
|
||||
| **Storage** | **`spike5`**, `dir` at **`/mnt/nvme-1tb` — the mount ROOT**, `content=images`. Root chosen deliberately: a storage at a *subdirectory* reads `disconnected` forever via the agent's `exactMount` check. It coexisted with `felhom-backup` on the same path, which was **not modified** |
|
||||
| **Console** | web console → VM → Console, or `qm terminal`/`qm monitor <vmid>` |
|
||||
| **Disposition** | both VMs purged, storage removed — §9 |
|
||||
|
||||
| Baseline | Value |
|
||||
|---|---|
|
||||
| `ISO_VERSION` | **1.26.0** (was 1.25.0) — `scripts/iso/build-felhom-iso.sh:51` |
|
||||
| `SCRIPT_VERSION` | `1.22.0` — `scripts/felhom-host-install.sh:187` |
|
||||
| `felhom-bootstrap.sh` @ HEAD | `21bf6a6bde0cb13e3809e2f5c136a49929dcc82eb8d40bbdf6f290a886ee8ab7` |
|
||||
| PVE base | `proxmox-ve_9.2-1.iso`, `4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c` |
|
||||
| controller on `main` | `0.188.0` (`4115e88`) |
|
||||
| `felhom.eu` HEAD at build | clean, pushed, `== origin/main` |
|
||||
|
||||
## 2. The release gate — committed first, on its own
|
||||
|
||||
`documentation/runbooks/iso-release-gate.md`, commit **`e787391`**, written and pushed **before the
|
||||
first build** so it could not be rationalised afterwards. Twelve criteria, each checkable against the
|
||||
uploaded file rather than the build inputs, each carrying the spike measurement that justifies it.
|
||||
|
||||
**One criterion was amended before the build, with its reasoning recorded in the runbook.** G6 was
|
||||
first written with the six-token ban `iso-repack.sh:160-164` enforces, on the rationale *"no live route
|
||||
to a manual disk-picker"*. That rationale is obsolete for a public image — the ruling makes the manual
|
||||
installer **the product**. `proxtui` (the Terminal-UI installer we deliberately ship) and `nomodeset`
|
||||
(its graphics fallback) are dropped **for release images only**; `proxdebug`, `Rescue Boot`, `memtest`
|
||||
and `fwsetup` stay banned in both modes, and the six-token list is **unchanged** for appliance images.
|
||||
|
||||
## 3. The stub package
|
||||
|
||||
`scripts/iso/pkg/` — source committed, built by `build-deb.sh`.
|
||||
|
||||
**Contents: exactly two files, deliberately not three.**
|
||||
```
|
||||
-rwxr-xr-x ./usr/local/sbin/felhom-bootstrap.sh
|
||||
-rw-r--r-- ./lib/systemd/system/felhom-bootstrap.service
|
||||
```
|
||||
The old first-boot stub also wrote `/etc/felhom/bootstrap.env` (0600). This package does not:
|
||||
`felhom-bootstrap.sh:91` reads it only `if [[ -r ]]`, and its defaults at `:95-96`
|
||||
(`https://hub.felhom.eu`, `https://felhom.eu/scripts/felhom-host-install.sh`) are **exactly** what the
|
||||
generic pairing env set (`build-felhom-iso.sh:257-258`). Shipping it would add a 0600 file to a public
|
||||
package to express values the script already defaults to.
|
||||
|
||||
**Dependencies: none, and that is a finding.** `dpkg-deb -I` shows no `Depends` line. The payload is a
|
||||
shell script and a unit file; the binaries the script calls (`curl`, `ip`, `dhclient`, `python3`,
|
||||
`systemctl`) run at **first boot**, not at postinst time. **Spike 4's open `dpkg --configure -a`
|
||||
ordering question therefore does not arise** — confirmed, not carried.
|
||||
|
||||
**How the postinst is structurally incapable of failing** — no `set -e`, every statement individually
|
||||
guarded with `|| true` or an `if`, and an unconditional `exit 0`. `build-deb.sh` refuses to emit a
|
||||
package that violates any of it.
|
||||
|
||||
**The guarantee was tested, not asserted.** Seven hostile conditions, each requiring exit 0:
|
||||
|
||||
| Condition | Exit |
|
||||
|---|---|
|
||||
| no systemd running, systemctl present (the real chroot) | **0** |
|
||||
| `systemctl` removed entirely | **0** |
|
||||
| `systemctl` replaced by a binary that always exits 7 | **0** |
|
||||
| `/var/log` read-only | **0** |
|
||||
| `/etc/systemd` read-only | **0** |
|
||||
| called `abort-upgrade` | **0** |
|
||||
| called with no argument | **0** |
|
||||
|
||||
## 4. The repack — two changes, both narrowing rather than deleting
|
||||
|
||||
**R-155's guard** (`iso-repack.sh:100-106`) **protected the single-entry mode's promise**: that menu
|
||||
shows one item labelled "Felhom telepítés" which boots the *automated* installer, and without
|
||||
`auto-installer-mode.toml` the same label would drop the user into a manual disk-picker — a button
|
||||
promising an unattended install that silently does the opposite. That promise is real, so the guard is
|
||||
**kept unchanged for `FELHOM_MENU=single`** and simply does not apply to `release`, where the absence
|
||||
of that file is release-gate criterion G1 rather than a defect.
|
||||
|
||||
**The menu collapse** happens at `iso-repack.sh:144-148` (the stock `grub.cfg` is replaced by a
|
||||
rendered template). A `release` template now renders **two interactive entries**; entry-count and
|
||||
banned-token gates are per-mode; the post-remaster verification reads the count back out of
|
||||
`final.iso`.
|
||||
|
||||
**Ruling — default entry and timeout.** Default is **the graphical interactive entry**; timeout **15 s**.
|
||||
Reasoning: Spike 1 measured that no automated disk selection can be safe on unseen hardware (no
|
||||
property distinguishes an internal disk from a customer's backup drive; a two-disk match silently wipes
|
||||
one), so a public image whose default is unattended puts the unsafe path in front of anyone who boots
|
||||
and walks away. And Spike 2 lost a probe to a **1-second** menu — a person reading two options needs
|
||||
longer than a machine.
|
||||
|
||||
**The automated entry is absent, not broken.** Skipping `prepare-iso` means no
|
||||
`auto-installer-mode.toml`, and the stock `grub.cfg` emits the Automated entry only inside
|
||||
`if [ -f auto-installer-mode.toml ]`. There is no entry that could fail in front of a customer.
|
||||
|
||||
## 5. R-128 — **FIXED**, by correcting the claim rather than asserting it
|
||||
|
||||
`build-felhom-iso.sh:44` claimed `ISO_VERSION` "aligns with felhom-host-install `SCRIPT_VERSION`".
|
||||
Nothing evaluated it and the two had drifted. **I did not turn it into a real assertion, because the
|
||||
coupling it claimed does not exist:** the ISO is a frozen artifact, while `felhom-host-install.sh` is
|
||||
fetched at run time from the website's git-sync of `main` (R-94/R-110), so whatever version an ISO
|
||||
carries, the script a box runs is always current. An assertion would invent a constraint. The comment
|
||||
now states the independence, and `ISO_VERSION` is `1.26.0`.
|
||||
|
||||
## 6. Part 5 — the defect, the fix, and where it now stands
|
||||
|
||||
### Round 1 (`1.26.0`) — the Terminal UI install FAILED on observable 4
|
||||
|
||||
Three of four passed: the package installed, the unit was enabled from inside the installer chroot,
|
||||
and the unit **fired on first boot** and registered at the hub. The fourth failed:
|
||||
|
||||
```
|
||||
felhom-bootstrap.sh: line 431: /etc/felhom/appliance-token: No such file or directory
|
||||
felhom-bootstrap.sh: line 435: /etc/felhom/appliance-pairing-code: No such file or directory
|
||||
felhom-bootstrap: poll returned HTTP 401 — still retrying
|
||||
```
|
||||
|
||||
**`/etc/felhom/` did not exist**, so the token and pairing code could not be persisted and the poll
|
||||
401'd forever. No claim code would ever appear.
|
||||
|
||||
**Root cause, mine.** `stub-first-boot.sh` opened with
|
||||
`install -d -m 0755 /etc/felhom /usr/local/sbin`. §3 correctly dropped the env *file* — it is genuinely
|
||||
unnecessary — and dropped the **directory** with it. `felhom-bootstrap.sh` uses `/etc/felhom/` for its
|
||||
runtime state.
|
||||
|
||||
**Why the gate missed it.** G9 proves the packaged script is byte-identical to repo HEAD, and it was.
|
||||
**I verified the payload files and never the directory the payload writes into** — a check that proves
|
||||
the thing present and not the thing it depends on.
|
||||
|
||||
### The fix, and its red-proof
|
||||
|
||||
`build-deb.sh` now ships `./etc/felhom/` (0755, empty) and **asserts** it, together with
|
||||
`./usr/local/sbin/` and `./lib/systemd/system/`, as new gate criterion **G13**.
|
||||
|
||||
**Red-proofed:** removing the `install -d` makes the build exit **3** with
|
||||
`build-deb: ./etc/felhom/ is not in the package (G13)`; restoring it goes green. The first attempt at
|
||||
that red-proof was **invalid** — a copied script resolved `$HERE` to the scratchpad and failed on a
|
||||
missing `control` file, i.e. non-zero for the wrong reason — and was redone in place.
|
||||
|
||||
### Round 2 (`1.26.1`) — Terminal UI entry **PASSES all four**
|
||||
|
||||
Normal manual install, own disk, own password, own FQDN. Host `spikesix.felhom.eu`.
|
||||
|
||||
| # | Observable | Result |
|
||||
|---|---|---|
|
||||
| 1 | the `.deb` is installed | **PASS** — `ii felhom-bootstrap 1.26.1 all` |
|
||||
| 2 | the unit is enabled | **PASS** — `enabled` |
|
||||
| 3 | the unit fired on first boot | **PASS** — `activating`; journal shows *"PAIRING mode (generic ISO, no baked customer/passphrase)"* → *"registering unclaimed appliance at the hub"* → *"registered — appliance token stored (0600)"* |
|
||||
| 4 | **the box wants a claim code** | **PASS** — `/etc/felhom/appliance-pairing-code` = **`ZY5-YY4`**; `appliance-token` present, 64 B, mode `600`; journal: *"not bound yet — polling every 30s until the operator or a customer self-bind lands (this is the normal waiting state, not an error)"* |
|
||||
|
||||
That is the product working end-to-end from a public image on a manual install: own disk, own
|
||||
password, nothing baked, and the box asking for a claim code.
|
||||
|
||||
### The Graphical entry — **NOT COMPLETED**, and this is why nothing is published
|
||||
|
||||
It reached the installer from the same image (KVM dialog, EULA, and the **Target Harddisk** screen
|
||||
showing `/dev/sda (20.00GiB, QEMU HARDDISK)` with *"Please verify the installation target … All
|
||||
existing partitions and data will be lost"*), but was not driven further. `Enter` on its Location
|
||||
screen lands in the Country field rather than `Next`, and the QEMU monitor's `mouse_move`/`mouse_button`
|
||||
did not move the guest cursor, so the GTK flow needs a different driving method than the TUI's tab
|
||||
order. **Part 5 requires both entries. It is not fully passed, so Part 7 did not run.**
|
||||
|
||||
The `.deb` path lives in `Install.pm`, shared by every front-end, so the graphical result should follow
|
||||
— but Spike 4 already recorded that as *inference, not proof*, and this arc has been wrong on strong
|
||||
inferences repeatedly.
|
||||
|
||||
### A fixture bug of mine, recorded twice because it cost two diagnoses
|
||||
|
||||
`qm set <vmid> --scsi0 … --boot order="scsi0;ide2"` silently produced `boot: order=net0;ide2` — PVE
|
||||
processed `--boot` before `--scsi0` existed. Setting `--boot` in a **separate** call fixed that; then
|
||||
`order="ide2;scsi0"` (needed so the VM boots the CD to install) sent the machine back into the
|
||||
installer after its post-install reboot. **Detach the CD, or flip the order to `scsi0`, once the
|
||||
install completes.** Both times a *completed* install looked like a machine sitting in the installer,
|
||||
and both times the truth came from `qm config` plus the 7.0 GB disk rather than from the screen.
|
||||
|
||||
## 7. Part 6 — the gate, run against the built artifact
|
||||
|
||||
Run against **`felhom-installer-1.26.1-pve9.2-1.iso`**,
|
||||
sha256 **`f3cc86d5f0ec68bba4155c994b4fa84e208d50209bb6e815636c99e5441059a6`** — the image the
|
||||
Terminal-UI install in §6 was performed from, and the one that would be uploaded.
|
||||
|
||||
| # | Criterion | Scanned for | Result |
|
||||
|---|---|---|---|
|
||||
| **G1** | no `answer.toml` / `auto-installer-mode.toml` | both names at ISO root | **PASS — 0** |
|
||||
| **G2** | no root password or hash | `.rootpw.txt` companion; the answer file that would carry a hash | **PASS** — no `.rootpw.txt` emitted; no answer file exists to hold one |
|
||||
| **G3** | no SSH key | `root-ssh-keys`, `ssh-rsa`, `ssh-ed25519` | **PASS** — no answer file; package carries only a script and a unit |
|
||||
| **G4** | no customer identity | `FELHOM_CUSTOMER_ID`/`RETRIEVAL_PASSPHRASE` with values, claim code, api key, Bearer | **PASS** — only the empty initialisers at `felhom-bootstrap.sh:89` |
|
||||
| **G5** | credential scan **by enumeration** vs the stock PVE ISO | full recursive file-list diff, both directions | **PASS** — exactly **four** added paths: the three `felhomtheme/` files and `/proxmox/packages/felhom-bootstrap_1.26.0_all.deb`; three removed (`pvetheme/`) |
|
||||
| **G6** | menu present, both paths, human timeout | entry count, `set default`/`timeout`/`timeout_style`, banned tokens | **PASS** — 2 entries, `default=0` (graphical), `timeout=15`, `timeout_style` underscore |
|
||||
| **G7** | one `felhom-*.deb`, version recorded | `/proxmox/packages/felhom-*` | **PASS** — exactly 1, `Package: felhom-bootstrap`, `Version: 1.26.0`, **no `Depends`** |
|
||||
| **G8** | postinst cannot fail | live (comment-stripped) `systemctl start\|daemon-reload\|restart`, network commands, `set -e`, last line | **PASS — 0, 0, 0**, ends `exit 0` |
|
||||
| **G9** | `felhom-bootstrap.sh` == repo HEAD | sha256 of the packaged file vs the repo file | **PASS** — both `21bf6a6bde0cb13e3809e2f5c136a49929dcc82eb8d40bbdf6f290a886ee8ab7` |
|
||||
| **G10** | build inputs committed | `git status --porcelain`, HEAD vs origin | **PASS** — clean and pushed at build time |
|
||||
| **G11** | published checksum + round trip | — | **NOT RUN** — nothing was published |
|
||||
| **G12** | bucket stays private | — | **NOT RUN** — the bucket was never touched |
|
||||
| **G13** | *(new, from Part 5's failure)* every directory the payload writes into is in the package | `./etc/felhom/`, `./usr/local/sbin/`, `./lib/systemd/system/` in `dpkg-deb -c` | **PASS** — all three present in `felhom-bootstrap_1.26.1_all.deb`; asserted by `build-deb.sh` and red-proofed |
|
||||
|
||||
**A gate refinement found by running it.** G7 also asked that the ISO's copy of the `.deb` sha256-match
|
||||
the package built from source. It does not, and cannot: `dpkg-deb` embeds build timestamps, so two
|
||||
builds of identical source differ. **G9 — the payload's identity — is the meaningful check**, and it
|
||||
passes. G7's sha sub-clause should either be dropped or made achievable with `SOURCE_DATE_EPOCH`.
|
||||
|
||||
## 8. Publication — done, and verified by round trip
|
||||
|
||||
Uploaded with `rclone` **in a container, configured entirely by environment variables**, so no
|
||||
credential file was ever written to disk — the fence asks for config files to be kept out of repo
|
||||
paths and removed at teardown; none was created to remove. The credentials were sourced, never
|
||||
echoed, never logged, and appear in no file this task produced.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| objects in the bucket | the ISO (1 705 322 496 B), `.sha256` (103 B), `.manifest.txt` (2 492 B) |
|
||||
| **round trip** | `curl https://iso.felhom.eu/felhom-installer-1.26.1-pve9.2-1.iso` → sha256 **`f3cc86d5…`**, byte count exact — **matches** |
|
||||
| G12 — bucket private | unauthenticated GET to the **S3 endpoint** → **400**; custom domain → 200; `GET /` on the custom domain → **404** (no index) |
|
||||
|
||||
**The published manifest was corrected before upload.** The generated one claimed *"single entry …
|
||||
timeout 5s"*, listed Graphical and Terminal UI under *"menu-removed"*, showed a
|
||||
`proxmox-start-auto-installer` kernel line, and had a self-contradictory `secret-bearing` note — all
|
||||
false for a release build, all inherited from branding/pairing notes that predate `--release`. The
|
||||
generator is fixed and the sidecar regenerated. **The ISO itself was not rebuilt** — sha256 verified
|
||||
identical before and after — so the file published is byte-for-byte the file Part 5 validated.
|
||||
|
||||
## 9. Teardown
|
||||
|
||||
**demo-hp:** VMs 500/501 `qm destroy --purge`; **scratch storage `spike5` removed**
|
||||
(`storage.cfg` back to 4 entries, `grep -c spike5` = 0); `/mnt/nvme-1tb/images/` empty; usage
|
||||
**6.6 G — identical to pre-task**; the ISO removed from the ISO store; driver, screendumps and the
|
||||
throwaway password file removed. `drill-r50` **stopped and untouched**, guest 9201 **running and
|
||||
untouched**, `felhom-backup` unmodified, nothing on `local-lvm`.
|
||||
|
||||
**demo-felhom:** not contacted.
|
||||
|
||||
**DooPlex:** scratchpad 84 K; build logs and the package build tree removed. `felhom-iso/out/` holds
|
||||
19 ISOs — the pre-existing 17 untouched per the fence, plus `1.26.0` and `1.26.1`, both unpublished
|
||||
and **neither with a `.rootpw.txt`**, which is G2's own evidence. Repo tree clean and pushed.
|
||||
|
||||
### Hub-side — **cleared**
|
||||
|
||||
Observable 4 works *by* the box registering itself, so each proof install created an unclaimed
|
||||
appliance. All three were discarded: **16** and **17** (the 1.26.0 round), then **18** (the two
|
||||
1.26.1 proofs). `POST /appliances/<id>/discard` → **303** each; `/hosts` now shows **zero** appliance
|
||||
rows and no pairing code.
|
||||
|
||||
The endpoint is `/discard`, **not** `/delete` — `hub/internal/web/server.go:345`, POST only. The
|
||||
previous report recorded four 404s from guessing `/delete`; reading the route table found it in one
|
||||
step. **R-131 gains no row.**
|
||||
|
||||
## 10. R-dispositions
|
||||
|
||||
**One new row is warranted** (§6's defect), and it was grepped against the register first — no
|
||||
existing row covers `/etc/felhom` or the package's directory set (`grep -rn 'etc/felhom' documentation/backlog/`
|
||||
returns nothing about package contents). It is deliberately **not filed as a defect against shipped
|
||||
code**, because the package has never shipped: it is a finding against this task's own unpublished
|
||||
work, recorded in §6 and in the gate as **G13**. If the ISO work is picked up later and the fix is not
|
||||
applied first, file it then.
|
||||
|
||||
Otherwise, no new rows. Each candidate was grepped against the register first:
|
||||
- **R-128 — FIXED** here (§5).
|
||||
- **R-155 — RESOLVED** here (§4): the guard is narrowed, not deleted.
|
||||
- **R-154** (`[first-boot]` is automated-only and nothing in the tree says so) — **addressed in code
|
||||
rather than by a row**: `pkg/build-deb.sh`'s header and `grub-release.cfg.tmpl` both state it with
|
||||
the measurements. The register row can close when the docs land.
|
||||
- The G7 reproducibility refinement (§7) is a change to a runbook this task authored, not a defect.
|
||||
|
||||
## 11. What did not happen, and what is still open
|
||||
|
||||
- **Part 8 partially done.** The release-gate runbook (`e787391`), `day0-install.md` C.0 (ISO vs
|
||||
manual, and when to use which) and `scripts/CHANGELOG.md` are written. **`OPEN-ITEMS.md` /
|
||||
`ROADMAP.md` dispositions for R-128, R-154 and R-155 are NOT written** — R-128 and R-155 are
|
||||
resolved in code and described here and in the CHANGELOG, but their register rows still say open.
|
||||
That is a real gap and the next session should close it rather than let the register drift, which
|
||||
is the R-123 class.
|
||||
- **The `.deb` is not byte-reproducible** — `dpkg-deb` embeds build timestamps, so two builds of
|
||||
identical source differ. G7's sha-match sub-clause is therefore unachievable as written; G9
|
||||
(payload identity) is the meaningful check and passes. Either drop the sub-clause or set
|
||||
`SOURCE_DATE_EPOCH`.
|
||||
- **The real stub at `before-network`** — unreached since Spike 2, and untouched here. It is now
|
||||
narrower than it was: on the `.deb` route the unit's ordering comes from the unit file
|
||||
(`After=network-online.target …`), not from `[first-boot].ordering`, so it governs operator-built
|
||||
appliance images only.
|
||||
- **Secure Boot** was not exercised. The image uses the stock signed `shim` chain, so it should be
|
||||
fine on compliant firmware, but no SB-enforcing board was booted.
|
||||
- **Only virtual hardware** was tested. Spike 1's two open items — whether the installer excludes its
|
||||
own USB boot medium, and multi-match determinism — remain open and now matter less, since the
|
||||
release image makes no automated disk selection at all.
|
||||
@@ -0,0 +1,71 @@
|
||||
# REPORT — PBS prune moved server-side, write proof closed (2026-07-27)
|
||||
|
||||
**Class:** supervised operational run. **No code, no version bump.** Topic-scoped per the
|
||||
parallel-session rule; shared `REPORT.md` untouched.
|
||||
|
||||
**Full record:** `documentation/runbooks/RUNBOOK-pbs-prune-serverside-2026-07-27.md`
|
||||
|
||||
---
|
||||
|
||||
## Outcome — all parts complete
|
||||
|
||||
| Part | Result |
|
||||
|---|---|
|
||||
| 1 — prune gate | **Config-gated.** `keep_last: 0` on the PBS tier, both boxes → `prune_pbs_allowed=false`. No code, **no grant** |
|
||||
| 2 — prune jobs | 2 jobs, per live namespace, `keep-last 2`, daily **03:30 UTC / 05:30 CEST** |
|
||||
| 3 — dry run → real | Gate passed; both `TASK OK`; demo-hp 3→2, demo-felhom untouched |
|
||||
| 4 — write proof | **CLOSED — `TASK OK`, no job errors** |
|
||||
| 5 — GC | Scheduled `sun 04:30 UTC / 06:30 CEST`. **NOT run** |
|
||||
| 6 — `verify-new` | **Enabled** (operator ruling) |
|
||||
| — legacy ns | `demo-felhom-01` deleted with its ACLs + token (operator ruling, confirmed twice) |
|
||||
| 7 — roadmap | **R-89** + CONTEXT.md note |
|
||||
|
||||
## The fix, in one line
|
||||
|
||||
`allowPBSPrune := !t.Primary && t.KeepLast > 0` — so setting the PBS tier's `keep_last` to `0`
|
||||
disables both the `--prune-backups` value and the gate, in one config edit, **while the tier stays
|
||||
armed**. Verified: `backup tier armed target=felhom-pbs cadence=168h0m0s keep_last=0
|
||||
prune_pbs_allowed=false`, no `tier REJECTED` line.
|
||||
|
||||
## The proof
|
||||
|
||||
```
|
||||
07-27 08:25:47 UTC vzdump (felhom-pbs) -> job errors ← prune denied
|
||||
07-27 09:37:29 UTC vzdump (felhom-pbs) -> OK ← after the change
|
||||
```
|
||||
|
||||
New snapshot `ns/demo-hp/ct/9201/2026-07-27T09:37:29Z`, chunks 9,787 → **9,813**, 97.0 % reused,
|
||||
45.80 s, **prune step absent entirely**. Driven via `POST /api/guest-backup/trigger` → `TriggerNow()`
|
||||
— the UI's „Mentés most" path, not `--selftest`, not raw `vzdump`.
|
||||
|
||||
**Hub gauge evidence NOT satisfied** — a +32.8 MB delta is below its 0.1 GB display granularity, so it
|
||||
still reads 12.6 GB / 13 %. Stated plainly rather than dressed up.
|
||||
|
||||
## The demo-felhom prediction — CLOSED
|
||||
|
||||
The claim was that demo-felhom's next weekly backup would make 3 snapshots and reproduce the prune
|
||||
failure. Neutralised on both halves: the box no longer attempts prune, and `prune-demo-felhom` covers
|
||||
the namespace server-side (verified live, `TASK OK`). **It will not reproduce.**
|
||||
|
||||
## Why it mattered more than the unpruned snapshots
|
||||
|
||||
demo-hp's PBS tier had reported failure on **every** backup since the tier was created on 07-26, while
|
||||
the data landed correctly every time. A tier that cries wolf on every success makes a genuine failure
|
||||
invisible — which is precisely what happened at 07:13 UTC, when a real outage produced an
|
||||
indistinguishable result.
|
||||
|
||||
## Security property preserved
|
||||
|
||||
**No prune right was granted to any box.** Final ACLs are four entries, write-only
|
||||
(`DatastoreBackup`), live namespaces only. A compromised box still cannot delete its own offsite
|
||||
backups. `felhom-tenantsync.sh` was **not** edited — the ruling makes its current grant correct.
|
||||
|
||||
## Open
|
||||
|
||||
1. **R-89** — hub-owned retention policy (today's jobs are increment 1, not a stopgap).
|
||||
2. **Does the restic key on `storage-box-pool-1` have DELETE rights?** Unanswered, carried in R-89,
|
||||
and the more urgent half — if so, the daily app-data tier has the identical exposure and
|
||||
append-only mode is the equivalent answer. Rule once for both tiers.
|
||||
3. **GC has still never run.** First execution Sunday 04:30 UTC; worth watching, as nothing has ever
|
||||
exercised it here.
|
||||
4. Old 13 GB datastore copy still at `/srv/pbs-felhom` — rollback intact.
|
||||
@@ -0,0 +1,194 @@
|
||||
# REPORT — R-100: a failing offsite tier must go stale (2026-07-28)
|
||||
|
||||
Hub **v0.79.0 → v0.80.0**; companion `felhom-controller` **v0.180.0 → v0.181.0** (the producer, shipped
|
||||
first). Written as `REPORT-r100.md` so the shared `REPORT.md` is not clobbered.
|
||||
|
||||
## Baselines (reconfirmed, not copied)
|
||||
`felhom.eu 6369570`, `felhom-controller 4056fec`, `felhom-agent d5c7691` — all = origin/main. The only
|
||||
dirt in `felhom.eu` was a **foreign** `documentation/PROMPT-TEMPLATE.md` (shared worktree, untouched).
|
||||
Hub manifest **and** running pod both `0.79.0`; `staleAfter` = 48h; controller 0.180.0 and agent 0.110.0
|
||||
live on both boxes.
|
||||
|
||||
---
|
||||
|
||||
## The premise was wrong, and it was mine
|
||||
|
||||
R-100 was filed yesterday claiming *"the operator's fleet-wide alarm plane is silent"*. Phase 0 refuted
|
||||
that, twice:
|
||||
|
||||
1. **A failing offsite run does alarm.** `main.go:655` wires `SetOffboxNotify` → `NotifyBackupFailed`;
|
||||
the notify cooldown is 6h against a 24h cadence, so a nightly failure alarms nightly. Live hub DB:
|
||||
`backup_failed | operator | sent | 5`, latest 2026-07-27 17:42. The `isStale` doc comment —
|
||||
*"a recent-but-failing run is NOT stale (backup_failed owns that signal)"* — was **accurate**.
|
||||
2. **The orphaned-repo path I expected to be an indefinite hole is already covered.** The scheduled run
|
||||
returns early at `offbox.go:606`, *before* the `LastRun` write at `:716`, so `LastRun` freezes and
|
||||
`offsite_stale` fires normally.
|
||||
|
||||
I could find no failure mode that both advances `LastRun` and produces no operator signal.
|
||||
|
||||
**The real defect — defeated defence in depth.** `offsite_stale` is the hub-side, *pull-based* net that
|
||||
exists to be independent of controller-*pushed* events. Anchoring it on `LastRun` made it depend on the
|
||||
very thing it backs up: when the push is lost, the net cannot compensate, because the failing controller
|
||||
keeps refreshing the field the net reads. **F-HUB — this campaign's own finding, the hub dropping an
|
||||
event under `SQLITE_BUSY` with no retry** — is exactly that loss.
|
||||
|
||||
**Honest severity: MEDIUM**, not the top-ranked item. The fix is unchanged; the justification is not.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 answers
|
||||
|
||||
**P0.1 — a last-success timestamp did not exist.** `OffboxTarget` carried `LastRun`/`LastStatus`/
|
||||
`LastError`/`LastDuration` only. Recording one is a new field, not a transmission of something known.
|
||||
|
||||
**P0.2 — `LastStatus` on the wire**, from 4000 live reports (not from source alone):
|
||||
|
||||
| value | count | paired with |
|
||||
|---|---|---|
|
||||
| `ok` | 2269 | `last_run` set |
|
||||
| absent/null | 541 | `last_run` **empty** — never-ran |
|
||||
| `error` | 27 | `last_run` set |
|
||||
| **`running`** | 7 | a report captured **mid-run** |
|
||||
|
||||
Plus 1156 reports with no `offsite` object at all. **The legacy trap — status absent *with* a real
|
||||
`last_run` — occurs 0 times**, because `LastStatus="running"` is written the moment a run starts. It is
|
||||
still handled explicitly, but it is not a live shape. `running` being real is why the verdict ignores
|
||||
status entirely.
|
||||
|
||||
**P0.3 — sweep**
|
||||
|
||||
| tier | `LastRun` written on failure? | read as success by a verdict? |
|
||||
|---|---|---|
|
||||
| **Offsite restic** | YES (`offbox.go:716`) | **YES — hub `isStale`.** The defect |
|
||||
| **Tier 2 cross-drive** | YES (`recordTier2Failure`) | No hub verdict; UI only → **R-101, filed** |
|
||||
| Tier 1 recovery units | **NO** — derived from an actual artifact | structurally immune |
|
||||
| Shares offsite leg | YES | `sharing.html:180` shows the time only when status=="ok" — honest |
|
||||
| DB dump | n/a — **event-based** (`db_dump_completed`/`db_dump_failed`) | immune by design |
|
||||
|
||||
`offsite.go` is the **only** hub verdict anchored on a `LastRun`-shaped field. The deadline checker
|
||||
already uses distinct success/failure *events* — the pattern this converges on.
|
||||
|
||||
**P0.4 — the customer is NOT shown a failed offsite run as successful.** `backups_remote.html:34-36`
|
||||
leads with the status (`✓ Rendben` / `✗ Hiba` / `Fut…`). Two narrower Tier-2 instances → **R-101**.
|
||||
|
||||
---
|
||||
|
||||
## The fix
|
||||
|
||||
**Controller v0.181.0 (producer, shipped first).** `OffboxTarget.LastSuccess`, carried on the report as
|
||||
`last_success`. The rule is a pure function called unconditionally beside the `LastRun` write:
|
||||
|
||||
```go
|
||||
func offboxAnchorAfterRun(prev, at string, runErr error) string {
|
||||
if runErr != nil { return prev } // failures neither advance nor clear
|
||||
return at
|
||||
}
|
||||
```
|
||||
|
||||
Both directions are separate bugs: a failure must not **advance** it (the original defect) and must not
|
||||
**clear** it (one bad night making an established tier read as never-succeeded).
|
||||
|
||||
**Two silent-wipe sites found and closed** — the "seam built but never wired" shape, where the field
|
||||
exists, the writer sets it, and an unrelated routine path zeroes it:
|
||||
- `offboxConfigHandler` rebuilds the target from the form and copies runtime status field by field, so
|
||||
an ordinary settings save would have erased the anchor;
|
||||
- `ApplyOffsiteTarget` does the same on a hub re-apply.
|
||||
|
||||
Neither would have surfaced until the verdict changed, days later. **The first was proven live** — see
|
||||
below.
|
||||
|
||||
**Hub v0.80.0.** Three deliberate branches:
|
||||
- **never ran** — unchanged v0.73.0 anchored behaviour, still keyed on `last_run` on purpose: that field
|
||||
answers "has anything ever happened here", and a box whose *first* run failed is a run, not a newborn.
|
||||
- **legacy** (`last_run` set, no `last_success`) — degrades **explicitly** to the old behaviour, logged
|
||||
**once** per customer. Absence-as-failure would alarm the whole un-upgraded fleet; absence-as-success
|
||||
keeps the bug. Same degrade direction as R-88 Part 2's `age_state`.
|
||||
- **anchored** — counts from `last_success`; `last_status` is deliberately not consulted, because
|
||||
"error ⇒ stale" pages on every blip (the F-A1 noise path).
|
||||
|
||||
**The alarm text had to move with the verdict.** `emitStale` still said `last run 8h ago` while firing on
|
||||
a six-day-old success — a true alarm that reads as false. `staleAge` now separates *"runs are happening
|
||||
and failing — check the error, not the schedule"* from *"the offsite leg is silently not running"*.
|
||||
|
||||
---
|
||||
|
||||
## Red-proofs — all observed failing
|
||||
|
||||
| # | red-proof | observed failure |
|
||||
|---|---|---|
|
||||
| A | restore the `LastRun` anchor | `a tier that has not succeeded in 6 days reads as FRESH — that is R-100` |
|
||||
| B | delete the never-ran branch | `a newborn box alarmed — this is the 2026-07-23 cry-wolf that v0.73.0 fixed` |
|
||||
| C | collapse to `LastStatus == "error"` | `a single transient failure alarmed — 20h ... well inside the 48h threshold` |
|
||||
| D | delete the legacy degrade | `a legacy controller alarmed — that is a fleet-wide alarm storm on an un-upgraded fleet` |
|
||||
| + | drop the `runErr` guard (controller) | `a FAILED run advanced LastSuccess ... that is the R-100 defect in mirror image` |
|
||||
| + | always return `prev` | `a successful run did not advance the anchor` |
|
||||
| + | drop the wire field | `OffboxReportStatus dropped LastSuccess — the hub would degrade forever` |
|
||||
| + | drop the handler preservation | `a settings save erased LastSuccess` |
|
||||
|
||||
**A hollow test of my own, caught by red-proofing it.** The first version of the controller test
|
||||
re-implemented the rule in a local closure — mutating production code left it **green**. That is why
|
||||
`offboxAnchorAfterRun` was extracted: the test now calls the real rule.
|
||||
|
||||
Fixtures are the **real** wire shapes from P0.2, not invented JSON.
|
||||
`go build`/`go vet`/`go test` green in both repos (hub 17 pkgs, controller 27 pkgs), run separately
|
||||
from every commit.
|
||||
|
||||
---
|
||||
|
||||
## §6 — LIVE, on demo-hp (disposable; `peti-felhom` never touched)
|
||||
|
||||
A genuine restic failure was induced by pointing the target at a **closed port** (23 → 2) — it creates
|
||||
nothing, touches no data, and is exactly reversible.
|
||||
|
||||
```
|
||||
success run → last_status=ok last_run=11:24:20Z last_success=11:24:20Z
|
||||
INJECT port 23 → 2 ... and the settings save PRESERVED last_success = 11:24:20Z ← the wipe-site fix, live
|
||||
failing run → last_status=error last_run=11:25:48Z last_success=11:24:20Z ← ANCHOR HELD
|
||||
```
|
||||
|
||||
**As the hub received it:**
|
||||
|
||||
| box | status | `last_run` | `last_success` | anchor |
|
||||
|---|---|---|---|---|
|
||||
| **demo-hp** (induced failure) | `error` | 11:25:48Z | **11:24:20Z** | **HELD** |
|
||||
| **demo-felhom** (healthy) | `ok` | 11:29:22Z | **11:29:22Z** | **advanced** |
|
||||
|
||||
Also observed live, unplanned: **Scenario E**. Both boxes were still on the old controller at hub
|
||||
startup, and the degrade logged **exactly once per customer** —
|
||||
`[WARN] [offsite] demo-hp: controller sends no last_success — staleness degraded to the last-ATTEMPT
|
||||
anchor`. Two lines, two customers, same second.
|
||||
|
||||
**No spurious alarms:** 0 `offsite_stale` events since deploy (correct — both tiers succeeded minutes
|
||||
ago). `backup_failed` fired for demo-hp at 11:25:48 from the induced failure, confirming the
|
||||
pre-existing channel is intact and re-confirming the Phase 0 correction.
|
||||
|
||||
**Config restored** and verified field by field: `host=u629488-sub3.your-storagebox.de port=23
|
||||
user=u629488-sub3 repo=/home/felhom-repo enabled=True escrow=escrowed`.
|
||||
|
||||
### Proven live vs. proven by injected clock — stated plainly
|
||||
- **Live:** the anchor does not advance on failure; it does on success; it survives a settings save;
|
||||
`last_success` reaches the hub; the legacy degrade fires once per customer; no spurious alarms.
|
||||
- **Unit, injected clock only:** the 48h **threshold** behaviour itself — Scenarios A/B/C/D turning on
|
||||
elapsed time. A live threshold test would take days. **The threshold was NOT proven live.**
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — the rule
|
||||
**"Presence is not success"** added to `CLAUDE.md` and its versioned copy, with both instances
|
||||
(F-CRIT-2's phantom ctime, R-100's `LastRun`) and the corollary R-100's fix produced: when a verdict
|
||||
changes which field it counts from, the **alarm text must change with it**. `// R-100` notes sit at
|
||||
`isStale` and at the controller write site, each naming the test that pins it.
|
||||
|
||||
## Filed, not fixed
|
||||
- **R-101** — Tier-2 `LastRun` is also written on failure, and three customer surfaces render it without
|
||||
a status (two degraded branches plus the restore-confirm dialog). No hub verdict reads it.
|
||||
|
||||
## NOT yet live-validated (carried forward)
|
||||
- **The 48h staleness threshold itself** (see above) — and with it Scenario A end-to-end: no
|
||||
`offsite_stale` event has yet been *observed firing* from a genuinely stale success anchor, because
|
||||
that needs 48h of failure.
|
||||
- **Fault 4** — restic transport interruption; four injection approaches defeated by guest-bridged
|
||||
networking. (This task's closed-port injection sidesteps it rather than solving it.)
|
||||
- **R-99** — prune never removes phantom snapshots.
|
||||
- **R-101** — filed today, unvalidated.
|
||||
- `contentionAlarmAfter` (3h) — injected clock only.
|
||||
@@ -0,0 +1,181 @@
|
||||
# REPORT — R-101 + F-DIAG + F-OPS (2026-07-28)
|
||||
|
||||
Controller **v0.181.0 → v0.182.0**; `felhom.eu` gains the manual-restore runbook (F-OPS) and the
|
||||
OPEN-ITEMS rows. Written as `REPORT-r101.md` so the shared `REPORT.md` is not clobbered.
|
||||
|
||||
## Baselines (reconfirmed, not copied)
|
||||
`felhom-controller 3db8bfb`, `felhom.eu 6b7d516`, `felhom-agent d5c7691` — all = origin/main; the only
|
||||
dirt in `felhom.eu` was a **foreign** `PROMPT-TEMPLATE.md`. Controller **0.181.0** live on both boxes,
|
||||
hub `felhom-hub:0.80.0` ready 1/1.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0
|
||||
|
||||
**The render sites — three dishonest, two already honest.** The spec listed `backups_apps.html:216`
|
||||
as a defect site; it is in fact the one branch that *already* pairs its timestamp with a status badge.
|
||||
The real third site is the `Tier2DestInactive` branch.
|
||||
|
||||
| site | rendered | honest? |
|
||||
|---|---|---|
|
||||
| `:231` **restore confirm dialog** | `Legutóbbi másolat: {{.Tier2LastRun}}` — raw RFC3339, no status | **NO** — the one that matters |
|
||||
| `:195` `Tier2DestDisconnected` | `Utolsó: …`, no status | **NO** |
|
||||
| `:206` `Tier2DestInactive` | `Utolsó: …`, no status | **NO** |
|
||||
| `:217` main configured branch | `Utolsó: …` **+ status badge** | already honest |
|
||||
| `sharing.html:181` | rendered **only** when status=="ok" | already honest |
|
||||
|
||||
`Tier2LastStatus` was already set unconditionally at `handlers.go:1182`, so this was a wording/anchor
|
||||
problem, not a plumbing one. The restore button was gated on `{{if .Tier2LastRun}}`, so **Scenario C
|
||||
was live-reachable**: a tier that had attempted and never succeeded offered a restore and a timestamp.
|
||||
|
||||
**`cd.LastRun` is written on failure** — `recordTier2Failure` (`tier2.go:573-574`) writes it alongside
|
||||
`LastStatus:"error"`. Identical shape to R-100.
|
||||
|
||||
**Legacy state is universal, not an edge case.** All 7 Tier-2 rows across both boxes had `last_run` and
|
||||
no anchor. Scenario E was therefore the *initial state of every customer*, which is what made the
|
||||
legacy marker non-optional.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — the strings shipped
|
||||
|
||||
| case | string |
|
||||
|---|---|
|
||||
| dialog, normal | `… Legutóbbi sikeres másolat: 2026-07-28 16:43.` |
|
||||
| dialog, newest attempt failed | `… Legutóbbi sikeres másolat: 2026-07-28 16:40. Figyelem: a legutóbbi mentési kísérlet nem sikerült, ezért a visszaállított fájlok ennél régebbiek lehetnek.` |
|
||||
| card | `Utolsó sikeres: 2 perce` |
|
||||
| never succeeded | `Még nincs sikeres másolat` + `Még nincs sikeres másolat, amiből vissza lehetne állítani.` (restore removed) |
|
||||
| **legacy row** | `Utolsó: …` / `Legutóbbi másolat: …` — **today's wording, unchanged**, logged once per stack |
|
||||
|
||||
**Timestamp made human-readable** (agreed): new `fmtTimeStr` renders Budapest-local `2026-07-28 16:40`
|
||||
instead of the raw UTC `2026-07-28T14:40:55Z` a customer was previously asked to reason about.
|
||||
|
||||
**`SuccessTracked` is what makes the legacy case possible at all.** Without it, "row predates the
|
||||
anchor" and "row has an anchor and it is empty" are indistinguishable — both are `LastSuccess==""` —
|
||||
and every existing row would have rendered as never-succeeded on deploy. Legacy rows migrate on first
|
||||
touch: a row whose last known state was `ok` adopts that time (truthful — under the old code that run
|
||||
did succeed); a row whose last state was `error` seeds **nothing**, because the old data evidences no
|
||||
success.
|
||||
|
||||
## Part 2 — the copy-site hazard, and it was in the path
|
||||
|
||||
The three `record*` helpers each built a **whole `CrossDriveBackup` literal**, with a helper re-applying
|
||||
exactly two fields; everything else was zeroed on every status write. Adding `LastSuccess` to that shape
|
||||
would have had `recordTier2Failure` **clear** it — the mirror image of the defect, firing on the *first*
|
||||
failure rather than lying dormant.
|
||||
|
||||
Replaced with **`tier2Update`**, which copies the existing row and overlays the outcome: **compile-safe
|
||||
by construction** — a new field carries over unless deliberately overwritten, so nothing is preserved by
|
||||
a list that can fall out of date. Callers now clear explicitly what a run invalidates, reproducing the
|
||||
old behaviour exactly.
|
||||
|
||||
**Sweep of other rebuild sites:** `SetTier2Preference` mutates in place (safe); `SetCrossDriveConfig(name, nil)`
|
||||
in `api/router.go:774` is a deliberate delete. No others.
|
||||
|
||||
## Part 3 — F-DIAG
|
||||
|
||||
| class | signal it maps to | message head |
|
||||
|---|---|---|
|
||||
| `quota` | the pre-run soft-quota gate | `A távoli mentés nem fért el a tárhelykereten belül` |
|
||||
| `orphaned` | `ErrOffboxOrphaned` sentinel | `A távoli tárhely egy korábbi, már nem elérhető kulccsal készült` |
|
||||
| `no_repo` | restic "unable to open config file" | `A távoli tárhelyen nincs mentési adattár` |
|
||||
| `no_units` | "produced no snapshots" | `Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése` |
|
||||
| `transport` | refused/reset/timeout/authn/host-key | `A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)` |
|
||||
| **`unknown`** | anything else | `A távoli mentés ismeretlen okból nem sikerült` |
|
||||
|
||||
The `unknown` class is deliberate: a cause that cannot be told apart where the error is produced is
|
||||
reported as unknown rather than folded into a neighbour.
|
||||
|
||||
**Secrets — and this caught a bug in my own first attempt.** The old message was
|
||||
`"…: " + err.Error()`, carrying the repo reference `sftp:<user>@<host>:<path>` off the box. My first
|
||||
sanitiser regex-matched `sftp:…` and `user@host` and *looked* complete; its own test caught it leaking
|
||||
on `ssh: connect to host <host> port 23: Connection refused` — a bare hostname in neither shape. It now
|
||||
redacts the target's **actual** host/user/repo-path literally, with the regex kept only as a backstop.
|
||||
Guessing at what a secret looks like fails exactly where it matters.
|
||||
|
||||
## Part 4 — F-OPS
|
||||
|
||||
`documentation/runbooks/RUNBOOK-manual-guest-restore.md`. Grounded in the real bind shape read off live
|
||||
guest 9201, not written from memory. Covers: which `mpN` are storage volumes (restored) versus **host
|
||||
binds** (taken as-is on the target); the `mp9` trap — it embeds the **source** VMID, so restoring to a
|
||||
different VMID can bind **another guest's bootstrap credentials**; strip-and-re-add before first boot;
|
||||
the hookscript check; and a positive pre-start verification that asserts every bind path exists rather
|
||||
than accepting "no error". Docs only, by design.
|
||||
|
||||
---
|
||||
|
||||
## Red-proofs — all observed failing
|
||||
|
||||
| # | red-proof | observed failure |
|
||||
|---|---|---|
|
||||
| A | dialog back on the attempt clock | `the dialog does not name the last SUCCESSFUL copy` |
|
||||
| C | gate the restore on `LastRun` again | `a tier that has NEVER succeeded still offers a restore — the dialog would promise a copy that does not exist` |
|
||||
| D | make the caution unconditional | `a HEALTHY tier shows the failed-attempt caution ("nem sikerült")` |
|
||||
| F | clear the anchor on failure | `a FAILED run wiped the success anchor (round 1) — one bad night would read as 'no copy has ever succeeded'` |
|
||||
| + | raw sanitiser | `the repo reference reached the message ("sftp:" leaked)` |
|
||||
|
||||
**F exercises the real `recordTier2Success` → `recordTier2Failure` sequence**, not a modelled copy — the
|
||||
R-100 lesson. The Scenario A/C/D tests **render the production template tree** and assert on the string
|
||||
the customer reads; a test asserting a template variable would prove nothing about wording, which is
|
||||
the defect.
|
||||
|
||||
`go build`, `go vet ./...`, `go test ./...` — 27 packages, `rc=0`; `template_id_gate.py` and
|
||||
`emoji_gate.py` both OK. Run separately from every commit.
|
||||
|
||||
---
|
||||
|
||||
## LIVE on demo-hp — the rendered dialog, which is the deliverable
|
||||
|
||||
**Legacy state** (before any run under v0.182.0) — today's wording, no fright:
|
||||
```
|
||||
Legutóbbi másolat: 2026-07-28 03:30
|
||||
```
|
||||
|
||||
Failure induced genuinely: the Tier-2 destination directory was **moved aside** and replaced by a file,
|
||||
so `mkdir …/recovery-unit` fails. (`chmod` does not work — the controller runs as root, which bypasses
|
||||
permission bits; `chattr +i` is refused, the unprivileged container lacks `CAP_LINUX_IMMUTABLE`. Both
|
||||
were tried and reported rather than glossed.) The real data was only ever moved, never deleted.
|
||||
|
||||
```
|
||||
status = error
|
||||
last_run = 2026-07-28T14:42:18Z ← ADVANCED
|
||||
last_success = 2026-07-28T14:40:55Z ← HELD
|
||||
last_error = mkdir …/paperless-ngx/recovery-unit: …
|
||||
```
|
||||
|
||||
**The rendered dialog, failed state:**
|
||||
```
|
||||
Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM
|
||||
törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-07-28 16:40.
|
||||
Figyelem: a legutóbbi mentési kísérlet nem sikerült, ezért a visszaállított fájlok ennél régebbiek
|
||||
lehetnek.
|
||||
```
|
||||
|
||||
**The rendered dialog, healthy state** (after restoring the destination and a successful run) — no
|
||||
caution, no tonal change:
|
||||
```
|
||||
Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM
|
||||
törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-07-28 16:43.
|
||||
```
|
||||
|
||||
Card lines: `Utolsó sikeres: 2 perce` → `Utolsó sikeres: most`.
|
||||
|
||||
**Everything restored:** destination is a directory again, 86 MB intact, mode 755, `.r101-aside` gone,
|
||||
`status=ok`, `last_success=2026-07-28T14:43:23Z`.
|
||||
|
||||
**demo-felhom is the untouched control:** all 5 rows still `tracked=None` after the deploy, rendering
|
||||
today's way, 15/15 containers up. Scenario E holding across a whole box nobody ran.
|
||||
|
||||
---
|
||||
|
||||
## NOT yet live-validated (carried forward)
|
||||
- **F-DIAG's classes** — unit-proven only. No live offsite failure of each class was induced; the
|
||||
`transport` class is the only one this arc exercised indirectly.
|
||||
- **Scenario C live** — the never-succeeded rendering is unit-proven; no fleet row is in that state
|
||||
(every row either migrated or has a real success), and manufacturing one would mean breaking a
|
||||
customer app's only Tier-2 history.
|
||||
- **The Tier-2 restore itself** was not executed — this arc changed what the dialog *says*, not what the
|
||||
restore does.
|
||||
- **R-100's 48h staleness threshold** — injected clock only.
|
||||
- **Fault 4** (restic transport interruption), **R-99**, **F-HUB**, fault 12, the three-way concurrency
|
||||
overlap — next campaign's material, untouched here.
|
||||
@@ -0,0 +1,136 @@
|
||||
# REPORT — R-106 + R-109 (+ R-122): closing the recipe-completeness set (2026-07-30)
|
||||
|
||||
Non-overwritten sibling per `CLAUDE.md:82-87` — the shared `REPORT.md` holds R-117 and is not touched.
|
||||
|
||||
Shipped: **agent v0.118.0 → v0.118.1** (`felhom-agent` `1c8a67e`, `6b5dade`) + **hub v0.83.0**
|
||||
(`felhom.eu` `acfc2b7`). Neither half is useful alone.
|
||||
|
||||
**Read §3 first if you read nothing else:** v0.118.0's R-106 half shipped INERT and live validation is
|
||||
what caught it — the recipe still said `"root"`, now with `namespace_state: resolved` beside it. Full
|
||||
account in the audit §6, filed as **R-125**.
|
||||
|
||||
## Part 0 — the answers, before the fix
|
||||
|
||||
### 0.1 Which items are actually open, and R-105/R-106's registration
|
||||
|
||||
`OPEN-ITEMS.md` calls itself "the single source of truth for open work" (`:1`), with `ROADMAP.md` keeping
|
||||
"the full history and reasoning" (`:3-4`).
|
||||
|
||||
| item | `ROADMAP.md` | `OPEN-ITEMS.md` | verdict |
|
||||
|---|---|---|---|
|
||||
| R-105 | row, `READY — 2026-07-28` (`:108`) | **absent** | **open but UNREGISTERED** |
|
||||
| R-106 | row, `READY — 2026-07-28` (`:109`) | **absent** | **open but UNREGISTERED** |
|
||||
| R-108 | row (`:111`) | row (`:50`) | registered |
|
||||
| R-109 | row (`:112`) | row (`:61`) | registered |
|
||||
|
||||
So R-109's own cell — "third recipe-completeness defect beside R-105/R-106" — was the **only** place in the
|
||||
register naming two open items. That is exactly the thread-loss the register exists to prevent, and it is
|
||||
itself a finding (filed **R-123**). Both now have rows.
|
||||
|
||||
**The set this task closes is R-106 + R-109**, matching the arc's stated definition of done (`OPEN-ITEMS.md:14`).
|
||||
**R-105 is NOT in it** and was not worked: it is M-sized and is about three *hub-held DR records* being `{}`
|
||||
(`hosts.dr_record_json`, `host_escrow.directive_json`, and the `drives` third — already traced and populated
|
||||
by the 2026-07-28 target move). Different fields, different owner, different size.
|
||||
|
||||
### 0.2 Where the recipe is generated — three producers, not two
|
||||
|
||||
| half | repo | function |
|
||||
|---|---|---|
|
||||
| host (guests/pbs/drives/pve_storage) | `felhom-agent` | `BuildDRRecipeHostHalf`, `internal/hub/dr_recipe.go:86` |
|
||||
| app (customer/apps/offsite_restic) | `felhom-controller` | `controller/internal/report/dr_recipe.go` |
|
||||
| **assembly + delivery** | `felhom.eu/hub` | `AssembleDRRecipe`, `internal/store/dr_recipe.go:104`; served by `handleDRRecipeDownload`, `internal/web/dr_recipe.go:14`, route `internal/web/server.go:439` |
|
||||
|
||||
R-109's "host-half" is therefore the **agent**, and the field must also pass the **hub's** allow-list — see §2.
|
||||
|
||||
### 0.3 What the namespace field actually contained — verified, and the brief was RIGHT
|
||||
|
||||
The eleven-session-old brief held up. Live, pre-fix, from the hub for **both** boxes:
|
||||
|
||||
```json
|
||||
"pbs": { "repo_id": "felhom-pbs", "namespace": "root", "latest_snapshot_id": "9201" }
|
||||
```
|
||||
|
||||
against `/etc/pve/storage.cfg` on the same boxes:
|
||||
|
||||
```
|
||||
pbs: felhom-pbs
|
||||
datastore felhom-offsite
|
||||
namespace demo-felhom # demo-hp reads: namespace demo-hp
|
||||
```
|
||||
|
||||
Traced to source: `Snapshot.Namespace` decodes `ns` (`internal/pbs/client.go:97`), which PBS does not echo
|
||||
per item once the list is namespace-scoped via `?ns=` (`:118-120`) → always empty → `ToHub` normalises empty
|
||||
to `"root"` (`internal/pbs/report.go:22-25`) → `latestPBSCoord` writes it in.
|
||||
|
||||
**The authority taken, and why:** storage.cfg's `namespace` on the pbs storage. It is the same field
|
||||
`vzdump --storage <pbs>` makes PVE read, and the agent's own verify client is built from it
|
||||
(`cmd/felhom-agent/main.go:1164`). Deriving the recipe from anything else is how it drifts again.
|
||||
|
||||
## 1. R-109's ambiguity is real, in the boxes' own pre-fix recipe
|
||||
|
||||
```json
|
||||
"pve_storage": [
|
||||
{ "name": "local-lvm", "type": "lvmthin", "content": "images,rootdir" },
|
||||
{ "name": "felhom-backup", "type": "local-dir", "content": "backup" },
|
||||
{ "name": "felhom-pbs", "type": "pbs", "content": "backup" },
|
||||
{ "name": "local", "type": "local", "content": "backup,import,vztmpl,iso" }
|
||||
]
|
||||
```
|
||||
|
||||
No `backup_target` key anywhere. `felhom-backup` (live, `/mnt/hdd_1`) and `local` (`/var/lib/vz`, archives
|
||||
frozen 2026-07-28) are both `content=backup` dir storages; `local` is also the *historically* correct answer,
|
||||
which is what makes guessing it so easy.
|
||||
|
||||
## 2. R-122 — a fourth defect, found here, and it had already shipped
|
||||
|
||||
`AssembleDRRecipe`'s `hostHalfShape`/`appHalfShape` are **allow-lists** dressed as forward-compat. The
|
||||
controller has emitted `offsite_restic` since fork-4 (`controller/internal/report/dr_recipe.go:39-41`, "so DR
|
||||
knows WHERE to recover from"); `appHalfShape` never listed the key. Verified both ways:
|
||||
|
||||
- **stored**: `dr_recipe.app_half_json` carries it for all three real customers —
|
||||
`peti-felhom`, `demo-felhom` (`u629488-sub1.your-storagebox.de:23/home/felhom-repo`), `demo-hp`.
|
||||
- **delivered**: the downloaded recipe's top-level keys were
|
||||
`recipe_version, customer, guests, pbs, drives, pve_storage, apps` — **no `offsite_restic`**.
|
||||
|
||||
So a restorer reading the recipe had **no offsite location at all**, for the whole life of the feature, with
|
||||
a green suite throughout — because the test fixture `drAppHalf` is hand-written and omits the field.
|
||||
|
||||
**Deviation from the task's §7.10 ("Findings — filed as R-n, none fixed"), stated rather than absorbed:**
|
||||
I fixed it. Reasons — (a) Part 0 authorises working the real set if it differs; (b) it is the same
|
||||
symptom the task is named for (the recipe is incomplete), and the worst instance, a whole section missing;
|
||||
(c) it is in the *same two structs* R-109 forced me to edit, and leaving one of three known keys off a
|
||||
drop-list I was already correcting would be indefensible. It is filed as R-122 with a SHIPPED disposition.
|
||||
|
||||
## 3. The before/after recipe — both boxes, quoted
|
||||
|
||||
```
|
||||
demo-felhom BEFORE "namespace":"root" backup_target absent offsite_restic absent
|
||||
AFTER "namespace":"demo-felhom" backup_target {resolved, felhom-backup, /mnt/hdd_1}
|
||||
offsite_restic {u629488-sub1…}
|
||||
demo-hp BEFORE "namespace":"root" backup_target absent offsite_restic absent
|
||||
AFTER "namespace":"demo-hp" backup_target {resolved, felhom-backup, /mnt/nvme-1tb}
|
||||
offsite_restic {u629488-sub3…}
|
||||
```
|
||||
|
||||
The two boxes DISAGREEING is the point — nothing is hardcoded. And the ambiguity was not theoretical:
|
||||
on both boxes `felhom-backup` holds an archive from **07-30 04:36** while `local` stops at
|
||||
**07-28 17:5x**, frozen at the target-move date. The recipe now names the live one.
|
||||
|
||||
Full evidence, all seven red-proofs and the publish observables:
|
||||
`documentation/audits/R106-R109-recipe-completeness-2026-07-30.md`.
|
||||
|
||||
## 4. Findings filed (none of them fixed except R-122, see §2)
|
||||
|
||||
| id | finding |
|
||||
|---|---|
|
||||
| **R-122** | `AssembleDRRecipe` allow-list dropped `offsite_restic` for the feature's whole life — **FIXED here**, hub v0.83.0 |
|
||||
| **R-123** | R-105 and R-106 were `READY` in `ROADMAP.md` with no `OPEN-ITEMS.md` row — referenced only inside R-109's prose. Registered here |
|
||||
| **R-125** | v0.118.0 shipped an inert R-106 because the "production path" test injected `fakeObserver` one layer below the break — **FIXED** in v0.118.1; filed for the doctrine point (name the seam you inject at) |
|
||||
| **R-124** | The recipe spells PBS's root namespace `"root"`, but the PBS API spells it `""` and there is no namespace literally named `root` — a restorer pasting it into `pct restore --ns root` would fail. Pre-existing wire convention, deliberately unchanged; documented at `PBSRootNamespace` |
|
||||
|
||||
## 5. Not done, and why
|
||||
|
||||
- **R-105, R-108, D5** — out of scope by the task's §6. R-108 blocks D5; starting either would leave both half-done.
|
||||
- **The backup machinery** — untouched. This corrects the record, not the doing.
|
||||
- **`sess-f` (0.116.0) and `drill-r50` (0.113.0) were not upgraded** — neither was named as a venue, and `drill-r50` is fenced by the task's §6.
|
||||
- **R-124 not fixed** — changing the wire's spelling of the root namespace mid-R-106 would shift the field's meaning during the fix meant to make it trustworthy.
|
||||
@@ -0,0 +1,117 @@
|
||||
# REPORT-r116-diag — the `/disks` payload captured, R-116's mechanism isolated (2026-07-30)
|
||||
|
||||
Read-only diagnosis run by CC on DooPlex. **No code written, nothing built, nothing published.**
|
||||
Full evidence: `documentation/audits/DIAG-r116-disks-payload-2026-07-30.md`.
|
||||
|
||||
A `REPORT-*.md` sibling, not the shared `REPORT.md` (`CLAUDE.md` parallel-session rule).
|
||||
|
||||
## Outcome
|
||||
|
||||
**Both goals met.** The `/disks` read path is solved and written down verbatim, proven by a
|
||||
present-drive control run *first*; and the absent-state payload was captured, which isolates the
|
||||
mechanism.
|
||||
|
||||
**R-116 is theory #1 — "the registry-union row writes `false`" — the theory that was raised, declared
|
||||
wrong, and retracted. The retraction was the error.**
|
||||
|
||||
In the absent state `/disks` returns **4 rows, not 3**. The drive appears twice and the two facts the
|
||||
controller needs sit on different rows:
|
||||
|
||||
| row | source | `mount_path` | `guest_path` | `backup_target` |
|
||||
|---|---|---|---|---|
|
||||
| `felhom-backup` | Observe (`disks.go:196-284`) | `""` | `""` | **`true`** |
|
||||
| `694034cc-…` (the UUID) | registry union (`disks.go:297-339`) | `/mnt/cel` | `/mnt/felhom-drives/cel` | **field ABSENT ⇒ `false`** |
|
||||
|
||||
So the row holding the flag contributes **no key** to `driveTargetByPath`, and the row that owns the key
|
||||
says `false` → `isTarget[a.Path]` is `false` → generic `storage_disconnected`. On return the rows
|
||||
re-merge into one carrying both facts → specific `backup_target_restored`. Applying
|
||||
`intermediary.go:602-618` to the captured payloads gives PRESENT `True` / ABSENT `False` /
|
||||
RETURNED `True` — **the live asymmetry reproduced from payload alone.**
|
||||
|
||||
The union row's `MountPath` survives the device because the union source is the systemd **`.mount` unit
|
||||
file** (`registry_known.go:40-75` via `main.go:605`→`:764`), which never reads the mount table. The
|
||||
dedup at `:298` therefore does not fire, because `seen` is keyed on the one field the absent state
|
||||
empties (`:290-295`).
|
||||
|
||||
**Theory #2 (the basis of the shipped v0.115.0) is false on both halves**; **#3 is false too**
|
||||
(`isTarget["/mnt/cel"]` is `false` as well). **v0.115.0 is provably inert** — its fallback calls
|
||||
`StablePathForRaw("")`, which returns `""` (`intermediary.go:69-75`), so it assigns nothing.
|
||||
|
||||
## The read path (this cost two prior sessions — it should never cost again)
|
||||
|
||||
The token plaintext exists in exactly one place: `bootstrap.json` **on the Proxmox host**. The agent's
|
||||
own store keeps SHA-256 hashes only (`tokenstore.go:26-32`), which is what defeated the earlier attempts.
|
||||
|
||||
```bash
|
||||
ssh felhom-pve
|
||||
B=/var/lib/felhom-agent/guests/9201/bootstrap/bootstrap.json
|
||||
TOK=$(python3 -c "import json;print(json.load(open('$B'))['local_api']['token'])")
|
||||
EP=$(python3 -c "import json;print(json.load(open('$B'))['local_api']['endpoint'])")
|
||||
curl -sS -k -H "Authorization: Bearer $TOK" "https://$EP/disks" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Control run, live felhom-pve, drive present: **HTTP 200, 2483 bytes, 4 plausible rows** — so Part 5's
|
||||
0-rows-on-a-present-drive failure mode is excluded.
|
||||
|
||||
## Where the absent state was staged
|
||||
|
||||
No new box. The existing DooPlex **nested-PVE drill fixture** (`drill/drill.qcow2`, snapshot `virgin`) —
|
||||
my own host, zero production exposure, and it can hot-unplug a disk for a genuine device loss. Run with
|
||||
the **byte-identical live agent binary** (`sha256 f48544ad…`, `--version` 0.115.0) and every
|
||||
state-producing step through the real endpoints (`format` → `assign` → `guest-attach` →
|
||||
`backup/target`). Its present-state row matched felhom-pve's control run field-for-field before it was
|
||||
trusted. Non-production aspects (root/direct privileged mode, stubbed hub, a hand-written bearer-token
|
||||
record, no controller) are enumerated in the audit §4.
|
||||
|
||||
## Two new findings, filed not chased
|
||||
|
||||
- **R-117 (READY M) — outranks R-116.** After a detach/reattach the guest's bind is a **dead mount**:
|
||||
host is healthy on the new device node, guest still names the old one, and `ls`/write through it
|
||||
return **`EIO`** — while `/disks` reports `attached` + `bound_under_parent:true` + `backup_target:true`.
|
||||
`planDriveGates` therefore takes the `Return` branch and **restarts the customer's apps onto a dead
|
||||
namespace, reporting healthy, with no alarm on any channel.** R-113's conjunction cannot catch it:
|
||||
one half is satisfied by the stale entry, the other by the new device, and neither compares them.
|
||||
This is the "stale bind" seen and dismissed as cosmetic in three consecutive runs.
|
||||
- **R-118 (READY XS).** An absent drive's union row reports the **root filesystem's** capacity as its own
|
||||
(46 GiB / 9.2 % for a 4 GB drive) — `statfsCapacity` at `disks.go:335-338` statfs's a bare directory on
|
||||
root. `observe.go:176-183` guards the Observe path against exactly this; the union path does not.
|
||||
`durable_id` is still correct, so re-attach identity is safe — it is a false capacity, not a DR mis-id.
|
||||
|
||||
## Register
|
||||
|
||||
`documentation/backlog/OPEN-ITEMS.md` — R-116 updated with the mechanism and the fix constraints;
|
||||
R-117 and R-118 added. The single register edit this session makes.
|
||||
|
||||
## Record correction
|
||||
|
||||
The brief's baseline `controller 0.185.1` is the version the **golden bakes**. **0.186.0** (R-114 +
|
||||
R-112, 2026-07-29) is what **demo-felhom** runs — **demo-hp is still on 0.185.1**, so the fleet is
|
||||
split, and R-114's `TargetAbsent` branch exists only on demo-felhom. Confirmed: agent **0.115.0**
|
||||
(felhom-pve) / **0.113.0** (demo-hp), hub **0.81.0** (manifest pin and live pod image agree),
|
||||
host-install **1.22.0**, `felhom.eu` HEAD `c3ce4c7`.
|
||||
|
||||
> **Correction, 2026-07-30.** As first written this section said 0.186.0 was what *both* demo boxes run.
|
||||
> That was wrong — only felhom-pve's guest was sampled and the result generalised to the fleet. demo-hp
|
||||
> re-checked directly → `0.185.1`. Fixed here and in the audit's baseline table.
|
||||
|
||||
## Teardown and fences
|
||||
|
||||
Drill guest destroyed, scratch storage removed, mount unit deleted, secrets `shred -u`'d, VM powered
|
||||
off, **`drill.qcow2` restored to `virgin`** (the golden-bake fixture is exactly as found), scratch qcow2
|
||||
and console dumps deleted. DooPlex `/mnt/5_hdd` at 24 %, unchanged.
|
||||
|
||||
Both demo boxes **read-only throughout** and re-verified after teardown: demo-hp `local-lvm` **38.83 %**
|
||||
(identical before/after and to Part 5), `drill-r50` still stopped, felhom-pve `felhom-backup` still
|
||||
active on `/dev/sdb`, both guests running, **v0.115.0 untouched**.
|
||||
|
||||
`sess-d-0452c4` now reads **STALE**, and the delete gate refuses only on ONLINE
|
||||
(`hub/internal/web/customer_delete.go:220-228`) — so it **is** now deletable; the command is recorded in
|
||||
the audit rather than executed (customer delete runs external teardown plus a DB purge). **`sess-c` is
|
||||
also still present and was not recorded by the Session-C audit** — same terms.
|
||||
|
||||
## Not done, deliberately
|
||||
|
||||
No fresh controller gate-log / hub-event correlation: that observable was already captured live and
|
||||
identically twice, and only the payload was missing. Staging a controller meant a hub customer, a
|
||||
pairing, a golden fetch and a claim — the work that consumed the three prior sessions. The audit §8
|
||||
correlates the payload to those existing measurements and labels that step as inference.
|
||||
@@ -0,0 +1,96 @@
|
||||
# REPORT — SPIKE R-117: a dead bind that reports healthy (2026-07-30)
|
||||
|
||||
Written as `REPORT-<topic>.md`, not `REPORT.md`, per this repo's parallel-session rule and the
|
||||
established local pattern (`REPORT-r116-diag.md`, `REPORT-session-c.md`, …). The shared `REPORT.md`
|
||||
was not touched.
|
||||
|
||||
**Class: Spike.** Deliverable is a findings document. **No production code was written; no `.go` file
|
||||
in either repo was modified; nothing was built for deployment, published, or version-bumped.**
|
||||
|
||||
## Deliverables
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `documentation/audits/SPIKE-r117-bind-liveness-2026-07-30.md` | **new** — the findings doc (Q1–Q7, evidence, recipe, probe comparison, recommendation) |
|
||||
| `documentation/backlog/OPEN-ITEMS.md` | **R-117 row rewritten** with the mechanism, the reproduction recipe and the fix constraint — the one register edit, per the brief |
|
||||
|
||||
**No CHANGELOG entry.** This repo's changelogs are per-area (`hub/`, `scripts/`, `website/`); a
|
||||
documentation-only change belongs to none of them. Stated rather than silently skipped, per standing
|
||||
rule 4.
|
||||
|
||||
## Baselines
|
||||
|
||||
agent `main` **v0.116.0** @ `d4eb259` · controller `main` **v0.186.0** @ `b331f18` ·
|
||||
`felhom.eu` HEAD `29bcfeb` · hub **live 0.82.0** · golden **0.186.0** ·
|
||||
demo-hp PVE 9.2.2 / kernel 7.0.2-6-pve, **live agent 0.113.0** (= manifest `MinAgent`; never used as
|
||||
the source of a behavioural claim — every predicate result came from a probe built from `main`).
|
||||
|
||||
## Results
|
||||
|
||||
**All seven questions answered empirically.**
|
||||
|
||||
- **Q1 — reproduced 3/3**, two device classes, on a purpose-built scratch LXC (9301) on demo-hp.
|
||||
**The device-node change is a consequence of the defect, not a precondition** — control test: with
|
||||
the stale bind held the drive returns as `sdc` (8:32); released, the letter is reused (`sdb`, 8:16).
|
||||
- **Q2 — two death states**: device removed ⇒ superopts gain `shutdown`, `EIO`(5) on read and write,
|
||||
host and guest; device errors in place ⇒ `emergency_ro`, write `EROFS`(30), reads served from cache.
|
||||
The raw host mount is genuinely healthy in both. **No cross-device mis-identification is possible**
|
||||
on this path — the unit is fs-UUID-keyed.
|
||||
- **Q3 — confirmed at source and live.** Both halves of the R-113 conjunction compare **field 5** of a
|
||||
mountinfo line and **never read field 3 (`major:minor`)**, so neither can see that the bind and the
|
||||
raw mount name different devices. Measured `BoundUnderParent = TRUE` over an `EIO` namespace.
|
||||
- **Q4 — a pure-`/proc` check costs 0.16–0.45 ms**, cannot hang, spins up no disk, writes nothing.
|
||||
**`statfs` and `getdents` both SUCCEED on a dead namespace** — probes built on either are hollow. The
|
||||
hang case is below; it is the sharpest result in the run.
|
||||
- **Q5 — the agent**, and not on balance: the controller runs inside the guest and cannot see the host
|
||||
mount tables the check needs.
|
||||
- **Q6 — recovery works in place, guest never restarted** (init PID identical). **The repair code
|
||||
already exists and three call sites already invoke it**, including the controller's `Return` branch
|
||||
*before* it restarts apps — all defeated by one early return.
|
||||
- **Q7 — a bind can die in steady state, no cycle at all.** The gate produces no action and **nothing
|
||||
is emitted on any channel.** A `Return`-branch fix cannot reach this half.
|
||||
|
||||
## Q4's hang case — measured, and it is the sharpest result
|
||||
|
||||
Against a `dmsetup suspend`ed device (I/O queues instead of returning `EIO`):
|
||||
|
||||
- **P1 and P2 completed in 364 µs / 206 µs.** They read `/proc`, so no block device is involved.
|
||||
- **`statfs` and `getdents` completed and reported HEALTHY** — on a wedged device they do not even hang.
|
||||
- **Every probe that touches the device blocked, including a buffered write with no `fsync`** — the
|
||||
`O_CREAT` metadata path needs journal access (`wchan=do_get_write_access`). There is no cheap-and-safe
|
||||
write probe.
|
||||
- **The blocked process survived `SIGTERM` and `SIGKILL`** (`stat=D`, still alive 3m50s after `kill -9`)
|
||||
and died only when the device was resumed. So **`systemctl restart felhom-agent` would hang**, leaving
|
||||
the agent unrecoverable until the device returns or the host reboots. The thread count does not reveal
|
||||
the leak (5→5, 5→6).
|
||||
|
||||
**A timeout protects the caller's control flow and nothing else.** This turns "prefer a cheap probe" into
|
||||
a fence: **the fix must issue no block I/O.**
|
||||
|
||||
## Teardown — done, all three layers
|
||||
|
||||
Guest 9301 destroyed; `r117scratch` removed; both dm devices and both loop devices gone; `scsi_debug`
|
||||
unloaded (`/dev/sd*` back to `sda1..3`); no `r117` mounts, `/mnt` and `/root` exactly as found; `local`
|
||||
**37.02 %** against a session-start **37.00 %**. Fences re-verified *after* teardown: 9201 `running`,
|
||||
`drill-r50` `stopped`, `local-lvm` **38.84 % byte-identical**, `felhom-backup` `content backup`
|
||||
unchanged, live `/mnt/felhom-drives` intact with both submounts, agent service `active`. **Layer 3 is
|
||||
genuinely empty** — 9301 had no network interface and ran no controller, so no hub-side record was ever
|
||||
created.
|
||||
|
||||
**Ordering trap worth keeping:** a suspended dm device must be `dmsetup resume`d *before* any `umount`,
|
||||
or the teardown itself blocks on the same uninterruptible sleep.
|
||||
|
||||
## Not measured
|
||||
|
||||
No load or duration testing of the recommended check — P1/P2 were single calls, not a sustained
|
||||
reconcile loop on a many-drive box. Nothing suggests a problem (they are two `/proc` reads the code
|
||||
already performs), but it was not measured.
|
||||
|
||||
## Findings filed, none fixed
|
||||
|
||||
R-117 (mechanism + recipe), **R-117a** steady-state death with no event (HIGH, larger than R-117 as
|
||||
filed), **R-117b** `statfs`/`getdents` are hollow liveness probes, **R-117c** three untested comments
|
||||
promising "live + usable in the guest", **R-117d** the self-heal that already exists is short-circuited
|
||||
(HIGH), **R-117e** both demo boxes share one failure domain — no route survives the site losing internet,
|
||||
including the WireGuard OOB path, **R-117f** an I/O liveness probe turns a wedged drive into an
|
||||
unkillable agent (HIGH — disqualifies a whole probe class).
|
||||
@@ -0,0 +1,160 @@
|
||||
# REPORT — hub v0.88.0: the WAL that never was (R-172), plus R-173 found
|
||||
|
||||
**Session artefact naming:** written as `REPORT-r172-hub-wal.md`, not `REPORT.md`, per this repo's
|
||||
parallel-session rule — the shared `REPORT.md` belongs to the controller boot-recovery session that
|
||||
ran immediately before this one and must not be clobbered.
|
||||
|
||||
**Repo:** `felhom.eu` (hub `v0.87.0` → **`v0.88.0`**) · **Trigger:** a `HOST STALE` banner the
|
||||
operator spotted on `hub.felhom.eu` after the previous session finished.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the alarm actually was
|
||||
|
||||
**Not the agent, not the guest.** The agent was up **2 days**, never restarted, and actively
|
||||
reconciling; the controller was reporting normally (the header read "Last report just now", 0.190.0,
|
||||
10/10 containers). The failure was the hub **writing** the host report:
|
||||
|
||||
```
|
||||
20:26:34 [ERROR] Failed to save host-report from demo-felhom-8363b5: database is locked (5) (SQLITE_BUSY)
|
||||
20:41:32 [ERROR] Failed to save host-report from demo-felhom-8363b5: database is locked (5) (SQLITE_BUSY)
|
||||
20:42:32 [INFO] Host staleness: demo-felhom-8363b5 ok → stale (host_stale)
|
||||
20:42:33 [INFO] Operator email sent for demo-felhom/host_stale
|
||||
```
|
||||
|
||||
The chain, and the margin is exactly one retry wide: reports are every **15 min**, staleness fires at
|
||||
**30 min**, the hub returns **500** on `SQLITE_BUSY` without retrying, and the agent logs
|
||||
`keeping current interval` and waits a full interval without retrying either. **Two consecutive
|
||||
collisions = a false alarm.** It had already fired once that day (19:12:32, recovered 19:20:32).
|
||||
|
||||
**Was it caused by the preceding session?** Partly amplified, not caused. 13 collisions in one pod
|
||||
lifetime; **the first at 15:56 CEST, ~3 h before that session's first deploy**. 7 of 13 fell inside
|
||||
its window of ~13 controller restarts, which raises write concurrency — so the burst made a
|
||||
pre-existing fault more likely, and the fault was not new.
|
||||
|
||||
## 2. Root cause — the pragmas were never applied
|
||||
|
||||
The DSN was `?_journal_mode=WAL&_busy_timeout=5000`. That is **mattn/go-sqlite3** syntax. The driver
|
||||
is **modernc.org/sqlite v1.45.0**, whose `applyQueryParams` (confirmed at source in the module cache)
|
||||
reads only `_pragma`, `_time_format`, `_time_integer_format`, `_txlock` and `_inttotime` — and
|
||||
**silently ignores everything else**. No error, no warning.
|
||||
|
||||
So the hub ran in the default **rollback-journal** mode with **`busy_timeout=0`** for its entire life
|
||||
while its own source said WAL. In rollback-journal mode a reader excludes a writer, so rendering an
|
||||
operator page can block a host report — which is precisely the observed 500.
|
||||
|
||||
**The observable that proved it before any code changed:** a 128 MB `/data/hub.db` with **no
|
||||
`-wal`/`-shm` file beside it while the database was open**. In WAL mode those must exist.
|
||||
|
||||
This is the project's recurring class — a configuration asserting an invariant the code does not
|
||||
provide — and it is the second one this week.
|
||||
|
||||
## 3. The fix
|
||||
|
||||
```
|
||||
?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_txlock=immediate
|
||||
```
|
||||
|
||||
| Parameter | Why it is not optional |
|
||||
|---|---|
|
||||
| `journal_mode(WAL)` | readers and one writer proceed concurrently, so a page render can no longer block a report; it is a property of the database FILE and persists once set |
|
||||
| `busy_timeout(5000)` | writers still serialise; without a timeout SQLite returns `SQLITE_BUSY` *immediately* rather than waiting |
|
||||
| `_txlock=immediate` | **the one that is easy to miss.** `database/sql`'s `Begin()` is DEFERRED, so a read-then-write transaction must upgrade its lock, and a failed upgrade is `SQLITE_BUSY_SNAPSHOT` — which **`busy_timeout` does not retry**. This store has **10+ `db.Begin()` sites and they are all write paths** (customer delete/reset, wg, appliance, pbsdr, telemetry, log bundles). WAL + busy_timeout alone would have shipped half a fix with a known un-retryable path left open |
|
||||
|
||||
**Retry options (b) and (c) from R-172 were deliberately NOT taken.** With readers no longer blocking
|
||||
writers and the upgrade path covered, a `SQLITE_BUSY` reaching a handler should now be rare enough to
|
||||
be a real signal; a retry would hide it. Revisit only on evidence.
|
||||
|
||||
## 4. Tests and the red-proof
|
||||
|
||||
**Every assertion reads the value back from the DATABASE, never the DSN string** — a string assertion
|
||||
would have passed happily for the entire life of the bug. Six tests in `internal/store/pragma_test.go`:
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `TestStorePragmasAreActuallyApplied` | runtime `journal_mode` = wal, `busy_timeout` ≥ 5000 |
|
||||
| `TestStoreWALFilesExistWhileOpen` | `-wal`/`-shm` exist beside an open DB — **the production signature, pinned** |
|
||||
| `TestStoreReaderDoesNotBlockWriter` | the CONSEQUENCE: a write during a held read succeeds |
|
||||
| `TestStoreConcurrentWritersDoNotReturnBusy` | 8 concurrent writers all wait rather than error |
|
||||
| `TestStoreTransactionUpgradeDoesNotReturnBusySnapshot` | 6 racing read-then-write transactions all commit |
|
||||
| `TestSQLiteDriverIgnoresMattnStyleParams` | guards the ROOT CAUSE: fails if the pragmas are "tidied" back to mattn form; skips itself with instructions if a future driver starts honouring them |
|
||||
|
||||
**Red-proof — restore the DSN that shipped.** Observed FAIL, then reverted with a passing control:
|
||||
|
||||
```
|
||||
journal_mode = "delete", want "wal"
|
||||
hub.db-wal is missing beside an OPEN database
|
||||
a write FAILED while a read was open: database is locked (5) (SQLITE_BUSY) ← the live error, exactly
|
||||
```
|
||||
|
||||
`go build ./... && go vet ./... && go test ./...` in `hub/` → **rc=0**. `scripts/repo_gates.py --fast`
|
||||
→ all 5 gates OK.
|
||||
|
||||
## 5. Operational consequence — handled, not discovered later
|
||||
|
||||
**A WAL database cannot be copied by taking `hub.db` alone.** A committed transaction may still live
|
||||
in `hub.db-wal`, so a bare `cat` yields a copy that **opens cleanly and silently omits the newest
|
||||
writes** — the worst possible shape for a credential lookup. The break-glass root-password retrieval
|
||||
in `documentation/operations/nodes.md` used exactly that command, and `_recovery-inventory-2026-07-28.md`
|
||||
records it as a past action that reads like a recipe. Both are now WAL-aware: copy the `-wal`
|
||||
alongside, `|| true` because an absent `-wal` is legitimate, and **shred both** (the WAL holds the
|
||||
same secrets).
|
||||
|
||||
Not hypothetical: the live `-wal` measured **729,272 bytes** during verification, all of which a bare
|
||||
`cat` would have dropped.
|
||||
|
||||
## 6. Live verification
|
||||
|
||||
- Image built and pushed; **`manifests/hub.yaml` 0.87.0 → 0.88.0** (the only thing ArgoCD deploys
|
||||
from), hard-refresh + deliberate sync (auto-sync is OFF). ArgoCD **Synced / Healthy**.
|
||||
- `/data/` now shows **`hub.db-wal` and `hub.db-shm`** beside the open DB — the exact observable whose
|
||||
absence proved the bug.
|
||||
- **Zero `SQLITE_BUSY` since the rollout.**
|
||||
- Host report landed at 21:11:33; staleness checker reports `2 ok, 0 stale` — `demo-felhom-8363b5`
|
||||
is back to `ok`.
|
||||
- `PRAGMA integrity_check` → **`ok`**, `journal_mode` → **`wal`**, all tables intact
|
||||
(hosts 4, customer_configs 6, host_recovery 4, host_reports 2756, events 2293). Read via the new
|
||||
WAL-aware copy recipe and shredded afterwards.
|
||||
|
||||
## 7. A mistake I made, and it caused a real outage
|
||||
|
||||
**I OOM-killed the hub pod three times** with a 60-concurrent page-render load test intended to prove
|
||||
the fix under contention. The pod's limit is **256 Mi**; 60 simultaneous renders of a heavy customer
|
||||
page exceeded it (`OOMKilled`, exit 137, readiness probe timeouts). The hub was unavailable for parts
|
||||
of ~6 minutes and recovered on its own.
|
||||
|
||||
Three things worth stating plainly:
|
||||
|
||||
1. **It was not the WAL change.** WAL's extra footprint is the 32 KB `-shm` mapping; the OOM was
|
||||
template rendering under concurrency I created.
|
||||
2. **I should not have run it.** DooPlex is **Tier 2 — precious**, and the hub is part of the
|
||||
recovery chain. A synthetic load test at that concurrency against a memory-limited pod on that box
|
||||
was the wrong call; the unit tests already proved the property, and the live proof needed was the
|
||||
`-wal` file plus a clean report — both of which I already had.
|
||||
3. **It did produce one piece of genuine evidence**, which does not excuse it: after three hard kills
|
||||
the WAL replayed cleanly and `integrity_check` returned `ok`, which is a real (if unplanned)
|
||||
demonstration of WAL crash-safety on this volume.
|
||||
|
||||
No data was lost. The pod is `Ready`, restart count 3, serving normally.
|
||||
|
||||
## 8. Backlog
|
||||
|
||||
- **R-172 → CLOSED**, with the root cause recorded as *the pragmas were never applied*, not as tuning.
|
||||
- **R-173 → NEW.** While checking who copies `hub.db` for the WAL change, I found `pvc/hub-data`
|
||||
carries `recurring-job-group.longhorn.io/default: disabled`, and `backup-daily` + `backup-weekly`
|
||||
are the only recurring jobs and both target `default`. **The hub database has no volume-level
|
||||
backup** — and it holds `host_recovery` (every box's break-glass root password), `host_escrow` +
|
||||
`host_escrow_superseded`, `host_pbs_secrets`, `customer_configs`, `dr_recipe` and the wg peers.
|
||||
Filed rather than fixed: whether the exclusion is deliberate is a question for the operator, and the
|
||||
manual hot copy recorded in `_recovery-inventory` is not a backup. `grep` established the ID free.
|
||||
|
||||
## 9. Observations — noticed, NOT acted on
|
||||
|
||||
1. **The hub returns HTTP 500 for a transient lock**, which is what turned a retryable condition into
|
||||
an alarm. Left as-is deliberately (§3) so a surviving `SQLITE_BUSY` stays visible.
|
||||
2. **The agent does not retry a failed report**, so one collision costs a full 15-minute interval —
|
||||
half the staleness budget. Same reasoning; if collisions recur, this is the cheaper of the two
|
||||
retry fixes.
|
||||
3. **`store.New` sets no `SetMaxOpenConns`**, so `database/sql` may open unbounded connections against
|
||||
a single-writer database. Not changed here — WAL plus the immediate-lock covers the observed
|
||||
failure, and bounding the pool changes latency characteristics that nothing currently measures.
|
||||
@@ -0,0 +1,203 @@
|
||||
# REPORT — installer-channel record correction + R-29 filing (2026-07-29)
|
||||
|
||||
Two commits, documentation only. No code, no version bump, no CHANGELOG entry, no build, no deploy,
|
||||
no box touched. Written as `REPORT-<topic>.md` per `CLAUDE.md:82-87` so root `REPORT.md` (the E-2
|
||||
increment-1 report) is preserved.
|
||||
|
||||
| # | Commit | Baseline | Scope |
|
||||
|---|--------|----------|-------|
|
||||
| 1 | `d4c07873ca0c3d3e547373a9fafc0e472a6535e8` | `36d635a4cdc1`, unmoved | Retract a false R-94/E-2d finding; open R-110 |
|
||||
| 2 | (this commit) | `d4c07873`, unmoved | File R-29 to the register; three record defects; this report |
|
||||
|
||||
---
|
||||
|
||||
## Commit 1 — `d4c07873`
|
||||
|
||||
### What was false
|
||||
|
||||
`36d635a4` recorded that `felhom-bootstrap.sh` fetches the installer **from the hub**, that the hub
|
||||
therefore serves 1.19.0, and that a fresh ISO install runs the pre-E-2 installer. All three wrong.
|
||||
The claim had propagated into two `OPEN-ITEMS.md` rows, the ranking rationale, and `ROADMAP.md:149`.
|
||||
|
||||
### Confirmation table — all PASS
|
||||
|
||||
| # | Claim | Read at | Result |
|
||||
|---|-------|---------|--------|
|
||||
| F1 | bootstrap fetches from the **website** | `scripts/iso/felhom-bootstrap.sh:96` | PASS — `INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}"` |
|
||||
| F2 | hub-rendered command points at the same URL | `customer_unified.html:563`, `:1262` | PASS — and **three** emission sites, not two: `:563` static, `:1262` JS error branch, **`:1267` JS success branch** |
|
||||
| F3 | website serves `/scripts/` from a git-sync tree tracking `main` | `manifests/webpage.yaml` — nginx `:74-77`, sparse-checkout CM `:211-218`, git-sync `:272-281`, init `:299-307` | PASS — `--branch=main --period=30s --link=current`; sparse-checkout `/website/` + `/scripts/`; `location /scripts/ { root …/current; }`. No image build, no ArgoCD step |
|
||||
| F4 | `hostInstallVersion` selects nothing | `configs.go:28`, `:487`; `render_test.go:219`; `customer_unified.html:494` | PASS — repo-wide grep returns exactly those 4 code sites; all other hits prose. Rendered as a text label |
|
||||
| F5 | every generated flag is parsed by 1.22.0 | generator `customer_unified.html:1206-1239` vs parser `felhom-host-install.sh:1175-1212` | PASS — `--mode --cores --memory --vmid --node --acl-storages --operator-pubkey-file --preserve-state-from --skip-provision --dry-run --preflight-only --allow-new-leaf` (+ `--customer-id`); every one a parser case. **No functional gap** |
|
||||
| F6 | installer is 1.22.0 | `scripts/felhom-host-install.sh:187` | PASS |
|
||||
| F7 | the drift test is hollow | `render_test.go:219-221` | PASS — `strings.Contains(html, hostInstallVersion)` compares the constant to itself; passes at any value |
|
||||
|
||||
### Live command 1 — what the URL actually serves
|
||||
|
||||
```
|
||||
$ curl -fsS https://felhom.eu/scripts/felhom-host-install.sh | grep -m1 '^SCRIPT_VERSION='
|
||||
SCRIPT_VERSION="1.22.0" # the SINGLE version source (F-1): -h, the run banners, and the hub
|
||||
```
|
||||
|
||||
### Live command 2 — the drift gate's real state
|
||||
|
||||
```
|
||||
$ python3 scripts/hostinstall_gates.py; echo "exit=$?"
|
||||
ok: SCRIPT_VERSION=1.22.0
|
||||
ok: header has no version literal
|
||||
FAIL: hub Setup-tab hostInstallVersion=1.19.0 != SCRIPT_VERSION=1.22.0 (F-1: bump both together)
|
||||
ok: age is in the installed package set
|
||||
… (six further ok lines) …
|
||||
hostinstall gates: 1 FAILURE(S)
|
||||
exit=1
|
||||
```
|
||||
|
||||
### Phase 0 source read — PAIRING reaches the same installer invocation
|
||||
|
||||
Mode selection `felhom-bootstrap.sh:537-541`: a fresh VM with no baked customer-id calls
|
||||
`run_pairing`. On HTTP 200 from `/api/v1/appliance/poll` the loop writes the hub-delivered
|
||||
`FELHOM_CUSTOMER_ID` + `FELHOM_RETRIEVAL_PASSPHRASE` into the 0600 env, re-sources it, and calls
|
||||
`run_direct` **in the same invocation** (`:495-499`). `run_direct` is the single site that fetches
|
||||
`$INSTALL_URL` (`:322-330`), builds the args (`:334`) and invokes `bash "$SCRIPT_TMP" "${args[@]}"`
|
||||
(`:343`). The customer it yields is the one the operator bound — claimable. **So the ISO leg is the
|
||||
spine for E-2d**, not an obstacle to it.
|
||||
|
||||
### Rows changed
|
||||
|
||||
| ID | Before | After |
|
||||
|---|---|---|
|
||||
| R-94 (line 14) | `READY — deferred until E-2d`, blocked on E-2d, false ISO/hub framing | `READY (XS)`, blocked on nothing, retracted + re-scoped to three legs |
|
||||
| R-94 (line 16) | duplicate row, `READY #2`, 1.19.0 vs 1.20.0 | **deleted** — merged |
|
||||
| R-110 | did not exist | opened, `WAITING-ON-OPERATOR (S)` |
|
||||
| E-2d | ISO implied as obstacle | Next-action appended; ISO is the spine |
|
||||
| ranked list | 1 R-95 · 2 R-94 (high-consequence) · 3 R-86 · 4 R-87 | 1 R-95 · 2 R-94 **de-ranked** · 3 R-86 · 4 R-87 · 5 R-110 |
|
||||
|
||||
Files: `documentation/backlog/OPEN-ITEMS.md`, `documentation/backlog/ROADMAP.md`,
|
||||
`documentation/runbooks/day0-install.md`.
|
||||
|
||||
---
|
||||
|
||||
## Commit 2 — R-29 filing + record hygiene
|
||||
|
||||
### The §1.1 ruling — is R-29 the right home for a non-design-v2 gate? **Yes. Proceeded.**
|
||||
|
||||
R-29's title says *"the design-v2 green gates"*, and `scripts/hostinstall_gates.py` is not one — it
|
||||
comes from drill F-1 (2026-07-12) and postdates the item. Four things decide it anyway:
|
||||
|
||||
1. **R-29's own audit list already spans well beyond design-v2 subject matter.** It names
|
||||
`docker_run_volume_path_gate` (docker mount safety), `offbox_rename_gate`, `app_row_dedup_gate`
|
||||
and `manifest_bearer_gate` (secrets — `runbooks/secrets.md:76`). The title is a misnomer relative
|
||||
to the item's own body.
|
||||
2. **Part (b) — "the systemic half is the real item" in R-29's words — is stated with no
|
||||
design-v2 restriction.** It is about the *enforcement mechanism*: "the gates run only when a human
|
||||
remembers to run them… decide where they run (pre-push hook, `build.sh` step, or a CI job) and make
|
||||
a red gate block the train." That is gate-agnostic and repo-wide.
|
||||
3. **`hub_confirm_gate.py` is already on R-29's list and lives in the same `scripts/` directory** as
|
||||
`hostinstall_gates.py`. Wiring one and not the other would be arbitrary.
|
||||
4. **Identical failure shape, identical genre.** Both self-describe as mechanical grep-assertions
|
||||
(`hostinstall_gates.py:2`, `hub_confirm_gate.py:1-8`); both exist, assert something true, and are
|
||||
invoked by nothing.
|
||||
|
||||
R-29 has already absorbed one independent re-raise without minting an ID (2026-07-18 rehearsal note)
|
||||
and says so explicitly. This is the third. No new ID minted.
|
||||
|
||||
### Orphan-search evidence — `hostinstall_gates.py` and `hub_confirm_gate.py`
|
||||
|
||||
Re-established at `d4c07873`. Pattern `hostinstall_gates\|hub_confirm_gate`.
|
||||
|
||||
| # | Scope | Command | Result |
|
||||
|---|-------|---------|--------|
|
||||
| S1 | `felhom.eu`, all file types | `grep -rn "$PAT" . --exclude-dir=.git` | **19 hits, zero invocations.** All are docstrings (`scripts/hostinstall_gates.py:5`, `scripts/hub_confirm_gate.py:7`), code comments (`hub/internal/web/configs.go:27`, `scripts/felhom-host-install.sh:189`) or prose (`REUSE.md:62`, `CONTEXT.md:540,564`, `hub/CHANGELOG.md:371,1292,1351,1385`, `scripts/CHANGELOG.md:483,524`, 3 files under `documentation/audits/`, `ROADMAP.md:149,158`, `OPEN-ITEMS.md:14`) |
|
||||
| S2 | sibling repos | `grep -rln "$PAT" /mnt/5_hdd/felhom.eu/git --exclude-dir=.git` minus this repo | 3 files, all in `.claude-memory/` (`MEMORY.md`, `drtier-by-default-2026-07-12.md`, `polish-batch-2026-07-13.md`). Notes, not invokers |
|
||||
| S3 | `~/.claude` | `grep -rln "$PAT" /home/kisfenyo/.claude`, and targeted on `settings*.json`, `skills/`, `hooks/` | **Zero hits in settings, skills or hooks** — where an invoker would live. Remaining hits are `file-history/` (Claude Code's own backups of files edited in past sessions) and `paste-cache/` (pasted task specs). Neither is an invocation site |
|
||||
| S4 | git hooks | `ls -1 .git/hooks/ \| grep -v '\.sample$'` | **Empty — every hook is a `.sample` stub** |
|
||||
| S5 | build files | `find . -type f \( -iname Makefile -o -iname '*.mk' -o -iname justfile -o -iname 'Taskfile*' \)` | Only `hub/Makefile`; `grep -n gate hub/Makefile` → **zero occurrences** |
|
||||
| S6 | CI | `find . -type d \( -name .github -o -name .gitea -o -name .woodpecker* -o -name .drone* -o -name .circleci \)` | **Empty — `felhom.eu` has no CI configuration at all** |
|
||||
|
||||
Of the four gates in `scripts/`, only `site_gates.py` is mandated (`CLAUDE.md:153`);
|
||||
`manifest_bearer_gate.py` is named in `runbooks/secrets.md:76`.
|
||||
|
||||
### Rows changed
|
||||
|
||||
| ID | Before | After |
|
||||
|---|---|---|
|
||||
| **R-29** | **absent from `OPEN-ITEMS.md`** (`grep -c` → 0) while present at `ROADMAP.md:158` since before the 2026-07-27 rebuild | **opened, `READY (S for (a) / M for (b))`**, owner CC |
|
||||
| R-94 | leg (b) stood alone | leg (b) carries `→ R-29` as its class. Row otherwise untouched |
|
||||
| E-2d | cited `:322-341` for an invocation at `:343` | cited `:322-343`, with the fetch / args / call each pinned separately |
|
||||
| R-95, R-86, R-87 | `READY #1`, `READY #3`, `READY #4` | `READY` — markers dropped (see below) |
|
||||
|
||||
Not added to the ranked list under *"Why the READY rows rank this way"*: that list is a top-N
|
||||
rationale, not a complete ordering (R-99, R-102, R-103, R-104, R-108, R-109, R-89, R-92, R-93 and
|
||||
E-2d are all READY and absent from it). Ranking R-29 was not asked for and would be a priority claim
|
||||
this task has no basis to make.
|
||||
|
||||
### §2.2 — markers dropped, not renumbered
|
||||
|
||||
The `#1/#3/#4` markers duplicated ranked-list positions 1/3/4 for exactly those three rows; two
|
||||
orderings of one set is what produced the hole when the `#2` row was merged in `d4c07873`. Removing
|
||||
the duplicate leaves the ranked list as the single maintained ordering.
|
||||
|
||||
### The other two record defects
|
||||
|
||||
- `ROADMAP.md:147` — cited a non-existent **R-164**; it means controller **v0.164.0**'s
|
||||
deliberate-stop filter. Corrected. (It had already cost one max-ID scan a false positive.)
|
||||
- `CONTEXT.md:564` — asserted in the present tense that the single VERSION source is *"gated by
|
||||
`scripts/hostinstall_gates.py`"*. The gate exists, asserts exactly that, is red, and runs nowhere.
|
||||
Corrected to say the cross-check exists but is not enforced, tracked as R-94 leg (b) / R-29.
|
||||
`hub/internal/web/configs.go:27` carries the same false claim in a code comment and was left alone
|
||||
— R-94 leg (b) territory, needs a hub build.
|
||||
|
||||
Files: `documentation/backlog/OPEN-ITEMS.md`, `documentation/backlog/ROADMAP.md`,
|
||||
`documentation/backlog/README.md`, `CONTEXT.md`, this file.
|
||||
|
||||
---
|
||||
|
||||
## Commit 3 — `de5a3e5` — the record-hygiene rider
|
||||
|
||||
Baseline `7383400a`, unmoved. Four XS items from `7383400a` §9; the three deferred observations stay
|
||||
deferred.
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `CONTEXT.md:540` | *"`scripts/hub_confirm_gate.py` enforces"* → asserts, but is not enforced (R-29). Third instance of the class after `:564` and `configs.go:27` |
|
||||
| `REUSE.md:62` | Same claim, *"enforces zero"*. The **rule stays** — never native `confirm()`/`prompt()` is correct guidance and this is a reuse-reference row — only the enforcement claim changes |
|
||||
| `OPEN-ITEMS.md:4` | Root `REPORT.md` = overwritten per-session; `REPORT-<topic>.md` = non-clobbering sibling (`CLAUDE.md:82-87`), 14 of them. Prohibition unchanged |
|
||||
| `OPEN-ITEMS.md:55` | Heading scoped to *"the **TOP** READY rows"* + a half-sentence that it is deliberately not a full ordering. **No row added to the list** |
|
||||
|
||||
`hub/internal/web/configs.go:27` — the fourth instance — left alone (R-94 leg (b), needs a hub build).
|
||||
|
||||
### Part 3 NOT done — its stated evidence is false
|
||||
|
||||
The spec proposed recording a convention: *"`ROADMAP`'s P3 `Status` column only ever takes `idea` or a
|
||||
`SHIPPED`/`PROVEN-LIVE` variant, and never takes `READY`, `BLOCKED` or `WAITING-ON-OPERATOR`"*, and
|
||||
concluded from that vocabulary split that `ROADMAP` `Status` is **disposition** while `OPEN-ITEMS`
|
||||
`State` is **live actionability**.
|
||||
|
||||
Refuted inside P3 itself (`ROADMAP.md`, lines 113–172):
|
||||
|
||||
| Line | ID | P3 `Status` cell |
|
||||
|---|---|---|
|
||||
| 151 | R-90 | `BLOCKED on Hetzner CX33 availability (operator, 2026-07-27)` |
|
||||
| 152 | R-91 | `WATCHING — gated on demo-felhom's first post-migration PBS backup` |
|
||||
| 150 | R-110 | `idea — found 2026-07-29, **WAITING-ON-OPERATOR (a ruling, not a defect)**` |
|
||||
| 137 | R-86 | `**NEXT — operator ruling 2026-07-27**` |
|
||||
|
||||
And P2's recovery-gaps sub-table (lines 99–107) uses a bare `READY` for nine rows, including `E-2`.
|
||||
|
||||
Both halves fail: the column **does** take the `OPEN-ITEMS` vocabulary, and `BLOCKED`/`WATCHING`/
|
||||
`NEXT` are live-actionability words, not dispositions. The real shape is a free-text status field
|
||||
that has absorbed both vocabularies over time — usually disposition, sometimes not. Writing the
|
||||
proposed convention down would install a **new false invariant** in the commit chain whose whole
|
||||
purpose was removing them, so per the spec's own instruction (*"If you think this ruling is wrong, say
|
||||
so and make no edit"*) `documentation/backlog/README.md` was not touched.
|
||||
|
||||
The narrow question that prompted it still resolves benignly: R-29 and R-94 reading `idea` in
|
||||
`ROADMAP` and `READY` in `OPEN-ITEMS` is **not** a defect and needs no sync. What is unsupported is
|
||||
generalising that into a rule.
|
||||
|
||||
---
|
||||
|
||||
## Standing note
|
||||
|
||||
**No gate was wired, fixed, run as a hook, or deleted by any of the three commits.** Filing R-29 is
|
||||
not doing R-29; the enforcement decision (pre-push hook / `build.sh` step / CI) and the audit of the
|
||||
remaining eight gates are R-29 part (b), M-sized, and its own task.
|
||||
@@ -0,0 +1,94 @@
|
||||
# REPORT — Session C: R-113, R-114, R-112 proven on a real box; C5 fails on a new defect (2026-07-29)
|
||||
|
||||
`RUNBOOK-session-c-2026-07-29.md`. Full evidence: `documentation/audits/SESSION-C-2026-07-29.md`.
|
||||
Root `REPORT.md` untouched.
|
||||
|
||||
## Verdicts
|
||||
|
||||
| Claim | Fix | Verdict |
|
||||
|---|---|---|
|
||||
| **C4** — offer appears and moves the target | R-112 | ✅ **PASS** |
|
||||
| **C3** — absent target tells the truth, offers nothing | R-114 | ✅ **PASS** |
|
||||
| **C5** — `backup_target_absent` + `backup_target_restored` | R-113 | ❌ **FAIL** — generic alarm, specific recovery → **R-116** |
|
||||
|
||||
**All three shipped fixes work.** R-113's gate fires in **4 seconds** (E-2d measured zero over 4½
|
||||
minutes). R-114's message is correct. R-112's banner reaches the customer. C5 fails on a **fourth,
|
||||
separate defect that was unreachable until R-113 made the gate fire at all.**
|
||||
|
||||
## What the drill box ran
|
||||
|
||||
Agent **0.114.0 from the Day-0 manifest** — the shipped binary, so C5 tested the real artifact and
|
||||
closed R-115's observation 1 for R-113. Controller **0.186.0 hand-deployed** after install (§3.1
|
||||
ruling (a)); the vouched golden bakes 0.185.1, so **C3/C4 prove the code, not the shipped golden** —
|
||||
that lag is filed against R-115, not a new ID.
|
||||
|
||||
## The three headline observables
|
||||
|
||||
**R-113** — detach at 18:43:50, gate at **18:43:54**, on exactly the shape that defeated it before:
|
||||
```
|
||||
raw /mnt/mentes : NOT mounted
|
||||
bind /mnt/felhom-drives/mentes : /dev/sdb[/felhom-data] <- the stale bind SURVIVED
|
||||
```
|
||||
|
||||
**R-114** — with the target absent: absent copy 1, **system-disk copy 0**, **offer block 0**. Both of
|
||||
E-2d's falsehoods gone.
|
||||
|
||||
**R-112** — banner element 1 and the never-configured copy in the HTML; after the wizard, the offer
|
||||
with `data-path="/mnt/felhom-drives/mentes"`. Healthy renders nothing, **proven positively**: idle
|
||||
delta 0 `/backup/tiers` calls, page-load delta **+1**, single caller ⇒ the seam ran and chose silence.
|
||||
|
||||
**Decline path proven** (registration confers no role), `restart_required:true`, agent did **not**
|
||||
self-restart, in-flight check recorded before I restarted it, wrapper created the storage at the
|
||||
drive's own mountpoint.
|
||||
|
||||
## C5's failure
|
||||
|
||||
```
|
||||
absent : Event pushed: storage_disconnected (error) <- GENERIC
|
||||
return : Event pushed: backup_target_restored (info) <- SPECIFIC
|
||||
```
|
||||
|
||||
`backup_target_absent` count **0** across the whole run. The alarm and its recovery cannot be matched
|
||||
— precisely what `notifyDriveReturned`'s own comment forbids.
|
||||
|
||||
**Root cause (R-116):** `driveTargetByPath` builds `out[GuestPath] = d.BackupTarget`, but the drive is
|
||||
**two `/disks` rows** and the flag and the guest path sit on different ones — the `felhom-backup`
|
||||
storage row carries `BackupTarget: true` and gets a guest path only while classified user-data; the
|
||||
registry union row carries the guest path and **never assigns `BackupTarget`**. Absent ⇒ they separate
|
||||
⇒ generic. Return ⇒ they rejoin ⇒ specific. v0.184.1 fixed the *keying*, not this.
|
||||
|
||||
## Mirror + over-correction guard — PASS, with a caveat
|
||||
|
||||
Non-target drive detached ⇒ `storage_disconnected`, `backup_target_absent` count 0. **Over-correction
|
||||
guard passes**: both drives present ⇒ 0 ABSENT lines, target stayed healthy — R-113's stricter presence
|
||||
did not make a healthy drive read absent. **Caveat: the mirror passes trivially**, because the target
|
||||
also produced the generic event; it confirms no over-correction but cannot confirm discrimination.
|
||||
|
||||
## Record
|
||||
|
||||
- `OPEN-ITEMS.md` — **R-113, R-114, R-112 → SHIPPED + PROVEN-LIVE**; **R-116 opened** (READY (S), P1);
|
||||
**E-2 and E-2d CLOSED as partially proven** with R-116 as the one named open leg, per the runbook's
|
||||
§9 decided-in-advance rule.
|
||||
- `ROADMAP.md` — R-116 under P1.
|
||||
- **Capability map NOT touched** — it still has **no E-2 / backup-target rows at all**, so no row could
|
||||
be moved to PROVEN-LIVE. Creating them is a design act, not a validation act. Third session running
|
||||
that this has been noted.
|
||||
|
||||
## Teardown
|
||||
|
||||
VM destroyed, storage removed, **`pvesm status` after == before** (`local-lvm` 38.78 %), guest 9201 and
|
||||
`drill-r50` untouched. **Customer ruling: DELETE**; attempted and correctly refused (`host … is
|
||||
ONLINE`) — deletable once the destroyed host ages to DOWN (>1 h), command recorded in `OPEN-ITEMS.md`.
|
||||
|
||||
## What did not happen
|
||||
|
||||
`backup_target_absent` never fired, so its severity, Hungarian copy and hub routing remain unexercised
|
||||
— R-116 blocks them. The offer was accepted via the endpoint the button POSTs, not a browser click (no
|
||||
browser automation on DooPlex); the rendered control and its non-auto-submission were verified in HTML.
|
||||
The stale bind still naming a dead device node after return was observed, not investigated.
|
||||
|
||||
## The arc
|
||||
|
||||
E-2 ends here. Its stated definition of done is **R-106 + R-109, R-108 and D5** — none of which this
|
||||
detour touched. The detour was worth taking: it found six real defects (R-111 through R-116), four of
|
||||
them customer-affecting, none of which any unit suite had caught.
|
||||
@@ -0,0 +1,98 @@
|
||||
# REPORT — tester gate: golden re-baked to 0.188.0, fresh-install proof PASSED (2026-07-31)
|
||||
|
||||
Written as `REPORT-<topic>.md` per `CLAUDE.md:82-87` so the shared `REPORT.md` (E-2 increment 1) is
|
||||
not clobbered. Full record with every observable: `documentation/audits/tester-gate-golden-0.188.0-2026-07-31.md`.
|
||||
|
||||
## Outcome
|
||||
|
||||
**§7.2 — YES: a fresh install is safe to hand to an external tester.** ISO boot → claimable,
|
||||
app-serving box in ~10 minutes unattended, and an app's data restored **from the drive with the
|
||||
guest's `app.yaml` gone**, proven readable by the application over its own TCP path.
|
||||
|
||||
**Golden 0.186.0 → 0.188.0** baked, published, vouched. **No ISO rebuilt** — Part 0 proved none was
|
||||
needed. No existing box changed; floor still v0.156.0, MinAgent still 0.113.0.
|
||||
|
||||
## Part 0 — the ISO does not need rebuilding
|
||||
|
||||
Verified against the ISO **on disk**, not from source. It bakes exactly three Felhom payloads
|
||||
(`felhom-bootstrap.sh`, its unit, the secret-free pairing env) — full-base64 match, 1 hit each — and
|
||||
**0** hits for `SCRIPT_VERSION="1.2`, `felhom-controller`, `vzdump-lxc-9100`. The installer is fetched
|
||||
at run time (`felhom-bootstrap.sh:96`) and the live URL is byte-identical to repo HEAD
|
||||
(sha `ab8b283e…`, v1.22.0, committed six days *after* the ISO). The golden arrives via the hub-vouched
|
||||
artifact manifest (`felhom-host-install.sh:423-433`). The one genuinely baked, drift-capable thing is
|
||||
`felhom-bootstrap.sh` itself — currently at repo HEAD.
|
||||
|
||||
**Proven live**, not just argued: the fresh box ran `felhom-host-install v1.22.0` and fetched golden
|
||||
**v0.188.0**, sha-verified.
|
||||
|
||||
## Part 1 — bake / publish / vouch
|
||||
|
||||
Baked **0.188.0**, not the brief's 0.187.0: 0.187.0 lacks D5, and Part 2 step 6 *is* the D5 claim, so
|
||||
that golden could not have passed the proof this task exists for. 0.188.0 satisfies R-120 anyway.
|
||||
Stated rather than absorbed, per standing rule 4.
|
||||
|
||||
GOLDEN_VERSION=0.188.0
|
||||
GOLDEN_SHA256=7353d8beb63641f87a848e45f8aa12e465647e1190ad164a65b32ad01fc3d299
|
||||
|
||||
Three observables: 404 pre-gate (with a 200 control on 0.186.0 so it is not vacuous), then an
|
||||
**anonymous** download returning `http=200 bytes=649310288` and a matching sha; the manifest read back
|
||||
showing `0.188.0` selected; and the consumer call `GET https://hub.felhom.eu/api/v1/artifacts/sess-g`
|
||||
returning the pair. Plus a fourth: a real fresh box fetched and sha-verified it.
|
||||
|
||||
**R-120's gate evaluated and allowed.** Exercised both ways rather than inferred from silence —
|
||||
vouching 0.185.1 first produced `flash=golden_behind_fleet`, the logged `artifact vouch REFUSED`, and
|
||||
**no write** (the manifest still read 0.186.0); then 0.188.0 produced
|
||||
`Artifact manifest set: agent=0.118.1 golden=0.188.0`.
|
||||
|
||||
## Part 2 — the clean-install proof, on demo-hp
|
||||
|
||||
All seven steps PASS. Venue was demo-hp (Tier 0, the designated drill host) using the scratch dir
|
||||
storage at `/mnt/nvme-1tb` that `target-selection.md:38-40` names; `local-lvm`, `drill-r50` and both
|
||||
9201s untouched.
|
||||
|
||||
Highlights: real day-0 pairing → bind → install; a **real** claim (the code is emailed-only, R-119 —
|
||||
the operator relayed it), with the gate flipping `dashboard not yet claimed` → `authentication
|
||||
required`; controller **0.188.0** confirmed *from the box*; **rallly** (postgres) + **homebox**
|
||||
deployed through the real endpoints.
|
||||
|
||||
The D5 leg: recovery unit `portable-carried=2/2, withheld=0`; the carried `DB_PASSWORD` matched the
|
||||
live one **by fingerprint** (`14c8f515…`, never printed); guest `app.yaml` moved aside; restore
|
||||
returned `secrets recovered=2/2`. Step 7 read the data from **rallly's own network namespace** over
|
||||
TCP to `rallly-postgres` — not the localhost trust socket that produced D5's false pass — and the same
|
||||
path with a wrong password returned `FATAL: password authentication failed`, proving the credential
|
||||
does real work. **The discriminator held: PRE-BACKUP row = 1, POST-BACKUP row = 0.**
|
||||
|
||||
## Part 3 — runbook integrity
|
||||
|
||||
`RUNBOOK-manual-build.md` told the reader to use a "RECORDED" qemu line that is itself labelled
|
||||
*reconstructed*, and whose source says it *"was never saved"*. The real invocation is now captured
|
||||
from this bake and recorded as canonical in **§4.0**, alongside the bake/publish/teardown steps, the
|
||||
template-rot warning and where the R-120 gate actually lives. The old runbook's deviation entry is
|
||||
marked SUPERSEDED with a forward pointer.
|
||||
|
||||
## Teardown — three layers
|
||||
|
||||
1. VM 310 destroyed with `--purge --destroy-unreferenced-disks 1`; `/mnt/nvme-1tb/images/` empty.
|
||||
2. `cc-scratch` removed, `storage.cfg` back to its original four entries; `felhom-backup` available
|
||||
**926 492 284 KiB before and after** — space returned exactly.
|
||||
3. Hub: **`sess-g` and its host record DELETED, full cascade** — `customer DELETE cascade COMPLETE
|
||||
for sess-g (journal #8) — full teardown`, residue purged including `appliance_registrations=1`.
|
||||
Verified positively: `/configs` and `/hosts` both loaded (10160 / 9880 bytes) with **0** hits for
|
||||
`sess-g` and 0 for the appliance UUID. The gate refused twice first (409 host ONLINE, then 400
|
||||
missing acknowledgements) — the record cannot be deleted until the destroyed box ages out of
|
||||
ONLINE, ~30 min. **`sess-f` deliberately NOT deleted** (R-131); its command is in the audit §7.1.
|
||||
Secrets shredded in the guest and on the box.
|
||||
|
||||
## Findings — filed, none fixed
|
||||
|
||||
`R-128` ISO_VERSION/SCRIPT_VERSION comment is false · `R-129` demo-hp's "no baked SSH key" is stale
|
||||
(key auth works) · `R-130` `HARD_MIN_LVM_GIB` warns and proceeds — a hard min that is not hard ·
|
||||
`R-131` `sess-f` is a fourth orphaned scratch customer · **`R-132` — `curl -w '%{redirect_url}'`
|
||||
printed the hub operator password into a session transcript; `HUB_PW` needs rotating.**
|
||||
|
||||
## Not done, deliberately
|
||||
|
||||
No ISO built; no defect fixed; no golden deployed to an existing box; no floor or MinAgent change;
|
||||
offsite/PBS-DR legs not exercised (the task forbids pointing anything at production PBS or the real
|
||||
restic offsite, so `sess-g` ran DR-tier off); Campaign 10, the demo-hp repartition and subdomain
|
||||
onboarding untouched.
|
||||
@@ -0,0 +1,97 @@
|
||||
# REPORT — SPIKE 4: can a `.deb` in the ISO deliver the stub on an interactive install? (2026-07-31)
|
||||
|
||||
> Written as `REPORT-universal-iso-spike.md`, not `REPORT.md`: the shared file belongs to today's hub
|
||||
> v0.85.0 session and the second session in a shared clone never touches it. Supersedes this file's
|
||||
> Spike 1–3 contents.
|
||||
|
||||
**Class: Spike.** Findings only — no production file changed, no release ISO built, nothing published.
|
||||
Evidence: `documentation/audits/SPIKE-universal-iso-4-2026-07-31.md`.
|
||||
|
||||
## The answer is yes, and it was measured with the negative control in the same box
|
||||
|
||||
One ISO, 15 GRUB entries, a trivial probe `.deb` injected into `/proxmox/packages/`. Two VMs on
|
||||
demo-hp built with `qm` so the run was visible in the web console: **400 interactive**, **401
|
||||
automated control**.
|
||||
|
||||
On the **interactive** install (`spikefour.felhom.eu`):
|
||||
|
||||
- the package is installed — `ii felhom-spike4-probe 0.0.1`
|
||||
- its **postinst ran** — marker file present, content intact
|
||||
- it **enabled a systemd unit**, and **that unit fired on first boot** (uptime 7.98 s, `pid1: systemd`)
|
||||
- and on **that same machine**, `proxmox-first-boot` is not installed and `/var/lib/proxmox-first-boot`
|
||||
does not exist — Spike 3's negative reproduced, not assumed
|
||||
|
||||
So the two delivery mechanisms are independent, and the one that survives the path we are actually
|
||||
shipping is the `.deb`. **The product — insert the stick, install Proxmox normally choosing your own
|
||||
disk and password, box sets itself up and waits for a claim code — is now measured rather than hoped
|
||||
for.** With one honest caveat: what was measured is a trivial probe package, not Felhom's real stub.
|
||||
Packaging the real stub and confirming pairing end-to-end is the last step before a spec (~60 min).
|
||||
|
||||
## What a postinst may and may not do
|
||||
|
||||
Identical on both paths: `pid1 = unconfigured.sh`, **no running systemd**, `/proc` and `/sys` mounted,
|
||||
and **`systemctl enable` succeeds** (it wrote the symlink). Network and DNS *happened* to be up —
|
||||
inherited from the installer's own DHCP.
|
||||
|
||||
Four constraints for the real postinst, so they get written against rather than discovered:
|
||||
|
||||
1. Never `systemctl start` or `daemon-reload` — there is no systemd running. `enable` is the only verb.
|
||||
2. **Never require the network**, despite it being present here. A box installed with the cable out
|
||||
gives a postinst no route, and a failing postinst breaks the customer's install.
|
||||
3. Never fail — guard everything, `exit 0`.
|
||||
4. Do the real work in the unit at first boot, where systemd, network and a booted kernel exist.
|
||||
|
||||
## Two smaller results
|
||||
|
||||
**The repack preserves the `.deb`, but not naively.** `xorriso … -boot_image any replay` fails with
|
||||
*"Overlapping MBR partition entries"* — and `iso-repack.sh:270-292` already documents that exact
|
||||
failure and its fix. Mirroring it produced a working image (19 El Torito entries; the `.deb` extracted
|
||||
back out is byte-identical). So this is an insertion into an extract→modify→re-master cycle our repack
|
||||
already performs, not a new build stage.
|
||||
|
||||
**Q3:** `iso-repack.sh:100-106` refuses an ISO without `auto-installer-mode.toml`. It is a guard, not
|
||||
a structural requirement, and its reasoning is sound for the shape it was written for — already R-155,
|
||||
cited exactly here. With no mode file the stock grub.cfg does not emit the Automated entry at all; with
|
||||
a mode file but no answer, that entry aborts safely and loudly.
|
||||
|
||||
## A correction I owe you from last session
|
||||
|
||||
**R-153 is retracted.** The register grep this task mandated shows R-94 already carries it verbatim at
|
||||
`OPEN-ITEMS.md:15`, status `READY (XS)`, with leg (b) being precisely "the gate fails today and is
|
||||
invoked by nothing" — and R-29, the class, says in terms *"do not mint a new ID for a new instance."*
|
||||
Spike 3 filed a duplicate.
|
||||
|
||||
**And the substantive half of that Spike 3 claim was wrong.** I wrote that the drift left the customer
|
||||
page's install-command generator "targeting a flag surface three minor versions stale." R-94 explicitly
|
||||
retracts exactly that reading: the constant selects no script — it renders as a text label, and the
|
||||
command beneath it fetches the script the website git-syncs from `main`, so **1.22.0 is what every
|
||||
install already gets**. It is a wrong number on your screen and nothing more. I overstated it.
|
||||
|
||||
## Still unknown
|
||||
|
||||
**The real stub has not been packaged** — that is the one thing between here and a build spec.
|
||||
Also unproven: `dpkg --configure -a` ordering for a package with dependencies; an ISO that never went
|
||||
through `prepare-iso` (blocked by R-155, which this spike was fenced from changing); and the Graphical
|
||||
installer, where the result should hold *a fortiori* since the `.deb` path is in `Install.pm`, shared
|
||||
by all front-ends — but that is inference, not measurement.
|
||||
|
||||
**Spike 3's Q3** — the real stub at `before-network` — **this session did not touch it.** Note it is
|
||||
now partly superseded: on the `.deb` route the unit's ordering comes from the unit file, not from
|
||||
`[first-boot].ordering`.
|
||||
|
||||
## R-rows
|
||||
|
||||
**None opened.** Each candidate was grepped against the register first: the delivery result is a
|
||||
positive finding, the postinst constraints belong in the build spec, and the repack guard is already
|
||||
R-155. **R-153 retracted** into R-94 leg (b) / R-29.
|
||||
|
||||
## Teardown
|
||||
|
||||
All three layers plus the scratch storage, verified positively. demo-hp: VMs 400/401 purged, **storage
|
||||
`spike4` removed** (`storage.cfg` back to 4, `grep -c spike4` = 0), `/mnt/nvme-1tb/images/` empty,
|
||||
**disk usage 6.6 G — identical to pre-spike**, probe ISO and driver removed, 0 loop devices,
|
||||
`drill-r50` stopped and untouched, 9201 running, `felhom-backup` unmodified, nothing on `local-lvm`.
|
||||
DooPlex: workspace scratch **4.8 GB removed**, scratchpad **3.3 GB → 88 K**, both throwaway passwords
|
||||
destroyed, 17 ISOs in `out/` untouched, no production file modified. **Hub-side: nothing created** —
|
||||
the VMs took LAN DHCP leases but never ran `felhom-host-install.sh` or contacted the hub; verified by
|
||||
fetching and searching the customer list. Nothing published.
|
||||
@@ -0,0 +1,216 @@
|
||||
# REPORT — R-181 (the reserve guards the write that fills the disk) + R-156 (papra) + two operator rulings
|
||||
|
||||
**Date:** 2026-08-03 · **Repos:** `felhom-controller` (v0.192.0 → **v0.193.1**), `app-catalog-felhom.eu`, `felhom.eu` (docs only — **no hub change, no hub version bump**)
|
||||
|
||||
## 1. Baselines — re-read on arrival, all matched §1
|
||||
|
||||
| Repo | `main` @ arrival | Version | Shipped |
|
||||
|---|---|---|---|
|
||||
| `felhom-controller` | `4be6467b501b` | v0.192.0 | **v0.193.0 `fef07c3`** → **v0.193.1 `6c43bf6`** |
|
||||
| `app-catalog-felhom.eu` | `7cb58ecdf8e7` | n/a | `122bbee` |
|
||||
| `felhom.eu` | `6b5d64c1fa73` | hub v0.89.0 | docs only, **no bump** |
|
||||
|
||||
All three clean (`git status --porcelain` empty, `HEAD == origin/main`) before every build.
|
||||
|
||||
## 2. The fix
|
||||
|
||||
**One admission verdict per app per run** (`controller/internal/backup/admission.go`), taken before
|
||||
that app's **first** write and consulted by all three legs — DB dump, volume dump, unit capture. The
|
||||
three write under one per-app root (`appbackup.RecoveryUnitPath`), which is what makes one verdict
|
||||
able to cover them honestly.
|
||||
|
||||
- **Lazy, not run-wide.** App A's dump can put app B under the reserve; a run-start verdict reads a
|
||||
disk that no longer exists. **Never re-decided between an app's own legs** — that is the split being
|
||||
closed. **Reset per run.**
|
||||
- **Ahead of `DumpAppVolumesSafe`**, which stops the stack as its first act, so a refused app is never
|
||||
bounced. **After** the volume-less check, which has no write to gate.
|
||||
- **Exactly one operator alert per refused app per run.** Leg order unchanged.
|
||||
- **Size term added:** *would this app's write cross the reserve?* — estimated from its previous
|
||||
`.sql` + `.tar`. **No history → headroom-only**, or the first backup becomes the one that can never
|
||||
happen; the alert says so when that applies.
|
||||
|
||||
## 3. Files
|
||||
|
||||
| File | |
|
||||
|---|---|
|
||||
| `controller/internal/backup/admission.go` | **new** — the gate, the memo, the estimator |
|
||||
| `controller/internal/backup/admission_test.go` | **new** — 11 tests |
|
||||
| `controller/internal/backup/backup.go` | run scope + gates in the DB and volume legs |
|
||||
| `controller/internal/backup/recovery_unit.go` | `floorVerdict` size-aware; capture leg via `admitApp` |
|
||||
| `controller/internal/backup/capture_floor_test.go` | 3 call sites updated for the new signature |
|
||||
| `controller/README.md`, `REUSE.md`, `CHANGELOG.md` | |
|
||||
| `app-catalog-felhom.eu/templates/papra/docker-compose.yml` | mount moved to `/app/app-data` |
|
||||
|
||||
## 4. Tests — 28 packages `ok`, `rc=0` (read separately from any commit)
|
||||
|
||||
All 11 new tests pass, plus the pre-existing floor suite. Refusal assertions are **sha256 tree
|
||||
fingerprints before and after**, never log lines — the defect being fixed *is* a log line the tree
|
||||
contradicted.
|
||||
|
||||
The DB leg cannot run without Docker (`DiscoverDatabases` shells out), so its gate is pinned by an
|
||||
**AST walk** of `backup.go` asserting `admitApp` precedes `DumpOne`. `strings.Contains` is
|
||||
insufficient: a commented-out call still contains the string.
|
||||
|
||||
### Red-proofs — each demonstrated failing, then restored
|
||||
|
||||
| # | Mutation | Result |
|
||||
|---|---|---|
|
||||
| 1 | **Both** dump-leg `admitApp` gates removed (= exactly v0.192.0) | Scenario A **RED** — *"the VOLUME leg ran for a refused app"*; with the leg assertions temporarily made non-fatal, the **tree fingerprint changed** too. Also red: Scenario C, Scenario D, and the AST wiring test (which named the DB leg specifically) |
|
||||
| 2 | The entire size term removed from `floorVerdict` (both its thresholds) | Scenario D **RED** — 0 alerts where 1 was required |
|
||||
| 3a | The reserve removed entirely | Scenario F **PASSED — recorded honestly.** The specified mutation does not exercise the assertion: removing the reserve makes every app write, which overwrites and adds but **deletes nothing**, so a deletion-watching test correctly stays green |
|
||||
| 3b | A prune injected into the refusal path | Scenario F **RED** — this is the mutation that proves the test watches deletion |
|
||||
| 4 | Floor moved above the warning band (90% / 6 GiB) | `TestFloorSitsBelowTheCriticalWarningBand` **RED** |
|
||||
|
||||
Every mutation removed **every** guard its test covers (#1 removed both dump-leg gates, not one).
|
||||
|
||||
## 5. Live validation — demo-hp guest 9201 (Tier 0), the method that found the defect
|
||||
|
||||
**Method:** endpoint-level — `POST /api/debug/backup/dbdump`, the exact endpoint the debug UI button
|
||||
calls, which runs the production `RunDBDumps`. No browser on DooPlex.
|
||||
|
||||
**The instrument was re-proven before use.** demo-hp's thin pool is 53.93 GiB, so a real fill of a
|
||||
70 G volume would exhaust it and corrupt every guest. A 5 GiB `fallocate` step moved guest `df`
|
||||
1.2G → 6.2G while thin-pool `data_percent` held **36.83 → 36.83** — zero blocks allocated. Re-checked
|
||||
at every step of the fill.
|
||||
|
||||
### Headroom term — 08:59:46, 906 MB free / 99% used
|
||||
|
||||
| Observable | Result |
|
||||
|---|---|
|
||||
| Tree fingerprint before | `TREE_SHA=111d1760c18d3440f700634ab325f8b8` (10 files; opengist's tar **182,272 B** — R-181's own "before" figure) |
|
||||
| Tree fingerprint after | **`111d1760c18d3440f700634ab325f8b8` — identical** |
|
||||
| Volume dumps written | **0** (baseline run at 08:58 wrote 2) |
|
||||
| `Stopping <app> for safe volume dump` | **absent** — and this is evidence, not an absence, because that line **is** present in the 08:58 baseline |
|
||||
| Operator alerts | one `recovery_unit_capture_failed` per app, severity `error`, HTTP 200 |
|
||||
|
||||
Free space restored → re-run at **09:01:33**: both apps captured normally.
|
||||
|
||||
### Size term — 09:03:00, proven separately
|
||||
|
||||
Reproducing the original sequence: a real 2 GiB file planted in opengist's volume, backed up so its
|
||||
**previous** tar became **2,147,666,432 B** (the exact live figure), then the filesystem set to
|
||||
**91% used / 2.9 GB free — both headroom terms deliberately clear**.
|
||||
|
||||
- **opengist refused `(size)`** — *"this app's last backup was 2.0 GB and writing it again would cross the reserve"*
|
||||
- **privatebin ADMITTED and dumped normally** — the term is per-app, not a global halt
|
||||
- Tree unchanged; 1 volume dump instead of 2
|
||||
|
||||
### One honest correction to the "app not stopped" claim
|
||||
|
||||
`StartedAt` on both apps *did* move, 26 s **after** the refusal. It was the **quiesce loop** for the
|
||||
whole-guest PBS backup, which my fill had broken — not the app-data path. Its own backoff logic then
|
||||
behaved correctly (*"deferring its next quiesce by 15m so the apps are not stopped again for a backup
|
||||
that cannot succeed"*). The app-data claim rests on the **absence of the `Stopping … for safe volume
|
||||
dump` line**, which is the line that appears when that leg bounces an app.
|
||||
|
||||
## 6. The `du` measurement (§Part 1.3) — measured, then rejected
|
||||
|
||||
**66 timed runs** on demo-hp guest 9201, `docker run --rm -v <vol>:/v alpine du -sb /v`:
|
||||
**median ~355 ms per volume, range 341–404 ms** — on volumes holding **tens of KB**. The cost is
|
||||
container start-up, not the walk, so it does not shrink for small apps and only grows for real ones.
|
||||
|
||||
**Rejected**, on two grounds beyond the number: `docker run` needs the writable layer, so the
|
||||
measurement mechanism can fail under exactly the disk pressure the reserve exists to handle; and the
|
||||
previous-dump estimate measures the **artifact that will be written** rather than the live volume,
|
||||
which is the truer predictor. The previous-dump estimate stands.
|
||||
|
||||
## 7. The refusal message as shipped, and what it guarantees
|
||||
|
||||
```
|
||||
[WARN] [backup] App backup REFUSED for opengist (headroom) — refused: backing up this app would
|
||||
leave the filesystem below the reserve (reserve: 97% used or 1.0 GiB free; the filesystem is already
|
||||
below it, before this app's estimated 178.0 KB write) — /mnt/sys_drive: 64.3/68.7 GB used (94%),
|
||||
0.9 GB free; NO database dump, NO volume dump and NO recovery-unit capture was written for it, the
|
||||
previous unit is untouched and NOTHING was deleted
|
||||
```
|
||||
|
||||
**It guarantees, for that app in that run:** no DB dump, no volume dump and no capture were written;
|
||||
every file under `backups/primary/<app>` is byte-identical; the app was not stopped; nothing anywhere
|
||||
was deleted; exactly one operator alert was sent. All five verified by fingerprint above.
|
||||
|
||||
**The wording was not weakened to fit the behaviour** — the behaviour moved so the wording became
|
||||
true. What was *added* is the bound term (`headroom` / `size`) and the estimate.
|
||||
|
||||
**v0.193.1 — found by this very proof run.** The estimate was rendered fixed to two-decimal GiB, so
|
||||
opengist's real **178 KB** printed as `estimated 0.00 GiB write`, which reads as *no estimate was
|
||||
available* — the opposite of what happened. Shipped the same session because it is the same defect
|
||||
class the whole task is about. Re-verified live after redeploy: `estimated 178.0 KB write`.
|
||||
|
||||
## 8. papra (R-156, last leg)
|
||||
|
||||
**Precondition checked, not inherited** — both boxes were wiped and rebuilt today, so the 2 August
|
||||
evidence was re-measured: `docker ps -a` (**including stopped**) on **both** demo guests → no papra;
|
||||
hub `/hosts` → exactly two enrolled hosts (`demo-felhom-8363b5`, `demo-hp-bb76ea`), **zero** papra.
|
||||
|
||||
**Decided from the image, not the README:** `WORKDIR=/app`, `DATABASE_URL=file:./app-data/db/db.sqlite`,
|
||||
`DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents`, `PAPRA_CONFIG_DIR=./app-data` — and
|
||||
**`/app/data` does not exist in the image at all**.
|
||||
|
||||
**Departure from the task's stated preference order, stated because it was deliberate.** Option (1)
|
||||
(reconfigure the app to write to `/app/data`) *was* available — all three paths are env-settable. Not
|
||||
taken: it enumerates data paths, so a fourth added upstream would silently escape to the writable
|
||||
layer again — this defect re-armed and invisible. Mounting the app's own data **root** captures every
|
||||
current and future path by construction.
|
||||
|
||||
**Gate output — the arbiter, run in both directions:**
|
||||
|
||||
- fixed → `papra CLEAN`, with the self-test passing on that run: *"prober flags the R-156 signature and clears a correct template — trustworthy"*
|
||||
- reverted to `/app/data` (red-proof on the **real template**, not just the canary) → `BROKEN`: *"mount /app/data is NOT writable by the app's own uid=999"*, *"DATA in the writable layer at /app/app-data/db (db_signature=True, e.g. ['db.sqlite'])"*, *"declared volume /app/data is EMPTY"*
|
||||
- `catalog_gates.py papra` (full, not `--fast`) → **rc=0**, all three gates OK
|
||||
|
||||
**Two operational findings about the gate:** it needs **root** (it reads `/var/lib/docker/volumes`,
|
||||
mode `drwx--x---`; as a normal user its own canary fails UNDETERMINED and it correctly refuses a
|
||||
verdict — fail-closed working as designed), and it hardcodes scratch path `/srv/felhom-gate`, created
|
||||
on DooPlex. Unscoped it deploys all 53 templates; that run was aborted after 10 minutes and its
|
||||
`volgate-*` scratch projects were cleaned up.
|
||||
|
||||
## 9. §3's correction — confirmed in passing, not chased
|
||||
|
||||
`restore_points.go:57-59` takes the manifest's mtime and then `newestArtifact` over the `.sql` and
|
||||
`.tar` files, so **the newest of the three wins**. The restore point does **not** show a stale
|
||||
timestamp. Confirmed and dropped, as instructed.
|
||||
|
||||
## 10. Register
|
||||
|
||||
| ID | Change |
|
||||
|---|---|
|
||||
| **R-181** | **CLOSED — SHIPPED** (v0.193.0 + v0.193.1), with the live evidence above |
|
||||
| **R-156** | **CLOSED** — all three apps fixed |
|
||||
| **R-110** | WAITING-ON-OPERATOR → **READY**, ruling attached: **option (b), tag-tracked**, and it must cover **both** channels (the `/scripts/` git-sync *and* the nine files fetched from `raw/branch/main`) or it only half-works |
|
||||
| **R-115** | WAITING-ON-OPERATOR → **READY**, ruling attached: **mechanism (b)**, a build-side gate refusing to deploy or vouch an unpublished version; the third instance (agent v0.120.0) would have silently downgraded both demo boxes while reporting success |
|
||||
| **R-182** | **NEW.** ID established free: `grep -ro "R-182\b"` over `documentation/` and `*.md` → 2 hits, both prose in `REPORT.md` recording it as *"checked and left unused"*; `R-183` → 0 hits and remains free |
|
||||
|
||||
**R-165** is collapsed to CLOSED/PROVEN-LIVE in `ROADMAP.md`; the capability map's local-backup row
|
||||
moves to **PROVEN-LIVE, both halves**, because the live fill proved the fixed behaviour for **both**
|
||||
reserve terms.
|
||||
|
||||
## 11. Observations — noticed, documented, NOT acted on
|
||||
|
||||
1. **R-182 (filed).** The periodic status refresh (`GetFullStatus` → `captureAllRecoveryUnits`) runs
|
||||
with no admission scope, so a refused app re-alerts on every poll — measured live: a second
|
||||
identical alert pair 13 s after the run's. **Pre-existing in v0.192.0**; R-181 changed neither
|
||||
caller. Its mitigation is a *comment* claiming the hub owns cooldown — which is exactly the
|
||||
"invariant asserted in a comment with no test pinning it" shape, so verify at the hub before
|
||||
scoping.
|
||||
2. **A reserve refusal does not make the run fail.** The DB and volume legs record `SKIP`, not `FAIL`,
|
||||
so `lastDBDump.Success` stays true and the customer-facing status does not turn red. Deliberate and
|
||||
consistent with v0.192.0 (the capture refusal never set it either), and the operator alert is the
|
||||
signal — but it means "backup succeeded" and "every app was backed up" are not the same statement.
|
||||
3. **`UnitSpace.UsedPercent` and `df` disagree** — `df` reported 99% where the alert said 94%, because
|
||||
`df`'s figure accounts for ext4 reserved blocks and the floor's does not. Harmless here (the
|
||||
free-byte term bound), but a percent-term threshold is being compared against a number the operator
|
||||
cannot reproduce with `df`.
|
||||
4. **The whole-guest PBS backup fails when the volume is near-full**, pushing
|
||||
`whole_guest_backup_failed` (severity `error`). Expected under a deliberate fill, and its backoff
|
||||
behaved correctly; noted because it is collateral any future fill test will also produce.
|
||||
|
||||
## 12. Teardown
|
||||
|
||||
Fill file removed; the planted 2 GiB file removed; a final backup regenerated a correct 178 KB tar;
|
||||
`pct fstrim 9201` returned 67.5 GiB and the thin pool settled at **29.43%**, *below* its 36.83%
|
||||
baseline. The backups tree is byte-identical to the pre-test fingerprint. Guest helper scripts and the
|
||||
credential file `shred`-ed. `volgate-*` scratch compose projects removed; the unrelated 9-day-old
|
||||
`jarr-*` containers on DooPlex were left untouched. papra is **not** left deployed.
|
||||
|
||||
No `--no-verify` was used on any push; the `felhom-controller` pre-push hook ran and reported
|
||||
`gates OK` on both pushes.
|
||||
@@ -0,0 +1,214 @@
|
||||
# REUSE.md — felhom.eu (hub + website + scripts)
|
||||
|
||||
> Before writing new code, check here. Canonical helpers, patterns to copy, traps to avoid.
|
||||
> Maintenance: update in the SAME commit that adds/changes/deprecates a shared helper.
|
||||
> Entries cite file + symbol. Line numbers are landmarks only — reconfirm before editing.
|
||||
|
||||
## 1. Canonical helpers (MUST reuse — do not reinvent)
|
||||
|
||||
### Report ingest & API auth (hub/internal/api/)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `(*Handler).checkAuthCustomer` | hub/internal/api/handler.go (~L94) | `(r) (customerID string, isGlobal, ok bool)` | Bearer auth for controller-facing endpoints (global key OR per-customer key) | Global key → `("", true, true)`: caller must then trust body `customer_id`. Constant-time compare on global key. |
|
||||
| `(*Handler).checkAuthHost` | hub/internal/api/handler.go (~L119) | `(r) (hostID, customerID string, isGlobal, ok bool)` | Bearer auth for agent-facing endpoints (global OR per-host key) | Sibling of checkAuthCustomer — do NOT mix the two token namespaces. Global key requires the host row to already exist (see handleHostReport). |
|
||||
| `(*Handler).handleEvent` + `allowedEventTypes` | hub/internal/api/handler.go (~L1115 / ~L1063) | `POST /api/v1/event` | The ONLY controller→hub structured-event ingest | Unknown `event_type` → 400 (add to the map FIRST). Accepted severities: info/warning/error/critical (critical since v0.31.0); anything else coerces to `"info"` — exact-match lowercase (`"Critical"` coerces). Tests: hub/internal/api/event_test.go. |
|
||||
| `(*Handler).handleHostReport` | hub/internal/api/handler.go (~L464) | `POST /api/v1/host-report` | Agent heartbeat ingest: denorm + guest upsert | Body cap via LimitReader; per-host key enforces `host_id` match (403 on mismatch); `received_at` is the dead-man's-switch. |
|
||||
| `(*Handler).handleConfigRetrieve` | hub/internal/api/handler.go (~L1484) | `GET /api/v1/config/{id}`, header `X-Retrieval-Password` | Canonical password-gated retrieval endpoint | Constant-time compare vs `cfg.RetrievalPassword`; 404-before-401 ordering. `handleArtifactManifest` mirrors it EXACTLY — keep them in lockstep. Also the Day-0 claim entry point: calls `claimEngine.EnsureIssued` + bakes the hash via `configgen.Generate(…, claimState)`. |
|
||||
| `claim.Engine` | hub/internal/claim/engine.go | `EnsureIssued` / `Resend` / `RequestReset` / `MarkClaimed` (all take `*store.CustomerConfig`) | Customer-claim code engine (v0.50.0, F-4) | Stores `bcrypt(code)` ONLY — plaintext lives just in the email send. `EnsureIssued` is idempotent (never rotates/re-sends an existing row). Wired via `api.SetClaimEngine` + `web.SetClaimEngine`; the `Mailer` seam is `*notify.Dispatcher`. |
|
||||
| `(*Store).RotateClaimCode` / `GetClaim` / `MarkClaimed` | hub/internal/store/store.go | claim-state CRUD | `customer_claims` row (v0.50.0) | `RotateClaimCode` bumps generation (single active code) + PRESERVES `claimed_at` (reset never un-claims); `MarkClaimed` is set-only. |
|
||||
| `configgen.Generate` | hub/internal/configgen/configgen.go (~L16) | `(templateYAML string, cfg *store.CustomerConfig, claimState *store.ClaimState) (string, error)` | Generate a customer's controller.yaml | The 3rd arg (nil-safe) bakes `web.claim_code_*`. The REAL config-retrieve path issues+emails first (EnsureIssued); the preview/DR paths bake read-only via `store.GetClaim`. |
|
||||
| `notify.FormatClaimEmail` / `(*Dispatcher).SendClaimEmail` | hub/internal/notify/{templates,dispatcher}.go | `(kind, customerID, email, domain, code)` | Hungarian claim/reset/claimed emails | `kind` ∈ claim\|reset\|claimed. The dispatcher method IS the `claim.Mailer`. Never log the `code`. |
|
||||
| `writeJSON` | hub/internal/api/dr.go (~L25) | `(w, code int, v any)` | JSON responses in api package | Only used in dr.go so far; prefer it over ad-hoc byte-writes for new endpoints. |
|
||||
| `(*mailRateLimiter).allow` | hub/internal/api/mail.go (~L48) | `(key string) bool` | Per-key token-bucket rate limiting | Refill = perMinute/60 per sec, burst = perMinute; in-memory (lost on restart, accepted). `now` is injectable for tests. |
|
||||
|
||||
### Alerting / Resend / dispatcher (hub/internal/notify/, monitor/)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `(*Dispatcher).ProcessEvent` | hub/internal/notify/dispatcher.go (~L88) | `(customerID, eventType, severity, message, detailsJSON, source)` | THE notification pipeline (operator + customer channels, cooldowns, prefs) | Call in a goroutine (handlers do `go d.ProcessEvent(...)`). No Resend key → silent no-op. `eventType=="test"` bypasses prefs/cooldown (and since v0.71.0 also mails the operator). `*_recovered` routes via the explicit recovery branch BEFORE the severity gate (v0.71.0) — see `processRecovery`. |
|
||||
| `(*Dispatcher).processRecovery` | hub/internal/notify/dispatcher.go (~L160) | internal | `node_recovered`/`host_recovered` routing (audit F11) | Operator always (1 h cooldown); customer iff PAIRED — a customer-channel `sent` stale/down row newer than the last sent recovery (`store.LastCustomerSentAt`); `enabled_events` deliberately ignored for recovery; timestamp ties → no mail (flap-safe). Severity stays `info` — never "fix" that by widening `severityNotifies`. |
|
||||
| `severityNotifies` | hub/internal/notify/dispatcher.go (~L77) | `(severity string) bool` | Deciding whether a severity emails | warning/error/critical notify; info intentionally doesn't; anything else is logged as unrecognized (v0.24.0 fix — do not regress). Recovery mails exist DESPITE this gate (eventType branch), not through it. |
|
||||
| `priorityHeaders` | hub/internal/notify/dispatcher.go (~L56) | `(severity string) map[string]string` | High-priority mail-client nudge (audit F14-light) | error/critical → `X-Priority: 1` + `Importance: high`; everything else nil — a warning/info mail must NOT masquerade as urgent (red-proofed). |
|
||||
| `sendEmailFn` seam / `sendEmail` | hub/internal/notify/dispatcher.go (~L33 / ~L300) | `func(to, subject, textBody string, headers map[string]string) error` | Test seam for all sends; Resend POST | Signature grew a `headers` param in v0.71.0 — payload carries `"headers"` only when non-empty. Tests capture recipient+subject+headers through the seam. |
|
||||
| `FormatOperatorEmail` / `FormatCustomerEmail` | hub/internal/notify/templates.go (~L24 / ~L118) | `(...) (subject, body)` | Operator (English) / customer (Hungarian) email bodies | Customer messages come from the `customerMessages` map — add the Hungarian text when adding an event type. Budapest TZ via package `init()`. Operator icon is eventType-aware: `*_recovered` → ✅ (severity is the fallback). |
|
||||
| `monitor.EventNotifyFunc` | hub/internal/monitor/staleness.go (~L14) | `func(customerID, eventType, severity, message, detailsJSON, source)` | Decoupling checkers from notify; wired to `dispatcher.ProcessEvent` in main | May be nil — always nil-check before calling (all checkers do). |
|
||||
| `(*Store).LogNotification` | hub/internal/store/store.go (~L433) | `(customerID, eventType, severity, message, status, errorMsg, channel)` | Audit trail of every send attempt (sent/failed, per channel) | Log BOTH success and failure (dispatcher does). Since v0.71.0 these rows are also the recovery PAIRING evidence — never prune them casually. |
|
||||
| `(*Store).LastCustomerSentAt` | hub/internal/store/store.go (~L815) | `(customerID, eventTypes []string) (time.Time, bool, error)` | Pairing-evidence query (max customer-channel `sent` created_at over types) | Uses the `(customer_id, created_at DESC)` index. Empty type list → `(zero, false, nil)`. |
|
||||
| `(*Store).SeedNotificationPrefs` | hub/internal/store/store.go (~L850) | `(customerID, email, enabledEvents) (seeded bool, err)` | Claim-time prefs seeding (audit F12) | INSERT OR IGNORE — never an upsert (red-proofed); empty email = no-op. Customer edits go through `SaveNotificationPrefs`, seeds NEVER do. |
|
||||
|
||||
### App-mail passthrough (hub/internal/mailrelay/)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `mailrelay.Sender` / `(*ResendSMTP).Send` | hub/internal/mailrelay/relay.go (~L24 / ~L50) | `Send(ctx, raw []byte, mailFrom, rcptTo) error` | Raw-MIME passthrough to Resend SMTP | Deliberately separate from notify's HTTP path — parse-then-API drops inline CID images (spike-proven). Do NOT "unify" them. Delivery verdict lands at DATA-close. |
|
||||
| `mailrelay.FromDomain` | hub/internal/mailrelay/relay.go (~L130) | `(raw []byte) (string, error)` | From-HEADER domain extraction for allowlisting | Header domain, not envelope — Resend checks the header. |
|
||||
|
||||
### Web auth / session / CSRF (hub/internal/web/)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `(*Server).RequireAuth` | hub/internal/web/server.go (~L359) | `(next http.Handler) http.Handler` | Session-cookie OR Basic-auth gate for all web routes | Empty effective hash disables auth entirely (dev mode). Browsers → /login redirect; JSON-ish requests → 401. |
|
||||
| `(*Server).effectivePasswordHash` | hub/internal/web/server.go (~L118) | `() string` | THE single source for the operator login hash — call this, never read `configPasswordHash` | Precedence: `hub_settings` DB override (set via Configuration UI) wins, else the hub.yaml `auth.password_hash` seed. ConfigMap = break-glass reset. Change it via `POST /configuration/password` (`handleChangePassword`). |
|
||||
| `(*Server).validateCSRF` | hub/internal/web/server.go (~L446) | `(r) bool` | CSRF check — enforced centrally in `web.ServeHTTP` for every non-GET | No session cookie → returns true (Basic-auth path is exempt). New POST routes get CSRF for free; forms MUST embed `csrfField`. |
|
||||
| `(*Server).csrfField` | hub/internal/web/server.go (~L483) | `(r) template.HTML` | Hidden `_csrf` input for HTML forms | Pass into template data on every form-rendering handler. |
|
||||
| `(*Server).CleanupSessions` | hub/internal/web/server.go (~L110) | `(ctx)` — goroutine | Expired-session sweeper | Started once from main; 15-min tick. |
|
||||
|
||||
### Status tokens & template funcmap (hub/internal/web/)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| funcMap in `web.New` | hub/internal/web/server.go (~L67) | `template.FuncMap{...}` | ALL template helpers (`timeAgo`, `statusColor`, `json`, `hubVersion`…) | Add new template funcs HERE, nowhere else. Tested by hub/internal/web/funcmap_test.go + render_test.go. |
|
||||
| `inline_confirm_js` partial (v0.52.0) | hub/internal/web/templates/inline_confirm.html | `{{template "inline_confirm_js"}}` in the page `<head>` | Inline "question + Igen/Mégse" confirm for consequential buttons — `data-confirm="…"` on the button, or `felhomConfirm(el, q, onYes)` from JS | NEVER native `confirm()`/`prompt()` — OS-modals freeze browser automation (F-16). `scripts/hub_confirm_gate.py` asserts zero, but is **not wired to run** (R-29) — so the rule holds only as long as you keep it. Uses `requestSubmit` so `formaction` sub-buttons riding a parent form work. NOT for the danger-zone typed-confirm cascade. |
|
||||
| `timeAgo` | hub/internal/web/server.go (~L603) | `(t time.Time) string` | Human-relative timestamps in UI | — |
|
||||
| `statusColor` | hub/internal/web/server.go (~L630) | `(status string) string` | Status → design-system-v2 token (nominal/warn/crit/neutral) | Class SUFFIX only, never inline color (D4). Exception-color principle: healthy = blue/neutral. |
|
||||
| `(*Server).hostStatus` + `hostStatusClass`/`hostStatusLabel` | hub/internal/web/hosts.go (~L16/34/48) | `(lastReport *time.Time) string` | Host liveness badge | Uses the SAME threshold as HostStalenessChecker (down = 2× stale) — never invent a second definition. |
|
||||
| `parseSQLiteTime` | hub/internal/store/store.go (~L1160) | `(s string) time.Time` | Parsing ANY timestamp read from SQLite | modernc/sqlite returns multiple formats; raw `time.Parse` will intermittently zero out. Always use this. |
|
||||
| `compareVersions` | hub/internal/web/server.go (~L571) | `(a, b string) int` | X.Y.Z comparisons in web (floor checks, update-available) | Returns 0 on parse error — unparseable compares as "equal" (see §3). |
|
||||
|
||||
### Host views & lifecycle / offsite endpoints (v0.47.0, hub/internal/web + store)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `(*Server).hostDetailData` | hub/internal/web/hosts.go (~L282) | `(host *store.Host, r) map[string]interface{}` | The ONE view-model builder for the shared `host_detail_body` sub-template (standalone `/hosts/{id}` + customer Host tab) | Booleans/counts only for DR/escrow; carries `Deletable` (= status != "ok") which gates the danger-zone card. Never add a secret field. |
|
||||
| `parseHostAddresses` + `(*Server).hostNetwork` / `hostNetworkView` (v0.85.0) | hub/internal/web/hosts.go | `(reportJSON) []hostAddressView` · `(host, reportJSON) hostNetworkView` | The host page's Network card: every routable address the box holds + its WireGuard allocation | Needs agent **>= 0.119.0** (`minAgentForAddresses`); below it the wire has no `addresses` key and the card renders **UNKNOWN, never "no addresses"** — an absent signal is not a negative result. WireGuard is TWO facts: the hub's allocation (`GetWGPeerForHost`, authoritative) AND whether the box confirms holding it — the allocation alone cannot distinguish a live tunnel from a peer that was never applied. The WG row is split out by comparing against the ALLOCATION, never by matching the interface name `wg-felhom`, which is a unit name that can change. |
|
||||
| `(*Store).GetHostRecoveryMeta` + `(*Server).handleHostRevealRecoveryCredential` | hub/internal/store/host_recovery.go · hub/internal/web/hosts.go | `(hostID) (*HostRecoveryMeta, error)` · `POST /hosts/{id}/reveal-recovery-credential` | The break-glass console credential, split into a RENDER half and a RETRIEVE half (v0.84.0) | **Use `GetHostRecoveryMeta` on any page-render path** — its struct and its `SELECT` both omit the `secret` column, so it cannot leak one; `GetHostRecoveryCredential` (which does select it) belongs only to the two retrieval handlers. The reveal is POST so the ServeHTTP-level CSRF check applies and no secret is reachable by URL; it writes ONE `recovery_credential_revealed` event via `SaveEvent` and calls NO dispatcher (the `handleRequestLogTail` shape). `api/handler.go handleAdminGetRecoveryCredential` (global key) is the independent fallback for when the UI is down — never route the UI through it. Secret at rest is plaintext → R-133. |
|
||||
| `host_detail_body` sub-template | hub/internal/web/templates/host_detail_body.html | `{{template "host_detail_body" .}}` | Rendering a host's detail sections on ANY surface | One namespace across ParseFS (icons.html pattern). Renders per-host — id-suffix any new element ids with `{{.HostID}}` (the customer page renders N instances). |
|
||||
| `(*Store).ListHostsByCustomer` | hub/internal/store/store.go (~L1620) | `(customerID) ([]Host, error)` | A customer's hosts, host_id order | A LIST by design (HA-cluster roadmap) — don't collapse to GetHostByCustomer. |
|
||||
| `(*Server).configFormData` (v0.49.0) | hub/internal/web/configs.go (~L430) | `(r, isNew, cfg, overrides, errMsg) configFormView` | The ONE view-model builder for the customer config form (standalone chrome + the customer page Edit tab) | `overrides=nil` → parses the STORED cfg.ConfigJSON; pass the SUBMITTED map on the update validation-error re-render or typed values reset (red-proofed). |
|
||||
| `config_form_body` sub-template (v0.49.0) | hub/internal/web/templates/config_form_body.html | `{{template "config_form_body" <configFormView>}}` | Rendering the config form on ANY surface (config_form.html chrome + customer Edit tab) | The floor/geo/danger cards on the Edit tab are SIBLINGS after `</form>` — never nest a form inside it (breaks the offsite/PBS formaction sub-buttons). Includes the F5 in-flight `<script>`. |
|
||||
| `(*Store).CountHostArtifacts` / `DeleteHost` | hub/internal/store/store.go (~L1640/~L1690) | `(hostID) (HostArtifacts, error)` / `(hostID, deleteEscrow bool) error` | Host-delete impact preview + the ONE-transaction cascade | ONLINE gate lives in the handler, escrow gate in the store (`ErrHostEscrowPresent`, tx never starts). log_bundles die by `scope_id == host_id` ONLY (customer-scoped bundles survive). The wg_peers delete is INSIDE the tx — never split it out. |
|
||||
| `(*Server).commitCustomerReset` (v0.69.0) | hub/internal/web/customer_reset.go (~L165) | `(ctx, cfg, resetID int64, purgeEscrow bool) *resetLegError` | THE committed RESET sequence — external teardown FIRST (Hetzner, PBS), then claim → descriptor → DB purge, each leg stamped into the `customer_resets` journal | Owns NO gate, NO audit event, NO journal open/close, NO redirect — those are the caller's (the two callers differ there). `purgeEscrow` governs ONLY whether `PurgeCustomerResetDBState` destroys retained custody: standalone RESET passes the operator's `escrow_ack`; the DELETE cascade passes **false** so custody dies exactly once, in its leg 3. Returns a `resetLegError` carrying the leg name + the exact status/message the standalone handler has always returned — do not re-word them. |
|
||||
| `(*Store).CustomerResidue` / `PurgeCustomerResidue` (v0.70.0) | hub/internal/store/customer_delete.go | `(customerID) (*CustomerResidue, error)` / `(customerID) error` | Counting + purging the report-derived state and the credential-bearing bindings a deleted customer leaves behind | **`GetCustomers()` is REPORT-derived** — until the reports are gone the customer stays on the Customers list AND stays in the staleness/offsite checkers’ work list, so a deleted customer keeps emailing the operator. Both funcs walk ONE shared `residueQueries` list so a table can never be counted-but-not-purged. Includes `appliance_registrations` + `selfbind_tokens` (credential-bearing, not telemetry). NEVER touches `events`, `notification_log`, `host_deletions`, `customer_resets`. |
|
||||
| `(*Server).handleCustomerDelete` / `handleCustomerDeletePreview` (v0.69.0) | hub/internal/web/customer_delete.go | `(w, r, customerID)` | THE customer offboarding entry: the guided full-teardown cascade `hosts → RESET → purge` (R-25b). GET = live inventory JSON for the dialog, POST = the cascade | There is NO shallow delete path any more — the old `handleConfigDelete` is gone; do not reintroduce one. Every gate (3 acks, typed customer-id, stale host-count, ONLINE-host refusal) runs BEFORE any write, so a refusal has zero side effects. Leg order is load-bearing twice over: ruling 3 (RESET never sees a host row) and custody purged exactly once, in leg 3. A failed leg retains the journal — a re-run resumes and must pass every gate again. |
|
||||
| `(*Store).ListWGEndpoints` / `DeleteWGEndpoint` | hub/internal/store/wg.go (~L64/~L86) | `() ([]WGEndpoint, error)` / `(endpointID) error` | The /offsite endpoint-management surface | `GetWGEndpoint` (lowest id, LIMIT 1) stays THE allocation/sync endpoint — do not switch allocator/reconciler/desired-state to the list without the `wg_peers.endpoint_id` migration arc. Peers-in-subnet guards live in hub/internal/web/offsite.go. |
|
||||
|
||||
### Artifact manifest / Day-0 trust root
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `(*Store).GetArtifactManifest` / `SetArtifactManifest` | hub/internal/store/store.go (~L933 / ~L944) | `() ArtifactManifest` / `(m) error` | The DB-backed (hub_settings) Day-0 artifact record | This is the checksum TRUST ROOT the host-bootstrap verifies against — distinct from Gitea, which only stores bytes. |
|
||||
| `(*Store).GetOperatorPasswordHash` / `SetOperatorPasswordHash` | hub/internal/store/store.go (~L1350) | `() string` / `(hash) error` | The DB-backed (hub_settings) operator login password override | Read via `Server.effectivePasswordHash()`, not directly. "" = no override (config seed authoritative). Store the bcrypt hash, never the plaintext. |
|
||||
| `(*Server).handleSetArtifacts` + `resolveArtifactSHA` | hub/internal/web/configs.go (~L644 / ~L680) | `POST /configuration/artifacts` | Operator UI to vouch artifact versions | With a Gitea client the sha is fetched AUTHORITATIVELY (submitted sha ignored); fetch failure refuses the save. Manual sha only in the no-creds fallback. |
|
||||
| `(*gitea.Client).ListVersions` / `FileSHA256` | hub/internal/gitea/gitea.go (~L47 / ~L72) | `(ctx, pkg) ([]string, error)` / `(ctx, pkg, ver, file)` | Read-only Gitea generic-package metadata | sha comes from package metadata — artifact bytes are never downloaded. Newest-semver-first sort. |
|
||||
| `(*Server).artifactChoices` | hub/internal/web/server.go (~L155) | `(ctx, pkg, file) []artifactChoice` | Version+sha dropdown data | nil Gitea client / unreachable → nil → UI degrades to manual entry. One bad version drops itself, not the list. |
|
||||
| `(*Handler).handleArtifactManifest` | hub/internal/api/handler.go (~L1550) | `GET /api/v1/artifacts/{id}` | Serving the vouched set to the bootstrap script | Auth mirrors handleConfigRetrieve exactly. Unset manifest = empty fields, not an error. |
|
||||
| `normalizeFloorInput` / `normalizeSHA256` | hub/internal/web/configs.go (~L27 / ~L627) | `(raw string) (string, bool)` | Validating operator-typed versions / shas | Empty string is VALID (means "clear"). Reuse for any new version/sha form field. |
|
||||
|
||||
### PBS DR tier / tenantsync (hub/internal/tenantsync/, hub/internal/web/pbsdr.go, hub/internal/api/pbsdr.go)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `tenantsync.Client` (`Provision`/`Reissue`/`Fingerprint`) | hub/internal/tenantsync/client.go | `(ctx, customerID) (*Result, error)` | ep0 per-customer PBS tenancy over the pinned-SSH forced-command channel (the wgsync twin) | `Result.TokenSecret` is transient custody → `SaveHostPBSSecret` immediately, never log the struct. Error paths NEVER embed stdout (the secret channel) — do not "improve" diagnostics by quoting the response. `ErrTokenExists` is typed: provision refuses an existing token; re-issue is the explicit path. |
|
||||
| `(*Store).SaveHostPBSSecret` / `ConsumeHostPBSSecret` | hub/internal/store/pbsdr.go | `(hostID, value)` / `(hostID) (string, error)` | HOST-scoped consume-once secret (the one_time_secrets host twin) | Same-tx mark-consumed; re-save resets consumption (re-issue supersedes). The agent consumes via `POST /api/v1/hosts/{id}/pbs/consume-token` (hub/internal/api/pbsdr.go). |
|
||||
| `offsite.DeliveryStateFor` (+ `DeliveryStatus`) | hub/internal/offsite/delivery.go | `(st, customerID) (DeliveryStatus, error)` | THE R-70 offsite last-mile detector — one implementation for every consumer (customer card `deliveryViewFor`, `monitor.OffsiteDeliveryChecker` event + R-71c heal) | Precedence: `applied` (latest report has offsite) wins over every secret-row shape; applied+unconsumed-staged = applied + `StaleStagedSince` flag (demo-felhom's live specimen). Never add a sibling derivation — consumers read THIS. |
|
||||
| `(*Store).GetOneTimeSecretInfo` / `LastEventAt` / `LatestReportOffsitePresence` / `CountReportsOffsiteSince` | hub/internal/store/store.go | `(customerID) (*OneTimeSecretInfo, error)` / `(customerID, eventType) (time.Time, error)` / … | Detector inputs + DURABLE event-cooldown source (events table survives restarts — prefer over in-memory maps for hub-emitted checker events) | `GetOneTimeSecretInfo` never selects the value column — keep it that way. `SetOneTimeSecretTimesForTest` is the back-dating seam (PBSDR pattern). |
|
||||
| `monitor.OffsiteDeliveryChecker` + `OffsiteReissuer` | hub/internal/monitor/offsite_delivery.go | `NewOffsiteDeliveryChecker(st, reissuer, onEvent, logger)` | R-70 stuck event + R-71c self-heal on the shared 60 s ticker | THE R-39(a) GUARD lives in `maybeHeal`: re-reads the secret row at act time and refuses over an UNCONSUMED row — `SaveOneTimeSecret` clobbers by design (Re-issue depends on supersede); never "fix" the store, never bypass the guard. reissuer nil = heal disabled (no provisioner) — required, else a heal-event fires for a silent no-op. |
|
||||
| `monitor.RestoreTestChecker` + `assessRestoreProven` | hub/internal/monitor/restoretest.go | `NewRestoreTestChecker(st, onEvent, logger)`; `.Check()` | R-85: turns a restore-test result into a SIGNAL — it was a `[WARN]` log line and nothing else, even for the tier already being tested | **TWO event types, never merged**: `restore_test_failed` (broken now, error) vs `restore_test_stale` (unverified — *not* known-broken, warning). Merging collapses the second into the first, and the second is what quietly becomes the first. **Anchored on R-81** (`assessRestoreProven` reuses `backupAssessment`/`verdict*`): a never-proven tier on a newborn box is UNKNOWN, not FAILED. Per-tier proof comes from the hub's RETAINED WINDOW — the agent reports only its latest run, so the latest report alone cannot answer "when was the OTHER tier last proven?". Operator-tier only: **no `customerMessages` entry** — do not add one without copy review. |
|
||||
| `(*Server).applyPBSDR` + `mergePBSDR`/`readPBSDR` | hub/internal/web/pbsdr.go | `(ctx, r, cfg) error` | The config form's DR-tier section → HOST desired_json `pbs_dr` descriptor + generation bump | Descriptor lives in the host desired_json, NOT ConfigJSON (buildConfigJSON drops foreign keys on re-save). v0.51.0: driven by `cfg.DRTier` (set from the form BEFORE applyOffsite/applyPBSDR); UNMET preconditions are honest waiting stages (save succeeds), REAL failures stay fail-closed; already-provisioned = success-no-op (red-proofed); disable keeps the ep0 tenancy. |
|
||||
| `(*Server).pbsdrProvisionAtom` + `PBSDRAutoProvision` | hub/internal/web/pbsdr.go | `(ctx, customerID, host, storageID) (blocked string, err error)` / `(ctx, customerID)` | The shared fresh-provision cascade atom; the WG-registration hook target (api `SetWGRegisteredHook`, wired in hub/cmd/hub/main.go when tenantsync is on) | `blocked != ""` = waiting stage (never an error); the hook runs in a detached goroutine and must never fail registration. Scenario-A e2e test: TestPBSDR_AutoProvisionOnWGRegistration. |
|
||||
| `cfg.DRTier` + offsite coupling | hub/internal/store/store.go (CustomerConfig), hub/internal/web/configs.go (applyOffsite guard) | bool | Per-customer DR-tier flag: new-customer default ON (handleConfigNewForm); offsite REFUSED without it (exact F-6 message) | One-time migration backfill initializes legacy rows from descriptor reality — never re-runs (opt-outs survive re-open; store test pins it). Form field `dr_tier` (formBool helper). |
|
||||
| `pbsdrheal.Reconciler` + `NewActions` | hub/internal/pbsdrheal/reconciler.go | `NewReconciler(st, act, logger)` · `RestrictToHost(hostID)` · `Run(ctx)`/`Trigger()` | PBS-DR SELF-HEAL: re-arms a consumable secret for a box stuck in `waiting_secret`/`consumed_failed` after losing its converged marker (re-install/rollback). From `SPIKE-pbsdr-selfheal-2026-07-15`. | Primary heal = **re-stage** the stored secret (no ep0 call, **NO generation bump** — a bump = agent refetch loop). Escalate to Re-issue only when no stored secret / `consumed_failed`. NEVER re-run `pbsdrProvisionAtom` (refuses `ErrTokenExists`) or blind-timer Re-issue (hash/gen thrash). Converged/`disabled`/`verify_failed`/DR-OFF = no-op. Debounce ≥2 distinct reports. `PBSDRHEAL_ONLY_HOST` scopes a supervised rollout. Fake seam: `fakeActions` in reconciler_test.go. |
|
||||
| `(*Store).RestageHostPBSSecret` + `PBSDRHealStates` | hub/internal/store/pbsdr.go | `(hostID) (restaged bool, err)` / `() ([]PBSDRHealRow, error)` | The self-heal store primitives: clear `consumed_at` IFF a row exists (re-arm the SAME value); the fleet work-set query (descriptor enable/provision + latest report `pbs_dr.state` + id) | `RestageHostPBSSecret` does NO insert, NO value change, NO generation bump (`restaged=false` = no row → caller escalates). `PBSDRHealStates` mirrors `GetHostOOBStates`' latest-report-per-host join; malformed JSON → zero values, never an error. |
|
||||
| `(*Server).ReissuePBSDR` | hub/internal/web/pbsdr.go | `(ctx, customerID) error` | The non-HTTP core of the operator Re-issue button — the self-heal reconciler's escalation seam (satisfies `pbsdrheal.Reissuer`) | Reuses `tenantsync.Reissue` + `SaveHostPBSSecret` + descriptor bump — NOT a re-run of `pbsdrProvisionAtom`. Keep in lockstep with the tail of `handlePBSDRReissue` (which is unchanged; the operator button's 303/400 codes are pinned by tests). |
|
||||
| `parseHostCapabilities` + `capabilityView` | hub/internal/web/hosts.go | `(reportJSON) []capabilityView` | Host-page capability chips (ok/degraded/inactive) | `inactive` (agent v0.86.0) = badge-neutral, NEVER warn/error — disabled ≠ degraded; unknown future statuses fall to the degraded styling (surface, don't hide). `capabilitiesNeedDRMigration` keys the pre-v1.15.0 migration hint on pbsdr-* + "binary not found". |
|
||||
|
||||
### Config generation & secrets hygiene (hub/internal/configgen/)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `configgen.Generate` | hub/internal/configgen/configgen.go (~L16) | `(templateYAML, cfg) (string, error)` | Producing a customer controller.yaml | Programmatic overrides (customer id/hub url/api_key) ALWAYS win over config_json; fresh session secret per generation. |
|
||||
| `configgen.RandomHex` | hub/internal/configgen/configgen.go (~L110) | `(n int) (string, error)` | crypto/rand hex tokens (API keys, session secrets) | — |
|
||||
| `configgen.RandomPassphrase` | hub/internal/configgen/passphrase.go (~L35) | `(wordCount int) (string, error)` | Human-dictatable Hungarian passphrases (retrieval passwords) | ~29K-word embedded list; 5 words ≈ 74 bits. |
|
||||
| `(*Store).EffectiveMinControllerVersion` | hub/internal/store/store.go (~L960) | `(customerID) string` | Resolving the floor that actually applies (per-customer → global) | "" = no floor (Phase 2 inert). |
|
||||
|
||||
### Assets, store misc, scheduling
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `assets.Manager` (`New`, `ServeFile`, `ReSeed`) | hub/internal/assets/assets.go (~L44/190/122) | seed-dir → PVC sync + manifest | Serving app logos/screenshots to controllers | `ServeFile` sanitizes to `filepath.Base` (no traversal). `isAssetFile` enforces the naming convention. |
|
||||
| `copyFile` | hub/internal/assets/assets.go (~L259) | `(src, dst) error` | THE atomic file write (tmp + rename) in the hub | Copy this shape for any new on-disk write. |
|
||||
| `fileSHA256` | hub/internal/assets/assets.go (~L244) | `(path) (string, error)` | Streaming sha256 of a file | — |
|
||||
| `(*Store).SaveEvent` | hub/internal/store/store.go (~L1003) | `(...) (int64, error)` | Persisting ANY event (controller or hub source) | Pair with dispatcher/`onEvent` — saving alone never notifies. |
|
||||
| `(*Store).RequestLogTail` / `GetPendingLogTailRequests` / `SaveAppLogTail` | hub/internal/store/logtail.go | pending-intent + consume-once fulfillment | THE ACK-flag pull pattern for hub→box requests (copy for any new one) | SaveAppLogTail clears the request in the SAME tx (consume-once) + prunes to last 2 per (customer,app); the hub NEVER connects into a box |
|
||||
| `(*Store).RequestLogBundle` / `PendingLogBundleRequest` / `SaveLogBundle` / `PurgeExpiredLogBundles` | hub/internal/store/logbundle.go | component (controller/agent) log pulls — the v0.46.0 sibling of logtail.go | box-component debug-ring pulls; gzip custody, newest-3, 72 h TTL on the 60 s sweep | scope = customer_id (controller/report ACK) vs host_id (agent/heartbeat envelope); `SaveLogBundle` runs the SECRET GATE fail-closed (blocked flag row, no payload) and clears the request in the same tx; `[REDACTED]`/checksums pass by design |
|
||||
| `upsertAppIssue` dismissal/context semantics | hub/internal/store/telemetry.go | ON CONFLICT CASE guards | Issue dismissal + first-capture-wins context | Un-dismiss ONLY on `excluded.last_seen > dismissed_at`; context adopted only while stored one is empty — do not "simplify" either CASE (red-proofed) |
|
||||
| `store.GuestID` | hub/internal/store/store.go (~L1268) | `(hostID string, vmid int) string` | Canonical guest primary key | Never hand-concatenate host+vmid. |
|
||||
| `(*Store).GetHostReportsSince` + `GetFirstHostReportAt` + `monitor.newestBackupEvidence` | hub/internal/store/store.go, hub/internal/monitor/deadline.go | `(customerID, since) ([]HostReportRow, error)`; `(customerID) (time.Time, error)`; `(rows, now) (time.Time, bool)` | **Asking "when did the hub last SEE evidence of X?" instead of "what does the latest report say?"** — the R-81 anchor. The agent's reporters are point-in-time and forget across a restart; the hub retains ~90 d of host-reports and does not. | The three go together: window scan + first-contact anchor + a bounded lookback (`backupEvidenceLookback`). **Never judge a report-derived absence on the LATEST report alone** — that is the bug class R-81 fixed for the third time. The scan early-exits on sufficiently-fresh evidence, so don't reorder rows away from newest-first. |
|
||||
| `scheduleDaily` | hub/cmd/hub/main.go (~L449) | `(ctx, name, "HH:MM", fn, logger)` | Daily jobs in Europe/Budapest (prune etc.) | Blocking — run as goroutine. `parseHM` returns 0,0 (midnight) on bad input. |
|
||||
|
||||
## 2. Canonical patterns (copy structure from THE named file)
|
||||
|
||||
| Pattern | Canonical file | Key traits |
|
||||
|---|---|---|
|
||||
| Monitor checker | hub/internal/monitor/staleness.go | Seed state on construction WITHOUT emitting events; in-memory `states` map under mutex; periodic `Check()`; `emitTransition` = SaveEvent then nil-checked `onEvent`; cleanup of vanished IDs. HostStaleness/HostDisk/HostLeaf/HostCapability/StorageFill all follow it. |
|
||||
| API endpoint | hub/internal/api/handler.go `ServeHTTP` (~L139) + any handler | Path switch in ServeHTTP; first line of every handler = checkAuth{Customer,Host}; `io.LimitReader` body cap; typed anonymous payload struct; explicit 4xx strings. |
|
||||
| Web POST action | hub/internal/web/configs.go `handleSetGlobalFloor` (~L602) | CSRF enforced centrally in web ServeHTTP; validate via `normalize*` helper; POST-redirect-GET with `?flash=` token; log INFO on success. |
|
||||
| Optional dependency injection | hub/internal/web/server.go `Set*` setters (~L131–148) / api handler `SetDispatcher` etc. | Constructor takes hard deps; optional subsystems wired via `SetX` after construction; nil = graceful degradation (never panic). |
|
||||
| Seam-injected sender for tests | hub/internal/notify/dispatcher.go `sendEmailFn` (~L33) | Function-field defaulting to the real impl in the constructor; tests overwrite it. Same idea: `mailRateLimiter.now`, `mailrelay.Sender` fake. |
|
||||
| Immediate-sync notify (per plane) | agent plane: `poke.Notifier` (`web.Server.poke` / `api.Handler.poker` via `SetPoke`/`SetPoker`) — controller plane: `intent.Hub.Bump` (`web.Server.bumpIntent`, `api.Handler.intentHub`) | EVERY desired-state mutation fires the RIGHT plane's notifier AFTER the successful store write, never on an error path (fire-after-commit). Agent-plane pokes a HOST when its generation moved (`SetHostDesired`/`Bump*HostDesired`); controller-plane bumps a CUSTOMER on a controller-visible change. Both receivers COALESCE bursts — add NO hub-side dedup. Deliberate non-sites need a documented reason (undeliverable pre-tunnel, transport removed, or no generation bump → the 60 s ticker is the pickup path). Both seams nil-safe: unset = the ≤15-min cycle still reconciles. Full site inventory: REPORT.md audit table (v0.63.0). |
|
||||
| Website page | website/index.html | UTF-8 **with BOM**; shared `<nav>`/`<footer>` byte-identical across pages (only `class="active"` differs); two-tone H1 = `<h1>…<span>accent</span></h1>`; all styling in website/assets/site.css tokens (`:root`) — zero embedded `<style>`; `?v=N` cache-bust on site.css/icons.svg; umami snippet; no CDN fonts; no emoji (sprite icons.svg instead). |
|
||||
| Gate script | scripts/site_gates.py | Byte-level mechanical gates (BOM, emoji codepoint ranges, nav/footer diff, analytics, banned tokens, cache-bust); run `python scripts/site_gates.py` after ANY website change; non-zero exit on failure. |
|
||||
| Fetch-validate-install (shell) | scripts/felhom-host-install.sh `step_agent_install` (~L1108) | `fetch_raw` to mktemp → syntax-check (`bash -n`) → `install -m0755 -o root -g root` → only then activate; guarded-mkfs wrapper installed BEFORE the sudoers that references it (ordering is the safety property). All mutations through `run()` (dry-run aware). |
|
||||
| Install-profile gate (shell) | scripts/felhom-host-install.sh `--mode appliance\|byo` (GL-2, v1.10.0) | Mandatory-flag profile (no default), refusals at argv time BEFORE any prompt/step, risky step gated at its CALL SITE (one auditable place — never a branch inside the step), mode persisted to state.json + resume-mismatch refusal, `FELHOM_INSTALL_STATE_DIR` override for harness isolation. Harness: scripts/hostinstall-mode-harness.sh (static refusal matrix + grep-invariants + PVE dry-transcript tier; red-proofs run against a mutated scratch copy). |
|
||||
| Disclosure↔uninstall parity (shell) | scripts/felhom-host-install.sh `_uninstall_statement` + harness GL4-D (v1.11.0) | Every host artifact the byo disclosure names must be removed OR explicitly listed KEPT by `run_uninstall`; the harness greps the parity (token list). New install-time artifact ⇒ add its removal + disclosure line + parity token in the SAME commit. Drive data rule: plain `umount` only, never `-l`/`-f`, never any format op under /mnt/felhom-drives. |
|
||||
| Website deploy (manifest) | manifests/webpage.yaml | git-sync sidecar (sparse-checkout `/website/` + `/scripts/`, `--link=current`) + init container waits for first sync; nginx serves `current/website`; push to main = deployed, no image build. |
|
||||
| Secret handling (manifest) | manifests/hub.yaml (env, ~L142) | Secrets via `secretKeyRef` to OUT-OF-BAND secrets created per documentation/runbooks/secrets.md — never inline stringData (see §3). `report-api` (the operator bearer, v0.53.0) is deliberately NOT `optional:` — a missing Secret fails Ready instead of booting an unauthenticatable hub. `scripts/manifest_bearer_gate.py` (run after ANY manifests/ change) blocks bearer-shaped (64-hex) literals. ERRATA (2026-07-03): `gitea-creds` is COMMITTED in manifests/felhom.secret.yaml AND live-consumed by hub.yaml — rotation + de-git is a pending operator task (spike SPIKE-a1 appendix). |
|
||||
| Hub deploy (GitOps) | manifests/hub.yaml `image:` (~L129) | Pinned explicit tag, bumped in git, deliberate ArgoCD sync (auto-sync OFF). Code push alone deploys nothing. |
|
||||
|
||||
## 3. Dangerous lookalikes — do NOT reuse
|
||||
|
||||
| Trap | Why it bites | Use instead |
|
||||
|---|---|---|
|
||||
| A plain `missed bool` for a report-derived absence (hub/internal/monitor/deadline.go) | Collapsing the three-valued verdict re-introduces one of TWO failure modes: absence→MISSED is the 2026-07-26 cry-wolf (three boxes alarmed at once, one reached a customer channel); absence→OK means a genuinely dead box alarms NEVER, which is strictly worse. Three instances of this class so far: hub v0.12.0, v0.73.0, R-81. | `backupAssessment{verdict: verdictOK|verdictUnknown|verdictMissed}` + an anchored window — copy the shape from `assessBackupFreshness`, not a bool. |
|
||||
| `(*Handler).handleNotify` + `formatNotificationEmail` + `sendResendEmail` (hub/internal/api/handler.go ~L1289/1624/1589) | Legacy pre-dispatcher notification trio: no cooldowns, no operator channel, no allowedEventTypes gate, duplicate Hungarian formatter. Controller path is FROZEN until slice-10 cutover. | `POST /api/v1/event` → `Dispatcher.ProcessEvent` + `notify.Format*Email` |
|
||||
| Severity `"critical"` POSTed to a PRE-v0.31.0 hub | Fixed in hub v0.31.0 (`handleEvent` now accepts critical). Older hubs coerce `critical` → `"info"`, which never notifies — silent alert loss. Case-variants (`"Critical"`) still coerce on every version. | Against an old hub send `warning`/`error`; otherwise lowercase `critical` is safe |
|
||||
| `compareVersions` for anything security-ish (hub/internal/web/server.go ~L571) | Returns 0 (equal) on unparseable input — a garbage version passes a floor check. `gitea.compareSemver` behaves differently (lexical fallback). | Validate input with `normalizeFloorInput` first; then compareVersions is safe |
|
||||
| Inline `stringData` secrets à la manifests/felhom.secret.yaml | Commits real credentials to git (healthchecks superuser pw, umami APP_SECRET/POSTGRES_PASSWORD, gitea-creds admin password still live there). | Out-of-band `kubectl create secret` + `secretKeyRef` (hub.yaml resend-api pattern; runbook documentation/runbooks/secrets.md) |
|
||||
| `kubectl apply` / `kubectl set image` on manifests/ | ArgoCD app `felhom` reverts drift on next sync; live state lies about git. | Edit manifest in git → push → ArgoCD sync (CLAUDE.md steps 3–5) |
|
||||
| `:latest` image tag in manifests | Re-push doesn't change the manifest → no redeploy; Synced/Rollback misreport. | Pinned version tag, bumped per deploy |
|
||||
| grep/regex hunting emoji in website HTML | Windows grep false-negatives multibyte emoji (proven in D0). | `python scripts/site_gates.py` (codepoint-range check) |
|
||||
| Adding a website page without touching site_gates.py | `PAGES` list (scripts/site_gates.py ~L22) is explicit — an unlisted page is silently ungated (BOM/nav/emoji drift undetected). | Add the filename to `PAGES` in the same commit |
|
||||
|
||||
## 4. Seams & interfaces (testing + cross-repo)
|
||||
|
||||
| Interface | Defined in | Implemented by | Fakes/tests at |
|
||||
|---|---|---|---|
|
||||
| `mailrelay.Sender` | hub/internal/mailrelay/relay.go (~L24) | `ResendSMTP` (prod) | fake sender in hub/internal/api/mail_test.go; hub/internal/mailrelay/relay_test.go |
|
||||
| `Dispatcher.sendEmailFn` (func seam) | hub/internal/notify/dispatcher.go (~L33) | `(*Dispatcher).sendEmail` (Resend HTTP) | hub/internal/notify/dispatcher_test.go |
|
||||
| `monitor.EventNotifyFunc` | hub/internal/monitor/staleness.go (~L14) | closure over `Dispatcher.ProcessEvent` (main.go) | hub/internal/monitor tests (captured-events func) |
|
||||
| `api.ConfigTemplateProvider` | hub/internal/api/handler.go (~L24) | `web.TemplateFetcher` (Gitea-pulled controller.yaml template) | stub providers in api tests |
|
||||
| `api.LatestVersionProvider` | hub/internal/api/handler.go (~L31) | `web.VersionChecker` (registry poll) | hub/internal/api/config_version_ack_test.go |
|
||||
| `mailRateLimiter.now` (func seam) | hub/internal/api/mail.go (~L27) | `time.Now` | hub/internal/api/mail_test.go clock injection |
|
||||
| `web.tenancyProvisioner` | hub/internal/web/pbsdr.go | `*tenantsync.Client` (pinned SSH to ep0's felhom-tenantsync) | `fakeTenancy` in hub/internal/web/pbsdr_test.go; in-process SSH server in hub/internal/tenantsync/client_test.go |
|
||||
| Cross-repo: ep0 tenancy surface | `scripts/felhom-tenantsync.sh` (JSON stdin/stdout forced command) | installed on ep0 per runbook offsite-endpoint.md §10 | provision/reissue/fingerprint ops; token secret rides stdout ONLY; the peersync script/key are untouched |
|
||||
| Cross-repo: controller → hub | `POST /api/v1/report` (frozen) + `POST /api/v1/event` | felhom-controller repo | new event types MUST enter `allowedEventTypes` (hub/internal/api/handler.go ~L1063) or the controller gets 400 |
|
||||
| Cross-repo: agent → hub | `POST /api/v1/host-report`, `/host-enroll`, jobs/desired-state/escrow routes (handler.go ~L145–189) | felhom-agent repo | hub/internal/api/host_test.go, desired_test.go, escrow_test.go, dr_test.go |
|
||||
| Cross-repo: Day-0 bootstrap → hub | `GET /api/v1/config/{id}` + `/artifacts/{id}` (X-Retrieval-Password) | scripts/felhom-host-install.sh (fetches + sha256-verifies against the hub-vouched manifest) | hub/internal/api/artifact_test.go |
|
||||
| Cross-repo: controller ← hub assets | `GET /api/v1/assets/manifest` + `/assets/file/{name}` | felhom-controller pulls app logos/screenshots | assets manifest sha-based change detection |
|
||||
|
||||
## 5. Extension points (where new features plug in)
|
||||
|
||||
- **New event type — THREE registers, and which ones depend on the AUDIENCE.** Always: `allowedEventTypes` (hub/internal/api/handler.go) — missing it means the controller's POST 400s and the event vanishes (the known gotcha). Then decide the audience and stop guessing from the other registers:
|
||||
- **Operator-only** → add to `notify.operatorOnlyEvents` (hub/internal/notify/dispatcher.go) and give it **no** `customerMessages` entry. **Allowlisting alone does NOT make a type operator-only** — `FormatCustomerEmail` treats a missing `customerMessages` entry as a *fallback to the raw message*, not a block, and the only customer gate is configuration. v0.78.0 asserted the opposite in a comment and shipped the defect (R-97c). Examples: `whole_guest_backup_failed`, `recovery_unit_capture_failed`.
|
||||
- **Customer-facing with a STATIC message** → add a `customerMessages` entry (hub/internal/notify/templates.go) and the controller's `settings.DefaultEnabledEvents` if it should be on by default.
|
||||
- **Customer-facing with a DYNAMIC message** (the producer builds Hungarian text carrying names/numbers) → deliberately **no** `customerMessages` entry: `FormatCustomerEmail` PREFERS the entry over the message, so adding one silently discards the specifics. Examples: `offbox_enlarge_blocked`, `disk_health_degraded`, and since v0.89.0 `disk_warning`/`disk_critical`.
|
||||
- Pin BOTH registers in ONE test (hub/internal/api/recovery_unit_event_test.go is the model) — fixing one and not the other is the realistic mistake, and `notify.IsOperatorOnly` exists so the api package can assert it.
|
||||
- **A type in these registers with no PRODUCER is inert.** `disk_warning`/`disk_critical` were allowlisted, copy'd, default-enabled and checkbox'd from early on, and nothing in any repo emitted them until controller v0.191.0 — grep for an emitter before assuming a type works.
|
||||
- **New monitor checker**: copy hub/internal/monitor/staleness.go (§2 pattern); wire in hub/cmd/hub/main.go with an `EventNotifyFunc`; severity must be warning/error/critical to notify.
|
||||
- **New API route**: switch in `api.ServeHTTP` (handler.go ~L139); auth helper first line.
|
||||
- **New web page/action**: switch in `web.ServeHTTP` (server.go ~L182) — non-GET gets CSRF automatically; template into hub/internal/web/templates/ (embedded FS, parsed in `web.New`); new helpers into the funcMap (server.go ~L67).
|
||||
- **New template func**: funcMap in web.New only; add a case to hub/internal/web/funcmap_test.go.
|
||||
- **New daily job**: `scheduleDaily` in hub/cmd/hub/main.go + add pruning to `pruneAll` if data grows.
|
||||
- **New site gate**: append to scripts/site_gates.py; new website pages go into its `PAGES` list.
|
||||
- **New artifact kind (Day-0)**: consts `pkg*`/`file*` (hub/internal/web/server.go ~L27), `ArtifactManifest` fields + settings keys (hub/internal/store/store.go ~L905), `handleSetArtifacts`, `artifactManifestResponse` (handler.go), and the install script's verify step.
|
||||
- **New host-install step**: `step_*` function in scripts/felhom-host-install.sh using `run()`/`fetch_raw`/`die` helpers; keep dry-run coverage.
|
||||
- **New DR-recipe section**: `hostHalfShape`/`appHalfShape` **and** `AssembledRecipe` (hub/internal/store/dr_recipe.go) — those shape structs are **ALLOW-LISTS, not forward-compat**: a section only the emitter knows about is stored intact and **silently dropped** before the operator downloads it. No error, no log, no red test. That is R-122: the controller emitted `offsite_restic` from fork-4, all three real customers had it stored, and no delivered recipe ever contained it. Then extend `TestAssembleDRRecipe_CarriesEveryEmittedSection` (same commit) and, for a host-half section, the agent's `DRRecipeHostHalf` + BOTH copies of `testdata/host-report.golden.json` (byte-identical, cross-repo).
|
||||
|
||||
## 6. Known duplication (observed — NOT fixed)
|
||||
|
||||
- Resend HTTP sender ×2: `(*Handler).sendResendEmail` (hub/internal/api/handler.go ~L1589) ≈ `(*Dispatcher).sendEmail` (hub/internal/notify/dispatcher.go ~L185) — byte-near-identical POST to api.resend.com. Kept because the handler copy serves the frozen legacy /notify path.
|
||||
- Hungarian customer-email formatter ×2: `formatNotificationEmail` (hub/internal/api/handler.go ~L1624) vs `notify.FormatCustomerEmail` (hub/internal/notify/templates.go ~L118). Legacy vs dispatcher; the legacy one lacks the per-event-type message map.
|
||||
- Semver compare ×2 with DIFFERENT fallback semantics: `web.compareVersions` (hub/internal/web/server.go ~L571, parse error → 0) vs `gitea.compareSemver` (hub/internal/gitea/gitea.go ~L115, parse error → lexical). Documented as deliberate (import-cycle avoidance) in gitea.go, but the behavior drift is not.
|
||||
- Checker-family structural repetition: staleness.go vs host_staleness.go, and host_disk.go vs storage_fill.go (band/bandRank/bandLabel vs bandForPercent) — same skeleton re-implemented per domain; treated as the accepted §2 pattern rather than a defect.
|
||||
- Duration formatting ×2: `monitor.formatDuration` (hub/internal/monitor/staleness.go ~L187) vs `web.timeAgo` (hub/internal/web/server.go ~L603) — different audiences (email vs UI) but overlapping logic.
|
||||
@@ -0,0 +1,111 @@
|
||||
# STATUS — what works, what's broken, what's next
|
||||
|
||||
**Updated 2026-08-03.**
|
||||
|
||||
> **A view, not a source.** `documentation/backlog/OPEN-ITEMS.md` is the authority on open work; this
|
||||
> page restates part of it in plain words, and **nothing may exist only here**. **Not `CONTEXT.md`**,
|
||||
> which is technical state written for Claude Code — keep the two separate. **Maintenance:** update
|
||||
> at the end of every session in which something shipped, broke, or was decided. One screen; cut
|
||||
> items rather than extend it.
|
||||
|
||||
## What works right now
|
||||
|
||||
A blank machine boots the Felhom disc, installs itself unattended, and is claimed by the customer,
|
||||
who sets their own password. They install apps from a catalogue of fifty-three, share files over the
|
||||
home network, and open apps from a launcher or a shared link. Backups run on their own to three
|
||||
places — the machine's drive, a second drive, and an encrypted off-site copy — and a customer can
|
||||
restore files and app data from the drive alone. Proven end to end on real hardware.
|
||||
|
||||
**Apps come back after a power cut.** The machine tells an app the customer switched off from one
|
||||
that simply did not come back, and waits for the system to finish starting before deciding instead of
|
||||
glancing once, five seconds in. Hard-reset the demo box six times in a row: everything came back every
|
||||
time, and an app switched off deliberately stayed off every time.
|
||||
|
||||
## What's broken
|
||||
|
||||
**The off-site copy can be erased by the machine that made it** — the credential that writes it can
|
||||
also delete it. A daily snapshot is armed as a stopgap, and we have never restored from that copy.
|
||||
*(R-95, R-87)*
|
||||
|
||||
**A full disk emails you repeatedly instead of once.** When the reserve refuses an app's backup you
|
||||
are told once by the backup run — correctly — but the page showing backup status re-checks on a timer
|
||||
and sends the same message again each time. Not new: as old as the reserve itself, and seen only
|
||||
because we watched the alerts closely while proving the fix below. Harmless if the hub already
|
||||
collapses repeats — which a comment claims and nobody has checked. *(R-182)*
|
||||
|
||||
## What shipped recently
|
||||
|
||||
**The backup partition is gone, and both demo machines run on the new shape.** Wiped and rebuilt on
|
||||
3 August and taken through the whole customer journey — set up, install an app, back it up, restore
|
||||
it. One storage area instead of two; the space a backup can use went from 19 GB to 65 GB on the small
|
||||
machine and 45 GB to 233 GB on the big one. Three reboots each, correct every time. The two were
|
||||
rebuilt deliberately differently — one from a local copy of the image, one by the ordinary customer
|
||||
route with the published fingerprint checked — so the disk shape and the delivery route are both
|
||||
proven, rather than one proven twice. Their previous demo apps and data are gone; that was the point
|
||||
of a wipe, and you approved it. *(R-165, R-178)*
|
||||
|
||||
**What replaced the wall — and it now watches the right moment.** The wall was quietly doing a second
|
||||
job: keeping a runaway backup from eating the space the machine needs to keep running. That job is now
|
||||
explicit, and as first built it was checked too late — the big write happened first, unchecked, and
|
||||
only the small write after it was refused, while the message still promised your last good copy was
|
||||
untouched. **Fixed and proven on 3 August.** The machine now decides once, per app, **before it writes
|
||||
anything at all**, and that one answer covers all three steps: a refused app writes nothing, is not
|
||||
restarted, and the promise is now literally true — checked by fingerprinting every file before and
|
||||
after. It also stopped being blind to size, so an app is no longer waved through at 96% full and then
|
||||
allowed to write two gigabytes. Proven by deliberately filling a demo machine, once for each way it
|
||||
can refuse. Nothing is ever deleted to make room: every app has only one local copy, so "delete the
|
||||
oldest" would always mean destroying some other app's only copy. *(R-181)*
|
||||
|
||||
**The last of the three apps that never saved their data is fixed.** Installed nowhere, so nothing was
|
||||
stranded — checked on both demo machines and in the fleet list rather than assumed. Proven by the check
|
||||
that caught it, run in both directions: it clears the fixed version and still convicts the old one.
|
||||
*(R-156)*
|
||||
|
||||
**A filling disk warns the customer before anything breaks, and a failed backup reaches you** — the
|
||||
customer while there is still room to act, naming the drive and the space left; you when one app's
|
||||
backup fails, with the disk figures. The customer is deliberately not told about the second: they can
|
||||
free space, but they can do nothing about a failed backup. Both proven by filling a real disk. There
|
||||
are two rules and not one because the serious warning fired on free space while the disk was only 91%
|
||||
full — a percentage alone would have missed it. *(R-167, R-158)*
|
||||
|
||||
**The checks have two nets and the second emails you.** Every repository has one command that runs all
|
||||
its checks, before every push. That one can be skipped, so the build server runs them again and emails
|
||||
you on failure. It cannot *stop* a change — everything goes straight to the main copy with no review
|
||||
step — but it notices quickly and tells you. *(R-29, R-161, R-168, R-169)*
|
||||
|
||||
## What we're working on
|
||||
|
||||
- **Now:** both of today's items are done — the reserve and the last unsaved app. Your two decisions
|
||||
are written down and are ours to build.
|
||||
- **Next:** building those two — moving the installer onto a labelled version so publishing is one
|
||||
step you can undo, and a check that refuses to install a version nobody can download *(R-110, R-115)*.
|
||||
- **After:** the off-site copy that the machine making it can still erase *(R-95, R-87)*.
|
||||
|
||||
## Waiting on you
|
||||
|
||||
- **A job, not a decision: the hub password needs changing.** A diagnostic command printed it into a
|
||||
session log; nothing suggests anyone else saw it. *(R-132)*
|
||||
- **Nothing else.** You settled both open questions on 3 August — the installer moves onto a labelled
|
||||
version, and a check will refuse to install a version nobody can download. Both are written down and
|
||||
are ours to build. *(R-110, R-115)*
|
||||
|
||||
## Changed since last update
|
||||
|
||||
- **2026-08-03** — The reserve now guards the step that fills the disk, and its promise is true; the
|
||||
last app whose data was never saved is fixed. Both proven on a demo machine, not just in tests.
|
||||
Earlier the same day: both demo machines wiped and rebuilt from the new base image and taken through
|
||||
set-up → install an app → back it up → restore it, with the backup space ceiling gone and measured.
|
||||
|
||||
- **2026-08-02** — The false "host offline" warning is fixed. The hub's database was supposed to be in
|
||||
a mode where reading a page cannot block a machine's status update; a one-word difference meant that
|
||||
setting had **never taken effect**, for the hub's whole life. Fixed and verified live. **Also found:
|
||||
the hub's own database is in no automatic backup** — it holds every machine's emergency password.
|
||||
Filed, not yet fixed.
|
||||
|
||||
- **2026-08-02** — Boot recovery finished; six hard resets, everything back every time. Two instances
|
||||
of the same hole — starting an app whose external drive was missing — were found by reading the code
|
||||
and fixed the same day.
|
||||
|
||||
- **2026-08-02** — Thirteen mechanical checks had built up and nothing ran most of them; two were
|
||||
failing quietly. Fixed. Decided the same day: the 20 GB backup partition goes away; and only this
|
||||
machine and the tester's box are protected, every other box may be broken or reinstalled freely.
|
||||
@@ -1,31 +0,0 @@
|
||||
# App Assets
|
||||
|
||||
This directory contains logos and screenshots for the dashboard.
|
||||
They are baked into the Docker image at build time.
|
||||
|
||||
## Naming convention
|
||||
|
||||
Files must follow the felhom.eu website convention:
|
||||
|
||||
- `{slug}-logo.svg` — App logo (SVG preferred, displayed on dark background)
|
||||
- `{slug}-logo.png` — App logo fallback (PNG, for apps without SVG)
|
||||
- `{slug}-screenshot-1.webp` — First screenshot
|
||||
- `{slug}-screenshot-2.webp` — Second screenshot (and so on)
|
||||
|
||||
The dashboard tries SVG first, falls back to PNG if not found.
|
||||
|
||||
Example:
|
||||
```
|
||||
paperless-ngx-logo.svg
|
||||
paperless-ngx-screenshot-1.webp
|
||||
adventurelog-logo.png
|
||||
adventurelog-screenshot-1.webp
|
||||
```
|
||||
|
||||
## Syncing from felhom.eu website
|
||||
|
||||
Run `make sync-assets` to copy assets from the felhom.eu website repo.
|
||||
This expects the website files to be available at `../felhom.eu/website/assets/`
|
||||
(relative to this repo), or set `WEBSITE_ASSETS_DIR` to override.
|
||||
|
||||
Alternatively, copy files manually from FileBrowser at https://felhom.eu.
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Actual Budget</title><path d="m17.442 10.779.737 2.01-16.758 6.145a.253.253 0 0 1-.324-.15l-.563-1.536a.253.253 0 0 1 .15-.324zM1.13 23.309 12.036.145A.253.253 0 0 1 12.265 0h.478c.097 0 .185.055.227.142l7.036 14.455 2.206-.848c.13-.05.277.015.327.145l.587 1.526a.253.253 0 0 1-.145.327l-2.034.783 2.51 5.156a.253.253 0 0 1-.117.338l-1.47.716a.253.253 0 0 1-.339-.117l-2.59-5.322-17.37 6.682a.253.253 0 0 1-.328-.145c0-.001 0-.003-.002-.004l-.12-.33a.252.252 0 0 1 .009-.195zM12.528 4.127 4.854 20.425 18 15.369z"/></svg>
|
||||
|
Before Width: | Height: | Size: 614 B |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 242 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Audiobookshelf</title><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0Zm-.023.402A11.598 11.598 0 0 1 23.575 12a11.598 11.598 0 0 1-11.598 11.598A11.598 11.598 0 0 1 .378 12 11.598 11.598 0 0 1 11.977.402Zm0 1.776a7.093 7.093 0 0 0-7.092 7.093v1.536a6.395 6.395 0 0 0-.439.33.35.35 0 0 0-.126.27v1.84a.36.36 0 0 0 .126.272c.22.182.722.564 1.504.956v.179c0 .483.31.873.694.873.384 0 .694-.392.694-.873v-4.415c0-.483-.31-.873-.694-.873-.369 0-.67.359-.694.812h-.002v-.91a6.027 6.027 0 1 1 12.054.003v.91c-.025-.454-.326-.813-.695-.813-.384 0-.694.391-.694.873v4.415c0 .483.31.873.694.873.384 0 .695-.392.695-.873v-.179a7.964 7.964 0 0 0 1.503-.956.35.35 0 0 0 .126-.272v-1.843a.342.342 0 0 0-.124-.27 5.932 5.932 0 0 0-.438-.329V9.271a7.093 7.093 0 0 0-7.092-7.093zm-3.34 5.548a.84.84 0 0 0-.84.84v9.405c0 .464.376.84.84.84h.866a.84.84 0 0 0 .84-.84V8.566a.84.84 0 0 0-.84-.84Zm2.905 0a.84.84 0 0 0-.84.84v9.405c0 .464.377.84.84.84h.867a.84.84 0 0 0 .84-.84V8.566a.84.84 0 0 0-.84-.84zm2.908 0a.84.84 0 0 0-.84.84v9.405c0 .464.376.84.84.84h.867a.84.84 0 0 0 .84-.84V8.566a.84.84 0 0 0-.84-.84zM8.112 9.983h1.915v.2H8.112Zm2.906 0h1.915v.2h-1.915Zm2.908 0h1.915v.2h-1.915zm-7.58 9.119a.633.633 0 0 0 0 1.265h11.26a.632.632 0 0 0 0-1.265z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 423 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 132 KiB |
@@ -1,8 +0,0 @@
|
||||
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--Circular Background-->
|
||||
<g transform="translate(48, 12) scale(0.85)">
|
||||
<path d="M56.7848 80.9116L0 25.6211L24.8434 1.43151C26.8037 -0.47717 29.9814 -0.47717 31.9414 1.43151L85.177 53.2662L56.7848 80.9116Z" fill="white"/>
|
||||
<path d="M-1.30805e-05 80.7335L21.2939 101.467L42.5878 80.7335L21.2939 60L-1.30805e-05 80.7335Z" fill="white"/>
|
||||
<path d="M56.7848 83L81.6279 107.19C83.5881 109.098 83.5881 112.192 81.6279 114.101L28.3925 165.936L0 138.29L56.7848 83Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 61 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>BookStack</title><path d="M.3013 17.6146c-.1299-.3387-.5228-1.5119-.1337-2.4314l9.8273 5.6738a.329.329 0 0 0 .3299 0L24 12.9616v2.3542l-13.8401 7.9906-9.8586-5.6918zM.1911 8.9628c-.2882.8769.0149 2.0581.1236 2.4261l9.8452 5.6841L24 9.0823V6.7275L10.3248 14.623a.329.329 0 0 1-.3299 0L.1911 8.9628zm13.1698-1.9361c-.1819.1113-.4394.0015-.4852-.2064l-.2805-1.1336-2.1254-.1752a.33.33 0 0 1-.1378-.6145l5.5782-3.2207-1.7021-.9826L.6979 8.4935l9.462 5.463 13.5104-7.8004-4.401-2.5407-5.9084 3.4113zm-.1821-1.7286.2321.938 5.1984-3.0014-2.0395-1.1775-4.994 2.8834 1.3099.108a.3302.3302 0 0 1 .2931.2495zM24 9.845l-13.6752 7.8954a.329.329 0 0 1-.3299 0L.1678 12.0667c-.3891.919.003 2.0914.1332 2.4311l9.8589 5.692L24 12.1993V9.845z"/></svg>
|
||||
|
Before Width: | Height: | Size: 827 B |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 73 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Cal.com</title><path d="M2.408 14.488C1.035 14.488 0 13.4 0 12.058c0-1.346.982-2.443 2.408-2.443.758 0 1.282.233 1.691.765l-.66.55a1.343 1.343 0 0 0-1.03-.442c-.93 0-1.44.711-1.44 1.57 0 .86.559 1.557 1.44 1.557.413 0 .765-.147 1.043-.443l.651.573c-.391.51-.929.743-1.695.743zM6.948 10.913h.89v3.49h-.89v-.51c-.185.362-.493.604-1.083.604-.943 0-1.695-.82-1.695-1.826 0-1.007.752-1.825 1.695-1.825.585 0 .898.241 1.083.604zm.026 1.758c0-.546-.374-.998-.964-.998-.568 0-.938.457-.938.998 0 .528.37.998.938.998.586 0 .964-.456.964-.998zM8.467 9.503h.89v4.895h-.89zM9.752 13.937a.53.53 0 0 1 .542-.528c.313 0 .533.242.533.528a.527.527 0 0 1-.533.537.534.534 0 0 1-.542-.537zM14.23 13.839c-.33.403-.832.658-1.426.658a1.806 1.806 0 0 1-1.84-1.826c0-1.007.778-1.825 1.84-1.825.572 0 1.07.241 1.4.622l-.687.577c-.172-.215-.396-.376-.713-.376-.568 0-.938.456-.938.998 0 .541.37.997.938.997.343 0 .58-.179.757-.42zM14.305 12.671c0-1.007.78-1.825 1.84-1.825 1.061 0 1.84.818 1.84 1.825 0 1.007-.779 1.826-1.84 1.826-1.06-.005-1.84-.82-1.84-1.826zm2.778 0c0-.546-.37-.998-.938-.998-.568-.004-.937.452-.937.998 0 .542.37.998.937.998.568 0 .938-.456.938-.998zM24 12.269v2.13h-.89v-1.911c0-.604-.281-.864-.704-.864-.396 0-.678.197-.678.864v1.91h-.89v-1.91c0-.604-.285-.864-.704-.864-.396 0-.744.197-.744.864v1.91h-.89v-3.49h.89v.484c.185-.376.52-.564 1.035-.564.489 0 .898.241 1.123.649.224-.417.554-.65 1.153-.65.731.005 1.299.56 1.299 1.442z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Calibre-Web</title><path d="M13.736.083q4.9353-.6785 5.5252 4.1915-1.104 5.4862-6.4778 7.1446-1.3131.3981-2.6673.1905-.409-.133-.6668-.4763a3.91 3.91 0 0 1 0-1.7147q4.0727.4425 6.4778-3.0484.8668-1.3161.5715-2.8578-.5576-1.2044-1.9052-1.1432-2.7075.4504-4.382 2.6674-3.9135 5.7548-2.4768 12.5745 1.59 5.4391 6.954 3.5246 1.458-.7474 2.6674-1.81 1.627.6834.8573 2.2864-4.452 3.9011-9.8119 1.4289-3.1384-2.512-3.5247-6.573-.858-7.33 3.62-13.1462Q10.673.9268 13.736.083"/></svg>
|
||||
|
Before Width: | Height: | Size: 568 B |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 232 KiB |
@@ -1,2 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" enable-background="new 0 0 64 64"><path d="M32,2C15.431,2,2,15.432,2,32c0,16.568,13.432,30,30,30c16.568,0,30-13.432,30-30C62,15.432,48.568,2,32,2z M25.025,50
|
||||
l-0.02-0.02L24.988,50L11,35.6l7.029-7.164l6.977,7.184l21-21.619L53,21.199L25.025,50z" fill="#43a047"/></svg>
|
||||
|
Before Width: | Height: | Size: 328 B |
@@ -1,2 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" enable-background="new 0 0 64 64"><path d="M32,2C15.431,2,2,15.432,2,32c0,16.568,13.432,30,30,30c16.568,0,30-13.432,30-30C62,15.432,48.568,2,32,2z M25.025,50
|
||||
l-0.02-0.02L24.988,50L11,35.6l7.029-7.164l6.977,7.184l21-21.619L53,21.199L25.025,50z" fill="#fdd835"/></svg>
|
||||
|
Before Width: | Height: | Size: 328 B |
@@ -1,13 +0,0 @@
|
||||
<svg id="Calque_1" data-name="Calque 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.91 28.98">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1{fill:#ffffff;}.cls-2,.cls-3,.cls-4{fill:none;stroke-linecap:round;stroke-miterlimit:10;stroke-width:4px;}.cls-2{stroke:#ffffff;}.cls-3{stroke:#ffffff;}.cls-4{stroke:#ffffff;}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Logo">
|
||||
<path stroke="white" fill="white" d="M6.8,23.35a2.78,2.78,0,0,1-4.4-.3A15.42,15.42,0,0,1,0,15.85,16.69,16.69,0,0,1,.5,10c.4-1.4,1.4-2.2,2.7-1.9a2.25,2.25,0,0,1,1.6,3,13.1,13.1,0,0,0,.1,6.8c2.9-3.8,5.6-7.3,8.3-10.9,1.5-2,3-4,4.5-5.9a2.53,2.53,0,0,1,3.5-.4,2,2,0,0,1,.6,3.1Z"/>
|
||||
<line class="cls-2" x1="10.4" y1="25.55" x2="26.7" y2="4.65"/>
|
||||
<line class="cls-3" x1="17.2" y1="26.85" x2="29.7" y2="11.05"/>
|
||||
<line class="cls-4" x1="25.2" y1="26.45" x2="31.5" y2="18.05"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Coder</title><path d="M14.862 6.67H24v10.663h-9.138zM6.945 15.304c-1.934 0-3.366-1.264-3.366-3.305s1.432-3.323 3.366-3.365c1.411-.03 2.787.99 2.878 2.543l3.472-.106c-.076-2.802-2.33-4.706-6.35-4.706S0 8.558 0 12c0 3.426 3.046 5.635 6.945 5.635 3.898 0 6.29-1.935 6.38-4.782l-3.472-.077c-.152 1.553-1.497 2.528-2.908 2.528Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 424 B |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Emby</title><path d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,21 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="560" height="560" version="1.1" id="prefix__svg44" clip-rule="evenodd" fill-rule="evenodd" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
||||
<defs id="prefix__defs4">
|
||||
<style type="text/css" id="style2">
|
||||
.prefix__fil1{fill:#fefefe}.prefix__fil6{fill:#006498}.prefix__fil5{fill:#bdeaff}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="prefix__g85" transform="translate(-70 -70)">
|
||||
<path d="M231 211h208l38 24v246c0 5-3 8-8 8H231c-5 0-8-3-8-8V219c0-5 3-8 8-8z" id="prefix__path13" fill="#ffffff"/>
|
||||
<path d="M231 211h208l38 24v2l-37-23H231c-4 0-7 3-7 7v263c-1-1-1-2-1-3V219c0-5 3-8 8-8z" id="prefix__path15" fill="#ffffff"/>
|
||||
<path id="prefix__polygon17" fill="#1c2128" d="M305 212h113v98H305z"/>
|
||||
<path d="M255 363h189c3 0 5 2 5 4v116H250V367c0-2 2-4 5-4z" id="prefix__path19" fill="#1c2128"/>
|
||||
<path id="prefix__polygon21" fill="#1c2128" d="M250 470h199v13H250z"/>
|
||||
<path d="M380 226h10c3 0 6 2 6 5v40c0 3-3 6-6 6h-10c-3 0-6-3-6-6v-40c0-3 3-5 6-5z" id="prefix__path23" fill="#ffffff"/>
|
||||
<path d="M267 448h165c2 0 3 1 3 3 0 1-1 3-3 3H267c-2 0-3-2-3-3 0-2 1-3 3-3z" id="prefix__path27" fill="#ffffff"/>
|
||||
<path d="M267 415h165c2 0 3 1 3 3 0 1-1 2-3 2H267c-2 0-3-1-3-2 0-2 1-3 3-3z" id="prefix__path29" fill="#ffffff"/>
|
||||
<path d="M267 381h165c2 0 3 2 3 3 0 2-1 3-3 3H267c-2 0-3-1-3-3 0-1 1-3 3-3z" id="prefix__path31" fill="#ffffff"/>
|
||||
<path id="prefix__polygon37" fill="#1c2128" d="M305 212h-21v98h21z"/>
|
||||
<path d="M477 479v2c0 5-3 8-8 8H231c-5 0-8-3-8-8v-2c0 4 3 8 8 8h238c5 0 8-4 8-8z" id="prefix__path39" fill="#ffffff"/>
|
||||
<path d="M350 70c155 0 280 125 280 280S505 630 350 630 70 505 70 350 195 70 350 70zm0 46c129 0 234 105 234 234S479 584 350 584 116 479 116 350s105-234 234-234z" id="prefix__path41" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 41 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Ghost</title>
|
||||
<path fill="white" d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm.256 2.313c2.47.005 5.116 2.008 5.898 2.962l.244.3c1.64 1.994 3.569 4.34 3.569 6.966 0 3.719-2.98 5.808-6.158 7.508-1.433.766-2.98 1.508-4.748 1.508-4.543 0-8.366-3.569-8.366-8.112 0-.706.17-1.425.342-2.15.122-.515.244-1.033.307-1.549.548-4.539 2.967-6.795 8.422-7.408a4.29 4.29 0 01.49-.026Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 494 B |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 75 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gitea</title><path d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" fill="#ffffff"/></svg>
|
||||
|
Before Width: | Height: | Size: 837 B |
@@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Glance</title><path d="M2.77 0A2.763 2.763 0 0 0 0 2.77v18.46A2.763 2.763 0 0 0 2.77 24h18.46A2.763 2.763 0 0 0 24 21.23V2.77A2.763 2.763 0 0 0 21.23 0Zm.922 1.846h5.539c1.023 0 1.846.824 1.846 1.846v16.616a1.842 1.842 0 0 1-1.846 1.846H3.692a1.842 1.842 0 0 1-1.846-1.846V3.692c0-1.022.824-1.846 1.846-1.846zm11.077 0h5.539c1.022 0 1.846.824 1.846 1.846v5.539a1.842 1.842 0 0 1-1.846 1.846h-5.539a1.842 1.842 0 0 1-1.846-1.846V3.692c0-1.022.823-1.846 1.846-1.846zm1.226 1.846-.946.961h2.964c.148 0 .29-.005.423-.012a.78.78 0 0 0 .312-.089L14.77 8.528l.725.703 3.923-3.941a1.031 1.031 0 0 0-.1.322 3.265 3.265 0 0 0-.023.38v3.071l1.014-1.004V3.692Zm-1.226 9.231h5.539c1.022 0 1.846.823 1.846 1.846v5.539a1.842 1.842 0 0 1-1.846 1.846h-5.539a1.842 1.842 0 0 1-1.846-1.846v-5.539c0-1.023.823-1.846 1.846-1.846z"/></svg>
|
||||
|
Before Width: | Height: | Size: 910 B |