@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
@@ -4,30 +4,64 @@
4
4
  * @namespace UnderpostMonitor
5
5
  */
6
6
 
7
+ import { loadReplicas, loadConfServerJson, etcHostFactory, clusterTypeFactory } from '../server/runtime/conf.js';
7
8
  import {
8
- loadReplicas,
9
- pathPortAssignmentFactory,
10
- loadConfServerJson,
11
- loadCronDeployEnv,
12
- etcHostFactory,
13
9
  deployRangePortFactory,
14
- } from '../server/conf.js';
15
- import { loggerFactory } from '../server/logger.js';
16
- import { timer } from '../client/components/core/CommonJs.js';
10
+ deployRoutesExists,
11
+ pathPortAssignmentFactory,
12
+ readDeployRoutes,
13
+ resolveDeployList,
14
+ } from '../server/network/router.js';
15
+ import { cronDeployIdResolve, loadCronDeployEnv } from '../server/ops/cron.js';
16
+ import { loggerFactory } from '../server/ops/logger.js';
17
+ import { timer, generateRandomPasswordSelection } from '../client/components/core/CommonJs.js';
17
18
  import {
18
19
  RUNTIME_STATUS,
19
20
  INTERNAL_STATUS_PATH,
21
+ INTERNAL_TELEMETRY_PATH,
20
22
  normalizeContainerStatus,
21
23
  deployStatusPort,
22
- } from '../server/runtime-status.js';
24
+ } from '../server/runtime/runtime-status.js';
25
+ import {
26
+ UNDERPOST_MONITORING,
27
+ alertRulesFactory,
28
+ alertmanagerConfFactory,
29
+ appScrapeEntriesFactory,
30
+ blackboxConfFactory,
31
+ deployedEventIdsFactory,
32
+ grafanaAdminSecretFactory,
33
+ grafanaDeploymentFactory,
34
+ grafanaDeploymentPatchFactory,
35
+ grafanaExposureFactory,
36
+ grafanaNodePortFactory,
37
+ grafanaResetPlanFactory,
38
+ grafanaStorageResetRequiredFactory,
39
+ monitoringConfigFactory,
40
+ prometheusConfFactory,
41
+ scrapeDeployListFactory,
42
+ webhookSecretFactory,
43
+ } from '../server/ops/monitoring.js';
23
44
  import axios from 'axios';
45
+ import crypto from 'node:crypto';
46
+ import os from 'node:os';
24
47
  import fs from 'fs-extra';
25
48
  import net from 'node:net';
26
- import { shellExec } from '../server/process.js';
49
+ import { shellArgumentFactory, shellExec } from '../server/runtime/process.js';
50
+ import { cli } from '../server/build/execution.js';
51
+ import { domainContextFactory } from './domains.js';
27
52
  import Underpost from '../index.js';
28
53
 
29
54
  const logger = loggerFactory(import.meta);
30
55
 
