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