@underpostnet/cyberia 3.2.90 → 3.3.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (490) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +16 -0
  3. package/.github/workflows/coverall.cyberia.ci.yml +107 -0
  4. package/.github/workflows/cyberia-client.cd.yml +6 -18
  5. package/.github/workflows/cyberia-server.cd.yml +6 -18
  6. package/.github/workflows/docker-image.cyberia-client.ci.yml +8 -5
  7. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +8 -3
  8. package/.github/workflows/docker-image.cyberia-server.ci.yml +8 -5
  9. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +8 -5
  10. package/.github/workflows/docker-image.engine-cyberia.ci.yml +15 -1
  11. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +15 -1
  12. package/.github/workflows/engine-cyberia.cd.yml +6 -78
  13. package/.github/workflows/engine-cyberia.ci.yml +4 -5
  14. package/.github/workflows/ghpkg.ci.yml +42 -21
  15. package/.github/workflows/hardhat.ci.yml +12 -11
  16. package/.github/workflows/npmpkg.ci.yml +17 -15
  17. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  18. package/.github/workflows/pwa-microservices-template-test.ci.yml +2 -2
  19. package/.github/workflows/release.cd.yml +2 -9
  20. package/.prettierignore +1 -0
  21. package/AGENTS.md +47 -0
  22. package/CHANGELOG.md +581 -2417
  23. package/CLI-HELP.md +416 -127
  24. package/Dockerfile +25 -9
  25. package/Dockerfile.dev +25 -9
  26. package/Dockerfile.test +4 -4
  27. package/README.md +21 -1
  28. package/bin/build.js +70 -43
  29. package/bin/build.template.js +8 -3
  30. package/bin/cyberia.js +372 -135
  31. package/bin/deploy.js +18 -104
  32. package/bin/index.js +372 -135
  33. package/compose.env +16 -0
  34. package/conf.js +93 -4
  35. package/deploy/cyberia-client/deploy.sh +47 -0
  36. package/deploy/cyberia-client/package.sh +18 -0
  37. package/deploy/cyberia-client/state.sh +14 -0
  38. package/deploy/cyberia-server/deploy.sh +47 -0
  39. package/deploy/cyberia-server/package.sh +18 -0
  40. package/deploy/cyberia-server/state.sh +14 -0
  41. package/deploy/dd-cyberia/deploy.sh +22 -0
  42. package/deploy/dd-cyberia/init.sh +55 -0
  43. package/deploy/dd-cyberia/package.sh +17 -0
  44. package/deploy/dd-cyberia/state.sh +14 -0
  45. package/deploy/dd-cyberia/sync-deploy.sh +116 -0
  46. package/deploy/lib/github-actions-logging.sh +594 -0
  47. package/deploy/lib/host.sh +185 -0
  48. package/deploy/lib/state.sh +62 -0
  49. package/deploy/pwa-microservices-template/deploy.sh +45 -0
  50. package/deploy/release/deploy.sh +32 -0
  51. package/deployment.yaml +7 -2
  52. package/docker-compose.yml +31 -37
  53. package/examples/static-page/README.md +19 -19
  54. package/examples/static-page/ssr-components/CustomPage.js +2 -2
  55. package/hardhat/hardhat.config.js +1 -6
  56. package/hardhat/package-lock.json +104 -43
  57. package/hardhat/package.json +3 -3
  58. package/manifests/alertmanager/deployment.yaml +81 -0
  59. package/manifests/blackbox-exporter/deployment.yaml +75 -0
  60. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +36 -7
  61. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +77 -0
  62. package/manifests/deployment/dd-cyberia-development/deployment.yaml +7 -2
  63. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  64. package/manifests/grafana/deployment.yaml +26 -7
  65. package/manifests/grafana/kustomization.yaml +1 -0
  66. package/manifests/grafana/service.yaml +6 -1
  67. package/manifests/mariadb/pv.yaml +1 -0
  68. package/manifests/mongodb/statefulset.yaml +9 -2
  69. package/manifests/mysql/pv-pvc.yaml +14 -1
  70. package/manifests/postgresql/pv.yaml +12 -1
  71. package/manifests/postgresql/pvc.yaml +1 -0
  72. package/manifests/prometheus/deployment.yaml +18 -22
  73. package/package.json +83 -10
  74. package/scripts/audit-selinux.sh +64 -0
  75. package/scripts/event-monitor.sh +56 -0
  76. package/scripts/gpu-diag.sh +0 -0
  77. package/scripts/ip-info.sh +0 -0
  78. package/scripts/k3s-node-setup.sh +30 -14
  79. package/scripts/kubeadm-node-setup.sh +40 -31
  80. package/scripts/lxd-vm-setup.sh +0 -0
  81. package/scripts/maas-nat-firewalld.sh +0 -0
  82. package/scripts/maas-setup.sh +8 -8
  83. package/scripts/nat-iptables.sh +2 -0
  84. package/scripts/rhel-grpc-setup.sh +0 -0
  85. package/scripts/rocky-kickstart.sh +25 -9
  86. package/scripts/selinux-normalize.sh +907 -0
  87. package/scripts/shutdown-machine.sh +843 -0
  88. package/scripts/test-monitor.sh +3 -3
  89. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +1 -1
  90. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +2 -2
  91. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +2 -2
  92. package/src/api/core/core.controller.js +1 -1
  93. package/src/api/core/core.router.js +2 -2
  94. package/src/api/core/core.service.js +2 -2
  95. package/src/api/crypto/crypto.controller.js +1 -1
  96. package/src/api/crypto/crypto.service.js +1 -1
  97. package/src/api/cyberia-action/cyberia-action.controller.js +1 -1
  98. package/src/api/cyberia-action/cyberia-action.router.js +1 -1
  99. package/src/api/cyberia-action/cyberia-action.service.js +2 -2
  100. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -1
  101. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +1 -1
  102. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +2 -2
  103. package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +1 -1
  104. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  105. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +2 -2
  106. package/src/api/cyberia-entity/cyberia-entity.controller.js +1 -1
  107. package/src/api/cyberia-entity/cyberia-entity.router.js +1 -1
  108. package/src/api/cyberia-entity/cyberia-entity.service.js +2 -2
  109. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +1 -1
  110. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +1 -1
  111. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +2 -2
  112. package/src/api/cyberia-instance/cyberia-fallback-capture.js +466 -0
  113. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +63 -0
  114. package/src/api/cyberia-instance/cyberia-fallback-world.js +16 -1
  115. package/src/api/cyberia-instance/cyberia-instance-map.service.js +1 -1
  116. package/src/api/cyberia-instance/cyberia-instance.controller.js +4 -1
  117. package/src/api/cyberia-instance/cyberia-instance.router.js +15 -2
  118. package/src/api/cyberia-instance/cyberia-instance.service.js +45 -2
  119. package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +1 -1
  120. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +1 -0
  121. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +1 -1
  122. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +2 -2
  123. package/src/api/cyberia-map/cyberia-map.controller.js +1 -1
  124. package/src/api/cyberia-map/cyberia-map.router.js +1 -1
  125. package/src/api/cyberia-map/cyberia-map.service.js +2 -2
  126. package/src/api/cyberia-quest/cyberia-quest.controller.js +1 -1
  127. package/src/api/cyberia-quest/cyberia-quest.router.js +1 -1
  128. package/src/api/cyberia-quest/cyberia-quest.service.js +2 -2
  129. package/src/api/cyberia-quest-progress/cyberia-quest-progress.controller.js +1 -1
  130. package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +1 -1
  131. package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +2 -2
  132. package/src/api/cyberia-saga/cyberia-saga.controller.js +1 -1
  133. package/src/api/cyberia-saga/cyberia-saga.router.js +1 -1
  134. package/src/api/cyberia-saga/cyberia-saga.service.js +2 -2
  135. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +16 -29
  136. package/src/api/cyberia-skill/cyberia-skill.controller.js +1 -1
  137. package/src/api/cyberia-skill/cyberia-skill.router.js +1 -1
  138. package/src/api/cyberia-skill/cyberia-skill.service.js +2 -2
  139. package/src/api/default/default.controller.js +1 -1
  140. package/src/api/default/default.router.js +1 -1
  141. package/src/api/default/default.service.js +2 -2
  142. package/src/api/document/document.controller.js +1 -1
  143. package/src/api/document/document.router.js +1 -1
  144. package/src/api/document/document.service.js +2 -2
  145. package/src/api/file/file.controller.js +1 -1
  146. package/src/api/file/file.router.js +1 -1
  147. package/src/api/file/file.service.js +2 -2
  148. package/src/api/instance/instance.controller.js +1 -1
  149. package/src/api/instance/instance.router.js +1 -1
  150. package/src/api/instance/instance.service.js +2 -2
  151. package/src/api/ipfs/ipfs.controller.js +1 -1
  152. package/src/api/ipfs/ipfs.router.js +2 -2
  153. package/src/api/ipfs/ipfs.service.js +2 -2
  154. package/src/api/object-layer/object-layer.controller.js +1 -1
  155. package/src/api/object-layer/object-layer.model.js +204 -3
  156. package/src/api/object-layer/object-layer.router.js +2 -2
  157. package/src/api/object-layer/object-layer.service.js +4 -17
  158. package/src/api/object-layer-render-frames/object-layer-render-frames.controller.js +1 -1
  159. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +1 -1
  160. package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +2 -2
  161. package/src/api/test/test.controller.js +1 -1
  162. package/src/api/test/test.service.js +1 -1
  163. package/src/api/user/guest.service.js +3 -3
  164. package/src/api/user/user.controller.js +1 -1
  165. package/src/api/user/user.router.js +2 -2
  166. package/src/api/user/user.service.js +4 -4
  167. package/src/api.js +5 -9
  168. package/src/cli/app.js +391 -0
  169. package/src/cli/baremetal.js +30 -36
  170. package/src/cli/client.js +306 -0
  171. package/src/cli/cloud-init.js +3 -3
  172. package/src/cli/cluster.js +242 -135
  173. package/src/cli/db.js +126 -67
  174. package/src/cli/deploy.js +158 -60
  175. package/src/cli/docker-compose.js +5 -5
  176. package/src/cli/domains.js +184 -0
  177. package/src/cli/dotenv-store.js +143 -0
  178. package/src/cli/event.js +2281 -0
  179. package/src/cli/fs.js +3 -3
  180. package/src/cli/host.js +672 -0
  181. package/src/cli/image.js +4 -7
  182. package/src/cli/index.js +426 -134
  183. package/src/cli/ipfs.js +34 -30
  184. package/src/cli/kickstart.js +1 -1
  185. package/src/cli/kubectl.js +218 -41
  186. package/src/cli/lxd.js +3 -3
  187. package/src/cli/monitor.js +1359 -26
  188. package/src/cli/package.js +90 -0
  189. package/src/cli/release.js +73 -34
  190. package/src/cli/repository.js +564 -367
  191. package/src/cli/run.js +362 -654
  192. package/src/cli/secrets.js +2158 -964
  193. package/src/cli/ssh.js +651 -331
  194. package/src/cli/state.js +394 -0
  195. package/src/cli/static.js +2 -2
  196. package/src/cli/system.js +26 -13
  197. package/src/cli/test.js +199 -125
  198. package/src/cli/vultr.js +623 -0
  199. package/src/cli/wireguard.js +2930 -0
  200. package/src/client/components/core/CalendarCore.js +53 -96
  201. package/src/client/components/core/Docs.js +258 -211
  202. package/src/client/components/core/FileExplorer.js +0 -21
  203. package/src/client/components/core/PanelForm.js +4 -4
  204. package/src/client/components/core/Repository.js +56 -0
  205. package/src/client/components/core/Translate.js +7 -0
  206. package/src/client/components/core/{FullScreen.js → ViewModeController.js} +42 -39
  207. package/src/client/components/core/Wallet.js +0 -11
  208. package/src/client/components/core/Worker.js +7 -1
  209. package/src/client/components/cryptokoyn/SettingsCryptokoyn.js +2 -2
  210. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +368 -0
  211. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +35 -1
  212. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +4 -0
  213. package/src/client/components/cyberia-portal/SettingsCyberiaPortal.js +2 -2
  214. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +4 -0
  215. package/src/client/components/default/AppShellDefault.js +1 -1
  216. package/src/client/components/default/SettingsDefault.js +2 -2
  217. package/src/client/components/itemledger/SettingsItemledger.js +2 -2
  218. package/src/client/components/underpost/AppShellUnderpost.js +3 -2
  219. package/src/client/components/underpost/SettingsUnderpost.js +2 -2
  220. package/src/client/public/cyberia-docs/ARCHITECTURE.md +17 -3
  221. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +32 -5
  222. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +1 -1
  223. package/src/client/public/cyberia-docs/CYBERIA.md +21 -1
  224. package/src/client/public/cyberia-docs/HARDHAT-MODULE.md +23 -6
  225. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  226. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  227. package/src/client/public/default/android-chrome-144x144.png +0 -0
  228. package/src/client/public/default/android-chrome-192x192.png +0 -0
  229. package/src/client/public/default/android-chrome-256x256.png +0 -0
  230. package/src/client/public/default/android-chrome-36x36.png +0 -0
  231. package/src/client/public/default/android-chrome-384x384.png +0 -0
  232. package/src/client/public/default/android-chrome-48x48.png +0 -0
  233. package/src/client/public/default/android-chrome-512x512.png +0 -0
  234. package/src/client/public/default/android-chrome-72x72.png +0 -0
  235. package/src/client/public/default/android-chrome-96x96.png +0 -0
  236. package/src/client/public/default/apple-touch-icon-1024x1024.png +0 -0
  237. package/src/client/public/default/apple-touch-icon-114x114.png +0 -0
  238. package/src/client/public/default/apple-touch-icon-120x120.png +0 -0
  239. package/src/client/public/default/apple-touch-icon-144x144.png +0 -0
  240. package/src/client/public/default/apple-touch-icon-152x152.png +0 -0
  241. package/src/client/public/default/apple-touch-icon-167x167.png +0 -0
  242. package/src/client/public/default/apple-touch-icon-180x180.png +0 -0
  243. package/src/client/public/default/apple-touch-icon-57x57.png +0 -0
  244. package/src/client/public/default/apple-touch-icon-60x60.png +0 -0
  245. package/src/client/public/default/apple-touch-icon-72x72.png +0 -0
  246. package/src/client/public/default/apple-touch-icon-76x76.png +0 -0
  247. package/src/client/public/default/apple-touch-icon-precomposed.png +0 -0
  248. package/src/client/public/default/apple-touch-icon.png +0 -0
  249. package/src/client/public/default/apple-touch-startup-image-1125x2436.png +0 -0
  250. package/src/client/public/default/apple-touch-startup-image-1136x640.png +0 -0
  251. package/src/client/public/default/apple-touch-startup-image-1170x2532.png +0 -0
  252. package/src/client/public/default/apple-touch-startup-image-1179x2556.png +0 -0
  253. package/src/client/public/default/apple-touch-startup-image-1242x2208.png +0 -0
  254. package/src/client/public/default/apple-touch-startup-image-1242x2688.png +0 -0
  255. package/src/client/public/default/apple-touch-startup-image-1284x2778.png +0 -0
  256. package/src/client/public/default/apple-touch-startup-image-1290x2796.png +0 -0
  257. package/src/client/public/default/apple-touch-startup-image-1334x750.png +0 -0
  258. package/src/client/public/default/apple-touch-startup-image-1488x2266.png +0 -0
  259. package/src/client/public/default/apple-touch-startup-image-1536x2048.png +0 -0
  260. package/src/client/public/default/apple-touch-startup-image-1620x2160.png +0 -0
  261. package/src/client/public/default/apple-touch-startup-image-1640x2160.png +0 -0
  262. package/src/client/public/default/apple-touch-startup-image-1668x2224.png +0 -0
  263. package/src/client/public/default/apple-touch-startup-image-1668x2388.png +0 -0
  264. package/src/client/public/default/apple-touch-startup-image-1792x828.png +0 -0
  265. package/src/client/public/default/apple-touch-startup-image-2048x1536.png +0 -0
  266. package/src/client/public/default/apple-touch-startup-image-2048x2732.png +0 -0
  267. package/src/client/public/default/apple-touch-startup-image-2160x1620.png +0 -0
  268. package/src/client/public/default/apple-touch-startup-image-2160x1640.png +0 -0
  269. package/src/client/public/default/apple-touch-startup-image-2208x1242.png +0 -0
  270. package/src/client/public/default/apple-touch-startup-image-2224x1668.png +0 -0
  271. package/src/client/public/default/apple-touch-startup-image-2266x1488.png +0 -0
  272. package/src/client/public/default/apple-touch-startup-image-2388x1668.png +0 -0
  273. package/src/client/public/default/apple-touch-startup-image-2436x1125.png +0 -0
  274. package/src/client/public/default/apple-touch-startup-image-2532x1170.png +0 -0
  275. package/src/client/public/default/apple-touch-startup-image-2556x1179.png +0 -0
  276. package/src/client/public/default/apple-touch-startup-image-2688x1242.png +0 -0
  277. package/src/client/public/default/apple-touch-startup-image-2732x2048.png +0 -0
  278. package/src/client/public/default/apple-touch-startup-image-2778x1284.png +0 -0
  279. package/src/client/public/default/apple-touch-startup-image-2796x1290.png +0 -0
  280. package/src/client/public/default/apple-touch-startup-image-640x1136.png +0 -0
  281. package/src/client/public/default/apple-touch-startup-image-750x1334.png +0 -0
  282. package/src/client/public/default/apple-touch-startup-image-828x1792.png +0 -0
  283. package/src/client/public/default/assets/background/white.jpg +0 -0
  284. package/src/client/public/default/browserconfig.xml +1 -1
  285. package/src/client/public/default/favicon-16x16.png +0 -0
  286. package/src/client/public/default/favicon-32x32.png +0 -0
  287. package/src/client/public/default/favicon-48x48.png +0 -0
  288. package/src/client/public/default/favicon.ico +0 -0
  289. package/src/client/public/default/manifest.webmanifest +5 -5
  290. package/src/client/public/default/mstile-144x144.png +0 -0
  291. package/src/client/public/default/mstile-150x150.png +0 -0
  292. package/src/client/public/default/mstile-310x150.png +0 -0
  293. package/src/client/public/default/mstile-310x310.png +0 -0
  294. package/src/client/public/default/mstile-70x70.png +0 -0
  295. package/src/client/public/default/yandex-browser-50x50.png +0 -0
  296. package/src/client/public/default/yandex-browser-manifest.json +1 -1
  297. package/src/client/services/cyberia-instance/cyberia-instance.service.js +40 -0
  298. package/src/client/services/object-layer/object-layer.management.js +4 -4
  299. package/src/client/ssr/RootDocument.js +1 -1
  300. package/src/client/ssr/body/404.js +1 -1
  301. package/src/client/ssr/body/500.js +1 -1
  302. package/src/client/ssr/body/CacheControl.js +1 -1
  303. package/src/client/ssr/body/CyberiaDefaultSplashScreen.js +1 -1
  304. package/src/client/ssr/body/DefaultSplashScreen.js +1 -1
  305. package/src/client/ssr/body/SwaggerDarkMode.js +1 -1
  306. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +1 -1
  307. package/src/client/ssr/head/CryptokoynScripts.js +1 -1
  308. package/src/client/ssr/head/Css.js +1 -1
  309. package/src/client/ssr/head/CyberiaPortalScripts.js +1 -1
  310. package/src/client/ssr/head/DefaultScripts.js +1 -1
  311. package/src/client/ssr/head/ItemledgerScripts.js +1 -1
  312. package/src/client/ssr/head/Microdata.js +1 -1
  313. package/src/client/ssr/head/Production.js +1 -1
  314. package/src/client/ssr/head/Pwa.js +1 -1
  315. package/src/client/ssr/head/PwaItemledger.js +1 -1
  316. package/src/client/ssr/head/Seo.js +1 -1
  317. package/src/client/ssr/head/UnderpostScripts.js +1 -1
  318. package/src/client/ssr/mailer/DefaultRecoverEmail.js +1 -1
  319. package/src/client/ssr/mailer/DefaultVerifyEmail.js +1 -1
  320. package/src/client/ssr/views/Cyberia404.js +7 -5
  321. package/src/client/ssr/views/CyberiaServerMetrics.js +1 -1
  322. package/src/client/ssr/views/Maintenance.js +1 -1
  323. package/src/client/ssr/views/NoNetworkConnection.js +1 -1
  324. package/src/client/ssr/views/Test.js +1 -1
  325. package/src/client/sw/core.sw.js +20 -14
  326. package/src/client-builder/client-build-docs.js +61 -46
  327. package/src/client-builder/client-build-live.js +2 -2
  328. package/src/client-builder/client-build.js +58 -44
  329. package/src/client-builder/client-dev-server.js +3 -3
  330. package/src/client-builder/client-icons.js +2 -2
  331. package/src/client-builder/ssr.js +3 -3
  332. package/src/client.build.js +3 -6
  333. package/src/client.dev.js +3 -6
  334. package/src/db/DataBaseProvider.js +186 -3
  335. package/src/db/mariadb/MariaDB.js +3 -2
  336. package/src/db/mongo/MongoBootstrap.js +273 -101
  337. package/src/db/mongo/MongooseDB.js +11 -8
  338. package/src/{server/valkey.js → db/valkey/Valkey.js} +5 -4
  339. package/src/grpc/cyberia/grpc-server.js +1 -1
  340. package/src/index.js +103 -23
  341. package/src/mailer/EmailRender.js +2 -2
  342. package/src/mailer/MailerInterceptor.js +87 -0
  343. package/src/mailer/MailerProvider.js +50 -6
  344. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +1 -1
  345. package/src/projects/cyberia/besu-genesis-generator.js +3 -3
  346. package/src/projects/cyberia/catalog-cyberia.js +54 -6
  347. package/src/projects/cyberia/gemini-client.js +1 -1
  348. package/src/projects/cyberia/generate-saga.js +15 -24
  349. package/src/projects/cyberia/hot-reload-trigger.js +1 -1
  350. package/src/projects/cyberia/instance-data.js +22 -3
  351. package/src/projects/cyberia/ipfs-client.js +4 -4
  352. package/src/projects/cyberia/map-preview-generator.js +1 -1
  353. package/src/projects/cyberia/object-layer.js +12 -22
  354. package/src/projects/cyberia/semantic-layer-generator.js +1 -1
  355. package/src/projects/underpost/catalog-underpost.js +13 -2
  356. package/src/proxy.js +4 -7
  357. package/src/runtime/cyberia-client/Dockerfile +9 -4
  358. package/src/runtime/cyberia-client/Dockerfile.dev +8 -2
  359. package/src/runtime/cyberia-server/Dockerfile +9 -4
  360. package/src/runtime/cyberia-server/Dockerfile.dev +8 -2
  361. package/src/runtime/engine-cyberia/Dockerfile +25 -9
  362. package/src/runtime/engine-cyberia/Dockerfile.dev +25 -9
  363. package/src/runtime/engine-cyberia/Dockerfile.test +4 -4
  364. package/src/runtime/engine-cyberia/compose.env +16 -0
  365. package/src/runtime/engine-cyberia/docker-compose.yml +31 -37
  366. package/src/runtime/express/Express.js +32 -27
  367. package/src/runtime/lampp/Lampp.js +22 -70
  368. package/src/runtime/nginx/Nginx.js +1 -1
  369. package/src/runtime/wp/Wp.js +16 -48
  370. package/src/server/{catalog.js → build/catalog.js} +15 -5
  371. package/src/server/build/coverage.js +117 -0
  372. package/src/server/build/execution.js +402 -0
  373. package/src/server/build/package.js +465 -0
  374. package/src/server/build/testing.js +528 -0
  375. package/src/server/{dns.js → network/dns.js} +241 -14
  376. package/src/server/network/forward-proxy.js +541 -0
  377. package/src/server/{middlewares.js → network/middlewares.js} +59 -4
  378. package/src/server/network/node-capability.js +98 -0
  379. package/src/server/{peer.js → network/peer.js} +3 -3
  380. package/src/server/{proxy.js → network/proxy.js} +7 -13
  381. package/src/server/network/router.js +356 -0
  382. package/src/server/{tls.js → network/tls.js} +2 -2
  383. package/src/server/network/underpost-compression.js +186 -0
  384. package/src/server/{underpost-gateway.js → network/underpost-gateway.js} +26 -16
  385. package/src/server/{underpost-ingress.js → network/underpost-ingress.js} +19 -3
  386. package/src/server/{cri.js → ops/cri.js} +2 -2
  387. package/src/server/ops/cron.js +949 -0
  388. package/src/server/ops/event-notification.js +284 -0
  389. package/src/server/{logger.js → ops/logger.js} +108 -23
  390. package/src/server/ops/monitoring.js +1724 -0
  391. package/src/server/ops/systemd.js +259 -0
  392. package/src/server/{conf.js → runtime/conf.js} +521 -520
  393. package/src/server/runtime/config-scope.js +215 -0
  394. package/src/server/runtime/environment.js +149 -0
  395. package/src/server/{process.js → runtime/process.js} +153 -13
  396. package/src/server/{runtime-status.js → runtime/runtime-status.js} +155 -16
  397. package/src/server/{runtime.js → runtime/runtime.js} +12 -8
  398. package/src/server/{start.js → runtime/start.js} +128 -39
  399. package/src/server/{auth.js → security/auth.js} +7 -8
  400. package/src/server/security/container-storage.js +382 -0
  401. package/src/server/{crypto.js → security/crypto.js} +1 -1
  402. package/src/server/security/selinux.js +185 -0
  403. package/src/server/{backup.js → storage/backup.js} +17 -8
  404. package/src/server/{data-query.js → storage/data-query.js} +1 -1
  405. package/src/server/{downloader.js → storage/downloader.js} +2 -2
  406. package/src/server/storage/repository.js +68 -0
  407. package/src/server.js +4 -7
  408. package/src/ws/IoInterface.js +1 -1
  409. package/src/ws/IoServer.js +1 -1
  410. package/src/ws/core/core.ws.connection.js +1 -1
  411. package/src/ws/core/core.ws.emit.js +1 -1
  412. package/src/ws/core/core.ws.server.js +1 -1
  413. package/src/ws/default/default.ws.connection.js +1 -1
  414. package/src/ws/default/default.ws.emit.js +1 -1
  415. package/src/ws/default/default.ws.server.js +1 -1
  416. package/test/e2e/event-e2e-public-ingress-down.js +45 -0
  417. package/test/e2e/event-e2e-wireguard-server-down.js +38 -0
  418. package/test/e2e/event-e2e-wireguard-spoke-down.js +40 -0
  419. package/test/{api.test.js → integration/app/api.test.js} +33 -14
  420. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +30 -0
  421. package/test/{cyberia-instance-conf-defaults.test.js → integration/app/cyberia/cyberia-instance-conf-defaults.test.js} +2 -1
  422. package/test/integration/app/cyberia/cyberia-load.test.js +467 -0
  423. package/test/integration/app/cyberia/fallback-world-capture.test.js +253 -0
  424. package/test/integration/app/cyberia/object-layer-item-id.test.js +327 -0
  425. package/test/{shape-generator.test.js → integration/app/cyberia/shape-generator.test.js} +1 -1
  426. package/test/integration/infra/1-security/config-scope.test.js +305 -0
  427. package/test/integration/infra/1-security/container-storage.test.js +356 -0
  428. package/test/integration/infra/1-security/in-pod-cli-surface.test.js +86 -0
  429. package/test/integration/infra/1-security/secret-onboarding.test.js +930 -0
  430. package/test/integration/infra/1-security/selinux.test.js +69 -0
  431. package/test/{sops-secret-store.test.js → integration/infra/1-security/sops-secret-store.test.js} +415 -61
  432. package/test/integration/infra/1-security/systemd-service.test.js +70 -0
  433. package/test/integration/infra/1-security/underpost-config-secret.test.js +367 -0
  434. package/test/integration/infra/2-network/dns-firewall.test.js +566 -0
  435. package/test/integration/infra/2-network/node-capability.test.js +182 -0
  436. package/test/integration/infra/2-network/wireguard-cli.test.js +1163 -0
  437. package/test/integration/infra/2-network/wireguard-edge.test.js +1731 -0
  438. package/test/{cluster-instances.test.js → integration/infra/3-cluster/cluster-instances.test.js} +11 -15
  439. package/test/{deploy-node-placement.test.js → integration/infra/3-cluster/deploy-node-placement.test.js} +3 -3
  440. package/test/integration/infra/3-cluster/docker-compose-stack.test.js +313 -0
  441. package/test/integration/infra/4-ingress/deploy-routes.test.js +379 -0
  442. package/test/integration/infra/4-ingress/gateway-static-assets.test.js +453 -0
  443. package/test/{instance-traffic-plan.test.js → integration/infra/4-ingress/instance-traffic-plan.test.js} +10 -6
  444. package/test/{underpost-gateway.test.js → integration/infra/4-ingress/underpost-gateway.test.js} +47 -6
  445. package/test/{underpost-ingress.test.js → integration/infra/4-ingress/underpost-ingress.test.js} +56 -4
  446. package/test/integration/infra/5-observability/cron-jobs.test.js +652 -0
  447. package/test/{deploy-monitor.test.js → integration/infra/5-observability/deploy-monitor.test.js} +29 -31
  448. package/test/integration/infra/5-observability/event-notification.test.js +197 -0
  449. package/test/integration/infra/5-observability/event-remediation.test.js +502 -0
  450. package/test/integration/infra/5-observability/event-targets.test.js +1174 -0
  451. package/test/integration/infra/5-observability/monitoring-stack.test.js +902 -0
  452. package/test/support/shell-harness.js +73 -0
  453. package/test/unit/build-template.test.js +97 -0
  454. package/test/unit/build-workflow-offline.test.js +132 -0
  455. package/test/unit/catalog.test.js +71 -0
  456. package/test/unit/client-build-docs.test.js +334 -0
  457. package/test/unit/conf-loading.test.js +303 -0
  458. package/test/unit/conf-resolution.test.js +979 -0
  459. package/test/unit/coverage-artifact.test.js +130 -0
  460. package/test/{crypto.test.js → unit/crypto.test.js} +1 -1
  461. package/test/unit/deploy-env-resolution.test.js +38 -0
  462. package/test/unit/deploy-log-table.test.js +447 -0
  463. package/test/unit/execution-profiles.test.js +219 -0
  464. package/test/unit/fleet-sync-source.test.js +149 -0
  465. package/test/unit/logger-redaction.test.js +112 -0
  466. package/test/unit/package.test.js +403 -0
  467. package/test/unit/prepare-host.test.js +299 -0
  468. package/test/unit/process-environment.test.js +18 -0
  469. package/test/unit/propagation-message.test.js +78 -0
  470. package/test/unit/redeploy-plan.test.js +24 -0
  471. package/test/unit/release-bump.test.js +474 -0
  472. package/test/unit/ssh-output-redaction.test.js +28 -0
  473. package/test/unit/start-options.test.js +46 -0
  474. package/test/unit/test-tiers.test.js +193 -0
  475. package/vitest.config.js +58 -0
  476. package/.nycrc +0 -9
  477. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +0 -48
  478. package/manifests/mongodb/pv-pvc.yaml +0 -59
  479. package/scripts/link-local-underpost-cli.sh +0 -6
  480. package/src/api/user/user.build.js +0 -16
  481. package/src/cli/env.js +0 -177
  482. package/src/client/components/core/Blockchain.js +0 -41
  483. package/src/client/components/core/Blog.js +0 -9
  484. package/src/client/components/core/KeyboardAvoidance.js +0 -145
  485. package/src/client/public/default/assets/background/dark.jpg +0 -0
  486. package/src/client/public/default/assets/background/white0-min.jpg +0 -0
  487. package/src/client/public/default/assets/background/white0.jpg +0 -0
  488. package/src/client/public/doc/favicon.ico +0 -0
  489. package/src/client/public/doc/sitemap +0 -148
  490. package/src/server/cron.js +0 -476