56
+ // Phase-2 status read, executed by whatever underpost the pod's image ships. Container status
57
+ // belongs to the state domain and to nothing else: it is per-container and resets with the
58
+ // container, so it is never read from the host configuration store, which is node-scoped and
59
+ // survives. An image too old to carry `state` answers nothing here, which the caller reports as
60
+ // an unreadable pod rather than as a status — a wrong status is worse than a missing one.
61
+ const CONTAINER_STATUS_READ = `'underpost state get container-status --plain 2>/dev/null || true'`;
62
+
63
+ const grafanaAdminSyncState = new WeakMap();
64
+
31
65
  /**
32
66
  * @class UnderpostMonitor
33
67
  * @description Manages deployment monitoring and health checks.
@@ -59,6 +93,19 @@ class UnderpostMonitor {
59
93
  * @param {string} [options.retryPerTryTimeout=''] - Timeout per retry attempt.
60
94
  * @param {boolean} [options.promote=false] - Promote the deployment after monitoring.
61
95
  * @param {boolean} [options.readyDeployment=false] - Monitor until the deployment is ready.
96
+ * @param {boolean} [options.observability=false] - Deploy or converge the Prometheus/Alertmanager/Blackbox/Grafana stack.
97
+ * @param {boolean} [options.syncProm=false] - Regenerate the stack's configuration and reload it in place.
98
+ * @param {boolean} [options.metricsServer=false] - Install the Kubernetes metrics-server.
99
+ * @param {boolean} [options.cockpit=false] - Install and enable the Cockpit KVM dashboard on this host.
100
+ * @param {boolean} [options.cockpitStop=false] - Stop and disable the Cockpit KVM dashboard.
101
+ * @param {boolean} [options.exposeGrafana=false] - Republish Grafana without touching the rest of the stack.
102
+ * @param {string} [options.grafanaHost=''] - Hostname to publish Grafana under, at `/grafana`.
103
+ * @param {boolean} [options.nodePort=false] - Publish Grafana on the node's LAN address.
104
+ * @param {boolean} [options.webhookToken=false] - Print the shared event webhook token and exit.
105
+ * @param {string} [options.events=''] - Comma-separated event ids to provision; empty selects every registered event.
106
+ * @param {string} [options.webhookUrl=''] - URL Alertmanager delivers events to.
107
+ * @param {string} [options.extraTargets=''] - Comma-separated additional `host:port` scrape targets.
108
+ * @param {string} [options.nodeName=''] - Strict workload node; moving Grafana resets its local data.
62
109
  * @param {string} [options.versions=''] - Specific version of the deployment to monitor.
63
110
  * @param {object} [commanderOptions] - Options passed from the command line interface.
64
111
  * @param {object} [auxRouter] - Optional router configuration for the deployment.
@@ -82,6 +129,19 @@ class UnderpostMonitor {
82
129
  promote: false,
83
130
  readyDeployment: false,
84
131
  versions: '',
132
+ observability: false,
133
+ syncProm: false,
134
+ metricsServer: false,
135
+ cockpit: false,
136
+ cockpitStop: false,
137
+ exposeGrafana: false,
138
+ grafanaHost: '',
139
+ nodePort: false,
140
+ webhookToken: false,
141
+ events: '',
142
+ webhookUrl: '',
143
+ extraTargets: '',
144
+ nodeName: '',
85
145
  },
86
146
  commanderOptions,
87
147
  auxRouter,
@@ -89,10 +149,20 @@ class UnderpostMonitor {
89
149
  loadCronDeployEnv();
90
150
  if (!options.namespace) options.namespace = 'default';
91
151
  if (!options.replicas) options.replicas = '1';
92
- if (deployId === 'dd' && fs.existsSync(`./engine-private/deploy/dd.router`)) {
93
- for (const _deployId of fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').split(','))
152
+
153
+ // Host- and cluster-scoped operations: none of them act on one deploy, so
154
+ // they resolve before the per-deploy health loop rather than inside it.
155
+ // `deploy-id` only selects which deploys the stack scrapes.
156
+ if (options.webhookToken) return console.log(Underpost.monitor.eventWebhookTokenFactory());
157
+ if (options.cockpit || options.cockpitStop) return Underpost.monitor.cockpit({ stop: !!options.cockpitStop });
158
+ if (options.exposeGrafana) return Underpost.monitor.exposeGrafana(options);
159
+ if (options.metricsServer) return await Underpost.monitor.deployMetricsServer(options);
160
+ if (options.observability) return await Underpost.monitor.deployObservability({ ...options, deployId });
161
+ if (options.syncProm) return await Underpost.monitor.syncObservability({ ...options, deployId });
162
+ if (deployId === 'dd' && deployRoutesExists()) {
163
+ for (const _deployId of readDeployRoutes())
94
164
  Underpost.monitor.callback(
95
- _deployId.trim(),
165
+ _deployId,
96
166
  env,
97
167
  options,
98
168
  commanderOptions,
@@ -124,9 +194,9 @@ class UnderpostMonitor {
124
194
  let errorPayloads = [];
125
195
  if (options.sync === true) {
126
196
  const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace, env });
127
- if (currentTraffic) Underpost.env.set(`${deployId}-${env}-traffic`, currentTraffic);
197
+ if (currentTraffic) Underpost.host.store.set(`${deployId}-${env}-traffic`, currentTraffic);
128
198
  }
129
- let traffic = Underpost.env.get(`${deployId}-${env}-traffic`) ?? 'blue';
199
+ let traffic = Underpost.host.store.get(`${deployId}-${env}-traffic`) ?? 'blue';
130
200
  const maxAttempts = parseInt(
131
201
  Object.keys(pathPortAssignmentData)
132
202
  .map((host) => pathPortAssignmentData[host].length)
@@ -187,7 +257,7 @@ class UnderpostMonitor {
187
257
  const confServer = loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`);
188
258
 
189
259
  const namespace = options.namespace;
190
- Underpost.deploy.configMap(env, namespace);
260
+ Underpost.host.apply(domainContextFactory({ env, namespace }));
191
261
 
192
262
  for (const host of Object.keys(confServer)) {
193
263
  shellExec(`sudo kubectl delete HTTPProxy ${host} -n ${namespace} --ignore-not-found`);
@@ -218,7 +288,7 @@ class UnderpostMonitor {
218
288
  const { renderHosts } = etcHostFactory([]);
219
289
  logger.info('renderHosts', renderHosts);
220
290
  }
221
- const envMsTimeout = Underpost.env.get(`${deployId}-${env}-monitor-ms`);
291
+ const envMsTimeout = Underpost.host.store.get(`${deployId}-${env}-monitor-ms`);
222
292
  setTimeout(
223
293
  async () => {
224
294
  const isOnline = await Underpost.dns.isInternetConnection();
@@ -256,7 +326,7 @@ class UnderpostMonitor {
256
326
  }
257
327
  }
258
328
  const monitorKey = `${deployId}-${env}-monitor-input`;
259
- const monitorValue = Underpost.env.get(monitorKey);
329
+ const monitorValue = Underpost.host.store.get(monitorKey);
260
330
  switch (monitorValue) {
261
331
  case 'pause':
262
332
  monitorCallBack(resolve, reject);
@@ -264,7 +334,7 @@ class UnderpostMonitor {
264
334
  case 'restart':
265
335
  case 'stop':
266
336
  case 'blue-green-switch':
267
- Underpost.env.delete(monitorKey);
337
+ Underpost.host.store.delete(monitorKey);
268
338
  case 'restart':
269
339
  return reject();
270
340
  case 'stop':
@@ -281,6 +351,1101 @@ class UnderpostMonitor {
281
351
  };
282
352
  return new Promise((...args) => monitorCallBack(...args));
283
353
  },
354
+ /**
355
+ * @method observabilityDeployListFactory
356
+ * @description The deploys the stack scrapes.
357
+ *
358
+ * Reads the two router files and hands the selection to
359
+ * {@link UnderpostMonitoring.scrapeDeployListFactory}, which owns the rule:
360
+ * the default set is `dd.cron` plus `dd.routes`, exactly what
361
+ * `loadCronDeployEnv()` loads.
362
+ * @param {string} [deployId] - Deploy id, comma-separated list, or `dd`/empty for the default set.
363
+ * @returns {string[]} Distinct deploy ids.
364
+ * @memberof UnderpostMonitor
365
+ */
366
+ observabilityDeployListFactory(deployId = '') {
367
+ return scrapeDeployListFactory({
368
+ deployId,
369
+ cronDeployId: cronDeployIdResolve(),
370
+ routerDeployIds: resolveDeployList('dd'),
371
+ });
372
+ },
373
+
374
+ /**
375
+ * @method observabilityContextFactory
376
+ * @description Resolves everything the observability stack is rendered from:
377
+ * the Express runtimes to scrape, the events to alert on, and the address
378
+ * Alertmanager delivers to.
379
+ *
380
+ * Scrape targets come from the same `conf.server.json` the runtime binds and
381
+ * probe targets from the event registry, so a host or an event is either
382
+ * deployed and observed or neither. Nothing here is stored — a re-run
383
+ * reflects the configuration as it is now.
384
+ * @param {object} [options]
385
+ * @param {string} [options.deployId='dd'] - Deploy id, comma-separated list, or `dd` for the whole router.
386
+ * @param {string} [options.events=''] - Comma-separated event ids; empty selects every registered event.
387
+ * @param {string} [options.namespace='default'] - Namespace holding the stack.
388
+ * @param {string} [options.webhookUrl=''] - Explicit dispatcher URL; resolved from the node otherwise.
389
+ * @param {string} [options.extraTargets=''] - Comma-separated additional `host:port` scrape targets.
390
+ * @param {string} [options.hosts=''] - Comma-separated hostnames scraped at `/metrics` alongside the configured ones.
391
+ * @param {boolean} [options.dev=false] - Development environment (scrapes over HTTP).
392
+ * @returns {object} Render context.
393
+ * @memberof UnderpostMonitor
394
+ */
395
+ observabilityContextFactory(options = {}) {
396
+ const namespace = options.namespace || 'default';
397
+ const deployIds = Underpost.monitor.observabilityDeployListFactory(options.deployId);
398
+ const scheme = options.dev ? 'http' : 'https';
399
+
400
+ const appTargets = [];
401
+ for (const deployId of deployIds) {
402
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
403
+ if (!fs.existsSync(confServerPath)) {
404
+ logger.warn('Deploy has no server configuration; skipping its scrape targets', { deployId });
405
+ continue;
406
+ }
407
+ appTargets.push(...appScrapeEntriesFactory(loadConfServerJson(confServerPath), { scheme }));
408
+ }
409
+
410
+ for (const host of `${options.hosts || ''}`
411
+ .split(',')
412
+ .map((entry) => entry.trim())
413
+ .filter(Boolean))
414
+ appTargets.push({ host, metricsPath: '/metrics', scheme });
415
+
416
+ const events = Underpost.event.assertDispatchReady(Underpost.event.definitions(options.events || ''));
417
+ const probes = events.flatMap((event) => event.probes);
418
+ const extraTargets = `${options.extraTargets || ''}`
419
+ .split(',')
420
+ .map((target) => target.trim())
421
+ .filter(Boolean);
422
+
423
+ return {
424
+ namespace,
425
+ deployIds,
426
+ appTargets,
427
+ extraTargets,
428
+ events,
429
+ probes,
430
+ token: options.token || Underpost.monitor.eventWebhookTokenFactory(),
431
+ webhookUrl: options.webhookUrl || Underpost.monitor.eventWebhookUrlFactory(options),
432
+ };
433
+ },
434
+
435
+ /**
436
+ * @method eventWebhookTokenFactory
437
+ * @description The bearer token Alertmanager presents to the event
438
+ * dispatcher, minted on first use.
439
+ *
440
+ * The webhook triggers root-equivalent remediation on the edge from an
441
+ * unauthenticated POST, so the stack is never provisioned without one.
442
+ *
443
+ * Two machines have to agree on it: the cluster provisions the Alertmanager
444
+ * Secret, and the hub runs the receiver that validates what Alertmanager
445
+ * sends. So the deploy environment wins over the machine-local host store
446
+ * store — `engine-private` is shared, and a token written there reaches both
447
+ * sides on the next pull. A token minted here is machine-local by
448
+ * definition, which is why minting says so.
449
+ * @returns {string} Shared bearer token.
450
+ * @memberof UnderpostMonitor
451
+ */
452
+ eventWebhookTokenFactory() {
453
+ const shared =
454
+ process.env.UNDERPOST_EVENT_TOKEN ||
455
+ Underpost.host.store.get('UNDERPOST_EVENT_TOKEN', undefined, { disableLog: true });
456
+ if (shared) return shared;
457
+ const token = generateRandomPasswordSelection(48);
458
+ Underpost.host.store.set('UNDERPOST_EVENT_TOKEN', token);
459
+ logger.warn('Minted a machine-local event webhook token', {
460
+ hint: 'persist it as UNDERPOST_EVENT_TOKEN in the cron deploy env so the hub receiver shares it',
461
+ read: 'node bin monitor --webhook-token',
462
+ });
463
+ return token;
464
+ },
465
+
466
+ /**
467
+ * @method eventWebhookUrlFactory
468
+ * @description Address the cluster reaches the event dispatcher at.
469
+ *
470
+ * Never a Service: the dispatcher runs on a host, because remediation needs
471
+ * the engine checkout and the SSH key store and neither exists in a pod.
472
+ * Which host, and therefore which address, follows the topology:
473
+ *
474
+ * The dispatcher belongs to the control plane. On kubeadm and K3s the
475
+ * kubelet runs on that machine, so the node's
476
+ * InternalIP is the host. On Kind each node is a Docker container on its
477
+ * own bridge, so the node InternalIP is the container and the bridge
478
+ * gateway is the host.
479
+ * @param {object} [options] - Cluster options (`kind`, `kubeadm`, `k3s`, `dev`).
480
+ * @returns {string} Webhook URL.
481
+ * @memberof UnderpostMonitor
482
+ */
483
+ eventWebhookUrlFactory(options = {}) {
484
+ const { port, path } = UNDERPOST_MONITORING.eventWebhook;
485
+ const read = (command) =>
486
+ `${shellExec(command, { stdout: true, silent: true, silentOnError: true, disableLog: true }) || ''}`
487
+ .trim()
488
+ .split(/\s+/)[0];
489
+
490
+ const host =
491
+ clusterTypeFactory(options, options.dev ? 'kind' : 'kubeadm') === 'kind'
492
+ ? read(`docker network inspect kind -f '{{range .IPAM.Config}}{{.Gateway}} {{end}}'`)
493
+ : Underpost.monitor.nodeInternalIp(options.nodeName);
494
+
495
+ if (!host)
496
+ logger.warn('Host address for the event webhook is unresolved; pass --webhook-url to set it explicitly');
497
+ return `http://${host || '127.0.0.1'}:${port}${path}`;
498
+ },
499
+
500
+ /** Reads a live Grafana admin Secret without emitting its data. */
501
+ grafanaAdminSecretState(namespace = 'default') {
502
+ const { adminSecretName, adminUserKey, adminPasswordKey } = UNDERPOST_MONITORING.grafana;
503
+ const source = shellExec(`kubectl get secret ${adminSecretName} -n ${namespace} --ignore-not-found -o json`, {
504
+ stdout: true,
505
+ silent: true,
506
+ silentOnError: true,
507
+ disableLog: true,
508
+ });
509
+ if (!`${source || ''}`.trim()) return null;
510
+ const secret = JSON.parse(source);
511
+ const decode = (key) => Buffer.from(`${secret.data?.[key] || ''}`, 'base64').toString('utf8');
512
+ return {
513
+ username: decode(adminUserKey),
514
+ password: decode(adminPasswordKey),
515
+ resourceVersion: `${secret.metadata?.resourceVersion || ''}`,
516
+ };
517
+ },
518
+
519
+ /** Resolves and applies the dedicated Grafana admin Secret. */
520
+ syncGrafanaAdminSecret(options = {}) {
521
+ const namespace = options.namespace || 'default';
522
+ const { grafana } = UNDERPOST_MONITORING;
523
+ const envCredentials = Underpost.host.grafanaAdmin({ ...options, required: false });
524
+ const stored = Underpost.secret.has(grafana.adminSecretName, namespace);
525
+ const data = stored
526
+ ? Underpost.secret.readData(grafana.adminSecretName, namespace)
527
+ : {
528
+ [grafana.adminUserKey]: envCredentials.username,
529
+ [grafana.adminPasswordKey]: envCredentials.password,
530
+ };
531
+ const desired = {
532
+ username: `${data[grafana.adminUserKey] || ''}`.trim(),
533
+ password: `${data[grafana.adminPasswordKey] || ''}`,
534
+ email: envCredentials.email,
535
+ };
536
+ if (!desired.username || !desired.password)
537
+ throw new Error(
538
+ `[grafana] ${stored ? grafana.adminSecretName : envCredentials.envPath} must define ` +
539
+ `${grafana.adminUserKey} and ${grafana.adminPasswordKey}`,
540
+ );
541
+
542
+ const previous = Underpost.monitor.grafanaAdminSecretState(namespace);
543
+ const deploymentSource = shellExec(`kubectl get deployment ${grafana.name} -n ${namespace} -o json`, {
544
+ stdout: true,
545
+ silent: true,
546
+ silentOnError: true,
547
+ disableLog: true,
548
+ });
549
+ const deployment = `${deploymentSource || ''}`.trim() ? JSON.parse(deploymentSource) : null;
550
+ const deploymentContainer = deployment?.spec?.template?.spec?.containers?.find(
551
+ ({ name }) => name === grafana.name,
552
+ );
553
+ const deploymentLogin =
554
+ deploymentContainer?.env?.find(({ name }) => name === 'GF_SECURITY_ADMIN_USER')?.value || '';
555
+ const recordedLogin = deployment?.metadata?.annotations?.[grafana.adminLoginAnnotation] || '';
556
+ const previousLogin = recordedLogin || deploymentLogin || previous?.username || 'admin';
557
+ if (deployment && !recordedLogin)
558
+ shellExec(
559
+ `kubectl patch deployment ${grafana.name} -n ${namespace} --type merge --patch-file=/dev/stdin <<'EOF'
560
+ ${JSON.stringify({ metadata: { annotations: { [grafana.adminLoginAnnotation]: previousLogin } } })}
561
+ EOF
562
+ `,
563
+ { silent: true, disableLog: true },
564
+ );
565
+
566
+ if (stored) Underpost.secret.applyIfPresent(grafana.adminSecretName, namespace, { quiet: true });
567
+ else
568
+ shellExec(
569
+ `kubectl apply -f - <<'EOF'
570
+ ${grafanaAdminSecretFactory({ ...desired, namespace })}
571
+ EOF
572
+ `,
573
+ { silent: true, disableLog: true },
574
+ );
575
+
576
+ const current = Underpost.monitor.grafanaAdminSecretState(namespace);
577
+ if (!current?.username || !current?.password)
578
+ throw new Error(`[grafana] secret/${grafana.adminSecretName} is missing its credential keys`);
579
+ logger.info('Grafana admin Secret synchronized', {
580
+ namespace,
581
+ source: stored ? 'sops' : 'cron-env',
582
+ changed: previous?.username !== current.username || previous?.password !== current.password,
583
+ resourceVersion: current.resourceVersion,
584
+ deploymentExists: Boolean(deployment),
585
+ emailConfigured: Boolean(desired.email),
586
+ });
587
+ return {
588
+ desired: { ...desired, username: current.username, password: current.password },
589
+ previousLogin,
590
+ resourceVersion: current.resourceVersion,
591
+ deploymentExists: Boolean(deployment),
592
+ deployment,
593
+ changed: previous?.username !== current.username || previous?.password !== current.password,
594
+ };
595
+ },
596
+
597
+ /** Converges the admin account stored in Grafana's PVC onto the Secret. */
598
+ async reconcileGrafanaAdmin({ namespace = 'default', desired, previousLogin = '' } = {}) {
599
+ const { grafana } = UNDERPOST_MONITORING;
600
+ const localPort = await Underpost.monitor.findFreePort();
601
+ let portForward;
602
+ let authenticationDiagnostics = {};
603
+ try {
604
+ logger.info('Reconciling Grafana administrator', {
605
+ namespace,
606
+ desiredLogin: desired.username,
607
+ previousLogin,
608
+ emailConfigured: Boolean(desired.email),
609
+ });
610
+ portForward = shellExec(
611
+ `exec kubectl port-forward deployment/${grafana.name} ${localPort}:${grafana.port} -n ${namespace} ` +
612
+ `</dev/null >/dev/null 2>&1`,
613
+ { async: true, silent: true, disableLog: true, silentOnError: true },
614
+ );
615
+
616
+ const paths = ['', grafana.subPath];
617
+ const logins = [...new Set([desired.username, previousLogin].filter(Boolean))];
618
+ const authenticate = async () => {
619
+ const diagnostics = {};
620
+ for (let attempt = 0; attempt < 20; attempt++) {
621
+ for (const prefix of paths) {
622
+ for (const username of logins) {
623
+ try {
624
+ const response = await axios.get(`http://127.0.0.1:${localPort}${prefix}/api/user`, {
625
+ auth: { username, password: desired.password },
626
+ timeout: 2500,
627
+ });
628
+ if (response.data?.id) return { prefix, username, user: response.data };
629
+ } catch (error) {
630
+ diagnostics[`${prefix || '/'}:${username}`] =
631
+ error.response?.status || error.code || 'request-failed';
632
+ }
633
+ }
634
+ }
635
+ await timer(350);
636
+ }
637
+ authenticationDiagnostics = diagnostics;
638
+ return null;
639
+ };
640
+
641
+ let session = await authenticate();
642
+ if (!session) {
643
+ logger.warn('Grafana API authentication failed; resetting admin user ID 1 from the mounted Secret', {
644
+ attempts: authenticationDiagnostics,
645
+ });
646
+ shellExec(
647
+ `kubectl exec deployment/${grafana.name} -n ${namespace} -- sh -c ` +
648
+ `'printf "%s" "$GF_SECURITY_ADMIN_PASSWORD" | grafana cli ` +
649
+ `--homepath "\${GF_PATHS_HOME:-/usr/share/grafana}" ` +
650
+ `--config "\${GF_PATHS_CONFIG:-/etc/grafana/grafana.ini}" ` +
651
+ `--configOverrides "cfg:default.paths.data=\${GF_PATHS_DATA:-/var/lib/grafana}" ` +
652
+ `admin reset-admin-password --password-from-stdin'`,
653
+ { silent: true, disableLog: true },
654
+ );
655
+ logger.info('Grafana CLI password reset completed', { userId: 1 });
656
+ session = await authenticate();
657
+ }
658
+ if (!session) {
659
+ logger.error('Grafana administrator authentication still fails after the CLI reset', {
660
+ attempts: authenticationDiagnostics,
661
+ hint: 'verify auth.basic is enabled and inspect the Grafana pod logs',
662
+ });
663
+ throw new Error('[grafana] unable to authenticate the persisted administrator account');
664
+ }
665
+
666
+ const auth = { username: session.username, password: desired.password };
667
+ const profile = {
668
+ login: desired.username,
669
+ name: session.user.name || desired.username,
670
+ email: desired.email || session.user.email || '',
671
+ theme: session.user.theme || '',
672
+ };
673
+ if (session.user.login !== profile.login || session.user.email !== profile.email)
674
+ await axios.put(`http://127.0.0.1:${localPort}${session.prefix}/api/users/${session.user.id}`, profile, {
675
+ auth,
676
+ timeout: 5000,
677
+ });
678
+ if (!session.user.isGrafanaAdmin)
679
+ await axios.put(
680
+ `http://127.0.0.1:${localPort}${session.prefix}/api/admin/users/${session.user.id}/permissions`,
681
+ { isGrafanaAdmin: true },
682
+ { auth: { username: desired.username, password: desired.password }, timeout: 5000 },
683
+ );
684
+ logger.info('Grafana administrator reconciled', {
685
+ userId: session.user.id,
686
+ login: desired.username,
687
+ profileUpdated: session.user.login !== profile.login || session.user.email !== profile.email,
688
+ permissionUpdated: !session.user.isGrafanaAdmin,
689
+ });
690
+ shellExec(
691
+ `kubectl patch deployment ${grafana.name} -n ${namespace} --type merge --patch-file=/dev/stdin <<'EOF'
692
+ ${JSON.stringify({ metadata: { annotations: { [grafana.adminLoginAnnotation]: desired.username } } })}
693
+ EOF
694
+ `,
695
+ { silent: true, disableLog: true },
696
+ );
697
+ } catch (error) {
698
+ if (`${error?.message || ''}`.startsWith('[grafana]')) throw error;
699
+ throw new Error('[grafana] failed to synchronize the persisted administrator account');
700
+ } finally {
701
+ if (portForward && typeof portForward.kill === 'function') {
702
+ try {
703
+ portForward.kill('SIGTERM');
704
+ } catch (_) {}
705
+ }
706
+ }
707
+ },
708
+
709
+ /**
710
+ * @method ensureObservabilityPrerequisites
711
+ * @description Verifies the cluster resources the stack depends on, and
712
+ * provisions the ones this CLI owns.
713
+ *
714
+ * Cluster DNS is verified rather than installed: the Envoy job and every
715
+ * component address is a `.svc.cluster.local` name, so without CoreDNS the
716
+ * stack comes up green and scrapes nothing. It belongs to the cluster
717
+ * runtime (kubeadm, K3s, Kind), so a missing one is reported, not patched
718
+ * over. The Gateway API control plane is installed when absent, because
719
+ * `cluster --gateway-api` is the same command that would otherwise be run
720
+ * by hand.
721
+ * @param {object} [options]
722
+ * @param {boolean} [options.dev=false] - Use the in-repo CLI rather than the global install.
723
+ * @returns {boolean} True when the stack has everything it needs.
724
+ * @memberof UnderpostMonitor
725
+ */
726
+ ensureObservabilityPrerequisites(options = {}) {
727
+ const exists = (command) =>
728
+ Boolean(shellExec(command, { stdout: true, silent: true, silentOnError: true, disableLog: true })?.trim());
729
+
730
+ const clusterDns = exists(`kubectl get deployment -n kube-system -l k8s-app=kube-dns -o name`);
731
+ if (!clusterDns) {
732
+ logger.error('Cluster DNS (CoreDNS/kube-dns) is not present', {
733
+ hint: 'the stack addresses every component by svc.cluster.local name; initialize the cluster first',
734
+ });
735
+ return false;
736
+ }
737
+ shellExec(`kubectl rollout status deployment -n kube-system -l k8s-app=kube-dns --timeout=3m`, {
738
+ silentOnError: true,
739
+ });
740
+
741
+ const { namespace: envoyNamespace } = UNDERPOST_MONITORING.envoy;
742
+ if (!exists(`kubectl get deployment envoy-gateway -n ${envoyNamespace} -o name`)) {
743
+ logger.info('Envoy Gateway is absent; installing the Gateway API control plane', { envoyNamespace });
744
+ shellExec(`${cli('underpost', { local: true })} cluster${options.dev ? ' --dev' : ''} --gateway-api`);
745
+ }
746
+ if (!exists(`kubectl get deployment envoy-gateway -n ${envoyNamespace} -o name`))
747
+ logger.warn('Envoy Gateway is still absent; its metrics job will discover no targets', { envoyNamespace });
748
+
749
+ Underpost.cluster.ensureLocalPathProvisioner();
750
+
751
+ return true;
752
+ },
753
+
754
+ /**
755
+ * @method syncObservability
756
+ * @description Renders the stack's configuration from the live deploy
757
+ * configuration and event registry, applies it, and reloads the running
758
+ * components.
759
+ *
760
+ * This is the single provisioning path: `monitor --observability`,
761
+ * `monitor --sync-prom` and `event <id> --deploy` all land here, so the
762
+ * scrape config, the alert rules and the Alertmanager route can never be
763
+ * generated by two implementations that disagree.
764
+ * @param {object} [options] - See `observabilityContextFactory`.
765
+ * @returns {Promise<object>} The applied context.
766
+ * @memberof UnderpostMonitor
767
+ */
768
+ async syncObservability(options = {}) {
769
+ const context = Underpost.monitor.observabilityContextFactory(options);
770
+ const { namespace, appTargets, extraTargets, probes, events, webhookUrl, token } = context;
771
+ const grafanaAdmin = Underpost.monitor.syncGrafanaAdminSecret({ ...options, namespace });
772
+
773
+ const stamp = (conf) =>
774
+ `# generated: ${crypto.createHash('sha1').update(conf).digest('hex').slice(0, 12)}\n${conf}`;
775
+ // The hub runs the collector but is not a cluster node, so node discovery
776
+ // cannot find it; it is scraped at the tunnel address topology records.
777
+ const hostTargets = Underpost.event
778
+ .hubs()
779
+ .map((hub) => hub.address)
780
+ .filter(Boolean);
781
+ const prometheusConf = stamp(
782
+ prometheusConfFactory({
783
+ appTargets,
784
+ extraTargets,
785
+ probes,
786
+ hostTargets,
787
+ nodeRoles: Underpost.event.clusterNodes(),
788
+ namespace,
789
+ }),
790
+ );
791
+ const alertmanagerConf = stamp(alertmanagerConfFactory({ webhookUrl }));
792
+
793
+ // disableLog: shellExec echoes the command, and this heredoc carries the
794
+ // webhook token.
795
+ shellExec(
796
+ `kubectl apply -f - <<'EOF'
797
+ ${webhookSecretFactory({ token, namespace })}
798
+ EOF
799
+ `,
800
+ { disableLog: true, silent: true },
801
+ );
802
+
803
+ shellExec(`kubectl apply -f - <<'EOF'
804
+ ${monitoringConfigFactory({
805
+ namespace,
806
+ prometheusConf,
807
+ alertRules: alertRulesFactory(events),
808
+ alertmanagerConf,
809
+ blackboxConf: blackboxConfFactory(),
810
+ })}
811
+ EOF
812
+ `);
813
+
814
+ const { prometheus, alertmanager, blackbox } = UNDERPOST_MONITORING;
815
+ const marker = (conf) => conf.split('\n')[0];
816
+ const prometheusReloaded = await Underpost.monitor.reloadMonitoringComponent({
817
+ name: prometheus.name,
818
+ namespace,
819
+ file: '/etc/prometheus/prometheus.yml',
820
+ marker: marker(prometheusConf),
821
+ });
822
+ // Planned-maintenance event suspension/resumption must not proceed while
823
+ // a running Prometheus still evaluates its previous rules. No pod is safe:
824
+ // the applied ConfigMap is what a future pod reads. A refused reload is not.
825
+ if (options.requireEventReload && prometheusReloaded === false)
826
+ throw new Error('[observability] running Prometheus did not accept the event configuration reload');
827
+ await Underpost.monitor.reloadMonitoringComponent({
828
+ name: alertmanager.name,
829
+ namespace,
830
+ file: '/etc/alertmanager/alertmanager.yml',
831
+ marker: marker(alertmanagerConf),
832
+ });
833
+ await Underpost.monitor.reloadMonitoringComponent({ name: blackbox.name, namespace });
834
+
835
+ if (grafanaAdmin.deploymentExists && !options.deferGrafanaAdmin) {
836
+ const container = grafanaAdmin.deployment.spec.template.spec.containers.find(
837
+ ({ name }) => name === UNDERPOST_MONITORING.grafana.name,
838
+ );
839
+ const env = Object.fromEntries((container?.env || []).map((entry) => [entry.name, entry.value]));
840
+ const exposure = {
841
+ rootUrl: env.GF_SERVER_ROOT_URL || grafanaExposureFactory({}).rootUrl,
842
+ subPath: env.GF_SERVER_SERVE_FROM_SUB_PATH === 'true',
843
+ };
844
+ const patch = grafanaDeploymentPatchFactory({
845
+ exposure,
846
+ nodeName: grafanaAdmin.deployment.spec.template.spec.nodeSelector?.['kubernetes.io/hostname'] || '',
847
+ adminSecretVersion: grafanaAdmin.resourceVersion,
848
+ });
849
+ shellExec(`kubectl patch deployment ${UNDERPOST_MONITORING.grafana.name} -n ${namespace} --type strategic --patch-file=/dev/stdin <<'EOF'
850
+ ${JSON.stringify(patch)}
851
+ EOF
852
+ `);
853
+ Underpost.monitor.waitForObservabilityDeployment({ name: UNDERPOST_MONITORING.grafana.name, namespace });
854
+ await Underpost.monitor.reconcileGrafanaAdmin({ namespace, ...grafanaAdmin });
855
+ } else if (options.deferGrafanaAdmin) grafanaAdminSyncState.set(context, grafanaAdmin);
856
+
857
+ logger.info('Observability configuration synced', {
858
+ namespace,
859
+ deployIds: context.deployIds,
860
+ scrapeTargets: appTargets.length + extraTargets.length,
861
+ events: events.map((event) => event.id),
862
+ webhookUrl,
863
+ grafanaAdminSecret: UNDERPOST_MONITORING.grafana.adminSecretName,
864
+ });
865
+ return context;
866
+ },
867
+
868
+ /**
869
+ * @method reloadMonitoringComponent
870
+ * @description Reloads one component in place, once the ConfigMap it mounts
871
+ * has actually reached its pod.
872
+ *
873
+ * kubelet projects an updated ConfigMap on its own schedule — up to a minute
874
+ * — so reloading immediately after `kubectl apply` re-reads the previous
875
+ * file and reports success. The generated `# generated:` stamp is polled
876
+ * inside the container first, which makes the wait observable rather than
877
+ * a fixed sleep.
878
+ *
879
+ * The reload is SIGHUP to PID 1, which all three Prometheus-project binaries
880
+ * honour and which needs nothing from the image beyond `kill`. Their HTTP
881
+ * reload endpoint would need a POST client the busybox-based images do not
882
+ * reliably ship. Reloading in place is what keeps Prometheus' in-memory
883
+ * series: the TSDB is an emptyDir, so a rollout restart would discard every
884
+ * sample collected so far.
885
+ * @param {object} params
886
+ * @param {string} params.name - Workload/app label.
887
+ * @param {string} [params.namespace='default'] - Namespace holding it.
888
+ * @param {string} [params.file] - Mounted config path to poll.
889
+ * @param {string} [params.marker] - Line that must appear in that file.
890
+ * @param {number} [params.timeoutMs=120000] - Maximum wait for projection.
891
+ * @returns {Promise<boolean|null>} True when reloaded, false on a failed
892
+ * reload, and null when no running pod exists (the next pod reads the
893
+ * already-applied ConfigMap at startup).
894
+ * @memberof UnderpostMonitor
895
+ */
896
+ async reloadMonitoringComponent({ name, namespace = 'default', file = '', marker = '', timeoutMs = 120000 }) {
897
+ const pod = shellExec(`kubectl get pods -n ${namespace} -l app=${name} -o jsonpath='{.items[0].metadata.name}'`, {
898
+ stdout: true,
899
+ silent: true,
900
+ silentOnError: true,
901
+ disableLog: true,
902
+ })
903
+ .toString()
904
+ .trim();
905
+ if (!pod) {
906
+ logger.warn('Component is not running; its configuration is applied and will be read at start', { name });
907
+ return null;
908
+ }
909
+
910
+ const exec = (command) =>
911
+ shellExec(`kubectl exec ${pod} -n ${namespace} -- ${command}`, {
912
+ stdout: false,
913
+ silent: true,
914
+ silentOnError: true,
915
+ disableLog: true,
916
+ });
917
+
918
+ if (file && marker) {
919
+ const deadline = Date.now() + timeoutMs;
920
+ let projected = false;
921
+ while (Date.now() < deadline) {
922
+ if (exec(`grep -qF '${marker}' ${file}`).code === 0) {
923
+ projected = true;
924
+ break;
925
+ }
926
+ await timer(3000);
927
+ }
928
+ if (!projected) {
929
+ logger.warn('Configuration has not reached the pod yet; skipping reload', { name, file });
930
+ return false;
931
+ }
932
+ }
933
+
934
+ const reload = exec(`kill -HUP 1`);
935
+ if (reload.code !== 0) {
936
+ logger.warn('Reload signal was refused; the component keeps its previous configuration', {
937
+ name,
938
+ stderr: `${reload.stderr || ''}`.slice(-200),
939
+ });
940
+ return false;
941
+ }
942
+ logger.info('Component reloaded', { name, pod });
943
+ return true;
944
+ },
945
+
946
+ /**
947
+ * @method deployObservability
948
+ * @description Installs or converges the whole stack: Prometheus,
949
+ * Alertmanager, the Blackbox Exporter and Grafana, then renders their
950
+ * configuration from the live deploy configuration and event registry.
951
+ *
952
+ * Idempotent and re-runnable: workloads are applied, never deleted first, so
953
+ * a re-run on a healthy cluster is a no-op for the pods and a refresh for
954
+ * the configuration.
955
+ *
956
+ * `--node-name` pins every component. Moving Grafana resets its node-local
957
+ * volume before the replacement is created.
958
+ * @param {object} [options] - See `observabilityContextFactory`.
959
+ * @param {string} [options.nodeName] - Strict workload node; moving Grafana resets its local data.
960
+ * @param {string} [options.grafanaHost] - Hostname to publish Grafana under, at `/grafana`.
961
+ * @param {boolean} [options.nodePort] - Publish Grafana on the node's LAN address.
962
+ * @returns {Promise<object|undefined>} The applied context, or undefined when prerequisites are missing.
963
+ * @memberof UnderpostMonitor
964
+ */
965
+ async deployObservability(options = {}) {
966
+ const namespace = options.namespace || 'default';
967
+ const manifestsRoot = Underpost.monitor.observabilityManifestsRoot();
968
+ const { grafana } = UNDERPOST_MONITORING;
969
+ const pinnable = [
970
+ UNDERPOST_MONITORING.prometheus.name,
971
+ UNDERPOST_MONITORING.alertmanager.name,
972
+ UNDERPOST_MONITORING.blackbox.name,
973
+ ];
974
+
975
+ if (!Underpost.monitor.ensureObservabilityPrerequisites(options)) return undefined;
976
+ const nodeName = Underpost.monitor.resolveObservabilityNode(options.nodeName);
977
+ Underpost.monitor.resetGrafanaForNode({ namespace, nodeName });
978
+
979
+ // Configuration first: every workload below mounts a ConfigMap, and a pod
980
+ // admitted before its ConfigMap exists stays pending until kubelet
981
+ // re-resolves the volume.
982
+ const context = await Underpost.monitor.syncObservability({
983
+ ...options,
984
+ namespace,
985
+ deferGrafanaAdmin: true,
986
+ });
987
+ const grafanaAdmin = grafanaAdminSyncState.get(context);
988
+
989
+ for (const manifest of ['prometheus', 'alertmanager', 'blackbox-exporter'])
990
+ shellExec(`kubectl apply -f ${manifestsRoot}/${manifest}/deployment.yaml -n ${namespace}`);
991
+
992
+ Underpost.monitor.pinWorkloads({ names: pinnable, namespace, nodeName });
993
+ Underpost.monitor.applyGrafanaResources({ manifestsRoot, namespace });
994
+ const exposure = Underpost.monitor.exposeGrafana({
995
+ ...options,
996
+ namespace,
997
+ nodeName,
998
+ patchDeployment: false,
999
+ });
1000
+ Underpost.monitor.applyGrafanaDeployment({
1001
+ manifestsRoot,
1002
+ namespace,
1003
+ nodeName,
1004
+ exposure,
1005
+ adminSecretVersion: grafanaAdmin.resourceVersion,
1006
+ });
1007
+
1008
+ for (const name of [...pinnable, grafana.name])
1009
+ Underpost.monitor.waitForObservabilityDeployment({ name, namespace });
1010
+ await Underpost.monitor.reconcileGrafanaAdmin({ namespace, ...grafanaAdmin });
1011
+ grafanaAdminSyncState.delete(context);
1012
+
1013
+ logger.info('Observability stack deployed', {
1014
+ grafanaUrl: exposure.url,
1015
+ namespace,
1016
+ clusterType: clusterTypeFactory(options, options.dev ? 'kind' : 'kubeadm'),
1017
+ nodeName: nodeName || '(scheduler)',
1018
+ grafana: `http://${UNDERPOST_MONITORING.grafana.name}.${namespace}.svc.cluster.local:${UNDERPOST_MONITORING.grafana.port}`,
1019
+ prometheus: `http://${UNDERPOST_MONITORING.prometheus.name}.${namespace}.svc.cluster.local:${UNDERPOST_MONITORING.prometheus.port}`,
1020
+ events: context.events.map((event) => event.id),
1021
+ });
1022
+ return context;
1023
+ },
1024
+
1025
+ /** Resolves and validates an explicit observability workload node. */
1026
+ resolveObservabilityNode(nodeName = '') {
1027
+ nodeName = `${nodeName || ''}`.trim();
1028
+ if (!nodeName) return '';
1029
+ const { node, corrected } = Underpost.deploy.resolveSchedulableNode({ node: nodeName });
1030
+ if (corrected)
1031
+ throw new Error(`[monitor] --node-name ${nodeName} is not a node in this cluster (schedulable: ${node})`);
1032
+ return node;
1033
+ },
1034
+
1035
+ /** Returns Grafana's bound volume and its node affinity. */
1036
+ grafanaVolume(namespace = 'default') {
1037
+ const { pvcName } = UNDERPOST_MONITORING.grafana;
1038
+ const read = (command) =>
1039
+ `${shellExec(command, { stdout: true, silent: true, silentOnError: true, disableLog: true }) || ''}`.trim();
1040
+ const boundName = read(`kubectl get pvc ${pvcName} -n ${namespace} -o jsonpath='{.spec.volumeName}'`);
1041
+ const pvList = read('kubectl get pv -o json');
1042
+ const volumes = pvList ? JSON.parse(pvList).items || [] : [];
1043
+ const volume = volumes.find(
1044
+ ({ metadata, spec }) =>
1045
+ metadata.name === boundName || (spec.claimRef?.name === pvcName && spec.claimRef?.namespace === namespace),
1046
+ );
1047
+ const nodes = (volume?.spec.nodeAffinity?.required?.nodeSelectorTerms || []).flatMap((term) =>
1048
+ (term.matchExpressions || [])
1049
+ .filter(({ key }) => key === 'kubernetes.io/hostname')
1050
+ .flatMap(({ values }) => values || []),
1051
+ );
1052
+ return { name: volume?.metadata.name || '', nodes };
1053
+ },
1054
+
1055
+ /** Removes Grafana and its local data before moving it to another node. */
1056
+ resetGrafanaForNode({ namespace = 'default', nodeName = '' } = {}) {
1057
+ const volume = Underpost.monitor.grafanaVolume(namespace);
1058
+ if (!grafanaStorageResetRequiredFactory({ nodeName, volumeNodes: volume.nodes })) return false;
1059
+ const plan = grafanaResetPlanFactory(volume.name);
1060
+
1061
+ logger.warn('Resetting Grafana to move its local storage', {
1062
+ from: volume.nodes,
1063
+ to: nodeName,
1064
+ dataLoss: true,
1065
+ });
1066
+ const remove = (resource, options = '') =>
1067
+ shellExec(
1068
+ `kubectl delete ${resource} -n ${namespace} --ignore-not-found --wait=true --timeout=3m ${options}`.trim(),
1069
+ );
1070
+ for (const controller of plan.controllers) remove(controller, '--cascade=foreground');
1071
+ for (const resource of plan.resources) remove(resource);
1072
+
1073
+ if (plan.volumeName)
1074
+ shellExec(
1075
+ `kubectl patch pv ${plan.volumeName} --type merge -p '{"spec":{"persistentVolumeReclaimPolicy":"Delete"}}'`,
1076
+ );
1077
+ remove(plan.claim);
1078
+ if (plan.volumeName)
1079
+ shellExec(`kubectl delete pv ${plan.volumeName} --ignore-not-found --wait=true --timeout=3m`);
1080
+
1081
+ logger.info('Grafana reset completed', { nodeName, removedVolume: plan.volumeName });
1082
+ return true;
1083
+ },
1084
+
1085
+ /** Applies Grafana with placement and URL settings in its initial pod template. */
1086
+ applyGrafanaDeployment({ manifestsRoot, namespace = 'default', nodeName = '', exposure, adminSecretVersion = '' }) {
1087
+ const source = shellExec(`kubectl create --dry-run=client -f ${manifestsRoot}/grafana/deployment.yaml -o json`, {
1088
+ stdout: true,
1089
+ silent: true,
1090
+ disableLog: true,
1091
+ });
1092
+ const deployment = grafanaDeploymentFactory({
1093
+ deployment: JSON.parse(source),
1094
+ exposure,
1095
+ namespace,
1096
+ nodeName,
1097
+ adminSecretVersion,
1098
+ });
1099
+ shellExec(
1100
+ `kubectl apply -f - <<'EOF'
1101
+ ${JSON.stringify(deployment)}
1102
+ EOF
1103
+ `,
1104
+ { disableLog: true },
1105
+ );
1106
+ },
1107
+
1108
+ /** Applies Grafana's storage and internal Service. */
1109
+ applyGrafanaResources({ manifestsRoot, namespace = 'default' }) {
1110
+ for (const resource of ['storage-class.yaml', 'pvc.yaml', 'service.yaml'])
1111
+ shellExec(`kubectl apply -f ${manifestsRoot}/grafana/${resource} -n ${namespace}`);
1112
+ },
1113
+
1114
+ /** Waits for a stack Deployment and prints actionable diagnostics on failure. */
1115
+ waitForObservabilityDeployment({ name, namespace = 'default' }) {
1116
+ const rollout = shellExec(`kubectl rollout status deployment/${name} -n ${namespace} --timeout=5m`, {
1117
+ silentOnError: true,
1118
+ });
1119
+ if (rollout.code === 0) return;
1120
+ shellExec(`kubectl get pods -n ${namespace} -l app=${name} -o wide`, { silentOnError: true });
1121
+ shellExec(`kubectl describe pods -n ${namespace} -l app=${name}`, { silentOnError: true });
1122
+ if (name === UNDERPOST_MONITORING.grafana.name)
1123
+ shellExec(`kubectl describe pvc ${UNDERPOST_MONITORING.grafana.pvcName} -n ${namespace}`, {
1124
+ silentOnError: true,
1125
+ });
1126
+ throw new Error(`[observability] deployment/${name} failed to become ready; diagnostics are shown above`);
1127
+ },
1128
+
1129
+ /**
1130
+ * @method pinWorkloads
1131
+ * @description Pins Deployments to one node, refusing a name no node carries.
1132
+ *
1133
+ * A workload pinned to a non-existent node does not degrade — it stays
1134
+ * Pending and every rollout wait times out with nothing naming the cause —
1135
+ * so the name is checked against the live node list before anything is
1136
+ * patched. Kind, kubeadm and K3s name their nodes differently
1137
+ * (`kind-worker`, the machine hostname, `node-01`), which is exactly why the
1138
+ * check is against the cluster rather than a per-runtime guess.
1139
+ * @param {object} params
1140
+ * @param {string[]} params.names - Deployment names.
1141
+ * @param {string} params.namespace - Namespace holding them.
1142
+ * @param {string} params.nodeName - Requested node; empty leaves placement to the scheduler.
1143
+ * @returns {boolean} True when the workloads were pinned.
1144
+ * @throws {Error} When `nodeName` is not a node in this cluster.
1145
+ * @memberof UnderpostMonitor
1146
+ */
1147
+ pinWorkloads({ names = [], namespace = 'default', nodeName = '' }) {
1148
+ if (!nodeName) return false;
1149
+ const node = Underpost.monitor.resolveObservabilityNode(nodeName);
1150
+ for (const name of names) Underpost.cluster.pinToNode({ kind: 'deployment', name, namespace, node });
1151
+ return true;
1152
+ },
1153
+
1154
+ /**
1155
+ * @method observabilityManifestsRoot
1156
+ * @description Directory the stack's workload manifests are applied from.
1157
+ *
1158
+ * Always the engine checkout, never the globally installed package: these
1159
+ * manifests ship with the repository and the deploy is run from it, so
1160
+ * resolving them out of `node_modules/underpost` applies whatever an older
1161
+ * global install happens to carry — or fails outright when it carries
1162
+ * nothing.
1163
+ * @returns {string} Manifests directory.
1164
+ * @throws {Error} When the directory is not present in the working directory.
1165
+ * @memberof UnderpostMonitor
1166
+ */
1167
+ observabilityManifestsRoot() {
1168
+ const root = './manifests';
1169
+ if (!fs.existsSync(root)) throw new Error(`[observability] ${root} not found; run this from the engine checkout`);
1170
+ return root;
1171
+ },
1172
+
1173
+ /**
1174
+ * @method exposeGrafana
1175
+ * @description Publishes Grafana to a browser, and tells Grafana where it is
1176
+ * being reached from.
1177
+ *
1178
+ * Two independent exposures, either or both:
1179
+ *
1180
+ * - `--grafana-host <host>` attaches it at `/grafana` on a hostname the
1181
+ * edge already serves, so it inherits that host's certificate and is
1182
+ * reachable publicly. The route carries no rewrite: with
1183
+ * `serve_from_sub_path` Grafana expects to receive the prefix, and
1184
+ * stripping it would break every asset URL it then generates.
1185
+ * - `--node-port` publishes it on the node's LAN address, for an operator
1186
+ * on the same network with no DNS or certificate involved.
1187
+ *
1188
+ * The `GF_SERVER_*` pair and the node pin are applied as one pod-template
1189
+ * patch rather than separate `set env` and `patch` calls. Each template
1190
+ * mutation starts a new ReplicaSet, and Grafana rolls with `Recreate`
1191
+ * because its data directory is a ReadWriteOnce volume — so two mutations in
1192
+ * sequence means two rollouts, and an identical patch means none at all.
1193
+ * @param {object} [options]
1194
+ * @param {string} [options.grafanaHost=''] - Hostname to attach the sub-path route to.
1195
+ * @param {boolean} [options.nodePort=false] - Publish the LAN NodePort.
1196
+ * @param {string} [options.nodeName=''] - Grafana node and NodePort address.
1197
+ * @param {string} [options.namespace='default'] - Namespace holding Grafana.
1198
+ * @param {string} [options.env] - Environment whose Gateway the route attaches to.
1199
+ * @param {boolean} [options.patchDeployment=true] - Apply the runtime settings to an existing Deployment.
1200
+ * @returns {object} The resolved exposure.
1201
+ * @memberof UnderpostMonitor
1202
+ */
1203
+ exposeGrafana(options = {}) {
1204
+ const namespace = options.namespace || 'default';
1205
+ const { grafana } = UNDERPOST_MONITORING;
1206
+ const host = `${options.grafanaHost || ''}`.trim();
1207
+ const nodeName = Underpost.monitor.resolveObservabilityNode(`${options.nodeName || ''}`.trim());
1208
+ const reset = options.patchDeployment !== false && Underpost.monitor.resetGrafanaForNode({ namespace, nodeName });
1209
+ const manifestsRoot = reset ? Underpost.monitor.observabilityManifestsRoot() : '';
1210
+ if (reset) {
1211
+ Underpost.cluster.ensureLocalPathProvisioner();
1212
+ Underpost.monitor.applyGrafanaResources({ manifestsRoot, namespace });
1213
+ }
1214
+
1215
+ const nodeIp = options.nodePort ? Underpost.monitor.nodeInternalIp(nodeName) : '';
1216
+
1217
+ if (options.nodePort)
1218
+ shellExec(`kubectl apply -f - <<'EOF'
1219
+ ${grafanaNodePortFactory({ namespace, nodePort: grafana.nodePort })}
1220
+ EOF
1221
+ `);
1222
+
1223
+ if (host) {
1224
+ // The deploy's Gateway already terminates TLS for this hostname, so the
1225
+ // route attaches to it rather than provisioning a second listener.
1226
+ const parentName = Underpost.deploy.gatewayNameFactory({
1227
+ deployId: Underpost.monitor.grafanaRouteDeployId(host, options),
1228
+ env: options.env || (options.dev ? 'development' : 'production'),
1229
+ });
1230
+ const routeYaml = Underpost.deploy.httpRouteYamlFactory({
1231
+ host,
1232
+ name: grafana.routeName,
1233
+ parentName,
1234
+ options: { namespace },
1235
+ rules: Underpost.deploy.httpRouteRuleFactory({
1236
+ path: grafana.subPath,
1237
+ matchType: 'PathPrefix',
1238
+ serviceId: grafana.name,
1239
+ port: grafana.port,
1240
+ }),
1241
+ });
1242
+ shellExec(`kubectl apply -f - <<'EOF'
1243
+ ${routeYaml}
1244
+ EOF
1245
+ `);
1246
+ } else shellExec(`kubectl delete httproute ${grafana.routeName} -n ${namespace} --ignore-not-found`);
1247
+
1248
+ const exposure = grafanaExposureFactory({ host, nodeIp, nodePort: grafana.nodePort });
1249
+ if (options.patchDeployment !== false) {
1250
+ if (reset) Underpost.monitor.applyGrafanaDeployment({ manifestsRoot, namespace, nodeName, exposure });
1251
+ else {
1252
+ const patch = JSON.stringify(grafanaDeploymentPatchFactory({ exposure, nodeName }));
1253
+ shellExec(`kubectl patch deployment ${grafana.name} -n ${namespace} -p '${patch}'`);
1254
+ }
1255
+ Underpost.monitor.waitForObservabilityDeployment({ name: grafana.name, namespace });
1256
+ }
1257
+
1258
+ logger.info('Grafana exposed', {
1259
+ url: exposure.url,
1260
+ nodePort: options.nodePort ? `${nodeIp || '<node>'}:${grafana.nodePort}` : null,
1261
+ route: host ? `${host}${grafana.subPath}` : null,
1262
+ node: nodeName || '(scheduler)',
1263
+ });
1264
+ return exposure;
1265
+ },
1266
+
1267
+ /**
1268
+ * @method nodeInternalIp
1269
+ * @description The InternalIP of a named node, or of the control plane when
1270
+ * none is named.
1271
+ *
1272
+ * Not the first node: `items[0]` is whichever node the API server lists
1273
+ * first, so an address meant for the control plane silently becomes a
1274
+ * worker's as soon as one sorts ahead of it.
1275
+ * @param {string} [nodeName] - Node to resolve.
1276
+ * @returns {string} InternalIP, empty when unresolved.
1277
+ * @memberof UnderpostMonitor
1278
+ */
1279
+ nodeInternalIp(nodeName = '') {
1280
+ const command = nodeName
1281
+ ? `kubectl get node ${nodeName} -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'`
1282
+ : `kubectl get nodes -l node-role.kubernetes.io/control-plane ` +
1283
+ `-o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}'`;
1284
+ return `${shellExec(command, { stdout: true, silent: true, silentOnError: true, disableLog: true }) || ''}`
1285
+ .trim()
1286
+ .split(/\s+/)[0];
1287
+ },
1288
+
1289
+ /**
1290
+ * @method grafanaRouteDeployId
1291
+ * @description The deploy whose Gateway terminates a hostname.
1292
+ *
1293
+ * Resolved from the live Gateways rather than guessed, because the Gateway
1294
+ * is named after the deploy that owns the host and a route attached to a
1295
+ * Gateway that does not list the hostname is accepted and then never
1296
+ * matched — a 404 with nothing logged.
1297
+ * @param {string} host - Hostname to publish under.
1298
+ * @param {object} [options] - Namespace and environment.
1299
+ * @returns {string} Owning deploy id.
1300
+ * @throws {Error} When no Gateway in the cluster serves the hostname.
1301
+ * @memberof UnderpostMonitor
1302
+ */
1303
+ grafanaRouteDeployId(host, options = {}) {
1304
+ const namespace = options.namespace || 'default';
1305
+ const env = options.env || (options.dev ? 'development' : 'production');
1306
+ const gateways = `${
1307
+ shellExec(
1308
+ `kubectl get gateway -n ${namespace} -o jsonpath='{range .items[*]}{.metadata.name}={range .spec.listeners[*]}{.hostname},{end}{"\\n"}{end}'`,
1309
+ { stdout: true, silent: true, silentOnError: true, disableLog: true },
1310
+ ) || ''
1311
+ }`.trim();
1312
+
1313
+ for (const line of gateways.split('\n')) {
1314
+ const [name, hostnames = ''] = line.split('=');
1315
+ if (!name || !hostnames.split(',').includes(host)) continue;
1316
+ const suffix = `-${env}`;
1317
+ if (name.endsWith(suffix)) return name.slice(0, -suffix.length);
1318
+ }
1319
+ throw new Error(
1320
+ `[observability] no ${env} Gateway serves '${host}'; deploy that host first, or use --node-port instead`,
1321
+ );
1322
+ },
1323
+
1324
+ /**
1325
+ * @method deployMetricsServer
1326
+ * @description Installs the Kubernetes metrics-server.
1327
+ *
1328
+ * A different concern from the observability stack: metrics-server backs the
1329
+ * resource API that `kubectl top` and the HorizontalPodAutoscaler read, and
1330
+ * keeps no history. Prometheus keeps history and answers queries; neither
1331
+ * substitutes for the other.
1332
+ *
1333
+ * `--kubelet-insecure-tls` is required on kubeadm and Kind, whose kubelets
1334
+ * serve a self-signed certificate that metrics-server would otherwise
1335
+ * reject with an x509 error on every node. K3s ships its own metrics-server
1336
+ * already wired to its kubelets, so installing this one there would leave
1337
+ * two Deployments contending for the same APIService.
1338
+ * @param {object} [options]
1339
+ * @param {boolean} [options.force=false] - Install on K3s anyway, replacing the bundled one.
1340
+ * @param {string} [options.nodeName=''] - Pin the metrics-server Deployment to this node.
1341
+ * @returns {Promise<boolean>} True when the install ran.
1342
+ * @memberof UnderpostMonitor
1343
+ */
1344
+ async deployMetricsServer(options = {}) {
1345
+ const namespace = 'kube-system';
1346
+ const clusterType = clusterTypeFactory(options, options.dev ? 'kind' : 'kubeadm');
1347
+ if (clusterType === 'k3s' && !options.force) {
1348
+ logger.warn('K3s bundles metrics-server; skipping (pass --force to replace the bundled one)');
1349
+ return false;
1350
+ }
1351
+
1352
+ shellExec(
1353
+ `kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/high-availability-1.21+.yaml`,
1354
+ );
1355
+ await timer(2000);
1356
+
1357
+ // Idempotent: the JSON patch appends the flag, so re-running would add a
1358
+ // duplicate. Checked first rather than made unconditional.
1359
+ const args = shellExec(
1360
+ `kubectl get deployment metrics-server -n ${namespace} -o jsonpath='{.spec.template.spec.containers[0].args}'`,
1361
+ { stdout: true, silent: true, silentOnError: true, disableLog: true },
1362
+ );
1363
+ if (!`${args || ''}`.includes('--kubelet-insecure-tls'))
1364
+ shellExec(
1365
+ `kubectl patch deployment metrics-server -n ${namespace} --type='json' ` +
1366
+ `-p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'`,
1367
+ );
1368
+
1369
+ shellExec(`kubectl scale deployment metrics-server -n ${namespace} --replicas=1`);
1370
+ Underpost.monitor.pinWorkloads({ names: ['metrics-server'], namespace, nodeName: options.nodeName });
1371
+ shellExec(`kubectl rollout status deployment/metrics-server -n ${namespace} --timeout=5m`, {
1372
+ silentOnError: true,
1373
+ });
1374
+ logger.info('metrics-server deployed', { clusterType, namespace });
1375
+ return true;
1376
+ },
1377
+
1378
+ /**
1379
+ * @method cockpit
1380
+ * @description Installs, enables or stops the Cockpit KVM dashboard on this
1381
+ * host — the web console for the libvirt guests the baremetal and LXD flows
1382
+ * provision, served on port 9090.
1383
+ *
1384
+ * Host-level and therefore idempotent and reversible by contract: the
1385
+ * install is a no-op on an already-provisioned host, the firewall service is
1386
+ * re-declared rather than toggled blindly, and `--cockpit-stop` reverses
1387
+ * exactly what was opened. Every step is guarded on the tool actually being
1388
+ * present, so a host without firewalld is configured rather than failed.
1389
+ *
1390
+ * Cockpit and Prometheus both default to 9090. They do not collide here —
1391
+ * Prometheus is a ClusterIP Service inside the cluster and Cockpit binds the
1392
+ * host — but a NodePort exposing Prometheus on the host must not use 9090 on
1393
+ * a node that also runs this dashboard.
1394
+ * @param {object} [options]
1395
+ * @param {boolean} [options.stop=false] - Stop and disable the console, and close its firewall service.
1396
+ * @returns {boolean} True when the requested state was applied.
1397
+ * @memberof UnderpostMonitor
1398
+ */
1399
+ cockpit(options = {}) {
1400
+ const has = (binary) =>
1401
+ Boolean(
1402
+ `${shellExec(`command -v ${binary} 2>/dev/null || true`, { stdout: true, silent: true, silentOnError: true, disableLog: true }) || ''}`.trim(),
1403
+ );
1404
+ const firewalld = () =>
1405
+ has('firewall-cmd') &&
1406
+ shellExec(`sudo firewall-cmd --state >/dev/null 2>&1`, { silentOnError: true, disableLog: true, stdout: false })
1407
+ .code === 0;
1408
+
1409
+ if (options.stop) {
1410
+ shellExec(`sudo systemctl disable --now cockpit.socket`, { silentOnError: true });
1411
+ if (firewalld()) {
1412
+ shellExec(`sudo firewall-cmd --permanent --remove-service=cockpit`, { silentOnError: true });
1413
+ shellExec(`sudo firewall-cmd --reload`);
1414
+ }
1415
+ // libvirtd is left running: it hosts the guests themselves, and this
1416
+ // command manages the console in front of them, not the hypervisor.
1417
+ logger.info('Cockpit stopped and disabled', { note: 'libvirtd left running' });
1418
+ return true;
1419
+ }
1420
+
1421
+ if (!has('dnf')) {
1422
+ logger.error('Cockpit install requires dnf', { hint: 'install cockpit cockpit-machines libvirt by hand' });
1423
+ return false;
1424
+ }
1425
+ shellExec(`sudo dnf install -y cockpit cockpit-machines libvirt`);
1426
+ shellExec(`sudo systemctl enable --now cockpit.socket libvirtd`);
1427
+
1428
+ if (firewalld()) {
1429
+ // Removed then re-added rather than added alone: a permanent rule from a
1430
+ // previous release may name a different zone or port set, and re-adding
1431
+ // over it leaves both in place.
1432
+ shellExec(`sudo firewall-cmd --zone=public --remove-service=cockpit --permanent`, { silentOnError: true });
1433
+ shellExec(`sudo firewall-cmd --permanent --add-service=cockpit`);
1434
+ shellExec(`sudo firewall-cmd --reload`);
1435
+ } else logger.warn('firewalld is not running; Cockpit port 9090 was not opened');
1436
+
1437
+ const status = shellExec(`sudo systemctl is-active cockpit.socket`, {
1438
+ stdout: true,
1439
+ silent: true,
1440
+ silentOnError: true,
1441
+ });
1442
+ logger.info('Cockpit KVM dashboard ready', {
1443
+ url: `https://${os.hostname()}:9090`,
1444
+ socket: `${status || ''}`.trim() || 'unknown',
1445
+ });
1446
+ return true;
1447
+ },
1448
+
284
1449
  /**
285
1450
  * Checks the status of a deployment.
286
1451
  * @param {string} deployId - Deployment ID for which the status is being checked.
@@ -352,6 +1517,136 @@ class UnderpostMonitor {
352
1517
  });
353
1518
  });
354
1519
  },
1520
+ /**
1521
+ * @method readDeployedEventState
1522
+ * @description The events the cluster is currently running, read from the
1523
+ * ConfigMaps it serves them from, with whether the cluster actually answered.
1524
+ *
1525
+ * The cluster is the state. Asking it what is deployed is what makes a
1526
+ * scoped `--deploy` additive instead of a replacement: the alternative is a
1527
+ * local file that claims what should be running and is wrong the moment
1528
+ * anyone applies anything by hand.
1529
+ *
1530
+ * A cluster that does not answer is not an empty cluster. Reporting the two
1531
+ * the same way makes an unreachable API server look like a stack with
1532
+ * nothing deployed, which reads as `PENDING` everywhere and renders a
1533
+ * `--deploy` set that withdraws every event it does not name.
1534
+ * @param {object} [params]
1535
+ * @param {string} [params.namespace='default'] - Namespace holding the stack.
1536
+ * @returns {{readable: boolean, ids: string[], reason: string}} Deployed set, empty and
1537
+ * unreadable when the cluster did not answer.
1538
+ * @memberof UnderpostMonitor
1539
+ */
1540
+ readDeployedEventState({ namespace = 'default' } = {}) {
1541
+ const { prometheus } = UNDERPOST_MONITORING;
1542
+ const read = (name) => {
1543
+ const result = shellExec(`kubectl get configmap ${name} -n ${namespace} -o jsonpath='{.data}'`, {
1544
+ silent: true,
1545
+ silentOnError: true,
1546
+ disableLog: true,
1547
+ });
1548
+ // kubectl repeats its discovery failure once per API group; the last
1549
+ // line is the one that names what actually went wrong.
1550
+ const reason =
1551
+ `${result?.stderr || ''}`
1552
+ .split('\n')
1553
+ .map((line) => line.trim())
1554
+ .filter(Boolean)
1555
+ .pop() || '';
1556
+ // A ConfigMap the cluster answered for and does not hold is an empty
1557
+ // set; every other failure leaves the deployed set unknown.
1558
+ const absent = /notfound|not found/i.test(reason);
1559
+ return { readable: result?.code === 0 || absent, data: `${result?.stdout || ''}`, reason };
1560
+ };
1561
+ const rules = read(prometheus.rulesConfigMapName);
1562
+ const conf = read(prometheus.configMapName);
1563
+ const readable = rules.readable && conf.readable;
1564
+ return {
1565
+ readable,
1566
+ ids: readable ? deployedEventIdsFactory(rules.data, conf.data) : [],
1567
+ reason: readable ? '' : rules.reason || conf.reason,
1568
+ };
1569
+ },
1570
+
1571
+ /**
1572
+ * @method readDeployedEventIds
1573
+ * @description The deployed event ids alone; an unreadable cluster reads as empty.
1574
+ * @param {object} [params]
1575
+ * @param {string} [params.namespace='default'] - Namespace holding the stack.
1576
+ * @returns {string[]} Distinct event ids, sorted.
1577
+ * @memberof UnderpostMonitor
1578
+ */
1579
+ readDeployedEventIds({ namespace = 'default' } = {}) {
1580
+ return Underpost.monitor.readDeployedEventState({ namespace }).ids;
1581
+ },
1582
+
1583
+ /**
1584
+ * @method awaitProbes
1585
+ * @description Reads event probes from the Blackbox Exporter, and waits for
1586
+ * them to reach an expected state.
1587
+ *
1588
+ * Reads the same `/probe` endpoint Prometheus scrapes, so what this observes
1589
+ * is what detection observes — a second implementation of ICMP or TCP here
1590
+ * would be able to disagree with the component that actually fires alerts.
1591
+ *
1592
+ * The exporter is a ClusterIP Service, reached through the API server's
1593
+ * service proxy: one stateless request per read, with no local listener and
1594
+ * no background child, so the read works identically on the node that hosts
1595
+ * the stack and from any machine that can run it there over SSH.
1596
+ *
1597
+ * @param {object} params
1598
+ * @param {Array<{module: string, target: string}>} params.probes - Probes to read.
1599
+ * @param {boolean} [params.expect=true] - `probe_success` state every probe must reach.
1600
+ * @param {string} [params.namespace='default'] - Namespace holding the exporter.
1601
+ * @param {number} [params.timeoutMs=0] - Maximum wait; `0` reads once.
1602
+ * @param {number} [params.intervalMs=5000] - Poll interval.
1603
+ * @param {{user?: string, host?: string}} [params.target] - Account that reaches the cluster; empty reads locally.
1604
+ * @returns {Promise<{ok: boolean, readable: boolean, elapsedMs: number, observations: Array<object>}>} Last observation set.
1605
+ * @memberof UnderpostMonitor
1606
+ */
1607
+ async awaitProbes({
1608
+ probes = [],
1609
+ expect = true,
1610
+ namespace = 'default',
1611
+ timeoutMs = 0,
1612
+ intervalMs = 5000,
1613
+ target = {},
1614
+ }) {
1615
+ if (probes.length === 0) return { ok: false, readable: false, elapsedMs: 0, observations: [] };
1616
+
1617
+ const { blackbox } = UNDERPOST_MONITORING;
1618
+ const startedAt = Date.now();
1619
+ const deadline = startedAt + Math.max(0, timeoutMs);
1620
+
1621
+ const read = async (probe) => {
1622
+ const path =
1623
+ `/api/v1/namespaces/${namespace}/services/${blackbox.name}:${blackbox.port}/proxy/probe` +
1624
+ `?module=${encodeURIComponent(probe.module)}&target=${encodeURIComponent(probe.target)}`;
1625
+ const result = await Underpost.event.runCommand(`kubectl get --raw '${path}'`, { ...target, silent: true });
1626
+ const match = `${result.output || ''}`.match(/^probe_success (\d)$/m);
1627
+ return {
1628
+ ...probe,
1629
+ success: match?.[1] === '1',
1630
+ read: Boolean(match),
1631
+ ...(match ? {} : { error: `${result.error || 'no probe_success line'}`.trim().slice(-200) }),
1632
+ };
1633
+ };
1634
+
1635
+ let observations = [];
1636
+ do {
1637
+ observations = [];
1638
+ for (const probe of probes) observations.push(await read(probe));
1639
+ // An unreadable probe is not an observation: the exporter being
1640
+ // unreachable must never be reported as a failing subject. `readable`
1641
+ // is returned so a caller can tell "the subject is down" from "nothing
1642
+ // could be asked about it".
1643
+ const readable = observations.every((observation) => observation.read);
1644
+ const ok = readable && observations.every((observation) => observation.success === expect);
1645
+ if (ok || Date.now() >= deadline) return { ok, readable, elapsedMs: Date.now() - startedAt, observations };
1646
+ await timer(intervalMs);
1647
+ } while (true);
1648
+ },
1649
+
355
1650
  /**
356
1651
  * Resolves the deployment's internal status port (Phase-2 transport target).
357
1652
  *
@@ -380,7 +1675,7 @@ class UnderpostMonitor {
380
1675
  /**
381
1676
  * Reads Phase-2 runtime status from a single pod using the selected transport.
382
1677
  *
383
- * - `exec` (default): `kubectl exec … underpost config get container-status`
1678
+ * - `exec` (default): `kubectl exec … ` + {@link CONTAINER_STATUS_READ}
384
1679
  * reads the env-file value. Synchronous, no background process — required
385
1680
  * for custom instances (cyberia-server/client) and the safe choice for
386
1681
  * CI/SSH. See `Deploy custom instance to K8S.md`.
@@ -411,14 +1706,53 @@ class UnderpostMonitor {
411
1706
  * @returns {{ok: boolean, status?: (string|null), transportError?: string}}
412
1707
  * @memberof UnderpostMonitor
413
1708
  */
