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