@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,930 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import Underpost from '../../../../src/index.js';
|
|
7
|
+
import UnderpostBaremetal from '../../../../src/cli/baremetal.js';
|
|
8
|
+
import UnderpostRepository from '../../../../src/cli/repository.js';
|
|
9
|
+
import UnderpostState from '../../../../src/cli/state.js';
|
|
10
|
+
import { shellHarness } from '../../../support/shell-harness.js';
|
|
11
|
+
import { scopeValuesFactory } from '../../../../src/server/runtime/config-scope.js';
|
|
12
|
+
|
|
13
|
+
const sops = () => Underpost.secret;
|
|
14
|
+
const CRON_ID_PATH = './engine-private/deploy/dd.cron';
|
|
15
|
+
const SECRETS_DIR = './engine-private/secrets';
|
|
16
|
+
const RECIPIENT = 'age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p';
|
|
17
|
+
// The key path is resolved from the environment exactly as sops resolves it, so
|
|
18
|
+
// a host that already exports one would otherwise decide where these cases look.
|
|
19
|
+
const KEY_FILE = '/tmp/underpost-secret-fixture/keys.txt';
|
|
20
|
+
|
|
21
|
+
const pinKeyFile = () => {
|
|
22
|
+
const previous = process.env.SOPS_AGE_KEY_FILE;
|
|
23
|
+
process.env.SOPS_AGE_KEY_FILE = KEY_FILE;
|
|
24
|
+
return () => {
|
|
25
|
+
if (previous === undefined) delete process.env.SOPS_AGE_KEY_FILE;
|
|
26
|
+
else process.env.SOPS_AGE_KEY_FILE = previous;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// The store, the key file and the cron deploy env all live under paths this
|
|
31
|
+
// checkout may or may not carry, and every apply reaches a live cluster. The
|
|
32
|
+
// suite fixes both boundaries so what is asserted is the command vector and the
|
|
33
|
+
// files a run would write.
|
|
34
|
+
const secretFixture = (files = {}) => {
|
|
35
|
+
const table = new Map(Object.entries(files));
|
|
36
|
+
const written = new Map();
|
|
37
|
+
const removed = [];
|
|
38
|
+
const keys = () => [...table.keys(), ...written.keys()];
|
|
39
|
+
|
|
40
|
+
vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => {
|
|
41
|
+
const key = `${filePath}`;
|
|
42
|
+
if (removed.includes(key)) return false;
|
|
43
|
+
return table.has(key) || written.has(key) || keys().some((entry) => entry.startsWith(`${key}/`));
|
|
44
|
+
});
|
|
45
|
+
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
|
46
|
+
const key = `${filePath}`;
|
|
47
|
+
if (written.has(key)) return written.get(key);
|
|
48
|
+
if (table.has(key)) return table.get(key);
|
|
49
|
+
throw Object.assign(new Error(`ENOENT: ${key}`), { code: 'ENOENT' });
|
|
50
|
+
});
|
|
51
|
+
vi.spyOn(fs, 'writeFileSync').mockImplementation((filePath, value) => written.set(`${filePath}`, `${value}`));
|
|
52
|
+
vi.spyOn(fs, 'outputFileSync').mockImplementation((filePath, value) => written.set(`${filePath}`, `${value}`));
|
|
53
|
+
vi.spyOn(fs, 'removeSync').mockImplementation((filePath) => {
|
|
54
|
+
written.delete(`${filePath}`);
|
|
55
|
+
removed.push(`${filePath}`);
|
|
56
|
+
});
|
|
57
|
+
vi.spyOn(fs, 'ensureDirSync').mockImplementation(() => undefined);
|
|
58
|
+
vi.spyOn(fs, 'chmodSync').mockImplementation(() => undefined);
|
|
59
|
+
vi.spyOn(fs, 'moveSync').mockImplementation((src, dest) => {
|
|
60
|
+
written.set(`${dest}`, written.get(`${src}`) ?? table.get(`${src}`) ?? '');
|
|
61
|
+
written.delete(`${src}`);
|
|
62
|
+
});
|
|
63
|
+
vi.spyOn(fs, 'statSync').mockReturnValue({ mode: 0o600 });
|
|
64
|
+
vi.spyOn(fs, 'readdirSync').mockImplementation((dir) =>
|
|
65
|
+
keys()
|
|
66
|
+
.filter((filePath) => filePath.startsWith(`${dir}/`))
|
|
67
|
+
.map((filePath) => filePath.slice(`${dir}/`.length))
|
|
68
|
+
.filter((name) => !name.includes('/')),
|
|
69
|
+
);
|
|
70
|
+
return { table, written, removed };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const encryptedManifest = ({ name, namespace = 'default', recipients = [RECIPIENT] }) =>
|
|
74
|
+
[
|
|
75
|
+
'apiVersion: v1',
|
|
76
|
+
'kind: Secret',
|
|
77
|
+
'metadata:',
|
|
78
|
+
` name: ${name}`,
|
|
79
|
+
` namespace: ${namespace}`,
|
|
80
|
+
'type: Opaque',
|
|
81
|
+
'stringData:',
|
|
82
|
+
' password: ENC[AES256_GCM,data:Lm8xQ2vT,iv:3fB7,tag:2cF5,type:str]',
|
|
83
|
+
'sops:',
|
|
84
|
+
' age:',
|
|
85
|
+
...recipients.flatMap((recipient) => [` - recipient: ${recipient}`, ' enc: |', ' key']),
|
|
86
|
+
' encrypted_regex: ^(data|stringData)$',
|
|
87
|
+
' version: 3.10.2',
|
|
88
|
+
'',
|
|
89
|
+
].join('\n');
|
|
90
|
+
|
|
91
|
+
describe('grafana administrator credentials', () => {
|
|
92
|
+
afterEach(() => vi.restoreAllMocks());
|
|
93
|
+
|
|
94
|
+
it('reads the administrator out of the cron deploy environment', () => {
|
|
95
|
+
secretFixture({
|
|
96
|
+
[CRON_ID_PATH]: 'dd-cron',
|
|
97
|
+
'./engine-private/conf/dd-cron/.env.production':
|
|
98
|
+
'GF_SECURITY_ADMIN_USER=admin\nGF_SECURITY_ADMIN_PASSWORD=secret\nGF_SECURITY_ADMIN_EMAIL=ops@fixture.test\n',
|
|
99
|
+
});
|
|
100
|
+
expect(Underpost.host.grafanaAdmin()).to.include({
|
|
101
|
+
username: 'admin',
|
|
102
|
+
password: 'secret',
|
|
103
|
+
email: 'ops@fixture.test',
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('reads the development environment when asked', () => {
|
|
108
|
+
secretFixture({
|
|
109
|
+
[CRON_ID_PATH]: 'dd-cron',
|
|
110
|
+
'./engine-private/conf/dd-cron/.env.development': 'GF_SECURITY_ADMIN_USER=dev\nGF_SECURITY_ADMIN_PASSWORD=p\n',
|
|
111
|
+
});
|
|
112
|
+
expect(Underpost.host.grafanaAdmin({ dev: true }).username).to.equal('dev');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('names the missing keys and the file they belong in', () => {
|
|
116
|
+
secretFixture({ [CRON_ID_PATH]: 'dd-cron' });
|
|
117
|
+
expect(() => Underpost.host.grafanaAdmin()).to.throw('GF_SECURITY_ADMIN_USER, GF_SECURITY_ADMIN_PASSWORD');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('reports the empty credentials when they are not required', () => {
|
|
121
|
+
secretFixture({});
|
|
122
|
+
expect(Underpost.host.grafanaAdmin({ required: false })).to.include({ username: '', password: '' });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('host configuration store', () => {
|
|
127
|
+
let cleaned;
|
|
128
|
+
let set;
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
cleaned = vi.spyOn(Underpost.host.store, 'clean').mockImplementation(() => undefined);
|
|
132
|
+
set = vi.spyOn(Underpost.host.store, 'set').mockImplementation(() => undefined);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => vi.restoreAllMocks());
|
|
136
|
+
|
|
137
|
+
it('replaces the store with the cron deploy host configuration', () => {
|
|
138
|
+
secretFixture({ [CRON_ID_PATH]: 'dd-cron', './engine-private/conf/dd-cron/.env.production': 'A=1\nB=2\n' });
|
|
139
|
+
const result = Underpost.host.load();
|
|
140
|
+
expect(cleaned.mock.calls.length).to.equal(1);
|
|
141
|
+
expect(set.mock.calls.map(([key, value]) => `${key}=${value}`)).to.deep.equal(['A=1', 'B=2']);
|
|
142
|
+
expect(result).to.deep.equal({ source: './engine-private/conf/dd-cron/.env.production', keys: 2 });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('targets an environment through the shared --env selector', () => {
|
|
146
|
+
secretFixture({ './engine-private/conf/dd-cron/.env.development': 'A=dev\n' });
|
|
147
|
+
expect(Underpost.host.load({ env: 'development' }).source).to.equal(
|
|
148
|
+
'./engine-private/conf/dd-cron/.env.development',
|
|
149
|
+
);
|
|
150
|
+
expect(set.mock.calls[0][1]).to.equal('dev');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('is idempotent: repeating the load rebuilds the store rather than accumulating', () => {
|
|
154
|
+
secretFixture({ './engine-private/conf/dd-cron/.env.production': 'A=1\n' });
|
|
155
|
+
const first = Underpost.host.load();
|
|
156
|
+
const second = Underpost.host.load();
|
|
157
|
+
expect(second).to.deep.equal(first);
|
|
158
|
+
expect(cleaned.mock.calls.length).to.equal(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('changes nothing under the shared --dry-run flag', () => {
|
|
162
|
+
secretFixture({ './engine-private/conf/dd-cron/.env.production': 'A=1\n' });
|
|
163
|
+
const result = Underpost.host.load({ dryRun: true });
|
|
164
|
+
expect(result.keys).to.equal(1);
|
|
165
|
+
expect(cleaned.mock.calls.length).to.equal(0);
|
|
166
|
+
expect(set.mock.calls.length).to.equal(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Inside a workload container `engine-private` is not cloned yet, so the same host
|
|
170
|
+
// configuration arrives as the injected container environment instead of a file. The
|
|
171
|
+
// process environment there also carries the runtime's own PATH and the Kubernetes vars.
|
|
172
|
+
it('falls back to the injected container environment when no host config file exists', () => {
|
|
173
|
+
secretFixture({});
|
|
174
|
+
const previous = { ...process.env };
|
|
175
|
+
process.env.FIXTURE_SECRET_KEY = 'value';
|
|
176
|
+
process.env.KUBERNETES_SERVICE_HOST = '10.96.0.1';
|
|
177
|
+
try {
|
|
178
|
+
expect(Underpost.host.load().source).to.equal('container-env');
|
|
179
|
+
const captured = Object.fromEntries(set.mock.calls);
|
|
180
|
+
expect(captured.FIXTURE_SECRET_KEY).to.equal('value');
|
|
181
|
+
expect(captured).not.to.have.property('PATH');
|
|
182
|
+
expect(captured).not.to.have.property('HOME');
|
|
183
|
+
expect(captured).not.to.have.property('KUBERNETES_SERVICE_HOST');
|
|
184
|
+
} finally {
|
|
185
|
+
process.env = previous;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('application deployment environment', () => {
|
|
191
|
+
let cleaned;
|
|
192
|
+
let set;
|
|
193
|
+
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
cleaned = vi.spyOn(Underpost.host.store, 'clean').mockImplementation(() => undefined);
|
|
196
|
+
set = vi.spyOn(Underpost.host.store, 'set').mockImplementation(() => undefined);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(() => vi.restoreAllMocks());
|
|
200
|
+
|
|
201
|
+
it('selects a deployment env file from the environment selector alone', () => {
|
|
202
|
+
secretFixture({ './engine-private/conf/dd-core/.env.development': 'A=1\n' });
|
|
203
|
+
expect(Underpost.app.envPath({ env: 'development', args: { 'deploy-id': 'dd-core' } })).to.equal(
|
|
204
|
+
'./engine-private/conf/dd-core/.env.development',
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('prefers a sub-configuration variant only when that file exists', () => {
|
|
209
|
+
secretFixture({
|
|
210
|
+
'./engine-private/conf/dd-core/.env.development': 'A=1\n',
|
|
211
|
+
'./engine-private/conf/dd-core/.env.development.nexodev-dev-api': 'A=2\n',
|
|
212
|
+
});
|
|
213
|
+
expect(
|
|
214
|
+
Underpost.app.envPath({ env: 'development', args: { 'deploy-id': 'dd-core', 'sub-conf': 'nexodev-dev-api' } }),
|
|
215
|
+
).to.equal('./engine-private/conf/dd-core/.env.development.nexodev-dev-api');
|
|
216
|
+
|
|
217
|
+
secretFixture({ './engine-private/conf/dd-core/.env.development': 'A=1\n' });
|
|
218
|
+
expect(
|
|
219
|
+
Underpost.app.envPath({ env: 'development', args: { 'deploy-id': 'dd-core', 'sub-conf': 'absent' } }),
|
|
220
|
+
).to.equal('./engine-private/conf/dd-core/.env.development');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('is deterministic: the same selector resolves the same file every time', () => {
|
|
224
|
+
secretFixture({ './engine-private/conf/dd-core/.env.production': 'A=1\n' });
|
|
225
|
+
const context = { env: 'production', args: { 'deploy-id': 'dd-core' }, dryRun: true };
|
|
226
|
+
const first = Underpost.app.load(context);
|
|
227
|
+
const second = Underpost.app.load(context);
|
|
228
|
+
expect(second).to.deep.equal(first);
|
|
229
|
+
expect(first.source).to.equal('./engine-private/conf/dd-core/.env.production');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('materializes the working tree and leaves the host configuration store alone', () => {
|
|
233
|
+
// Regression: `app load` used to rebuild the host configuration store, which is host-scoped.
|
|
234
|
+
// Running it after `host load` erased the node's own configuration, container-status included.
|
|
235
|
+
const appSource = fs.readFileSync(new URL('../../../../src/cli/app.js', import.meta.url), 'utf8');
|
|
236
|
+
const loadBody = appSource.slice(
|
|
237
|
+
appSource.indexOf(' load(context = {}) {'),
|
|
238
|
+
appSource.indexOf(' publish(context = {}) {'),
|
|
239
|
+
);
|
|
240
|
+
expect(loadBody).to.include('loadConf(deployId');
|
|
241
|
+
expect(loadBody).to.not.include('Underpost.host.store.clean()');
|
|
242
|
+
expect(loadBody).to.not.include('Underpost.host.store.set(');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('names the missing file instead of silently loading nothing', () => {
|
|
246
|
+
secretFixture({});
|
|
247
|
+
expect(() => Underpost.app.load({ env: 'production', args: { 'deploy-id': 'dd-core' } })).to.throw(
|
|
248
|
+
'deployment environment not found',
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('resolves the deployment id from context when none is given', () => {
|
|
253
|
+
secretFixture({});
|
|
254
|
+
const previous = process.env.DEPLOY_ID;
|
|
255
|
+
process.env.DEPLOY_ID = 'dd-from-context';
|
|
256
|
+
try {
|
|
257
|
+
expect(Underpost.app.deployId()).to.equal('dd-from-context');
|
|
258
|
+
expect(Underpost.app.envPath({ env: 'development' })).to.equal(
|
|
259
|
+
'./engine-private/conf/dd-from-context/.env.development',
|
|
260
|
+
);
|
|
261
|
+
} finally {
|
|
262
|
+
if (previous === undefined) delete process.env.DEPLOY_ID;
|
|
263
|
+
else process.env.DEPLOY_ID = previous;
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('underpost-config secret', () => {
|
|
269
|
+
afterEach(() => vi.restoreAllMocks());
|
|
270
|
+
|
|
271
|
+
it('strips the keys that would override the image own runtime', () => {
|
|
272
|
+
const sanitized = Underpost.host.sanitizeEnvFile(
|
|
273
|
+
['# comment', '', 'PATH=/usr/bin', 'HOME=/root', 'NODE_ENV=production', 'APP_SECRET=value', 'malformed'].join(
|
|
274
|
+
'\n',
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
expect(sanitized.split('\n')).to.deep.equal([
|
|
278
|
+
'# comment',
|
|
279
|
+
'',
|
|
280
|
+
'NODE_ENV=production',
|
|
281
|
+
'APP_SECRET=value',
|
|
282
|
+
'malformed',
|
|
283
|
+
]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('resolves the env file the cron deploy publishes', () => {
|
|
287
|
+
secretFixture({ [CRON_ID_PATH]: 'dd-fixture-cron' });
|
|
288
|
+
expect(Underpost.host.envPath('production')).to.equal('./engine-private/conf/dd-fixture-cron/.env.production');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('republishes the secret from a sanitized copy staged on tmpfs, then removes it', () => {
|
|
292
|
+
// Staged off tmpfs rather than beside the source: the source composes from one file per
|
|
293
|
+
// scope once the migration has run, and there is no longer one file to stage next to.
|
|
294
|
+
const harness = shellHarness();
|
|
295
|
+
const stagedPath = '/dev/shm/underpost-host-apply/ops.env';
|
|
296
|
+
const { written, removed } = secretFixture({
|
|
297
|
+
[CRON_ID_PATH]: 'dd-cron',
|
|
298
|
+
'./engine-private/conf/dd-cron/.env.production': 'PATH=/usr/bin\nAPP_SECRET=value\n',
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
Underpost.host.apply({ env: 'production', namespace: 'ops' });
|
|
302
|
+
expect(harness.ran('kubectl delete secret underpost-config -n ops --ignore-not-found')).to.equal(true);
|
|
303
|
+
expect(harness.ran(`--from-env-file=${stagedPath}`)).to.equal(true);
|
|
304
|
+
expect(removed).to.include(stagedPath);
|
|
305
|
+
expect(written.has(stagedPath)).to.equal(false);
|
|
306
|
+
} finally {
|
|
307
|
+
harness.restore();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('names the env file it could not find', () => {
|
|
312
|
+
secretFixture({ [CRON_ID_PATH]: 'dd-cron' });
|
|
313
|
+
expect(() => Underpost.host.apply()).to.throw('configuration source not found');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('clears the host configuration store and leaves container state untouched', () => {
|
|
317
|
+
// Container runtime state has its own store, so the clean needs no carve-out for it.
|
|
318
|
+
const clean = vi.spyOn(Underpost.host.store, 'clean').mockImplementation(() => undefined);
|
|
319
|
+
const stateClean = vi.spyOn(UnderpostState.API, 'clean').mockImplementation(() => undefined);
|
|
320
|
+
vi.spyOn(UnderpostRepository.API, 'cleanupPrivateEngineRepo').mockImplementation(() => undefined);
|
|
321
|
+
Underpost.host.clean({ args: {} });
|
|
322
|
+
expect(clean.mock.calls.length).to.equal(1);
|
|
323
|
+
expect(clean.mock.calls[0][0]).to.equal(undefined);
|
|
324
|
+
expect(stateClean.mock.calls.length).to.equal(0);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('host tooling', () => {
|
|
329
|
+
let harness;
|
|
330
|
+
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
harness = shellHarness();
|
|
333
|
+
vi.spyOn(UnderpostBaremetal.API, 'getHostArch').mockReturnValue({ arch: 'x86_64', alias: 'amd64' });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
afterEach(() => {
|
|
337
|
+
harness.restore();
|
|
338
|
+
vi.restoreAllMocks();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('reports a binary on PATH', () => {
|
|
342
|
+
harness.route({ match: 'command -v', code: 0, stdout: 'missing\n' });
|
|
343
|
+
harness.route({ match: 'command -v sops ', code: 0, stdout: 'exists\n' });
|
|
344
|
+
expect(sops().hasBinary('sops')).to.equal(true);
|
|
345
|
+
expect(sops().hasBinary('age')).to.equal(false);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// An opaque shell exit code mid-apply is what this replaces.
|
|
349
|
+
it('names the missing binary and the command that installs it', () => {
|
|
350
|
+
harness.route({ match: 'command -v', code: 0, stdout: 'missing\n' });
|
|
351
|
+
expect(() => sops().assertTooling(['sops'])).to.throw('underpost secret --install-tools');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('installs both binaries from their pinned upstream builds', () => {
|
|
355
|
+
harness.route({ match: 'command -v', code: 0, stdout: 'missing\n' });
|
|
356
|
+
// The post-install assertion reads them back as present.
|
|
357
|
+
let installed = false;
|
|
358
|
+
harness.route({
|
|
359
|
+
match: (command) => command.includes('command -v') && installed,
|
|
360
|
+
code: 0,
|
|
361
|
+
stdout: 'exists\n',
|
|
362
|
+
});
|
|
363
|
+
harness.route({
|
|
364
|
+
match: (command) => {
|
|
365
|
+
if (command.includes('ln -sf /usr/local/bin/age-keygen')) installed = true;
|
|
366
|
+
return false;
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(sops().installTooling()).to.deep.equal({ sops: true, age: true });
|
|
371
|
+
expect(harness.ran('getsops/sops/releases/download')).to.equal(true);
|
|
372
|
+
expect(harness.ran('FiloSottile/age/releases/download')).to.equal(true);
|
|
373
|
+
expect(harness.ran('install -m 0755 /tmp/sops /usr/local/bin/sops')).to.equal(true);
|
|
374
|
+
expect(harness.ran('rm -rf /tmp/age /tmp/age.tar.gz')).to.equal(true);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('installs nothing on a host that already has both', () => {
|
|
378
|
+
harness.route({ match: 'command -v', code: 0, stdout: 'exists\n' });
|
|
379
|
+
expect(sops().installTooling()).to.deep.equal({ sops: false, age: false });
|
|
380
|
+
expect(harness.ran('curl')).to.equal(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('fails loudly when an install left a binary unresolvable', () => {
|
|
384
|
+
harness.route({ match: 'command -v', code: 0, stdout: 'missing\n' });
|
|
385
|
+
expect(() => sops().installTooling()).to.throw('not found in PATH');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe('encrypted manifest lifecycle', () => {
|
|
390
|
+
let harness;
|
|
391
|
+
let restoreKeyFile;
|
|
392
|
+
|
|
393
|
+
beforeEach(() => {
|
|
394
|
+
harness = shellHarness([
|
|
395
|
+
{ match: 'command -v', code: 0, stdout: 'exists\n' },
|
|
396
|
+
{ match: 'age-keygen -y', code: 0, stdout: `${RECIPIENT}\n` },
|
|
397
|
+
]);
|
|
398
|
+
restoreKeyFile = pinKeyFile();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
afterEach(() => {
|
|
402
|
+
restoreKeyFile();
|
|
403
|
+
harness.restore();
|
|
404
|
+
vi.restoreAllMocks();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const storeFixture = (files = {}) =>
|
|
408
|
+
secretFixture({
|
|
409
|
+
[`${SECRETS_DIR}/.sops.yaml`]: `creation_rules:\n - path_regex: .*\\.enc\\.yaml$\n age: ${RECIPIENT}\n`,
|
|
410
|
+
[KEY_FILE]: `# public key: ${RECIPIENT}\nAGE-SECRET-KEY-FIXTURE\n`,
|
|
411
|
+
...files,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('reads the recipients a manifest is sealed to without any private key', () => {
|
|
415
|
+
const other = 'age1w7yx5kq0h3n2t4mzr9vp8ldjc6fs0eguya3hx2nq7r5tvk9m4dlq8zwptn';
|
|
416
|
+
storeFixture({
|
|
417
|
+
[`${SECRETS_DIR}/default/app.enc.yaml`]: encryptedManifest({ name: 'app', recipients: [RECIPIENT, other] }),
|
|
418
|
+
});
|
|
419
|
+
expect(sops().manifestRecipients(`${SECRETS_DIR}/default/app.enc.yaml`)).to.deep.equal([RECIPIENT, other]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('refuses to encrypt a manifest that is not there', () => {
|
|
423
|
+
storeFixture();
|
|
424
|
+
expect(() => sops().encrypt('/dev/shm/absent.yaml')).to.throw('Plaintext manifest not found');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('refuses to encrypt without creation rules', () => {
|
|
428
|
+
secretFixture({ '/dev/shm/app.yaml': 'kind: Secret\n' });
|
|
429
|
+
expect(() => sops().encrypt('/dev/shm/app.yaml')).to.throw('Missing creation rules');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Re-encrypting would double-wrap it, and the result decrypts to ciphertext.
|
|
433
|
+
it('refuses to re-encrypt a manifest that already carries sops metadata', () => {
|
|
434
|
+
storeFixture({ '/dev/shm/app.yaml': encryptedManifest({ name: 'app' }) });
|
|
435
|
+
expect(() => sops().encrypt('/dev/shm/app.yaml')).to.throw('already carries sops metadata');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('refuses to replace a stored manifest without --force', () => {
|
|
439
|
+
storeFixture({
|
|
440
|
+
'/dev/shm/app.yaml': 'apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n',
|
|
441
|
+
[`${SECRETS_DIR}/default/app.enc.yaml`]: encryptedManifest({ name: 'app' }),
|
|
442
|
+
});
|
|
443
|
+
expect(() => sops().encrypt('/dev/shm/app.yaml')).to.throw('pass --force to replace');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// A bare `sops … > out` truncates the destination before sops runs, so a
|
|
447
|
+
// failed encrypt would destroy the manifest already there.
|
|
448
|
+
it('encrypts through a staged file and shreds the plaintext source', () => {
|
|
449
|
+
const { written } = storeFixture({ '/dev/shm/app.yaml': 'apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n' });
|
|
450
|
+
harness.route({
|
|
451
|
+
match: 'sops --config',
|
|
452
|
+
code: 0,
|
|
453
|
+
stdout: '',
|
|
454
|
+
});
|
|
455
|
+
// The staged file is what `assertManifest` reads back.
|
|
456
|
+
vi.spyOn(fs, 'writeFileSync').mockImplementation((filePath, value) => written.set(`${filePath}`, `${value}`));
|
|
457
|
+
written.set(`${SECRETS_DIR}/default/app.enc.yaml.staged`, encryptedManifest({ name: 'app' }));
|
|
458
|
+
|
|
459
|
+
expect(sops().encrypt('/dev/shm/app.yaml')).to.equal(`${SECRETS_DIR}/default/app.enc.yaml`);
|
|
460
|
+
expect(harness.ran('--filename-override "./engine-private/secrets/default/app.enc.yaml"')).to.equal(true);
|
|
461
|
+
expect(harness.ran('shred -u "/dev/shm/app.yaml"')).to.equal(true);
|
|
462
|
+
expect(written.get(`${SECRETS_DIR}/default/app.enc.yaml`)).to.include('sops:');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('applies only the requested manifests', () => {
|
|
466
|
+
storeFixture({
|
|
467
|
+
[`${SECRETS_DIR}/default/app.enc.yaml`]: encryptedManifest({ name: 'app' }),
|
|
468
|
+
[`${SECRETS_DIR}/default/other.enc.yaml`]: encryptedManifest({ name: 'other' }),
|
|
469
|
+
});
|
|
470
|
+
expect(sops().applySelected(['app'], 'default')).to.equal(1);
|
|
471
|
+
expect(harness.ran('sops --decrypt "./engine-private/secrets/default/app.enc.yaml"')).to.equal(true);
|
|
472
|
+
expect(harness.ran('other.enc.yaml')).to.equal(false);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('refuses a selection matching nothing stored', () => {
|
|
476
|
+
storeFixture();
|
|
477
|
+
expect(() => sops().applySelected(['absent'], 'default')).to.throw('No requested encrypted secrets');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Applying in one pass means manifest N failing leaves 1..N-1 already live.
|
|
481
|
+
it('server-dry-runs every manifest before the first mutation', () => {
|
|
482
|
+
storeFixture({
|
|
483
|
+
[`${SECRETS_DIR}/default/a.enc.yaml`]: encryptedManifest({ name: 'a' }),
|
|
484
|
+
[`${SECRETS_DIR}/default/b.enc.yaml`]: encryptedManifest({ name: 'b' }),
|
|
485
|
+
});
|
|
486
|
+
expect(sops().applyStore('default')).to.equal(2);
|
|
487
|
+
const firstMutation = harness.calls.findIndex(
|
|
488
|
+
(command) => command.includes('kubectl apply -f -') && !command.includes('--dry-run=server'),
|
|
489
|
+
);
|
|
490
|
+
const dryRuns = harness.calls
|
|
491
|
+
.slice(0, firstMutation)
|
|
492
|
+
.filter((command) => command.includes('--dry-run=server')).length;
|
|
493
|
+
expect(dryRuns).to.equal(2);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('refuses to apply a namespace the store does not carry', () => {
|
|
497
|
+
storeFixture();
|
|
498
|
+
expect(() => sops().applyStore('absent')).to.throw('No encrypted secrets for namespace');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('refuses to apply a namespace directory holding no manifests', () => {
|
|
502
|
+
storeFixture({ [`${SECRETS_DIR}/default/README.md`]: 'notes\n' });
|
|
503
|
+
expect(() => sops().applyStore('default')).to.throw('No *.enc.yaml manifests');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('decrypts one secret through an anonymous pipe and returns it in memory', () => {
|
|
507
|
+
storeFixture({ [`${SECRETS_DIR}/default/app.enc.yaml`]: encryptedManifest({ name: 'app' }) });
|
|
508
|
+
harness.route({
|
|
509
|
+
match: 'kubectl create --dry-run=client',
|
|
510
|
+
code: 0,
|
|
511
|
+
stdout: JSON.stringify({
|
|
512
|
+
data: { password: Buffer.from('s3cr3t').toString('base64') },
|
|
513
|
+
stringData: { username: 'admin' },
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
516
|
+
expect(sops().readData('app')).to.deep.equal({ password: 's3cr3t', username: 'admin' });
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('applies a stored secret and reports that it did', () => {
|
|
520
|
+
storeFixture({ [`${SECRETS_DIR}/default/app.enc.yaml`]: encryptedManifest({ name: 'app' }) });
|
|
521
|
+
expect(sops().applyIfPresent('app')).to.equal(true);
|
|
522
|
+
expect(harness.ran('kubectl apply -f -')).to.equal(true);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// A manifest that is absent means the cluster is not onboarded yet; one that
|
|
526
|
+
// exists but is corrupt must not slide back to the origin seed path.
|
|
527
|
+
it('reports an absent manifest rather than applying anything', () => {
|
|
528
|
+
storeFixture();
|
|
529
|
+
expect(sops().applyIfPresent('app')).to.equal(false);
|
|
530
|
+
expect(harness.ran('kubectl apply')).to.equal(false);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('lists every stored manifest with its recipients', () => {
|
|
534
|
+
storeFixture({ [`${SECRETS_DIR}/default/app.enc.yaml`]: encryptedManifest({ name: 'app' }) });
|
|
535
|
+
expect(() => sops().list()).not.to.throw();
|
|
536
|
+
vi.restoreAllMocks();
|
|
537
|
+
secretFixture({});
|
|
538
|
+
expect(() => sops().list()).not.to.throw();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe('secret onboarding', () => {
|
|
543
|
+
let harness;
|
|
544
|
+
let restoreKeyFile;
|
|
545
|
+
|
|
546
|
+
beforeEach(() => {
|
|
547
|
+
harness = shellHarness([
|
|
548
|
+
{ match: 'command -v', code: 0, stdout: 'exists\n' },
|
|
549
|
+
{ match: 'age-keygen -y', code: 0, stdout: `${RECIPIENT}\n` },
|
|
550
|
+
]);
|
|
551
|
+
restoreKeyFile = pinKeyFile();
|
|
552
|
+
vi.spyOn(UnderpostBaremetal.API, 'getHostArch').mockReturnValue({ arch: 'x86_64', alias: 'amd64' });
|
|
553
|
+
vi.spyOn(sops(), 'init').mockReturnValue(undefined);
|
|
554
|
+
vi.spyOn(sops(), 'applySelected').mockReturnValue(1);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
afterEach(() => {
|
|
558
|
+
restoreKeyFile();
|
|
559
|
+
harness.restore();
|
|
560
|
+
vi.restoreAllMocks();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const onboardFixture = (files = {}) =>
|
|
564
|
+
secretFixture({
|
|
565
|
+
[`${SECRETS_DIR}/.sops.yaml`]: `creation_rules:\n - path_regex: .*\\.enc\\.yaml$\n age: ${RECIPIENT}\n`,
|
|
566
|
+
[KEY_FILE]: `# public key: ${RECIPIENT}\nAGE-SECRET-KEY-FIXTURE\n`,
|
|
567
|
+
[CRON_ID_PATH]: 'dd-cron',
|
|
568
|
+
...files,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const stageEncrypted = (written, name, namespace = 'default') =>
|
|
572
|
+
harness.route({
|
|
573
|
+
match: (command) => {
|
|
574
|
+
if (command.includes(`--filename-override "${SECRETS_DIR}/${namespace}/${name}.enc.yaml"`))
|
|
575
|
+
written.set(`${SECRETS_DIR}/${namespace}/${name}.enc.yaml.staged`, encryptedManifest({ name, namespace }));
|
|
576
|
+
return false;
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('pins the resolved key path so a non-interactive run can decrypt', () => {
|
|
581
|
+
const { written } = onboardFixture();
|
|
582
|
+
stageEncrypted(written, 'postgres-secret');
|
|
583
|
+
sops().setupStore('postgres-secret', {});
|
|
584
|
+
expect(harness.ran(KEY_FILE)).to.equal(true);
|
|
585
|
+
expect(harness.ran('/etc/profile.d/underpost-sops.sh')).to.equal(true);
|
|
586
|
+
expect(harness.ran('chmod 644 /etc/profile.d/underpost-sops.sh')).to.equal(true);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('seeds a data key from its origin seed file', () => {
|
|
590
|
+
const { written } = onboardFixture({ './engine-private/mariadb-password': ' fromSeedFile \n' });
|
|
591
|
+
stageEncrypted(written, 'mariadb-secret');
|
|
592
|
+
sops().setupStore('mariadb-secret', {});
|
|
593
|
+
expect(written.get('/dev/shm/underpost-secrets/mariadb-secret.yaml')).to.include("password: 'fromSeedFile'");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('takes a data key from --args when nothing seeded it', () => {
|
|
597
|
+
const { written } = onboardFixture();
|
|
598
|
+
stageEncrypted(written, 'postgres-secret');
|
|
599
|
+
sops().setupStore('postgres-secret', { args: 'password=fromArgs,ignored' });
|
|
600
|
+
expect(written.get('/dev/shm/underpost-secrets/postgres-secret.yaml')).to.include("password: 'fromArgs'");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('generates a value when nothing else supplies one', () => {
|
|
604
|
+
const { written } = onboardFixture();
|
|
605
|
+
stageEncrypted(written, 'postgres-secret');
|
|
606
|
+
sops().setupStore('postgres-secret', {});
|
|
607
|
+
const manifest = written.get('/dev/shm/underpost-secrets/postgres-secret.yaml');
|
|
608
|
+
expect(manifest).to.include('kind: Secret');
|
|
609
|
+
expect(manifest).to.match(/password: '.+'/);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('leaves an already onboarded secret alone', () => {
|
|
613
|
+
onboardFixture({
|
|
614
|
+
[`${SECRETS_DIR}/default/postgres-secret.enc.yaml`]: encryptedManifest({ name: 'postgres-secret' }),
|
|
615
|
+
});
|
|
616
|
+
sops().setupStore('postgres-secret', {});
|
|
617
|
+
expect(harness.ran('--filename-override')).to.equal(false);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// A stored manifest this host cannot open is present but unusable here, so
|
|
621
|
+
// reporting it as onboarded would send the operator on to a failing apply.
|
|
622
|
+
it('reports a stored manifest sealed to a recipient this host does not hold', () => {
|
|
623
|
+
onboardFixture({
|
|
624
|
+
[`${SECRETS_DIR}/default/postgres-secret.enc.yaml`]: encryptedManifest({
|
|
625
|
+
name: 'postgres-secret',
|
|
626
|
+
recipients: ['age1w7yx5kq0h3n2t4mzr9vp8ldjc6fs0eguya3hx2nq7r5tvk9m4dlq8zwptn'],
|
|
627
|
+
}),
|
|
628
|
+
});
|
|
629
|
+
sops().setupStore('postgres-secret', {});
|
|
630
|
+
expect(harness.ran('--filename-override')).to.equal(false);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('replaces a stored manifest under --force', () => {
|
|
634
|
+
const { written } = onboardFixture({
|
|
635
|
+
[`${SECRETS_DIR}/default/postgres-secret.enc.yaml`]: encryptedManifest({ name: 'postgres-secret' }),
|
|
636
|
+
});
|
|
637
|
+
stageEncrypted(written, 'postgres-secret');
|
|
638
|
+
sops().setupStore('postgres-secret', { force: true });
|
|
639
|
+
expect(harness.ran('--filename-override')).to.equal(true);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('validates without touching the cluster under --dry-run', () => {
|
|
643
|
+
const { written } = onboardFixture();
|
|
644
|
+
stageEncrypted(written, 'postgres-secret');
|
|
645
|
+
const applySelected = vi.spyOn(sops(), 'applySelected').mockReturnValue(1);
|
|
646
|
+
sops().setupStore('postgres-secret', { dryRun: true });
|
|
647
|
+
expect(applySelected.mock.calls.length).to.equal(1);
|
|
648
|
+
expect(applySelected.mock.calls[0][2]).to.deep.equal({ dryRun: true });
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('onboards the whole data tier by default', () => {
|
|
652
|
+
const { written } = onboardFixture();
|
|
653
|
+
for (const name of ['postgres-secret', 'mariadb-secret', 'mongodb-secret', 'mongodb-keyfile'])
|
|
654
|
+
stageEncrypted(written, name, 'ops');
|
|
655
|
+
sops().setupStore('', { namespace: 'ops' });
|
|
656
|
+
expect(harness.count('--filename-override')).to.equal(4);
|
|
657
|
+
expect(harness.ran('/ops/mongodb-keyfile.enc.yaml')).to.equal(true);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('removes the shared-memory staging directory even when a step throws', () => {
|
|
661
|
+
const { removed } = onboardFixture();
|
|
662
|
+
harness.route({ match: 'sops --config', throws: new Error('sops refused') });
|
|
663
|
+
expect(() => sops().setupStore('postgres-secret', {})).to.throw('sops refused');
|
|
664
|
+
expect(removed).to.include('/dev/shm/underpost-secrets');
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe('secret status report', () => {
|
|
669
|
+
let harness;
|
|
670
|
+
let restoreKeyFile;
|
|
671
|
+
|
|
672
|
+
beforeEach(() => {
|
|
673
|
+
harness = shellHarness([
|
|
674
|
+
{ match: 'command -v', code: 0, stdout: 'exists\n' },
|
|
675
|
+
{ match: 'age-keygen -y', code: 0, stdout: `${RECIPIENT}\n` },
|
|
676
|
+
]);
|
|
677
|
+
restoreKeyFile = pinKeyFile();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
afterEach(() => {
|
|
681
|
+
restoreKeyFile();
|
|
682
|
+
harness.restore();
|
|
683
|
+
vi.restoreAllMocks();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const statusFixture = (files = {}) =>
|
|
687
|
+
secretFixture({
|
|
688
|
+
[`${SECRETS_DIR}/.sops.yaml`]: `creation_rules:\n - path_regex: .*\\.enc\\.yaml$\n age: ${RECIPIENT}\n`,
|
|
689
|
+
[KEY_FILE]: `# public key: ${RECIPIENT}\nAGE-SECRET-KEY-FIXTURE\n`,
|
|
690
|
+
...files,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('reports tooling, key, rules, store and coverage', () => {
|
|
694
|
+
statusFixture({
|
|
695
|
+
[`${SECRETS_DIR}/default/postgres-secret.enc.yaml`]: encryptedManifest({ name: 'postgres-secret' }),
|
|
696
|
+
'./engine-private/mariadb-username': 'root\n',
|
|
697
|
+
'./engine-private/mariadb-password': 'pw\n',
|
|
698
|
+
});
|
|
699
|
+
harness.route({ match: 'kubectl get secret', code: 0, stdout: 'secret/postgres-secret\n' });
|
|
700
|
+
harness.route({ match: 'kubectl diff', code: 0 });
|
|
701
|
+
expect(() => sops().statusReport('', {})).not.to.throw();
|
|
702
|
+
expect(harness.ran('kubectl diff -f -')).to.equal(true);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// Its stdout would contain the decrypted values, so drift is decided by the
|
|
706
|
+
// exit code alone.
|
|
707
|
+
it('reports drift from the diff exit code without capturing its output', () => {
|
|
708
|
+
statusFixture({
|
|
709
|
+
[`${SECRETS_DIR}/default/postgres-secret.enc.yaml`]: encryptedManifest({ name: 'postgres-secret' }),
|
|
710
|
+
});
|
|
711
|
+
harness.route({ match: 'kubectl get secret', code: 0, stdout: 'secret/postgres-secret\n' });
|
|
712
|
+
harness.route({ match: 'kubectl diff', code: 1 });
|
|
713
|
+
sops().statusReport('', {});
|
|
714
|
+
const diff = harness.calls.find((command) => command.includes('kubectl diff'));
|
|
715
|
+
expect(diff).to.include('>/dev/null 2>&1');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('narrows both the store listing and the coverage table by a partial key', () => {
|
|
719
|
+
statusFixture({
|
|
720
|
+
[`${SECRETS_DIR}/default/mongodb-secret.enc.yaml`]: encryptedManifest({ name: 'mongodb-secret' }),
|
|
721
|
+
[`${SECRETS_DIR}/default/postgres-secret.enc.yaml`]: encryptedManifest({ name: 'postgres-secret' }),
|
|
722
|
+
});
|
|
723
|
+
sops().statusReport('mongo', {});
|
|
724
|
+
expect(harness.calls.some((command) => command.includes('mongodb-secret'))).to.equal(true);
|
|
725
|
+
expect(harness.calls.some((command) => command.includes('postgres-secret'))).to.equal(false);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('reports an empty store and a filter matching no managed secret', () => {
|
|
729
|
+
statusFixture();
|
|
730
|
+
expect(() => sops().statusReport('not-a-managed-key', {})).not.to.throw();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('reports a host with no tooling and no key at all', () => {
|
|
734
|
+
secretFixture({});
|
|
735
|
+
harness.route({ match: 'command -v', code: 0, stdout: 'missing\n' });
|
|
736
|
+
expect(() => sops().statusReport('', {})).not.to.throw();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// The cron workloads used to read these values by bind-mounting the directory that holds the
|
|
740
|
+
// operator's global `.env` — a home-directory tree no unprivileged container can read under
|
|
741
|
+
// SELinux. Projecting them as a Secret is what replaced that mount, so the environment path has
|
|
742
|
+
// to work even on a host that keeps no seed file for them.
|
|
743
|
+
describe('environment-seeded workload secrets', () => {
|
|
744
|
+
// The connection, not the key: the key path names a host location and would shadow the Secret
|
|
745
|
+
// volume a pod actually reads, so it is entitled to no scope.
|
|
746
|
+
const CRON_ENV_VARS = [
|
|
747
|
+
'GITHUB_TOKEN',
|
|
748
|
+
'GITHUB_USERNAME',
|
|
749
|
+
'DEFAULT_SSH_USER',
|
|
750
|
+
'DEFAULT_SSH_HOST',
|
|
751
|
+
'DEFAULT_SSH_PORT',
|
|
752
|
+
];
|
|
753
|
+
const CRON_UNENTITLED = ['DEFAULT_SSH_KEY_PATH', 'VULTR_SSH_KEY_PATH'];
|
|
754
|
+
|
|
755
|
+
// Every cron-entitled variable is pinned, not just the ones a case sets: the key set is derived
|
|
756
|
+
// from the live environment, and the host running the suite exports its own connection.
|
|
757
|
+
const withEnv = (values, run) => {
|
|
758
|
+
const entitled = Object.keys(scopeValuesFactory(process.env, 'cron'));
|
|
759
|
+
const cleared = Object.fromEntries([...CRON_ENV_VARS, ...entitled].map((key) => [key, undefined]));
|
|
760
|
+
const pinned = { ...cleared, ...values };
|
|
761
|
+
const previous = {};
|
|
762
|
+
for (const [key, value] of Object.entries(pinned)) {
|
|
763
|
+
previous[key] = process.env[key];
|
|
764
|
+
if (value === undefined) delete process.env[key];
|
|
765
|
+
else process.env[key] = value;
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
return run();
|
|
769
|
+
} finally {
|
|
770
|
+
for (const [key, value] of Object.entries(previous))
|
|
771
|
+
if (value === undefined) delete process.env[key];
|
|
772
|
+
else process.env[key] = value;
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
it('maps what the cron scope entitles the pods to, derived rather than listed', () => {
|
|
777
|
+
// The key set is the `cron` scope of whatever the host source carries, so it cannot drift
|
|
778
|
+
// from the ownership table the way a hand-written list does.
|
|
779
|
+
const set = Object.fromEntries([...CRON_ENV_VARS, ...CRON_UNENTITLED].map((key) => [key, 'set']));
|
|
780
|
+
withEnv(set, () => {
|
|
781
|
+
const mapped = Object.keys(sops().seedEnvKeys('underpost-cron-env'));
|
|
782
|
+
expect(mapped.sort()).to.include.members(CRON_ENV_VARS.slice().sort());
|
|
783
|
+
for (const key of CRON_UNENTITLED) expect(mapped, key).to.not.include(key);
|
|
784
|
+
});
|
|
785
|
+
for (const key of CRON_UNENTITLED) delete process.env[key];
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('never maps a key another scope owns, whatever the host environment carries', () => {
|
|
789
|
+
const foreign = {
|
|
790
|
+
DB_PASSWORD: 'app-secret',
|
|
791
|
+
JWT_SECRET: 'app-secret',
|
|
792
|
+
MAAS_API_KEY: 'baremetal-secret',
|
|
793
|
+
NPM_TOKEN: 'publishing-secret',
|
|
794
|
+
GF_SECURITY_ADMIN_PASSWORD: 'host-only-secret',
|
|
795
|
+
};
|
|
796
|
+
withEnv({ GITHUB_TOKEN: 'token-value', ...foreign }, () => {
|
|
797
|
+
const mapped = sops().seedEnvKeys('underpost-cron-env');
|
|
798
|
+
const values = sops().seedEnvValues('underpost-cron-env');
|
|
799
|
+
for (const key of Object.keys(foreign)) {
|
|
800
|
+
expect(mapped, key).to.not.have.property(key);
|
|
801
|
+
expect(values, key).to.not.have.property(key);
|
|
802
|
+
}
|
|
803
|
+
expect(values).to.have.property('GITHUB_TOKEN', 'token-value');
|
|
804
|
+
});
|
|
805
|
+
for (const key of Object.keys(foreign)) delete process.env[key];
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('reads the values out of the environment when no seed file exists', () => {
|
|
809
|
+
withEnv({ GITHUB_TOKEN: 'token-value', DEFAULT_SSH_HOST: '10.0.0.4' }, () => {
|
|
810
|
+
const values = sops().seedEnvValues('underpost-cron-env');
|
|
811
|
+
expect(values).to.include({ GITHUB_TOKEN: 'token-value', DEFAULT_SSH_HOST: '10.0.0.4' });
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('treats an unset or empty variable as absent rather than projecting a blank credential', () => {
|
|
816
|
+
// The cron deploy env declares DEFAULT_SSH_* with empty values; projecting those would give
|
|
817
|
+
// the pod a configured-looking connection with nothing behind it.
|
|
818
|
+
withEnv({ GITHUB_TOKEN: 'token-value', GITHUB_USERNAME: '', DEFAULT_SSH_KEY_PATH: '' }, () => {
|
|
819
|
+
const values = sops().seedEnvValues('underpost-cron-env');
|
|
820
|
+
expect(values).to.have.property('GITHUB_TOKEN', 'token-value');
|
|
821
|
+
expect(values).to.not.have.property('GITHUB_USERNAME');
|
|
822
|
+
expect(values).to.not.have.property('DEFAULT_SSH_KEY_PATH');
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('projects the Secret from the environment, staged on tmpfs and never as a literal', () => {
|
|
827
|
+
secretFixture({});
|
|
828
|
+
withEnv({ GITHUB_TOKEN: 'token-value', GITHUB_USERNAME: 'octocat', DEFAULT_SSH_HOST: '10.0.0.4' }, () => {
|
|
829
|
+
expect(sops().applyFromOriginSeed('underpost-cron-env', 'default')).to.equal(true);
|
|
830
|
+
});
|
|
831
|
+
const create = harness.calls.find((command) => command.includes('kubectl create secret generic'));
|
|
832
|
+
expect(create).to.include('--from-file=GITHUB_TOKEN=/dev/shm/');
|
|
833
|
+
expect(create).to.include('--from-file=GITHUB_USERNAME=/dev/shm/');
|
|
834
|
+
expect(create).to.include('--from-file=DEFAULT_SSH_HOST=/dev/shm/');
|
|
835
|
+
expect(create).to.not.include('token-value');
|
|
836
|
+
expect(create).to.not.include('--from-literal');
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it('projects the keys the host does set and omits the rest, rather than refusing outright', () => {
|
|
840
|
+
// Environment-mapped keys are ambient, not a contract: a partial set still deploys. Only a
|
|
841
|
+
// half-present set of file-mapped keys (a database credential) is an error.
|
|
842
|
+
secretFixture({});
|
|
843
|
+
withEnv({ GITHUB_TOKEN: 'token-value' }, () => {
|
|
844
|
+
expect(sops().applyFromOriginSeed('underpost-cron-env', 'default')).to.equal(true);
|
|
845
|
+
});
|
|
846
|
+
const create = harness.calls.find((command) => command.includes('kubectl create secret generic'));
|
|
847
|
+
expect(create).to.include('--from-file=GITHUB_TOKEN=');
|
|
848
|
+
expect(create).to.not.include('DEFAULT_SSH_HOST');
|
|
849
|
+
expect(create).to.not.include('GITHUB_USERNAME');
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('projects nothing, rather than a partial Secret, when neither seed nor environment has it', () => {
|
|
853
|
+
secretFixture({});
|
|
854
|
+
withEnv({ GITHUB_TOKEN: undefined, GITHUB_USERNAME: undefined }, () => {
|
|
855
|
+
expect(sops().applyFromOriginSeed('underpost-cron-env', 'default')).to.equal(false);
|
|
856
|
+
});
|
|
857
|
+
expect(harness.calls.some((command) => command.includes('kubectl create secret generic'))).to.equal(false);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it('leaves the grafana credentials on their own resolver', () => {
|
|
861
|
+
expect(sops().seedEnvKeys('grafana-admin')).to.have.keys(['admin-user', 'admin-password']);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// The other half of removing the mount: the Secret puts the values in the pod's environment,
|
|
865
|
+
// and this is what lets `underpost host get` — which reads a file store next to the global
|
|
866
|
+
// installation — resolve them there.
|
|
867
|
+
it('resolves a host store key from the environment when the store file is absent', () => {
|
|
868
|
+
secretFixture({});
|
|
869
|
+
withEnv({ DEFAULT_SSH_KEY_PATH: '/home/dd/engine/engine-private/deploy/id_rsa' }, () => {
|
|
870
|
+
expect(Underpost.host.get('DEFAULT_SSH_KEY_PATH', undefined, { disableLog: true })).to.equal(
|
|
871
|
+
'/home/dd/engine/engine-private/deploy/id_rsa',
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('keeps the store file authoritative over the environment', () => {
|
|
877
|
+
secretFixture({ [Underpost.host.store.path()]: 'DEFAULT_SSH_HOST=from-file\n' });
|
|
878
|
+
// `read()` gates on statSync().isFile(); the shared fixture models only the path table.
|
|
879
|
+
vi.spyOn(fs, 'statSync').mockReturnValue({ isFile: () => true });
|
|
880
|
+
withEnv({ DEFAULT_SSH_HOST: 'from-env' }, () => {
|
|
881
|
+
expect(Underpost.host.get('DEFAULT_SSH_HOST', undefined, { disableLog: true })).to.equal('from-file');
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// The connection key is the one credential a workload cannot take as an environment variable:
|
|
888
|
+
// ssh authenticates with a file. One resolver answers where that file is, so a pod and the host
|
|
889
|
+
// CLI reach different keys through the same call rather than through a flag either side sets.
|
|
890
|
+
describe('ssh key resolution', () => {
|
|
891
|
+
const SECRET_KEY_PATH = '/etc/underpost/secrets/ssh/id_rsa';
|
|
892
|
+
let mounted;
|
|
893
|
+
|
|
894
|
+
beforeEach(() => {
|
|
895
|
+
mounted = false;
|
|
896
|
+
delete process.env.DEFAULT_SSH_KEY_PATH;
|
|
897
|
+
vi.spyOn(fs, 'existsSync').mockImplementation((path) => (`${path}` === SECRET_KEY_PATH ? mounted : false));
|
|
898
|
+
});
|
|
899
|
+
afterEach(() => {
|
|
900
|
+
delete process.env.DEFAULT_SSH_KEY_PATH;
|
|
901
|
+
vi.restoreAllMocks();
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it('takes the checkout key on a host with no mount and no configured path', () => {
|
|
905
|
+
expect(Underpost.ssh.keyPathFactory()).to.equal('./engine-private/deploy/id_rsa');
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('takes the projected Secret inside a pod, where the checkout key does not exist', () => {
|
|
909
|
+
mounted = true;
|
|
910
|
+
expect(Underpost.ssh.keyPathFactory()).to.equal(SECRET_KEY_PATH);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it('lets an explicit path win over the mount, so a caller can still name a key', () => {
|
|
914
|
+
mounted = true;
|
|
915
|
+
expect(Underpost.ssh.keyPathFactory('/tmp/other-key')).to.equal('/tmp/other-key');
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// Regression: resolving the key from the ambient DEFAULT_SSH_KEY_PATH sent every node of a
|
|
919
|
+
// fleet sync the same key. On a host the key is per target — the hub answers to root's, the LAN
|
|
920
|
+
// nodes to another account's — and only the caller knows which, so an explicit path always wins.
|
|
921
|
+
it('never overrides a caller path, which is how a fleet reaches each node with its own key', () => {
|
|
922
|
+
process.env.DEFAULT_SSH_KEY_PATH = '/ambient/key';
|
|
923
|
+
mounted = true;
|
|
924
|
+
expect(Underpost.ssh.keyPathFactory('/hub/root-key')).to.equal('/hub/root-key');
|
|
925
|
+
mounted = false;
|
|
926
|
+
expect(Underpost.ssh.keyPathFactory('/hub/root-key')).to.equal('/hub/root-key');
|
|
927
|
+
// The ambient value is not a source: it names one key for a fleet that has several.
|
|
928
|
+
expect(Underpost.ssh.keyPathFactory()).to.equal('./engine-private/deploy/id_rsa');
|
|
929
|
+
});
|
|
930
|
+
});
|