@underpostnet/cyberia 3.2.90 → 3.3.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (490) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +16 -0
  3. package/.github/workflows/coverall.cyberia.ci.yml +107 -0
  4. package/.github/workflows/cyberia-client.cd.yml +6 -18
  5. package/.github/workflows/cyberia-server.cd.yml +6 -18
  6. package/.github/workflows/docker-image.cyberia-client.ci.yml +8 -5
  7. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +8 -3
  8. package/.github/workflows/docker-image.cyberia-server.ci.yml +8 -5
  9. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +8 -5
  10. package/.github/workflows/docker-image.engine-cyberia.ci.yml +15 -1
  11. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +15 -1
  12. package/.github/workflows/engine-cyberia.cd.yml +6 -78
  13. package/.github/workflows/engine-cyberia.ci.yml +4 -5
  14. package/.github/workflows/ghpkg.ci.yml +42 -21
  15. package/.github/workflows/hardhat.ci.yml +12 -11
  16. package/.github/workflows/npmpkg.ci.yml +17 -15
  17. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  18. package/.github/workflows/pwa-microservices-template-test.ci.yml +2 -2
  19. package/.github/workflows/release.cd.yml +2 -9
  20. package/.prettierignore +1 -0
  21. package/AGENTS.md +47 -0
  22. package/CHANGELOG.md +581 -2417
  23. package/CLI-HELP.md +416 -127
  24. package/Dockerfile +25 -9
  25. package/Dockerfile.dev +25 -9
  26. package/Dockerfile.test +4 -4
  27. package/README.md +21 -1
  28. package/bin/build.js +70 -43
  29. package/bin/build.template.js +8 -3
  30. package/bin/cyberia.js +372 -135
  31. package/bin/deploy.js +18 -104
  32. package/bin/index.js +372 -135
  33. package/compose.env +16 -0
  34. package/conf.js +93 -4
  35. package/deploy/cyberia-client/deploy.sh +47 -0
  36. package/deploy/cyberia-client/package.sh +18 -0
  37. package/deploy/cyberia-client/state.sh +14 -0
  38. package/deploy/cyberia-server/deploy.sh +47 -0
  39. package/deploy/cyberia-server/package.sh +18 -0
  40. package/deploy/cyberia-server/state.sh +14 -0
  41. package/deploy/dd-cyberia/deploy.sh +22 -0
  42. package/deploy/dd-cyberia/init.sh +55 -0
  43. package/deploy/dd-cyberia/package.sh +17 -0
  44. package/deploy/dd-cyberia/state.sh +14 -0
  45. package/deploy/dd-cyberia/sync-deploy.sh +116 -0
  46. package/deploy/lib/github-actions-logging.sh +594 -0
  47. package/deploy/lib/host.sh +185 -0
  48. package/deploy/lib/state.sh +62 -0
  49. package/deploy/pwa-microservices-template/deploy.sh +45 -0
  50. package/deploy/release/deploy.sh +32 -0
  51. package/deployment.yaml +7 -2
  52. package/docker-compose.yml +31 -37
  53. package/examples/static-page/README.md +19 -19
  54. package/examples/static-page/ssr-components/CustomPage.js +2 -2
  55. package/hardhat/hardhat.config.js +1 -6
  56. package/hardhat/package-lock.json +104 -43
  57. package/hardhat/package.json +3 -3
  58. package/manifests/alertmanager/deployment.yaml +81 -0
  59. package/manifests/blackbox-exporter/deployment.yaml +75 -0
  60. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +36 -7
  61. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +77 -0
  62. package/manifests/deployment/dd-cyberia-development/deployment.yaml +7 -2
  63. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  64. package/manifests/grafana/deployment.yaml +26 -7
  65. package/manifests/grafana/kustomization.yaml +1 -0
  66. package/manifests/grafana/service.yaml +6 -1
  67. package/manifests/mariadb/pv.yaml +1 -0
  68. package/manifests/mongodb/statefulset.yaml +9 -2
  69. package/manifests/mysql/pv-pvc.yaml +14 -1
  70. package/manifests/postgresql/pv.yaml +12 -1
  71. package/manifests/postgresql/pvc.yaml +1 -0
  72. package/manifests/prometheus/deployment.yaml +18 -22
  73. package/package.json +83 -10
  74. package/scripts/audit-selinux.sh +64 -0
  75. package/scripts/event-monitor.sh +56 -0
  76. package/scripts/gpu-diag.sh +0 -0
  77. package/scripts/ip-info.sh +0 -0
  78. package/scripts/k3s-node-setup.sh +30 -14
  79. package/scripts/kubeadm-node-setup.sh +40 -31
  80. package/scripts/lxd-vm-setup.sh +0 -0
  81. package/scripts/maas-nat-firewalld.sh +0 -0
  82. package/scripts/maas-setup.sh +8 -8
  83. package/scripts/nat-iptables.sh +2 -0
  84. package/scripts/rhel-grpc-setup.sh +0 -0
  85. package/scripts/rocky-kickstart.sh +25 -9
  86. package/scripts/selinux-normalize.sh +907 -0
  87. package/scripts/shutdown-machine.sh +843 -0
  88. package/scripts/test-monitor.sh +3 -3
  89. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +1 -1
  90. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +2 -2
  91. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +2 -2
  92. package/src/api/core/core.controller.js +1 -1
  93. package/src/api/core/core.router.js +2 -2
  94. package/src/api/core/core.service.js +2 -2
  95. package/src/api/crypto/crypto.controller.js +1 -1
  96. package/src/api/crypto/crypto.service.js +1 -1
  97. package/src/api/cyberia-action/cyberia-action.controller.js +1 -1
  98. package/src/api/cyberia-action/cyberia-action.router.js +1 -1
  99. package/src/api/cyberia-action/cyberia-action.service.js +2 -2
  100. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -1
  101. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +1 -1
  102. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +2 -2
  103. package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +1 -1
  104. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  105. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +2 -2
  106. package/src/api/cyberia-entity/cyberia-entity.controller.js +1 -1
  107. package/src/api/cyberia-entity/cyberia-entity.router.js +1 -1
  108. package/src/api/cyberia-entity/cyberia-entity.service.js +2 -2
  109. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +1 -1
  110. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +1 -1
  111. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +2 -2
  112. package/src/api/cyberia-instance/cyberia-fallback-capture.js +466 -0
  113. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +63 -0
  114. package/src/api/cyberia-instance/cyberia-fallback-world.js +16 -1
  115. package/src/api/cyberia-instance/cyberia-instance-map.service.js +1 -1
  116. package/src/api/cyberia-instance/cyberia-instance.controller.js +4 -1
  117. package/src/api/cyberia-instance/cyberia-instance.router.js +15 -2
  118. package/src/api/cyberia-instance/cyberia-instance.service.js +45 -2
  119. package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +1 -1
  120. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +1 -0
  121. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +1 -1
  122. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +2 -2
  123. package/src/api/cyberia-map/cyberia-map.controller.js +1 -1
  124. package/src/api/cyberia-map/cyberia-map.router.js +1 -1
  125. package/src/api/cyberia-map/cyberia-map.service.js +2 -2
  126. package/src/api/cyberia-quest/cyberia-quest.controller.js +1 -1
  127. package/src/api/cyberia-quest/cyberia-quest.router.js +1 -1
  128. package/src/api/cyberia-quest/cyberia-quest.service.js +2 -2
  129. package/src/api/cyberia-quest-progress/cyberia-quest-progress.controller.js +1 -1
  130. package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +1 -1
  131. package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +2 -2
  132. package/src/api/cyberia-saga/cyberia-saga.controller.js +1 -1
  133. package/src/api/cyberia-saga/cyberia-saga.router.js +1 -1
  134. package/src/api/cyberia-saga/cyberia-saga.service.js +2 -2
  135. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +16 -29
  136. package/src/api/cyberia-skill/cyberia-skill.controller.js +1 -1
  137. package/src/api/cyberia-skill/cyberia-skill.router.js +1 -1
  138. package/src/api/cyberia-skill/cyberia-skill.service.js +2 -2
  139. package/src/api/default/default.controller.js +1 -1
  140. package/src/api/default/default.router.js +1 -1
  141. package/src/api/default/default.service.js +2 -2
  142. package/src/api/document/document.controller.js +1 -1
  143. package/src/api/document/document.router.js +1 -1
  144. package/src/api/document/document.service.js +2 -2
  145. package/src/api/file/file.controller.js +1 -1
  146. package/src/api/file/file.router.js +1 -1
  147. package/src/api/file/file.service.js +2 -2
  148. package/src/api/instance/instance.controller.js +1 -1
  149. package/src/api/instance/instance.router.js +1 -1
  150. package/src/api/instance/instance.service.js +2 -2
  151. package/src/api/ipfs/ipfs.controller.js +1 -1
  152. package/src/api/ipfs/ipfs.router.js +2 -2
  153. package/src/api/ipfs/ipfs.service.js +2 -2
  154. package/src/api/object-layer/object-layer.controller.js +1 -1
  155. package/src/api/object-layer/object-layer.model.js +204 -3
  156. package/src/api/object-layer/object-layer.router.js +2 -2
  157. package/src/api/object-layer/object-layer.service.js +4 -17
  158. package/src/api/object-layer-render-frames/object-layer-render-frames.controller.js +1 -1
  159. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +1 -1
  160. package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +2 -2
  161. package/src/api/test/test.controller.js +1 -1
  162. package/src/api/test/test.service.js +1 -1
  163. package/src/api/user/guest.service.js +3 -3
  164. package/src/api/user/user.controller.js +1 -1
  165. package/src/api/user/user.router.js +2 -2
  166. package/src/api/user/user.service.js +4 -4
  167. package/src/api.js +5 -9
  168. package/src/cli/app.js +391 -0
  169. package/src/cli/baremetal.js +30 -36
  170. package/src/cli/client.js +306 -0
  171. package/src/cli/cloud-init.js +3 -3
  172. package/src/cli/cluster.js +242 -135
  173. package/src/cli/db.js +126 -67
  174. package/src/cli/deploy.js +158 -60
  175. package/src/cli/docker-compose.js +5 -5
  176. package/src/cli/domains.js +184 -0
  177. package/src/cli/dotenv-store.js +143 -0
  178. package/src/cli/event.js +2281 -0
  179. package/src/cli/fs.js +3 -3
  180. package/src/cli/host.js +672 -0
  181. package/src/cli/image.js +4 -7
  182. package/src/cli/index.js +426 -134
  183. package/src/cli/ipfs.js +34 -30
  184. package/src/cli/kickstart.js +1 -1
  185. package/src/cli/kubectl.js +218 -41
  186. package/src/cli/lxd.js +3 -3
  187. package/src/cli/monitor.js +1359 -26
  188. package/src/cli/package.js +90 -0
  189. package/src/cli/release.js +73 -34
  190. package/src/cli/repository.js +564 -367
  191. package/src/cli/run.js +362 -654
  192. package/src/cli/secrets.js +2158 -964
  193. package/src/cli/ssh.js +651 -331
  194. package/src/cli/state.js +394 -0
  195. package/src/cli/static.js +2 -2
  196. package/src/cli/system.js +26 -13
  197. package/src/cli/test.js +199 -125
  198. package/src/cli/vultr.js +623 -0
  199. package/src/cli/wireguard.js +2930 -0
  200. package/src/client/components/core/CalendarCore.js +53 -96
  201. package/src/client/components/core/Docs.js +258 -211
  202. package/src/client/components/core/FileExplorer.js +0 -21
  203. package/src/client/components/core/PanelForm.js +4 -4
  204. package/src/client/components/core/Repository.js +56 -0
  205. package/src/client/components/core/Translate.js +7 -0
  206. package/src/client/components/core/{FullScreen.js → ViewModeController.js} +42 -39
  207. package/src/client/components/core/Wallet.js +0 -11
  208. package/src/client/components/core/Worker.js +7 -1
  209. package/src/client/components/cryptokoyn/SettingsCryptokoyn.js +2 -2
  210. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +368 -0
  211. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +35 -1
  212. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +4 -0
  213. package/src/client/components/cyberia-portal/SettingsCyberiaPortal.js +2 -2
  214. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +4 -0
  215. package/src/client/components/default/AppShellDefault.js +1 -1
  216. package/src/client/components/default/SettingsDefault.js +2 -2
  217. package/src/client/components/itemledger/SettingsItemledger.js +2 -2
  218. package/src/client/components/underpost/AppShellUnderpost.js +3 -2
  219. package/src/client/components/underpost/SettingsUnderpost.js +2 -2
  220. package/src/client/public/cyberia-docs/ARCHITECTURE.md +17 -3
  221. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +32 -5
  222. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +1 -1
  223. package/src/client/public/cyberia-docs/CYBERIA.md +21 -1
  224. package/src/client/public/cyberia-docs/HARDHAT-MODULE.md +23 -6
  225. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  226. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  227. package/src/client/public/default/android-chrome-144x144.png +0 -0
  228. package/src/client/public/default/android-chrome-192x192.png +0 -0
  229. package/src/client/public/default/android-chrome-256x256.png +0 -0
  230. package/src/client/public/default/android-chrome-36x36.png +0 -0
  231. package/src/client/public/default/android-chrome-384x384.png +0 -0
  232. package/src/client/public/default/android-chrome-48x48.png +0 -0
  233. package/src/client/public/default/android-chrome-512x512.png +0 -0
  234. package/src/client/public/default/android-chrome-72x72.png +0 -0
  235. package/src/client/public/default/android-chrome-96x96.png +0 -0
  236. package/src/client/public/default/apple-touch-icon-1024x1024.png +0 -0
  237. package/src/client/public/default/apple-touch-icon-114x114.png +0 -0
  238. package/src/client/public/default/apple-touch-icon-120x120.png +0 -0
  239. package/src/client/public/default/apple-touch-icon-144x144.png +0 -0
  240. package/src/client/public/default/apple-touch-icon-152x152.png +0 -0
  241. package/src/client/public/default/apple-touch-icon-167x167.png +0 -0
  242. package/src/client/public/default/apple-touch-icon-180x180.png +0 -0
  243. package/src/client/public/default/apple-touch-icon-57x57.png +0 -0
  244. package/src/client/public/default/apple-touch-icon-60x60.png +0 -0
  245. package/src/client/public/default/apple-touch-icon-72x72.png +0 -0
  246. package/src/client/public/default/apple-touch-icon-76x76.png +0 -0
  247. package/src/client/public/default/apple-touch-icon-precomposed.png +0 -0
  248. package/src/client/public/default/apple-touch-icon.png +0 -0
  249. package/src/client/public/default/apple-touch-startup-image-1125x2436.png +0 -0
  250. package/src/client/public/default/apple-touch-startup-image-1136x640.png +0 -0
  251. package/src/client/public/default/apple-touch-startup-image-1170x2532.png +0 -0
  252. package/src/client/public/default/apple-touch-startup-image-1179x2556.png +0 -0
  253. package/src/client/public/default/apple-touch-startup-image-1242x2208.png +0 -0
  254. package/src/client/public/default/apple-touch-startup-image-1242x2688.png +0 -0
  255. package/src/client/public/default/apple-touch-startup-image-1284x2778.png +0 -0
  256. package/src/client/public/default/apple-touch-startup-image-1290x2796.png +0 -0
  257. package/src/client/public/default/apple-touch-startup-image-1334x750.png +0 -0
  258. package/src/client/public/default/apple-touch-startup-image-1488x2266.png +0 -0
  259. package/src/client/public/default/apple-touch-startup-image-1536x2048.png +0 -0
  260. package/src/client/public/default/apple-touch-startup-image-1620x2160.png +0 -0
  261. package/src/client/public/default/apple-touch-startup-image-1640x2160.png +0 -0
  262. package/src/client/public/default/apple-touch-startup-image-1668x2224.png +0 -0
  263. package/src/client/public/default/apple-touch-startup-image-1668x2388.png +0 -0
  264. package/src/client/public/default/apple-touch-startup-image-1792x828.png +0 -0
  265. package/src/client/public/default/apple-touch-startup-image-2048x1536.png +0 -0
  266. package/src/client/public/default/apple-touch-startup-image-2048x2732.png +0 -0
  267. package/src/client/public/default/apple-touch-startup-image-2160x1620.png +0 -0
  268. package/src/client/public/default/apple-touch-startup-image-2160x1640.png +0 -0
  269. package/src/client/public/default/apple-touch-startup-image-2208x1242.png +0 -0
  270. package/src/client/public/default/apple-touch-startup-image-2224x1668.png +0 -0
  271. package/src/client/public/default/apple-touch-startup-image-2266x1488.png +0 -0
  272. package/src/client/public/default/apple-touch-startup-image-2388x1668.png +0 -0
  273. package/src/client/public/default/apple-touch-startup-image-2436x1125.png +0 -0
  274. package/src/client/public/default/apple-touch-startup-image-2532x1170.png +0 -0
  275. package/src/client/public/default/apple-touch-startup-image-2556x1179.png +0 -0
  276. package/src/client/public/default/apple-touch-startup-image-2688x1242.png +0 -0
  277. package/src/client/public/default/apple-touch-startup-image-2732x2048.png +0 -0
  278. package/src/client/public/default/apple-touch-startup-image-2778x1284.png +0 -0
  279. package/src/client/public/default/apple-touch-startup-image-2796x1290.png +0 -0
  280. package/src/client/public/default/apple-touch-startup-image-640x1136.png +0 -0
  281. package/src/client/public/default/apple-touch-startup-image-750x1334.png +0 -0
  282. package/src/client/public/default/apple-touch-startup-image-828x1792.png +0 -0
  283. package/src/client/public/default/assets/background/white.jpg +0 -0
  284. package/src/client/public/default/browserconfig.xml +1 -1
  285. package/src/client/public/default/favicon-16x16.png +0 -0
  286. package/src/client/public/default/favicon-32x32.png +0 -0
  287. package/src/client/public/default/favicon-48x48.png +0 -0
  288. package/src/client/public/default/favicon.ico +0 -0
  289. package/src/client/public/default/manifest.webmanifest +5 -5
  290. package/src/client/public/default/mstile-144x144.png +0 -0
  291. package/src/client/public/default/mstile-150x150.png +0 -0
  292. package/src/client/public/default/mstile-310x150.png +0 -0
  293. package/src/client/public/default/mstile-310x310.png +0 -0
  294. package/src/client/public/default/mstile-70x70.png +0 -0
  295. package/src/client/public/default/yandex-browser-50x50.png +0 -0
  296. package/src/client/public/default/yandex-browser-manifest.json +1 -1
  297. package/src/client/services/cyberia-instance/cyberia-instance.service.js +40 -0
  298. package/src/client/services/object-layer/object-layer.management.js +4 -4
  299. package/src/client/ssr/RootDocument.js +1 -1
  300. package/src/client/ssr/body/404.js +1 -1
  301. package/src/client/ssr/body/500.js +1 -1
  302. package/src/client/ssr/body/CacheControl.js +1 -1
  303. package/src/client/ssr/body/CyberiaDefaultSplashScreen.js +1 -1
  304. package/src/client/ssr/body/DefaultSplashScreen.js +1 -1
  305. package/src/client/ssr/body/SwaggerDarkMode.js +1 -1
  306. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +1 -1
  307. package/src/client/ssr/head/CryptokoynScripts.js +1 -1
  308. package/src/client/ssr/head/Css.js +1 -1
  309. package/src/client/ssr/head/CyberiaPortalScripts.js +1 -1
  310. package/src/client/ssr/head/DefaultScripts.js +1 -1
  311. package/src/client/ssr/head/ItemledgerScripts.js +1 -1
  312. package/src/client/ssr/head/Microdata.js +1 -1
  313. package/src/client/ssr/head/Production.js +1 -1
  314. package/src/client/ssr/head/Pwa.js +1 -1
  315. package/src/client/ssr/head/PwaItemledger.js +1 -1
  316. package/src/client/ssr/head/Seo.js +1 -1
  317. package/src/client/ssr/head/UnderpostScripts.js +1 -1
  318. package/src/client/ssr/mailer/DefaultRecoverEmail.js +1 -1
  319. package/src/client/ssr/mailer/DefaultVerifyEmail.js +1 -1
  320. package/src/client/ssr/views/Cyberia404.js +7 -5
  321. package/src/client/ssr/views/CyberiaServerMetrics.js +1 -1
  322. package/src/client/ssr/views/Maintenance.js +1 -1
  323. package/src/client/ssr/views/NoNetworkConnection.js +1 -1
  324. package/src/client/ssr/views/Test.js +1 -1
  325. package/src/client/sw/core.sw.js +20 -14
  326. package/src/client-builder/client-build-docs.js +61 -46
  327. package/src/client-builder/client-build-live.js +2 -2
  328. package/src/client-builder/client-build.js +58 -44
  329. package/src/client-builder/client-dev-server.js +3 -3
  330. package/src/client-builder/client-icons.js +2 -2
  331. package/src/client-builder/ssr.js +3 -3
  332. package/src/client.build.js +3 -6
  333. package/src/client.dev.js +3 -6
  334. package/src/db/DataBaseProvider.js +186 -3
  335. package/src/db/mariadb/MariaDB.js +3 -2
  336. package/src/db/mongo/MongoBootstrap.js +273 -101
  337. package/src/db/mongo/MongooseDB.js +11 -8
  338. package/src/{server/valkey.js → db/valkey/Valkey.js} +5 -4
  339. package/src/grpc/cyberia/grpc-server.js +1 -1
  340. package/src/index.js +103 -23
  341. package/src/mailer/EmailRender.js +2 -2
  342. package/src/mailer/MailerInterceptor.js +87 -0
  343. package/src/mailer/MailerProvider.js +50 -6
  344. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +1 -1
  345. package/src/projects/cyberia/besu-genesis-generator.js +3 -3
  346. package/src/projects/cyberia/catalog-cyberia.js +54 -6
  347. package/src/projects/cyberia/gemini-client.js +1 -1
  348. package/src/projects/cyberia/generate-saga.js +15 -24
  349. package/src/projects/cyberia/hot-reload-trigger.js +1 -1
  350. package/src/projects/cyberia/instance-data.js +22 -3
  351. package/src/projects/cyberia/ipfs-client.js +4 -4
  352. package/src/projects/cyberia/map-preview-generator.js +1 -1
  353. package/src/projects/cyberia/object-layer.js +12 -22
  354. package/src/projects/cyberia/semantic-layer-generator.js +1 -1
  355. package/src/projects/underpost/catalog-underpost.js +13 -2
  356. package/src/proxy.js +4 -7
  357. package/src/runtime/cyberia-client/Dockerfile +9 -4
  358. package/src/runtime/cyberia-client/Dockerfile.dev +8 -2
  359. package/src/runtime/cyberia-server/Dockerfile +9 -4
  360. package/src/runtime/cyberia-server/Dockerfile.dev +8 -2
  361. package/src/runtime/engine-cyberia/Dockerfile +25 -9
  362. package/src/runtime/engine-cyberia/Dockerfile.dev +25 -9
  363. package/src/runtime/engine-cyberia/Dockerfile.test +4 -4
  364. package/src/runtime/engine-cyberia/compose.env +16 -0
  365. package/src/runtime/engine-cyberia/docker-compose.yml +31 -37
  366. package/src/runtime/express/Express.js +32 -27
  367. package/src/runtime/lampp/Lampp.js +22 -70
  368. package/src/runtime/nginx/Nginx.js +1 -1
  369. package/src/runtime/wp/Wp.js +16 -48
  370. package/src/server/{catalog.js → build/catalog.js} +15 -5
  371. package/src/server/build/coverage.js +117 -0
  372. package/src/server/build/execution.js +402 -0
  373. package/src/server/build/package.js +465 -0
  374. package/src/server/build/testing.js +528 -0
  375. package/src/server/{dns.js → network/dns.js} +241 -14
  376. package/src/server/network/forward-proxy.js +541 -0
  377. package/src/server/{middlewares.js → network/middlewares.js} +59 -4
  378. package/src/server/network/node-capability.js +98 -0
  379. package/src/server/{peer.js → network/peer.js} +3 -3
  380. package/src/server/{proxy.js → network/proxy.js} +7 -13
  381. package/src/server/network/router.js +356 -0
  382. package/src/server/{tls.js → network/tls.js} +2 -2
  383. package/src/server/network/underpost-compression.js +186 -0
  384. package/src/server/{underpost-gateway.js → network/underpost-gateway.js} +26 -16
  385. package/src/server/{underpost-ingress.js → network/underpost-ingress.js} +19 -3
  386. package/src/server/{cri.js → ops/cri.js} +2 -2
  387. package/src/server/ops/cron.js +949 -0
  388. package/src/server/ops/event-notification.js +284 -0
  389. package/src/server/{logger.js → ops/logger.js} +108 -23
  390. package/src/server/ops/monitoring.js +1724 -0
  391. package/src/server/ops/systemd.js +259 -0
  392. package/src/server/{conf.js → runtime/conf.js} +521 -520
  393. package/src/server/runtime/config-scope.js +215 -0
  394. package/src/server/runtime/environment.js +149 -0
  395. package/src/server/{process.js → runtime/process.js} +153 -13
  396. package/src/server/{runtime-status.js → runtime/runtime-status.js} +155 -16
  397. package/src/server/{runtime.js → runtime/runtime.js} +12 -8
  398. package/src/server/{start.js → runtime/start.js} +128 -39
  399. package/src/server/{auth.js → security/auth.js} +7 -8
  400. package/src/server/security/container-storage.js +382 -0
  401. package/src/server/{crypto.js → security/crypto.js} +1 -1
  402. package/src/server/security/selinux.js +185 -0
  403. package/src/server/{backup.js → storage/backup.js} +17 -8
  404. package/src/server/{data-query.js → storage/data-query.js} +1 -1
  405. package/src/server/{downloader.js → storage/downloader.js} +2 -2
  406. package/src/server/storage/repository.js +68 -0
  407. package/src/server.js +4 -7
  408. package/src/ws/IoInterface.js +1 -1
  409. package/src/ws/IoServer.js +1 -1
  410. package/src/ws/core/core.ws.connection.js +1 -1
  411. package/src/ws/core/core.ws.emit.js +1 -1
  412. package/src/ws/core/core.ws.server.js +1 -1
  413. package/src/ws/default/default.ws.connection.js +1 -1
  414. package/src/ws/default/default.ws.emit.js +1 -1
  415. package/src/ws/default/default.ws.server.js +1 -1
  416. package/test/e2e/event-e2e-public-ingress-down.js +45 -0
  417. package/test/e2e/event-e2e-wireguard-server-down.js +38 -0
  418. package/test/e2e/event-e2e-wireguard-spoke-down.js +40 -0
  419. package/test/{api.test.js → integration/app/api.test.js} +33 -14
  420. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +30 -0
  421. package/test/{cyberia-instance-conf-defaults.test.js → integration/app/cyberia/cyberia-instance-conf-defaults.test.js} +2 -1
  422. package/test/integration/app/cyberia/cyberia-load.test.js +467 -0
  423. package/test/integration/app/cyberia/fallback-world-capture.test.js +253 -0
  424. package/test/integration/app/cyberia/object-layer-item-id.test.js +327 -0
  425. package/test/{shape-generator.test.js → integration/app/cyberia/shape-generator.test.js} +1 -1
  426. package/test/integration/infra/1-security/config-scope.test.js +305 -0
  427. package/test/integration/infra/1-security/container-storage.test.js +356 -0
  428. package/test/integration/infra/1-security/in-pod-cli-surface.test.js +86 -0
  429. package/test/integration/infra/1-security/secret-onboarding.test.js +930 -0
  430. package/test/integration/infra/1-security/selinux.test.js +69 -0
  431. package/test/{sops-secret-store.test.js → integration/infra/1-security/sops-secret-store.test.js} +415 -61
  432. package/test/integration/infra/1-security/systemd-service.test.js +70 -0
  433. package/test/integration/infra/1-security/underpost-config-secret.test.js +367 -0
  434. package/test/integration/infra/2-network/dns-firewall.test.js +566 -0
  435. package/test/integration/infra/2-network/node-capability.test.js +182 -0
  436. package/test/integration/infra/2-network/wireguard-cli.test.js +1163 -0
  437. package/test/integration/infra/2-network/wireguard-edge.test.js +1731 -0
  438. package/test/{cluster-instances.test.js → integration/infra/3-cluster/cluster-instances.test.js} +11 -15
  439. package/test/{deploy-node-placement.test.js → integration/infra/3-cluster/deploy-node-placement.test.js} +3 -3
  440. package/test/integration/infra/3-cluster/docker-compose-stack.test.js +313 -0
  441. package/test/integration/infra/4-ingress/deploy-routes.test.js +379 -0
  442. package/test/integration/infra/4-ingress/gateway-static-assets.test.js +453 -0
  443. package/test/{instance-traffic-plan.test.js → integration/infra/4-ingress/instance-traffic-plan.test.js} +10 -6
  444. package/test/{underpost-gateway.test.js → integration/infra/4-ingress/underpost-gateway.test.js} +47 -6
  445. package/test/{underpost-ingress.test.js → integration/infra/4-ingress/underpost-ingress.test.js} +56 -4
  446. package/test/integration/infra/5-observability/cron-jobs.test.js +652 -0
  447. package/test/{deploy-monitor.test.js → integration/infra/5-observability/deploy-monitor.test.js} +29 -31
  448. package/test/integration/infra/5-observability/event-notification.test.js +197 -0
  449. package/test/integration/infra/5-observability/event-remediation.test.js +502 -0
  450. package/test/integration/infra/5-observability/event-targets.test.js +1174 -0
  451. package/test/integration/infra/5-observability/monitoring-stack.test.js +902 -0
  452. package/test/support/shell-harness.js +73 -0
  453. package/test/unit/build-template.test.js +97 -0
  454. package/test/unit/build-workflow-offline.test.js +132 -0
  455. package/test/unit/catalog.test.js +71 -0
  456. package/test/unit/client-build-docs.test.js +334 -0
  457. package/test/unit/conf-loading.test.js +303 -0
  458. package/test/unit/conf-resolution.test.js +979 -0
  459. package/test/unit/coverage-artifact.test.js +130 -0
  460. package/test/{crypto.test.js → unit/crypto.test.js} +1 -1
  461. package/test/unit/deploy-env-resolution.test.js +38 -0
  462. package/test/unit/deploy-log-table.test.js +447 -0
  463. package/test/unit/execution-profiles.test.js +219 -0
  464. package/test/unit/fleet-sync-source.test.js +149 -0
  465. package/test/unit/logger-redaction.test.js +112 -0
  466. package/test/unit/package.test.js +403 -0
  467. package/test/unit/prepare-host.test.js +299 -0
  468. package/test/unit/process-environment.test.js +18 -0
  469. package/test/unit/propagation-message.test.js +78 -0
  470. package/test/unit/redeploy-plan.test.js +24 -0
  471. package/test/unit/release-bump.test.js +474 -0
  472. package/test/unit/ssh-output-redaction.test.js +28 -0
  473. package/test/unit/start-options.test.js +46 -0
  474. package/test/unit/test-tiers.test.js +193 -0
  475. package/vitest.config.js +58 -0
  476. package/.nycrc +0 -9
  477. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +0 -48
  478. package/manifests/mongodb/pv-pvc.yaml +0 -59
  479. package/scripts/link-local-underpost-cli.sh +0 -6
  480. package/src/api/user/user.build.js +0 -16
  481. package/src/cli/env.js +0 -177
  482. package/src/client/components/core/Blockchain.js +0 -41
  483. package/src/client/components/core/Blog.js +0 -9
  484. package/src/client/components/core/KeyboardAvoidance.js +0 -145
  485. package/src/client/public/default/assets/background/dark.jpg +0 -0
  486. package/src/client/public/default/assets/background/white0-min.jpg +0 -0
  487. package/src/client/public/default/assets/background/white0.jpg +0 -0
  488. package/src/client/public/doc/favicon.ico +0 -0
  489. package/src/client/public/doc/sitemap +0 -148
  490. package/src/server/cron.js +0 -476
