@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
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import UnderpostEvent from '../../../../src/cli/event.js';
|
|
8
|
+
import UnderpostMonitor from '../../../../src/cli/monitor.js';
|
|
9
|
+
import UnderpostSSH from '../../../../src/cli/ssh.js';
|
|
10
|
+
import { EDGE_TOPOLOGY_PATH } from '../../../../src/cli/wireguard.js';
|
|
11
|
+
import { UNDERPOST_MONITORING } from '../../../../src/server/ops/monitoring.js';
|
|
12
|
+
import { shellHarness } from '../../../support/shell-harness.js';
|
|
13
|
+
|
|
14
|
+
const NODES_PATH = './engine-private/deploy/nodes';
|
|
15
|
+
const ROUTES_PATH = './engine-private/deploy/dd.routes';
|
|
16
|
+
const HUB_HOST = '203.0.113.10';
|
|
17
|
+
const KEY_PATH = './engine-private/deploy/users/fixture/id_rsa';
|
|
18
|
+
|
|
19
|
+
const TOPOLOGY = {
|
|
20
|
+
[HUB_HOST]: {
|
|
21
|
+
interfaceName: 'wg0',
|
|
22
|
+
listenPort: 51820,
|
|
23
|
+
address: '10.0.0.1/24',
|
|
24
|
+
publicKey: 'hubPublicKeyFixture=',
|
|
25
|
+
sshForwardPort: 2222,
|
|
26
|
+
peers: [
|
|
27
|
+
{
|
|
28
|
+
id: 'control-a',
|
|
29
|
+
address: '10.0.0.2',
|
|
30
|
+
managementHost: '198.51.100.2',
|
|
31
|
+
publicKey: 'peerAPublicKeyFixture=',
|
|
32
|
+
hosts: ['app.fixture.test'],
|
|
33
|
+
default: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'worker-b',
|
|
37
|
+
address: '10.0.0.3',
|
|
38
|
+
managementHost: '198.51.100.3',
|
|
39
|
+
publicKey: 'peerBPublicKeyFixture=',
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const FILES = {
|
|
46
|
+
[EDGE_TOPOLOGY_PATH]: JSON.stringify(TOPOLOGY),
|
|
47
|
+
[`${NODES_PATH}/hub-node.json`]: JSON.stringify({ role: 'hub', hubHost: HUB_HOST }),
|
|
48
|
+
[`${NODES_PATH}/control-node.json`]: JSON.stringify({ role: 'control', hubHost: HUB_HOST, peerId: 'control-a' }),
|
|
49
|
+
[`${NODES_PATH}/worker-node.json`]: JSON.stringify({ role: 'worker', hubHost: HUB_HOST, peerId: 'worker-b' }),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Topology, node documents and the SSH registry all live in the private
|
|
53
|
+
// repository, and every repair reaches a real host. The suite replaces the
|
|
54
|
+
// three: an in-memory document tree, a stubbed connection registry, and a
|
|
55
|
+
// recorded command runner.
|
|
56
|
+
const eventFixture = ({ files = FILES, hostname = 'hub-node' } = {}) => {
|
|
57
|
+
const table = new Map(Object.entries(files));
|
|
58
|
+
const written = new Map();
|
|
59
|
+
const keys = () => [...table.keys(), ...written.keys()];
|
|
60
|
+
|
|
61
|
+
vi.spyOn(os, 'hostname').mockReturnValue(hostname);
|
|
62
|
+
vi.spyOn(os, 'networkInterfaces').mockReturnValue({
|
|
63
|
+
eth0: [{ family: 'IPv4', address: '203.0.113.10', internal: false }],
|
|
64
|
+
});
|
|
65
|
+
vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => {
|
|
66
|
+
const key = `${filePath}`;
|
|
67
|
+
return table.has(key) || written.has(key) || keys().some((entry) => entry.startsWith(`${key}/`));
|
|
68
|
+
});
|
|
69
|
+
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
|
70
|
+
const key = `${filePath}`;
|
|
71
|
+
if (written.has(key)) return written.get(key);
|
|
72
|
+
if (table.has(key)) return table.get(key);
|
|
73
|
+
throw Object.assign(new Error(`ENOENT: ${key}`), { code: 'ENOENT' });
|
|
74
|
+
});
|
|
75
|
+
vi.spyOn(fs, 'writeFileSync').mockImplementation((filePath, value) => written.set(`${filePath}`, `${value}`));
|
|
76
|
+
vi.spyOn(fs, 'readdirSync').mockImplementation((dir) =>
|
|
77
|
+
keys()
|
|
78
|
+
.filter((filePath) => filePath.startsWith(`${dir}/`))
|
|
79
|
+
.map((filePath) => filePath.slice(`${dir}/`.length))
|
|
80
|
+
.filter((name) => !name.includes('/')),
|
|
81
|
+
);
|
|
82
|
+
vi.spyOn(UnderpostSSH.API, 'resolveConnection').mockImplementation(({ host }) =>
|
|
83
|
+
host ? { user: 'fixture', host, port: 22, keyPath: KEY_PATH } : null,
|
|
84
|
+
);
|
|
85
|
+
return { table, written };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const stubRunCommand = (implementation) =>
|
|
89
|
+
vi
|
|
90
|
+
.spyOn(UnderpostEvent.API, 'runCommand')
|
|
91
|
+
.mockImplementation(implementation ?? (async () => ({ ok: true, output: 'ok' })));
|
|
92
|
+
|
|
93
|
+
describe('event subject registries', () => {
|
|
94
|
+
afterEach(() => vi.restoreAllMocks());
|
|
95
|
+
|
|
96
|
+
it('reads the hub tunnel address from either end', () => {
|
|
97
|
+
eventFixture();
|
|
98
|
+
expect(UnderpostEvent.API.hubAddress()).to.equal('10.0.0.1');
|
|
99
|
+
vi.restoreAllMocks();
|
|
100
|
+
eventFixture({ hostname: 'control-node' });
|
|
101
|
+
expect(UnderpostEvent.API.hubAddress()).to.equal('10.0.0.1');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('joins each peer to the node document that claims it', () => {
|
|
105
|
+
eventFixture();
|
|
106
|
+
expect(UnderpostEvent.API.spokes()).to.deep.equal([
|
|
107
|
+
{
|
|
108
|
+
id: 'control-a',
|
|
109
|
+
address: '10.0.0.2',
|
|
110
|
+
managementHost: '198.51.100.2',
|
|
111
|
+
nodeRole: 'control',
|
|
112
|
+
nodeName: 'control-node',
|
|
113
|
+
local: false,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'worker-b',
|
|
117
|
+
address: '10.0.0.3',
|
|
118
|
+
managementHost: '198.51.100.3',
|
|
119
|
+
nodeRole: 'worker',
|
|
120
|
+
nodeName: 'worker-node',
|
|
121
|
+
local: false,
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Locality is settled against this machine's own addresses, never against the
|
|
127
|
+
// node document that named it: a generic hostname names every machine that
|
|
128
|
+
// kept the default, and a repair would then run on whichever host loaded the
|
|
129
|
+
// config.
|
|
130
|
+
it('settles locality against this machine own addresses', () => {
|
|
131
|
+
eventFixture();
|
|
132
|
+
vi.spyOn(os, 'networkInterfaces').mockReturnValue({
|
|
133
|
+
eth0: [{ family: 'IPv4', address: '198.51.100.3', internal: false }],
|
|
134
|
+
});
|
|
135
|
+
expect(UnderpostEvent.API.spokes().find((spoke) => spoke.id === 'worker-b').local).to.equal(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('reports the nodes the cluster schedules on, hubs excluded', () => {
|
|
139
|
+
eventFixture();
|
|
140
|
+
expect(UnderpostEvent.API.clusterNodes()).to.deep.equal([
|
|
141
|
+
{ nodeName: 'control-node', role: 'control' },
|
|
142
|
+
{ nodeName: 'worker-node', role: 'worker' },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('lists every registered hub, named by its node document', () => {
|
|
147
|
+
eventFixture();
|
|
148
|
+
expect(UnderpostEvent.API.hubs()).to.deep.equal([
|
|
149
|
+
{ nodeName: 'hub-node', hubHost: HUB_HOST, address: '10.0.0.1', sshForwardPort: 2222 },
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('selects a hub by node name or by static address', () => {
|
|
154
|
+
eventFixture();
|
|
155
|
+
expect(UnderpostEvent.API.hubs('hub-node')[0].hubHost).to.equal(HUB_HOST);
|
|
156
|
+
expect(UnderpostEvent.API.hubs(HUB_HOST)[0].nodeName).to.equal('hub-node');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('refuses a selector that names no registered hub', () => {
|
|
160
|
+
eventFixture();
|
|
161
|
+
expect(() => UnderpostEvent.API.hubs('ghost')).to.throw('is not a registered hub node or address');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// A hub with no node document is still a hub: topology owns the hub set.
|
|
165
|
+
it('reports a hub the node documents never named', () => {
|
|
166
|
+
eventFixture({ files: { [EDGE_TOPOLOGY_PATH]: JSON.stringify(TOPOLOGY) } });
|
|
167
|
+
expect(UnderpostEvent.API.hubs()[0].nodeName).to.equal('');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('event execution targets', () => {
|
|
172
|
+
afterEach(() => vi.restoreAllMocks());
|
|
173
|
+
|
|
174
|
+
it('resolves a hub through its static public address, which no failed tunnel is part of', () => {
|
|
175
|
+
eventFixture();
|
|
176
|
+
expect(UnderpostEvent.API.hubTarget()).to.deep.equal({
|
|
177
|
+
role: 'hub',
|
|
178
|
+
nodeName: 'hub-node',
|
|
179
|
+
address: '10.0.0.1',
|
|
180
|
+
user: 'fixture',
|
|
181
|
+
host: HUB_HOST,
|
|
182
|
+
port: 22,
|
|
183
|
+
keyPath: KEY_PATH,
|
|
184
|
+
via: `fixture@${HUB_HOST}:22`,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('resolves a spoke through its registered LAN management host', () => {
|
|
189
|
+
eventFixture();
|
|
190
|
+
expect(UnderpostEvent.API.spokeTarget('worker-b')).to.include({
|
|
191
|
+
role: 'spoke',
|
|
192
|
+
nodeRole: 'worker',
|
|
193
|
+
spokeId: 'worker-b',
|
|
194
|
+
address: '10.0.0.3',
|
|
195
|
+
host: '198.51.100.3',
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('runs locally for a spoke that is this machine', () => {
|
|
200
|
+
eventFixture();
|
|
201
|
+
vi.spyOn(os, 'networkInterfaces').mockReturnValue({
|
|
202
|
+
eth0: [{ family: 'IPv4', address: '198.51.100.3', internal: false }],
|
|
203
|
+
});
|
|
204
|
+
expect(UnderpostEvent.API.spokeTarget('worker-b')).to.include({ via: 'local', user: '', host: '' });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('names the command that registers an unknown or identity-less spoke', () => {
|
|
208
|
+
eventFixture();
|
|
209
|
+
expect(() => UnderpostEvent.API.spokeTarget('ghost')).to.throw('register it with --peer-add');
|
|
210
|
+
vi.restoreAllMocks();
|
|
211
|
+
eventFixture({
|
|
212
|
+
files: {
|
|
213
|
+
[EDGE_TOPOLOGY_PATH]: JSON.stringify(TOPOLOGY),
|
|
214
|
+
...FILES,
|
|
215
|
+
[`${NODES_PATH}/worker-node.json`]: JSON.stringify({ role: 'control', hubHost: HUB_HOST, peerId: 'other' }),
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
expect(() => UnderpostEvent.API.spokeTarget('worker-b')).to.throw('run --node-config on that machine');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('names the command that registers a management route for a spoke', () => {
|
|
222
|
+
eventFixture();
|
|
223
|
+
vi.spyOn(UnderpostSSH.API, 'resolveConnection').mockReturnValue(null);
|
|
224
|
+
expect(() => UnderpostEvent.API.spokeTarget('worker-b')).to.throw('--user-add');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('refuses a spoke registered with no management host', () => {
|
|
228
|
+
eventFixture({
|
|
229
|
+
files: {
|
|
230
|
+
...FILES,
|
|
231
|
+
[EDGE_TOPOLOGY_PATH]: JSON.stringify({
|
|
232
|
+
[HUB_HOST]: {
|
|
233
|
+
...TOPOLOGY[HUB_HOST],
|
|
234
|
+
peers: [{ id: 'worker-b', address: '10.0.0.3', publicKey: 'k=' }],
|
|
235
|
+
},
|
|
236
|
+
}),
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
expect(() => UnderpostEvent.API.spokeTarget('worker-b')).to.throw('has no managementHost');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// A rehearsal must be runnable from any machine holding the deploy
|
|
243
|
+
// configuration and the SSH registry, not only from the control plane.
|
|
244
|
+
it('resolves the node the observability stack reads probes on', () => {
|
|
245
|
+
eventFixture();
|
|
246
|
+
expect(UnderpostEvent.API.controlTarget().spokeId).to.equal('control-a');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('refuses when no control node is registered', () => {
|
|
250
|
+
eventFixture({
|
|
251
|
+
files: {
|
|
252
|
+
[EDGE_TOPOLOGY_PATH]: JSON.stringify(TOPOLOGY),
|
|
253
|
+
[`${NODES_PATH}/hub-node.json`]: FILES[`${NODES_PATH}/hub-node.json`],
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
expect(() => UnderpostEvent.API.controlTarget()).to.throw('probes have no reader');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('selects every subject of a role when no selector narrows it', () => {
|
|
260
|
+
eventFixture();
|
|
261
|
+
expect(UnderpostEvent.API.subjectSelection('hub', {})).to.deep.equal([HUB_HOST]);
|
|
262
|
+
expect(UnderpostEvent.API.subjectSelection('spoke', {})).to.deep.equal(['control-a', 'worker-b']);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('narrows a selection by node name or subject id', () => {
|
|
266
|
+
eventFixture();
|
|
267
|
+
expect(UnderpostEvent.API.subjectSelection('hub', { nodes: 'hub-node' })).to.deep.equal([HUB_HOST]);
|
|
268
|
+
expect(UnderpostEvent.API.subjectSelection('spoke', { nodes: 'worker-node' })).to.deep.equal(['worker-b']);
|
|
269
|
+
expect(UnderpostEvent.API.subjectSelection('spoke', { spoke: 'control-a' })).to.deep.equal(['control-a']);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('refuses a selector matching nothing of that role', () => {
|
|
273
|
+
eventFixture();
|
|
274
|
+
expect(() => UnderpostEvent.API.subjectSelection('spoke', { spoke: 'ghost' })).to.throw(
|
|
275
|
+
'is not a registered spoke id or node name',
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('keys every collector target by the address Prometheus labels its series with', () => {
|
|
280
|
+
eventFixture();
|
|
281
|
+
const targets = UnderpostEvent.API.nodeTargets();
|
|
282
|
+
expect(targets.map(({ instance }) => instance)).to.deep.equal(['10.0.0.1', '198.51.100.2', '198.51.100.3']);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('reports an unresolvable target rather than dropping the node', () => {
|
|
286
|
+
eventFixture();
|
|
287
|
+
vi.spyOn(UnderpostSSH.API, 'resolveConnection').mockReturnValue(null);
|
|
288
|
+
expect(UnderpostEvent.API.nodeTargets().every((target) => target.via === 'unresolved')).to.equal(true);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe('event remediation handlers', () => {
|
|
293
|
+
let harness;
|
|
294
|
+
|
|
295
|
+
beforeEach(() => {
|
|
296
|
+
harness = shellHarness();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
afterEach(() => {
|
|
300
|
+
harness.restore();
|
|
301
|
+
vi.restoreAllMocks();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('hub', () => {
|
|
305
|
+
it('restarts each hub across its external SSH endpoint', async () => {
|
|
306
|
+
eventFixture();
|
|
307
|
+
const run = stubRunCommand();
|
|
308
|
+
const result = await UnderpostEvent.API.repairHub({});
|
|
309
|
+
expect(result.ok).to.equal(true);
|
|
310
|
+
expect(run.mock.calls[0][0]).to.include('--wireguard-restart --check');
|
|
311
|
+
expect(run.mock.calls[0][1].host).to.equal(HUB_HOST);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('acts on exactly the hubs the alert names', async () => {
|
|
315
|
+
eventFixture();
|
|
316
|
+
const run = stubRunCommand();
|
|
317
|
+
await UnderpostEvent.API.repairHub({}, [{ labels: { underpost_hub: HUB_HOST } }]);
|
|
318
|
+
expect(run.mock.calls.filter(([command]) => command.includes('--wireguard-restart')).length).to.equal(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('reports an unresolvable hub without failing the pass', async () => {
|
|
322
|
+
eventFixture();
|
|
323
|
+
stubRunCommand();
|
|
324
|
+
vi.spyOn(UnderpostEvent.API, 'hubTarget').mockImplementation(() => {
|
|
325
|
+
throw new Error('no management route');
|
|
326
|
+
});
|
|
327
|
+
const result = await UnderpostEvent.API.repairHub({});
|
|
328
|
+
expect(result.ok).to.equal(false);
|
|
329
|
+
expect(result.targets[0].via).to.equal('unresolved');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('reports that no hub is registered at all', async () => {
|
|
333
|
+
eventFixture({ files: {} });
|
|
334
|
+
vi.spyOn(UnderpostEvent.API, 'subjectSelection').mockReturnValue([]);
|
|
335
|
+
const result = await UnderpostEvent.API.repairHub({});
|
|
336
|
+
expect(result).to.include({ ok: false, role: 'hub' });
|
|
337
|
+
expect(result.targets).to.deep.equal([]);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('spokes', () => {
|
|
342
|
+
it('repairs every registered spoke when invoked by hand', async () => {
|
|
343
|
+
eventFixture();
|
|
344
|
+
const run = stubRunCommand();
|
|
345
|
+
const result = await UnderpostEvent.API.repairSpokes({});
|
|
346
|
+
expect(result.ok).to.equal(true);
|
|
347
|
+
expect(run.mock.calls[0][0]).to.include('--expected-id');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('acts on exactly the spokes the alert names', async () => {
|
|
351
|
+
eventFixture();
|
|
352
|
+
const run = stubRunCommand();
|
|
353
|
+
await UnderpostEvent.API.repairSpokes({}, [{ labels: { underpost_spoke: 'worker-b' } }]);
|
|
354
|
+
expect(run.mock.calls[0][0]).to.include('worker-b');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Spokes are independent hosts; a partial recovery beats none.
|
|
358
|
+
it('does not let one unresolvable spoke stop the others', async () => {
|
|
359
|
+
eventFixture();
|
|
360
|
+
stubRunCommand();
|
|
361
|
+
vi.spyOn(UnderpostEvent.API, 'spokeTarget').mockImplementation((spokeId) => {
|
|
362
|
+
if (spokeId === 'control-a') throw new Error('no management route');
|
|
363
|
+
return { role: 'spoke', nodeRole: 'worker', spokeId, address: '10.0.0.3', user: 'u', host: 'h', via: 'ssh' };
|
|
364
|
+
});
|
|
365
|
+
const result = await UnderpostEvent.API.repairSpokes({});
|
|
366
|
+
expect(result.ok).to.equal(false);
|
|
367
|
+
expect(result.targets.map(({ ok }) => ok)).to.deep.equal([false, true]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('reports that no spoke is registered at all', async () => {
|
|
371
|
+
eventFixture();
|
|
372
|
+
vi.spyOn(UnderpostEvent.API, 'subjectSelection').mockReturnValue([]);
|
|
373
|
+
const result = await UnderpostEvent.API.repairSpokes({});
|
|
374
|
+
expect(result).to.include({ ok: false, role: 'spoke' });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('reads the edge status the CLI reports rather than assembling a second opinion', async () => {
|
|
378
|
+
eventFixture();
|
|
379
|
+
const run = stubRunCommand(async () => ({ ok: true, output: 'status output' }));
|
|
380
|
+
expect(await UnderpostEvent.API.wireguardHealth({})).to.equal('status output');
|
|
381
|
+
expect(run.mock.calls[0][0]).to.equal('node bin wireguard --status');
|
|
382
|
+
expect(await UnderpostEvent.API.wireguardHealth({ dryRun: true })).to.include('dry run');
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe('public ingress', () => {
|
|
387
|
+
const ingressFixture = () =>
|
|
388
|
+
eventFixture({
|
|
389
|
+
files: {
|
|
390
|
+
...FILES,
|
|
391
|
+
[ROUTES_PATH]: 'dd-core',
|
|
392
|
+
'./engine-private/conf/dd-core/conf.server.json': JSON.stringify({
|
|
393
|
+
'app.fixture.test': { '/': { client: 'App' } },
|
|
394
|
+
'api.fixture.test': { '/': { client: 'Api' } },
|
|
395
|
+
}),
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const curl = (finalByHost) =>
|
|
400
|
+
harness.route({
|
|
401
|
+
match: 'curl -L -v -i -s',
|
|
402
|
+
code: 0,
|
|
403
|
+
stdout: '',
|
|
404
|
+
// The probe reads the final code out of the write-out marker.
|
|
405
|
+
}) ||
|
|
406
|
+
vi
|
|
407
|
+
.spyOn(UnderpostEvent.API, 'publicIngressUrls')
|
|
408
|
+
.mockImplementation(() =>
|
|
409
|
+
Object.keys(finalByHost).map((host) => ({ host, path: '/', url: `https://${host}/` })),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
it('lists every public URL the deploy conf publishes', () => {
|
|
413
|
+
ingressFixture();
|
|
414
|
+
expect(UnderpostEvent.API.publicIngressUrls().map(({ url }) => url)).to.deep.equal([
|
|
415
|
+
'https://app.fixture.test/',
|
|
416
|
+
'https://api.fixture.test/',
|
|
417
|
+
]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('reports a healthy edge when every host lands on 200', () => {
|
|
421
|
+
ingressFixture();
|
|
422
|
+
harness.route({ match: 'curl', code: 0, stdout: '< HTTP/2 200\nUNDERPOST_CURL_FINAL=200\n' });
|
|
423
|
+
expect(UnderpostEvent.API.publicIngressHealth()).to.include({ state: 'healthy', total: 2, healthy: 2 });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// A 301 that never lands on a 200 is a broken route, not a served one.
|
|
427
|
+
it('classifies a redirect chain that never lands as failing', () => {
|
|
428
|
+
ingressFixture();
|
|
429
|
+
harness.route({ match: 'curl', code: 0, stdout: '< HTTP/1.1 301\nUNDERPOST_CURL_FINAL=301\n' });
|
|
430
|
+
expect(UnderpostEvent.API.publicIngressHealth().state).to.equal('down');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('separates one host failing from every host failing', () => {
|
|
434
|
+
ingressFixture();
|
|
435
|
+
harness.route({
|
|
436
|
+
match: 'curl',
|
|
437
|
+
code: 0,
|
|
438
|
+
stdout: '< HTTP/2 200\nUNDERPOST_CURL_FINAL=200\n',
|
|
439
|
+
});
|
|
440
|
+
harness.route({
|
|
441
|
+
match: (command) => command.includes('curl') && command.includes('api.fixture.test'),
|
|
442
|
+
code: 0,
|
|
443
|
+
stdout: 'UNDERPOST_CURL_FINAL=000\n',
|
|
444
|
+
});
|
|
445
|
+
const health = UnderpostEvent.API.publicIngressHealth();
|
|
446
|
+
expect(health).to.include({ state: 'partial', total: 2, healthy: 1 });
|
|
447
|
+
expect(UnderpostEvent.API.publicIngressReport(health)).to.include('api.fixture.test');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('announces nothing at all while the edge is healthy', async () => {
|
|
451
|
+
ingressFixture();
|
|
452
|
+
harness.route({ match: 'curl', code: 0, stdout: '< HTTP/2 200\nUNDERPOST_CURL_FINAL=200\n' });
|
|
453
|
+
expect(await UnderpostEvent.API.repairPublicIngress({})).to.include({ ok: true, silent: true });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Some hosts failing is a deploy-level fault the edge cannot repair.
|
|
457
|
+
it('announces a per-host fault and leaves the edge alone', async () => {
|
|
458
|
+
ingressFixture();
|
|
459
|
+
const run = stubRunCommand();
|
|
460
|
+
harness.route({ match: 'curl', code: 0, stdout: '< HTTP/2 200\nUNDERPOST_CURL_FINAL=200\n' });
|
|
461
|
+
harness.route({
|
|
462
|
+
match: (command) => command.includes('curl') && command.includes('api.fixture.test'),
|
|
463
|
+
code: 0,
|
|
464
|
+
stdout: 'UNDERPOST_CURL_FINAL=000\n',
|
|
465
|
+
});
|
|
466
|
+
const result = await UnderpostEvent.API.repairPublicIngress({});
|
|
467
|
+
expect(result.ok).to.equal(false);
|
|
468
|
+
expect(result.condition).to.include('the fault is per host');
|
|
469
|
+
expect(run.mock.calls.length).to.equal(0);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Ingress first: a blocked edge makes the tunnel rebuild look successful
|
|
473
|
+
// while nothing can still reach it.
|
|
474
|
+
it('unblocks ingress before rebuilding the tunnel on a whole-edge outage', async () => {
|
|
475
|
+
ingressFixture();
|
|
476
|
+
harness.route({ match: 'curl', code: 0, stdout: 'UNDERPOST_CURL_FINAL=000\n' });
|
|
477
|
+
const run = stubRunCommand();
|
|
478
|
+
const result = await UnderpostEvent.API.repairPublicIngress({ dryRun: true });
|
|
479
|
+
expect(run.mock.calls.map(([command]) => command)).to.deep.equal([
|
|
480
|
+
'node bin ip --unblock-all-ingress',
|
|
481
|
+
'node bin wireguard --wireguard-setup --wireguard-restart',
|
|
482
|
+
]);
|
|
483
|
+
expect(result.ok).to.equal(false);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('stops the command chain at the first failure on a node', async () => {
|
|
487
|
+
ingressFixture();
|
|
488
|
+
harness.route({ match: 'curl', code: 0, stdout: 'UNDERPOST_CURL_FINAL=000\n' });
|
|
489
|
+
const run = stubRunCommand(async () => ({ ok: false, error: 'refused' }));
|
|
490
|
+
await UnderpostEvent.API.repairPublicIngress({ dryRun: true });
|
|
491
|
+
expect(run.mock.calls.length).to.equal(1);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('reports an unresolvable hub during an edge outage', async () => {
|
|
495
|
+
ingressFixture();
|
|
496
|
+
harness.route({ match: 'curl', code: 0, stdout: 'UNDERPOST_CURL_FINAL=000\n' });
|
|
497
|
+
stubRunCommand();
|
|
498
|
+
vi.spyOn(UnderpostEvent.API, 'hubTarget').mockImplementation(() => {
|
|
499
|
+
throw new Error('no management route');
|
|
500
|
+
});
|
|
501
|
+
const result = await UnderpostEvent.API.repairPublicIngress({ dryRun: true });
|
|
502
|
+
expect(result.targets[0].via).to.equal('unresolved');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('stops polling as soon as the edge answers again', async () => {
|
|
506
|
+
ingressFixture();
|
|
507
|
+
let pass = 0;
|
|
508
|
+
vi.spyOn(UnderpostEvent.API, 'publicIngressHealth').mockImplementation(() => ({
|
|
509
|
+
state: pass++ === 0 ? 'down' : 'healthy',
|
|
510
|
+
total: 1,
|
|
511
|
+
healthy: pass === 0 ? 0 : 1,
|
|
512
|
+
failing: [],
|
|
513
|
+
}));
|
|
514
|
+
const health = await UnderpostEvent.API.awaitPublicIngressHealth({ timeoutMs: 1000, intervalMs: 1 });
|
|
515
|
+
expect(health.state).to.equal('healthy');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('gives up once the recovery window closes', async () => {
|
|
519
|
+
ingressFixture();
|
|
520
|
+
vi.spyOn(UnderpostEvent.API, 'publicIngressHealth').mockReturnValue({
|
|
521
|
+
state: 'down',
|
|
522
|
+
total: 1,
|
|
523
|
+
healthy: 0,
|
|
524
|
+
failing: [],
|
|
525
|
+
});
|
|
526
|
+
expect((await UnderpostEvent.API.awaitPublicIngressHealth({ timeoutMs: 5, intervalMs: 1 })).state).to.equal(
|
|
527
|
+
'down',
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('node inspection', () => {
|
|
533
|
+
it('runs the diagnostic on every registered node when the alert names none', async () => {
|
|
534
|
+
eventFixture();
|
|
535
|
+
const run = stubRunCommand();
|
|
536
|
+
const result = await UnderpostEvent.API.inspectNodes({
|
|
537
|
+
role: 'node',
|
|
538
|
+
command: 'top -bn1',
|
|
539
|
+
condition: 'cpu above threshold',
|
|
540
|
+
});
|
|
541
|
+
expect(result.ok).to.equal(true);
|
|
542
|
+
expect(run.mock.calls.length).to.equal(3);
|
|
543
|
+
expect(run.mock.calls[0][1].silent).to.equal(true);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('narrows the diagnostic to the instances the alert names', async () => {
|
|
547
|
+
eventFixture();
|
|
548
|
+
const run = stubRunCommand();
|
|
549
|
+
await UnderpostEvent.API.inspectNodes({
|
|
550
|
+
role: 'node',
|
|
551
|
+
command: 'top -bn1',
|
|
552
|
+
condition: 'cpu',
|
|
553
|
+
alerts: [{ labels: { instance: '198.51.100.3:9100' } }],
|
|
554
|
+
});
|
|
555
|
+
expect(run.mock.calls.length).to.equal(1);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('reports that no registered node matches the alert', async () => {
|
|
559
|
+
eventFixture();
|
|
560
|
+
const result = await UnderpostEvent.API.inspectNodes({
|
|
561
|
+
role: 'node',
|
|
562
|
+
command: 'top -bn1',
|
|
563
|
+
condition: 'cpu',
|
|
564
|
+
alerts: [{ labels: { instance: '10.9.9.9:9100' } }],
|
|
565
|
+
});
|
|
566
|
+
expect(result.ok).to.equal(false);
|
|
567
|
+
expect(result.error).to.include('no registered node matches');
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('reports an unresolvable node rather than running nothing', async () => {
|
|
571
|
+
eventFixture();
|
|
572
|
+
stubRunCommand();
|
|
573
|
+
vi.spyOn(UnderpostSSH.API, 'resolveConnection').mockReturnValue(null);
|
|
574
|
+
const result = await UnderpostEvent.API.inspectNodes({ role: 'node', command: 'top', condition: 'cpu' });
|
|
575
|
+
expect(result.ok).to.equal(false);
|
|
576
|
+
expect(result.targets.every((target) => target.via === 'unresolved')).to.equal(true);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe('event dispatch and publication', () => {
|
|
582
|
+
afterEach(() => vi.restoreAllMocks());
|
|
583
|
+
|
|
584
|
+
it('reads the firing event ids out of an Alertmanager payload', () => {
|
|
585
|
+
expect(
|
|
586
|
+
UnderpostEvent.API.webhookEventIds({
|
|
587
|
+
status: 'firing',
|
|
588
|
+
alerts: [
|
|
589
|
+
{ labels: { underpost_event: 'wireguard-spoke-down' } },
|
|
590
|
+
{ status: 'resolved', labels: { underpost_event: 'wireguard-server-down' } },
|
|
591
|
+
{ labels: { underpost_event: 'not-a-registered-event' } },
|
|
592
|
+
{ labels: {} },
|
|
593
|
+
{ labels: { underpost_event: 'wireguard-spoke-down' } },
|
|
594
|
+
],
|
|
595
|
+
}),
|
|
596
|
+
).to.deep.equal(['wireguard-spoke-down']);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('reads no event out of an empty or malformed payload', () => {
|
|
600
|
+
expect(UnderpostEvent.API.webhookEventIds({})).to.deep.equal([]);
|
|
601
|
+
expect(UnderpostEvent.API.webhookEventIds({ alerts: 'not-an-array' })).to.deep.equal([]);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('refuses an unknown event id', async () => {
|
|
605
|
+
await expect(UnderpostEvent.API.dispatch('not-an-event')).rejects.toThrow('unknown event id');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// The dispatcher may be serving a webhook, where an unhandled rejection would
|
|
609
|
+
// silence every later alert.
|
|
610
|
+
it('reports a failing handler rather than throwing out of the dispatcher', async () => {
|
|
611
|
+
const notify = vi.spyOn(UnderpostEvent.API, 'notify').mockResolvedValue(undefined);
|
|
612
|
+
vi.spyOn(UnderpostEvent.API, 'repairSpokes').mockRejectedValue(new Error('ssh refused'));
|
|
613
|
+
const result = await UnderpostEvent.API.dispatch('wireguard-spoke-down', {});
|
|
614
|
+
expect(result).to.include({ ok: false });
|
|
615
|
+
expect(result.error).to.include('ssh refused');
|
|
616
|
+
expect(notify.mock.calls.length).to.equal(1);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Alerting on a healthy check is how an inbox stops being read.
|
|
620
|
+
it('announces nothing when the handler found nothing wrong', async () => {
|
|
621
|
+
const notify = vi.spyOn(UnderpostEvent.API, 'notify').mockResolvedValue(undefined);
|
|
622
|
+
vi.spyOn(UnderpostEvent.API, 'repairPublicIngress').mockResolvedValue({ ok: true, silent: true, targets: [] });
|
|
623
|
+
await UnderpostEvent.API.dispatch('public-ingress-down', {});
|
|
624
|
+
expect(notify.mock.calls.length).to.equal(0);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('suppresses the notification when the run asked for none', async () => {
|
|
628
|
+
const notify = vi.spyOn(UnderpostEvent.API, 'notify').mockResolvedValue(undefined);
|
|
629
|
+
vi.spyOn(UnderpostEvent.API, 'repairSpokes').mockResolvedValue({ ok: true, targets: [] });
|
|
630
|
+
await UnderpostEvent.API.dispatch('wireguard-spoke-down', { notify: false });
|
|
631
|
+
expect(notify.mock.calls.length).to.equal(0);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('reports the command instead of running it under a dry run', async () => {
|
|
635
|
+
const result = await UnderpostEvent.API.runCommand('node bin wireguard --status', {
|
|
636
|
+
dryRun: true,
|
|
637
|
+
user: 'u',
|
|
638
|
+
host: 'h',
|
|
639
|
+
});
|
|
640
|
+
expect(result.ok).to.equal(true);
|
|
641
|
+
expect(result.output).to.include('ssh u@h');
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('runs a command through the single execution facility', async () => {
|
|
645
|
+
const runner = vi.spyOn(UnderpostSSH.API, 'sshRemoteRunner').mockResolvedValue('done');
|
|
646
|
+
const result = await UnderpostEvent.API.runCommand('uptime', { user: 'u', host: 'h' });
|
|
647
|
+
expect(result).to.deep.equal({ ok: true, output: 'done' });
|
|
648
|
+
expect(runner.mock.calls[0][1]).to.include({ user: 'u', host: 'h', remote: true });
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// The generated SSH script is in the thrown message and tells a reader nothing.
|
|
652
|
+
it('reports what the command said rather than the wrapper that carried it', async () => {
|
|
653
|
+
vi.spyOn(UnderpostSSH.API, 'sshRemoteRunner').mockRejectedValue(
|
|
654
|
+
Object.assign(new Error('Command failed: ssh ...'), { stderr: 'permission denied\n' }),
|
|
655
|
+
);
|
|
656
|
+
const result = await UnderpostEvent.API.runCommand('uptime', { user: 'u', host: 'h' });
|
|
657
|
+
expect(result).to.include({ ok: false, output: '' });
|
|
658
|
+
expect(result.error).to.equal('permission denied');
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('merges one event into the set the cluster reported', () => {
|
|
662
|
+
vi.spyOn(UnderpostMonitor.API, 'readDeployedEventState').mockReturnValue({
|
|
663
|
+
readable: true,
|
|
664
|
+
ids: ['wireguard-spoke-down'],
|
|
665
|
+
reason: '',
|
|
666
|
+
});
|
|
667
|
+
expect(UnderpostEvent.API.deployedEventIds({})).to.deep.equal(['wireguard-spoke-down']);
|
|
668
|
+
expect(UnderpostEvent.API.deploySelection('public-ingress-down', {})).to.deep.equal([
|
|
669
|
+
'public-ingress-down',
|
|
670
|
+
'wireguard-spoke-down',
|
|
671
|
+
]);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('reports each declared event against the deployed set', () => {
|
|
675
|
+
vi.spyOn(UnderpostMonitor.API, 'readDeployedEventState').mockReturnValue({
|
|
676
|
+
readable: true,
|
|
677
|
+
ids: ['wireguard-spoke-down', 'retired-event'],
|
|
678
|
+
reason: '',
|
|
679
|
+
});
|
|
680
|
+
const status = Object.fromEntries(UnderpostEvent.API.deploymentStatus({}).map((row) => [row.id, row.status]));
|
|
681
|
+
expect(status['wireguard-spoke-down']).to.equal('DEPLOYED');
|
|
682
|
+
expect(status['public-ingress-down']).to.equal('PENDING');
|
|
683
|
+
expect(status['retired-event']).to.equal('OUT_OF_SYNC');
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('reports every event as unknown when the cluster did not answer', () => {
|
|
687
|
+
vi.spyOn(UnderpostMonitor.API, 'readDeployedEventState').mockReturnValue({
|
|
688
|
+
readable: false,
|
|
689
|
+
ids: [],
|
|
690
|
+
reason: 'no cluster',
|
|
691
|
+
});
|
|
692
|
+
expect(UnderpostEvent.API.deploymentStatus({}).every((row) => row.status === 'UNKNOWN')).to.equal(true);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
describe('maintenance suspension', () => {
|
|
697
|
+
let fixturePath;
|
|
698
|
+
|
|
699
|
+
beforeEach(() => {
|
|
700
|
+
fixturePath = fs.mkdtempSync('/tmp/engine-event-');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
afterEach(() => {
|
|
704
|
+
fs.removeSync(fixturePath);
|
|
705
|
+
vi.restoreAllMocks();
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
const stateFile = () => `${fixturePath}/suspension.json`;
|
|
709
|
+
|
|
710
|
+
it('records the exact deployed set and republishes with no events', async () => {
|
|
711
|
+
vi.spyOn(UnderpostMonitor.API, 'readDeployedEventState').mockReturnValue({
|
|
712
|
+
readable: true,
|
|
713
|
+
ids: ['wireguard-spoke-down', 'public-ingress-down'],
|
|
714
|
+
reason: '',
|
|
715
|
+
});
|
|
716
|
+
const sync = vi.spyOn(UnderpostMonitor.API, 'syncObservability').mockResolvedValue({});
|
|
717
|
+
|
|
718
|
+
const suspension = await UnderpostEvent.API.suspendEvents(stateFile(), {});
|
|
719
|
+
|
|
720
|
+
expect(suspension.events).to.deep.equal(['public-ingress-down', 'wireguard-spoke-down']);
|
|
721
|
+
expect(sync.mock.calls[0][0].events).to.deep.equal([]);
|
|
722
|
+
expect(fs.readJsonSync(stateFile()).events).to.deep.equal(suspension.events);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('refuses to suspend without a state file, or over an existing one', async () => {
|
|
726
|
+
await expect(UnderpostEvent.API.suspendEvents('')).rejects.toThrow('requires a state-file path');
|
|
727
|
+
fs.outputJsonSync(stateFile(), {});
|
|
728
|
+
await expect(UnderpostEvent.API.suspendEvents(stateFile())).rejects.toThrow('resume it before suspending again');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Restoring from a set the cluster never reported would silently drop events.
|
|
732
|
+
it('refuses to suspend a set the cluster could not report', async () => {
|
|
733
|
+
vi.spyOn(UnderpostMonitor.API, 'readDeployedEventState').mockReturnValue({
|
|
734
|
+
readable: false,
|
|
735
|
+
ids: [],
|
|
736
|
+
reason: 'no cluster',
|
|
737
|
+
});
|
|
738
|
+
await expect(UnderpostEvent.API.suspendEvents(stateFile(), {})).rejects.toThrow('cannot be suspended safely');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('refuses to suspend a deployed event this checkout cannot restore', async () => {
|
|
742
|
+
vi.spyOn(UnderpostMonitor.API, 'readDeployedEventState').mockReturnValue({
|
|
743
|
+
readable: true,
|
|
744
|
+
ids: ['retired-event'],
|
|
745
|
+
reason: '',
|
|
746
|
+
});
|
|
747
|
+
await expect(UnderpostEvent.API.suspendEvents(stateFile(), {})).rejects.toThrow('undeploy them first');
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('retains the recovery state when the republication fails', async () => {
|
|
751
|
+
vi.spyOn(UnderpostMonitor.API, 'readDeployedEventState').mockReturnValue({
|
|
752
|
+
readable: true,
|
|
753
|
+
ids: ['wireguard-spoke-down'],
|
|
754
|
+
reason: '',
|
|
755
|
+
});
|
|
756
|
+
vi.spyOn(UnderpostMonitor.API, 'syncObservability').mockRejectedValue(new Error('cluster refused'));
|
|
757
|
+
await expect(UnderpostEvent.API.suspendEvents(stateFile(), {})).rejects.toThrow('cluster refused');
|
|
758
|
+
expect(fs.existsSync(stateFile())).to.equal(true);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('restores the saved set and removes the state only once it converged', async () => {
|
|
762
|
+
fs.outputJsonSync(stateFile(), {
|
|
763
|
+
version: 1,
|
|
764
|
+
namespace: 'default',
|
|
765
|
+
events: ['wireguard-spoke-down'],
|
|
766
|
+
suspendedAt: '2026-01-01T00:00:00.000Z',
|
|
767
|
+
});
|
|
768
|
+
const sync = vi.spyOn(UnderpostMonitor.API, 'syncObservability').mockResolvedValue({ ok: true });
|
|
769
|
+
|
|
770
|
+
await UnderpostEvent.API.resumeEvents(stateFile(), {});
|
|
771
|
+
|
|
772
|
+
expect(sync.mock.calls[0][0].events).to.deep.equal(['wireguard-spoke-down']);
|
|
773
|
+
expect(fs.existsSync(stateFile())).to.equal(false);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it('keeps the state file when the resync fails, so it can be retried', async () => {
|
|
777
|
+
fs.outputJsonSync(stateFile(), { version: 1, namespace: 'default', events: [] });
|
|
778
|
+
vi.spyOn(UnderpostMonitor.API, 'syncObservability').mockRejectedValue(new Error('cluster refused'));
|
|
779
|
+
await expect(UnderpostEvent.API.resumeEvents(stateFile(), {})).rejects.toThrow('cluster refused');
|
|
780
|
+
expect(fs.existsSync(stateFile())).to.equal(true);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('refuses every malformed suspension state', async () => {
|
|
784
|
+
await expect(UnderpostEvent.API.resumeEvents('')).rejects.toThrow('requires a state-file path');
|
|
785
|
+
await expect(UnderpostEvent.API.resumeEvents(`${fixturePath}/absent.json`)).rejects.toThrow('does not exist');
|
|
786
|
+
|
|
787
|
+
fs.outputFileSync(stateFile(), '{ not json');
|
|
788
|
+
await expect(UnderpostEvent.API.resumeEvents(stateFile())).rejects.toThrow('invalid JSON');
|
|
789
|
+
|
|
790
|
+
fs.outputJsonSync(stateFile(), { version: 99 });
|
|
791
|
+
await expect(UnderpostEvent.API.resumeEvents(stateFile())).rejects.toThrow('unsupported suspension state version');
|
|
792
|
+
|
|
793
|
+
fs.outputJsonSync(stateFile(), { version: 1, namespace: 'Not A Namespace', events: [] });
|
|
794
|
+
await expect(UnderpostEvent.API.resumeEvents(stateFile())).rejects.toThrow('invalid namespace');
|
|
795
|
+
|
|
796
|
+
fs.outputJsonSync(stateFile(), { version: 1, namespace: 'default', events: [42] });
|
|
797
|
+
await expect(UnderpostEvent.API.resumeEvents(stateFile())).rejects.toThrow('invalid event list');
|
|
798
|
+
|
|
799
|
+
fs.outputJsonSync(stateFile(), { version: 1, namespace: 'default', events: ['retired-event'] });
|
|
800
|
+
await expect(UnderpostEvent.API.resumeEvents(stateFile())).rejects.toThrow('no longer declared by this checkout');
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
describe('dispatcher service', () => {
|
|
805
|
+
let harness;
|
|
806
|
+
|
|
807
|
+
beforeEach(() => {
|
|
808
|
+
harness = shellHarness();
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
afterEach(() => {
|
|
812
|
+
harness.restore();
|
|
813
|
+
vi.restoreAllMocks();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('reads no installed port when no unit is present', () => {
|
|
817
|
+
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
|
818
|
+
expect(UnderpostEvent.API.serviceInstalledPort()).to.equal(0);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('reads the port rendered into the installed unit', () => {
|
|
822
|
+
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
|
823
|
+
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
|
824
|
+
UnderpostEvent.API.serviceUnitFactory({ port: 39111, workingDirectory: '/home/dd/engine' }),
|
|
825
|
+
);
|
|
826
|
+
expect(UnderpostEvent.API.serviceInstalledPort()).to.equal(39111);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('returns the recent journal without echoing the command', () => {
|
|
830
|
+
harness.route({ match: 'journalctl', code: 0, stdout: 'log line\n' });
|
|
831
|
+
expect(UnderpostEvent.API.serviceJournal(5)).to.equal('log line\n');
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// systemd refuses a binary under /root or /home on an SELinux host, and the failure surfaces
|
|
835
|
+
// only as 203/EXEC in the journal — which is why every candidate is probed with a transient
|
|
836
|
+
// unit, and why each rejection has to say which of the three ways it failed.
|
|
837
|
+
const currentMajor = `${process.versions.node}`.split('.')[0];
|
|
838
|
+
|
|
839
|
+
it('selects a system Node new enough for the checkout, over one under a home directory', () => {
|
|
840
|
+
harness.route({ match: '--version', code: 0, stdout: `v${currentMajor}.0.0\n` });
|
|
841
|
+
const probed = UnderpostEvent.API.serviceNodePath();
|
|
842
|
+
expect(probed.probed).to.equal(true);
|
|
843
|
+
expect(probed.path).to.equal('/usr/bin/node');
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('passes over a Node older than the engine requires, naming the version it found', () => {
|
|
847
|
+
harness.route({ match: '--version', code: 0, stdout: 'v18.0.0\n' });
|
|
848
|
+
const result = UnderpostEvent.API.serviceNodePath();
|
|
849
|
+
expect(result.probed).to.equal(false);
|
|
850
|
+
expect(result.rejected[0].reason).to.include('runs Node v18, the engine needs');
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('separates a binary systemd cannot execute from one it can', () => {
|
|
854
|
+
harness.route({ match: '--version', code: 0, stdout: `v${currentMajor}.0.0\n` });
|
|
855
|
+
harness.route({ match: 'systemd-run', code: 1 });
|
|
856
|
+
const result = UnderpostEvent.API.serviceNodePath();
|
|
857
|
+
expect(result.probed).to.equal(false);
|
|
858
|
+
expect(result.rejected.map(({ reason }) => reason)).to.satisfy((reasons) =>
|
|
859
|
+
reasons.every((reason) => reason.startsWith('systemd cannot execute it')),
|
|
860
|
+
);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it('names the entry script when the unit can execute Node but not run the checkout', () => {
|
|
864
|
+
harness.route({ match: '--version', code: 0, stdout: `v${currentMajor}.0.0\n` });
|
|
865
|
+
harness.route({ match: 'WorkingDirectory', code: 1 });
|
|
866
|
+
const result = UnderpostEvent.API.serviceNodePath();
|
|
867
|
+
expect(result.probed).to.equal(false);
|
|
868
|
+
expect(result.rejected[0].reason).to.include('it cannot run');
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('falls back to the first candidate when no probe succeeds', () => {
|
|
872
|
+
harness.route({ match: '', code: 1 });
|
|
873
|
+
const result = UnderpostEvent.API.serviceNodePath();
|
|
874
|
+
expect(result).to.include({ path: '/usr/bin/node', probed: false });
|
|
875
|
+
expect(result.rejected[0].reason).to.equal('not present');
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
describe('alertmanager webhook receiver', () => {
|
|
880
|
+
const TOKEN = 'fixture-webhook-token';
|
|
881
|
+
let server;
|
|
882
|
+
let address;
|
|
883
|
+
|
|
884
|
+
// Raw HTTP rather than fetch: the receiver answers before reading the body on
|
|
885
|
+
// a refusal, and undici reports the resulting socket close as a failure even
|
|
886
|
+
// though the response arrived.
|
|
887
|
+
const post = async (body, { headers = {}, path = UNDERPOST_MONITORING.eventWebhook.path, method = 'POST' } = {}) =>
|
|
888
|
+
new Promise((resolve, reject) => {
|
|
889
|
+
const payload = method === 'POST' ? (typeof body === 'string' ? body : JSON.stringify(body ?? {})) : '';
|
|
890
|
+
const request = http.request(
|
|
891
|
+
`${address}${path}`,
|
|
892
|
+
{
|
|
893
|
+
method,
|
|
894
|
+
// Each case gets a fresh server, so a pooled keep-alive socket from
|
|
895
|
+
// the previous one would be answered by nothing.
|
|
896
|
+
agent: false,
|
|
897
|
+
headers: { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json', ...headers },
|
|
898
|
+
},
|
|
899
|
+
(response) => {
|
|
900
|
+
let text = '';
|
|
901
|
+
response.on('data', (chunk) => (text += chunk));
|
|
902
|
+
response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(text || '{}') }));
|
|
903
|
+
},
|
|
904
|
+
);
|
|
905
|
+
request.on('error', reject);
|
|
906
|
+
if (payload) request.write(payload);
|
|
907
|
+
request.end();
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
beforeEach(async () => {
|
|
911
|
+
vi.spyOn(UnderpostMonitor.API, 'eventWebhookTokenFactory').mockReturnValue(TOKEN);
|
|
912
|
+
server = await UnderpostEvent.API.serve({ port: 0, token: TOKEN, cooldownMs: 60000 });
|
|
913
|
+
address = `http://127.0.0.1:${server.address().port}`;
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
afterEach(async () => {
|
|
917
|
+
if (server?.listening) await new Promise((resolve) => server.close(resolve));
|
|
918
|
+
server = undefined;
|
|
919
|
+
vi.restoreAllMocks();
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it('refuses every path and method that is not the webhook', async () => {
|
|
923
|
+
expect((await post({}, { path: '/' })).status).to.equal(404);
|
|
924
|
+
expect((await post(undefined, { method: 'GET' })).status).to.equal(404);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// The receiver is a remediation path with root-equivalent reach.
|
|
928
|
+
it('refuses a delivery carrying no valid bearer token', async () => {
|
|
929
|
+
expect((await post({}, { headers: { authorization: 'Bearer wrong' } })).status).to.equal(401);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('refuses a payload that is not JSON', async () => {
|
|
933
|
+
expect((await post('{ not json')).status).to.equal(400);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// Remediation can take minutes and Alertmanager retries any delivery it does
|
|
937
|
+
// not see accepted, so the answer comes before the dispatch.
|
|
938
|
+
it('accepts the delivery before dispatching, and names what it accepted', async () => {
|
|
939
|
+
const dispatch = vi.spyOn(UnderpostEvent.API, 'dispatch').mockResolvedValue({ ok: true });
|
|
940
|
+
const response = await post({
|
|
941
|
+
status: 'firing',
|
|
942
|
+
alerts: [{ labels: { underpost_event: 'wireguard-spoke-down', underpost_spoke: 'worker-b' } }],
|
|
943
|
+
});
|
|
944
|
+
expect(response).to.deep.equal({ status: 202, body: { accepted: ['wireguard-spoke-down'] } });
|
|
945
|
+
await vi.waitFor(() => expect(dispatch.mock.calls.length).to.equal(1));
|
|
946
|
+
expect(dispatch.mock.calls[0][2][0].labels.underpost_spoke).to.equal('worker-b');
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('accepts a payload naming no registered event without dispatching', async () => {
|
|
950
|
+
const dispatch = vi.spyOn(UnderpostEvent.API, 'dispatch').mockResolvedValue({ ok: true });
|
|
951
|
+
expect((await post({ status: 'firing', alerts: [{ labels: {} }] })).body).to.deep.equal({ accepted: [] });
|
|
952
|
+
expect(dispatch.mock.calls.length).to.equal(0);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it('holds a repeated group inside the cooldown', async () => {
|
|
956
|
+
const dispatch = vi.spyOn(UnderpostEvent.API, 'dispatch').mockResolvedValue({ ok: true });
|
|
957
|
+
const payload = {
|
|
958
|
+
status: 'firing',
|
|
959
|
+
alerts: [{ labels: { underpost_event: 'wireguard-spoke-down', underpost_spoke: 'worker-b' } }],
|
|
960
|
+
};
|
|
961
|
+
await post(payload);
|
|
962
|
+
await vi.waitFor(() => expect(dispatch.mock.calls.length).to.equal(1));
|
|
963
|
+
await post(payload);
|
|
964
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
965
|
+
expect(dispatch.mock.calls.length).to.equal(1);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it('reports a rejected dispatch rather than crashing the receiver', async () => {
|
|
969
|
+
vi.spyOn(UnderpostEvent.API, 'dispatch').mockRejectedValue(new Error('handler exploded'));
|
|
970
|
+
expect(
|
|
971
|
+
(await post({ status: 'firing', alerts: [{ labels: { underpost_event: 'public-ingress-down' } }] })).status,
|
|
972
|
+
).to.equal(202);
|
|
973
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
974
|
+
expect(server.listening).to.equal(true);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it('reports a port it cannot bind', async () => {
|
|
978
|
+
await expect(UnderpostEvent.API.serve({ port: server.address().port, token: TOKEN })).rejects.toThrow();
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
describe('event CLI', () => {
|
|
983
|
+
beforeEach(() => {
|
|
984
|
+
vi.spyOn(UnderpostMonitor.API, 'readDeployedEventState').mockReturnValue({
|
|
985
|
+
readable: true,
|
|
986
|
+
ids: ['wireguard-spoke-down'],
|
|
987
|
+
reason: '',
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
afterEach(() => vi.restoreAllMocks());
|
|
992
|
+
|
|
993
|
+
it('routes the maintenance flags before every other action', async () => {
|
|
994
|
+
const suspend = vi.spyOn(UnderpostEvent.API, 'suspendEvents').mockResolvedValue({});
|
|
995
|
+
const resume = vi.spyOn(UnderpostEvent.API, 'resumeEvents').mockResolvedValue({});
|
|
996
|
+
await UnderpostEvent.API.callback('', { suspendEvents: '/tmp/state.json', list: true });
|
|
997
|
+
await UnderpostEvent.API.callback('', { resumeEvents: '/tmp/state.json', list: true });
|
|
998
|
+
expect(suspend.mock.calls.length).to.equal(1);
|
|
999
|
+
expect(resume.mock.calls.length).to.equal(1);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it('prints every declared event with its deployed state', async () => {
|
|
1003
|
+
vi.spyOn(UnderpostEvent.API, 'definitions').mockReturnValue([
|
|
1004
|
+
{
|
|
1005
|
+
id: 'wireguard-spoke-down',
|
|
1006
|
+
role: 'spoke',
|
|
1007
|
+
description: 'fixture',
|
|
1008
|
+
schedule: { probeInterval: '30s', alertFor: '2m' },
|
|
1009
|
+
alert: { name: 'FixtureAlert', expr: 'probe_success == 0', for: '2m' },
|
|
1010
|
+
probes: [{ module: 'icmp', targets: ['10.0.0.2'] }],
|
|
1011
|
+
remediation: [{ spokeId: 'worker-b', address: '10.0.0.3', via: 'ssh', error: 'no route' }],
|
|
1012
|
+
notifications: [{ providerId: 'smtp', target: 'ops', recipients: [{ email: 'ops@fixture.test' }] }],
|
|
1013
|
+
},
|
|
1014
|
+
]);
|
|
1015
|
+
await UnderpostEvent.API.callback('', { list: true });
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it('reports an unreadable cluster and an event no longer declared', async () => {
|
|
1019
|
+
vi.spyOn(UnderpostMonitor.API, 'readDeployedEventState').mockReturnValue({
|
|
1020
|
+
readable: false,
|
|
1021
|
+
ids: ['retired-event'],
|
|
1022
|
+
reason: 'no cluster',
|
|
1023
|
+
});
|
|
1024
|
+
vi.spyOn(UnderpostEvent.API, 'definitions').mockReturnValue([]);
|
|
1025
|
+
await UnderpostEvent.API.callback('', { list: true });
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// The ConfigMaps are rendered whole, so publishing one event means rendering
|
|
1029
|
+
// every event that must stay published with it.
|
|
1030
|
+
it('publishes an event by merging it into the cluster set', async () => {
|
|
1031
|
+
const sync = vi.spyOn(UnderpostMonitor.API, 'syncObservability').mockResolvedValue({});
|
|
1032
|
+
await UnderpostEvent.API.callback('public-ingress-down', { deploy: true });
|
|
1033
|
+
expect(sync.mock.calls[0][0].events).to.deep.equal(['public-ingress-down', 'wireguard-spoke-down']);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it('withdraws an event by removing it from the cluster set', async () => {
|
|
1037
|
+
const sync = vi.spyOn(UnderpostMonitor.API, 'syncObservability').mockResolvedValue({});
|
|
1038
|
+
await UnderpostEvent.API.callback('wireguard-spoke-down', { undeploy: true });
|
|
1039
|
+
expect(sync.mock.calls[0][0].events).to.deep.equal([]);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it('refuses a publication that names no event', async () => {
|
|
1043
|
+
await expect(UnderpostEvent.API.callback('', { deploy: true })).rejects.toThrow('an event id is required');
|
|
1044
|
+
await expect(UnderpostEvent.API.callback('', { undeploy: true })).rejects.toThrow('an event id is required');
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it('routes the service flags to the service reconciler', async () => {
|
|
1048
|
+
const service = vi.spyOn(UnderpostEvent.API, 'service').mockReturnValue({});
|
|
1049
|
+
for (const flag of ['service', 'serviceStop', 'serviceStatus']) {
|
|
1050
|
+
await UnderpostEvent.API.callback('', { [flag]: true });
|
|
1051
|
+
}
|
|
1052
|
+
expect(service.mock.calls.length).to.equal(3);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it('runs the receiver in the foreground', async () => {
|
|
1056
|
+
const serve = vi.spyOn(UnderpostEvent.API, 'serve').mockResolvedValue({});
|
|
1057
|
+
await UnderpostEvent.API.callback('', { serve: true });
|
|
1058
|
+
expect(serve.mock.calls.length).to.equal(1);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('names the registered events when a dispatch selects none', async () => {
|
|
1062
|
+
await expect(UnderpostEvent.API.callback('', {})).rejects.toThrow('an event id is required; one of');
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it('dispatches one event by hand', async () => {
|
|
1066
|
+
const dispatch = vi.spyOn(UnderpostEvent.API, 'dispatch').mockResolvedValue({ ok: true, targets: [] });
|
|
1067
|
+
expect(await UnderpostEvent.API.callback('wireguard-spoke-down', { dryRun: true })).to.include({ ok: true });
|
|
1068
|
+
expect(dispatch.mock.calls[0][0]).to.equal('wireguard-spoke-down');
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('prints the rehearsal step by step', async () => {
|
|
1072
|
+
vi.spyOn(UnderpostEvent.API, 'e2e').mockResolvedValue({
|
|
1073
|
+
ok: true,
|
|
1074
|
+
steps: [{ name: 'baseline', subject: 'spoke worker-b', ok: true, detail: 'answered' }],
|
|
1075
|
+
});
|
|
1076
|
+
expect(await UnderpostEvent.API.callback('wireguard-spoke-down', { e2eTest: true })).to.include({ ok: true });
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
describe('dispatcher service reconciliation', () => {
|
|
1081
|
+
let harness;
|
|
1082
|
+
|
|
1083
|
+
beforeEach(() => {
|
|
1084
|
+
harness = shellHarness();
|
|
1085
|
+
vi.spyOn(UnderpostEvent.API, 'serviceJournal').mockReturnValue('journal line\n');
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
afterEach(() => {
|
|
1089
|
+
harness.restore();
|
|
1090
|
+
vi.restoreAllMocks();
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it('reports the unit state and the journal when it is not settled', () => {
|
|
1094
|
+
const previousExitCode = process.exitCode;
|
|
1095
|
+
vi.spyOn(UnderpostEvent.API, 'serviceState').mockReturnValue({
|
|
1096
|
+
service: 'underpost-event.service',
|
|
1097
|
+
active: 'failed',
|
|
1098
|
+
enabled: 'enabled',
|
|
1099
|
+
settled: false,
|
|
1100
|
+
});
|
|
1101
|
+
try {
|
|
1102
|
+
expect(UnderpostEvent.API.service({ serviceStatus: true }).active).to.equal('failed');
|
|
1103
|
+
expect(process.exitCode).to.equal(1);
|
|
1104
|
+
} finally {
|
|
1105
|
+
process.exitCode = previousExitCode;
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('withdraws the unit and the firewall rule it opened', () => {
|
|
1110
|
+
vi.spyOn(UnderpostEvent.API, 'serviceInstalledPort').mockReturnValue(39111);
|
|
1111
|
+
const state = UnderpostEvent.API.service({ serviceStop: true });
|
|
1112
|
+
expect(state.active).to.equal('inactive');
|
|
1113
|
+
expect(harness.ran('39111')).to.equal(true);
|
|
1114
|
+
expect(harness.ran('rm -f')).to.equal(true);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it('refuses to install the dispatcher anywhere but a control node', () => {
|
|
1118
|
+
vi.spyOn(UnderpostEvent.API, 'serviceInstalledPort').mockReturnValue(0);
|
|
1119
|
+
eventFixture({ hostname: 'hub-node' });
|
|
1120
|
+
expect(() => UnderpostEvent.API.service({})).to.throw("requires 'event-service'");
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it('refuses to install a unit no Node binary can start', () => {
|
|
1124
|
+
vi.spyOn(UnderpostEvent.API, 'serviceInstalledPort').mockReturnValue(0);
|
|
1125
|
+
vi.spyOn(UnderpostEvent.API, 'assertDispatchReady').mockReturnValue([]);
|
|
1126
|
+
vi.spyOn(UnderpostEvent.API, 'serviceNodePath').mockReturnValue({ path: '/usr/bin/node', probed: false });
|
|
1127
|
+
eventFixture({ hostname: 'control-node' });
|
|
1128
|
+
expect(() => UnderpostEvent.API.service({})).to.throw('no Node executable can run');
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('reports what it would apply under a dry run', () => {
|
|
1132
|
+
vi.spyOn(UnderpostEvent.API, 'serviceInstalledPort').mockReturnValue(39111);
|
|
1133
|
+
vi.spyOn(UnderpostEvent.API, 'assertDispatchReady').mockReturnValue([]);
|
|
1134
|
+
vi.spyOn(UnderpostEvent.API, 'serviceNodePath').mockReturnValue({ path: '/usr/bin/node', probed: true });
|
|
1135
|
+
eventFixture({ hostname: 'control-node' });
|
|
1136
|
+
const state = UnderpostEvent.API.service({ dryRun: true, port: 39222 });
|
|
1137
|
+
expect(state.active).to.equal('dry-run');
|
|
1138
|
+
expect(harness.calls.length).to.equal(0);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it('reconciles the unit and reports a settled service', () => {
|
|
1142
|
+
vi.spyOn(UnderpostEvent.API, 'serviceInstalledPort').mockReturnValue(0);
|
|
1143
|
+
vi.spyOn(UnderpostEvent.API, 'assertDispatchReady').mockReturnValue([]);
|
|
1144
|
+
vi.spyOn(UnderpostEvent.API, 'serviceNodePath').mockReturnValue({ path: '/usr/bin/node', probed: true });
|
|
1145
|
+
vi.spyOn(UnderpostEvent.API, 'serviceState').mockReturnValue({
|
|
1146
|
+
active: 'active',
|
|
1147
|
+
enabled: 'enabled',
|
|
1148
|
+
settled: true,
|
|
1149
|
+
});
|
|
1150
|
+
eventFixture({ hostname: 'control-node' });
|
|
1151
|
+
expect(UnderpostEvent.API.service({})).to.include({ active: 'active' });
|
|
1152
|
+
expect(harness.ran('systemctl reset-failed underpost-event.service')).to.equal(true);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it('names the process holding the port when the unit could not bind', () => {
|
|
1156
|
+
const previousExitCode = process.exitCode;
|
|
1157
|
+
vi.spyOn(UnderpostEvent.API, 'serviceInstalledPort').mockReturnValue(0);
|
|
1158
|
+
vi.spyOn(UnderpostEvent.API, 'assertDispatchReady').mockReturnValue([]);
|
|
1159
|
+
vi.spyOn(UnderpostEvent.API, 'serviceNodePath').mockReturnValue({ path: '/usr/bin/node', probed: true });
|
|
1160
|
+
vi.spyOn(UnderpostEvent.API, 'serviceState').mockReturnValue({
|
|
1161
|
+
active: 'failed',
|
|
1162
|
+
enabled: 'enabled',
|
|
1163
|
+
settled: false,
|
|
1164
|
+
});
|
|
1165
|
+
vi.spyOn(UnderpostEvent.API, 'serviceJournal').mockReturnValue('Error: listen EADDRINUSE\n');
|
|
1166
|
+
eventFixture({ hostname: 'control-node' });
|
|
1167
|
+
try {
|
|
1168
|
+
expect(UnderpostEvent.API.service({}).active).to.equal('failed');
|
|
1169
|
+
expect(process.exitCode).to.equal(1);
|
|
1170
|
+
} finally {
|
|
1171
|
+
process.exitCode = previousExitCode;
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
});
|