414
- readRuntimeStatusViaExec(podName, namespace) {
1709
+ /**
1710
+ * Full telemetry for one pod, read from the runtime's own internal endpoint.
1711
+ *
1712
+ * The endpoint is served by the long-lived server process, so its metrics describe that
1713
+ * process. Running the CLI instead would report the metrics of the short-lived CLI — a
1714
+ * constant ~2s uptime and its own heap, never the runtime's.
1715
+ * @param {string} podName
1716
+ * @param {string} namespace
1717
+ * @param {number} internalPort
1718
+ * @returns {{ok: boolean, telemetry?: object, transportError?: string}}
1719
+ * @memberof UnderpostMonitor
1720
+ */
1721
+ readRuntimeTelemetryViaExec(podName, namespace, internalPort) {
1722
+ if (!internalPort) return { ok: false, transportError: 'no_internal_port' };
1723
+ const fetchScript = `fetch('http://127.0.0.1:${internalPort}${INTERNAL_TELEMETRY_PATH}').then(r=>r.text()).then(t=>process.stdout.write(t)).catch(()=>{})`;
415
1724
  try {
416
- const raw = shellExec(
417
- `sudo kubectl exec ${podName} -n ${namespace} -- sh -c 'underpost config get container-status --plain'`,
1725
+ const result = shellExec(
1726
+ `sudo kubectl exec ${podName} -n ${namespace} -- node -e ${shellArgumentFactory(fetchScript)}`,
418
1727
  { silent: true, disableLog: true, stdout: true, silentOnError: true },
419
1728
  );
420
- const status = normalizeContainerStatus(raw ? raw.toString().trim() : '');
421
- return status === undefined ? { ok: false, transportError: 'empty_status' } : { ok: true, status };
1729
+ const raw = `${result ?? ''}`.trim();
1730
+ const start = raw.indexOf('{');
1731
+ if (start === -1) return { ok: false, transportError: 'no_telemetry' };
1732
+ try {
1733
+ return { ok: true, telemetry: JSON.parse(raw.slice(start)) };
1734
+ } catch {
1735
+ return { ok: false, transportError: 'unparsable_telemetry' };
1736
+ }
1737
+ } catch (error) {
1738
+ return { ok: false, transportError: error?.code || error?.message || 'exec_failed' };
1739
+ }
1740
+ },
1741
+ readRuntimeStatusViaExec(podName, namespace) {
1742
+ try {
1743
+ const result = shellExec(`sudo kubectl exec ${podName} -n ${namespace} -- sh -c ${CONTAINER_STATUS_READ}`, {
1744
+ silent: true,
1745
+ disableLog: true,
1746
+ stdout: true,
1747
+ silentOnError: true,
1748
+ });
1749
+ const status = normalizeContainerStatus(`${result ?? ''}`.trim());
1750
+ if (status !== undefined) return { ok: true, status };
1751
+ // Report what the pod actually said. Collapsing every unreadable answer to `empty_status`
1752
+ // hid an `unknown command` from a pod whose CLI predates the one the read was written
1753
+ // for, and the monitor polled that unchanged message until it timed out.
1754
+ const stderr = `${result?.stderr ?? ''}`.trim().split('\n').filter(Boolean).pop();
1755
+ return { ok: false, transportError: stderr ? `${stderr} (exit ${result?.code ?? '?'})` : 'empty_status' };
422
1756
  } catch (error) {
423
1757
  return { ok: false, transportError: error?.code || error?.message || 'exec_failed' };
424
1758
  }
@@ -644,7 +1978,6 @@ class UnderpostMonitor {
644
1978
  }
645
1979
 
646
1980
  await timer(delayMs);
647
- if ((i + 1) % 10 === 0) logger.info(`${tag} | In progress... iteration ${i + 1}`);
648
1981
  }
649
1982
 
650
1983
  emit('timeout');