@@ -0,0 +1,1724 @@
1
+ /**
2
+ * The cluster observability stack: Prometheus, Alertmanager, the Blackbox
3
+ * Exporter and Grafana.
4
+ *
5
+ * Every file these four read is derived, never hand-written: scrape targets come
6
+ * from the same `conf.server.json` the runtime binds, probe targets and alert
7
+ * rules come from the event registry that also holds the remediation handlers,
8
+ * and the Alertmanager route points back at the dispatcher that owns them. A
9
+ * probe with no handler and a handler with no probe are therefore both
10
+ * unrepresentable.
11
+ *
12
+ * This module is pure: it renders configuration and manifests from values it is
13
+ * given. Reading deploy configuration, talking to the cluster and reloading the
14
+ * running components belong to `src/cli/monitor.js`.
15
+ *
16
+ * @module src/server/ops/monitoring.js
17
+ * @namespace UnderpostMonitoring
18
+ */
19
+
20
+ import { containerStorageCommandsFactory } from '../security/container-storage.js';
21
+ import { systemdServiceCommandsFactory, systemdUnitFactory } from './systemd.js';
22
+
23
+ /**
24
+ * @constant UNDERPOST_MONITORING
25
+ * @description Identity of the observability workloads. One stack serves every
26
+ * deploy on the cluster, so these names are cluster-wide constants rather than
27
+ * per-deploy.
28
+ * @memberof UnderpostMonitoring
29
+ */
30
+ const UNDERPOST_MONITORING = {
31
+ scrapeInterval: '30s',
32
+ prometheus: {
33
+ name: 'prometheus',
34
+ configMapName: 'prometheus-config',
35
+ rulesConfigMapName: 'prometheus-rules',
36
+ port: 9090,
37
+ },
38
+ alertmanager: {
39
+ name: 'alertmanager',
40
+ configMapName: 'alertmanager-config',
41
+ secretName: 'alertmanager-webhook',
42
+ tokenKey: 'token',
43
+ port: 9093,
44
+ },
45
+ blackbox: {
46
+ name: 'blackbox-exporter',
47
+ configMapName: 'blackbox-exporter-config',
48
+ port: 9115,
49
+ },
50
+ grafana: {
51
+ name: 'grafana',
52
+ adminSecretName: 'grafana-admin',
53
+ adminUserKey: 'admin-user',
54
+ adminPasswordKey: 'admin-password',
55
+ adminSecretVersionAnnotation: 'underpost.net/grafana-admin-secret-version',
56
+ adminLoginAnnotation: 'underpost.net/grafana-admin-login',
57
+ pvcName: 'grafana-pvc',
58
+ nodePortName: 'grafana-nodeport',
59
+ routeName: 'grafana-route',
60
+ datasourcesConfigMapName: 'grafana-datasources',
61
+ dashboardProviderConfigMapName: 'grafana-dashboard-provider',
62
+ dashboardsConfigMapName: 'grafana-dashboards',
63
+ port: 3000,
64
+ nodePort: 32300,
65
+ // Sub-path under an existing hostname, so the dashboards ride the edge
66
+ // certificate already issued for that host instead of needing one of their own.
67
+ subPath: '/grafana',
68
+ dashboardsPath: '/var/lib/grafana/dashboards',
69
+ },
70
+ // Envoy Gateway publishes the data plane's admin metrics on this port; the
71
+ // path is Envoy's own, not a Kubernetes convention.
72
+ envoy: {
73
+ namespace: 'envoy-gateway-system',
74
+ metricsPort: 19001,
75
+ metricsPath: '/stats/prometheus',
76
+ },
77
+ // Host metrics. `hostPort` on a DaemonSet rather than a Service: Prometheus
78
+ // discovers nodes, not endpoints, so the address it scrapes is the node's own
79
+ // and the series carry the node identity the alerts act on.
80
+ nodeExporter: {
81
+ name: 'node-exporter',
82
+ port: 9100,
83
+ // Read by the collector and written by anything that has a number the
84
+ // cluster cannot scrape for itself, the Vultr quota above all.
85
+ textfileDirectory: '/var/lib/node_exporter/textfile',
86
+ // The hub is a VPS, not a cluster node, so it runs the same collector as a
87
+ // systemd service instead of as a DaemonSet pod.
88
+ version: '1.9.1',
89
+ binaryPath: '/usr/local/bin/node_exporter',
90
+ serviceName: 'underpost-node-exporter.service',
91
+ unitPath: '/etc/systemd/system/underpost-node-exporter.service',
92
+ filesystemExclude: '^/(dev|proc|sys|var/lib/(docker|containers|kubelet)|run)($|/)',
93
+ // Physical and tunnel interfaces, named by what they are not: enumerating
94
+ // them misses whatever a given host calls its NIC (ens3 on the hub).
95
+ networkDeviceSelector: 'device!~"lo|veth.*|docker.*|br-.*|cni.*|flannel.*|cali.*|tunl.*|virbr.*"',
96
+ },
97
+ // Where Alertmanager delivers, and where `underpost event --serve` listens.
98
+ eventWebhook: {
99
+ port: 39099,
100
+ path: '/event',
101
+ },
102
+ };
103
+
104
+ /**
105
+ * @method serviceDnsFactory
106
+ * @description Fully qualified in-cluster address of a stack component.
107
+ *
108
+ * Always qualified: the stack is namespaced by `--namespace`, and a short name
109
+ * only resolves for a client that happens to share that namespace. Prometheus
110
+ * scraping the Blackbox Exporter is such a client only by coincidence.
111
+ * @param {string} name - Service name.
112
+ * @param {number} port - Service port.
113
+ * @param {string} [namespace] - Namespace holding the Service.
114
+ * @returns {string} `<name>.<namespace>.svc.cluster.local:<port>`.
115
+ * @memberof UnderpostMonitoring
116
+ */
117
+ const serviceDnsFactory = (name, port, namespace = 'default') => `${name}.${namespace}.svc.cluster.local:${port}`;
118
+
119
+ /**
120
+ * @method metricsPathFactory
121
+ * @description The `/metrics` route an Express runtime mounts for a proxy
122
+ * sub-path. Shared with the runtime itself so a scrape target can never point
123
+ * at a route the server does not serve.
124
+ * @param {string} [path] - Proxy sub-path (`/`, `/api`).
125
+ * @returns {string} Metrics route.
126
+ * @memberof UnderpostMonitoring
127
+ */
128
+ const metricsPathFactory = (path = '/') => `${path === '/' ? '' : path}/metrics`;
129
+
130
+ /**
131
+ * @method scrapeDeployListFactory
132
+ * @description Selects the deploys the stack scrapes.
133
+ *
134
+ * The default set is the cron deploy plus the router deploys, which is exactly
135
+ * what `loadCronDeployEnv()` loads. The router alone would leave the cron deploy
136
+ * — the one that runs the backups, the DNS records and the bandwidth guard — as
137
+ * the single unmonitored runtime on the cluster. An explicit id or list is taken
138
+ * as given, so narrowing to one deploy stays possible.
139
+ * @param {object} params
140
+ * @param {string} [params.deployId] - Explicit deploy id or comma-separated list; `dd` or empty selects the default set.
141
+ * @param {string} [params.cronDeployId] - Deploy id from `engine-private/deploy/dd.cron`.
142
+ * @param {string[]} [params.routerDeployIds] - Deploy ids from `engine-private/deploy/dd.routes`.
143
+ * @returns {string[]} Distinct deploy ids, in resolution order.
144
+ * @memberof UnderpostMonitoring
145
+ */
146
+ const scrapeDeployListFactory = ({ deployId = '', cronDeployId = '', routerDeployIds = [] } = {}) => {
147
+ const explicit = `${deployId || ''}`
148
+ .split(',')
149
+ .map((id) => id.trim())
150
+ .filter(Boolean);
151
+ if (explicit.length > 0 && !explicit.includes('dd')) return [...new Set(explicit)];
152
+ return [...new Set([`${cronDeployId || ''}`.trim(), ...routerDeployIds.map((id) => `${id}`.trim())].filter(Boolean))];
153
+ };
154
+
155
+ /**
156
+ * @method appScrapeEntriesFactory
157
+ * @description Scrape entries for the Express runtimes a deploy declares.
158
+ *
159
+ * Only `nodejs` paths are emitted: they are the ones `ExpressService.createApp`
160
+ * gives a `prom-client` registry to. Redirect-only and `lampp`/`wp` paths serve
161
+ * no registry, and scraping them would produce permanently-down targets that
162
+ * make the stack's own health unreadable.
163
+ * @param {object} confServer - Parsed `conf.server.json` (host → path → config).
164
+ * @param {object} [params]
165
+ * @param {string} [params.scheme] - `https` in production, `http` in development.
166
+ * @returns {Array<{host: string, metricsPath: string, scheme: string}>} Scrape entries.
167
+ * @memberof UnderpostMonitoring
168
+ */
169
+ const appScrapeEntriesFactory = (confServer = {}, { scheme = 'https' } = {}) => {
170
+ const entries = [];
171
+ for (const host of Object.keys(confServer)) {
172
+ for (const path of Object.keys(confServer[host] || {})) {
173
+ const conf = confServer[host][path] || {};
174
+ if (conf.runtime !== 'nodejs' || conf.redirect) continue;
175
+ entries.push({ host, metricsPath: metricsPathFactory(path), scheme });
176
+ }
177
+ }
178
+ return entries;
179
+ };
180
+
181
+ const yamlList = (values = []) => `[${values.map((value) => `'${value}'`).join(', ')}]`;
182
+
183
+ /** Escapes a literal for a fully anchored Prometheus relabel regex. */
184
+ const promRegexEscape = (value = '') => `${value}`.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&');
185
+
186
+ const indentBlock = (text, spaces) =>
187
+ `${text}`
188
+ .replace(/\n$/, '')
189
+ .split('\n')
190
+ .map((line) => (line.length > 0 ? `${' '.repeat(spaces)}${line}` : ''))
191
+ .join('\n');
192
+
193
+ /**
194
+ * @method probeGroupsFactory
195
+ * @description Groups event probes by Blackbox module and probe interval, so one
196
+ * scrape job serves every event that probes the same way, at the same cadence.
197
+ *
198
+ * Each probe keeps its own label set as a distinct `static_configs` group. That
199
+ * is what lets one event fan out over many subjects — a probe per WireGuard
200
+ * spoke, each labelled with its id — so the alert Prometheus raises names the
201
+ * subject that failed instead of the event that covers them all.
202
+ *
203
+ * The interval is the event's own `probeInterval`, so a cheap tunnel ping and a
204
+ * fan-out over every public host do not have to share a cadence. Prometheus is
205
+ * the scheduler; this only tells it what each job's period is.
206
+ * @param {Array<{eventId: string, module: string, interval: string, targets: string[], labels: object}>} [probes] - Registered probes.
207
+ * @returns {Array<{module: string, interval: string, groups: Array<{targets: string[], labels: object}>}>} Grouped probes.
208
+ * @memberof UnderpostMonitoring
209
+ */
210
+ const probeGroupsFactory = (probes = []) => {
211
+ const byJob = new Map();
212
+ for (const probe of probes) {
213
+ const targets = (probe.targets || []).filter(Boolean);
214
+ if (!probe.module || !probe.eventId || targets.length === 0) continue;
215
+ const interval = `${probe.interval || ''}`.trim();
216
+ const key = `${probe.module}|${interval}`;
217
+ if (!byJob.has(key)) byJob.set(key, { module: probe.module, interval, groups: [] });
218
+ byJob.get(key).groups.push({ targets, labels: { underpost_event: probe.eventId, ...(probe.labels || {}) } });
219
+ }
220
+ return [...byJob.values()];
221
+ };
222
+
223
+ /**
224
+ * @method deployedEventIdsFactory
225
+ * @description The event ids a rendered monitoring configuration carries.
226
+ *
227
+ * Every probe group and every rule is labelled `underpost_event`, so the
228
+ * generated documents already state which events they serve. Reading them back
229
+ * makes the cluster its own record of what is deployed — no local state file to
230
+ * fall out of step with the objects that actually exist.
231
+ * @param {...string} documents - Rendered ConfigMap data (`prometheus.yml`, rules).
232
+ * @returns {string[]} Distinct event ids, sorted.
233
+ * @memberof UnderpostMonitoring
234
+ */
235
+ const deployedEventIdsFactory = (...documents) =>
236
+ [
237
+ ...new Set(
238
+ documents.flatMap((document) =>
239
+ [...`${document || ''}`.matchAll(/underpost_event\s*[:=]~?\s*'?"?([a-z0-9-]+)'?"?/gi)].map((match) => match[1]),
240
+ ),
241
+ ),
242
+ ]
243
+ .filter((id) => id && id !== '.+')
244
+ .sort();
245
+
246
+ /**
247
+ * @method prometheusConfFactory
248
+ * @description Renders `prometheus.yml`.
249
+ *
250
+ * The Envoy job discovers the data plane instead of naming it: Envoy Gateway
251
+ * provisions its own Deployment per GatewayClass with a generated name, so a
252
+ * static target would break on every GatewayClass change. Pod discovery needs
253
+ * the RBAC the Prometheus manifest carries.
254
+ * @param {object} params
255
+ * @param {Array<{host: string, metricsPath: string, scheme: string}>} [params.appTargets] - Express runtime targets.
256
+ * @param {string[]} [params.extraTargets] - Additional `host:port` targets scraped over HTTP at `/metrics`.
257
+ * @param {Array<{eventId: string, module: string, targets: string[]}>} [params.probes] - Blackbox probes.
258
+ * @param {Array<{nodeName: string, role: string}>} [params.nodeRoles] - Registered cluster
259
+ * nodes, relabelled onto their discovered series as `underpost_role`.
260
+ * @param {string} [params.namespace] - Namespace holding the stack.
261
+ * @param {string} [params.scrapeInterval] - Global scrape/evaluation interval.
262
+ * @returns {string} `prometheus.yml` contents.
263
+ * @memberof UnderpostMonitoring
264
+ */
265
+ const prometheusConfFactory = ({
266
+ appTargets = [],
267
+ extraTargets = [],
268
+ probes = [],
269
+ hostTargets = [],
270
+ nodeRoles = [],
271
+ namespace = 'default',
272
+ scrapeInterval = UNDERPOST_MONITORING.scrapeInterval,
273
+ } = {}) => {
274
+ const { prometheus, alertmanager, blackbox, envoy, nodeExporter } = UNDERPOST_MONITORING;
275
+
276
+ const appGroups = new Map();
277
+ for (const target of appTargets) {
278
+ const key = `${target.scheme}|${target.metricsPath}`;
279
+ if (!appGroups.has(key)) appGroups.set(key, []);
280
+ appGroups.get(key).push(target.host);
281
+ }
282
+
283
+ const appJobs = [...appGroups.entries()].map(([key, hosts], index) => {
284
+ const [scheme, metricsPath] = key.split('|');
285
+ return `
286
+ - job_name: 'underpost-express${index > 0 ? `-${index}` : ''}'
287
+ metrics_path: ${metricsPath}
288
+ scheme: ${scheme}
289
+ tls_config:
290
+ insecure_skip_verify: true
291
+ static_configs:
292
+ - targets: ${yamlList([...new Set(hosts)])}`;
293
+ });
294
+
295
+ const extraJob =
296
+ extraTargets.length > 0
297
+ ? `
298
+ - job_name: 'underpost-extra'
299
+ metrics_path: /metrics
300
+ static_configs:
301
+ - targets: ${yamlList([...new Set(extraTargets)])}`
302
+ : '';
303
+
304
+ // Cluster nodes are discovered; the hub is a VPS that discovery cannot see, so
305
+ // it is scraped across the tunnel at the address the WireGuard events probe.
306
+ // Discovery knows a node's name, not the role the deploy registry gives it, so
307
+ // the role every panel and alert groups by is relabelled on from that registry.
308
+ const roleRelabels = nodeRoles
309
+ .filter((entry) => entry?.nodeName && entry?.role)
310
+ .map(
311
+ (entry) => `
312
+ - source_labels: [__meta_kubernetes_node_name]
313
+ regex: '${promRegexEscape(entry.nodeName)}'
314
+ target_label: underpost_role
315
+ replacement: '${entry.role}'`,
316
+ )
317
+ .join('');
318
+
319
+ const hostJobs = [
320
+ `
321
+ - job_name: '${nodeExporter.name}'
322
+ kubernetes_sd_configs:
323
+ - role: node
324
+ relabel_configs:
325
+ - source_labels: [__meta_kubernetes_node_address_InternalIP]
326
+ target_label: __address__
327
+ replacement: '\$1:${nodeExporter.port}'
328
+ - source_labels: [__meta_kubernetes_node_name]
329
+ target_label: node
330
+ - target_label: underpost_role
331
+ replacement: 'cluster'${roleRelabels}`,
332
+ ...(hostTargets.length > 0
333
+ ? [
334
+ `
335
+ - job_name: '${nodeExporter.name}-hub'
336
+ static_configs:
337
+ - targets: ${yamlList(hostTargets.map((target) => `${target}:${nodeExporter.port}`))}
338
+ labels:
339
+ underpost_role: 'hub'`,
340
+ ]
341
+ : []),
342
+ ];
343
+
344
+ const probeJobs = probeGroupsFactory(probes).map(
345
+ ({ module, interval, groups }, index) => `
346
+ - job_name: 'blackbox-${module}${index > 0 ? `-${index}` : ''}'
347
+ metrics_path: /probe${interval ? `\n scrape_interval: ${interval}` : ''}
348
+ params:
349
+ module: ['${module}']
350
+ static_configs:
351
+ ${groups
352
+ .map(
353
+ (group) => ` - targets: ${yamlList(group.targets)}
354
+ labels:
355
+ ${Object.entries(group.labels)
356
+ .map(([key, value]) => ` ${key}: '${value}'`)
357
+ .join('\n')}`,
358
+ )
359
+ .join('\n')}
360
+ relabel_configs:
361
+ - source_labels: [__address__]
362
+ target_label: __param_target
363
+ - source_labels: [__param_target]
364
+ target_label: instance
365
+ - target_label: __address__
366
+ replacement: '${serviceDnsFactory(blackbox.name, blackbox.port, namespace)}'`,
367
+ );
368
+
369
+ return `global:
370
+ scrape_interval: ${scrapeInterval}
371
+ evaluation_interval: ${scrapeInterval}
372
+
373
+ rule_files:
374
+ - /etc/prometheus/rules/*.yml
375
+
376
+ alerting:
377
+ alertmanagers:
378
+ - static_configs:
379
+ - targets: ['${serviceDnsFactory(alertmanager.name, alertmanager.port, namespace)}']
380
+
381
+ scrape_configs:
382
+ - job_name: '${prometheus.name}'
383
+ static_configs:
384
+ - targets: ['127.0.0.1:${prometheus.port}']
385
+
386
+ - job_name: 'envoy-gateway'
387
+ metrics_path: ${envoy.metricsPath}
388
+ kubernetes_sd_configs:
389
+ - role: pod
390
+ namespaces:
391
+ names: ['${envoy.namespace}']
392
+ relabel_configs:
393
+ - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
394
+ regex: envoy
395
+ action: keep
396
+ - source_labels: [__meta_kubernetes_pod_ip]
397
+ target_label: __address__
398
+ replacement: '\$1:${envoy.metricsPort}'
399
+ - source_labels: [__meta_kubernetes_pod_name]
400
+ target_label: pod
401
+ - source_labels: [__meta_kubernetes_namespace]
402
+ target_label: namespace
403
+ metric_relabel_configs:
404
+ # Envoy truncates a cluster name at its first hyphen when it extracts the
405
+ # tag, so every www-* HTTPRoute reports as httproute/default/www. The
406
+ # samples arrive already indistinguishable, no relabeling can separate
407
+ # them, and Prometheus drops the scrape's worth of them as duplicates with
408
+ # the same timestamp. Nothing reads this socket-match counter, so it goes.
409
+ - source_labels: [__name__]
410
+ regex: envoy_cluster_total_match_count
411
+ action: drop
412
+ ${[...appJobs, extraJob, ...hostJobs, ...probeJobs].filter(Boolean).join('\n')}
413
+ `;
414
+ };
415
+
416
+ /**
417
+ * @method alertRulesFactory
418
+ * @description Renders the alerting rules for the registered events.
419
+ *
420
+ * Every rule carries `underpost_event`, because that label is the whole
421
+ * contract between the rule, the Alertmanager route and the dispatcher: the
422
+ * route matches on its presence and the dispatcher reads it to select a
423
+ * handler. A rule without it fires into the null receiver.
424
+ * @param {Array<object>} [events] - Registered events with an `alert` definition carrying its declared `for`.
425
+ * @returns {string} Rules file contents.
426
+ * @memberof UnderpostMonitoring
427
+ */
428
+ const alertRulesFactory = (events = []) => {
429
+ const rules = events
430
+ // A rule needs its window as much as its expression: the contract declares
431
+ // `alertFor`, and rendering a default here would put the answer in two places.
432
+ .filter((event) => event.id && event.alert?.expr && event.alert?.for)
433
+ .map(
434
+ (event) => ` - alert: ${event.alert.name}
435
+ expr: ${event.alert.expr}
436
+ for: ${event.alert.for}
437
+ labels:
438
+ severity: ${event.alert.severity || 'critical'}
439
+ underpost_event: '${event.id}'
440
+ annotations:
441
+ summary: '${event.alert.summary || event.description || event.id}'
442
+ description: '${event.alert.description || event.description || event.id}'`,
443
+ );
444
+
445
+ return `groups:
446
+ - name: underpost-events
447
+ rules:
448
+ ${rules.length > 0 ? rules.join('\n') : ' []'}
449
+ `;
450
+ };
451
+
452
+ /**
453
+ * @method alertmanagerConfFactory
454
+ * @description Renders `alertmanager.yml`.
455
+ *
456
+ * One route matters: anything labelled `underpost_event` goes to the webhook
457
+ * the dispatcher serves. Everything else lands in a null receiver rather than
458
+ * an inbox, because an alert with no registered handler has no remediation to
459
+ * announce.
460
+ *
461
+ * `send_resolved` stays off. The dispatcher acts — it re-runs setup and opens a
462
+ * ticket by mail — and a resolution notice would ask it to act again on a
463
+ * condition that has already cleared.
464
+ *
465
+ * Deliveries always carry the bearer token from the mounted Secret: the
466
+ * receiver runs on the host with root-equivalent reach on the edge, so an
467
+ * unauthenticated variant of this route is not a mode worth having.
468
+ * @param {object} params
469
+ * @param {string} params.webhookUrl - Absolute URL of the dispatcher's receiver.
470
+ * @param {string} [params.repeatInterval] - How long before an unresolved alert is redelivered.
471
+ * @returns {string} `alertmanager.yml` contents.
472
+ * @memberof UnderpostMonitoring
473
+ */
474
+ const alertmanagerConfFactory = ({ webhookUrl, repeatInterval = '1h' } = {}) => {
475
+ const { alertmanager } = UNDERPOST_MONITORING;
476
+ return `global:
477
+ resolve_timeout: 5m
478
+
479
+ route:
480
+ receiver: 'underpost-null'
481
+ # Grouped by subject as well as by event: a fan-out event raises one alert per
482
+ # subject, and grouping them together would deliver a single webhook naming
483
+ # whichever one Alertmanager happened to pick first.
484
+ group_by: ['alertname', 'underpost_event', 'underpost_spoke']
485
+ group_wait: 30s
486
+ group_interval: 5m
487
+ repeat_interval: ${repeatInterval}
488
+ routes:
489
+ - receiver: 'underpost-event'
490
+ matchers:
491
+ - underpost_event =~ ".+"
492
+
493
+ receivers:
494
+ - name: 'underpost-null'
495
+
496
+ - name: 'underpost-event'
497
+ webhook_configs:
498
+ - url: '${webhookUrl}'
499
+ send_resolved: false
500
+ max_alerts: 0
501
+ http_config:
502
+ authorization:
503
+ type: Bearer
504
+ credentials_file: /etc/alertmanager/secret/${alertmanager.tokenKey}
505
+ `;
506
+ };
507
+
508
+ /**
509
+ * @method blackboxConfFactory
510
+ * @description Renders `blackbox.yml`.
511
+ *
512
+ * The three modules the event registry probes with, and nothing else: a module
513
+ * no probe names is configuration that cannot be observed to be wrong.
514
+ * `insecure_skip_verify` applies to the HTTP prober alone — probes target the
515
+ * edge by address, where the certificate names a hostname it cannot present.
516
+ * @returns {string} `blackbox.yml` contents.
517
+ * @memberof UnderpostMonitoring
518
+ */
519
+ const blackboxConfFactory = () => `modules:
520
+ http_2xx:
521
+ prober: http
522
+ timeout: 5s
523
+ http:
524
+ preferred_ip_protocol: ip4
525
+ ip_protocol_fallback: false
526
+ valid_status_codes: []
527
+ follow_redirects: true
528
+ tls_config:
529
+ insecure_skip_verify: true
530
+
531
+ tcp_connect:
532
+ prober: tcp
533
+ timeout: 5s
534
+ tcp:
535
+ preferred_ip_protocol: ip4
536
+ ip_protocol_fallback: false
537
+
538
+ icmp:
539
+ prober: icmp
540
+ timeout: 5s
541
+ icmp:
542
+ preferred_ip_protocol: ip4
543
+ ip_protocol_fallback: false
544
+ `;
545
+
546
+ /**
547
+ * @method grafanaDatasourcesFactory
548
+ * @description Renders the provisioned Prometheus datasource.
549
+ * @param {object} [params]
550
+ * @param {string} [params.namespace] - Namespace holding Prometheus.
551
+ * @returns {string} Datasource provisioning file contents.
552
+ * @memberof UnderpostMonitoring
553
+ */
554
+ const grafanaDatasourcesFactory = ({ namespace = 'default' } = {}) => {
555
+ const { prometheus } = UNDERPOST_MONITORING;
556
+ return `apiVersion: 1
557
+ datasources:
558
+ - name: Prometheus
559
+ uid: underpost-prometheus
560
+ type: prometheus
561
+ access: proxy
562
+ url: 'http://${serviceDnsFactory(prometheus.name, prometheus.port, namespace)}'
563
+ isDefault: true
564
+ editable: false
565
+ jsonData:
566
+ timeInterval: ${UNDERPOST_MONITORING.scrapeInterval}
567
+ `;
568
+ };
569
+
570
+ /**
571
+ * @method nodeExporterManifestFactory
572
+ * @description Renders the host-metrics collector as a DaemonSet.
573
+ *
574
+ * `hostNetwork` and `hostPID` because the point is the host, not the pod: CPU,
575
+ * memory, filesystem and interface counters have to describe the machine the
576
+ * workloads run on. The root filesystem is mounted read-only so a collector can
577
+ * never write to what it measures, and the textfile directory is the one path
578
+ * anything else may drop a `.prom` file into.
579
+ * @param {object} [params]
580
+ * @param {string} [params.namespace='default'] - Namespace holding the stack.
581
+ * @returns {string} DaemonSet manifest.
582
+ * @memberof UnderpostMonitoring
583
+ */
584
+ const nodeExporterManifestFactory = ({ namespace = 'default' } = {}) => {
585
+ const { nodeExporter } = UNDERPOST_MONITORING;
586
+ return `---
587
+ apiVersion: apps/v1
588
+ kind: DaemonSet
589
+ metadata:
590
+ name: ${nodeExporter.name}
591
+ namespace: ${namespace}
592
+ labels:
593
+ app: ${nodeExporter.name}
594
+ spec:
595
+ selector:
596
+ matchLabels:
597
+ app: ${nodeExporter.name}
598
+ template:
599
+ metadata:
600
+ labels:
601
+ app: ${nodeExporter.name}
602
+ spec:
603
+ hostNetwork: true
604
+ hostPID: true
605
+ tolerations:
606
+ - operator: Exists
607
+ containers:
608
+ - name: ${nodeExporter.name}
609
+ image: prom/node-exporter:latest
610
+ args:
611
+ - --path.rootfs=/host/root
612
+ - --path.procfs=/host/proc
613
+ - --path.sysfs=/host/sys
614
+ - --collector.textfile.directory=${nodeExporter.textfileDirectory}
615
+ - --collector.filesystem.mount-points-exclude=${nodeExporter.filesystemExclude}
616
+ ports:
617
+ - name: metrics
618
+ containerPort: ${nodeExporter.port}
619
+ resources:
620
+ requests:
621
+ cpu: 25m
622
+ memory: 32Mi
623
+ securityContext:
624
+ runAsUser: 0
625
+ volumeMounts:
626
+ - name: proc
627
+ mountPath: /host/proc
628
+ readOnly: true
629
+ - name: sys
630
+ mountPath: /host/sys
631
+ readOnly: true
632
+ - name: root
633
+ mountPath: /host/root
634
+ mountPropagation: HostToContainer
635
+ readOnly: true
636
+ - name: textfile
637
+ mountPath: ${nodeExporter.textfileDirectory}
638
+ readOnly: true
639
+ volumes:
640
+ - name: proc
641
+ hostPath:
642
+ path: /proc
643
+ - name: sys
644
+ hostPath:
645
+ path: /sys
646
+ - name: root
647
+ hostPath:
648
+ path: /
649
+ - name: textfile
650
+ hostPath:
651
+ path: ${nodeExporter.textfileDirectory}
652
+ type: DirectoryOrCreate
653
+ `;
654
+ };
655
+
656
+ /**
657
+ * @method nodeExporterServiceFactory
658
+ * @description Renders the same host collector as a systemd unit, for a machine
659
+ * the cluster cannot schedule a pod onto.
660
+ *
661
+ * Bound to the tunnel address rather than every interface: Prometheus scrapes
662
+ * the hub across WireGuard, and a listener on the VPS public address would
663
+ * publish the host's inventory to the internet. Binding it makes the unit
664
+ * depend on the interface, which is why it is tied to `wg-quick@`.
665
+ * @param {object} params
666
+ * @param {string} params.host - Tunnel address the collector binds.
667
+ * @param {string} [params.interfaceName='wg0'] - Tunnel interface carrying that address.
668
+ * @returns {string} Rendered unit file.
669
+ * @memberof UnderpostMonitoring
670
+ */
671
+ const nodeExporterServiceFactory = ({ host, interfaceName = 'wg0' }) => {
672
+ const { nodeExporter } = UNDERPOST_MONITORING;
673
+ const tunnelUnit = `wg-quick@${interfaceName}.service`;
674
+ return systemdUnitFactory({
675
+ header:
676
+ '# Generated by `underpost wireguard --node-exporter`. Do not edit by hand:\n' +
677
+ '# the next run rewrites the file and restarts the service.',
678
+ sections: {
679
+ Unit: {
680
+ Description: `Underpost host metrics collector on ${host}:${nodeExporter.port}`,
681
+ Documentation: 'https://www.nexodev.org/docs',
682
+ After: `network-online.target ${tunnelUnit}`,
683
+ Wants: 'network-online.target',
684
+ Requires: tunnelUnit,
685
+ PartOf: tunnelUnit,
686
+ },
687
+ Service: {
688
+ Type: 'simple',
689
+ User: 'root',
690
+ ExecStart: [
691
+ nodeExporter.binaryPath,
692
+ `--web.listen-address=${host}:${nodeExporter.port}`,
693
+ `--collector.textfile.directory=${nodeExporter.textfileDirectory}`,
694
+ `--collector.filesystem.mount-points-exclude=${nodeExporter.filesystemExclude}`,
695
+ ].join(' '),
696
+ Restart: 'always',
697
+ RestartSec: 5,
698
+ },
699
+ Install: { WantedBy: `multi-user.target ${tunnelUnit}` },
700
+ },
701
+ });
702
+ };
703
+
704
+ /**
705
+ * @method nodeExporterServiceScriptFactory
706
+ * @description The single command that provisions the collector on a host.
707
+ *
708
+ * Base64 in, bash out: the remote runner interpolates its command into
709
+ * `bash -lc "..."`, which would eat the quotes, expansions and heredoc this
710
+ * needs. Encoding the script transports it intact and keeps one implementation
711
+ * for the local and the SSH path. The final state read is what makes a failed
712
+ * start a failed run rather than a silent one.
713
+ * @param {object} params
714
+ * @param {string} params.host - Tunnel address the collector binds.
715
+ * @param {string} [params.interfaceName='wg0'] - Tunnel interface carrying that address.
716
+ * @returns {string} Command to execute on the target host.
717
+ * @memberof UnderpostMonitoring
718
+ */
719
+ const nodeExporterServiceScriptFactory = ({ host, interfaceName = 'wg0' }) => {
720
+ const { nodeExporter } = UNDERPOST_MONITORING;
721
+ const lifecycle = systemdServiceCommandsFactory({
722
+ changed: true,
723
+ name: nodeExporter.serviceName,
724
+ unitPath: nodeExporter.unitPath,
725
+ }).ensure;
726
+
727
+ const script = `set -e
728
+ VERSION=${nodeExporter.version}
729
+ ARCH=$(uname -m)
730
+ case "$ARCH" in x86_64) ARCH=amd64 ;; aarch64) ARCH=arm64 ;; esac
731
+ RELEASE=node_exporter-$VERSION.linux-$ARCH
732
+ # The collector's textfile directory is written by CronJob pods, so it needs the shared container
733
+ # label; the /var/lib default leaves it readable but not writable by container_t. Non-fatal: a
734
+ # host without the SELinux userspace still gets a working collector, just unlabeled.
735
+ # A subshell, not a brace group: the generated mapping command calls exit 1 when semanage is
736
+ # missing, which in a brace group would terminate this installer rather than just this check.
737
+ if ! ( ${containerStorageCommandsFactory(nodeExporter.textfileDirectory)
738
+ .map((command) => `{ ${command}; }`)
739
+ .join(' && ')} ); then
740
+ echo "WARN: could not label ${nodeExporter.textfileDirectory} for container access" >&2
741
+ fi
742
+ if ! ${nodeExporter.binaryPath} --version 2>/dev/null | grep -q "version $VERSION"; then
743
+ TMP=$(mktemp -d)
744
+ curl -fsSL "https://github.com/prometheus/node_exporter/releases/download/v$VERSION/$RELEASE.tar.gz" -o "$TMP/$RELEASE.tar.gz"
745
+ tar -xzf "$TMP/$RELEASE.tar.gz" -C "$TMP"
746
+ sudo install -m 0755 "$TMP/$RELEASE/node_exporter" ${nodeExporter.binaryPath}
747
+ rm -rf "$TMP"
748
+ fi
749
+ sudo tee ${nodeExporter.unitPath} >/dev/null <<'UNIT'
750
+ ${nodeExporterServiceFactory({ host, interfaceName })}UNIT
751
+ ${lifecycle.join('\n')}
752
+ systemctl is-active --quiet ${nodeExporter.serviceName}
753
+ `;
754
+
755
+ return `echo ${Buffer.from(script, 'utf8').toString('base64')} | base64 -d | bash -s`;
756
+ };
757
+
758
+ /**
759
+ * @method grafanaDashboardProviderFactory
760
+ * @description Renders the file-based dashboard provider.
761
+ * @returns {string} Dashboard provider provisioning file contents.
762
+ * @memberof UnderpostMonitoring
763
+ */
764
+ const grafanaDashboardProviderFactory = () => `apiVersion: 1
765
+ providers:
766
+ - name: 'underpost'
767
+ orgId: 1
768
+ folder: 'Underpost'
769
+ type: file
770
+ disableDeletion: false
771
+ allowUiUpdates: true
772
+ updateIntervalSeconds: 30
773
+ options:
774
+ path: ${UNDERPOST_MONITORING.grafana.dashboardsPath}
775
+ foldersFromFilesStructure: false
776
+ `;
777
+
778
+ const timeSeriesPanel = ({ id, title, unit = 'short', gridPos, targets, overrides = [] }) => ({
779
+ id,
780
+ type: 'timeseries',
781
+ title,
782
+ datasource: { type: 'prometheus', uid: 'underpost-prometheus' },
783
+ gridPos,
784
+ fieldConfig: { defaults: { unit, custom: { fillOpacity: 8, lineWidth: 1 } }, overrides },
785
+ options: { legend: { displayMode: 'list', placement: 'bottom' }, tooltip: { mode: 'multi' } },
786
+ targets: targets.map((target, index) => ({ refId: String.fromCharCode(65 + index), ...target })),
787
+ });
788
+
789
+ /** Series identity every host panel groups and labels by. */
790
+ const NODE_LEGEND = '{{instance}} ({{underpost_role}})';
791
+
792
+ /** Re-units one query of a panel and moves it to the opposite axis. */
793
+ const secondaryAxisOverride = (refId, unit) => ({
794
+ matcher: { id: 'byFrameRefID', options: refId },
795
+ properties: [
796
+ { id: 'unit', value: unit },
797
+ { id: 'custom.axisPlacement', value: 'right' },
798
+ ],
799
+ });
800
+
801
+ const statPanel = ({ id, title, description, unit = 'short', gridPos, targets, defaults = {}, options = {} }) => ({
802
+ id,
803
+ type: 'stat',
804
+ title,
805
+ ...(description ? { description } : {}),
806
+ datasource: { type: 'prometheus', uid: 'underpost-prometheus' },
807
+ gridPos,
808
+ fieldConfig: { defaults: { unit, ...defaults }, overrides: [] },
809
+ options: {
810
+ reduceOptions: { calcs: ['lastNotNull'], fields: '', values: false },
811
+ colorMode: 'value',
812
+ ...options,
813
+ },
814
+ targets: targets.map((target, index) => ({ refId: String.fromCharCode(65 + index), ...target })),
815
+ });
816
+
817
+ /**
818
+ * The field config every boolean availability field carries: `probe_success`
819
+ * and `up` are only ever 0 or 1, so the number itself says nothing an operator
820
+ * reads faster than a word and a colour. The threshold step at 1 is what makes
821
+ * anything short of a full success red, and `noValue` separates a target that
822
+ * reports failure from one that stopped reporting at all.
823
+ */
824
+ const availabilityDefaults = () => ({
825
+ min: 0,
826
+ max: 1,
827
+ decimals: 0,
828
+ noValue: 'NO DATA',
829
+ color: { mode: 'thresholds' },
830
+ mappings: [
831
+ {
832
+ type: 'value',
833
+ options: {
834
+ 0: { text: 'DOWN', color: 'red', index: 0 },
835
+ 1: { text: 'UP', color: 'green', index: 1 },
836
+ },
837
+ },
838
+ ],
839
+ thresholds: {
840
+ mode: 'absolute',
841
+ steps: [
842
+ { color: 'red', value: null },
843
+ { color: 'green', value: 1 },
844
+ ],
845
+ },
846
+ });
847
+
848
+ /**
849
+ * @method stateTimelinePanel
850
+ * @description A boolean availability series as coloured bands over time.
851
+ *
852
+ * A line chart of dozens of targets that are almost always 1 spends its whole
853
+ * vertical axis on two values and draws every target on top of the same line.
854
+ * A state timeline spends the axis on identity instead — one row per series —
855
+ * which is what makes a single red band among fifty green ones findable.
856
+ * Values are merged, so a target that never failed is one band rather than one
857
+ * rectangle per scrape, and nulls are not spanned, so a target that stopped
858
+ * reporting reads as a gap rather than as continued health.
859
+ * @param {object} params
860
+ * @param {number} params.id - Panel id.
861
+ * @param {string} params.title - Panel title.
862
+ * @param {string} [params.description] - Panel description.
863
+ * @param {object} params.gridPos - Grafana grid position.
864
+ * @param {Array<object>} params.targets - Panel queries.
865
+ * @returns {object} Panel model.
866
+ * @memberof UnderpostMonitoring
867
+ */
868
+ const stateTimelinePanel = ({ id, title, description, gridPos, targets }) => ({
869
+ id,
870
+ type: 'state-timeline',
871
+ title,
872
+ ...(description ? { description } : {}),
873
+ datasource: { type: 'prometheus', uid: 'underpost-prometheus' },
874
+ gridPos,
875
+ fieldConfig: {
876
+ defaults: {
877
+ ...availabilityDefaults(),
878
+ custom: { fillOpacity: 85, lineWidth: 0, spanNulls: false },
879
+ },
880
+ overrides: [],
881
+ },
882
+ options: {
883
+ mergeValues: true,
884
+ showValue: 'never',
885
+ alignValue: 'left',
886
+ rowHeight: 0.9,
887
+ legend: { showLegend: false, displayMode: 'list', placement: 'bottom' },
888
+ tooltip: { mode: 'single', sort: 'none' },
889
+ },
890
+ targets: targets.map((target, index) => ({ refId: String.fromCharCode(65 + index), ...target })),
891
+ });
892
+
893
+ /** One background-coloured UP/DOWN card per series: the instant state, read at a glance. */
894
+ const availabilityStatPanel = (panel) =>
895
+ statPanel({
896
+ ...panel,
897
+ defaults: availabilityDefaults(),
898
+ options: {
899
+ colorMode: 'background',
900
+ graphMode: 'none',
901
+ textMode: 'value_and_name',
902
+ justifyMode: 'center',
903
+ wideLayout: false,
904
+ text: { titleSize: 11, valueSize: 20 },
905
+ },
906
+ });
907
+
908
+ /** Availability as a fraction of the selected range, coloured against an SLO. */
909
+ const uptimeStatPanel = (panel) =>
910
+ statPanel({
911
+ ...panel,
912
+ unit: 'percentunit',
913
+ defaults: {
914
+ min: 0,
915
+ max: 1,
916
+ decimals: 3,
917
+ noValue: 'NO DATA',
918
+ color: { mode: 'thresholds' },
919
+ thresholds: {
920
+ mode: 'absolute',
921
+ steps: [
922
+ { color: 'red', value: null },
923
+ { color: 'orange', value: 0.99 },
924
+ { color: 'green', value: 0.999 },
925
+ ],
926
+ },
927
+ },
928
+ options: { colorMode: 'background', graphMode: 'area', textMode: 'value' },
929
+ });
930
+
931
+ /** How many members of a fleet are failing right now; zero reads as a word, not a digit. */
932
+ const outageCountPanel = (panel) =>
933
+ statPanel({
934
+ ...panel,
935
+ defaults: {
936
+ decimals: 0,
937
+ color: { mode: 'thresholds' },
938
+ mappings: [{ type: 'value', options: { 0: { text: 'ALL UP', color: 'green', index: 0 } } }],
939
+ thresholds: {
940
+ mode: 'absolute',
941
+ steps: [
942
+ { color: 'green', value: null },
943
+ { color: 'red', value: 1 },
944
+ ],
945
+ },
946
+ },
947
+ options: { colorMode: 'background', graphMode: 'area', textMode: 'value' },
948
+ });
949
+
950
+ /** A label-values variable, so dozens of targets can be narrowed to the one under investigation. */
951
+ const dashboardVariableFactory = ({ name, label, query }) => ({
952
+ name,
953
+ label,
954
+ type: 'query',
955
+ datasource: { type: 'prometheus', uid: 'underpost-prometheus' },
956
+ definition: query,
957
+ query: { query, refId: `${name}-variable` },
958
+ refresh: 2,
959
+ sort: 1,
960
+ multi: true,
961
+ includeAll: true,
962
+ allValue: '.+',
963
+ current: { selected: true, text: ['All'], value: ['$__all'] },
964
+ options: [],
965
+ });
966
+
967
+ /**
968
+ * @method envoyDashboardFactory
969
+ * @description The Envoy Gateway data plane dashboard.
970
+ *
971
+ * Downstream 5xx is read from `envoy_http_downstream_rq_xx` filtered to
972
+ * `envoy_response_code_class="5"`. Envoy exposes response classes as one
973
+ * labelled family, not as a `..._rq_5xx` series, so selecting the label is what
974
+ * actually yields the 5xx rate.
975
+ * @returns {string} Dashboard JSON.
976
+ * @memberof UnderpostMonitoring
977
+ */
978
+ const envoyDashboardFactory = () =>
979
+ `${JSON.stringify(
980
+ {
981
+ uid: 'underpost-envoy',
982
+ title: 'Underpost · Envoy Gateway',
983
+ tags: ['underpost', 'envoy'],
984
+ timezone: 'browser',
985
+ schemaVersion: 39,
986
+ refresh: '30s',
987
+ time: { from: 'now-6h', to: 'now' },
988
+ panels: [
989
+ statPanel({
990
+ id: 1,
991
+ title: 'Data plane uptime',
992
+ unit: 's',
993
+ gridPos: { h: 4, w: 6, x: 0, y: 0 },
994
+ targets: [{ expr: 'max(envoy_server_uptime)', legendFormat: 'uptime' }],
995
+ }),
996
+ statPanel({
997
+ id: 2,
998
+ title: 'Memory allocated',
999
+ unit: 'bytes',
1000
+ gridPos: { h: 4, w: 6, x: 6, y: 0 },
1001
+ targets: [{ expr: 'sum(envoy_server_memory_allocated)', legendFormat: 'allocated' }],
1002
+ }),
1003
+ statPanel({
1004
+ id: 3,
1005
+ title: 'Active downstream connections',
1006
+ gridPos: { h: 4, w: 6, x: 12, y: 0 },
1007
+ targets: [{ expr: 'sum(envoy_http_downstream_cx_active)', legendFormat: 'active' }],
1008
+ }),
1009
+ statPanel({
1010
+ id: 4,
1011
+ title: 'Probe success',
1012
+ gridPos: { h: 4, w: 6, x: 18, y: 0 },
1013
+ targets: [{ expr: 'min(probe_success)', legendFormat: 'probes' }],
1014
+ }),
1015
+ timeSeriesPanel({
1016
+ id: 5,
1017
+ title: 'Downstream requests completed',
1018
+ unit: 'reqps',
1019
+ gridPos: { h: 8, w: 12, x: 0, y: 4 },
1020
+ targets: [
1021
+ {
1022
+ expr: 'sum by (envoy_http_conn_manager_prefix) (rate(envoy_http_downstream_rq_completed[5m]))',
1023
+ legendFormat: '{{envoy_http_conn_manager_prefix}}',
1024
+ },
1025
+ ],
1026
+ }),
1027
+ timeSeriesPanel({
1028
+ id: 6,
1029
+ title: 'Downstream 5xx',
1030
+ unit: 'reqps',
1031
+ gridPos: { h: 8, w: 12, x: 12, y: 4 },
1032
+ targets: [
1033
+ {
1034
+ expr: 'sum by (envoy_http_conn_manager_prefix) (rate(envoy_http_downstream_rq_xx{envoy_response_code_class="5"}[5m]))',
1035
+ legendFormat: '{{envoy_http_conn_manager_prefix}} 5xx',
1036
+ },
1037
+ ],
1038
+ }),
1039
+ timeSeriesPanel({
1040
+ id: 7,
1041
+ title: 'Downstream throughput',
1042
+ unit: 'Bps',
1043
+ gridPos: { h: 8, w: 12, x: 0, y: 12 },
1044
+ targets: [
1045
+ { expr: 'sum(rate(envoy_http_downstream_cx_rx_bytes_total[5m]))', legendFormat: 'rx' },
1046
+ { expr: 'sum(rate(envoy_http_downstream_cx_tx_bytes_total[5m]))', legendFormat: 'tx' },
1047
+ ],
1048
+ }),
1049
+ timeSeriesPanel({
1050
+ id: 8,
1051
+ title: 'Active connections',
1052
+ gridPos: { h: 8, w: 12, x: 12, y: 12 },
1053
+ targets: [
1054
+ {
1055
+ expr: 'sum by (envoy_http_conn_manager_prefix) (envoy_http_downstream_cx_active)',
1056
+ legendFormat: '{{envoy_http_conn_manager_prefix}}',
1057
+ },
1058
+ ],
1059
+ }),
1060
+ ],
1061
+ },
1062
+ null,
1063
+ 2,
1064
+ )}\n`;
1065
+
1066
+ /** Series the availability panels read, narrowed by the dashboard's own variables. */
1067
+ const PROBE_SELECTOR = 'probe_success{underpost_event=~"$event"}';
1068
+ const TARGET_SELECTOR = 'up{job=~"$job"}';
1069
+
1070
+ /**
1071
+ * A step of a state timeline is wider than a scrape as soon as the range is,
1072
+ * and Prometheus answers each step with its last sample — which is how a short
1073
+ * outage disappears from a week-long view. Reducing the step with `min_over_time`
1074
+ * keeps the failure: any zero inside the window colours the whole band red.
1075
+ * `$__rate_interval` rather than `$__interval` because it is guaranteed to be
1076
+ * wider than the scrape interval, so the reduction can never see an empty range.
1077
+ */
1078
+ const timelineExpr = (selector) => `min_over_time(${selector}[$__rate_interval])`;
1079
+
1080
+ /**
1081
+ * @method eventsDashboardFactory
1082
+ * @description The event/probe dashboard: what each registered event's probes
1083
+ * report, and what the Express runtimes are serving.
1084
+ *
1085
+ * Availability is boolean, and the dashboard is laid out the way an operator
1086
+ * reads one: the top row answers "is anything broken right now, and how much
1087
+ * has it cost", the card grids name which subject is broken, and the state
1088
+ * timelines say when it broke and for how long. Nothing renders 0/1 on a
1089
+ * numeric axis, because dozens of targets that are almost always up produce a
1090
+ * single line and an unreadable legend.
1091
+ * @returns {string} Dashboard JSON.
1092
+ * @memberof UnderpostMonitoring
1093
+ */
1094
+ const eventsDashboardFactory = () =>
1095
+ `${JSON.stringify(
1096
+ {
1097
+ uid: 'underpost-events',
1098
+ title: 'Underpost · Events and Probes',
1099
+ tags: ['underpost', 'events'],
1100
+ timezone: 'browser',
1101
+ schemaVersion: 39,
1102
+ refresh: '30s',
1103
+ time: { from: 'now-6h', to: 'now' },
1104
+ templating: {
1105
+ list: [
1106
+ dashboardVariableFactory({
1107
+ name: 'event',
1108
+ label: 'Event',
1109
+ query: 'label_values(probe_success, underpost_event)',
1110
+ }),
1111
+ dashboardVariableFactory({ name: 'job', label: 'Job', query: 'label_values(up, job)' }),
1112
+ ],
1113
+ },
1114
+ panels: [
1115
+ outageCountPanel({
1116
+ id: 10,
1117
+ title: 'Probes failing now',
1118
+ description: 'Blackbox probes reporting 0 on their most recent scrape.',
1119
+ gridPos: { h: 4, w: 6, x: 0, y: 0 },
1120
+ targets: [{ expr: `count(${PROBE_SELECTOR} == 0) or vector(0)`, legendFormat: 'failing', instant: true }],
1121
+ }),
1122
+ uptimeStatPanel({
1123
+ id: 11,
1124
+ title: 'Probe availability · range',
1125
+ description: 'Mean probe success over the selected time range, across every probe.',
1126
+ gridPos: { h: 4, w: 6, x: 6, y: 0 },
1127
+ targets: [
1128
+ { expr: `avg(avg_over_time(${PROBE_SELECTOR}[$__range]))`, legendFormat: 'availability', instant: true },
1129
+ ],
1130
+ }),
1131
+ outageCountPanel({
1132
+ id: 12,
1133
+ title: 'Targets failing now',
1134
+ description: 'Scrape targets Prometheus could not reach on its most recent attempt.',
1135
+ gridPos: { h: 4, w: 6, x: 12, y: 0 },
1136
+ targets: [{ expr: `count(${TARGET_SELECTOR} == 0) or vector(0)`, legendFormat: 'failing', instant: true }],
1137
+ }),
1138
+ uptimeStatPanel({
1139
+ id: 13,
1140
+ title: 'Target availability · range',
1141
+ description: 'Worst single target over the selected range, not the fleet average.',
1142
+ gridPos: { h: 4, w: 6, x: 18, y: 0 },
1143
+ targets: [
1144
+ {
1145
+ expr: `min(avg_over_time(${TARGET_SELECTOR}[$__range]))`,
1146
+ legendFormat: 'worst target',
1147
+ instant: true,
1148
+ },
1149
+ ],
1150
+ }),
1151
+ availabilityStatPanel({
1152
+ id: 14,
1153
+ title: 'Probe status by event',
1154
+ gridPos: { h: 5, w: 24, x: 0, y: 4 },
1155
+ targets: [{ expr: PROBE_SELECTOR, legendFormat: '{{underpost_event}} · {{instance}}', instant: true }],
1156
+ }),
1157
+ stateTimelinePanel({
1158
+ id: 1,
1159
+ title: 'Probe success by event',
1160
+ gridPos: { h: 9, w: 24, x: 0, y: 9 },
1161
+ targets: [{ expr: timelineExpr(PROBE_SELECTOR), legendFormat: '{{underpost_event}} · {{instance}}' }],
1162
+ }),
1163
+ availabilityStatPanel({
1164
+ id: 15,
1165
+ title: 'Target status',
1166
+ gridPos: { h: 5, w: 24, x: 0, y: 18 },
1167
+ targets: [{ expr: TARGET_SELECTOR, legendFormat: '{{job}} · {{instance}}', instant: true }],
1168
+ }),
1169
+ stateTimelinePanel({
1170
+ id: 4,
1171
+ title: 'Target availability',
1172
+ gridPos: { h: 9, w: 24, x: 0, y: 23 },
1173
+ targets: [{ expr: timelineExpr(TARGET_SELECTOR), legendFormat: '{{job}} · {{instance}}' }],
1174
+ }),
1175
+ timeSeriesPanel({
1176
+ id: 2,
1177
+ title: 'Probe duration',
1178
+ unit: 's',
1179
+ gridPos: { h: 8, w: 12, x: 0, y: 32 },
1180
+ targets: [
1181
+ {
1182
+ expr: 'probe_duration_seconds{underpost_event=~"$event"}',
1183
+ legendFormat: '{{underpost_event}} · {{instance}}',
1184
+ },
1185
+ ],
1186
+ }),
1187
+ timeSeriesPanel({
1188
+ id: 3,
1189
+ title: 'Express HTTP requests',
1190
+ unit: 'reqps',
1191
+ gridPos: { h: 8, w: 12, x: 12, y: 32 },
1192
+ targets: [
1193
+ {
1194
+ expr: 'sum by (job, instance) (rate({__name__=~".+_http_requests_total"}[5m]))',
1195
+ legendFormat: '{{instance}}',
1196
+ },
1197
+ ],
1198
+ }),
1199
+ ],
1200
+ },
1201
+ null,
1202
+ 2,
1203
+ )}\n`;
1204
+
1205
+ /**
1206
+ * @method grafanaExposureFactory
1207
+ * @description The URL model Grafana must be told about for a given exposure.
1208
+ *
1209
+ * Grafana builds every asset link, redirect and OAuth callback from
1210
+ * `root_url`, so it has to be told where it is being reached — a dashboard
1211
+ * served at a sub-path with the default root URL loads a blank page whose
1212
+ * assets 404 against the domain root. The two exposures need opposite answers,
1213
+ * which is why this resolves to a pair rather than a constant:
1214
+ *
1215
+ * - behind the edge at `/grafana`, it serves from the sub-path and must keep
1216
+ * the prefix, so no route rewrite is involved;
1217
+ * - on a NodePort it owns the whole origin and serves from the root.
1218
+ * @param {object} [params]
1219
+ * @param {string} [params.host] - Hostname the edge serves it under.
1220
+ * @param {string} [params.nodeIp] - Node address for the NodePort exposure.
1221
+ * @param {number} [params.nodePort] - Node port.
1222
+ * @returns {{rootUrl: string, subPath: boolean, url: string}} Root URL, sub-path mode, and the browser URL.
1223
+ * @memberof UnderpostMonitoring
1224
+ */
1225
+ const grafanaExposureFactory = ({ host = '', nodeIp = '', nodePort = UNDERPOST_MONITORING.grafana.nodePort } = {}) => {
1226
+ const { subPath, port } = UNDERPOST_MONITORING.grafana;
1227
+ if (host) {
1228
+ const url = `https://${host}${subPath}`;
1229
+ return { rootUrl: `${url}/`, subPath: true, url };
1230
+ }
1231
+ if (nodeIp) {
1232
+ const url = `http://${nodeIp}:${nodePort}`;
1233
+ return { rootUrl: `${url}/`, subPath: false, url };
1234
+ }
1235
+ return { rootUrl: `http://localhost:${port}/`, subPath: false, url: `http://localhost:${port}` };
1236
+ };
1237
+
1238
+ const grafanaStorageResetRequiredFactory = ({ nodeName = '', volumeNodes = [] } = {}) =>
1239
+ Boolean(nodeName && volumeNodes.length > 0 && !volumeNodes.includes(nodeName));
1240
+
1241
+ const grafanaResetPlanFactory = (volumeName = '') => {
1242
+ const { name, nodePortName, pvcName, routeName } = UNDERPOST_MONITORING.grafana;
1243
+ return {
1244
+ controllers: [`deployment/${name}`, `statefulset/${name}`],
1245
+ resources: [`service/${name}`, `service/${nodePortName}`, `httproute/${routeName}`],
1246
+ claim: `pvc/${pvcName}`,
1247
+ volumeName,
1248
+ };
1249
+ };
1250
+
1251
+ const grafanaAdminEnvFactory = () => [
1252
+ {
1253
+ name: 'GF_SECURITY_ADMIN_USER',
1254
+ valueFrom: {
1255
+ secretKeyRef: {
1256
+ name: UNDERPOST_MONITORING.grafana.adminSecretName,
1257
+ key: UNDERPOST_MONITORING.grafana.adminUserKey,
1258
+ },
1259
+ },
1260
+ },
1261
+ {
1262
+ name: 'GF_SECURITY_ADMIN_PASSWORD',
1263
+ valueFrom: {
1264
+ secretKeyRef: {
1265
+ name: UNDERPOST_MONITORING.grafana.adminSecretName,
1266
+ key: UNDERPOST_MONITORING.grafana.adminPasswordKey,
1267
+ },
1268
+ },
1269
+ },
1270
+ ];
1271
+ const grafanaRuntimeEnvFactory = (exposure) => [
1272
+ { name: 'GF_SERVER_ROOT_URL', value: exposure.rootUrl },
1273
+ { name: 'GF_SERVER_SERVE_FROM_SUB_PATH', value: `${exposure.subPath}` },
1274
+ ];
1275
+ const grafanaManagedEnvFactory = (exposure) => [...grafanaAdminEnvFactory(), ...grafanaRuntimeEnvFactory(exposure)];
1276
+
1277
+ /**
1278
+ * @method grafanaDeploymentPatchFactory
1279
+ * @description Renders the runtime fields used when republishing an existing Grafana Deployment.
1280
+ * @param {object} params
1281
+ * @param {object} params.exposure - Result of {@link grafanaExposureFactory}.
1282
+ * @param {string} [params.nodeName] - Kubernetes node to pin Grafana to.
1283
+ * @param {string} [params.adminSecretVersion] - Secret resource version used to roll Grafana.
1284
+ * @returns {object} Kubernetes Deployment merge patch.
1285
+ * @memberof UnderpostMonitoring
1286
+ */
1287
+ const grafanaDeploymentPatchFactory = ({ exposure, nodeName = '', adminSecretVersion = '' } = {}) => ({
1288
+ spec: {
1289
+ template: {
1290
+ ...(adminSecretVersion
1291
+ ? {
1292
+ metadata: {
1293
+ annotations: {
1294
+ [UNDERPOST_MONITORING.grafana.adminSecretVersionAnnotation]: adminSecretVersion,
1295
+ },
1296
+ },
1297
+ }
1298
+ : {}),
1299
+ spec: {
1300
+ ...(nodeName ? { nodeSelector: { 'kubernetes.io/hostname': nodeName } } : {}),
1301
+ containers: [
1302
+ {
1303
+ name: UNDERPOST_MONITORING.grafana.name,
1304
+ env: [...(adminSecretVersion ? grafanaAdminEnvFactory() : []), ...grafanaRuntimeEnvFactory(exposure)],
1305
+ },
1306
+ ],
1307
+ },
1308
+ },
1309
+ },
1310
+ });
1311
+
1312
+ /**
1313
+ * @method grafanaDeploymentFactory
1314
+ * @description Adds the browser origin and placement before Grafana's first pod is created.
1315
+ * @param {object} params
1316
+ * @param {object} params.deployment - Parsed base Deployment manifest.
1317
+ * @param {object} params.exposure - Result of {@link grafanaExposureFactory}.
1318
+ * @param {string} [params.namespace='default'] - Namespace holding Grafana.
1319
+ * @param {string} [params.nodeName] - Kubernetes node to pin Grafana to.
1320
+ * @param {string} [params.adminSecretVersion] - Secret resource version used to roll Grafana.
1321
+ * @returns {object} Deployment ready to apply.
1322
+ * @memberof UnderpostMonitoring
1323
+ */
1324
+ const grafanaDeploymentFactory = ({
1325
+ deployment,
1326
+ exposure,
1327
+ namespace = 'default',
1328
+ nodeName = '',
1329
+ adminSecretVersion = '',
1330
+ } = {}) => {
1331
+ const rendered = structuredClone(deployment);
1332
+ rendered.metadata.namespace = namespace;
1333
+ if (adminSecretVersion) {
1334
+ rendered.spec.template.metadata ||= {};
1335
+ rendered.spec.template.metadata.annotations = {
1336
+ ...(rendered.spec.template.metadata.annotations || {}),
1337
+ [UNDERPOST_MONITORING.grafana.adminSecretVersionAnnotation]: adminSecretVersion,
1338
+ };
1339
+ }
1340
+ const podSpec = rendered.spec.template.spec;
1341
+ podSpec.nodeSelector = nodeName ? { 'kubernetes.io/hostname': nodeName } : {};
1342
+
1343
+ const container = podSpec.containers.find(({ name }) => name === UNDERPOST_MONITORING.grafana.name);
1344
+ if (!container) throw new Error('Grafana Deployment manifest has no grafana container');
1345
+ const managedNames = new Set(grafanaManagedEnvFactory(exposure).map(({ name }) => name));
1346
+ container.env = [
1347
+ ...(container.env || []).filter(({ name }) => !managedNames.has(name)),
1348
+ ...grafanaManagedEnvFactory(exposure),
1349
+ ];
1350
+ return rendered;
1351
+ };
1352
+
1353
+ /** Renders the Secret used for Grafana's initial administrator credentials. */
1354
+ const grafanaAdminSecretFactory = ({ username, password, namespace = 'default' } = {}) => {
1355
+ if (!username || !password) throw new Error('Grafana admin username and password are required');
1356
+ const { name, adminSecretName, adminUserKey, adminPasswordKey } = UNDERPOST_MONITORING.grafana;
1357
+ return `
1358
+ ---
1359
+ apiVersion: v1
1360
+ kind: Secret
1361
+ metadata:
1362
+ name: ${adminSecretName}
1363
+ namespace: ${namespace}
1364
+ labels:
1365
+ app: ${name}
1366
+ app.kubernetes.io/managed-by: underpost
1367
+ type: Opaque
1368
+ data:
1369
+ ${adminUserKey}: ${Buffer.from(`${username}`, 'utf8').toString('base64')}
1370
+ ${adminPasswordKey}: ${Buffer.from(`${password}`, 'utf8').toString('base64')}
1371
+ `;
1372
+ };
1373
+
1374
+ /**
1375
+ * @method grafanaNodePortFactory
1376
+ * @description Renders the NodePort Service that publishes Grafana on the LAN.
1377
+ *
1378
+ * A second Service beside the ClusterIP rather than a change to it: the
1379
+ * in-cluster datasource and the browser reach Grafana for different reasons,
1380
+ * and a NodePort withdrawn later must not take the cluster-internal address
1381
+ * with it.
1382
+ * @param {object} [params]
1383
+ * @param {string} [params.namespace] - Namespace holding Grafana.
1384
+ * @param {number} [params.nodePort] - Node port to publish on.
1385
+ * @returns {string} Service YAML.
1386
+ * @memberof UnderpostMonitoring
1387
+ */
1388
+ const grafanaNodePortFactory = ({ namespace = 'default', nodePort = UNDERPOST_MONITORING.grafana.nodePort } = {}) => {
1389
+ const { name, nodePortName, port } = UNDERPOST_MONITORING.grafana;
1390
+ return `
1391
+ ---
1392
+ apiVersion: v1
1393
+ kind: Service
1394
+ metadata:
1395
+ name: ${nodePortName}
1396
+ namespace: ${namespace}
1397
+ labels:
1398
+ app: ${name}
1399
+ spec:
1400
+ type: NodePort
1401
+ externalTrafficPolicy: Cluster
1402
+ selector:
1403
+ app: ${name}
1404
+ ports:
1405
+ - name: http
1406
+ protocol: TCP
1407
+ port: ${port}
1408
+ targetPort: ${port}
1409
+ nodePort: ${nodePort}
1410
+ `;
1411
+ };
1412
+
1413
+ /**
1414
+ * @method nodeMetricsDashboardFactory
1415
+ * @description The host-metrics dashboard.
1416
+ *
1417
+ * Every panel is grouped by `instance`, which for a discovered node is its
1418
+ * InternalIP and for the hub its tunnel address — the same identity the node
1419
+ * events resolve a repair target from, so a spike here names a machine an
1420
+ * operator can act on, and legends carry the `underpost_role` Prometheus
1421
+ * relabels onto it so hub, control and worker are told apart at a glance.
1422
+ * @returns {string} Dashboard JSON.
1423
+ * @memberof UnderpostMonitoring
1424
+ */
1425
+ const nodeMetricsDashboardFactory = () => {
1426
+ const { networkDeviceSelector } = UNDERPOST_MONITORING.nodeExporter;
1427
+ return `${JSON.stringify(
1428
+ {
1429
+ uid: 'underpost-node-metrics',
1430
+ title: 'Underpost · Node Metrics',
1431
+ tags: ['underpost', 'nodes', 'hardware'],
1432
+ timezone: 'browser',
1433
+ schemaVersion: 39,
1434
+ refresh: '30s',
1435
+ time: { from: 'now-6h', to: 'now' },
1436
+ panels: [
1437
+ timeSeriesPanel({
1438
+ id: 1,
1439
+ title: 'Node CPU Usage %',
1440
+ unit: 'percent',
1441
+ gridPos: { h: 8, w: 12, x: 0, y: 0 },
1442
+ targets: [
1443
+ {
1444
+ expr:
1445
+ '100 - (avg by (instance, underpost_role) ' + '(rate(node_cpu_seconds_total{mode="idle"}[2m])) * 100)',
1446
+ legendFormat: NODE_LEGEND,
1447
+ },
1448
+ ],
1449
+ }),
1450
+ timeSeriesPanel({
1451
+ id: 2,
1452
+ title: 'Node Memory Usage %',
1453
+ unit: 'percent',
1454
+ gridPos: { h: 8, w: 12, x: 12, y: 0 },
1455
+ targets: [
1456
+ {
1457
+ expr: '100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))',
1458
+ legendFormat: NODE_LEGEND,
1459
+ },
1460
+ ],
1461
+ }),
1462
+ timeSeriesPanel({
1463
+ id: 3,
1464
+ title: 'Network Throughput (RX/TX)',
1465
+ unit: 'binBps',
1466
+ gridPos: { h: 8, w: 12, x: 0, y: 8 },
1467
+ targets: [
1468
+ {
1469
+ expr: `rate(node_network_receive_bytes_total{${networkDeviceSelector}}[2m])`,
1470
+ legendFormat: `${NODE_LEGEND} {{device}} rx`,
1471
+ },
1472
+ {
1473
+ expr: `rate(node_network_transmit_bytes_total{${networkDeviceSelector}}[2m])`,
1474
+ legendFormat: `${NODE_LEGEND} {{device}} tx`,
1475
+ },
1476
+ ],
1477
+ }),
1478
+ timeSeriesPanel({
1479
+ id: 4,
1480
+ title: 'Disk Usage % & I/O Rates',
1481
+ unit: 'percent',
1482
+ gridPos: { h: 8, w: 12, x: 12, y: 8 },
1483
+ overrides: [secondaryAxisOverride('B', 'binBps'), secondaryAxisOverride('C', 'binBps')],
1484
+ targets: [
1485
+ {
1486
+ expr:
1487
+ '100 - ((node_filesystem_avail_bytes{mountpoint="/"} * 100) / ' +
1488
+ 'node_filesystem_size_bytes{mountpoint="/"})',
1489
+ legendFormat: `${NODE_LEGEND} used %`,
1490
+ },
1491
+ { expr: 'rate(node_disk_read_bytes_total[2m])', legendFormat: `${NODE_LEGEND} {{device}} read` },
1492
+ { expr: 'rate(node_disk_written_bytes_total[2m])', legendFormat: `${NODE_LEGEND} {{device}} write` },
1493
+ ],
1494
+ }),
1495
+ timeSeriesPanel({
1496
+ id: 5,
1497
+ title: 'Vultr Hub Monthly Bandwidth Usage',
1498
+ unit: 'percent',
1499
+ gridPos: { h: 6, w: 24, x: 0, y: 16 },
1500
+ targets: [
1501
+ {
1502
+ expr: '(vultr_bandwidth_used_bytes / vultr_bandwidth_limit_bytes) * 100',
1503
+ legendFormat: 'Hub Bandwidth Usage % (hub)',
1504
+ },
1505
+ ],
1506
+ }),
1507
+ ],
1508
+ },
1509
+ null,
1510
+ 2,
1511
+ )}\n`;
1512
+ };
1513
+
1514
+ /**
1515
+ * @method monitoringConfigFactory
1516
+ * @description Renders the namespace-dependent half of the stack: the RBAC that
1517
+ * lets Prometheus discover pods, and every ConfigMap the four components mount.
1518
+ *
1519
+ * Configuration is generated and workloads are not: the Deployments in
1520
+ * `manifests/` describe processes that do not change, while these documents are
1521
+ * a projection of the live deploy configuration and event registry. Applying
1522
+ * them is what makes a newly added host or event visible to the stack. The RBAC
1523
+ * belongs here rather than in the file because a ClusterRoleBinding names its
1524
+ * ServiceAccount's namespace in the document body, where `kubectl -n` does not
1525
+ * reach.
1526
+ * @param {object} params
1527
+ * @param {string} [params.namespace] - Namespace to render into.
1528
+ * @param {string} params.prometheusConf - Rendered `prometheus.yml`.
1529
+ * @param {string} params.alertRules - Rendered rules file.
1530
+ * @param {string} params.alertmanagerConf - Rendered `alertmanager.yml`.
1531
+ * @param {string} params.blackboxConf - Rendered `blackbox.yml`.
1532
+ * @returns {string} Multi-document YAML.
1533
+ * @memberof UnderpostMonitoring
1534
+ */
1535
+ const monitoringConfigFactory = ({
1536
+ namespace = 'default',
1537
+ prometheusConf,
1538
+ alertRules,
1539
+ alertmanagerConf,
1540
+ blackboxConf,
1541
+ } = {}) => {
1542
+ const { prometheus, alertmanager, blackbox, grafana } = UNDERPOST_MONITORING;
1543
+ return `${nodeExporterManifestFactory({ namespace })}
1544
+ ---
1545
+ apiVersion: v1
1546
+ kind: ServiceAccount
1547
+ metadata:
1548
+ name: ${prometheus.name}
1549
+ namespace: ${namespace}
1550
+ labels:
1551
+ app: ${prometheus.name}
1552
+ ---
1553
+ apiVersion: rbac.authorization.k8s.io/v1
1554
+ kind: ClusterRole
1555
+ metadata:
1556
+ name: ${prometheus.name}
1557
+ labels:
1558
+ app: ${prometheus.name}
1559
+ rules:
1560
+ - apiGroups: ['']
1561
+ resources: ['nodes', 'nodes/metrics', 'services', 'endpoints', 'pods']
1562
+ verbs: ['get', 'list', 'watch']
1563
+ - nonResourceURLs: ['/metrics']
1564
+ verbs: ['get']
1565
+ ---
1566
+ apiVersion: rbac.authorization.k8s.io/v1
1567
+ kind: ClusterRoleBinding
1568
+ metadata:
1569
+ name: ${prometheus.name}
1570
+ labels:
1571
+ app: ${prometheus.name}
1572
+ roleRef:
1573
+ apiGroup: rbac.authorization.k8s.io
1574
+ kind: ClusterRole
1575
+ name: ${prometheus.name}
1576
+ subjects:
1577
+ - kind: ServiceAccount
1578
+ name: ${prometheus.name}
1579
+ namespace: ${namespace}
1580
+ ---
1581
+ apiVersion: v1
1582
+ kind: ConfigMap
1583
+ metadata:
1584
+ name: ${prometheus.configMapName}
1585
+ namespace: ${namespace}
1586
+ labels:
1587
+ app: ${prometheus.name}
1588
+ data:
1589
+ prometheus.yml: |
1590
+ ${indentBlock(prometheusConf, 4)}
1591
+ ---
1592
+ apiVersion: v1
1593
+ kind: ConfigMap
1594
+ metadata:
1595
+ name: ${prometheus.rulesConfigMapName}
1596
+ namespace: ${namespace}
1597
+ labels:
1598
+ app: ${prometheus.name}
1599
+ data:
1600
+ underpost-events.yml: |
1601
+ ${indentBlock(alertRules, 4)}
1602
+ ---
1603
+ apiVersion: v1
1604
+ kind: ConfigMap
1605
+ metadata:
1606
+ name: ${alertmanager.configMapName}
1607
+ namespace: ${namespace}
1608
+ labels:
1609
+ app: ${alertmanager.name}
1610
+ data:
1611
+ alertmanager.yml: |
1612
+ ${indentBlock(alertmanagerConf, 4)}
1613
+ ---
1614
+ apiVersion: v1
1615
+ kind: ConfigMap
1616
+ metadata:
1617
+ name: ${blackbox.configMapName}
1618
+ namespace: ${namespace}
1619
+ labels:
1620
+ app: ${blackbox.name}
1621
+ data:
1622
+ blackbox.yml: |
1623
+ ${indentBlock(blackboxConf, 4)}
1624
+ ---
1625
+ apiVersion: v1
1626
+ kind: ConfigMap
1627
+ metadata:
1628
+ name: ${grafana.datasourcesConfigMapName}
1629
+ namespace: ${namespace}
1630
+ labels:
1631
+ app: ${grafana.name}
1632
+ data:
1633
+ underpost.yaml: |
1634
+ ${indentBlock(grafanaDatasourcesFactory({ namespace }), 4)}
1635
+ ---
1636
+ apiVersion: v1
1637
+ kind: ConfigMap
1638
+ metadata:
1639
+ name: ${grafana.dashboardProviderConfigMapName}
1640
+ namespace: ${namespace}
1641
+ labels:
1642
+ app: ${grafana.name}
1643
+ data:
1644
+ underpost.yaml: |
1645
+ ${indentBlock(grafanaDashboardProviderFactory(), 4)}
1646
+ ---
1647
+ apiVersion: v1
1648
+ kind: ConfigMap
1649
+ metadata:
1650
+ name: ${grafana.dashboardsConfigMapName}
1651
+ namespace: ${namespace}
1652
+ labels:
1653
+ app: ${grafana.name}
1654
+ data:
1655
+ underpost-envoy.json: |
1656
+ ${indentBlock(envoyDashboardFactory(), 4)}
1657
+ underpost-events.json: |
1658
+ ${indentBlock(eventsDashboardFactory(), 4)}
1659
+ underpost-node-metrics.json: |
1660
+ ${indentBlock(nodeMetricsDashboardFactory(), 4)}
1661
+ `;
1662
+ };
1663
+
1664
+ /**
1665
+ * @method webhookSecretFactory
1666
+ * @description Renders the Secret holding the bearer token Alertmanager
1667
+ * presents to the dispatcher.
1668
+ *
1669
+ * A Secret rather than a field in the Alertmanager ConfigMap: the same token
1670
+ * authorizes anything that can reach the receiver to trigger remediation on the
1671
+ * hub, so it must not sit in a document `kubectl get -o yaml` prints in full.
1672
+ * @param {object} params
1673
+ * @param {string} params.token - Shared bearer token.
1674
+ * @param {string} [params.namespace] - Namespace to render into.
1675
+ * @returns {string} Secret YAML.
1676
+ * @memberof UnderpostMonitoring
1677
+ */
1678
+ const webhookSecretFactory = ({ token, namespace = 'default' } = {}) => {
1679
+ const { alertmanager } = UNDERPOST_MONITORING;
1680
+ return `
1681
+ ---
1682
+ apiVersion: v1
1683
+ kind: Secret
1684
+ metadata:
1685
+ name: ${alertmanager.secretName}
1686
+ namespace: ${namespace}
1687
+ labels:
1688
+ app: ${alertmanager.name}
1689
+ type: Opaque
1690
+ data:
1691
+ ${alertmanager.tokenKey}: ${Buffer.from(`${token}`, 'utf8').toString('base64')}
1692
+ `;
1693
+ };
1694
+
1695
+ export {
1696
+ UNDERPOST_MONITORING,
1697
+ alertRulesFactory,
1698
+ alertmanagerConfFactory,
1699
+ appScrapeEntriesFactory,
1700
+ blackboxConfFactory,
1701
+ deployedEventIdsFactory,
1702
+ envoyDashboardFactory,
1703
+ eventsDashboardFactory,
1704
+ nodeExporterManifestFactory,
1705
+ nodeExporterServiceFactory,
1706
+ nodeExporterServiceScriptFactory,
1707
+ nodeMetricsDashboardFactory,
1708
+ grafanaAdminSecretFactory,
1709
+ grafanaDeploymentFactory,
1710
+ grafanaDeploymentPatchFactory,
1711
+ grafanaDashboardProviderFactory,
1712
+ grafanaExposureFactory,
1713
+ grafanaNodePortFactory,
1714
+ grafanaResetPlanFactory,
1715
+ grafanaStorageResetRequiredFactory,
1716
+ grafanaDatasourcesFactory,
1717
+ metricsPathFactory,
1718
+ monitoringConfigFactory,
1719
+ probeGroupsFactory,
1720
+ prometheusConfFactory,
1721
+ scrapeDeployListFactory,
1722
+ serviceDnsFactory,
1723
+ webhookSecretFactory,
1724
+ };