@@ -0,0 +1,2281 @@
1
+ /**
2
+ * Operational event dispatcher: the single place where a detected fault and its
3
+ * remediation are declared together.
4
+ *
5
+ * One registry entry holds an event's probes, its alerting rule and the handler
6
+ * that repairs it. Prometheus and Alertmanager are provisioned from that entry
7
+ * rather than configured beside it, so a probe that nothing can act on and a
8
+ * handler nothing can trigger are both unrepresentable — and adding an event
9
+ * means adding one object, not editing four files.
10
+ *
11
+ * Alertmanager reaches the control-plane handler over the webhook `--serve` exposes. It is a
12
+ * remediation path with root-equivalent reach, so every delivery
13
+ * authenticates with the bearer token the Alertmanager Secret carries.
14
+ *
15
+ * @module src/cli/event.js
16
+ * @namespace UnderpostEvent
17
+ */
18
+
19
+ import http from 'node:http';
20
+ import fs from 'fs-extra';
21
+ import os from 'node:os';
22
+ import nodePath from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+ import { loadCronDeployEnv, parseList } from '../server/ops/cron.js';
25
+ import { timer } from '../client/components/core/CommonJs.js';
26
+ import { installRootFile, shellExec, sleepSync } from '../server/runtime/process.js';
27
+ import {
28
+ homeDirectoryPathFactory,
29
+ journalctlCommandFactory,
30
+ nodeCandidatesFactory,
31
+ nodeProbeCommandFactory,
32
+ runSystemdCommands,
33
+ scriptProbeCommandFactory,
34
+ systemdServiceCommandsFactory,
35
+ systemdStatusCommandsFactory,
36
+ systemdUnitFactory,
37
+ } from '../server/ops/systemd.js';
38
+ import { loggerFactory } from '../server/ops/logger.js';
39
+ import { shellArgumentFactory } from '../server/security/selinux.js';
40
+ import { mailerInterceptorFactory } from '../mailer/MailerInterceptor.js';
41
+ import {
42
+ EVENT_CONF_PATH,
43
+ assertNotificationRoutes,
44
+ deliverEventNotification,
45
+ eventNotificationRoutes,
46
+ assertEventSchedules,
47
+ eventSchedule,
48
+ THRESHOLD_TOKEN,
49
+ readEventConf,
50
+ } from '../server/ops/event-notification.js';
51
+ import { publicIngressProbeFactory, publicIngressUrlsFactory } from '../server/runtime/conf.js';
52
+ import { resolveDeployList } from '../server/network/router.js';
53
+ import { UNDERPOST_MONITORING } from '../server/ops/monitoring.js';
54
+ import {
55
+ EDGE_TOPOLOGY_PATH,
56
+ hostAddressesFactory,
57
+ hubTunnelAddressFactory,
58
+ localPeerFactory,
59
+ readEdgeContext,
60
+ readNodeConfigs,
61
+ readTopology,
62
+ tunnelAddressFactory,
63
+ } from './wireguard.js';
64
+ import { assertRoleCapability } from '../server/network/node-capability.js';
65
+ import Underpost from '../index.js';
66
+
67
+ const logger = loggerFactory(import.meta);
68
+
69
+ /** Interfaces the node events measure, shared with the dashboard that plots them. */
70
+ const NETWORK_DEVICE_SELECTOR = UNDERPOST_MONITORING.nodeExporter.networkDeviceSelector;
71
+
72
+ /**
73
+ * @constant ENGINE_REMOTE_PATH
74
+ * @description Working directory every remediation runs from. The engine
75
+ * checkout is the only place the `underpost` CLI and the deploy configuration
76
+ * exist together on a managed host.
77
+ * @memberof UnderpostEvent
78
+ */
79
+ const ENGINE_REMOTE_PATH = '/home/dd/engine';
80
+
81
+ /**
82
+ * @constant EVENT_SERVICE
83
+ * @description Identity of the supervised dispatcher.
84
+ *
85
+ * The receiver has to answer whenever Alertmanager fires, which is precisely
86
+ * when nobody is watching a terminal. A unit, not a session: it survives the
87
+ * SSH connection that installed it, and the reboot after it.
88
+ * @memberof UnderpostEvent
89
+ */
90
+ const EVENT_SERVICE = {
91
+ name: 'underpost-event.service',
92
+ unitPath: '/etc/systemd/system/underpost-event.service',
93
+ restartSeconds: 5,
94
+ settleTimeoutMs: 15000,
95
+ settleIntervalMs: 500,
96
+ };
97
+
98
+ /**
99
+ * @constant EVENT_E2E
100
+ * @description Bounds of the end-to-end rehearsal.
101
+ *
102
+ * The probe is read from the Blackbox Exporter directly rather than through
103
+ * Prometheus' `for:` window, so these bound how long a real host takes to drop
104
+ * and come back — not how long an alert takes to fire.
105
+ * @memberof UnderpostEvent
106
+ */
107
+ const EVENT_E2E = {
108
+ scenarioDirectory: 'test/e2e',
109
+ baselineTimeoutMs: 60000,
110
+ detectTimeoutMs: 180000,
111
+ recoverTimeoutMs: 180000,
112
+ notifyTimeoutMs: 60000,
113
+ probeIntervalMs: 5000,
114
+ };
115
+
116
+ /** Versioned on-disk contract used to restore alerting after planned maintenance. */
117
+ const EVENT_SUSPENSION_VERSION = 1;
118
+
119
+ /**
120
+ * Renders what a probe wait actually observed.
121
+ *
122
+ * "Probes answered again" is the only reading of a bare elapsed time, and it is
123
+ * wrong for every failing wait. An unreadable exporter is named as such rather
124
+ * than reported as a subject that stayed down, because the two need opposite
125
+ * responses from whoever reads the line.
126
+ */
127
+ const probeDetail = ({ ok, readable, elapsedMs, observations = [] }, expectation) => {
128
+ if (ok) return `probes ${expectation} after ${elapsedMs}ms`;
129
+ if (!readable) {
130
+ const reasons = [
131
+ ...new Set(observations.filter((entry) => !entry.read).map((entry) => entry.error || 'no answer')),
132
+ ];
133
+ return (
134
+ `the Blackbox Exporter could not be read after ${elapsedMs}ms (${reasons.join(', ')}); ` +
135
+ 'the observability stack has to be deployed and reachable by kubectl from here'
136
+ );
137
+ }
138
+ return `probes never ${expectation} within ${elapsedMs}ms (${observations
139
+ .map((entry) => `${entry.target} probe_success=${entry.success ? 1 : 0}`)
140
+ .join(', ')})`;
141
+ };
142
+
143
+ /**
144
+ * @constant PUBLIC_INGRESS_RECOVERY
145
+ * @description How long the edge is given to carry traffic again after a repair.
146
+ *
147
+ * Restarting the tunnel does not restore service the instant the command
148
+ * returns: HAProxy rebinds and every spoke re-handshakes first. Reading the
149
+ * verdict immediately reports a failure that has already fixed itself.
150
+ * @memberof UnderpostEvent
151
+ */
152
+ const PUBLIC_INGRESS_RECOVERY = { timeoutMs: 120000, intervalMs: 10000 };
153
+
154
+ /** Strips the colour codes a remote command's logger wrote for its own terminal. */
155
+ const plainText = (value = '') =>
156
+ `${value ?? ''}`
157
+ // eslint-disable-next-line no-control-regex
158
+ .replace(/\u001b\[[0-9;]*m/g, '')
159
+ .trim();
160
+
161
+ /** Gives each event subject an independent webhook cooldown. */
162
+ const eventCooldownKeyFactory = (eventId, alerts = []) => {
163
+ const subjects = [
164
+ ...new Set(
165
+ alerts
166
+ .map((alert) => alert?.labels?.underpost_spoke || alert?.labels?.instance || '')
167
+ .filter(Boolean)
168
+ .sort(),
169
+ ),
170
+ ];
171
+ return `${eventId}:${subjects.join(',') || 'global'}`;
172
+ };
173
+
174
+ /** Opens or closes the authenticated dispatcher port when firewalld is active. */
175
+ const eventFirewallCommandsFactory = ({ port = UNDERPOST_MONITORING.eventWebhook.port, remove = false } = {}) => {
176
+ const resolvedPort = Number(port) || UNDERPOST_MONITORING.eventWebhook.port;
177
+ const action = remove ? '--remove-port' : '--add-port';
178
+ const guard = 'command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld';
179
+ return [
180
+ `sudo sh -c '${guard} && firewall-cmd --permanent ${action}=${resolvedPort}/tcp >/dev/null || true'`,
181
+ `sudo sh -c '${guard} && firewall-cmd --reload >/dev/null || true'`,
182
+ ];
183
+ };
184
+
185
+ const eventServicePortFactory = (unit = '') => {
186
+ const match = `${unit || ''}`.match(/^ExecStart=.*\bevent\s+--serve\b[^\n]*\s--port\s+(\d+)(?:\s|$)/m);
187
+ const port = Number(match?.[1]);
188
+ return port > 0 && port <= 65535 ? port : 0;
189
+ };
190
+
191
+ const assertHubManagementConnection = ({ hubHost, sshForwardPort = 0, connection } = {}) => {
192
+ if (!connection)
193
+ throw new Error(
194
+ `[event] no SSH connection is registered for the WireGuard hub at ${hubHost}; ` +
195
+ `run: node bin ssh --user <user> --host ${hubHost} --user-add`,
196
+ );
197
+ if (sshForwardPort && Number(connection.port) === Number(sshForwardPort))
198
+ throw new Error(
199
+ `[event] hub SSH at ${hubHost}:${connection.port} collides with the spoke-forward port; ` +
200
+ 'register the VPS sshd port instead',
201
+ );
202
+ return connection;
203
+ };
204
+
205
+ /**
206
+ * @method wireguardAlertFactory
207
+ * @description The alerting rule shape both WireGuard events share.
208
+ *
209
+ * `probe_success == 0` on the event's own label selector, and nothing else.
210
+ * `absent()` would also fire during Prometheus' first scrape after a config
211
+ * reload — not a tunnel fault, and not something to run remediation for.
212
+ * @param {object} params
213
+ * @param {string} params.name - Alert name.
214
+ * @param {string} params.eventId - Event id the probes are labelled with.
215
+ * @param {string} params.summary - Alert summary, rendered with the alert's own labels.
216
+ * @param {string} params.description - What the handler is about to do.
217
+ * @returns {object} Alert definition, without a duration: `alertFor` is contract
218
+ * data, resolved onto the definition, so the rule cannot carry a second answer.
219
+ * @memberof UnderpostEvent
220
+ */
221
+ const wireguardAlertFactory = ({ name, eventId, summary, description }) => ({
222
+ name,
223
+ expr: `probe_success{underpost_event="${eventId}"} == 0`,
224
+ severity: 'critical',
225
+ summary,
226
+ description,
227
+ });
228
+
229
+ /**
230
+ * @constant warnedManagementDrift
231
+ * @description Peer ids already reported as no longer matching this machine.
232
+ * The check runs on every topology read; the warning is worth one line per
233
+ * process, not one per probe render.
234
+ * @memberof UnderpostEvent
235
+ */
236
+ const warnedManagementDrift = new Set();
237
+
238
+ /**
239
+ * @method warnManagementHostDrift
240
+ * @description Warns when this machine no longer answers to the management
241
+ * address its own peer is registered under.
242
+ *
243
+ * Locality is settled against the host's live interfaces, so a management
244
+ * address that drifts — a DHCP lease lost during an outage, a renumbered LAN —
245
+ * silently reclassifies this node as remote. Nothing fails: the spoke probe
246
+ * moves from the hub's tunnel address to this node's own, which answers locally
247
+ * whether or not the tunnel carries anything, and remediation routes over SSH
248
+ * to an address that is now someone else's. Both are quiet wrong answers, so
249
+ * the drift is reported where it is detected.
250
+ * @param {object} state - Edge context for this machine.
251
+ * @param {Array<object>} spokes - Resolved spokes.
252
+ * @param {Set<string>} addresses - This machine's addresses.
253
+ * @memberof UnderpostEvent
254
+ */
255
+ const warnManagementHostDrift = (state, spokes, addresses) => {
256
+ if (!state.peerId || warnedManagementDrift.has(state.peerId)) return;
257
+ const self = spokes.find((spoke) => spoke.id === state.peerId);
258
+ if (!self || self.local) return;
259
+ warnedManagementDrift.add(state.peerId);
260
+ logger.warn('This host no longer answers to the management address its peer is registered under', {
261
+ peerId: state.peerId,
262
+ registered: self.managementHost,
263
+ addresses: [...addresses],
264
+ topology: EDGE_TOPOLOGY_PATH,
265
+ effect: 'probes and remediation for this node are resolved as remote',
266
+ });
267
+ };
268
+
269
+ /**
270
+ * @constant EVENTS
271
+ * @description The event registry.
272
+ *
273
+ * The spoke repair runs before the delayed hub repair. The control plane acts
274
+ * locally for itself, over LAN SSH for workers, and over external SSH for the hub.
275
+ *
276
+ * `probes` and `remediation` are functions, not values: both are read from the
277
+ * live WireGuard topology at call time, and resolving them at module load would
278
+ * freeze whatever the file said when the process started.
279
+ * @memberof UnderpostEvent
280
+ */
281
+ const EVENTS = {
282
+ 'wireguard-server-down': {
283
+ role: 'hub',
284
+ description: 'The WireGuard hub stopped carrying traffic across the tunnel.',
285
+ alert: wireguardAlertFactory({
286
+ name: 'UnderpostWireguardServerDown',
287
+ eventId: 'wireguard-server-down',
288
+ summary: 'WireGuard hub {{ $labels.underpost_hub }} unreachable across the tunnel ({{ $labels.instance }})',
289
+ description: 'The hub tunnel address remains unavailable after spoke remediation; restarting the hub.',
290
+ }),
291
+ // The hub's tunnel address, probed from a spoke. Only traffic the hub's
292
+ // WireGuard service is actually carrying reaches that address, so a reply
293
+ // proves the tunnel works — unlike a probe of the public endpoint, which a
294
+ // running VPS answers whether or not WireGuard is up.
295
+ probes: () =>
296
+ Underpost.event
297
+ .hubs()
298
+ .filter((hub) => hub.address)
299
+ .map((hub) => ({
300
+ module: 'icmp',
301
+ targets: [hub.address],
302
+ labels: { underpost_role: 'hub', underpost_hub: hub.hubHost },
303
+ })),
304
+ remediation: () =>
305
+ Underpost.event.hubs().map((hub) => {
306
+ try {
307
+ return Underpost.event.hubTarget(hub.hubHost);
308
+ } catch (error) {
309
+ return { role: 'hub', nodeName: hub.nodeName, address: hub.address, via: 'unresolved', error: error.message };
310
+ }
311
+ }),
312
+ handler: async (options = {}, alerts = []) => Underpost.event.repairHub(options, alerts),
313
+ },
314
+
315
+ 'wireguard-spoke-down': {
316
+ role: 'spoke',
317
+ description: 'A registered WireGuard spoke fell off the hub tunnel.',
318
+ alert: wireguardAlertFactory({
319
+ name: 'UnderpostWireguardSpokeDown',
320
+ eventId: 'wireguard-spoke-down',
321
+ summary: 'WireGuard spoke {{ $labels.underpost_spoke }} unreachable ({{ $labels.instance }})',
322
+ description: 'Spoke {{ $labels.underpost_spoke }} has not answered across the tunnel for 2m; restarting it.',
323
+ }),
324
+ probes: () =>
325
+ Underpost.event.spokes().map((spoke) => ({
326
+ module: 'icmp',
327
+ targets: [spoke.local ? Underpost.event.hubAddress() : spoke.address],
328
+ labels: { underpost_role: 'spoke', underpost_spoke: spoke.id },
329
+ })),
330
+ remediation: () =>
331
+ Underpost.event.spokes().map((spoke) => {
332
+ try {
333
+ return Underpost.event.spokeTarget(spoke.id);
334
+ } catch (error) {
335
+ return {
336
+ role: 'spoke',
337
+ spokeId: spoke.id,
338
+ address: spoke.address,
339
+ via: 'unresolved',
340
+ error: error.message,
341
+ };
342
+ }
343
+ }),
344
+ handler: async (options = {}, alerts = []) => Underpost.event.repairSpokes(options, alerts),
345
+ },
346
+
347
+ 'public-ingress-down': {
348
+ role: 'ingress',
349
+ description: 'Public hosts stopped answering through the edge.',
350
+ alert: {
351
+ name: 'UnderpostPublicIngressDown',
352
+ expr: 'probe_success{underpost_event="public-ingress-down"} == 0',
353
+ severity: 'critical',
354
+ summary: 'Public host {{ $labels.instance }} unreachable',
355
+ description: 'A published host stopped answering; the handler classifies the outage before acting.',
356
+ },
357
+ probes: () =>
358
+ Underpost.event.publicIngressUrls().map((entry) => ({
359
+ module: 'http_2xx',
360
+ targets: [entry.url],
361
+ labels: { underpost_role: 'ingress', underpost_host: entry.host },
362
+ })),
363
+ remediation: () =>
364
+ Underpost.event.hubs().map((hub) => {
365
+ try {
366
+ return { ...Underpost.event.hubTarget(hub.hubHost), role: 'ingress' };
367
+ } catch (error) {
368
+ return {
369
+ role: 'ingress',
370
+ nodeName: hub.nodeName,
371
+ address: hub.address,
372
+ via: 'unresolved',
373
+ error: error.message,
374
+ };
375
+ }
376
+ }),
377
+ handler: async (options = {}) => Underpost.event.repairPublicIngress(options),
378
+ },
379
+
380
+ 'node-cpu-limit-exceeded': {
381
+ role: 'node',
382
+ description: 'CPU usage on a cluster node stayed above its declared threshold.',
383
+ alert: {
384
+ name: 'UnderpostNodeCpuLimitExceeded',
385
+ expr: `(100 - (avg by (instance, underpost_role) (rate(node_cpu_seconds_total{mode="idle"}[2m])) * 100)) > ${THRESHOLD_TOKEN}`,
386
+ severity: 'warning',
387
+ summary: 'High CPU usage on {{ $labels.instance }}',
388
+ description: 'Sustained CPU pressure; the handler captures the processes responsible.',
389
+ },
390
+ probes: () => [],
391
+ remediation: () => Underpost.event.nodeTargets(),
392
+ handler: async (options = {}, alerts = []) =>
393
+ Underpost.event.inspectNodes({
394
+ role: 'node',
395
+ command: 'ps aux --sort=-%cpu | head -n 10',
396
+ condition: 'CPU usage stayed above the declared threshold',
397
+ options,
398
+ alerts,
399
+ }),
400
+ },
401
+
402
+ 'node-memory-limit-exceeded': {
403
+ role: 'node',
404
+ description: 'Available memory on a cluster node fell below its declared threshold.',
405
+ alert: {
406
+ name: 'UnderpostNodeMemoryLimitExceeded',
407
+ expr: '(100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))) > <threshold>',
408
+ severity: 'warning',
409
+ summary: 'High RAM usage on {{ $labels.instance }}',
410
+ description: 'Sustained memory pressure; the handler captures the processes responsible.',
411
+ },
412
+ probes: () => [],
413
+ remediation: () => Underpost.event.nodeTargets(),
414
+ handler: async (options = {}, alerts = []) =>
415
+ Underpost.event.inspectNodes({
416
+ role: 'node',
417
+ command: 'ps aux --sort=-%mem | head -n 10',
418
+ condition: 'memory usage stayed above the declared threshold',
419
+ options,
420
+ alerts,
421
+ }),
422
+ },
423
+
424
+ 'hub-bandwidth-limit-exceeded': {
425
+ role: 'hub',
426
+ description: 'The hub consumed the declared share of its monthly bandwidth quota.',
427
+ alert: {
428
+ name: 'UnderpostHubBandwidthLimitExceeded',
429
+ expr: '(vultr_bandwidth_used_bytes / vultr_bandwidth_limit_bytes) * 100 > <threshold>',
430
+ severity: 'critical',
431
+ summary: 'Hub bandwidth limit threshold reached',
432
+ description: 'Monthly quota consumption crossed the declared share; the handler reports the breakdown.',
433
+ },
434
+ // The quota is read by the Vultr cron and published as a textfile metric,
435
+ // so nothing here probes an external API on the alert path.
436
+ probes: () => [],
437
+ remediation: () =>
438
+ Underpost.event.hubs().map((hub) => {
439
+ try {
440
+ return Underpost.event.hubTarget(hub.hubHost);
441
+ } catch (error) {
442
+ return { role: 'hub', nodeName: hub.nodeName, address: hub.address, via: 'unresolved', error: error.message };
443
+ }
444
+ }),
445
+ handler: async (options = {}, alerts = []) =>
446
+ Underpost.event.inspectNodes({
447
+ role: 'hub',
448
+ command: 'node bin vultr --bandwidth --dry-run',
449
+ condition: 'monthly bandwidth consumption crossed the declared share of the quota',
450
+ options,
451
+ alerts,
452
+ }),
453
+ },
454
+
455
+ 'node-disk-limit-exceeded': {
456
+ role: 'node',
457
+ description: 'Root filesystem usage on a cluster node exceeded its declared threshold.',
458
+ alert: {
459
+ name: 'UnderpostNodeDiskLimitExceeded',
460
+ expr: '(100 - ((node_filesystem_avail_bytes{mountpoint="/"} * 100) / node_filesystem_size_bytes{mountpoint="/"})) > <threshold>',
461
+ severity: 'warning',
462
+ summary: 'High disk utilization on {{ $labels.instance }}',
463
+ description: 'The root filesystem is filling; the handler reports what is consuming it.',
464
+ },
465
+ probes: () => [],
466
+ remediation: () => Underpost.event.nodeTargets(),
467
+ handler: async (options = {}, alerts = []) =>
468
+ Underpost.event.inspectNodes({
469
+ role: 'node',
470
+ command: 'df -h / && du -sh /var/log/* 2>/dev/null | sort -rh | head -n 10',
471
+ condition: 'root filesystem usage stayed above the declared threshold',
472
+ options,
473
+ alerts,
474
+ }),
475
+ },
476
+
477
+ 'node-network-traffic-exceeded': {
478
+ role: 'node',
479
+ description: 'Interface throughput on a cluster node exceeded its declared rate.',
480
+ alert: {
481
+ name: 'UnderpostNodeNetworkTrafficExceeded',
482
+ expr:
483
+ `((rate(node_network_receive_bytes_total{${NETWORK_DEVICE_SELECTOR}}[2m]) + ` +
484
+ `rate(node_network_transmit_bytes_total{${NETWORK_DEVICE_SELECTOR}}[2m])) * 8 / 1000000) > ${THRESHOLD_TOKEN}`,
485
+ severity: 'warning',
486
+ summary: 'High network traffic burst on {{ $labels.instance }} ({{ $labels.device }})',
487
+ description: 'Sustained throughput above the declared rate; the handler reports the sockets carrying it.',
488
+ },
489
+ probes: () => [],
490
+ remediation: () => Underpost.event.nodeTargets(),
491
+ handler: async (options = {}, alerts = []) =>
492
+ Underpost.event.inspectNodes({
493
+ role: 'node',
494
+ command: 'ss -tunp state established | head -n 20 && cat /proc/net/dev',
495
+ condition: 'interface throughput stayed above the declared rate',
496
+ options,
497
+ alerts,
498
+ }),
499
+ },
500
+ };
501
+
502
+ /**
503
+ * @class UnderpostEvent
504
+ * @description Dispatches operational events, and provisions the monitoring
505
+ * configuration that triggers them.
506
+ * @memberof UnderpostEvent
507
+ */
508
+ class UnderpostEvent {
509
+ static API = {
510
+ /**
511
+ * @method EVENTS
512
+ * @description The registered events, keyed by id.
513
+ * @returns {object} Event registry.
514
+ * @memberof UnderpostEvent
515
+ */
516
+ get EVENTS() {
517
+ return EVENTS;
518
+ },
519
+
520
+ /**
521
+ * @method hubAddress
522
+ * @description The hub's address inside the tunnel, from either end.
523
+ * @returns {string} Hub tunnel address, or an empty string when underivable.
524
+ * @memberof UnderpostEvent
525
+ */
526
+ hubAddress() {
527
+ return hubTunnelAddressFactory(readEdgeContext());
528
+ },
529
+
530
+ /**
531
+ * @method spokes
532
+ * @description Registered spokes with tunnel and management identity.
533
+ *
534
+ * Read from `conf.wireguard.json`, which is the only source of tunnel
535
+ * topology and stable management addresses. `local` is settled against this
536
+ * machine's own addresses rather than against the node document that named
537
+ * it, so a repair never runs here for a peer that is somewhere else.
538
+ * @returns {Array<object>} Spokes with a resolvable tunnel address.
539
+ * @memberof UnderpostEvent
540
+ */
541
+ spokes() {
542
+ const state = readEdgeContext();
543
+ const nodes = readNodeConfigs().filter((node) => node.hubHost === state.hubHost);
544
+ const addresses = hostAddressesFactory();
545
+ const spokes = state.peers
546
+ .map((peer) => {
547
+ const node = nodes.find((entry) => entry.peerId === peer.id);
548
+ return {
549
+ id: peer.id,
550
+ address: tunnelAddressFactory(peer.address),
551
+ managementHost: peer.managementHost,
552
+ nodeRole: node?.role || '',
553
+ nodeName: node?.nodeName || '',
554
+ local: localPeerFactory(peer.managementHost, addresses),
555
+ };
556
+ })
557
+ .filter((spoke) => spoke.id && spoke.address);
558
+ warnManagementHostDrift(state, spokes, addresses);
559
+ return spokes;
560
+ },
561
+
562
+ /**
563
+ * @method clusterNodes
564
+ * @description Registered nodes the cluster schedules on, with their role.
565
+ *
566
+ * Node discovery knows a machine by the name the kubelet registered, which
567
+ * is the name its node document is filed under; the role only the document
568
+ * holds is what the dashboards group by.
569
+ * @returns {Array<{nodeName: string, role: string}>} Non-hub nodes.
570
+ * @memberof UnderpostEvent
571
+ */
572
+ clusterNodes() {
573
+ return readNodeConfigs()
574
+ .filter((node) => node.nodeName && node.role && node.role !== 'hub')
575
+ .map((node) => ({ nodeName: node.nodeName, role: node.role }));
576
+ },
577
+
578
+ /**
579
+ * @method hubs
580
+ * @description Registered hubs, joined from topology to the node documents
581
+ * that name them.
582
+ *
583
+ * Topology owns the hub set — its top-level key is the static public
584
+ * address — so a hub with no node document is still a hub. The document only
585
+ * supplies the name `--nodes` selects it by.
586
+ * @param {string} [selector] - Hub node name or static address; empty returns every hub.
587
+ * @returns {Array<{nodeName: string, hubHost: string, address: string, sshForwardPort: number}>} Registered hubs.
588
+ * @throws {Error} When a selector matches no registered hub.
589
+ * @memberof UnderpostEvent
590
+ */
591
+ hubs(selector = '') {
592
+ const nodes = readNodeConfigs().filter((node) => node.role === 'hub');
593
+ const hubs = Object.entries(readTopology()).map(([hubHost, hub]) => ({
594
+ nodeName: nodes.find((node) => node.hubHost === hubHost)?.nodeName || '',
595
+ hubHost,
596
+ address: hubTunnelAddressFactory({ role: 'hub', address: hub.address }),
597
+ sshForwardPort: hub.sshForwardPort,
598
+ }));
599
+ const selected = `${selector || ''}`.trim();
600
+ if (!selected) return hubs;
601
+ const hub = hubs.find((entry) => entry.nodeName === selected || entry.hubHost === selected);
602
+ if (!hub)
603
+ throw new Error(`[event] '${selected}' is not a registered hub node or address in ${EDGE_TOPOLOGY_PATH}`);
604
+ return [hub];
605
+ },
606
+
607
+ /** Resolves one hub through its static public address, which no failed tunnel is part of. */
608
+ hubTarget(hubHost = '') {
609
+ const [hub] = Underpost.event.hubs(`${hubHost || ''}`.trim() || readEdgeContext().hubHost);
610
+ const connection = assertHubManagementConnection({
611
+ hubHost: hub.hubHost,
612
+ sshForwardPort: hub.sshForwardPort,
613
+ connection: Underpost.ssh.resolveConnection({ host: hub.hubHost }),
614
+ });
615
+ return {
616
+ role: 'hub',
617
+ nodeName: hub.nodeName,
618
+ address: hub.address,
619
+ user: connection.user,
620
+ host: connection.host,
621
+ port: connection.port,
622
+ keyPath: connection.keyPath,
623
+ via: `${connection.user}@${connection.host}:${connection.port}`,
624
+ };
625
+ },
626
+
627
+ /**
628
+ * @method spokeTarget
629
+ * @description Maps a spoke to local execution or its registered LAN management host.
630
+ *
631
+ * The management host joins exactly against `conf.users.json`; credentials
632
+ * never enter the topology or rendered monitoring configuration.
633
+ * @param {string} spokeId - Registered spoke id.
634
+ * @returns {{spokeId: string, address: string, user: string, host: string, port: number, keyPath: string, via: string}} Resolved remediation target.
635
+ * @throws {Error} When the spoke is unregistered or has no management connection.
636
+ * @memberof UnderpostEvent
637
+ */
638
+ spokeTarget(spokeId) {
639
+ const spoke = Underpost.event.spokes().find((entry) => entry.id === spokeId);
640
+ if (!spoke)
641
+ throw new Error(`[event] spoke '${spokeId}' is not in ${EDGE_TOPOLOGY_PATH}; register it with --peer-add`);
642
+ if (!spoke.nodeRole)
643
+ throw new Error(
644
+ `[event] spoke '${spokeId}' has no node identity in deploy/nodes; run --node-config on that machine`,
645
+ );
646
+ if (spoke.local) {
647
+ return {
648
+ role: 'spoke',
649
+ nodeRole: spoke.nodeRole,
650
+ spokeId: spoke.id,
651
+ address: spoke.address,
652
+ user: '',
653
+ host: '',
654
+ port: 0,
655
+ keyPath: '',
656
+ via: 'local',
657
+ };
658
+ }
659
+ if (!spoke.managementHost)
660
+ throw new Error(
661
+ `[event] spoke '${spokeId}' has no managementHost; ` +
662
+ `re-register it with --peer-add ${spokeId} --management-host <lan-ip> --build-conf`,
663
+ );
664
+ const connection = Underpost.ssh.resolveConnection({ host: spoke.managementHost });
665
+ if (!connection)
666
+ throw new Error(
667
+ `[event] no SSH connection is registered for spoke '${spokeId}' at ${spoke.managementHost}; ` +
668
+ `run: node bin ssh --user <user> --host ${spoke.managementHost} --user-add`,
669
+ );
670
+ return {
671
+ role: 'spoke',
672
+ nodeRole: spoke.nodeRole,
673
+ spokeId: spoke.id,
674
+ address: spoke.address,
675
+ user: connection.user,
676
+ host: connection.host,
677
+ port: connection.port,
678
+ keyPath: connection.keyPath,
679
+ via: `${connection.user}@${connection.host}:${connection.port}`,
680
+ };
681
+ },
682
+
683
+ /**
684
+ * @method controlTarget
685
+ * @description The node the observability stack runs on, and the identity
686
+ * that reaches it.
687
+ *
688
+ * Probes are read where detection reads them. Resolving that node from
689
+ * topology rather than assuming it is this machine is what lets a rehearsal
690
+ * run from any machine holding the deploy configuration and the SSH
691
+ * registry, instead of only from the control plane itself.
692
+ * @returns {object} Resolved execution target.
693
+ * @throws {Error} When no control node is registered.
694
+ * @memberof UnderpostEvent
695
+ */
696
+ controlTarget() {
697
+ const control = Underpost.event.spokes().find((spoke) => spoke.nodeRole === 'control');
698
+ if (!control)
699
+ throw new Error(`[event] no node with role 'control' is registered in deploy/nodes; probes have no reader`);
700
+ return Underpost.event.spokeTarget(control.id);
701
+ },
702
+
703
+ /**
704
+ * @method subjectSelection
705
+ * @description The subjects one dispatch acts on.
706
+ *
707
+ * `--nodes` names node documents and `--spoke` topology peer ids; either
708
+ * narrows the dispatch to those subjects, and a selector matching nothing of
709
+ * the event's role is refused. This is the single place that reading happens,
710
+ * so a hand-run repair, a rehearsal and `--list` cannot disagree about what
711
+ * a selector means.
712
+ * @param {('hub'|'spoke')} role - Which registry to select from.
713
+ * @param {object} [options] - CLI options (`nodes`, `spoke`).
714
+ * @returns {string[]} Hub addresses or spoke ids, in registry order.
715
+ * @throws {Error} When a selector matches nothing of that role.
716
+ * @memberof UnderpostEvent
717
+ */
718
+ subjectSelection(role, options = {}) {
719
+ const selectors = parseList(options.nodes);
720
+ if (role === 'hub') {
721
+ if (selectors.length === 0) return Underpost.event.hubs().map((hub) => hub.hubHost);
722
+ return [...new Set(selectors.flatMap((selector) => Underpost.event.hubs(selector).map((hub) => hub.hubHost)))];
723
+ }
724
+
725
+ const spokes = Underpost.event.spokes();
726
+ const spokeSelectors = selectors.length > 0 ? selectors : parseList(options.spoke);
727
+ if (spokeSelectors.length === 0) return spokes.map((spoke) => spoke.id);
728
+ return [
729
+ ...new Set(
730
+ spokeSelectors.map((selector) => {
731
+ const spoke = spokes.find((entry) => entry.id === selector || entry.nodeName === selector);
732
+ if (!spoke)
733
+ throw new Error(
734
+ `[event] '${selector}' is not a registered spoke id or node name in ${EDGE_TOPOLOGY_PATH}`,
735
+ );
736
+ return spoke.id;
737
+ }),
738
+ ),
739
+ ];
740
+ },
741
+
742
+ /**
743
+ * @method wireguardHealth
744
+ * @description The edge status report, for the notification's health line.
745
+ *
746
+ * Runs the same `wireguard --status` an operator would, locally or on the
747
+ * spoke, so the mail carries the state the CLI reports rather than a second
748
+ * opinion assembled here.
749
+ * @param {object} [params]
750
+ * @param {string} [params.user] - SSH user; omitted runs locally on the control plane.
751
+ * @param {string} [params.host] - Host that account should reach.
752
+ * @param {boolean} [params.dryRun] - Skip the read.
753
+ * @returns {Promise<string>} Trimmed status output.
754
+ * @memberof UnderpostEvent
755
+ */
756
+ async wireguardHealth({ user = '', host = '', dryRun = false } = {}) {
757
+ if (dryRun) return '(dry run: status not read)';
758
+ const read = await Underpost.event.runCommand('node bin wireguard --status', { user, host });
759
+ return `${read.output || read.error || ''}`.trim().slice(-1500);
760
+ },
761
+
762
+ /**
763
+ * @method repairHub
764
+ * @description Remediation for `wireguard-server-down`.
765
+ *
766
+ * Runs through each hub's external SSH endpoint so the failed tunnel is not
767
+ * part of the repair path. Acts on the hubs the alert names, else on the
768
+ * `--nodes` selection, else on every registered hub.
769
+ * @param {object} [options] - Dispatch options (`dryRun`, `nodes`).
770
+ * @param {Array<object>} [alerts] - Originating Alertmanager alerts.
771
+ * @returns {Promise<object>} Structured remediation result.
772
+ * @memberof UnderpostEvent
773
+ */
774
+ async repairHub(options = {}, alerts = []) {
775
+ const named = [...new Set(alerts.map((alert) => alert?.labels?.underpost_hub).filter(Boolean))];
776
+ const hubHosts = named.length > 0 ? named : Underpost.event.subjectSelection('hub', options);
777
+
778
+ if (hubHosts.length === 0)
779
+ return {
780
+ ok: false,
781
+ role: 'hub',
782
+ condition: `no hub is registered in ${EDGE_TOPOLOGY_PATH}`,
783
+ health: '',
784
+ targets: [],
785
+ };
786
+
787
+ const targets = [];
788
+ for (const hubHost of hubHosts) {
789
+ let target;
790
+ try {
791
+ target = Underpost.event.hubTarget(hubHost);
792
+ } catch (error) {
793
+ targets.push({ role: 'hub', address: '', via: 'unresolved', commands: [], ok: false, output: error.message });
794
+ continue;
795
+ }
796
+ const commands = ['node bin wireguard --wireguard-restart --check --check-timeout 30 --expected-role hub'];
797
+ const result = await Underpost.event.runCommand(commands[0], {
798
+ ...options,
799
+ user: target.user,
800
+ host: target.host,
801
+ });
802
+ targets.push({
803
+ role: 'hub',
804
+ nodeName: target.nodeName,
805
+ address: target.address,
806
+ via: target.via,
807
+ commands,
808
+ ok: result.ok,
809
+ output: result.output || result.error,
810
+ health: await Underpost.event.wireguardHealth({
811
+ user: target.user,
812
+ host: target.host,
813
+ dryRun: options.dryRun,
814
+ }),
815
+ });
816
+ }
817
+
818
+ return {
819
+ ok: targets.length > 0 && targets.every((target) => target.ok),
820
+ role: 'hub',
821
+ condition:
822
+ `${hubHosts.length} hub(s) stopped answering across the tunnel after spoke remediation: ` +
823
+ hubHosts.join(', '),
824
+ health: '',
825
+ targets,
826
+ };
827
+ },
828
+
829
+ /**
830
+ * @method repairSpokes
831
+ * @description Remediation for `wireguard-spoke-down`.
832
+ *
833
+ * Acts only on the spokes the alert names, resolving each one's own SSH
834
+ * account before touching it. Invoked by hand with no alerts, it falls back
835
+ * to the `--spoke` / `--nodes` selection and then to every registered
836
+ * spoke, which is what makes `--dry-run` a useful rehearsal of the whole
837
+ * topology.
838
+ *
839
+ * One spoke failing to resolve or repair does not stop the others: they are
840
+ * independent hosts, and a partial recovery is better than none. The
841
+ * notification reports each outcome separately.
842
+ * @param {object} [options] - Dispatch options (`dryRun`, `spoke`, `nodes`).
843
+ * @param {Array<object>} [alerts] - Originating Alertmanager alerts.
844
+ * @returns {Promise<object>} Structured remediation result.
845
+ * @memberof UnderpostEvent
846
+ */
847
+ async repairSpokes(options = {}, alerts = []) {
848
+ const named = [...new Set(alerts.map((alert) => alert?.labels?.underpost_spoke).filter(Boolean))];
849
+ const spokeIds = named.length > 0 ? named : Underpost.event.subjectSelection('spoke', options);
850
+
851
+ if (spokeIds.length === 0)
852
+ return {
853
+ ok: false,
854
+ role: 'spoke',
855
+ condition: `no spoke is registered in ${EDGE_TOPOLOGY_PATH}`,
856
+ health: '',
857
+ targets: [],
858
+ };
859
+
860
+ const targets = [];
861
+ for (const spokeId of spokeIds) {
862
+ let target;
863
+ try {
864
+ target = Underpost.event.spokeTarget(spokeId);
865
+ } catch (error) {
866
+ targets.push({
867
+ role: 'spoke',
868
+ spokeId,
869
+ address: '',
870
+ via: 'unresolved',
871
+ commands: [],
872
+ ok: false,
873
+ output: `${error.message}`,
874
+ });
875
+ continue;
876
+ }
877
+ const commands = [
878
+ `node bin wireguard --wireguard-restart --check --check-timeout 30 --expected-role ${shellArgumentFactory(target.nodeRole)} --expected-id ${shellArgumentFactory(target.spokeId)}`,
879
+ ];
880
+ const result = await Underpost.event.runCommand(commands[0], {
881
+ ...options,
882
+ user: target.user,
883
+ host: target.host,
884
+ });
885
+ targets.push({
886
+ role: 'spoke',
887
+ spokeId: target.spokeId,
888
+ address: target.address,
889
+ via: target.via,
890
+ commands,
891
+ ok: result.ok,
892
+ output: result.output || result.error,
893
+ health: await Underpost.event.wireguardHealth({
894
+ user: target.user,
895
+ host: target.host,
896
+ dryRun: options.dryRun,
897
+ }),
898
+ });
899
+ }
900
+
901
+ return {
902
+ ok: targets.length > 0 && targets.every((target) => target.ok),
903
+ role: 'spoke',
904
+ condition: `${spokeIds.length} spoke(s) stopped answering across the tunnel: ${spokeIds.join(', ')}`,
905
+ health: '',
906
+ targets,
907
+ };
908
+ },
909
+
910
+ /**
911
+ * @method publicIngressUrls
912
+ * @description Every public URL the cluster publishes, from the deploy conf
913
+ * the traffic report reads.
914
+ * @returns {Array<{host: string, path: string, url: string}>} Routable URLs.
915
+ * @memberof UnderpostEvent
916
+ */
917
+ publicIngressUrls() {
918
+ return publicIngressUrlsFactory(resolveDeployList('dd'), 'production');
919
+ },
920
+
921
+ /**
922
+ * @method publicIngressHealth
923
+ * @description Probes every public URL and classifies the outage.
924
+ *
925
+ * The final code of the redirect chain is the answer: a `301` that never
926
+ * lands on a `200` is a broken route, not a served one. The classification
927
+ * is what decides the response — the difference between one host failing and
928
+ * every host failing is the difference between a deploy problem and an edge
929
+ * outage, and only the second is something remediation can fix.
930
+ * @returns {{state: ('healthy'|'partial'|'down'), total: number, failing: Array<object>, healthy: number}} Report.
931
+ * @memberof UnderpostEvent
932
+ */
933
+ publicIngressHealth() {
934
+ const results = Underpost.event.publicIngressUrls().map((entry) => {
935
+ const statuses = publicIngressProbeFactory(entry.url);
936
+ return { ...entry, statuses, ok: statuses[statuses.length - 1] === '200' };
937
+ });
938
+ const failing = results.filter((entry) => !entry.ok);
939
+ return {
940
+ state:
941
+ results.length === 0 || failing.length === 0
942
+ ? 'healthy'
943
+ : failing.length === results.length
944
+ ? 'down'
945
+ : 'partial',
946
+ total: results.length,
947
+ healthy: results.length - failing.length,
948
+ failing,
949
+ };
950
+ },
951
+
952
+ /**
953
+ * @method awaitPublicIngressHealth
954
+ * @description Probes the public edge until it is healthy, or the wait runs out.
955
+ * @param {object} [params]
956
+ * @param {number} [params.timeoutMs] - Maximum wait.
957
+ * @param {number} [params.intervalMs] - Pause between passes.
958
+ * @returns {Promise<object>} The last health report.
959
+ * @memberof UnderpostEvent
960
+ */
961
+ async awaitPublicIngressHealth({
962
+ timeoutMs = PUBLIC_INGRESS_RECOVERY.timeoutMs,
963
+ intervalMs = PUBLIC_INGRESS_RECOVERY.intervalMs,
964
+ } = {}) {
965
+ const deadline = Date.now() + Math.max(0, timeoutMs);
966
+ let health = Underpost.event.publicIngressHealth();
967
+ while (health.state !== 'healthy' && Date.now() < deadline) {
968
+ await timer(intervalMs);
969
+ health = Underpost.event.publicIngressHealth();
970
+ }
971
+ return health;
972
+ },
973
+
974
+ /**
975
+ * @method repairPublicIngress
976
+ * @description Remediation for `public-ingress-down`.
977
+ *
978
+ * Three outcomes, because they need three different responses. Every host
979
+ * healthy is not an event at all and is reported to nobody. Some hosts
980
+ * failing is a deploy-level fault the edge cannot repair, so it is announced
981
+ * and left alone. Every host failing is an edge outage: ingress is unblocked
982
+ * and the tunnel rebuilt on the hub, then the same probe decides whether it
983
+ * worked — polled, because the edge takes a moment to carry traffic again
984
+ * and a single immediate read reports a failure that has already cleared.
985
+ * @param {object} [options] - Dispatch options (`dryRun`, `nodes`).
986
+ * @returns {Promise<object>} Structured remediation result.
987
+ * @memberof UnderpostEvent
988
+ */
989
+ async repairPublicIngress(options = {}) {
990
+ const before = Underpost.event.publicIngressHealth();
991
+ const summary = (health) => `${health.healthy}/${health.total} public hosts answering 200`;
992
+
993
+ if (before.state === 'healthy')
994
+ return { ok: true, role: 'ingress', silent: true, condition: summary(before), health: '', targets: [] };
995
+
996
+ if (before.state === 'partial')
997
+ return {
998
+ ok: false,
999
+ role: 'ingress',
1000
+ condition: `${summary(before)}; ${before.failing.length} failing, so the edge is up and the fault is per host`,
1001
+ health: Underpost.event.publicIngressReport(before),
1002
+ targets: [],
1003
+ };
1004
+
1005
+ const targets = [];
1006
+ for (const hubHost of Underpost.event.subjectSelection('hub', options)) {
1007
+ let target;
1008
+ try {
1009
+ target = Underpost.event.hubTarget(hubHost);
1010
+ } catch (error) {
1011
+ targets.push({
1012
+ role: 'ingress',
1013
+ address: '',
1014
+ via: 'unresolved',
1015
+ commands: [],
1016
+ ok: false,
1017
+ output: error.message,
1018
+ });
1019
+ continue;
1020
+ }
1021
+ // Ingress first: a blocked edge would make the tunnel rebuild look
1022
+ // successful while nothing could still reach it.
1023
+ const commands = [
1024
+ 'node bin ip --unblock-all-ingress',
1025
+ 'node bin wireguard --wireguard-setup --wireguard-restart',
1026
+ ];
1027
+ const outputs = [];
1028
+ let ok = true;
1029
+ for (const command of commands) {
1030
+ const result = await Underpost.event.runCommand(command, {
1031
+ ...options,
1032
+ user: target.user,
1033
+ host: target.host,
1034
+ });
1035
+ outputs.push(`${result.output || result.error || ''}`.trim().slice(-500));
1036
+ ok = result.ok;
1037
+ if (!ok) break;
1038
+ }
1039
+ targets.push({
1040
+ role: 'ingress',
1041
+ nodeName: target.nodeName,
1042
+ address: target.address,
1043
+ via: target.via,
1044
+ commands,
1045
+ ok,
1046
+ output: outputs.join('\n'),
1047
+ });
1048
+ }
1049
+
1050
+ const after = options.dryRun ? before : await Underpost.event.awaitPublicIngressHealth();
1051
+ return {
1052
+ ok: targets.length > 0 && targets.every((target) => target.ok) && after.state === 'healthy',
1053
+ role: 'ingress',
1054
+ condition: `every public host stopped answering (${before.total} of ${before.total}); after remediation ${summary(after)}`,
1055
+ health: Underpost.event.publicIngressReport(after),
1056
+ targets,
1057
+ };
1058
+ },
1059
+
1060
+ /** Renders the per-host probe outcome for the notification body. */
1061
+ publicIngressReport(health) {
1062
+ return health.failing.map((entry) => `${entry.url} -> ${entry.statuses.join('→')}`).join('\n');
1063
+ },
1064
+
1065
+ /**
1066
+ * @method nodeTargets
1067
+ * @description Every machine host metrics are collected from, keyed by the
1068
+ * address Prometheus labels its series with.
1069
+ *
1070
+ * A discovered node reports its InternalIP, which is the management address
1071
+ * topology already records; the hub reports its tunnel address. Both resolve
1072
+ * to the identity that can run a command there, so a threshold alert names a
1073
+ * machine an operator can reach.
1074
+ * @returns {Array<object>} Execution targets with the `instance` they answer to.
1075
+ * @memberof UnderpostEvent
1076
+ */
1077
+ nodeTargets() {
1078
+ const targets = [];
1079
+ for (const hub of Underpost.event.hubs())
1080
+ try {
1081
+ targets.push({ ...Underpost.event.hubTarget(hub.hubHost), instance: hub.address });
1082
+ } catch (error) {
1083
+ targets.push({ role: 'hub', instance: hub.address, via: 'unresolved', error: error.message });
1084
+ }
1085
+ for (const spoke of Underpost.event.spokes())
1086
+ try {
1087
+ const target = Underpost.event.spokeTarget(spoke.id);
1088
+ targets.push({ ...target, instance: spoke.managementHost || spoke.address });
1089
+ } catch (error) {
1090
+ targets.push({ role: 'spoke', instance: spoke.managementHost, via: 'unresolved', error: error.message });
1091
+ }
1092
+ return targets;
1093
+ },
1094
+
1095
+ /**
1096
+ * @method inspectNodes
1097
+ * @description Runs one diagnostic on every node an alert names, or on all
1098
+ * of them when it names none.
1099
+ *
1100
+ * The diagnostic is read-only: a threshold crossing says a machine is under
1101
+ * pressure, not what to do about it, and the useful response is the evidence
1102
+ * an operator would gather by hand.
1103
+ * @param {object} params
1104
+ * @param {string} params.role - Reported role for the notification.
1105
+ * @param {string} params.command - Diagnostic to run on each node.
1106
+ * @param {string} params.condition - What the rule observed.
1107
+ * @param {object} [params.options] - Dispatch options.
1108
+ * @param {Array<object>} [params.alerts] - Originating alerts; their `instance` selects the nodes.
1109
+ * @returns {Promise<object>} Structured result.
1110
+ * @memberof UnderpostEvent
1111
+ */
1112
+ async inspectNodes({ role, command, condition, options = {}, alerts = [] }) {
1113
+ const named = [
1114
+ ...new Set(alerts.map((alert) => `${alert?.labels?.instance || ''}`.split(':')[0]).filter(Boolean)),
1115
+ ];
1116
+ const nodes = Underpost.event.nodeTargets();
1117
+ const selected = named.length > 0 ? nodes.filter((node) => named.includes(node.instance)) : nodes;
1118
+
1119
+ if (selected.length === 0)
1120
+ return {
1121
+ ok: false,
1122
+ role,
1123
+ condition,
1124
+ health: '',
1125
+ targets: [],
1126
+ error: `no registered node matches ${named.join(', ') || 'the alert'}`,
1127
+ };
1128
+
1129
+ const targets = [];
1130
+ for (const node of selected) {
1131
+ if (node.via === 'unresolved') {
1132
+ targets.push({
1133
+ role,
1134
+ address: node.instance,
1135
+ via: 'unresolved',
1136
+ commands: [],
1137
+ ok: false,
1138
+ output: node.error,
1139
+ });
1140
+ continue;
1141
+ }
1142
+ const result = await Underpost.event.runCommand(command, {
1143
+ ...options,
1144
+ user: node.user,
1145
+ host: node.host,
1146
+ silent: true,
1147
+ });
1148
+ targets.push({
1149
+ role,
1150
+ nodeName: node.nodeName || node.spokeId || '',
1151
+ address: node.instance,
1152
+ via: node.via,
1153
+ commands: [command],
1154
+ ok: result.ok,
1155
+ output: `${result.output || result.error || ''}`.trim().slice(-1500),
1156
+ });
1157
+ }
1158
+
1159
+ return { ok: targets.every((target) => target.ok), role, condition, health: '', targets };
1160
+ },
1161
+
1162
+ /**
1163
+ * @method definitions
1164
+ * @description Resolves event definitions, with probes and remediation
1165
+ * identities expanded from live host state. This is the shape the monitoring
1166
+ * stack is rendered from, and the shape `--list` prints.
1167
+ * A string selects by name and empty means every registered event, which is
1168
+ * what a full convergence wants. An array is taken literally, empty
1169
+ * included: `--undeploy` of the last event has to render no events at all,
1170
+ * and "none" must not collapse into "all".
1171
+ * @param {string|string[]} [eventIds] - Comma-separated ids, or an exact list.
1172
+ * @returns {Array<object>} Definitions with `id`, resolved `probes` and resolved `remediation`.
1173
+ * @memberof UnderpostEvent
1174
+ */
1175
+ definitions(eventIds = '') {
1176
+ const ids = parseList(eventIds);
1177
+ const selected = Array.isArray(eventIds) || ids.length > 0 ? ids : Object.keys(EVENTS);
1178
+ const conf = readEventConf();
1179
+ return selected.map((id) => {
1180
+ const event = EVENTS[id];
1181
+ if (!event) throw new Error(`[event] unknown event id: ${id}`);
1182
+ const schedule = eventSchedule(id, conf);
1183
+ const probes = event.probes().map((probe) => ({ ...probe, eventId: id, interval: schedule.probeInterval }));
1184
+ // Only a rule that reads `probe_success` depends on them; a threshold
1185
+ // rule reads scraped host metrics and declares no probe by design.
1186
+ if (probes.length === 0 && `${event.alert.expr}`.includes('probe_success'))
1187
+ logger.warn(`Event has no resolvable probe targets; its rule will never fire`, { eventId: id });
1188
+ return {
1189
+ ...event,
1190
+ id,
1191
+ schedule,
1192
+ alert: {
1193
+ ...event.alert,
1194
+ for: schedule.alertFor,
1195
+ expr: `${event.alert.expr}`.replaceAll(THRESHOLD_TOKEN, schedule.threshold),
1196
+ },
1197
+ probes,
1198
+ remediation: event.remediation(),
1199
+ notifications: eventNotificationRoutes(id, conf),
1200
+ };
1201
+ });
1202
+ },
1203
+
1204
+ /** Ensures every rendered event has an executable remediation route. */
1205
+ assertRemediationReady(definitions = Underpost.event.definitions()) {
1206
+ const unresolved = definitions
1207
+ .flatMap((definition) => definition.remediation)
1208
+ .filter((target) => target.via === 'unresolved');
1209
+ if (unresolved.length > 0)
1210
+ throw new Error(
1211
+ `[event] remediation targets are unresolved:\n${unresolved
1212
+ .map((target) => `- ${target.spokeId || target.role}: ${target.error || 'no management route'}`)
1213
+ .join('\n')}`,
1214
+ );
1215
+ return definitions;
1216
+ },
1217
+
1218
+ /**
1219
+ * @method deployedEventIds
1220
+ * @description The events the cluster is currently running.
1221
+ * @param {object} [options] - CLI options (`namespace`).
1222
+ * @returns {string[]} Event ids read from the live ConfigMaps.
1223
+ * @memberof UnderpostEvent
1224
+ */
1225
+ deployedEventIds(options = {}) {
1226
+ return Underpost.event.deployedEventState(options).ids;
1227
+ },
1228
+
1229
+ /**
1230
+ * @method deployedEventState
1231
+ * @description The deployed event set together with whether the cluster answered.
1232
+ * @param {object} [options] - CLI options (`namespace`).
1233
+ * @returns {{readable: boolean, ids: string[], reason: string}} Deployed set and its readability.
1234
+ * @memberof UnderpostEvent
1235
+ */
1236
+ deployedEventState(options = {}) {
1237
+ return Underpost.monitor.readDeployedEventState({ namespace: options.namespace || 'default' });
1238
+ },
1239
+
1240
+ /**
1241
+ * @method suspendEvents
1242
+ * @description Atomically records the exact deployed event set, then
1243
+ * republishes observability with no event probes or alert rules. The state
1244
+ * file is deliberately retained on every failure so maintenance rollback
1245
+ * can converge the original set instead of guessing from local defaults.
1246
+ * @param {string} stateFile - Root-owned maintenance state file.
1247
+ * @param {object} [options] - Observability options (`namespace`).
1248
+ * @returns {Promise<object>} The persisted suspension state.
1249
+ * @memberof UnderpostEvent
1250
+ */
1251
+ async suspendEvents(stateFile, options = {}) {
1252
+ const target = nodePath.resolve(`${stateFile || ''}`);
1253
+ const namespace = options.namespace || 'default';
1254
+ if (!stateFile) throw new Error('[event] --suspend-events requires a state-file path');
1255
+ if (fs.existsSync(target))
1256
+ throw new Error(`[event] suspension state already exists at ${target}; resume it before suspending again`);
1257
+
1258
+ const state = Underpost.event.deployedEventState({ namespace });
1259
+ if (!state.readable)
1260
+ throw new Error(
1261
+ `[event] the deployed event set is unreadable and cannot be suspended safely${state.reason ? ` (${state.reason})` : ''}`,
1262
+ );
1263
+ const events = [...new Set(state.ids)].sort();
1264
+ const unknown = events.filter((id) => !EVENTS[id]);
1265
+ if (unknown.length > 0)
1266
+ throw new Error(
1267
+ `[event] cannot suspend and exactly restore undeclared deployed event(s): ${unknown.join(', ')}; undeploy them first`,
1268
+ );
1269
+
1270
+ const suspension = {
1271
+ version: EVENT_SUSPENSION_VERSION,
1272
+ namespace,
1273
+ events,
1274
+ suspendedAt: new Date().toISOString(),
1275
+ };
1276
+ const temporary = `${target}.tmp-${process.pid}-${Date.now()}`;
1277
+ fs.ensureDirSync(nodePath.dirname(target), { mode: 0o700 });
1278
+ try {
1279
+ fs.writeFileSync(temporary, `${JSON.stringify(suspension, null, 2)}\n`, {
1280
+ encoding: 'utf8',
1281
+ flag: 'wx',
1282
+ mode: 0o600,
1283
+ });
1284
+ fs.renameSync(temporary, target);
1285
+ fs.chmodSync(target, 0o600);
1286
+ } finally {
1287
+ fs.removeSync(temporary);
1288
+ }
1289
+
1290
+ logger.warn('Suspending operational events for planned maintenance', { namespace, events, stateFile: target });
1291
+ try {
1292
+ await Underpost.monitor.syncObservability({
1293
+ ...options,
1294
+ namespace,
1295
+ events: [],
1296
+ requireEventReload: true,
1297
+ });
1298
+ } catch (error) {
1299
+ logger.error('Event suspension failed; recovery state was retained', {
1300
+ namespace,
1301
+ events,
1302
+ stateFile: target,
1303
+ error: `${error?.message || error}`,
1304
+ });
1305
+ throw error;
1306
+ }
1307
+ logger.info('Operational events suspended', { namespace, events, stateFile: target });
1308
+ return suspension;
1309
+ },
1310
+
1311
+ /**
1312
+ * @method resumeEvents
1313
+ * @description Restores the exact event set saved by `suspendEvents` and
1314
+ * removes the state file only after observability reconciliation succeeds.
1315
+ * A failed resync is therefore safe to retry after boot or rollback.
1316
+ * @param {string} stateFile - Suspension state created by `suspendEvents`.
1317
+ * @param {object} [options] - Observability options.
1318
+ * @returns {Promise<object>} The applied observability context.
1319
+ * @memberof UnderpostEvent
1320
+ */
1321
+ async resumeEvents(stateFile, options = {}) {
1322
+ const target = nodePath.resolve(`${stateFile || ''}`);
1323
+ if (!stateFile) throw new Error('[event] --resume-events requires a state-file path');
1324
+ if (!fs.existsSync(target)) throw new Error(`[event] suspension state does not exist: ${target}`);
1325
+
1326
+ let suspension;
1327
+ try {
1328
+ suspension = fs.readJsonSync(target);
1329
+ } catch (error) {
1330
+ throw new Error(`[event] suspension state is invalid JSON at ${target}: ${error.message}`);
1331
+ }
1332
+ if (suspension?.version !== EVENT_SUSPENSION_VERSION)
1333
+ throw new Error(`[event] unsupported suspension state version at ${target}: ${suspension?.version}`);
1334
+ const namespace = `${suspension.namespace || ''}`;
1335
+ if (!/^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/.test(namespace))
1336
+ throw new Error(`[event] suspension state has an invalid namespace: ${namespace || '<empty>'}`);
1337
+ if (!Array.isArray(suspension.events) || suspension.events.some((id) => typeof id !== 'string'))
1338
+ throw new Error(`[event] suspension state has an invalid event list: ${target}`);
1339
+ const events = [...new Set(suspension.events)].sort();
1340
+ const unknown = events.filter((id) => !EVENTS[id]);
1341
+ if (unknown.length > 0)
1342
+ throw new Error(`[event] cannot restore event(s) no longer declared by this checkout: ${unknown.join(', ')}`);
1343
+
1344
+ logger.info('Resynchronizing operational events after planned maintenance', {
1345
+ namespace,
1346
+ events,
1347
+ stateFile: target,
1348
+ });
1349
+ try {
1350
+ const context = await Underpost.monitor.syncObservability({
1351
+ ...options,
1352
+ namespace,
1353
+ events,
1354
+ requireEventReload: true,
1355
+ });
1356
+ fs.removeSync(target);
1357
+ logger.info('Operational events restored', { namespace, events });
1358
+ return context;
1359
+ } catch (error) {
1360
+ logger.error('Event resynchronization failed; suspension state was retained for retry', {
1361
+ namespace,
1362
+ events,
1363
+ stateFile: target,
1364
+ error: `${error?.message || error}`,
1365
+ });
1366
+ throw error;
1367
+ }
1368
+ },
1369
+
1370
+ /**
1371
+ * @method deploySelection
1372
+ * @description The event set a scoped `--deploy` or `--undeploy` publishes.
1373
+ *
1374
+ * The ConfigMaps are rendered whole, so publishing one event means rendering
1375
+ * every event that must stay published with it. The set already deployed is
1376
+ * read back from the cluster and the named event merged into or removed from
1377
+ * it — the only way an incremental command can be incremental without a
1378
+ * local file claiming what the cluster contains.
1379
+ * @param {string} eventId - Event being added or withdrawn.
1380
+ * @param {object} [options] - CLI options (`namespace`).
1381
+ * @param {boolean} [remove=false] - Withdraw the event instead of adding it.
1382
+ * @returns {string[]} The exact set to render.
1383
+ * @memberof UnderpostEvent
1384
+ */
1385
+ deploySelection(eventId, options = {}, remove = false) {
1386
+ const state = Underpost.event.deployedEventState(options);
1387
+ // The render is whole: merging into a set the cluster never reported would
1388
+ // publish this event alone and withdraw every other deployed one.
1389
+ if (!state.readable)
1390
+ throw new Error(
1391
+ `[event] the deployed event set is unreadable, so ${eventId} cannot be merged into it without ` +
1392
+ `withdrawing the rest; resolve cluster access and retry${state.reason ? ` (${state.reason})` : ''}`,
1393
+ );
1394
+ const deployed = new Set(state.ids);
1395
+ if (remove) deployed.delete(eventId);
1396
+ else deployed.add(eventId);
1397
+ // An event the registry no longer declares cannot be rendered; withdrawing
1398
+ // it is what `--undeploy` is for, so it is dropped rather than failing the
1399
+ // publication of everything else.
1400
+ return [...deployed].filter((id) => EVENTS[id]).sort();
1401
+ },
1402
+
1403
+ /**
1404
+ * @method deploymentStatus
1405
+ * @description Declared events against deployed events.
1406
+ * @param {object} [options] - CLI options (`namespace`).
1407
+ * @returns {Array<{id: string, status: string, reason: string}>} One row per declared or
1408
+ * deployed event; `UNKNOWN` when the cluster did not answer.
1409
+ * @memberof UnderpostEvent
1410
+ */
1411
+ deploymentStatus(options = {}) {
1412
+ const state = Underpost.event.deployedEventState(options);
1413
+ const deployed = new Set(state.ids);
1414
+ const declared = new Set(Object.keys(EVENTS));
1415
+ return [...new Set([...declared, ...deployed])].sort().map((id) => ({
1416
+ id,
1417
+ status: !state.readable
1418
+ ? 'UNKNOWN'
1419
+ : declared.has(id)
1420
+ ? deployed.has(id)
1421
+ ? 'DEPLOYED'
1422
+ : 'PENDING'
1423
+ : 'OUT_OF_SYNC',
1424
+ reason: state.reason,
1425
+ }));
1426
+ },
1427
+
1428
+ /**
1429
+ * @method assertDispatchReady
1430
+ * @description The gate every publication passes: detection is refused
1431
+ * unless the fault can be repaired *and* reported.
1432
+ *
1433
+ * A rule with no repair route repeats an outage; a rule with no
1434
+ * notification route repairs it silently, so a recurring fault is never
1435
+ * escalated; a rule with no declared cadence falls back to a period and a
1436
+ * window nobody chose. All three are failures of the same contract and are
1437
+ * refused together.
1438
+ * @param {Array<object>} [definitions] - Resolved definitions.
1439
+ * @returns {Array<object>} The same definitions, when every route resolves.
1440
+ * @memberof UnderpostEvent
1441
+ */
1442
+ assertDispatchReady(definitions = Underpost.event.definitions()) {
1443
+ Underpost.event.assertRemediationReady(definitions);
1444
+ assertEventSchedules(definitions);
1445
+ assertNotificationRoutes(definitions.flatMap((definition) => definition.notifications));
1446
+ return definitions;
1447
+ },
1448
+
1449
+ /**
1450
+ * @method runCommand
1451
+ * @description Runs one remediation command through the single execution
1452
+ * facility, locally or on a named account's host.
1453
+ *
1454
+ * `sshRemoteRunner` selects local or remote execution from whether a registered
1455
+ * user resolved. That is right for remediation, which repairs this node without
1456
+ * an SSH hop, and wrong for anything that rewrites the checkout it runs in:
1457
+ * such a caller passes `requireRemote` so the fallback cannot happen silently.
1458
+ * @param {string} command - Command to run.
1459
+ * @param {object} [options]
1460
+ * @param {string} [options.user] - Registered SSH user; omitted runs locally.
1461
+ * @param {string} [options.host] - Host that account should reach.
1462
+ * @param {boolean} [options.requireRemote] - Refuse to fall back to local execution.
1463
+ * @param {boolean} [options.dryRun] - Report the command instead of running it.
1464
+ * @param {boolean} [options.silent] - Suppress the command's output; it is still returned.
1465
+ * @returns {Promise<{ok: boolean, output: string, error?: string}>} Execution result.
1466
+ * @memberof UnderpostEvent
1467
+ */
1468
+ async runCommand(command, options = {}) {
1469
+ const user = options.user || '';
1470
+ const host = options.host || '';
1471
+ if (options.requireRemote === true && !user)
1472
+ throw new Error(
1473
+ `[event] refusing to run locally: this command requires a remote target, but no ` +
1474
+ `registered SSH user resolved${host ? ` for ${host}` : ''}.`,
1475
+ );
1476
+ const where = user ? `ssh ${user}@${host || '(registered host)'}` : 'local';
1477
+ if (options.dryRun) {
1478
+ logger.info(`[dry-run] ${where} :: ${command}`);
1479
+ return { ok: true, output: `[dry-run] ${where} :: ${command}` };
1480
+ }
1481
+ // A polled read announces itself once per target per interval; at one probe
1482
+ // per public host that is the whole report, so a silent call stays silent.
1483
+ if (options.silent !== true) logger.info(`${where} :: ${command}`);
1484
+ try {
1485
+ const output = await Underpost.ssh.sshRemoteRunner(command, {
1486
+ user,
1487
+ host,
1488
+ cd: ENGINE_REMOTE_PATH,
1489
+ remote: Boolean(user),
1490
+ silent: options.silent === true,
1491
+ });
1492
+ return { ok: true, output: `${output || ''}` };
1493
+ } catch (error) {
1494
+ // What the command said, not the wrapper that carried it: the generated
1495
+ // SSH script is in the thrown message and tells a reader nothing.
1496
+ const reported = `${error?.stderr || ''}${error?.stdout || ''}`.trim();
1497
+ return { ok: false, output: '', error: plainText(reported || `${error?.message || error}`) };
1498
+ }
1499
+ },
1500
+
1501
+ /**
1502
+ * @method notificationFactory
1503
+ * @description Renders the operational alert.
1504
+ *
1505
+ * An alert exists to let whoever reads it act without opening a terminal, so
1506
+ * it names the event, the role, the specific subject, the condition that was
1507
+ * detected, the commands attempted and their outcome. "WireGuard down" tells
1508
+ * an operator nothing they can act on; "spoke `node-a` at 10.0.0.2, restart
1509
+ * failed" tells them where to look.
1510
+ * @param {object} params
1511
+ * @param {string} params.eventId - Dispatched event id.
1512
+ * @param {object} params.result - Handler result.
1513
+ * @param {Array<object>} [params.alerts] - Originating Alertmanager alerts.
1514
+ * @returns {{subject: string, text: string}} Rendered alert.
1515
+ * @memberof UnderpostEvent
1516
+ */
1517
+ notificationFactory({ eventId, result, alerts = [] }) {
1518
+ const targets = result?.targets || [];
1519
+ const subjects = targets.map((target) => target.spokeId || target.role).join(', ');
1520
+ const dryRun = targets.some((target) => `${target.output || ''}`.startsWith('[dry-run]'));
1521
+ const outcome = dryRun ? 'planned' : result?.ok ? 'remediated' : 'FAILED';
1522
+
1523
+ const lines = [
1524
+ `event: ${eventId}`,
1525
+ `role: ${result?.role || 'unknown'}`,
1526
+ `subjects: ${subjects || '(none)'}`,
1527
+ `condition: ${result?.condition || 'unknown'}`,
1528
+ `result: ${outcome}${dryRun ? ' (dry run; no command executed)' : ''}`,
1529
+ `deploy: ${process.env.DEPLOY_ID || ''}`,
1530
+ `timestamp: ${new Date().toISOString()}`,
1531
+ alerts.length > 0 ? `alert instances: ${alerts.map((alert) => alert?.labels?.instance || '').join(', ')}` : '',
1532
+ '',
1533
+ ];
1534
+
1535
+ for (const target of targets) {
1536
+ lines.push(
1537
+ `--- ${target.role}${target.spokeId ? ` ${target.spokeId}` : ''} ---`,
1538
+ `tunnel address: ${target.address || '(unresolved)'}`,
1539
+ `remediated via: ${target.via}`,
1540
+ `commands: ${target.commands.join(' && ') || '(none)'}`,
1541
+ `outcome: ${target.ok ? 'ok' : 'failed'}`,
1542
+ `${target.output || ''}`.trim().slice(-1500),
1543
+ `${target.health ? `health:\n${target.health}` : ''}`,
1544
+ '',
1545
+ );
1546
+ }
1547
+ if (result?.health) lines.push('--- hub health ---', result.health, '');
1548
+ if (result?.error) lines.push(`error: ${result.error}`);
1549
+
1550
+ return {
1551
+ subject: `[underpost] ${eventId} — ${subjects || result?.role || 'unknown'} — ${outcome}`,
1552
+ text: lines.filter((line) => line !== '').join('\n'),
1553
+ };
1554
+ },
1555
+
1556
+ /**
1557
+ * @method notify
1558
+ * @description Delivers the rendered alert over the routes the event declares.
1559
+ *
1560
+ * Who hears about an event is deployment data, not behaviour, so the routes
1561
+ * come from the notification contract rather than from this registry or
1562
+ * from ambient SMTP environment variables: adding a subscriber is a change
1563
+ * to `engine-private/deploy/conf.event.json` and to nothing else.
1564
+ * @param {object} params
1565
+ * @param {string} params.eventId - Dispatched event id.
1566
+ * @param {object} params.result - Handler result.
1567
+ * @param {Array<object>} [params.alerts] - Originating Alertmanager alerts.
1568
+ * @returns {Promise<{ok: boolean, delivered: Array<object>, failed: Array<object>}>} Per-route outcome.
1569
+ * @memberof UnderpostEvent
1570
+ */
1571
+ async notify({ eventId, result, alerts = [] }) {
1572
+ const { subject, text } = Underpost.event.notificationFactory({ eventId, result, alerts });
1573
+ return await deliverEventNotification({ eventId, subject, text });
1574
+ },
1575
+
1576
+ /**
1577
+ * @method dispatch
1578
+ * @description Runs one event's handler and mails the outcome.
1579
+ *
1580
+ * A failing handler is reported rather than thrown: the dispatcher may be
1581
+ * serving a webhook, where an unhandled rejection would take down the
1582
+ * receiver and silence every later alert. The mail is what carries the
1583
+ * failure to a human.
1584
+ * @param {string} eventId - Registered event id.
1585
+ * @param {object} [options] - Dispatch options (`spoke`, `dryRun`, `notify`).
1586
+ * @param {Array<object>} [alerts] - Originating Alertmanager alerts; they name the subject to act on.
1587
+ * @returns {Promise<object>} Handler result.
1588
+ * @memberof UnderpostEvent
1589
+ */
1590
+ async dispatch(eventId, options = {}, alerts = []) {
1591
+ const event = EVENTS[eventId];
1592
+ if (!event) throw new Error(`[event] unknown event id: ${eventId}`);
1593
+
1594
+ logger.info('Dispatching event', { eventId, dryRun: options.dryRun === true, alerts: alerts.length });
1595
+
1596
+ let result;
1597
+ try {
1598
+ result = { ok: true, role: event.role, ...(await event.handler(options, alerts)) };
1599
+ } catch (error) {
1600
+ result = { ok: false, role: event.role, targets: [], error: `${error?.message || error}` };
1601
+ logger.error(`Event handler failed`, { eventId, error: result.error });
1602
+ }
1603
+
1604
+ // A handler that found nothing wrong has nothing to announce; alerting on
1605
+ // a healthy check is how an inbox stops being read.
1606
+ if (options.notify !== false && result.silent !== true) await Underpost.event.notify({ eventId, result, alerts });
1607
+ return result;
1608
+ },
1609
+
1610
+ /**
1611
+ * @method e2eScenario
1612
+ * @description Loads the rehearsal an event id declares.
1613
+ *
1614
+ * A scenario is the only thing that knows how to break a real subject, so
1615
+ * it lives beside the tests rather than in the registry: the registry
1616
+ * describes production behaviour, and nothing in it should be able to take
1617
+ * a host down.
1618
+ * @param {string} eventId - Registered event id.
1619
+ * @returns {Promise<object>} Scenario module.
1620
+ * @throws {Error} When no scenario exists, or it does not implement the contract.
1621
+ * @memberof UnderpostEvent
1622
+ */
1623
+ async e2eScenario(eventId) {
1624
+ const path = nodePath.resolve(process.cwd(), EVENT_E2E.scenarioDirectory, `event-e2e-${eventId}.js`);
1625
+ if (!fs.existsSync(path)) throw new Error(`[event] no end-to-end scenario for '${eventId}' at ${path}`);
1626
+ const scenario = (await import(pathToFileURL(path).href)).default;
1627
+ for (const method of ['subjects', 'break', 'restore'])
1628
+ if (typeof scenario?.[method] !== 'function')
1629
+ throw new Error(`[event] scenario ${path} does not implement ${method}()`);
1630
+ return scenario;
1631
+ },
1632
+
1633
+ /**
1634
+ * @method e2eSubject
1635
+ * @description Rehearses one subject: break it, watch detection see it,
1636
+ * repair it, watch it come back, and confirm the notification was sent.
1637
+ * @param {object} params
1638
+ * @param {string} params.eventId - Registered event id.
1639
+ * @param {object} params.subject - Resolved scenario subject.
1640
+ * @param {object} params.scenario - Scenario module.
1641
+ * @param {object} params.context - Scenario context.
1642
+ * @param {object} params.interceptor - Installed mailer interceptor.
1643
+ * @returns {Promise<Array<object>>} Steps, in the order they ran.
1644
+ * @memberof UnderpostEvent
1645
+ */
1646
+ async e2eSubject({ eventId, subject, scenario, context, interceptor }) {
1647
+ const { namespace, options, controlTarget } = context;
1648
+ const steps = [];
1649
+ const step = (name, ok, detail) => {
1650
+ steps.push({ name, subject: subject.label, ok, detail });
1651
+ logger[ok ? 'info' : 'error'](`e2e ${name}`, { eventId, subject: subject.label, ok, detail });
1652
+ return ok;
1653
+ };
1654
+ const awaitProbes = (expect, timeoutMs) =>
1655
+ Underpost.monitor.awaitProbes({
1656
+ probes: subject.probes,
1657
+ expect,
1658
+ namespace,
1659
+ timeoutMs,
1660
+ intervalMs: EVENT_E2E.probeIntervalMs,
1661
+ target: { user: controlTarget.user, host: controlTarget.host },
1662
+ });
1663
+
1664
+ // What the probe reports is only evidence if it was answering to begin
1665
+ // with: a subject that is already down makes detection pass vacuously and
1666
+ // recovery impossible, and an unreadable exporter looks exactly the same.
1667
+ const baseline = await awaitProbes(true, EVENT_E2E.baselineTimeoutMs);
1668
+ if (!step('baseline', baseline.ok, probeDetail(baseline, 'answered before the fault was induced'))) return steps;
1669
+
1670
+ let recovered = false;
1671
+ try {
1672
+ const broken = await scenario.break(context, subject);
1673
+ if (!step('arrange', broken.ok === true, `${broken.output || broken.error || ''}`.trim().slice(-500)))
1674
+ return steps;
1675
+
1676
+ const detected = await awaitProbes(false, EVENT_E2E.detectTimeoutMs);
1677
+ if (!step('detect', detected.ok, probeDetail(detected, 'stopped answering'))) return steps;
1678
+
1679
+ const sent = interceptor.messages.length;
1680
+ const result = await Underpost.event.dispatch(eventId, { ...options, ...subject.dispatchOptions });
1681
+ step('act', result.ok === true, `${result.condition || result.error || ''}`);
1682
+
1683
+ const recovery = await awaitProbes(true, EVENT_E2E.recoverTimeoutMs);
1684
+ recovered = recovery.ok;
1685
+ step('recover', recovery.ok, probeDetail(recovery, 'answered again'));
1686
+
1687
+ // Only mail this dispatch produced counts: an earlier subject's
1688
+ // notification would otherwise satisfy every later subject's wait.
1689
+ const mail = await interceptor.waitFor(
1690
+ (message, index) => index >= sent && `${message.sendOptions.subject}`.includes(eventId),
1691
+ { timeoutMs: EVENT_E2E.notifyTimeoutMs },
1692
+ );
1693
+ step(
1694
+ 'notify',
1695
+ Boolean(mail?.accepted),
1696
+ mail
1697
+ ? `${mail.sendOptions.subject} -> ${mail.sendOptions.to}`
1698
+ : `no notification was sent for '${eventId}'; declared routes are in ${EVENT_CONF_PATH}`,
1699
+ );
1700
+ return steps;
1701
+ } finally {
1702
+ // The remediation is what should have restored the subject; this runs
1703
+ // only when it did not, so an aborted rehearsal cannot leave it down.
1704
+ if (!recovered) {
1705
+ const restored = await scenario.restore(context, subject);
1706
+ logger[restored.ok ? 'warn' : 'error']('e2e restore', {
1707
+ eventId,
1708
+ subject: subject.label,
1709
+ ok: restored.ok === true,
1710
+ });
1711
+ }
1712
+ }
1713
+ },
1714
+
1715
+ /**
1716
+ * @method e2e
1717
+ * @description Rehearses one event against the live edge, end to end.
1718
+ *
1719
+ * Breaks the real subject, waits for the Blackbox Exporter to actually stop
1720
+ * seeing it, runs the production remediation, waits for the subject to come
1721
+ * back, and confirms the notification left the transport. Every step is a
1722
+ * real component: a rehearsal that stubs detection or delivery proves only
1723
+ * that the stubs agree with each other.
1724
+ *
1725
+ * Every subject the event covers is rehearsed — each hub for a hub event,
1726
+ * each peer of this node's hub for a spoke event — one at a time, each
1727
+ * restored before the next is touched. `--nodes` narrows that selection.
1728
+ * @param {string} eventId - Registered event id.
1729
+ * @param {object} [options] - CLI options; `--nodes` / `--spoke` narrow the subjects.
1730
+ * @returns {Promise<{ok: boolean, steps: Array<object>}>} Step-by-step outcome.
1731
+ * @memberof UnderpostEvent
1732
+ */
1733
+ async e2e(eventId, options = {}) {
1734
+ if (options.dryRun)
1735
+ throw new Error('[event] --e2e-test runs the real remediation and is not compatible with --dry-run');
1736
+ if (options.notify === false)
1737
+ throw new Error('[event] --e2e-test verifies the notification and is not compatible with --no-notify');
1738
+
1739
+ const [definition] = Underpost.event.assertDispatchReady(Underpost.event.definitions(eventId));
1740
+ const scenario = await Underpost.event.e2eScenario(eventId);
1741
+ const context = {
1742
+ eventId,
1743
+ definition,
1744
+ options,
1745
+ namespace: options.namespace || 'default',
1746
+ controlTarget: Underpost.event.controlTarget(),
1747
+ };
1748
+ const subjects = await scenario.subjects(context);
1749
+ if (subjects.length === 0) throw new Error(`[event] '${eventId}' has no subject to rehearse`);
1750
+
1751
+ const interceptor = mailerInterceptorFactory({
1752
+ filter: ({ id }) => `${id}`.startsWith('event-notification:'),
1753
+ });
1754
+ const steps = [];
1755
+
1756
+ try {
1757
+ logger.info('Running end-to-end event rehearsal', {
1758
+ eventId,
1759
+ subjects: subjects.map((subject) => subject.label),
1760
+ probesReadVia: context.controlTarget.via,
1761
+ scenario: scenario.description,
1762
+ });
1763
+ for (const subject of subjects)
1764
+ steps.push(...(await Underpost.event.e2eSubject({ eventId, subject, scenario, context, interceptor })));
1765
+ return { ok: steps.every((entry) => entry.ok), steps };
1766
+ } finally {
1767
+ interceptor.close();
1768
+ }
1769
+ },
1770
+
1771
+ /**
1772
+ * @method webhookEventIds
1773
+ * @description Event ids a firing Alertmanager payload asks for.
1774
+ *
1775
+ * Resolved alerts are ignored: remediation is an action, and the condition
1776
+ * having cleared is not a reason to take it again.
1777
+ * @param {object} [payload] - Alertmanager webhook body.
1778
+ * @returns {string[]} Distinct registered event ids.
1779
+ * @memberof UnderpostEvent
1780
+ */
1781
+ webhookEventIds(payload = {}) {
1782
+ const alerts = Array.isArray(payload.alerts) ? payload.alerts : [];
1783
+ return [
1784
+ ...new Set(
1785
+ alerts
1786
+ .filter((alert) => (alert?.status || payload.status) === 'firing')
1787
+ .map((alert) => alert?.labels?.underpost_event)
1788
+ .filter((eventId) => eventId && EVENTS[eventId]),
1789
+ ),
1790
+ ];
1791
+ },
1792
+
1793
+ /**
1794
+ * @method serviceUnitFactory
1795
+ * @description Renders the dispatcher's systemd unit.
1796
+ *
1797
+ * The unit depends only on the host network so it survives the tunnel it repairs.
1798
+ * @param {object} [params]
1799
+ * @param {number} [params.port] - Listening port.
1800
+ * @param {string} [params.workingDirectory] - Engine checkout the unit runs from.
1801
+ * @param {string} [params.user] - Account the unit runs as.
1802
+ * @param {string} [params.execPath] - Node binary the unit executes; probed by the caller.
1803
+ * @returns {string} Rendered unit file.
1804
+ * @memberof UnderpostEvent
1805
+ */
1806
+ serviceUnitFactory({
1807
+ port = UNDERPOST_MONITORING.eventWebhook.port,
1808
+ workingDirectory = process.cwd(),
1809
+ user = os.userInfo().username,
1810
+ execPath = process.execPath,
1811
+ } = {}) {
1812
+ return systemdUnitFactory({
1813
+ header:
1814
+ '# Generated by `underpost event --service`. Do not edit by hand:\n' +
1815
+ '# the next run rewrites the file and restarts the service.',
1816
+ sections: {
1817
+ Unit: {
1818
+ Description: `Underpost operational event dispatcher on :${port}`,
1819
+ Documentation: 'https://www.nexodev.org/docs',
1820
+ After: 'network-online.target',
1821
+ Wants: 'network-online.target',
1822
+ StartLimitIntervalSec: 60,
1823
+ StartLimitBurst: 5,
1824
+ },
1825
+ Service: {
1826
+ Type: 'simple',
1827
+ User: user,
1828
+ WorkingDirectory: workingDirectory,
1829
+ Environment: `PATH=${nodePath.dirname(execPath)}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin`,
1830
+ ExecStart: `${execPath} ${workingDirectory}/bin event --serve --port ${port}`,
1831
+ Restart: 'on-failure',
1832
+ RestartSec: EVENT_SERVICE.restartSeconds,
1833
+ },
1834
+ Install: { WantedBy: 'multi-user.target' },
1835
+ },
1836
+ });
1837
+ },
1838
+
1839
+ /**
1840
+ * @method serviceState
1841
+ * @description Reads the unit's state once it has settled.
1842
+ *
1843
+ * `systemctl restart` returns as soon as the job is queued, so a state read
1844
+ * immediately after it can catch a short-lived `active` state before the
1845
+ * process exits. Requiring one full restart interval without a transition
1846
+ * distinguishes a running service from a restart loop.
1847
+ * @param {object} [params]
1848
+ * @param {number} [params.timeoutMs] - Maximum wait for the unit to settle.
1849
+ * @returns {{service: string, active: string, enabled: string, logs: string, settled: boolean}} Unit state.
1850
+ * @memberof UnderpostEvent
1851
+ */
1852
+ serviceState({ timeoutMs = EVENT_SERVICE.settleTimeoutMs } = {}) {
1853
+ const status = systemdStatusCommandsFactory(EVENT_SERVICE.name);
1854
+ const read = (command) =>
1855
+ `${shellExec(command, { stdout: true, silent: true, silentOnError: true, disableLog: true }) || ''}`.trim();
1856
+
1857
+ const stableWindowMs = (EVENT_SERVICE.restartSeconds + 1) * 1000;
1858
+ const deadline = Date.now() + Math.max(timeoutMs, stableWindowMs);
1859
+ let active = read(status.active);
1860
+ let activeSince = active === 'active' ? Date.now() : 0;
1861
+ while (Date.now() < deadline) {
1862
+ if (active === 'active' && Date.now() - activeSince >= stableWindowMs) break;
1863
+ sleepSync(EVENT_SERVICE.settleIntervalMs);
1864
+ active = read(status.active);
1865
+ if (active !== 'active') activeSince = 0;
1866
+ else if (!activeSince) activeSince = Date.now();
1867
+ }
1868
+ const settled = active === 'active' && Date.now() - activeSince >= stableWindowMs;
1869
+ return {
1870
+ service: EVENT_SERVICE.name,
1871
+ active,
1872
+ enabled: read(status.enabled),
1873
+ logs: status.logs,
1874
+ settled,
1875
+ };
1876
+ },
1877
+
1878
+ /** Returns the recent dispatcher journal without echoing the command. */
1879
+ serviceJournal(lines = 30) {
1880
+ return (
1881
+ shellExec(`${journalctlCommandFactory({ name: EVENT_SERVICE.name, lines })} --no-pager`, {
1882
+ stdout: true,
1883
+ silent: true,
1884
+ silentOnError: true,
1885
+ disableLog: true,
1886
+ }) || ''
1887
+ );
1888
+ },
1889
+
1890
+ /** Returns the port rendered into the installed dispatcher unit. */
1891
+ serviceInstalledPort() {
1892
+ if (!fs.existsSync(EVENT_SERVICE.unitPath)) return 0;
1893
+ return eventServicePortFactory(fs.readFileSync(EVENT_SERVICE.unitPath, 'utf8'));
1894
+ },
1895
+
1896
+ /**
1897
+ * @method serviceNodePath
1898
+ * @description The Node binary a unit can actually execute, probed rather
1899
+ * than assumed.
1900
+ *
1901
+ * systemd refuses to execute a binary under `/root` or `/home` on an SELinux
1902
+ * host, and the failure surfaces only as 203/EXEC in the journal. Probing
1903
+ * with a transient unit reproduces the constraint before one is installed.
1904
+ *
1905
+ * Each rejection carries its reason, because the three ways a candidate fails need three
1906
+ * different answers from whoever reads them: a binary that is not installed, one too old for
1907
+ * the checkout, and one systemd cannot reach or cannot run the entry script with.
1908
+ * @returns {{path: string, probed: boolean, rejected: Array<{candidate: string, reason: string}>}}
1909
+ * Chosen path, whether a probe confirmed it, and why each earlier candidate was passed over.
1910
+ * @memberof UnderpostEvent
1911
+ */
1912
+ serviceNodePath() {
1913
+ const read = (command) =>
1914
+ `${shellExec(command, { stdout: true, silent: true, silentOnError: true, disableLog: true }) || ''}`.trim();
1915
+ const ok = (command) =>
1916
+ shellExec(command, { silent: true, silentOnError: true, disableLog: true, stdout: false }).code === 0;
1917
+ const requiredMajor = Number(`${process.versions.node}`.split('.')[0]) || 0;
1918
+ const candidates = nodeCandidatesFactory();
1919
+ const rejected = [];
1920
+
1921
+ for (const path of candidates) {
1922
+ const major = Number(read(`${path} --version`).replace(/^v/, '').split('.')[0]) || 0;
1923
+ if (major < requiredMajor) {
1924
+ rejected.push({
1925
+ candidate: path,
1926
+ reason: major ? `runs Node v${major}, the engine needs v${requiredMajor}` : 'not present',
1927
+ });
1928
+ continue;
1929
+ }
1930
+ if (!ok(nodeProbeCommandFactory(path))) {
1931
+ rejected.push({
1932
+ candidate: path,
1933
+ reason: homeDirectoryPathFactory(path)
1934
+ ? 'systemd cannot execute it: it is under a home directory, whose SELinux label a unit cannot enter'
1935
+ : 'systemd cannot execute it',
1936
+ });
1937
+ continue;
1938
+ }
1939
+ if (
1940
+ !ok(
1941
+ scriptProbeCommandFactory({
1942
+ nodePath: path,
1943
+ scriptPath: process.argv[1],
1944
+ workingDirectory: process.cwd(),
1945
+ }),
1946
+ )
1947
+ ) {
1948
+ rejected.push({
1949
+ candidate: path,
1950
+ reason: homeDirectoryPathFactory(process.cwd())
1951
+ ? `it cannot run ${process.argv[1]}: the checkout is under a home directory, which a unit cannot read on an SELinux host`
1952
+ : `it cannot run ${process.argv[1]}`,
1953
+ });
1954
+ continue;
1955
+ }
1956
+ return { path, probed: true, rejected };
1957
+ }
1958
+ return { path: candidates[0] || process.execPath, probed: false, rejected };
1959
+ },
1960
+
1961
+ /**
1962
+ * @method service
1963
+ * @description Installs, reports or removes the supervised dispatcher.
1964
+ *
1965
+ * Convergent: the unit is rewritten only when it differs, and the service is
1966
+ * restarted only when it was. Re-running after a `git pull` is therefore a
1967
+ * no-op unless something about the unit actually changed.
1968
+ * @param {object} [options]
1969
+ * @param {boolean} [options.serviceStop] - Disable and remove the unit instead of installing it.
1970
+ * @param {boolean} [options.serviceStatus] - Report the unit's state and exit.
1971
+ * @param {number} [options.port] - Listening port baked into the unit.
1972
+ * @param {boolean} [options.dryRun] - Print what would be applied.
1973
+ * @returns {object} The unit's resolved state.
1974
+ * @memberof UnderpostEvent
1975
+ */
1976
+ service(options = {}) {
1977
+ const run = (commands) =>
1978
+ runSystemdCommands(commands, {
1979
+ dryRun: options.dryRun === true,
1980
+ execute: (command) => shellExec(command),
1981
+ onDryRun: (command) => logger.info(`[dry-run] ${command}`),
1982
+ });
1983
+
1984
+ if (options.serviceStatus) {
1985
+ const state = Underpost.event.serviceState();
1986
+ logger.info('Event dispatcher service', state);
1987
+ if (state.active !== 'active' || !state.settled) {
1988
+ console.log(Underpost.event.serviceJournal());
1989
+ process.exitCode = 1;
1990
+ }
1991
+ return state;
1992
+ }
1993
+
1994
+ const installedPort = Underpost.event.serviceInstalledPort();
1995
+
1996
+ if (options.serviceStop) {
1997
+ const port = installedPort || Number(options.port) || UNDERPOST_MONITORING.eventWebhook.port;
1998
+ run(eventFirewallCommandsFactory({ port, remove: true }));
1999
+ run(systemdServiceCommandsFactory({ name: EVENT_SERVICE.name, unitPath: EVENT_SERVICE.unitPath }).remove);
2000
+ logger.info('Event dispatcher service removed', { service: EVENT_SERVICE.name, port });
2001
+ return { service: EVENT_SERVICE.name, active: 'inactive', enabled: 'disabled' };
2002
+ }
2003
+
2004
+ assertRoleCapability({
2005
+ role: readEdgeContext().role,
2006
+ capability: 'event-service',
2007
+ operation: 'event --service (remove it elsewhere with --service-stop)',
2008
+ });
2009
+
2010
+ Underpost.event.assertDispatchReady();
2011
+
2012
+ const node = Underpost.event.serviceNodePath();
2013
+ if (!node.probed) {
2014
+ logger.error('No Node binary the dispatcher service can execute', {
2015
+ requires: `Node v${`${process.versions.node}`.split('.')[0]}`,
2016
+ rejected: node.rejected,
2017
+ fix: 'install Node system-wide, then re-run: curl -fsSL https://rpm.nodesource.com/setup_24.x | sudo bash - && sudo dnf install -y nodejs',
2018
+ });
2019
+ throw new Error(
2020
+ `[event] no Node executable can run ${process.argv[1]} from ${process.cwd()} under systemd; ` +
2021
+ 'install Node system-wide or move the checkout outside /root and /home',
2022
+ );
2023
+ }
2024
+ const port = Number(options.port) || UNDERPOST_MONITORING.eventWebhook.port;
2025
+ const unit = Underpost.event.serviceUnitFactory({
2026
+ port,
2027
+ execPath: node.path,
2028
+ });
2029
+ const changed = installRootFile({
2030
+ target: EVENT_SERVICE.unitPath,
2031
+ content: unit,
2032
+ mode: '0644',
2033
+ dryRun: options.dryRun === true,
2034
+ });
2035
+ if (installedPort && installedPort !== port)
2036
+ run(eventFirewallCommandsFactory({ port: installedPort, remove: true }));
2037
+ run(eventFirewallCommandsFactory({ port }));
2038
+ run([`sudo systemctl reset-failed ${EVENT_SERVICE.name} 2>/dev/null || true`]);
2039
+ run(
2040
+ systemdServiceCommandsFactory({ changed, name: EVENT_SERVICE.name, unitPath: EVENT_SERVICE.unitPath }).ensure,
2041
+ );
2042
+ if (options.dryRun === true) return { service: EVENT_SERVICE.name, active: 'dry-run', enabled: 'dry-run' };
2043
+
2044
+ const state = Underpost.event.serviceState();
2045
+ if (state.active === 'active' && state.settled) {
2046
+ logger.info('Event dispatcher service reconciled', { ...state, changed, node: node.path });
2047
+ return state;
2048
+ }
2049
+
2050
+ const journal = Underpost.event.serviceJournal();
2051
+ const addressInUse = journal.includes('EADDRINUSE');
2052
+ logger.error('Event dispatcher did not start', {
2053
+ ...state,
2054
+ node: node.path,
2055
+ workingDirectory: process.cwd(),
2056
+ likely: addressInUse
2057
+ ? `port ${port} is owned by another process; inspect it with sudo lsof -nP -iTCP:${port} -sTCP:LISTEN`
2058
+ : homeDirectoryPathFactory(process.cwd())
2059
+ ? 'the checkout is under a home directory, which a unit cannot read on an SELinux host'
2060
+ : 'the unit failed; see the journal below',
2061
+ check: `sudo ${scriptProbeCommandFactory({ nodePath: node.path, scriptPath: process.argv[1], workingDirectory: process.cwd() }).replace(/^sudo /, '')}`,
2062
+ });
2063
+ console.log(journal);
2064
+ process.exitCode = 1;
2065
+ return state;
2066
+ },
2067
+
2068
+ /**
2069
+ * @method serve
2070
+ * @description Runs the Alertmanager webhook receiver.
2071
+ *
2072
+ * Answers before dispatching. Remediation reboots a tunnel and can take
2073
+ * minutes, while Alertmanager retries any delivery it does not see accepted
2074
+ * — holding the response open would have it re-fire the same repair.
2075
+ * A cooldown per event subject covers repeated groups.
2076
+ * @param {object} [options]
2077
+ * @param {number} [options.port] - Listening port.
2078
+ * @param {string} [options.token] - Required bearer token; defaults to the one the stack was provisioned with.
2079
+ * @param {number} [options.cooldownMs] - Minimum interval between two dispatches of one event.
2080
+ * @returns {Promise<import('node:http').Server>} The listening server.
2081
+ * @memberof UnderpostEvent
2082
+ */
2083
+ async serve(options = {}) {
2084
+ // An explicit port 0 asks the OS for an ephemeral one, so it must not fall
2085
+ // through to the default the way an absent option does.
2086
+ const requestedPort = Number(options.port);
2087
+ const port =
2088
+ options.port === undefined || options.port === null || options.port === '' || !Number.isInteger(requestedPort)
2089
+ ? UNDERPOST_MONITORING.eventWebhook.port
2090
+ : requestedPort;
2091
+ const token = options.token || Underpost.monitor.eventWebhookTokenFactory();
2092
+ const cooldownMs = Number(options.cooldownMs) || 5 * 60 * 1000;
2093
+ const lastDispatch = new Map();
2094
+
2095
+ const server = http.createServer((req, res) => {
2096
+ const reply = (code, body) => {
2097
+ res.writeHead(code, { 'Content-Type': 'application/json' });
2098
+ res.end(JSON.stringify(body));
2099
+ };
2100
+ if (req.method !== 'POST' || req.url.split('?')[0] !== UNDERPOST_MONITORING.eventWebhook.path)
2101
+ return reply(404, { error: 'not found' });
2102
+ if (req.headers.authorization !== `Bearer ${token}`) return reply(401, { error: 'unauthorized' });
2103
+
2104
+ let body = '';
2105
+ req.on('data', (chunk) => {
2106
+ body += chunk;
2107
+ // An Alertmanager group is small; anything larger is not one.
2108
+ if (body.length > 1e6) req.destroy();
2109
+ });
2110
+ req.on('end', () => {
2111
+ let payload;
2112
+ try {
2113
+ payload = JSON.parse(body || '{}');
2114
+ } catch (_) {
2115
+ return reply(400, { error: 'invalid payload' });
2116
+ }
2117
+ const eventIds = Underpost.event.webhookEventIds(payload);
2118
+ reply(202, { accepted: eventIds });
2119
+ logger.info('Event webhook accepted', { events: eventIds, alerts: payload.alerts?.length || 0 });
2120
+
2121
+ for (const eventId of eventIds) {
2122
+ const alerts = (payload.alerts || []).filter((alert) => alert?.labels?.underpost_event === eventId);
2123
+ const cooldownKey = eventCooldownKeyFactory(eventId, alerts);
2124
+ const now = Date.now();
2125
+ if (now - (lastDispatch.get(cooldownKey) || 0) < cooldownMs) {
2126
+ logger.warn('Event dispatch skipped: still within cooldown', { eventId, cooldownKey, cooldownMs });
2127
+ continue;
2128
+ }
2129
+ lastDispatch.set(cooldownKey, now);
2130
+ Underpost.event
2131
+ .dispatch(eventId, options, alerts)
2132
+ .catch((error) => logger.error('Event dispatch rejected', { eventId, error: `${error}` }));
2133
+ }
2134
+ });
2135
+ });
2136
+
2137
+ return new Promise((resolve, reject) => {
2138
+ server.once('error', (error) => {
2139
+ logger.error('Event webhook cannot bind', {
2140
+ port,
2141
+ code: error?.code,
2142
+ hint:
2143
+ error?.code === 'EADDRINUSE'
2144
+ ? `another process owns this port; inspect it with sudo lsof -nP -iTCP:${port} -sTCP:LISTEN`
2145
+ : undefined,
2146
+ });
2147
+ reject(error);
2148
+ });
2149
+ server.listen(port, () => {
2150
+ logger.info('Event webhook listening', {
2151
+ url: `http://0.0.0.0:${port}${UNDERPOST_MONITORING.eventWebhook.path}`,
2152
+ events: Object.keys(EVENTS),
2153
+ });
2154
+ resolve(server);
2155
+ });
2156
+ });
2157
+ },
2158
+
2159
+ /**
2160
+ * @method callback
2161
+ * @description CLI entrypoint for `underpost event`.
2162
+ * @param {string} [eventId] - Registered event id; optional for `--list`, `--serve` and `--deploy`.
2163
+ * @param {object} [options] - CLI options.
2164
+ * @param {boolean} [options.deploy=false] - Merge the event into the cluster's deployed set and publish.
2165
+ * @param {boolean} [options.undeploy=false] - Remove the event from the cluster's deployed set and publish.
2166
+ * @param {string} [options.suspendEvents] - Save and temporarily undeploy every event for planned maintenance.
2167
+ * @param {string} [options.resumeEvents] - Restore the exact event set from planned-maintenance state.
2168
+ * @param {boolean} [options.serve=false] - Run the Alertmanager webhook receiver in the foreground.
2169
+ * @param {boolean} [options.service=false] - Install and start the receiver as a supervised systemd unit.
2170
+ * @param {boolean} [options.serviceStop=false] - Stop, disable and remove that unit.
2171
+ * @param {boolean} [options.serviceStatus=false] - Report the unit's state.
2172
+ * @param {boolean} [options.list=false] - List the registered events.
2173
+ * @param {boolean} [options.dryRun=false] - Report the remediation instead of running it.
2174
+ * @param {boolean} [options.e2eTest=false] - Rehearse the event against the live edge, including its notification.
2175
+ * @param {boolean} [options.notify=true] - Mail the outcome to the administrator.
2176
+ * @param {string} [options.spoke=''] - Spoke id to remediate when dispatching by hand.
2177
+ * @param {string} [options.nodes=''] - Node document naming the single hub or spoke to act on.
2178
+ * @param {string} [options.port=''] - Webhook receiver port.
2179
+ * @param {string} [options.namespace='default'] - Namespace holding the monitoring stack.
2180
+ * @param {string} [options.webhookUrl=''] - URL Alertmanager delivers to.
2181
+ * @returns {Promise<any>} Command result.
2182
+ * @memberof UnderpostEvent
2183
+ */
2184
+ async callback(eventId = '', options = {}) {
2185
+ loadCronDeployEnv();
2186
+
2187
+ if (options.suspendEvents) return await Underpost.event.suspendEvents(options.suspendEvents, options);
2188
+ if (options.resumeEvents) return await Underpost.event.resumeEvents(options.resumeEvents, options);
2189
+
2190
+ if (options.list) {
2191
+ const rows = Underpost.event.deploymentStatus(options);
2192
+ const status = Object.fromEntries(rows.map((entry) => [entry.id, entry.status]));
2193
+ const unreadable = rows.find((entry) => entry.status === 'UNKNOWN');
2194
+ if (unreadable)
2195
+ console.log(
2196
+ `\n${'the cluster did not report its deployed events; the state column below is unknown, not empty'.yellow}` +
2197
+ `${unreadable.reason ? `\n${` reason: ${unreadable.reason}`.yellow}` : ''}`,
2198
+ );
2199
+ for (const [id, state] of Object.entries(status).filter(([, state]) => state === 'OUT_OF_SYNC'))
2200
+ console.log(
2201
+ `\n${id.bold.red} [${state}] — running in the cluster but no longer declared; withdraw it with --undeploy`,
2202
+ );
2203
+ for (const definition of Underpost.event.definitions(eventId)) {
2204
+ const state = status[definition.id] || 'UNKNOWN';
2205
+ console.log(
2206
+ `\n${definition.id.bold.green} [${definition.role}] [${state === 'DEPLOYED' ? state.green : state.yellow}] — ${definition.description}`,
2207
+ );
2208
+ console.log(
2209
+ ` every probe ${definition.schedule.probeInterval}, alert after ${definition.schedule.alertFor}`,
2210
+ );
2211
+ console.log(` alert ${definition.alert.name} ${definition.alert.expr} for ${definition.alert.for}`);
2212
+ for (const probe of definition.probes)
2213
+ console.log(` probe ${probe.module.padEnd(12)} ${probe.targets.join(', ')}`);
2214
+ for (const target of definition.remediation)
2215
+ console.log(
2216
+ ` repair ${`${target.spokeId || target.role}`.padEnd(12)} ${target.address} via ${target.via}`,
2217
+ );
2218
+ for (const target of definition.remediation.filter((entry) => entry.error))
2219
+ console.log(` ${`reason: ${target.error}`.yellow}`);
2220
+ for (const route of definition.notifications)
2221
+ console.log(
2222
+ route.error
2223
+ ? ` notify ${'unresolved'.padEnd(12)} ${route.error.yellow}`
2224
+ : ` notify ${route.providerId.padEnd(12)} ${route.target} -> ${route.recipients
2225
+ .map((recipient) => recipient.email)
2226
+ .join(', ')}`,
2227
+ );
2228
+ if (definition.probes.length === 0) console.log(` ${'no resolvable targets'.yellow}`);
2229
+ }
2230
+ return;
2231
+ }
2232
+
2233
+ // Provisioning is the monitor CLI's concern: the same render/apply/reload
2234
+ // path serves `monitor --observability`, so an event's rules land through
2235
+ // one implementation rather than two that can disagree. The set is
2236
+ // resolved against the cluster first, because the ConfigMaps are rendered
2237
+ // whole and publishing one event must not withdraw the rest.
2238
+ if (options.deploy || options.undeploy) {
2239
+ if (!eventId)
2240
+ throw new Error(`[event] an event id is required for --${options.undeploy ? 'undeploy' : 'deploy'}`);
2241
+ const events = Underpost.event.deploySelection(eventId, options, options.undeploy === true);
2242
+ logger.info(options.undeploy ? 'Withdrawing event' : 'Publishing event', { eventId, events });
2243
+ return await Underpost.monitor.syncObservability({ ...options, events });
2244
+ }
2245
+
2246
+ if (options.service || options.serviceStop || options.serviceStatus) return Underpost.event.service(options);
2247
+
2248
+ if (options.serve) return await Underpost.event.serve(options);
2249
+
2250
+ if (!eventId) throw new Error(`[event] an event id is required; one of: ${Object.keys(EVENTS).join(', ')}`);
2251
+
2252
+ if (options.e2eTest) {
2253
+ const rehearsal = await Underpost.event.e2e(eventId, options);
2254
+ for (const entry of rehearsal.steps)
2255
+ console.log(
2256
+ ` ${entry.ok ? 'ok '.green : 'FAIL'.red} ${entry.name.padEnd(9)} ${entry.subject.padEnd(28)} ${entry.detail || ''}`,
2257
+ );
2258
+ if (!rehearsal.ok) process.exit(1);
2259
+ return rehearsal;
2260
+ }
2261
+
2262
+ const result = await Underpost.event.dispatch(eventId, options);
2263
+ if (!result.ok) process.exit(1);
2264
+ return result;
2265
+ },
2266
+ };
2267
+ }
2268
+
2269
+ export {
2270
+ ENGINE_REMOTE_PATH,
2271
+ EVENTS,
2272
+ EVENT_E2E,
2273
+ EVENT_SUSPENSION_VERSION,
2274
+ assertHubManagementConnection,
2275
+ eventCooldownKeyFactory,
2276
+ eventFirewallCommandsFactory,
2277
+ eventServicePortFactory,
2278
+ probeDetail,
2279
+ };
2280
+
2281
+ export default UnderpostEvent;