@underpostnet/cyberia 3.3.0 → 3.3.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (471) hide show
  1. package/.dockerignore +1 -0
  2. package/.github/workflows/coverall.cyberia.ci.yml +34 -14
  3. package/.github/workflows/cyberia-client.cd.yml +5 -0
  4. package/.github/workflows/cyberia-server.cd.yml +5 -0
  5. package/.github/workflows/docker-image.cyberia-client.ci.yml +8 -5
  6. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +8 -3
  7. package/.github/workflows/docker-image.cyberia-server.ci.yml +8 -5
  8. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +8 -5
  9. package/.github/workflows/docker-image.engine-cyberia.ci.yml +15 -1
  10. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +15 -1
  11. package/.github/workflows/engine-cyberia.cd.yml +3 -0
  12. package/.github/workflows/engine-cyberia.ci.yml +4 -5
  13. package/.github/workflows/ghpkg.ci.yml +35 -20
  14. package/.github/workflows/hardhat.ci.yml +12 -11
  15. package/.github/workflows/npmpkg.ci.yml +17 -15
  16. package/.github/workflows/pwa-microservices-template-test.ci.yml +2 -2
  17. package/.github/workflows/release.cd.yml +1 -0
  18. package/.prettierignore +1 -0
  19. package/AGENTS.md +47 -0
  20. package/CHANGELOG.md +565 -2510
  21. package/CLI-HELP.md +306 -147
  22. package/Dockerfile +25 -9
  23. package/Dockerfile.dev +25 -9
  24. package/Dockerfile.test +4 -4
  25. package/README.md +21 -1
  26. package/bin/build.js +66 -41
  27. package/bin/build.template.js +8 -3
  28. package/bin/cyberia.js +308 -91
  29. package/bin/deploy.js +18 -104
  30. package/bin/index.js +308 -91
  31. package/conf.js +91 -4
  32. package/deploy/cyberia-client/deploy.sh +47 -0
  33. package/deploy/cyberia-client/package.sh +18 -0
  34. package/deploy/cyberia-client/state.sh +14 -0
  35. package/deploy/cyberia-server/deploy.sh +47 -0
  36. package/deploy/cyberia-server/package.sh +18 -0
  37. package/deploy/cyberia-server/state.sh +14 -0
  38. package/deploy/dd-cyberia/deploy.sh +6 -26
  39. package/deploy/dd-cyberia/init.sh +35 -43
  40. package/deploy/dd-cyberia/package.sh +17 -0
  41. package/deploy/dd-cyberia/state.sh +14 -0
  42. package/deploy/dd-cyberia/sync-deploy.sh +92 -170
  43. package/deploy/lib/github-actions-logging.sh +594 -0
  44. package/deploy/lib/host.sh +185 -0
  45. package/deploy/lib/state.sh +62 -0
  46. package/deploy/pwa-microservices-template/deploy.sh +12 -39
  47. package/deploy/release/deploy.sh +9 -39
  48. package/deployment.yaml +7 -2
  49. package/docker-compose.yml +7 -7
  50. package/examples/static-page/README.md +19 -19
  51. package/examples/static-page/ssr-components/CustomPage.js +2 -2
  52. package/hardhat/hardhat.config.js +1 -6
  53. package/hardhat/package-lock.json +101 -40
  54. package/hardhat/package.json +2 -2
  55. package/manifests/alertmanager/deployment.yaml +81 -0
  56. package/manifests/blackbox-exporter/deployment.yaml +75 -0
  57. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +32 -7
  58. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +32 -7
  59. package/manifests/deployment/dd-cyberia-development/deployment.yaml +7 -2
  60. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  61. package/manifests/grafana/deployment.yaml +26 -7
  62. package/manifests/grafana/kustomization.yaml +1 -0
  63. package/manifests/grafana/service.yaml +6 -1
  64. package/manifests/mariadb/pv.yaml +1 -0
  65. package/manifests/mongodb/statefulset.yaml +9 -2
  66. package/manifests/mysql/pv-pvc.yaml +14 -1
  67. package/manifests/postgresql/pv.yaml +12 -1
  68. package/manifests/postgresql/pvc.yaml +1 -0
  69. package/manifests/prometheus/deployment.yaml +18 -22
  70. package/package.json +83 -10
  71. package/scripts/event-monitor.sh +56 -0
  72. package/scripts/k3s-node-setup.sh +27 -14
  73. package/scripts/kubeadm-node-setup.sh +31 -11
  74. package/scripts/maas-setup.sh +8 -8
  75. package/scripts/selinux-normalize.sh +907 -0
  76. package/scripts/shutdown-machine.sh +843 -0
  77. package/scripts/test-monitor.sh +3 -3
  78. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +1 -1
  79. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +2 -2
  80. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +2 -2
  81. package/src/api/core/core.controller.js +1 -1
  82. package/src/api/core/core.router.js +2 -2
  83. package/src/api/core/core.service.js +2 -2
  84. package/src/api/crypto/crypto.controller.js +1 -1
  85. package/src/api/crypto/crypto.service.js +1 -1
  86. package/src/api/cyberia-action/cyberia-action.controller.js +1 -1
  87. package/src/api/cyberia-action/cyberia-action.router.js +1 -1
  88. package/src/api/cyberia-action/cyberia-action.service.js +2 -2
  89. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -1
  90. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +1 -1
  91. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +2 -2
  92. package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +1 -1
  93. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  94. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +2 -2
  95. package/src/api/cyberia-entity/cyberia-entity.controller.js +1 -1
  96. package/src/api/cyberia-entity/cyberia-entity.router.js +1 -1
  97. package/src/api/cyberia-entity/cyberia-entity.service.js +2 -2
  98. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +1 -1
  99. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +1 -1
  100. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +2 -2
  101. package/src/api/cyberia-instance/cyberia-fallback-capture.js +466 -0
  102. package/src/api/cyberia-instance/cyberia-fallback-world.js +8 -0
  103. package/src/api/cyberia-instance/cyberia-instance-map.service.js +1 -1
  104. package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -1
  105. package/src/api/cyberia-instance/cyberia-instance.router.js +2 -2
  106. package/src/api/cyberia-instance/cyberia-instance.service.js +2 -2
  107. package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +1 -1
  108. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +1 -1
  109. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +2 -2
  110. package/src/api/cyberia-map/cyberia-map.controller.js +1 -1
  111. package/src/api/cyberia-map/cyberia-map.router.js +1 -1
  112. package/src/api/cyberia-map/cyberia-map.service.js +2 -2
  113. package/src/api/cyberia-quest/cyberia-quest.controller.js +1 -1
  114. package/src/api/cyberia-quest/cyberia-quest.router.js +1 -1
  115. package/src/api/cyberia-quest/cyberia-quest.service.js +2 -2
  116. package/src/api/cyberia-quest-progress/cyberia-quest-progress.controller.js +1 -1
  117. package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +1 -1
  118. package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +2 -2
  119. package/src/api/cyberia-saga/cyberia-saga.controller.js +1 -1
  120. package/src/api/cyberia-saga/cyberia-saga.router.js +1 -1
  121. package/src/api/cyberia-saga/cyberia-saga.service.js +2 -2
  122. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +0 -26
  123. package/src/api/cyberia-skill/cyberia-skill.controller.js +1 -1
  124. package/src/api/cyberia-skill/cyberia-skill.router.js +1 -1
  125. package/src/api/cyberia-skill/cyberia-skill.service.js +2 -2
  126. package/src/api/default/default.controller.js +1 -1
  127. package/src/api/default/default.router.js +1 -1
  128. package/src/api/default/default.service.js +2 -2
  129. package/src/api/document/document.controller.js +1 -1
  130. package/src/api/document/document.router.js +1 -1
  131. package/src/api/document/document.service.js +2 -2
  132. package/src/api/file/file.controller.js +1 -1
  133. package/src/api/file/file.router.js +1 -1
  134. package/src/api/file/file.service.js +2 -2
  135. package/src/api/instance/instance.controller.js +1 -1
  136. package/src/api/instance/instance.router.js +1 -1
  137. package/src/api/instance/instance.service.js +2 -2
  138. package/src/api/ipfs/ipfs.controller.js +1 -1
  139. package/src/api/ipfs/ipfs.router.js +2 -2
  140. package/src/api/ipfs/ipfs.service.js +2 -2
  141. package/src/api/object-layer/object-layer.controller.js +1 -1
  142. package/src/api/object-layer/object-layer.router.js +2 -2
  143. package/src/api/object-layer/object-layer.service.js +3 -3
  144. package/src/api/object-layer-render-frames/object-layer-render-frames.controller.js +1 -1
  145. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +1 -1
  146. package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +2 -2
  147. package/src/api/test/test.controller.js +1 -1
  148. package/src/api/test/test.service.js +1 -1
  149. package/src/api/user/guest.service.js +3 -3
  150. package/src/api/user/user.controller.js +1 -1
  151. package/src/api/user/user.router.js +2 -2
  152. package/src/api/user/user.service.js +4 -4
  153. package/src/api.js +5 -9
  154. package/src/cli/app.js +391 -0
  155. package/src/cli/baremetal.js +30 -35
  156. package/src/cli/client.js +306 -0
  157. package/src/cli/cloud-init.js +3 -3
  158. package/src/cli/cluster.js +184 -82
  159. package/src/cli/db.js +120 -68
  160. package/src/cli/deploy.js +153 -71
  161. package/src/cli/docker-compose.js +5 -5
  162. package/src/cli/domains.js +184 -0
  163. package/src/cli/dotenv-store.js +143 -0
  164. package/src/cli/event.js +2281 -0
  165. package/src/cli/fs.js +3 -3
  166. package/src/cli/host.js +672 -0
  167. package/src/cli/image.js +4 -6
  168. package/src/cli/index.js +322 -142
  169. package/src/cli/ipfs.js +34 -30
  170. package/src/cli/kickstart.js +1 -1
  171. package/src/cli/kubectl.js +218 -41
  172. package/src/cli/lxd.js +3 -3
  173. package/src/cli/monitor.js +1359 -26
  174. package/src/cli/package.js +90 -0
  175. package/src/cli/release.js +16 -12
  176. package/src/cli/repository.js +558 -363
  177. package/src/cli/run.js +331 -650
  178. package/src/cli/secrets.js +2158 -964
  179. package/src/cli/ssh.js +478 -279
  180. package/src/cli/state.js +394 -0
  181. package/src/cli/static.js +2 -2
  182. package/src/cli/system.js +1 -1
  183. package/src/cli/test.js +199 -125
  184. package/src/cli/vultr.js +61 -21
  185. package/src/cli/wireguard.js +1111 -306
  186. package/src/client/components/core/CalendarCore.js +53 -96
  187. package/src/client/components/core/Docs.js +258 -211
  188. package/src/client/components/core/FileExplorer.js +0 -21
  189. package/src/client/components/core/PanelForm.js +4 -4
  190. package/src/client/components/core/Repository.js +56 -0
  191. package/src/client/components/core/Translate.js +7 -0
  192. package/src/client/components/core/{FullScreen.js → ViewModeController.js} +42 -39
  193. package/src/client/components/core/Wallet.js +0 -11
  194. package/src/client/components/core/Worker.js +7 -1
  195. package/src/client/components/cryptokoyn/SettingsCryptokoyn.js +2 -2
  196. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +2 -1
  197. package/src/client/components/cyberia-portal/SettingsCyberiaPortal.js +2 -2
  198. package/src/client/components/default/AppShellDefault.js +1 -1
  199. package/src/client/components/default/SettingsDefault.js +2 -2
  200. package/src/client/components/itemledger/SettingsItemledger.js +2 -2
  201. package/src/client/components/underpost/AppShellUnderpost.js +3 -2
  202. package/src/client/components/underpost/SettingsUnderpost.js +2 -2
  203. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +32 -5
  204. package/src/client/public/cyberia-docs/CYBERIA.md +21 -1
  205. package/src/client/public/cyberia-docs/HARDHAT-MODULE.md +23 -6
  206. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  207. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  208. package/src/client/public/default/android-chrome-144x144.png +0 -0
  209. package/src/client/public/default/android-chrome-192x192.png +0 -0
  210. package/src/client/public/default/android-chrome-256x256.png +0 -0
  211. package/src/client/public/default/android-chrome-36x36.png +0 -0
  212. package/src/client/public/default/android-chrome-384x384.png +0 -0
  213. package/src/client/public/default/android-chrome-48x48.png +0 -0
  214. package/src/client/public/default/android-chrome-512x512.png +0 -0
  215. package/src/client/public/default/android-chrome-72x72.png +0 -0
  216. package/src/client/public/default/android-chrome-96x96.png +0 -0
  217. package/src/client/public/default/apple-touch-icon-1024x1024.png +0 -0
  218. package/src/client/public/default/apple-touch-icon-114x114.png +0 -0
  219. package/src/client/public/default/apple-touch-icon-120x120.png +0 -0
  220. package/src/client/public/default/apple-touch-icon-144x144.png +0 -0
  221. package/src/client/public/default/apple-touch-icon-152x152.png +0 -0
  222. package/src/client/public/default/apple-touch-icon-167x167.png +0 -0
  223. package/src/client/public/default/apple-touch-icon-180x180.png +0 -0
  224. package/src/client/public/default/apple-touch-icon-57x57.png +0 -0
  225. package/src/client/public/default/apple-touch-icon-60x60.png +0 -0
  226. package/src/client/public/default/apple-touch-icon-72x72.png +0 -0
  227. package/src/client/public/default/apple-touch-icon-76x76.png +0 -0
  228. package/src/client/public/default/apple-touch-icon-precomposed.png +0 -0
  229. package/src/client/public/default/apple-touch-icon.png +0 -0
  230. package/src/client/public/default/apple-touch-startup-image-1125x2436.png +0 -0
  231. package/src/client/public/default/apple-touch-startup-image-1136x640.png +0 -0
  232. package/src/client/public/default/apple-touch-startup-image-1170x2532.png +0 -0
  233. package/src/client/public/default/apple-touch-startup-image-1179x2556.png +0 -0
  234. package/src/client/public/default/apple-touch-startup-image-1242x2208.png +0 -0
  235. package/src/client/public/default/apple-touch-startup-image-1242x2688.png +0 -0
  236. package/src/client/public/default/apple-touch-startup-image-1284x2778.png +0 -0
  237. package/src/client/public/default/apple-touch-startup-image-1290x2796.png +0 -0
  238. package/src/client/public/default/apple-touch-startup-image-1334x750.png +0 -0
  239. package/src/client/public/default/apple-touch-startup-image-1488x2266.png +0 -0
  240. package/src/client/public/default/apple-touch-startup-image-1536x2048.png +0 -0
  241. package/src/client/public/default/apple-touch-startup-image-1620x2160.png +0 -0
  242. package/src/client/public/default/apple-touch-startup-image-1640x2160.png +0 -0
  243. package/src/client/public/default/apple-touch-startup-image-1668x2224.png +0 -0
  244. package/src/client/public/default/apple-touch-startup-image-1668x2388.png +0 -0
  245. package/src/client/public/default/apple-touch-startup-image-1792x828.png +0 -0
  246. package/src/client/public/default/apple-touch-startup-image-2048x1536.png +0 -0
  247. package/src/client/public/default/apple-touch-startup-image-2048x2732.png +0 -0
  248. package/src/client/public/default/apple-touch-startup-image-2160x1620.png +0 -0
  249. package/src/client/public/default/apple-touch-startup-image-2160x1640.png +0 -0
  250. package/src/client/public/default/apple-touch-startup-image-2208x1242.png +0 -0
  251. package/src/client/public/default/apple-touch-startup-image-2224x1668.png +0 -0
  252. package/src/client/public/default/apple-touch-startup-image-2266x1488.png +0 -0
  253. package/src/client/public/default/apple-touch-startup-image-2388x1668.png +0 -0
  254. package/src/client/public/default/apple-touch-startup-image-2436x1125.png +0 -0
  255. package/src/client/public/default/apple-touch-startup-image-2532x1170.png +0 -0
  256. package/src/client/public/default/apple-touch-startup-image-2556x1179.png +0 -0
  257. package/src/client/public/default/apple-touch-startup-image-2688x1242.png +0 -0
  258. package/src/client/public/default/apple-touch-startup-image-2732x2048.png +0 -0
  259. package/src/client/public/default/apple-touch-startup-image-2778x1284.png +0 -0
  260. package/src/client/public/default/apple-touch-startup-image-2796x1290.png +0 -0
  261. package/src/client/public/default/apple-touch-startup-image-640x1136.png +0 -0
  262. package/src/client/public/default/apple-touch-startup-image-750x1334.png +0 -0
  263. package/src/client/public/default/apple-touch-startup-image-828x1792.png +0 -0
  264. package/src/client/public/default/assets/background/white.jpg +0 -0
  265. package/src/client/public/default/browserconfig.xml +1 -1
  266. package/src/client/public/default/favicon-16x16.png +0 -0
  267. package/src/client/public/default/favicon-32x32.png +0 -0
  268. package/src/client/public/default/favicon-48x48.png +0 -0
  269. package/src/client/public/default/favicon.ico +0 -0
  270. package/src/client/public/default/manifest.webmanifest +5 -5
  271. package/src/client/public/default/mstile-144x144.png +0 -0
  272. package/src/client/public/default/mstile-150x150.png +0 -0
  273. package/src/client/public/default/mstile-310x150.png +0 -0
  274. package/src/client/public/default/mstile-310x310.png +0 -0
  275. package/src/client/public/default/mstile-70x70.png +0 -0
  276. package/src/client/public/default/yandex-browser-50x50.png +0 -0
  277. package/src/client/public/default/yandex-browser-manifest.json +1 -1
  278. package/src/client/ssr/RootDocument.js +1 -1
  279. package/src/client/ssr/body/404.js +1 -1
  280. package/src/client/ssr/body/500.js +1 -1
  281. package/src/client/ssr/body/CacheControl.js +1 -1
  282. package/src/client/ssr/body/CyberiaDefaultSplashScreen.js +1 -1
  283. package/src/client/ssr/body/DefaultSplashScreen.js +1 -1
  284. package/src/client/ssr/body/SwaggerDarkMode.js +1 -1
  285. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +1 -1
  286. package/src/client/ssr/head/CryptokoynScripts.js +1 -1
  287. package/src/client/ssr/head/Css.js +1 -1
  288. package/src/client/ssr/head/CyberiaPortalScripts.js +1 -1
  289. package/src/client/ssr/head/DefaultScripts.js +1 -1
  290. package/src/client/ssr/head/ItemledgerScripts.js +1 -1
  291. package/src/client/ssr/head/Microdata.js +1 -1
  292. package/src/client/ssr/head/Production.js +1 -1
  293. package/src/client/ssr/head/Pwa.js +1 -1
  294. package/src/client/ssr/head/PwaItemledger.js +1 -1
  295. package/src/client/ssr/head/Seo.js +1 -1
  296. package/src/client/ssr/head/UnderpostScripts.js +1 -1
  297. package/src/client/ssr/mailer/DefaultRecoverEmail.js +1 -1
  298. package/src/client/ssr/mailer/DefaultVerifyEmail.js +1 -1
  299. package/src/client/ssr/views/Cyberia404.js +7 -5
  300. package/src/client/ssr/views/CyberiaServerMetrics.js +1 -1
  301. package/src/client/ssr/views/Maintenance.js +1 -1
  302. package/src/client/ssr/views/NoNetworkConnection.js +1 -1
  303. package/src/client/ssr/views/Test.js +1 -1
  304. package/src/client/sw/core.sw.js +20 -14
  305. package/src/client-builder/client-build-docs.js +61 -46
  306. package/src/client-builder/client-build-live.js +2 -2
  307. package/src/client-builder/client-build.js +38 -30
  308. package/src/client-builder/client-dev-server.js +3 -3
  309. package/src/client-builder/client-icons.js +2 -2
  310. package/src/client-builder/ssr.js +3 -3
  311. package/src/client.build.js +3 -6
  312. package/src/client.dev.js +3 -6
  313. package/src/db/DataBaseProvider.js +186 -3
  314. package/src/db/mariadb/MariaDB.js +3 -2
  315. package/src/db/mongo/MongoBootstrap.js +273 -101
  316. package/src/db/mongo/MongooseDB.js +7 -8
  317. package/src/{server/valkey.js → db/valkey/Valkey.js} +5 -4
  318. package/src/grpc/cyberia/grpc-server.js +1 -1
  319. package/src/index.js +79 -23
  320. package/src/mailer/EmailRender.js +2 -2
  321. package/src/mailer/MailerInterceptor.js +87 -0
  322. package/src/mailer/MailerProvider.js +50 -6
  323. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +1 -1
  324. package/src/projects/cyberia/besu-genesis-generator.js +3 -3
  325. package/src/projects/cyberia/catalog-cyberia.js +53 -10
  326. package/src/projects/cyberia/gemini-client.js +1 -1
  327. package/src/projects/cyberia/generate-saga.js +1 -1
  328. package/src/projects/cyberia/hot-reload-trigger.js +1 -1
  329. package/src/projects/cyberia/instance-data.js +1 -1
  330. package/src/projects/cyberia/ipfs-client.js +4 -4
  331. package/src/projects/cyberia/map-preview-generator.js +1 -1
  332. package/src/projects/cyberia/object-layer.js +1 -1
  333. package/src/projects/cyberia/semantic-layer-generator.js +1 -1
  334. package/src/projects/underpost/catalog-underpost.js +9 -1
  335. package/src/proxy.js +4 -7
  336. package/src/runtime/cyberia-client/Dockerfile +9 -4
  337. package/src/runtime/cyberia-client/Dockerfile.dev +8 -2
  338. package/src/runtime/cyberia-server/Dockerfile +9 -4
  339. package/src/runtime/cyberia-server/Dockerfile.dev +8 -2
  340. package/src/runtime/engine-cyberia/Dockerfile +25 -9
  341. package/src/runtime/engine-cyberia/Dockerfile.dev +25 -9
  342. package/src/runtime/engine-cyberia/Dockerfile.test +4 -4
  343. package/src/runtime/engine-cyberia/docker-compose.yml +7 -7
  344. package/src/runtime/express/Express.js +32 -27
  345. package/src/runtime/lampp/Lampp.js +22 -70
  346. package/src/runtime/nginx/Nginx.js +1 -1
  347. package/src/runtime/wp/Wp.js +16 -48
  348. package/src/server/{catalog.js → build/catalog.js} +15 -5
  349. package/src/server/build/coverage.js +117 -0
  350. package/src/server/build/execution.js +402 -0
  351. package/src/server/build/package.js +465 -0
  352. package/src/server/build/testing.js +528 -0
  353. package/src/server/{dns.js → network/dns.js} +144 -11
  354. package/src/server/{forward-proxy.js → network/forward-proxy.js} +10 -18
  355. package/src/server/{middlewares.js → network/middlewares.js} +3 -3
  356. package/src/server/network/node-capability.js +98 -0
  357. package/src/server/{peer.js → network/peer.js} +3 -3
  358. package/src/server/{proxy.js → network/proxy.js} +7 -13
  359. package/src/server/network/router.js +356 -0
  360. package/src/server/{tls.js → network/tls.js} +2 -2
  361. package/src/server/{underpost-compression.js → network/underpost-compression.js} +1 -1
  362. package/src/server/{underpost-gateway.js → network/underpost-gateway.js} +7 -7
  363. package/src/server/{underpost-ingress.js → network/underpost-ingress.js} +2 -2
  364. package/src/server/{cri.js → ops/cri.js} +2 -2
  365. package/src/server/{cron.js → ops/cron.js} +349 -74
  366. package/src/server/ops/event-notification.js +284 -0
  367. package/src/server/{logger.js → ops/logger.js} +108 -23
  368. package/src/server/ops/monitoring.js +1724 -0
  369. package/src/server/{systemd.js → ops/systemd.js} +55 -1
  370. package/src/server/{conf.js → runtime/conf.js} +513 -422
  371. package/src/server/runtime/config-scope.js +215 -0
  372. package/src/server/runtime/environment.js +149 -0
  373. package/src/server/{process.js → runtime/process.js} +153 -12
  374. package/src/server/{runtime-status.js → runtime/runtime-status.js} +155 -16
  375. package/src/server/{runtime.js → runtime/runtime.js} +12 -8
  376. package/src/server/{start.js → runtime/start.js} +128 -39
  377. package/src/server/{auth.js → security/auth.js} +7 -8
  378. package/src/server/security/container-storage.js +382 -0
  379. package/src/server/{crypto.js → security/crypto.js} +1 -1
  380. package/src/server/{selinux.js → security/selinux.js} +1 -1
  381. package/src/server/{backup.js → storage/backup.js} +17 -8
  382. package/src/server/{data-query.js → storage/data-query.js} +1 -1
  383. package/src/server/{downloader.js → storage/downloader.js} +2 -2
  384. package/src/server/storage/repository.js +68 -0
  385. package/src/server.js +4 -7
  386. package/src/ws/IoInterface.js +1 -1
  387. package/src/ws/IoServer.js +1 -1
  388. package/src/ws/core/core.ws.connection.js +1 -1
  389. package/src/ws/core/core.ws.emit.js +1 -1
  390. package/src/ws/core/core.ws.server.js +1 -1
  391. package/src/ws/default/default.ws.connection.js +1 -1
  392. package/src/ws/default/default.ws.emit.js +1 -1
  393. package/src/ws/default/default.ws.server.js +1 -1
  394. package/test/e2e/event-e2e-public-ingress-down.js +45 -0
  395. package/test/e2e/event-e2e-wireguard-server-down.js +38 -0
  396. package/test/e2e/event-e2e-wireguard-spoke-down.js +40 -0
  397. package/test/{api.test.js → integration/app/api.test.js} +33 -14
  398. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +30 -0
  399. package/test/{cyberia-instance-conf-defaults.test.js → integration/app/cyberia/cyberia-instance-conf-defaults.test.js} +1 -1
  400. package/test/integration/app/cyberia/cyberia-load.test.js +467 -0
  401. package/test/integration/app/cyberia/fallback-world-capture.test.js +253 -0
  402. package/test/integration/app/cyberia/object-layer-item-id.test.js +327 -0
  403. package/test/{shape-generator.test.js → integration/app/cyberia/shape-generator.test.js} +1 -1
  404. package/test/integration/infra/1-security/config-scope.test.js +305 -0
  405. package/test/integration/infra/1-security/container-storage.test.js +356 -0
  406. package/test/integration/infra/1-security/in-pod-cli-surface.test.js +86 -0
  407. package/test/integration/infra/1-security/secret-onboarding.test.js +930 -0
  408. package/test/{selinux.test.js → integration/infra/1-security/selinux.test.js} +2 -4
  409. package/test/{sops-secret-store.test.js → integration/infra/1-security/sops-secret-store.test.js} +415 -61
  410. package/test/integration/infra/1-security/systemd-service.test.js +70 -0
  411. package/test/integration/infra/1-security/underpost-config-secret.test.js +367 -0
  412. package/test/integration/infra/2-network/dns-firewall.test.js +566 -0
  413. package/test/integration/infra/2-network/node-capability.test.js +182 -0
  414. package/test/integration/infra/2-network/wireguard-cli.test.js +1163 -0
  415. package/test/{wireguard-edge.test.js → integration/infra/2-network/wireguard-edge.test.js} +609 -55
  416. package/test/{cluster-instances.test.js → integration/infra/3-cluster/cluster-instances.test.js} +11 -15
  417. package/test/{deploy-node-placement.test.js → integration/infra/3-cluster/deploy-node-placement.test.js} +3 -3
  418. package/test/integration/infra/3-cluster/docker-compose-stack.test.js +313 -0
  419. package/test/integration/infra/4-ingress/deploy-routes.test.js +379 -0
  420. package/test/integration/infra/4-ingress/gateway-static-assets.test.js +453 -0
  421. package/test/{instance-traffic-plan.test.js → integration/infra/4-ingress/instance-traffic-plan.test.js} +10 -6
  422. package/test/{underpost-gateway.test.js → integration/infra/4-ingress/underpost-gateway.test.js} +6 -6
  423. package/test/{underpost-ingress.test.js → integration/infra/4-ingress/underpost-ingress.test.js} +4 -4
  424. package/test/integration/infra/5-observability/cron-jobs.test.js +652 -0
  425. package/test/{deploy-monitor.test.js → integration/infra/5-observability/deploy-monitor.test.js} +29 -31
  426. package/test/integration/infra/5-observability/event-notification.test.js +197 -0
  427. package/test/integration/infra/5-observability/event-remediation.test.js +502 -0
  428. package/test/integration/infra/5-observability/event-targets.test.js +1174 -0
  429. package/test/integration/infra/5-observability/monitoring-stack.test.js +902 -0
  430. package/test/support/shell-harness.js +73 -0
  431. package/test/unit/build-template.test.js +97 -0
  432. package/test/unit/build-workflow-offline.test.js +132 -0
  433. package/test/unit/catalog.test.js +71 -0
  434. package/test/unit/client-build-docs.test.js +334 -0
  435. package/test/unit/conf-loading.test.js +303 -0
  436. package/test/unit/conf-resolution.test.js +979 -0
  437. package/test/unit/coverage-artifact.test.js +130 -0
  438. package/test/{crypto.test.js → unit/crypto.test.js} +1 -1
  439. package/test/unit/deploy-env-resolution.test.js +38 -0
  440. package/test/unit/deploy-log-table.test.js +447 -0
  441. package/test/unit/execution-profiles.test.js +219 -0
  442. package/test/unit/fleet-sync-source.test.js +149 -0
  443. package/test/unit/logger-redaction.test.js +112 -0
  444. package/test/unit/package.test.js +403 -0
  445. package/test/unit/prepare-host.test.js +299 -0
  446. package/test/unit/process-environment.test.js +18 -0
  447. package/test/unit/propagation-message.test.js +78 -0
  448. package/test/unit/redeploy-plan.test.js +24 -0
  449. package/test/unit/release-bump.test.js +474 -0
  450. package/test/unit/ssh-output-redaction.test.js +28 -0
  451. package/test/unit/start-options.test.js +46 -0
  452. package/test/unit/test-tiers.test.js +193 -0
  453. package/vitest.config.js +58 -0
  454. package/.nycrc +0 -9
  455. package/deploy/lib/logging.sh +0 -96
  456. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +0 -48
  457. package/manifests/mongodb/pv-pvc.yaml +0 -59
  458. package/scripts/coverall-test-cyberia.sh +0 -24
  459. package/scripts/coverall-test.sh +0 -24
  460. package/scripts/link-local-underpost-cli.sh +0 -6
  461. package/src/api/user/user.build.js +0 -16
  462. package/src/cli/env.js +0 -177
  463. package/src/client/components/core/Blockchain.js +0 -41
  464. package/src/client/components/core/Blog.js +0 -9
  465. package/src/client/components/core/KeyboardAvoidance.js +0 -145
  466. package/src/client/public/default/assets/background/dark.jpg +0 -0
  467. package/src/client/public/default/assets/background/white0-min.jpg +0 -0
  468. package/src/client/public/default/assets/background/white0.jpg +0 -0
  469. package/src/client/public/doc/favicon.ico +0 -0
  470. package/src/client/public/doc/sitemap +0 -148
  471. package/src/server/environment.js +0 -98
@@ -4,50 +4,63 @@
4
4
  * @namespace UnderpostSecret
5
5
  */
6
6
 
7
- import { shellExec } from '../server/process.js';
7
+ import { shellExec } from '../server/runtime/process.js';
8
+ import { generateRandomPasswordSelection } from '../client/components/core/CommonJs.js';
8
9
  import fs from 'fs-extra';
9
10
  import os from 'os';
10
11
  import dotenv from 'dotenv';
11
12
  import Underpost from '../index.js';
12
- import { loadConf } from '../server/conf.js';
13
- import { loggerFactory } from '../server/logger.js';
13
+ import { domainContextFactory } from './domains.js';
14
+ import { readDeployRoutes, resolveDeployList } from '../server/network/router.js';
15
+ import { loggerFactory } from '../server/ops/logger.js';
16
+ import { activeExecutionProfile } from '../server/build/execution.js';
17
+ import { scopeValuesFactory } from '../server/runtime/config-scope.js';
14
18
 
15
19
  const logger = loggerFactory(import.meta);
16
20
 
17
- // Git-tracked store of SOPS/Age encrypted Kubernetes Secret manifests, laid out as
18
- // `<SOPS_SECRETS_DIR>/<namespace>/<name>.enc.yaml`. Lives inside the per-deploy private
19
- // repo so encrypted manifests version alongside the conf they belong to. Only the
20
- // ciphertext is committed; the Age private key never enters this tree.
21
21
  const SOPS_SECRETS_DIR = './engine-private/secrets';
22
22
  const SOPS_MANIFEST_EXT = '.enc.yaml';
23
- // `creation_rules[].path_regex` is matched against the manifest path **relative to the directory
24
- // holding .sops.yaml**, not the repo root. Since .sops.yaml lives at the store root, an
25
- // `engine-private/secrets/` prefix here can never match — sops sees `<namespace>/<name>.enc.yaml`
26
- // and reports "no matching creation rules found".
27
23
  const SOPS_MANIFEST_PATH_REGEX = `.*${SOPS_MANIFEST_EXT.replace(/\./g, '\\.')}$`;
28
- // Purged manifests are moved here rather than deleted, so an emergency purge stays
29
- // reversible. Dot-prefixed so it is never mistaken for a namespace directory.
24
+ const SOPS_STAGE_DIR = './engine-private/secrets/.stage';
30
25
  const SOPS_ARCHIVE_DIR = `${SOPS_SECRETS_DIR}/.archive`;
31
- // Encrypting only the value tree under `data`/`stringData` keeps apiVersion/kind/metadata
32
- // readable, so kubectl, kustomize, and code review still work on the encrypted file.
33
26
  const SOPS_ENCRYPTED_REGEX = '^(data|stringData)$';
34
- // Pinned SOPS/Age release binaries. Neither has an RHEL package, so both are installed
35
- // from upstream static builds. Single source of truth for host provisioning: both
36
- // `underpost secret --install-tools` and `underpost cluster --init-host` resolve here.
37
27
  const SOPS_VERSION = 'v3.10.2';
38
28
  const AGE_VERSION = 'v1.2.1';
39
- // Origin seed paths: the plaintext credential files a secret is seeded from before SOPS/Age
40
- // onboarding, and the path cluster init falls back to when no encrypted manifest exists.
41
- // Keyed by Secret name, then by the Secret data key each file supplies — stated explicitly
42
- // rather than derived from the filename, because `mongodb-keyfile` supplies a key of that same
43
- // full name while `postgresql-password` supplies `password`.
44
- // Mirrors the seed-fallback branches in UnderpostCluster.API.init() and
45
- // MongoBootstrap.ensureMongoSecrets(); kept here so a purge can report whether the cluster
46
- // still has a working seed path for that secret.
47
- // Every Secret whose creation goes through `applyIfPresent` — i.e. the encrypted store is
48
- // consulted first and the origin seed path is the fallback. Single source of truth for coverage
49
- // reporting; keep in step with the call sites in UnderpostCluster.API.init(),
50
- // MongoBootstrap.ensureMongoSecrets(), and UnderpostIPFS.applySecrets().
29
+
30
+ // The GitHub-side credential CI workflows and cross-repository checkouts authenticate with. The
31
+ // stored manifest keeps this file's kebab-case naming, while the data key keeps the environment
32
+ // spelling so a workload consumes it through `envFrom` unchanged. Deliberately absent from
33
+ // MANAGED_SECRETS: its authoritative home is the GitHub Actions secret store, and the encrypted
34
+ // manifest is an optional mirror rather than the origin.
35
+ const GIT_AUTH_TOKEN_KEY = 'GIT_AUTH_TOKEN';
36
+ const GIT_AUTH_TOKEN_SECRET = 'git-auth-token';
37
+ const GIT_AUTH_TOKEN_STAGE_DIR = '/dev/shm/underpost-git-auth';
38
+
39
+ // The second meta id alongside `dd`. The template lineage is not a deploy, so it carries no conf
40
+ // id and resolves to its own repositories rather than through the `engine-<conf-id>` naming.
41
+ const TEMPLATE_ALIAS = 'template';
42
+ const TEMPLATE_REPOS = ['pwa-microservices-template', 'pwa-microservices-template-ghpkg', 'engine'];
43
+ // Plaintext staging lives on tmpfs and is created at its final mode rather than created and then
44
+ // chmod'ed: between the `mkdir`/`open` and the `chmod` the entry exists at the process umask
45
+ // (0755 / 0644), and that window is enough for any local user to read a credential. The mode
46
+ // argument is only masked by umask, never widened, so it is safe under any umask.
47
+ const STAGE_DIR_MODE = 0o700;
48
+ const STAGE_FILE_MODE = 0o600;
49
+
50
+ const stageDirSync = (path) => {
51
+ fs.mkdirSync(path, { recursive: true, mode: STAGE_DIR_MODE });
52
+ // A directory left behind by an interrupted run keeps whatever mode it had, so tighten it —
53
+ // guarded, because `mkdirSync` is a no-op for a path that already exists and the guard is what
54
+ // keeps this from throwing where the filesystem is not the real one.
55
+ if (fs.existsSync(path)) fs.chmodSync(path, STAGE_DIR_MODE);
56
+ return path;
57
+ };
58
+
59
+ const writeStageFileSync = (path, value) => {
60
+ fs.writeFileSync(path, value, { mode: STAGE_FILE_MODE });
61
+ return path;
62
+ };
63
+
51
64
  const MANAGED_SECRETS = [
52
65
  'postgres-secret',
53
66
  'mariadb-secret',
@@ -55,25 +68,80 @@ const MANAGED_SECRETS = [
55
68
  'mongodb-secret',
56
69
  'mongodb-keyfile',
57
70
  'ipfs-cluster-secret',
71
+ 'grafana-admin',
58
72
  ];
59
- const ORIGIN_SEED_SOURCES = {
60
- 'mariadb-secret': {
61
- username: './engine-private/mariadb-username',
62
- password: './engine-private/mariadb-password',
63
- },
64
- 'mysql-secret': {
65
- username: './engine-private/mysql-username',
66
- password: './engine-private/mysql-password',
73
+ // Origin credentials for the workload secret system, the first link in
74
+ // `origin secret -> SOPS/Age -> Kubernetes Secret -> workload`. They live in the deploy
75
+ // secret area alongside the other deploy-scoped material (SSH keys, node and route lists).
76
+ const DEPLOY_SECRET_DIR = './engine-private/deploy';
77
+ const LEGACY_SEED_DIR = './engine-private';
78
+ // A data key maps either to a whole file, or to `{ file, json }` when the credential is one
79
+ // field of a structured origin — the IPFS peer private key lives inside the identity document
80
+ // next to the peer id, which is public and belongs in a ConfigMap rather than a Secret.
81
+ const ORIGIN_SEED_FILES = {
82
+ 'mariadb-secret': { username: 'mariadb-username', password: 'mariadb-password' },
83
+ 'mysql-secret': { username: 'mysql-username', password: 'mysql-password' },
84
+ 'postgres-secret': { password: 'postgresql-password' },
85
+ 'mongodb-secret': { username: 'mongodb-username', password: 'mongodb-password' },
86
+ // Shared replica-set auth keyfile, mounted as a volume rather than injected as env.
87
+ 'mongodb-keyfile': { 'mongodb-keyfile': 'mongodb-keyfile' },
88
+ 'ipfs-cluster-secret': {
89
+ 'cluster-secret': 'ipfs-cluster-secret',
90
+ 'bootstrap-peer-priv-key': { file: 'ipfs-cluster-identity.json', json: 'private_key' },
67
91
  },
68
- 'postgres-secret': { password: './engine-private/postgresql-password' },
69
- 'mongodb-secret': {
70
- username: './engine-private/mongodb-username',
71
- password: './engine-private/mongodb-password',
92
+ // Optional: when these files are absent the values fall back to the environment the host CLI
93
+ // already carries (see SECRET_ENV_KEYS), which is where they live today.
94
+ 'underpost-cron-env': { GITHUB_TOKEN: 'github-token', GITHUB_USERNAME: 'github-username' },
95
+ // The connection key the cron workloads SSH out with, mounted as a volume rather than injected
96
+ // as env: ssh authenticates with a file, and a private key in the environment is readable from
97
+ // every process listing and every log that dumps it.
98
+ 'underpost-ssh-key': { 'id-rsa': 'id_rsa' },
99
+ };
100
+
101
+ /** Normalizes a registry entry to `{ file, json }`. */
102
+ const seedDescriptor = (entry) => (typeof entry === 'string' ? { file: entry, json: '' } : { json: '', ...entry });
103
+
104
+ /**
105
+ * Resolves an origin seed file, preferring the deploy secret area and falling back to the
106
+ * pre-move location.
107
+ *
108
+ * The fallback is what keeps `setup` idempotent on a host onboarded before the move: without
109
+ * it the existing credential would look absent and a second one would be generated, encrypted
110
+ * and applied over the password the running data tier is already using.
111
+ * @param {string} fileName - Seed file basename.
112
+ * @returns {string} Path to read the origin credential from.
113
+ * @memberof UnderpostSecret
114
+ */
115
+ const originSeedPath = (fileName) => {
116
+ const current = `${DEPLOY_SECRET_DIR}/${fileName}`;
117
+ if (fs.existsSync(current)) return current;
118
+ const legacy = `${LEGACY_SEED_DIR}/${fileName}`;
119
+ return fs.existsSync(legacy) ? legacy : current;
120
+ };
121
+ const SECRET_ENV_KEYS = {
122
+ 'grafana-admin': {
123
+ 'admin-user': 'GF_SECURITY_ADMIN_USER',
124
+ 'admin-password': 'GF_SECURITY_ADMIN_PASSWORD',
72
125
  },
73
- // Shared replica-set auth keyfile, mounted as a volume rather than injected as env.
74
- 'mongodb-keyfile': { 'mongodb-keyfile': './engine-private/mongodb-keyfile' },
126
+ // Credentials the cron workloads consume as plain environment variables. They previously
127
+ // reached the CronJob pods by bind-mounting the operator's global underpost directory out of
128
+ // root's home, which no unprivileged container can read once SELinux is Enforcing — and which
129
+ // exposed far more of that tree than the two values actually needed.
75
130
  };
76
131
 
132
+ /**
133
+ * Secrets whose key set is a configuration scope rather than a fixed list.
134
+ *
135
+ * The cron workloads receive their whole runtime environment this way, so the Secret has to say
136
+ * *which* environment rather than enumerate it: a hand-written list drifts from the jobs that read
137
+ * it, and every key it gains is a key nobody reviewed. {@link scopeValuesFactory} answers it from
138
+ * {@link ConfigScope.CONFIG_OWNERSHIP}, which is where widening a workload's access is a visible
139
+ * edit. Everything outside the scope — the provisioning credentials, the registry identities, the
140
+ * deployments' own databases — is not in the projection and cannot be.
141
+ * @memberof UnderpostSecret
142
+ */
143
+ const SECRET_ENV_SCOPES = Object.freeze({ 'underpost-cron-env': 'cron' });
144
+
77
145
  // Shell/runtime-critical and Kubernetes-injected env keys that must never be persisted as
78
146
  // application secrets nor injected into a pod via `envFrom`. An injected PATH (or HOME, etc.)
79
147
  // overrides the container image's own and breaks coreutils/sudo resolution inside the pod
@@ -123,8 +191,65 @@ const RESERVED_ENV_KEYS = new Set([
123
191
  'which_declare',
124
192
  ]);
125
193
  const RESERVED_ENV_KEY_PREFIXES = ['KUBERNETES_', 'npm_', 'NODE_'];
194
+ // `NODE_` covers the image's own NODE_VERSION/NODE_OPTIONS, but NODE_ENV is the deployment
195
+ // environment itself: the value `underpost-config` exists to carry onto the node, and the one
196
+ // `loadConf` reads to pick conf.*.json and .env.<env>. Stripping it made every deploy fall back
197
+ // to `development` regardless of the requested environment.
198
+ const PRESERVED_ENV_KEYS = new Set(['NODE_ENV']);
126
199
  const isReservedEnvKey = (key) =>
127
- RESERVED_ENV_KEYS.has(key) || RESERVED_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
200
+ !PRESERVED_ENV_KEYS.has(key) &&
201
+ (RESERVED_ENV_KEYS.has(key) || RESERVED_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)));
202
+
203
+ // Secrets `setup` onboards when no explicit list is passed: the full self-hosted data tier.
204
+ // `mongodb-keyfile` is listed alongside `mongodb-secret` because the MongoDB StatefulSet mounts
205
+ // it as a volume for intra-replica-set auth and will not start without it, so onboarding the
206
+ // credentials alone would leave Mongo broken.
207
+ const SOPS_SETUP_DEFAULT_SECRETS = ['postgres-secret', 'mariadb-secret', 'mongodb-secret', 'mongodb-keyfile'];
208
+
209
+ /**
210
+ * Produces a value for a Secret data key that has no origin seed file and no `--args` override.
211
+ * Key-aware because the data tier does not want one shape of secret: a replica-set keyfile is a
212
+ * long base64 blob, a username is an identifier, and everything else is a password.
213
+ * @param {string} key - Secret data key (e.g. 'password', 'username', 'mongodb-keyfile').
214
+ * @returns {string} Generated value.
215
+ * @memberof UnderpostSecret
216
+ */
217
+ const generateSeedValue = (key) => {
218
+ if (key === 'username' || key === 'admin-user') return 'admin';
219
+ // MongoDB keyfile: 6-1024 base64 characters shared by every replica-set member. Newlines are
220
+ // stripped so the value round-trips identically through YAML and through
221
+ // MongoBootstrap.readCredential, which strips them too.
222
+ if (key === 'mongodb-keyfile')
223
+ return shellExec(`openssl rand -base64 756`, { stdout: true, silent: true, disableLog: true }).replace(
224
+ /\r?\n/g,
225
+ '',
226
+ );
227
+ return generateRandomPasswordSelection(24);
228
+ };
229
+
230
+ /**
231
+ * Whether a value carries the shape of a GitHub personal access, OAuth, app or refresh token.
232
+ * Advisory only: GitHub has changed token formats before, so an unrecognized shape warns rather
233
+ * than blocks a rotation the operator has already decided on.
234
+ * @param {string} token - Candidate token.
235
+ * @returns {boolean} True when the value matches a known GitHub token prefix and length.
236
+ * @memberof UnderpostSecret
237
+ */
238
+ /**
239
+ * Whether fd 0 carries piped or redirected data, as opposed to a terminal or `/dev/null`.
240
+ * @returns {boolean} True when stdin can be read to EOF without blocking on a user.
241
+ * @memberof UnderpostSecret
242
+ */
243
+ const stdinIsRedirected = () => {
244
+ try {
245
+ const stat = fs.fstatSync(0);
246
+ return stat.isFIFO() || stat.isFile();
247
+ } catch {
248
+ return false;
249
+ }
250
+ };
251
+
252
+ const looksLikeGitHubToken = (token) => /^(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})$/.test(token);
128
253
 
129
254
  /**
130
255
  * @class UnderpostSecret
@@ -133,45 +258,200 @@ const isReservedEnvKey = (key) =>
133
258
  */
134
259
  class UnderpostSecret {
135
260
  static API = {
261
+ // ── canonical domain actions ──────────────────────────────────────────────────────────
262
+ // The seven verbs every domain implements, taking the same normalized context. Everything
263
+ // below them in this file is implementation the CLI no longer reaches directly: tooling
264
+ // install and key generation are automated inside `setup`, and the per-secret operations
265
+ // are addressed through `--args` rather than through flags of their own.
266
+
136
267
  /**
137
- * @method underpost
138
- * @description Manages the secrets of the application.
268
+ * Onboards the workload secret store end to end: installs the SOPS and Age binaries,
269
+ * generates the key and creation rules, encrypts the managed Secrets into the Git-tracked
270
+ * store, then validates and applies them.
271
+ *
272
+ * Every step is idempotent. Notably it never regenerates a credential that already exists —
273
+ * an origin seed file is read through rather than replaced, so re-running this on a live
274
+ * cluster cannot rotate the password the data tier is running on.
275
+ * @param {object} context - Normalized domain context. `--args names=a,b` narrows the set.
276
+ * @returns {*} The onboarding report.
139
277
  * @memberof UnderpostSecret
140
278
  */
141
- underpost: {
142
- /**
143
- * @method createFromEnvFile
144
- * @description Reads application secrets from a .env file and writes them to the underpost .env file. Used for local development and testing.
145
- * @param {string} envPath - The path to the .env file to read secrets from. Defaults to './.env'.
146
- * @memberof UnderpostSecret
147
- */
148
- createFromEnvFile(envPath = './.env') {
149
- Underpost.env.clean();
150
- const envObj = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
151
- for (const key of Object.keys(envObj)) {
152
- Underpost.env.set(key, envObj[key]);
153
- }
154
- },
155
- /**
156
- * @method createFromContainerEnv
157
- * @description Reads application secrets from process.env (injected via envFrom: secretRef)
158
- * and writes them to the underpost .env file, filtering out known system and
159
- * Kubernetes-injected environment variables. Replaces the fragile shell-based
160
- * `printenv | grep -vE` pattern with a maintainable Node.js blocklist.
161
- * @memberof UnderpostSecret
162
- */
163
- createFromContainerEnv() {
164
- Underpost.env.clean();
165
- for (const [key, value] of Object.entries(process.env)) {
166
- if (isReservedEnvKey(key)) continue;
167
- Underpost.env.set(key, value);
168
- }
169
- },
279
+ setup(context = {}) {
280
+ context = domainContextFactory(context);
281
+ return Underpost.secret.setupStore(`${context.args.names ?? ''}`, {
282
+ namespace: context.namespace,
283
+ dryRun: context.dryRun,
284
+ force: context.force,
285
+ args: Object.entries(context.args)
286
+ .filter(([key]) => !['names', 'keys'].includes(key))
287
+ .map(([key, value]) => `${key}=${value}`),
288
+ });
289
+ },
290
+
291
+ /**
292
+ * Loads decrypted secret values into the host store, so a local runtime can read a workload
293
+ * credential without a plaintext file ever touching disk.
294
+ *
295
+ * The local-development half of the propagation contract: production workloads receive these
296
+ * same values through `envFrom` off the Secret `apply` projects, and `npm run dev` receives
297
+ * them here. This domain has no local store of its own — a decrypted credential is node-local
298
+ * configuration, so it lands in the host domain's store rather than in a fourth one.
299
+ * `--args names=` narrows to one secret.
300
+ * @param {object} context - Normalized domain context.
301
+ * @returns {{loaded: Array<string>, keys: number}} Which secrets were loaded, and how many keys.
302
+ * @memberof UnderpostSecret
303
+ */
304
+ load(context = {}) {
305
+ context = domainContextFactory(context);
306
+ const requested = `${context.args.names ?? ''}`.split(/[,\s]+/).filter(Boolean);
307
+ const names = (requested.length > 0 ? requested : Underpost.secret.managedSecrets()).filter((name) =>
308
+ Underpost.secret.has(name, context.namespace),
309
+ );
310
+ const values = {};
311
+ for (const name of names)
312
+ for (const [key, value] of Object.entries(Underpost.secret.readData(name, context.namespace) ?? {}))
313
+ values[`${name.replace(/-/g, '_').toUpperCase()}_${key.replace(/-/g, '_').toUpperCase()}`] = value;
314
+ if (context.dryRun) {
315
+ logger.info('[dry-run] secret load would populate the host store', {
316
+ loaded: names,
317
+ keys: Object.keys(values).length,
318
+ });
319
+ return { loaded: names, keys: Object.keys(values).length };
320
+ }
321
+ for (const [key, value] of Object.entries(values)) Underpost.host.store.set(key, value);
322
+ logger.info('Workload secrets loaded', { loaded: names, keys: Object.keys(values).length });
323
+ return { loaded: names, keys: Object.keys(values).length };
324
+ },
325
+
326
+ /**
327
+ * Writes a plaintext Secret manifest into the encrypted store and shreds the source, making
328
+ * SOPS/Age the durable record for it.
329
+ * @param {object} context - Normalized domain context. Requires `--args path=<plaintext.yaml>`.
330
+ * @returns {*} The encryption result.
331
+ * @memberof UnderpostSecret
332
+ */
333
+ publish(context = {}) {
334
+ context = domainContextFactory(context);
335
+ const path = `${context.args.path ?? ''}`.trim();
336
+ if (!path) throw new Error('[secret] publish requires --args path=<plaintext-manifest>');
337
+ return Underpost.secret.encrypt(path, context.namespace, context);
338
+ },
339
+
340
+ /**
341
+ * Projects the encrypted store into the cluster: decrypts stored manifests and streams them
342
+ * straight into `kubectl apply`, so plaintext never reaches persistent storage.
343
+ * Idempotent — re-applying converges on the stored values.
344
+ * @param {object} context - Normalized domain context. `--args names=a,b` narrows the set.
345
+ * @returns {*} The apply result.
346
+ * @memberof UnderpostSecret
347
+ */
348
+ apply(context = {}) {
349
+ context = domainContextFactory(context);
350
+ const names = `${context.args.names ?? ''}`.trim();
351
+ if (names) return Underpost.secret.applySelected(names, context.namespace, { dryRun: context.dryRun });
352
+ return Underpost.secret.applyStore(context.namespace, { dryRun: context.dryRun });
170
353
  },
171
354
 
172
355
  /**
173
- * @method sops
174
- * @description Git-native encrypted secret management backed by Mozilla SOPS and Age, for
356
+ * Read-only report of the store: tooling, key and recipients, creation rules, stored
357
+ * manifests with decryptability and cluster drift.
358
+ * @param {object} context - Normalized domain context. `--args keys=mongo` narrows by substring.
359
+ * @returns {*} The status report.
360
+ * @memberof UnderpostSecret
361
+ */
362
+ status(context = {}) {
363
+ context = domainContextFactory(context);
364
+ return Underpost.secret.statusReport(`${context.args.keys ?? ''}`, { namespace: context.namespace });
365
+ },
366
+
367
+ /**
368
+ * Replaces the current projection: either the Age identity the store is sealed to, or the
369
+ * value of a credential itself.
370
+ *
371
+ * `--args secret=GIT_AUTH_TOKEN` selects the credential rotation — the GitHub Actions secret
372
+ * every repository of a deploy authenticates with, mirrored into the encrypted store.
373
+ * Without it the recipient rotation runs, which re-keys stored manifests onto a new Age
374
+ * recipient: secret values are unchanged there, so no workload restart is needed.
375
+ * @param {object} context - Normalized domain context. Requires either
376
+ * `--args secret=GIT_AUTH_TOKEN` (with `token=`, `deploy-id=`, `owner=`, `repos=`,
377
+ * `store=true`, `apply=true`) or `--args recipient=age1...` (with `prune=true` to revoke
378
+ * previous recipients and `keep=` to retain named ones).
379
+ * @returns {*} The rotation result.
380
+ * @memberof UnderpostSecret
381
+ */
382
+ rotate(context = {}) {
383
+ context = domainContextFactory(context);
384
+ const secret = `${context.args.secret ?? ''}`.trim();
385
+ if (secret) {
386
+ if (secret.toUpperCase() !== GIT_AUTH_TOKEN_KEY)
387
+ // Phrased without a `secret=<value>` pair on purpose: the log redactor treats one as a
388
+ // credential and would replace the very name the operator needs to read back.
389
+ throw new Error(
390
+ `[secret] rotate does not know the credential '${secret}'; ${GIT_AUTH_TOKEN_KEY} is the only one ` +
391
+ `it rotates. Re-key the store's encryption identity with --args recipient=age1... instead.`,
392
+ );
393
+ return Underpost.secret.rotateGitAuthToken({
394
+ namespace: context.namespace,
395
+ dryRun: context.dryRun,
396
+ token: context.args.token,
397
+ deployId: context.args['deploy-id'] ?? context.args.deployId,
398
+ owner: context.args.owner,
399
+ repos: context.args.repos,
400
+ store: context.args.store,
401
+ apply: context.args.apply,
402
+ });
403
+ }
404
+ const recipient = `${context.args.recipient ?? ''}`.trim();
405
+ if (!recipient)
406
+ throw new Error(
407
+ '[secret] rotate requires a target: --args recipient=<age-public-key> re-keys the store onto a new ' +
408
+ `Age identity, and the 'secret' parameter rotates a credential value (${GIT_AUTH_TOKEN_KEY} is the ` +
409
+ 'one it supports).',
410
+ );
411
+ return Underpost.secret.rotateRecipient(recipient, {
412
+ namespace: context.namespace,
413
+ dryRun: context.dryRun,
414
+ force: context.force,
415
+ pruneRecipients: context.args.prune === true || context.args.prune === 'true',
416
+ keepRecipients: `${context.args.keep ?? ''}`,
417
+ });
418
+ },
419
+
420
+ /**
421
+ * Withdraws local plaintext traces of workload secrets. The Age private key is kept: the node
422
+ * needs it to re-apply the store on restart. `--args names=` with `--force` additionally
423
+ * purges those secrets from the cluster and takes their manifests out of the store.
424
+ *
425
+ * A purge archives the manifest under `.archive/` by default, so it stays reversible;
426
+ * `--args delete=true` is the irreversible variant. `--force` gates the cluster deletion
427
+ * itself, so it cannot double as the disposition — that would make the archive path
428
+ * unreachable from the CLI and every purge permanent.
429
+ * @param {object} context - Normalized domain context.
430
+ * @returns {{staged: number, purged: Array<string>, disposition: string}} What was withdrawn.
431
+ * @memberof UnderpostSecret
432
+ */
433
+ clean(context = {}) {
434
+ context = domainContextFactory(context);
435
+ const purge = `${context.args.names ?? ''}`.split(/[,\s]+/).filter(Boolean);
436
+ if (purge.length > 0 && !context.force)
437
+ throw new Error('[secret] clean --args names=<secret> removes cluster state; re-run with --force');
438
+ const deleteManifest = context.args.delete === true || `${context.args.delete}` === 'true';
439
+ const disposition = deleteManifest ? 'delete' : 'archive';
440
+ const staged = fs.existsSync(SOPS_STAGE_DIR) ? fs.readdirSync(SOPS_STAGE_DIR).length : 0;
441
+ if (context.dryRun) {
442
+ logger.info('[dry-run] secret clean would withdraw', { staged, purge, disposition });
443
+ return { staged, purged: purge, disposition };
444
+ }
445
+ if (fs.existsSync(SOPS_STAGE_DIR)) fs.removeSync(SOPS_STAGE_DIR);
446
+ for (const name of purge)
447
+ Underpost.secret.purge(name, { namespace: context.namespace, dryRun: false, force: deleteManifest });
448
+ logger.info('Workload secret traces withdrawn', { staged, purged: purge, disposition });
449
+ return { staged, purged: purge, disposition };
450
+ },
451
+
452
+ /**
453
+ * @method store
454
+ * @description Git-native encrypted credential management backed by Mozilla SOPS and Age, for
175
455
  * fully self-hosted clusters with no cloud KMS or external secret store. Encrypted manifests
176
456
  * live in `engine-private/secrets/<namespace>/<name>.enc.yaml` and are safe to commit; the Age
177
457
  * private key stays at `~/.config/sops/age/keys.txt` (or `$SOPS_AGE_KEY_FILE`) and is never
@@ -179,940 +459,1854 @@ class UnderpostSecret {
179
459
  * streamed straight into `kubectl apply -f -`, so plaintext never reaches persistent storage.
180
460
  * @memberof UnderpostSecret
181
461
  */
182
- sops: {
183
- /**
184
- * @method keyFileCandidates
185
- * @description Ordered paths the Age private key is looked for, matching what sops itself
186
- * resolves. Surfaced separately so a "key not found" error can name every location tried —
187
- * the identity-context trap is a key generated as an unprivileged user but read back under
188
- * `sudo`, where `os.homedir()` points at root's home instead.
189
- * @returns {Array<string>} Candidate paths, highest precedence first.
190
- * @memberof UnderpostSecret
191
- */
192
- keyFileCandidates() {
193
- const candidates = [];
194
- if (process.env.SOPS_AGE_KEY_FILE) candidates.push(process.env.SOPS_AGE_KEY_FILE);
195
- if (process.env.XDG_CONFIG_HOME) candidates.push(`${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`);
196
- candidates.push(`${os.homedir()}/.config/sops/age/keys.txt`);
197
- // Under `sudo`, the invoking user's key is the one the operator actually generated.
198
- // Offered as a diagnostic hint only — never resolved implicitly, since silently reading a
199
- // different user's private key would make the effective identity non-obvious.
200
- if (process.env.SUDO_USER) candidates.push(`/home/${process.env.SUDO_USER}/.config/sops/age/keys.txt`);
201
- return [...new Set(candidates)];
202
- },
203
-
204
- /**
205
- * @method keyFile
206
- * @description Resolves the Age private key path. Honors `SOPS_AGE_KEY_FILE` then
207
- * `XDG_CONFIG_HOME`, matching sops' own resolution so key location has a single source of
208
- * truth. Deliberately resolves a *path*, never key material `SOPS_AGE_KEY` would expose the
209
- * private key in `/proc/<pid>/environ` and in any process listing.
210
- * @returns {string} Path to the Age private key file (may not exist yet).
211
- * @memberof UnderpostSecret
212
- */
213
- keyFile() {
214
- if (process.env.SOPS_AGE_KEY_FILE) return process.env.SOPS_AGE_KEY_FILE;
215
- if (process.env.XDG_CONFIG_HOME) return `${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`;
216
- return `${os.homedir()}/.config/sops/age/keys.txt`;
217
- },
218
-
219
- /**
220
- * @method assertKeyFile
221
- * @description Resolves the Age private key and refuses to proceed unless it exists and is
222
- * unreadable by group/other. A key at mode 0644 is a disclosed key, so this fails closed
223
- * rather than warning. When the resolved path is missing it names every candidate checked,
224
- * including the invoking user's home under `sudo`, so the identity mismatch is diagnosable
225
- * instead of presenting as a decrypt failure.
226
- * @returns {string} Verified key file path.
227
- * @memberof UnderpostSecret
228
- */
229
- assertKeyFile() {
230
- const keyFile = Underpost.secret.sops.keyFile();
231
- if (!fs.existsSync(keyFile)) {
232
- const alternatives = Underpost.secret.sops
233
- .keyFileCandidates()
234
- .filter((candidate) => candidate !== keyFile && fs.existsSync(candidate));
235
- throw new Error(
236
- `Age private key not found: ${keyFile} (running as uid ${process.getuid?.() ?? '?'})` +
237
- (alternatives.length
238
- ? `. A key does exist at ${alternatives.join(', ')} re-run with ` +
239
- `SOPS_AGE_KEY_FILE=<path>, or copy it to ${keyFile}.`
240
- : `. Run: underpost secret sops --init`),
462
+ /**
463
+ * @method setup
464
+ * @description End-to-end SOPS/Age onboarding for a host: installs tooling, generates the Age
465
+ * keypair and creation rules, pins the key path for non-interactive runs, encrypts the
466
+ * requested Secrets into the Git-tracked store, then validates and applies them.
467
+ *
468
+ * Every step is idempotent and re-runnable. Notably it delegates key generation to
469
+ * `secret setup` rather than calling `age-keygen` directly: a bare `age-keygen -o`
470
+ * overwrites an existing key, which would orphan every manifest already encrypted to the
471
+ * previous recipient with no way to recover them.
472
+ *
473
+ * On a host that pulled a store created elsewhere, the freshly generated key is not a recipient
474
+ * of the inherited manifests. `init()` registers this host in the creation rules so what it
475
+ * encrypts from here on stays readable, but existing manifests can only be re-keyed from a host
476
+ * that still holds a decrypting key. That case is reported per secret and then raised by the
477
+ * apply pre-flight with the available remedies, rather than surfacing as a sops decrypt error.
478
+ *
479
+ * Onboards the whole self-hosted data tier by default PostgreSQL, MariaDB, and MongoDB
480
+ * (`postgres-secret`, `mariadb-secret`, `mongodb-secret`, `mongodb-keyfile`). The MongoDB
481
+ * keyfile is included because the StatefulSet mounts it for intra-replica-set auth and will
482
+ * not start without it. Pass an explicit comma-separated list to narrow the set.
483
+ *
484
+ * Secret values are resolved per data key, in order:
485
+ * 1. the origin seed file, when one exists (`engine-private/postgresql-password`) — this is
486
+ * the real onboarding path, carrying the credential the cluster already runs on;
487
+ * 2. `--args` as `key=value` pairs, for a value supplied by the operator;
488
+ * 3. a freshly generated value: a base64 keyfile for `mongodb-keyfile`, `admin` for a
489
+ * `username`, otherwise a 24-character secure password.
490
+ *
491
+ * Plaintext manifests are written by Node under `/dev/shm` at mode 600 and shredded by
492
+ * `encrypt()`. They are never emitted through a shell heredoc, which would place the
493
+ * credential in the command string and therefore in the process table and the command log.
494
+ *
495
+ * Usage:
496
+ * underpost secret setup # postgres + mariadb + mongo
497
+ * underpost secret setup --args names=mongodb-secret,mongodb-keyfile --namespace prod
498
+ * underpost secret setup --args "names=postgres-secret,password=s3cr3t"
499
+ * underpost secret setup --dry-run # stop before mutating cluster
500
+ * underpost secret setup --force # replace stored manifests
501
+ * @param {string} names - Comma-separated Secret names to onboard. Defaults to the full data
502
+ * tier: postgres-secret, mariadb-secret, mongodb-secret, mongodb-keyfile.
503
+ * @param {object} options - Onboarding options
504
+ * @param {string} options.namespace - Target namespace for the store and the apply (default: 'default').
505
+ * @param {string} options.args - Comma-separated `key=value` overrides for Secret data keys.
506
+ * @param {boolean} options.dryRun - Validate and server-dry-run only; never apply.
507
+ * @param {boolean} options.force - Replace encrypted manifests that already exist.
508
+ * @memberof UnderpostSecret
509
+ */
510
+ setupStore(names = '', options = {}) {
511
+ const namespace = options.namespace || 'default';
512
+ const secretNames = (names || SOPS_SETUP_DEFAULT_SECRETS.join(','))
513
+ .split(',')
514
+ .map((name) => name.trim())
515
+ .filter(Boolean);
516
+
517
+ // `--args key=value,key2=value2` overrides, applied to any secret that declares that key.
518
+ const overrides = `${options.args || ''}`.split(',').reduce((acc, pair) => {
519
+ const separator = pair.indexOf('=');
520
+ if (separator > 0) acc[pair.slice(0, separator).trim()] = pair.slice(separator + 1).trim();
521
+ return acc;
522
+ }, {});
523
+
524
+ logger.info('[secret setup]', { secretNames, namespace, dryRun: !!options.dryRun, force: !!options.force });
525
+
526
+ // 1. Host tooling, then keypair + creation rules. Both no-op when already present.
527
+ Underpost.secret.installTooling();
528
+ Underpost.secret.init();
529
+
530
+ // 2. Pin the resolved key path for non-interactive runs (systemd units, CronJobs, sudo).
531
+ // Written with the concrete path rather than a guessed default, because `sudo` resets
532
+ // HOME and a wrong guess surfaces later as an opaque decrypt failure.
533
+ const keyFile = Underpost.secret.keyFile();
534
+ shellExec(
535
+ `sudo tee /etc/profile.d/underpost-sops.sh >/dev/null <<'UNDERPOST_SOPS_ENV_EOF'
536
+ export SOPS_AGE_KEY_FILE="\${SOPS_AGE_KEY_FILE:-${keyFile}}"
537
+ UNDERPOST_SOPS_ENV_EOF`,
538
+ );
539
+ shellExec(`sudo chmod 644 /etc/profile.d/underpost-sops.sh`);
540
+
541
+ // 3. Build and encrypt each requested Secret.
542
+ const stageDir = '/dev/shm/underpost-secrets';
543
+ const held = Underpost.secret.localRecipients();
544
+ stageDirSync(stageDir);
545
+ try {
546
+ for (const name of secretNames) {
547
+ const stored = Underpost.secret.has(name, namespace);
548
+ if (stored && !options.force) {
549
+ // A stored manifest this host cannot open is present but unusable here, so reporting it
550
+ // as onboarded would send the operator on to an apply that is guaranteed to fail.
551
+ if (Underpost.secret.decryptable(Underpost.secret.manifestPath(name, namespace), held))
552
+ logger.info(`${name} is already onboarded in ns/${namespace}; skipping (use --force to replace)`);
553
+ else
554
+ logger.warn(
555
+ `${name} is stored in ns/${namespace} but is sealed to an Age recipient this host does not hold; ` +
556
+ `skipping. Adopt the store's key, re-key it from a host that holds one, or re-onboard from the ` +
557
+ `origin seed files with --force.`,
558
+ );
559
+ continue;
560
+ }
561
+
562
+ // Data keys come from the secret's origin seed contract, so an onboarded manifest
563
+ // carries exactly the keys the workload's secretKeyRef already expects.
564
+ const seedSources = Underpost.secret.seedSources(name);
565
+ const envKeys = Underpost.secret.seedEnvKeys(name);
566
+ const envValues = Underpost.secret.seedEnvValues(name, options);
567
+ const mappedKeys = [...new Set([...Object.keys(seedSources), ...Object.keys(envKeys)])];
568
+ const dataKeys = mappedKeys.length > 0 ? mappedKeys : ['password'];
569
+ const stringData = {};
570
+ for (const key of dataKeys) {
571
+ const seedPath = seedSources[key];
572
+ if (seedPath && fs.existsSync(seedPath)) {
573
+ stringData[key] = fs.readFileSync(seedPath, 'utf8').trim();
574
+ logger.info(`${name}.${key} seeded from ${seedPath}`);
575
+ } else if (envValues[key] !== undefined) {
576
+ stringData[key] = envValues[key];
577
+ logger.info(`${name}.${key} seeded from the cron deploy environment`);
578
+ } else if (overrides[key] !== undefined) {
579
+ stringData[key] = overrides[key];
580
+ logger.info(`${name}.${key} taken from --args`);
581
+ } else {
582
+ stringData[key] = generateSeedValue(key);
583
+ // Replacing a stored manifest with a value nothing seeded means the credential the
584
+ // running datastore still authenticates against is being thrown away.
585
+ if (stored)
586
+ logger.warn(
587
+ `${name}.${key} generated while replacing the stored manifest — no seed file at ` +
588
+ `${seedPath || '(unmapped)'} and no --args override. The running datastore keeps its old ` +
589
+ `credential until this value is applied to it; pass --args "${key}=<value>" to keep the ` +
590
+ `existing one.`,
591
+ );
592
+ else logger.info(`${name}.${key} generated`);
593
+ }
594
+ }
595
+
596
+ const stagePath = `${stageDir}/${name}.yaml`;
597
+ writeStageFileSync(
598
+ stagePath,
599
+ [
600
+ 'apiVersion: v1',
601
+ 'kind: Secret',
602
+ 'metadata:',
603
+ ` name: ${name}`,
604
+ ` namespace: ${namespace}`,
605
+ ' labels:',
606
+ ' app.kubernetes.io/managed-by: underpost',
607
+ 'type: Opaque',
608
+ 'stringData:',
609
+ // Single-quoted YAML scalars with doubled internal quotes: values are generated or
610
+ // operator-supplied and may contain characters YAML would otherwise interpret.
611
+ ...Object.entries(stringData).map(([key, value]) => ` ${key}: '${`${value}`.replace(/'/g, "''")}'`),
612
+ '',
613
+ ].join('\n'),
241
614
  );
615
+ // encrypt() stages, validates, moves into place, and shreds the plaintext source.
616
+ Underpost.secret.encrypt(stagePath, namespace, options);
242
617
  }
243
- const mode = fs.statSync(keyFile).mode & 0o777;
244
- if (mode & 0o077)
245
- throw new Error(
246
- `Age private key ${keyFile} is group/world accessible (mode ${mode.toString(8)}). ` +
247
- `Run: chmod 600 ${keyFile}`,
618
+ } finally {
619
+ // Defense in depth: encrypt() shreds each source, but a throw mid-loop must not leave a
620
+ // plaintext manifest sitting in shared memory.
621
+ fs.removeSync(stageDir);
622
+ }
623
+
624
+ Underpost.secret.list();
625
+
626
+ // 4. Validate only the requested manifests, then apply unless this is a dry run.
627
+ Underpost.secret.applySelected(secretNames, namespace, { dryRun: true });
628
+ if (options.dryRun) return logger.info('--dry-run: validated only, cluster left unchanged');
629
+ Underpost.secret.applySelected(secretNames, namespace);
630
+ },
631
+ /**
632
+ * @method status
633
+ * @description Reports the live state of the SOPS/Age secret system: host tooling, the Age
634
+ * key and its recipient, the committed creation rules, every stored manifest with whether the
635
+ * local key can open it and whether the cluster still matches, and which managed Secrets are
636
+ * onboarded versus still seeding from their origin path.
637
+ *
638
+ * Read-only and safe to run anywhere. Decryption happens only for the drift check, only for
639
+ * manifests the local key is a recipient of, and only into `kubectl diff` with its output
640
+ * discarded — no secret value is ever printed or written to disk.
641
+ *
642
+ * Usage:
643
+ * underpost secret status # every managed key, ns default
644
+ * underpost secret status --args keys=mongo # partial match: both mongo keys
645
+ * underpost secret status --namespace prod # every managed key in ns prod
646
+ * @param {string} filter - Comma-separated managed Secret keys to report on; empty reports all.
647
+ * Matched as case-insensitive substrings (`mongo` selects mongodb-secret and mongodb-keyfile).
648
+ * Filters both the stored-manifest listing and the coverage table.
649
+ * @param {object} options - Reporting options
650
+ * @param {string} options.namespace - Namespace to inspect (default: 'default').
651
+ * @memberof UnderpostSecret
652
+ */
653
+ statusReport(filter = '', options = {}) {
654
+ const sops = Underpost.secret;
655
+ // `--namespace` selects the namespace; `path` narrows which managed
656
+ // Secret keys to report on, so the two axes stay independent.
657
+ const namespace = options.namespace || 'default';
658
+ const manageSecretKeyFilter = `${filter || ''}`
659
+ .split(',')
660
+ .map((key) => key.trim().toLowerCase())
661
+ .filter(Boolean);
662
+ // Partial, case-insensitive substring match, so `mongo` reaches both `mongodb-secret` and
663
+ // `mongodb-keyfile` without having to spell either out.
664
+ const matchesKeyFilter = (name) =>
665
+ manageSecretKeyFilter.length === 0 || manageSecretKeyFilter.some((key) => name.toLowerCase().includes(key));
666
+ const mark = (ok) => (ok ? 'yes' : 'no');
667
+
668
+ // ── Tooling ────────────────────────────────────────────────────────────
669
+ const version = (bin, flag) =>
670
+ sops.hasBinary(bin)
671
+ ? shellExec(`${bin} ${flag} 2>/dev/null | head -1`, { stdout: true, silent: true, disableLog: true }).trim()
672
+ : '(not installed)';
673
+ logger.info(
674
+ '[secret status] Tooling\n' +
675
+ ` sops ${version('sops', '--version')}\n` +
676
+ ` age ${version('age', '--version')}\n` +
677
+ ` age-keygen ${sops.hasBinary('age-keygen') ? 'installed' : '(not installed)'}`,
678
+ );
679
+
680
+ // ── Age key ────────────────────────────────────────────────────────────
681
+ const keyFile = sops.keyFile();
682
+ const keyExists = fs.existsSync(keyFile);
683
+ // A key file may hold several identities — that is how a host joins a store it did not
684
+ // create — so every check below works against the whole held set, not one recipient.
685
+ const held = sops.localRecipients();
686
+ const keyMode = keyExists ? (fs.statSync(keyFile).mode & 0o777).toString(8) : '';
687
+ logger.info(
688
+ '[secret status] Age key\n' +
689
+ ` path ${keyFile}\n` +
690
+ ` present ${mark(keyExists)}${keyExists ? ` (mode ${keyMode}${keyMode === '600' || keyMode === '400' ? '' : ' — INSECURE, run chmod 600'})` : ''}\n` +
691
+ ` recipients ${held.join(', ') || (keyExists ? '(none — unreadable key file)' : '(none)')}` +
692
+ (keyExists ? '' : `\n searched ${sops.keyFileCandidates().join(', ')}`),
693
+ );
694
+
695
+ // ── Creation rules ─────────────────────────────────────────────────────
696
+ const confPath = './engine-private/secrets/.sops.yaml';
697
+ const ruleRecipients = sops.creationRecipients();
698
+ logger.info(
699
+ '[secret status] Creation rules\n' +
700
+ ` config ${confPath} ${fs.existsSync(confPath) ? '' : '(missing — run: underpost secret setup)'}\n` +
701
+ ` recipients ${ruleRecipients.length > 0 ? ruleRecipients.join(', ') : '(none)'}\n` +
702
+ ` local key listed ${mark(held.some((recipient) => ruleRecipients.includes(recipient)))}`,
703
+ );
704
+
705
+ // ── Stored manifests ───────────────────────────────────────────────────
706
+ const manifests = sops.manifests(namespace).filter((manifest) => matchesKeyFilter(manifest.name));
707
+ const onboarded = new Set();
708
+ if (manifests.length === 0)
709
+ logger.warn(
710
+ `[secret status] Store\n no encrypted manifests in ns/${namespace}` +
711
+ (manageSecretKeyFilter.length > 0 ? ` matching ${manageSecretKeyFilter.join(', ')}` : ''),
712
+ );
713
+ else {
714
+ const rows = manifests.map((manifest) => {
715
+ onboarded.add(manifest.name);
716
+ const recipients = sops.manifestRecipients(manifest.path);
717
+ const decryptable = sops.decryptable(manifest.path, held);
718
+ const live = shellExec(
719
+ `kubectl get secret ${manifest.name} -n ${manifest.namespace} --ignore-not-found -o name 2>/dev/null || true`,
720
+ { stdout: true, silent: true, silentOnError: true, disableLog: true },
721
+ ).trim();
722
+ // Drift is decided by kubectl's exit code; its stdout would contain the decrypted
723
+ // values, so it is discarded rather than captured.
724
+ let sync = 'n/a';
725
+ if (live && decryptable) {
726
+ const result = shellExec(
727
+ `bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifest.path}" ` +
728
+ `| kubectl diff -f - -n "${manifest.namespace}" >/dev/null 2>&1'`,
729
+ { silentOnError: true, disableLog: true, stdout: false },
730
+ );
731
+ sync = result.code === 0 ? 'in-sync' : result.code === 1 ? 'DRIFT' : 'error';
732
+ } else if (!live) sync = 'not applied';
733
+ else if (!decryptable) sync = 'no local key';
734
+ return (
735
+ ` ${`${manifest.namespace}/${manifest.name}`.padEnd(34)} ` +
736
+ `recipients=${String(recipients.length).padEnd(3)} ` +
737
+ `decryptable=${mark(decryptable).padEnd(4)} ` +
738
+ `live=${mark(!!live).padEnd(4)} ` +
739
+ `${sync}`
248
740
  );
249
- return keyFile;
250
- },
251
-
252
- /**
253
- * @method managedSecrets
254
- * @description Names of every Secret wired to prefer the encrypted store, with the origin
255
- * seed path as fallback. Used for coverage reporting.
256
- * @returns {Array<string>} Managed Secret names.
257
- * @memberof UnderpostSecret
258
- */
259
- managedSecrets() {
260
- return [...MANAGED_SECRETS];
261
- },
262
-
263
- /**
264
- * @method seedSources
265
- * @description Origin seed files a secret can be onboarded from, as `{ dataKey: path }`.
266
- * The mapping is the contract between the plaintext seeding in cluster init
267
- * (`--from-file=<key>=<path>`) and the keys a workload's `secretKeyRef` expects, so an
268
- * onboarded manifest carries exactly the keys the workload already reads.
269
- * @param {string} name - Secret name (e.g. 'postgres-secret').
270
- * @returns {Object<string, string>} Data key to seed file path; empty for unknown secrets.
271
- * @memberof UnderpostSecret
272
- */
273
- seedSources(name) {
274
- return { ...(ORIGIN_SEED_SOURCES[name] || {}) };
275
- },
276
-
277
- /**
278
- * @method manifestPath
279
- * @description Builds the canonical store path for an encrypted Secret manifest.
280
- * @param {string} name - Secret name (e.g. 'postgres-secret').
281
- * @param {string} [namespace='default'] - Kubernetes namespace.
282
- * @returns {string} Path to the `.enc.yaml` manifest.
283
- * @memberof UnderpostSecret
284
- */
285
- manifestPath(name, namespace = 'default') {
286
- return `${SOPS_SECRETS_DIR}/${namespace}/${name}${SOPS_MANIFEST_EXT}`;
287
- },
288
-
289
- /**
290
- * @method has
291
- * @description Reports whether an encrypted manifest exists for a secret. Lets callers
292
- * prefer the SOPS store while keeping the origin seed path for clusters not yet onboarded.
293
- * Existence only — integrity is {@link assertManifest}'s job, so a corrupt manifest is a
294
- * hard failure rather than a silent slide back to the seed path.
295
- * @param {string} name - Secret name.
296
- * @param {string} [namespace='default'] - Kubernetes namespace.
297
- * @returns {boolean} True when the encrypted manifest is present.
298
- * @memberof UnderpostSecret
299
- */
300
- has(name, namespace = 'default') {
301
- return fs.existsSync(Underpost.secret.sops.manifestPath(name, namespace));
302
- },
303
-
304
- /**
305
- * @method manifestMeta
306
- * @description Reads the unencrypted envelope of a stored manifest: `kind`, `metadata.name`,
307
- * `metadata.namespace`, and whether a `sops:` block is present. `encrypted_regex` leaves all
308
- * of this in plaintext by design, so the check needs no private key and can run before any
309
- * decrypt is attempted.
310
- * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
311
- * @returns {{kind: string, name: string, namespace: string, encrypted: boolean}} Envelope facts.
312
- * @memberof UnderpostSecret
313
- */
314
- manifestMeta(manifestPath) {
315
- const content = fs.readFileSync(manifestPath, 'utf8');
316
- const field = (pattern) => (content.match(pattern) || [])[1] || '';
741
+ });
742
+ logger.info(`[secret status] Store — ns/${namespace} (${manifests.length} manifest(s))\n` + rows.join('\n'));
743
+ }
744
+
745
+ // ── Coverage ───────────────────────────────────────────────────────────
746
+ const coverage = sops
747
+ .managedSecrets()
748
+ .filter(matchesKeyFilter)
749
+ .map((name) => {
750
+ const seeds = Object.values(sops.seedSources(name));
751
+ const seedPresent = seeds.length > 0 && seeds.every((seed) => fs.existsSync(seed));
752
+ const envKeys = Object.keys(sops.seedEnvKeys(name));
753
+ const envValues = sops.seedEnvValues(name);
754
+ const envPresent = envKeys.length > 0 && envKeys.every((key) => envValues[key] !== undefined);
755
+ let source = 'unmapped';
756
+ if (onboarded.has(name)) source = 'sops';
757
+ else if (seedPresent) source = 'origin seed';
758
+ else if (envPresent) source = 'cron env';
759
+ else if (seeds.length || envKeys.length) source = 'MISSING';
760
+ return ` ${name.padEnd(24)} ${source.padEnd(12)} ${
761
+ seeds.length ? `seed=${mark(seedPresent)}` : envKeys.length ? `env=${mark(envPresent)}` : ''
762
+ }`;
763
+ });
764
+ if (coverage.length === 0)
765
+ logger.warn(
766
+ `[secret status] Coverage\n no managed Secret matches ${manageSecretKeyFilter.join(', ')}\n` +
767
+ ` known keys: ${sops.managedSecrets().join(', ')}`,
768
+ );
769
+ else
770
+ logger.info('[secret status] Coverage (which source each managed Secret deploys from)\n' + coverage.join('\n'));
771
+ },
772
+ /**
773
+ * @method keyFileCandidates
774
+ * @description Ordered paths the Age private key is looked for, matching what sops itself
775
+ * resolves. Surfaced separately so a "key not found" error can name every location tried —
776
+ * the identity-context trap is a key generated as an unprivileged user but read back under
777
+ * `sudo`, where `os.homedir()` points at root's home instead.
778
+ * @returns {Array<string>} Candidate paths, highest precedence first.
779
+ * @memberof UnderpostSecret
780
+ */
781
+ keyFileCandidates() {
782
+ const candidates = [];
783
+ if (process.env.SOPS_AGE_KEY_FILE) candidates.push(process.env.SOPS_AGE_KEY_FILE);
784
+ if (process.env.XDG_CONFIG_HOME) candidates.push(`${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`);
785
+ candidates.push(`${os.homedir()}/.config/sops/age/keys.txt`);
786
+ // Under `sudo`, the invoking user's key is the one the operator actually generated.
787
+ // Offered as a diagnostic hint only — never resolved implicitly, since silently reading a
788
+ // different user's private key would make the effective identity non-obvious.
789
+ if (process.env.SUDO_USER) candidates.push(`/home/${process.env.SUDO_USER}/.config/sops/age/keys.txt`);
790
+ return [...new Set(candidates)];
791
+ },
792
+
793
+ /**
794
+ * @method keyFile
795
+ * @description Resolves the Age private key path. Honors `SOPS_AGE_KEY_FILE` then
796
+ * `XDG_CONFIG_HOME`, matching sops' own resolution so key location has a single source of
797
+ * truth. Deliberately resolves a *path*, never key material — `SOPS_AGE_KEY` would expose the
798
+ * private key in `/proc/<pid>/environ` and in any process listing.
799
+ * @returns {string} Path to the Age private key file (may not exist yet).
800
+ * @memberof UnderpostSecret
801
+ */
802
+ keyFile() {
803
+ if (process.env.SOPS_AGE_KEY_FILE) return process.env.SOPS_AGE_KEY_FILE;
804
+ if (process.env.XDG_CONFIG_HOME) return `${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`;
805
+ return `${os.homedir()}/.config/sops/age/keys.txt`;
806
+ },
807
+
808
+ /**
809
+ * @method assertKeyFile
810
+ * @description Resolves the Age private key and refuses to proceed unless it exists and is
811
+ * unreadable by group/other. A key at mode 0644 is a disclosed key, so this fails closed
812
+ * rather than warning. When the resolved path is missing it names every candidate checked,
813
+ * including the invoking user's home under `sudo`, so the identity mismatch is diagnosable
814
+ * instead of presenting as a decrypt failure.
815
+ * @returns {string} Verified key file path.
816
+ * @memberof UnderpostSecret
817
+ */
818
+ assertKeyFile() {
819
+ const keyFile = Underpost.secret.keyFile();
820
+ if (!fs.existsSync(keyFile)) {
821
+ const alternatives = Underpost.secret
822
+ .keyFileCandidates()
823
+ .filter((candidate) => candidate !== keyFile && fs.existsSync(candidate));
824
+ throw new Error(
825
+ `Age private key not found: ${keyFile} (running as uid ${process.getuid?.() ?? '?'})` +
826
+ (alternatives.length
827
+ ? `. A key does exist at ${alternatives.join(', ')} — re-run with ` +
828
+ `SOPS_AGE_KEY_FILE=<path>, or copy it to ${keyFile}.`
829
+ : `. Run: underpost secret setup`),
830
+ );
831
+ }
832
+ const mode = fs.statSync(keyFile).mode & 0o777;
833
+ if (mode & 0o077)
834
+ throw new Error(
835
+ `Age private key ${keyFile} is group/world accessible (mode ${mode.toString(8)}). ` +
836
+ `Run: chmod 600 ${keyFile}`,
837
+ );
838
+ return keyFile;
839
+ },
840
+
841
+ /**
842
+ * @method managedSecrets
843
+ * @description Names of every Secret wired to prefer the encrypted store, with the origin
844
+ * seed path as fallback. Used for coverage reporting.
845
+ * @returns {Array<string>} Managed Secret names.
846
+ * @memberof UnderpostSecret
847
+ */
848
+ managedSecrets() {
849
+ return [...MANAGED_SECRETS];
850
+ },
851
+
852
+ /**
853
+ * @method seedSources
854
+ * @description Origin seed files a secret can be onboarded from, as `{ dataKey: path }`.
855
+ * The mapping is the contract between the plaintext seeding in cluster init
856
+ * (`--from-file=<key>=<path>`) and the keys a workload's `secretKeyRef` expects, so an
857
+ * onboarded manifest carries exactly the keys the workload already reads.
858
+ * @param {string} name - Secret name (e.g. 'postgres-secret').
859
+ * @returns {Object<string, string>} Data key to seed file path; empty for unknown secrets.
860
+ * @memberof UnderpostSecret
861
+ */
862
+ seedSources(name) {
863
+ return Object.fromEntries(
864
+ Object.entries(ORIGIN_SEED_FILES[name] || {}).map(([dataKey, entry]) => [
865
+ dataKey,
866
+ originSeedPath(seedDescriptor(entry).file),
867
+ ]),
868
+ );
869
+ },
870
+
871
+ /**
872
+ * @method seedValues
873
+ * @description Reads a secret's origin credentials as `{ dataKey: value }`, extracting the
874
+ * named field where the origin is a structured document rather than a bare credential file.
875
+ * Returns only the keys whose origin file is present, so callers can distinguish a partial
876
+ * origin from an absent one.
877
+ * @param {string} name - Managed secret name.
878
+ * @returns {Object<string, string>} Data key to credential value.
879
+ * @memberof UnderpostSecret
880
+ */
881
+ seedValues(name) {
882
+ const values = {};
883
+ for (const [dataKey, entry] of Object.entries(ORIGIN_SEED_FILES[name] || {})) {
884
+ const { file, json } = seedDescriptor(entry);
885
+ const path = originSeedPath(file);
886
+ if (!fs.existsSync(path)) continue;
887
+ const raw = fs.readFileSync(path, 'utf8');
888
+ if (!json) {
889
+ values[dataKey] = raw.trim();
890
+ continue;
891
+ }
892
+ let parsed;
893
+ try {
894
+ parsed = JSON.parse(raw);
895
+ } catch (error) {
896
+ throw new Error(`[${name}] origin ${path} is not valid JSON: ${error.message}`);
897
+ }
898
+ if (parsed?.[json] === undefined) throw new Error(`[${name}] origin ${path} has no '${json}' field`);
899
+ values[dataKey] = `${parsed[json]}`;
900
+ }
901
+ return values;
902
+ },
903
+
904
+ /**
905
+ * Environment keys that seed a managed Secret: a fixed mapping, or every key the Secret's
906
+ * configuration scope entitles it to.
907
+ */
908
+ seedEnvKeys(name) {
909
+ const scope = SECRET_ENV_SCOPES[name];
910
+ if (!scope) return { ...(SECRET_ENV_KEYS[name] || {}) };
911
+ return Object.fromEntries(Object.keys(scopeValuesFactory(process.env, scope)).map((key) => [key, key]));
912
+ },
913
+
914
+ /** Resolves present environment-backed seed values without logging them. */
915
+ seedEnvValues(name, options = {}) {
916
+ if (name === 'grafana-admin') {
917
+ const credentials = Underpost.host.grafanaAdmin({ ...options, required: false });
317
918
  return {
318
- kind: field(/^kind:\s*(\S+)/m),
319
- name: field(/^\s{2,}name:\s*(\S+)/m),
320
- namespace: field(/^\s{2,}namespace:\s*(\S+)/m),
321
- encrypted: /^sops:/m.test(content) && /ENC\[AES256_GCM/.test(content),
919
+ ...(credentials.username ? { 'admin-user': credentials.username } : {}),
920
+ ...(credentials.password ? { 'admin-password': credentials.password } : {}),
322
921
  };
323
- },
324
-
325
- /**
326
- * @method assertManifest
327
- * @description Fails closed on a manifest that exists but is not what the caller asked for.
328
- * Guards three silent-failure modes that a plain existence check misses: a plaintext file
329
- * that was never encrypted (credential disclosure in Git), a non-Secret resource, and a
330
- * name/namespace mismatch — the last of which would otherwise apply cleanly while leaving
331
- * the workload's `secretKeyRef` permanently unresolvable.
332
- * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
333
- * @param {object} [expect={}] - Expected envelope.
334
- * @param {string} [expect.name] - Required `metadata.name`.
335
- * @param {string} [expect.namespace] - Required `metadata.namespace` when the manifest sets one.
336
- * @memberof UnderpostSecret
337
- */
338
- assertManifest(manifestPath, expect = {}) {
339
- if (!fs.existsSync(manifestPath)) throw new Error(`Encrypted manifest not found: ${manifestPath}`);
340
- const meta = Underpost.secret.sops.manifestMeta(manifestPath);
341
- if (!meta.encrypted)
342
- throw new Error(
343
- `${manifestPath} carries no sops metadata — it is not encrypted. Refusing to apply; ` +
344
- `treat any credential in it as disclosed and rotate it.`,
922
+ }
923
+ // Every other env-mapped secret reads straight from the process environment, which on the
924
+ // host already carries the global underpost `.env` (loaded with override in src/cli/index.js).
925
+ return Object.entries(Underpost.secret.seedEnvKeys(name)).reduce((values, [dataKey, envKey]) => {
926
+ const value = process.env[envKey];
927
+ if (value !== undefined && `${value}`.length > 0) values[dataKey] = value;
928
+ return values;
929
+ }, {});
930
+ },
931
+
932
+ /**
933
+ * @method applyFromOriginSeed
934
+ * @description Projects a workload secret straight from its origin seed files into a
935
+ * Kubernetes Secret, for a cluster whose credentials are not onboarded into the encrypted
936
+ * store yet.
937
+ *
938
+ * The fallback half of `applyIfPresent(name) || applyFromOriginSeed(name)`: SOPS/Age stays
939
+ * the source of truth wherever a manifest exists, and this keeps a not-yet-onboarded
940
+ * cluster deploying exactly as before. Paths come from {@link UnderpostSecret.workload.seedSources},
941
+ * so callers never spell out a credential path of their own.
942
+ *
943
+ * Idempotent: `--dry-run=client | kubectl apply` converges on the seed contents.
944
+ * @param {string} name - Managed secret name (e.g. 'postgres-secret').
945
+ * @param {string} [namespace='default'] - Target namespace.
946
+ * @returns {boolean} True when the Secret was applied; false when no seed file is present.
947
+ * @memberof UnderpostSecret
948
+ */
949
+ applyFromOriginSeed(name, namespace = 'default') {
950
+ const sources = Underpost.secret.seedSources(name);
951
+ const envKeys = Underpost.secret.seedEnvKeys(name);
952
+ const fileKeys = Object.keys(sources);
953
+ if (fileKeys.length === 0 && Object.keys(envKeys).length === 0) {
954
+ logger.warn('No origin seed registered for secret; nothing to project', { name });
955
+ return false;
956
+ }
957
+ // Seed files first, then the environment — the same precedence `setup` applies, so a
958
+ // credential that only ever lived in the host CLI's environment can still reach a workload
959
+ // as a Kubernetes Secret instead of as a bind mount of the directory holding it.
960
+ const values = { ...Underpost.secret.seedEnvValues(name), ...Underpost.secret.seedValues(name) };
961
+ const dataKeys = [...new Set([...fileKeys, ...Object.keys(envKeys)])].filter((key) => values[key] !== undefined);
962
+ if (dataKeys.length === 0) {
963
+ logger.warn('No origin seed present for secret; nothing to project', { name, sources, envKeys });
964
+ return false;
965
+ }
966
+ // A key backed only by a seed file is a contract: half a database credential is worse than
967
+ // none, so a partial set of those still fails. A key with an environment fallback is
968
+ // ambient — a host that has registered no SSH connection simply contributes nothing, and
969
+ // the workload reports its own missing configuration rather than the apply refusing to run.
970
+ // A scope-backed secret is ambient in whole: its key set is whatever the host environment
971
+ // carries within the scope, so a key absent from this host is a host that does not set it,
972
+ // never an incomplete credential. Only a fixed file mapping can be half-present.
973
+ const missingRequired = SECRET_ENV_SCOPES[name]
974
+ ? []
975
+ : fileKeys.filter((key) => !(key in envKeys) && values[key] === undefined);
976
+ if (missingRequired.length > 0)
977
+ throw new Error(
978
+ `[${name}] incomplete origin seed: ${missingRequired.map((key) => sources[key]).join(', ')} missing`,
979
+ );
980
+ const missingOptional = Object.keys(envKeys).filter((key) => values[key] === undefined);
981
+ if (missingOptional.length > 0)
982
+ logger.warn('Secret projected without environment-only keys this host does not set', {
983
+ name,
984
+ missing: missingOptional.map((key) => `$${envKeys[key]}`),
985
+ });
986
+ // Values are staged on tmpfs and projected with `--from-file` rather than passed as
987
+ // `--from-literal`: a literal puts the credential in the command string, where it is
988
+ // visible in the process table for the life of the call.
989
+ const stageDir = `/dev/shm/underpost-origin-seed-${name}`;
990
+ try {
991
+ stageDirSync(stageDir);
992
+ const fromFile = dataKeys
993
+ .map((key) => `--from-file=${key}=${writeStageFileSync(`${stageDir}/${key}`, values[key])}`)
994
+ .join(' ');
995
+ // No `sudo`: the staged files are owned by this user at 0600, and elevating only to read
996
+ // them would make the manifest generation run as root for no gain.
997
+ shellExec(
998
+ `kubectl create secret generic ${name} ${fromFile} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
999
+ );
1000
+ } finally {
1001
+ fs.removeSync(stageDir);
1002
+ }
1003
+ logger.info('Workload secret projected from origin seed', { name, namespace, keys: dataKeys });
1004
+ return true;
1005
+ },
1006
+
1007
+ /**
1008
+ * @method manifestPath
1009
+ * @description Builds the canonical store path for an encrypted Secret manifest.
1010
+ * @param {string} name - Secret name (e.g. 'postgres-secret').
1011
+ * @param {string} [namespace='default'] - Kubernetes namespace.
1012
+ * @returns {string} Path to the `.enc.yaml` manifest.
1013
+ * @memberof UnderpostSecret
1014
+ */
1015
+ manifestPath(name, namespace = 'default') {
1016
+ return `${SOPS_SECRETS_DIR}/${namespace}/${name}${SOPS_MANIFEST_EXT}`;
1017
+ },
1018
+
1019
+ /**
1020
+ * @method has
1021
+ * @description Reports whether an encrypted manifest exists for a secret. Lets callers
1022
+ * prefer the SOPS store while keeping the origin seed path for clusters not yet onboarded.
1023
+ * Existence only — integrity is {@link assertManifest}'s job, so a corrupt manifest is a
1024
+ * hard failure rather than a silent slide back to the seed path.
1025
+ * @param {string} name - Secret name.
1026
+ * @param {string} [namespace='default'] - Kubernetes namespace.
1027
+ * @returns {boolean} True when the encrypted manifest is present.
1028
+ * @memberof UnderpostSecret
1029
+ */
1030
+ has(name, namespace = 'default') {
1031
+ return fs.existsSync(Underpost.secret.manifestPath(name, namespace));
1032
+ },
1033
+
1034
+ /**
1035
+ * @method manifestMeta
1036
+ * @description Reads the unencrypted envelope of a stored manifest: `kind`, `metadata.name`,
1037
+ * `metadata.namespace`, and whether a `sops:` block is present. `encrypted_regex` leaves all
1038
+ * of this in plaintext by design, so the check needs no private key and can run before any
1039
+ * decrypt is attempted.
1040
+ * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
1041
+ * @returns {{kind: string, name: string, namespace: string, encrypted: boolean}} Envelope facts.
1042
+ * @memberof UnderpostSecret
1043
+ */
1044
+ manifestMeta(manifestPath) {
1045
+ const content = fs.readFileSync(manifestPath, 'utf8');
1046
+ const field = (pattern) => (content.match(pattern) || [])[1] || '';
1047
+ return {
1048
+ kind: field(/^kind:\s*(\S+)/m),
1049
+ name: field(/^\s{2,}name:\s*(\S+)/m),
1050
+ namespace: field(/^\s{2,}namespace:\s*(\S+)/m),
1051
+ encrypted: /^sops:/m.test(content) && /ENC\[AES256_GCM/.test(content),
1052
+ };
1053
+ },
1054
+
1055
+ /**
1056
+ * @method assertManifest
1057
+ * @description Fails closed on a manifest that exists but is not what the caller asked for.
1058
+ * Guards three silent-failure modes that a plain existence check misses: a plaintext file
1059
+ * that was never encrypted (credential disclosure in Git), a non-Secret resource, and a
1060
+ * name/namespace mismatch — the last of which would otherwise apply cleanly while leaving
1061
+ * the workload's `secretKeyRef` permanently unresolvable.
1062
+ * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
1063
+ * @param {object} [expect={}] - Expected envelope.
1064
+ * @param {string} [expect.name] - Required `metadata.name`.
1065
+ * @param {string} [expect.namespace] - Required `metadata.namespace` when the manifest sets one.
1066
+ * @memberof UnderpostSecret
1067
+ */
1068
+ assertManifest(manifestPath, expect = {}) {
1069
+ if (!fs.existsSync(manifestPath)) throw new Error(`Encrypted manifest not found: ${manifestPath}`);
1070
+ const meta = Underpost.secret.manifestMeta(manifestPath);
1071
+ if (!meta.encrypted)
1072
+ throw new Error(
1073
+ `${manifestPath} carries no sops metadata — it is not encrypted. Refusing to apply; ` +
1074
+ `treat any credential in it as disclosed and rotate it.`,
1075
+ );
1076
+ if (meta.kind && meta.kind !== 'Secret') throw new Error(`${manifestPath} is a ${meta.kind}, not a Secret`);
1077
+ if (expect.name && meta.name && meta.name !== expect.name)
1078
+ throw new Error(
1079
+ `${manifestPath} declares metadata.name "${meta.name}" but is stored as "${expect.name}". ` +
1080
+ `Applying it would leave secretKeyRef "${expect.name}" unresolved.`,
1081
+ );
1082
+ if (expect.namespace && meta.namespace && meta.namespace !== expect.namespace)
1083
+ throw new Error(
1084
+ `${manifestPath} declares metadata.namespace "${meta.namespace}" but is being applied to ` +
1085
+ `"${expect.namespace}".`,
1086
+ );
1087
+ },
1088
+
1089
+ /**
1090
+ * @method localRecipients
1091
+ * @description Every Age recipient this host holds a private key for. A key file may carry
1092
+ * more than one identity — that is exactly how a host joins a store it did not create, by
1093
+ * appending the origin host's key alongside its own — so this returns all of them rather
1094
+ * than assuming one. Never throws: an absent or unreadable key file is a legitimate state
1095
+ * for a host that has not been onboarded yet, reported as an empty set.
1096
+ * @returns {Array<string>} The `age1…` recipients derived from the local key file.
1097
+ * @memberof UnderpostSecret
1098
+ */
1099
+ localRecipients() {
1100
+ const keyFile = Underpost.secret.keyFile();
1101
+ if (!fs.existsSync(keyFile)) return [];
1102
+ const output = shellExec(`age-keygen -y "${keyFile}"`, {
1103
+ stdout: true,
1104
+ silent: true,
1105
+ silentOnError: true,
1106
+ disableLog: true,
1107
+ });
1108
+ return [...new Set(`${output || ''}`.match(/age1[0-9a-z]+/g) || [])];
1109
+ },
1110
+
1111
+ /**
1112
+ * @method recipient
1113
+ * @description Derives the primary Age public recipient from the private key — the one new
1114
+ * manifests are encrypted to. The reverse is not possible, so this is safe to log and to
1115
+ * commit into `.sops.yaml`.
1116
+ * @returns {string} The `age1…` public recipient.
1117
+ * @memberof UnderpostSecret
1118
+ */
1119
+ recipient() {
1120
+ const keyFile = Underpost.secret.keyFile();
1121
+ if (!fs.existsSync(keyFile)) throw new Error(`Age private key not found: ${keyFile}`);
1122
+ const recipients = Underpost.secret.localRecipients();
1123
+ if (recipients.length === 0)
1124
+ throw new Error(`No Age identity could be read from ${keyFile}. Run: underpost secret setup`);
1125
+ return recipients[0];
1126
+ },
1127
+
1128
+ /**
1129
+ * @method decryptable
1130
+ * @description Reports whether the local key can open a stored manifest, by set-intersecting
1131
+ * the manifest's plaintext `sops:` recipients with the identities this host holds. Needs no
1132
+ * decrypt attempt and no private key material, so it is safe to call as a pre-flight on every
1133
+ * manifest before the first mutation.
1134
+ * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
1135
+ * @param {Array<string>} [held] - Locally held recipients; resolved from the key file when omitted.
1136
+ * @returns {boolean} True when at least one recipient of the manifest is held locally.
1137
+ * @memberof UnderpostSecret
1138
+ */
1139
+ decryptable(manifestPath, held = Underpost.secret.localRecipients()) {
1140
+ if (held.length === 0) return false;
1141
+ return Underpost.secret.manifestRecipients(manifestPath).some((recipient) => held.includes(recipient));
1142
+ },
1143
+
1144
+ /**
1145
+ * @method assertDecryptable
1146
+ * @description Fails closed, and legibly, on the store-adoption trap: a host that pulled an
1147
+ * encrypted store created elsewhere holds a key that is not among the manifests' recipients.
1148
+ * sops reports that as "no identity matched any of the recipients" from inside a decrypt
1149
+ * pipe, which names neither the manifest nor a way out; this raises first, listing every
1150
+ * unreadable manifest, the recipients it is sealed to, the identities this host actually
1151
+ * holds, and the three ways to resolve it.
1152
+ * @param {Array<{namespace: string, name: string, path: string}>} manifests - Manifests to check.
1153
+ * @memberof UnderpostSecret
1154
+ */
1155
+ assertDecryptable(manifests) {
1156
+ const held = Underpost.secret.localRecipients();
1157
+ const unreadable = manifests.filter((manifest) => !Underpost.secret.decryptable(manifest.path, held));
1158
+ if (unreadable.length === 0) return;
1159
+ const local =
1160
+ held.length > 0 ? held.join(', ') : `(none — no readable Age identity at ${Underpost.secret.keyFile()})`;
1161
+ throw new Error(
1162
+ `${unreadable.length} encrypted manifest(s) are sealed to Age recipients this host does not hold, ` +
1163
+ `so they cannot be decrypted here:\n` +
1164
+ unreadable
1165
+ .map(
1166
+ (manifest) =>
1167
+ ` ${manifest.namespace}/${manifest.name} -> ` +
1168
+ `${Underpost.secret.manifestRecipients(manifest.path).join(', ') || 'no age recipients'}`,
1169
+ )
1170
+ .join('\n') +
1171
+ `\n this host holds: ${local}\n` +
1172
+ `Resolve with exactly one of:\n` +
1173
+ ` 1. Install the key that already opens them — append the origin host's ` +
1174
+ `${Underpost.secret.keyFile()} to this host's own (one file may hold several identities), ` +
1175
+ `chmod 600 it, then re-run.\n` +
1176
+ ` 2. Re-key the store from a host that still holds that key: ` +
1177
+ `underpost secret rotate --args recipient=<this host's recipient>, commit engine-private/secrets, ` +
1178
+ `pull here, then re-run.\n` +
1179
+ ` 3. Re-onboard from this host's origin seed files, replacing the stored manifests: ` +
1180
+ `underpost secret setup --force. Valid only when those seed files carry the credentials the ` +
1181
+ `cluster already runs on — any regenerated value must also be applied to the running datastore.`,
1182
+ );
1183
+ },
1184
+
1185
+ /**
1186
+ * @method init
1187
+ * @description Generates the Age keypair and the `.sops.yaml` creation rule when absent.
1188
+ * Idempotent, and never overwrites an existing key: regenerating would orphan every manifest
1189
+ * already encrypted to the previous recipient, with no way to recover them.
1190
+ * @memberof UnderpostSecret
1191
+ */
1192
+ init() {
1193
+ Underpost.secret.assertTooling(['age-keygen', 'sops']);
1194
+ const keyFile = Underpost.secret.keyFile();
1195
+ if (fs.existsSync(keyFile)) logger.info(`Age key already present; reusing ${keyFile}`);
1196
+ else {
1197
+ fs.ensureDirSync(keyFile.slice(0, keyFile.lastIndexOf('/')));
1198
+ shellExec(`umask 077 && age-keygen -o "${keyFile}"`);
1199
+ }
1200
+ shellExec(`chmod 600 "${keyFile}"`);
1201
+
1202
+ const recipient = Underpost.secret.recipient();
1203
+ const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
1204
+ if (fs.existsSync(sopsConfPath)) {
1205
+ logger.info(`Creation rules already present; leaving ${sopsConfPath} intact`);
1206
+ Underpost.secret.repairCreationRules();
1207
+ Underpost.secret.ensureCreationRecipient(recipient);
1208
+ } else {
1209
+ fs.outputFileSync(
1210
+ sopsConfPath,
1211
+ [
1212
+ 'creation_rules:',
1213
+ ` - path_regex: ${SOPS_MANIFEST_PATH_REGEX}`,
1214
+ ` encrypted_regex: '${SOPS_ENCRYPTED_REGEX}'`,
1215
+ ` age: ${recipient}`,
1216
+ '',
1217
+ ].join('\n'),
1218
+ 'utf8',
1219
+ );
1220
+ logger.info(`Created ${sopsConfPath}`);
1221
+ }
1222
+ logger.info(`Age recipient: ${recipient}`);
1223
+ logger.warn(`Back up ${keyFile} offline. Without it every encrypted manifest is unrecoverable.`);
1224
+ },
1225
+
1226
+ /**
1227
+ * @method ensureCreationRecipient
1228
+ * @description Registers this host's recipient in an inherited `.sops.yaml` so anything it
1229
+ * encrypts from now on, it can also decrypt. Without this, a host that pulled a store created
1230
+ * elsewhere encrypts to the *other* host's recipient only, producing manifests it cannot read
1231
+ * back — a failure that surfaces later as an opaque decrypt error rather than at write time.
1232
+ *
1233
+ * Strictly additive: no existing recipient loses access, and existing manifests are left
1234
+ * untouched, since re-keying them requires a private key that can still decrypt (see
1235
+ * {@link rotate}). Left alone when the rule lists no `age:` recipients at all, which means a
1236
+ * deliberately non-Age rule rather than a store this host should join.
1237
+ * @param {string} recipient - This host's `age1…` public recipient.
1238
+ * @returns {boolean} True when the creation rule was rewritten.
1239
+ * @memberof UnderpostSecret
1240
+ */
1241
+ ensureCreationRecipient(recipient) {
1242
+ const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
1243
+ if (!fs.existsSync(confPath) || !recipient) return false;
1244
+ const current = Underpost.secret.creationRecipients();
1245
+ if (current.includes(recipient)) return false;
1246
+ if (current.length === 0) {
1247
+ logger.warn(
1248
+ `${confPath} declares no age recipients; leaving it untouched. Add ${recipient} manually if this ` +
1249
+ `host is meant to encrypt into this store.`,
1250
+ );
1251
+ return false;
1252
+ }
1253
+ Underpost.secret.writeCreationRecipients([...current, recipient]);
1254
+ logger.warn(
1255
+ `Registered this host's recipient in ${confPath} so manifests it encrypts stay readable here. ` +
1256
+ `Existing manifests are NOT re-keyed by this — run \`underpost secret rotate --args recipient=` +
1257
+ `${recipient}\` from a host that can still decrypt them, then commit ${SOPS_SECRETS_DIR}.`,
1258
+ { added: recipient, recipients: [...current, recipient] },
1259
+ );
1260
+ return true;
1261
+ },
1262
+
1263
+ /**
1264
+ * @method repairCreationRules
1265
+ * @description Rewrites a `path_regex` that can never match, in place, preserving recipients
1266
+ * and every other setting. Configs written before the relative-path semantics were understood
1267
+ * carry an `engine-private/secrets/` prefix; because sops matches relative to the directory
1268
+ * holding `.sops.yaml`, that rule matches nothing and every encrypt fails with
1269
+ * "no matching creation rules found". Repairs only that known-broken form, so a deliberately
1270
+ * customized rule is left alone.
1271
+ * @returns {boolean} True when the file was rewritten.
1272
+ * @memberof UnderpostSecret
1273
+ */
1274
+ repairCreationRules() {
1275
+ const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
1276
+ if (!fs.existsSync(confPath)) return false;
1277
+ const lines = fs.readFileSync(confPath, 'utf8').split('\n');
1278
+ // The rule is the first key of a YAML list item, so the line carries a `- ` marker that
1279
+ // has to be preserved: ` - path_regex: …`.
1280
+ const brokenRule = /^(\s*(?:-\s*)?)path_regex:\s*.*engine-private\/secrets\//;
1281
+ const index = lines.findIndex((line) => brokenRule.test(line));
1282
+ if (index === -1) return false;
1283
+ const prefix = lines[index].match(brokenRule)[1];
1284
+ const previous = lines[index].trim();
1285
+ lines[index] = `${prefix}path_regex: ${SOPS_MANIFEST_PATH_REGEX}`;
1286
+ fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
1287
+ logger.warn(
1288
+ `Repaired an unmatchable creation rule in ${confPath}: sops matches path_regex relative to ` +
1289
+ `that file's own directory, so the store prefix never matched.`,
1290
+ { from: previous, to: `path_regex: ${SOPS_MANIFEST_PATH_REGEX}` },
1291
+ );
1292
+ return true;
1293
+ },
1294
+
1295
+ /**
1296
+ * @method encrypt
1297
+ * @description Encrypts a plaintext Secret manifest into the Git-tracked store and shreds the
1298
+ * source. Recipients resolve from the committed `.sops.yaml`, so a manifest cannot be
1299
+ * encrypted to an unlisted key. Author the plaintext under `/dev/shm` so it never touches
1300
+ * persistent storage.
1301
+ * Written via a staged temp file and moved into place only after the output validates. A
1302
+ * bare `sops … > out` redirect has the shell truncate `out` before sops runs, so a failed
1303
+ * encrypt would destroy an existing manifest and leave a zero-byte file in its place.
1304
+ * @param {string} plaintextPath - Path to the plaintext Secret manifest.
1305
+ * @param {string} [namespace='default'] - Target namespace directory in the store.
1306
+ * @param {object} [options={}] - Encryption options.
1307
+ * @param {boolean} [options.force=false] - Replace an existing manifest at the target path.
1308
+ * @returns {string} Path of the written encrypted manifest.
1309
+ * @memberof UnderpostSecret
1310
+ */
1311
+ encrypt(plaintextPath, namespace = 'default', options = {}) {
1312
+ Underpost.secret.assertTooling(['sops']);
1313
+ if (!plaintextPath || !fs.existsSync(plaintextPath))
1314
+ throw new Error(`Plaintext manifest not found: ${plaintextPath}`);
1315
+ const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
1316
+ if (!fs.existsSync(sopsConfPath))
1317
+ throw new Error(`Missing creation rules: ${sopsConfPath} (run: underpost secret setup)`);
1318
+
1319
+ const sourceMeta = Underpost.secret.manifestMeta(plaintextPath);
1320
+ if (sourceMeta.encrypted)
1321
+ throw new Error(
1322
+ `${plaintextPath} already carries sops metadata. Re-encrypting would double-wrap it; ` +
1323
+ `edit it in place with: sops ${plaintextPath}`,
1324
+ );
1325
+
1326
+ const name = plaintextPath
1327
+ .split('/')
1328
+ .pop()
1329
+ .replace(/\.ya?ml$/, '');
1330
+ const outPath = Underpost.secret.manifestPath(name, namespace);
1331
+ if (fs.existsSync(outPath) && !options.force)
1332
+ throw new Error(`${outPath} already exists. Edit it with \`sops ${outPath}\`, or pass --force to replace.`);
1333
+ fs.ensureDirSync(`${SOPS_SECRETS_DIR}/${namespace}`);
1334
+
1335
+ // Encrypt to a temp file and move into place only on success. A bare `sops … > out` has the
1336
+ // shell truncate `out` before sops runs, so a failed encrypt destroys the manifest that was
1337
+ // already there and leaves an empty file the apply path would happily skip over.
1338
+ const stagePath = `${outPath}.staged`;
1339
+ try {
1340
+ // `--filename-override` makes sops match creation_rules against the destination path
1341
+ // rather than the tmpfs source. Without it the rule never matches, because the plaintext
1342
+ // deliberately lives outside the store (in /dev/shm) and is not named `*.enc.yaml`.
1343
+ shellExec(
1344
+ `sops --config "${sopsConfPath}" --filename-override "${outPath}" ` +
1345
+ `--encrypt "${plaintextPath}" > "${stagePath}"`,
1346
+ );
1347
+ Underpost.secret.assertManifest(stagePath, { name });
1348
+ // Sealing to a recipient held elsewhere is legitimate (encrypting *for* another host),
1349
+ // so this warns rather than fails — but it is also the shape of the store-adoption trap,
1350
+ // where it would otherwise only surface at the next apply.
1351
+ if (!Underpost.secret.decryptable(stagePath))
1352
+ logger.warn(
1353
+ `${outPath} is sealed to ${Underpost.secret.manifestRecipients(stagePath).join(', ')}, none of ` +
1354
+ `which this host holds a private key for — it cannot be decrypted here. Add this host's recipient ` +
1355
+ `to ${sopsConfPath} and re-encrypt if that is not intended.`,
345
1356
  );
346
- if (meta.kind && meta.kind !== 'Secret') throw new Error(`${manifestPath} is a ${meta.kind}, not a Secret`);
347
- if (expect.name && meta.name && meta.name !== expect.name)
348
- throw new Error(
349
- `${manifestPath} declares metadata.name "${meta.name}" but is stored as "${expect.name}". ` +
350
- `Applying it would leave secretKeyRef "${expect.name}" unresolved.`,
1357
+ fs.moveSync(stagePath, outPath, { overwrite: true });
1358
+ } finally {
1359
+ fs.removeSync(stagePath);
1360
+ }
1361
+
1362
+ shellExec(`shred -u "${plaintextPath}" 2>/dev/null || rm -f "${plaintextPath}"`, { silentOnError: true });
1363
+ logger.info(`Encrypted -> ${outPath}`);
1364
+ return outPath;
1365
+ },
1366
+
1367
+ /**
1368
+ * @method apply
1369
+ * @description Decrypts every encrypted manifest for a namespace and streams each one
1370
+ * directly into `kubectl apply -f -`. Plaintext exists only in an anonymous kernel pipe.
1371
+ * @param {string} [namespace='default'] - Target namespace.
1372
+ * @param {object} [options={}] - Apply options.
1373
+ * @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
1374
+ * @returns {number} Count of manifests applied.
1375
+ * @memberof UnderpostSecret
1376
+ */
1377
+ applyStore(namespace = 'default', options = {}) {
1378
+ const dir = `${SOPS_SECRETS_DIR}/${namespace}`;
1379
+ if (!fs.existsSync(dir)) throw new Error(`No encrypted secrets for namespace: ${namespace}`);
1380
+ const manifests = Underpost.secret.manifests(namespace);
1381
+ if (manifests.length === 0) throw new Error(`No *${SOPS_MANIFEST_EXT} manifests under ${dir}`);
1382
+
1383
+ // Validate-then-commit. Applying in a single pass means manifest N failing to decrypt
1384
+ // leaves 1..N-1 already live — a half-rotated namespace nobody asked for. The envelope
1385
+ // check plus a server dry run of every manifest catches wrong-key, malformed-YAML, schema
1386
+ // and RBAC failures before the first mutation.
1387
+ for (const manifest of manifests)
1388
+ Underpost.secret.assertManifest(manifest.path, { name: manifest.name, namespace });
1389
+ Underpost.secret.assertDecryptable(manifests);
1390
+ if (!options.dryRun)
1391
+ for (const manifest of manifests)
1392
+ Underpost.secret.applyManifest(manifest.path, namespace, {
1393
+ ...options,
1394
+ dryRun: true,
1395
+ quiet: true,
1396
+ });
1397
+
1398
+ for (const manifest of manifests) Underpost.secret.applyManifest(manifest.path, namespace, options);
1399
+ logger.info(`${options.dryRun ? 'Validated' : 'Applied'} ${manifests.length} manifest(s) in ns/${namespace}`);
1400
+ return manifests.length;
1401
+ },
1402
+
1403
+ /** Applies a named subset without requiring access to unrelated manifests in the namespace. */
1404
+ applySelected(names, namespace = 'default', options = {}) {
1405
+ const manifests = [...new Set(names)]
1406
+ .filter((name) => Underpost.secret.has(name, namespace))
1407
+ .map((name) => ({
1408
+ name,
1409
+ namespace,
1410
+ path: Underpost.secret.manifestPath(name, namespace),
1411
+ }));
1412
+ if (manifests.length === 0) throw new Error(`No requested encrypted secrets for namespace: ${namespace}`);
1413
+ for (const manifest of manifests)
1414
+ Underpost.secret.assertManifest(manifest.path, { name: manifest.name, namespace });
1415
+ Underpost.secret.assertDecryptable(manifests);
1416
+ for (const manifest of manifests)
1417
+ Underpost.secret.applyManifest(manifest.path, namespace, {
1418
+ ...options,
1419
+ expectName: manifest.name,
1420
+ quiet: true,
1421
+ });
1422
+ logger.info(
1423
+ `${options.dryRun ? 'Validated' : 'Applied'} ${manifests.length} selected manifest(s) in ns/${namespace}`,
1424
+ );
1425
+ return manifests.length;
1426
+ },
1427
+
1428
+ /**
1429
+ * @method applyManifest
1430
+ * @description Streams one encrypted manifest through `sops --decrypt` into `kubectl apply`.
1431
+ * Runs under an explicit `bash -c` with `pipefail`, which is load-bearing: without it a sops
1432
+ * failure yields an empty stream and `kubectl apply -f -` exits 0, silently applying nothing.
1433
+ * `disableLog` keeps the command (and therefore the key path) out of the log stream.
1434
+ * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
1435
+ * @param {string} [namespace='default'] - Target namespace.
1436
+ * @param {object} [options={}] - Apply options.
1437
+ * @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
1438
+ * @param {string} [options.expectName] - Require this `metadata.name` in the manifest envelope.
1439
+ * @param {boolean} [options.quiet=false] - Suppress the per-manifest log line.
1440
+ * @memberof UnderpostSecret
1441
+ */
1442
+ applyManifest(manifestPath, namespace = 'default', options = {}) {
1443
+ // Envelope first: it needs no private key, so a malformed or unencrypted store is reported
1444
+ // as such even on a host whose key is missing or wrongly permissioned.
1445
+ Underpost.secret.assertManifest(manifestPath, { name: options.expectName, namespace });
1446
+ // Recipient set next: a manifest sealed to a key this host does not hold fails inside the
1447
+ // decrypt pipe with an error that names neither the file nor a remedy.
1448
+ Underpost.secret.assertDecryptable([
1449
+ { namespace, name: options.expectName || manifestPath.split('/').pop(), path: manifestPath },
1450
+ ]);
1451
+ Underpost.secret.assertTooling(['sops']);
1452
+ const keyFile = Underpost.secret.assertKeyFile();
1453
+ const dryRun = options.dryRun ? ' --dry-run=server' : '';
1454
+ shellExec(
1455
+ `bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifestPath}" ` +
1456
+ `| kubectl apply -f -${dryRun} -n "${namespace}"'`,
1457
+ { disableLog: true },
1458
+ );
1459
+ if (!options.quiet) logger.info(`${options.dryRun ? 'Dry-run' : 'Applied'} ${manifestPath} -> ns/${namespace}`);
1460
+ },
1461
+
1462
+ /** Decrypts one stored Secret through an anonymous pipe and returns its data in memory. */
1463
+ readData(name, namespace = 'default') {
1464
+ const manifestPath = Underpost.secret.manifestPath(name, namespace);
1465
+ Underpost.secret.assertManifest(manifestPath, { name, namespace });
1466
+ Underpost.secret.assertDecryptable([{ namespace, name, path: manifestPath }]);
1467
+ Underpost.secret.assertTooling(['sops']);
1468
+ const keyFile = Underpost.secret.assertKeyFile();
1469
+ const source = shellExec(
1470
+ `bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifestPath}" ` +
1471
+ `| kubectl create --dry-run=client -f - -o json'`,
1472
+ { stdout: true, silent: true, disableLog: true },
1473
+ );
1474
+ const secret = JSON.parse(source);
1475
+ const data = Object.fromEntries(
1476
+ Object.entries(secret.data || {}).map(([key, value]) => [
1477
+ key,
1478
+ Buffer.from(`${value}`, 'base64').toString('utf8'),
1479
+ ]),
1480
+ );
1481
+ return { ...data, ...(secret.stringData || {}) };
1482
+ },
1483
+
1484
+ /**
1485
+ * @method applyIfPresent
1486
+ * @description Applies a secret from the SOPS store when an encrypted manifest exists,
1487
+ * reporting whether it did. Single decision point for callers that keep an origin seed path
1488
+ * for clusters not yet onboarded to the encrypted store.
1489
+ *
1490
+ * Falls back to the seed path only when the manifest is *absent*. A manifest that exists but
1491
+ * is corrupt, unencrypted, or names a different Secret raises instead: sliding back to the
1492
+ * seed path there would mask a tampered store and silently deploy stale credentials the
1493
+ * operator believes were replaced.
1494
+ * @param {string} name - Secret name.
1495
+ * @param {string} [namespace='default'] - Kubernetes namespace.
1496
+ * @param {object} [options={}] - Apply options forwarded to {@link applyManifest}.
1497
+ * @returns {boolean} True when the encrypted manifest was applied.
1498
+ * @memberof UnderpostSecret
1499
+ */
1500
+ applyIfPresent(name, namespace = 'default', options = {}) {
1501
+ if (!Underpost.secret.has(name, namespace)) return false;
1502
+ Underpost.secret.applyManifest(Underpost.secret.manifestPath(name, namespace), namespace, {
1503
+ ...options,
1504
+ expectName: name,
1505
+ });
1506
+ return true;
1507
+ },
1508
+
1509
+ /**
1510
+ * @method list
1511
+ * @description Lists encrypted manifests with their Age recipients. Reads only the plaintext
1512
+ * `sops:` metadata block, so no private key is required and this is safe to run anywhere.
1513
+ * @memberof UnderpostSecret
1514
+ */
1515
+ list() {
1516
+ const manifests = Underpost.secret.manifests();
1517
+ if (manifests.length === 0) return logger.warn(`No encrypted manifests under ${SOPS_SECRETS_DIR}`);
1518
+ for (const manifest of manifests) {
1519
+ const recipients = Underpost.secret.manifestRecipients(manifest.path);
1520
+ console.log(
1521
+ `${manifest.namespace}/${manifest.name}${SOPS_MANIFEST_EXT} -> ${
1522
+ recipients.join(', ') || 'no age recipients'
1523
+ }`,
1524
+ );
1525
+ }
1526
+ },
1527
+
1528
+ /**
1529
+ * @method manifestRecipients
1530
+ * @description Extracts the Age recipients an encrypted manifest is sealed to, from its
1531
+ * plaintext `sops:` metadata block. Requires no private key.
1532
+ * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
1533
+ * @returns {Array<string>} Recipients that can decrypt the manifest.
1534
+ * @memberof UnderpostSecret
1535
+ */
1536
+ manifestRecipients(manifestPath) {
1537
+ const content = fs.readFileSync(manifestPath, 'utf8');
1538
+ return [...content.matchAll(/recipient:\s*(age1\S+)/g)].map((match) => match[1]);
1539
+ },
1540
+
1541
+ /**
1542
+ * @method rotate
1543
+ * @description Re-keys every encrypted manifest onto a new Age recipient after key
1544
+ * compromise or scheduled rotation. Secret *values* are untouched: `sops updatekeys` only
1545
+ * re-wraps each file's data key, so no workload restart is needed. Requires a private key
1546
+ * that can still decrypt, so rotation must run before the outgoing key is destroyed.
1547
+ *
1548
+ * Additive by default (the outgoing recipient keeps working, which is what a scheduled
1549
+ * rotation wants). `options.pruneRecipients` makes the new recipient the only one, which is
1550
+ * what a compromise wants — and which also revokes every *other* operator and CI/CD key in
1551
+ * the rule, so it additionally requires `options.force` after showing exactly what is lost.
1552
+ * @param {string} recipient - Incoming `age1…` public recipient.
1553
+ * @param {object} [options={}] - Rotation options.
1554
+ * @param {boolean} [options.pruneRecipients=false] - Drop all existing recipients.
1555
+ * @param {Array<string>|string} [options.keepRecipients] - Recipients to retain while pruning
1556
+ * (e.g. the CI/CD key), as an array or comma-separated list.
1557
+ * @param {boolean} [options.force=false] - Confirm an irreversible prune.
1558
+ * @param {boolean} [options.dryRun=false] - Report the plan without rewriting anything.
1559
+ * @returns {{recipients: Array<string>, revoked: Array<string>, rekeyed: number}} Outcome.
1560
+ * @memberof UnderpostSecret
1561
+ */
1562
+ rotateRecipient(recipient, options = {}) {
1563
+ if (!recipient) throw new Error('Rotation requires --args recipient=<age-public-key>');
1564
+ if (!/^age1[0-9a-z]{20,}$/.test(recipient))
1565
+ throw new Error(`Not a valid Age public recipient: ${recipient} (expected age1…)`);
1566
+
1567
+ Underpost.secret.assertTooling(['sops']);
1568
+ const keyFile = Underpost.secret.assertKeyFile();
1569
+
1570
+ const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
1571
+ if (!fs.existsSync(confPath))
1572
+ throw new Error(`Missing creation rules: ${confPath} (run: underpost secret setup)`);
1573
+
1574
+ // `--args` itself splits on commas, so a multi-recipient keep list cannot use one. Pipe,
1575
+ // semicolon and whitespace all separate here, and a comma still works for a direct caller.
1576
+ const keep = (
1577
+ Array.isArray(options.keepRecipients)
1578
+ ? options.keepRecipients
1579
+ : `${options.keepRecipients || ''}`.split(/[,|;\s]+/)
1580
+ )
1581
+ .map((value) => value.trim())
1582
+ .filter(Boolean);
1583
+ const current = Underpost.secret.creationRecipients();
1584
+ const next = options.pruneRecipients ? [...new Set([recipient, ...keep])] : [...new Set([...current, recipient])];
1585
+ const revoked = current.filter((existing) => !next.includes(existing));
1586
+ const manifests = Underpost.secret.manifests();
1587
+ // `updatekeys` re-wraps each data key, which means decrypting it first. A host that cannot
1588
+ // read the store cannot rotate it, dry run included — reporting a plan that can never run
1589
+ // is what sends an operator down the wrong remedy.
1590
+ Underpost.secret.assertDecryptable(manifests);
1591
+
1592
+ if (options.dryRun) {
1593
+ logger.info('Rotation plan (dry run)', { from: current, to: next, revoked, manifests: manifests.length });
1594
+ if (revoked.length)
1595
+ logger.warn(
1596
+ `${revoked.length} recipient(s) would permanently lose access. Confirm none is a CI/CD or ` +
1597
+ `co-operator key before re-running with --force.`,
1598
+ { revoked },
351
1599
  );
352
- if (expect.namespace && meta.namespace && meta.namespace !== expect.namespace)
1600
+ return { recipients: next, revoked, rekeyed: 0 };
1601
+ }
1602
+
1603
+ // A prune revokes every recipient not explicitly retained — including CI/CD keys the
1604
+ // operator may not have in mind. Irreversible for anyone holding only a revoked key, so it
1605
+ // is gated behind an explicit confirmation that has to be made after seeing the list.
1606
+ if (revoked.length && !options.force)
1607
+ throw new Error(
1608
+ `Refusing to revoke ${revoked.length} recipient(s) without --force: ${revoked.join(', ')}. ` +
1609
+ `Review with --dry-run, retain any CI/CD key via --keep-recipients <age1…>, then re-run with --force.`,
1610
+ );
1611
+
1612
+ Underpost.secret.writeCreationRecipients(next);
1613
+ for (const manifest of manifests) {
1614
+ // `updatekeys` decrypts the data key with a held private key and re-wraps it for the
1615
+ // recipients now in `.sops.yaml`. disableLog keeps the key path out of the log stream.
1616
+ shellExec(
1617
+ `bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" ` +
1618
+ `sops --config "${confPath}" updatekeys --yes "${manifest.path}"'`,
1619
+ { disableLog: true, silent: true },
1620
+ );
1621
+ // updatekeys is a no-op when it decides nothing changed, and its exit code does not
1622
+ // distinguish that from a successful re-key. Confirm against the file itself, so a
1623
+ // rotation can never be reported as done while a manifest stays on the old recipient.
1624
+ const sealed = Underpost.secret.manifestRecipients(manifest.path);
1625
+ if (!sealed.includes(recipient))
353
1626
  throw new Error(
354
- `${manifestPath} declares metadata.namespace "${meta.namespace}" but is being applied to ` +
355
- `"${expect.namespace}".`,
1627
+ `${manifest.path} is still sealed to ${sealed.join(', ') || 'no recipients'} after updatekeys; ` +
1628
+ `expected ${recipient}. Rotation aborted with the store partially re-keyed — re-run once resolved.`,
356
1629
  );
357
- },
358
-
359
- /**
360
- * @method localRecipients
361
- * @description Every Age recipient this host holds a private key for. A key file may carry
362
- * more than one identity — that is exactly how a host joins a store it did not create, by
363
- * appending the origin host's key alongside its own so this returns all of them rather
364
- * than assuming one. Never throws: an absent or unreadable key file is a legitimate state
365
- * for a host that has not been onboarded yet, reported as an empty set.
366
- * @returns {Array<string>} The `age1…` recipients derived from the local key file.
367
- * @memberof UnderpostSecret
368
- */
369
- localRecipients() {
370
- const keyFile = Underpost.secret.sops.keyFile();
371
- if (!fs.existsSync(keyFile)) return [];
372
- const output = shellExec(`age-keygen -y "${keyFile}"`, {
1630
+ logger.info(`Re-keyed ${manifest.namespace}/${manifest.name}`);
1631
+ }
1632
+
1633
+ logger.info(`Rotated ${manifests.length} manifest(s)`, { recipients: next, revoked });
1634
+ if (revoked.length)
1635
+ logger.warn(
1636
+ `Revoked ${revoked.length} recipient(s); those keys can no longer decrypt any manifest. ` +
1637
+ `Every host applying these secrets now needs a private key for one of: ${next.join(', ')}.`,
1638
+ { revoked },
1639
+ );
1640
+ return { recipients: next, revoked, rekeyed: manifests.length };
1641
+ },
1642
+
1643
+ /**
1644
+ * @method gitAuthTokenTargets
1645
+ * @description Resolves every GitHub repository that carries a deploy's `GIT_AUTH_TOKEN`:
1646
+ * the private configuration repository its conf lives in, and the engine source repositories
1647
+ * it deploys from — production and test, which are one deploy under two names.
1648
+ *
1649
+ * Naming is delegated to {@link UnderpostRepository} rather than re-derived here, so a
1650
+ * rotation targets exactly the repositories `run pull` and `deploy/lib/host.sh` resolve for
1651
+ * the same deploy. Any reference naming it works — `dd-cyberia`, `engine-cyberia`,
1652
+ * `engine-test-cyberia`, `engine-cyberia-private` or a clone URL — because they all reduce to
1653
+ * the one conf id.
1654
+ *
1655
+ * Each deploy contributes its private conf repository, its production and test engine sources,
1656
+ * its `engine-ghpkg-<conf-id>` package mirror, and every `metadata.repository` its
1657
+ * `conf.instances.json` declares — an instance is a separate product with its own workflows
1658
+ * reading the same token. Derived names that do not exist are dropped by the reachability
1659
+ * probe, so no separate existence check is needed here.
1660
+ *
1661
+ * `dd` fans out across `engine-private/deploy/dd.routes`, so one rotation covers the whole
1662
+ * fleet. `template` is the second meta id: the template lineage carries no conf id, so it
1663
+ * resolves to `pwa-microservices-template`, its `-ghpkg` mirror, and `engine`. The union is
1664
+ * deduplicated: deploys share repositories, and one listed twice would be rotated twice.
1665
+ * @param {object} [options={}] - Resolution options.
1666
+ * @param {string} [options.deployId] - Deploy id, any repository reference naming it, a list
1667
+ * separated by `|`, `;` or whitespace, the meta id `dd` for every deploy in
1668
+ * `engine-private/deploy/dd.routes`, or `template` for the template lineage. Falls back to
1669
+ * `ENGINE_SRC_REPO`, then the monorepo pair.
1670
+ * @param {string} [options.owner] - GitHub owner. Falls back to the owner of `ENGINE_SRC_REPO`,
1671
+ * then `GITHUB_USERNAME`, then `underpostnet`.
1672
+ * @param {string} [options.repos] - Extra targets separated by `|`, `;` or whitespace —
1673
+ * `--args` itself splits on commas, so a list there cannot use one.
1674
+ * @returns {Array<string>} Deduplicated `owner/repo` slugs, private configuration first.
1675
+ * @memberof UnderpostSecret
1676
+ */
1677
+ gitAuthTokenTargets(options = {}) {
1678
+ const envSource = `${process.env.ENGINE_SRC_REPO ?? ''}`.trim();
1679
+ const owner =
1680
+ `${options.owner ?? ''}`.trim() ||
1681
+ (envSource.includes('/') ? envSource.split('/')[0] : '') ||
1682
+ process.env.GITHUB_USERNAME ||
1683
+ 'underpostnet';
1684
+ const requested = `${options.deployId ?? ''}`.trim() || envSource;
1685
+ // `dd` is the meta id every runner reads as "all of dd.routes", resolved through the one
1686
+ // reader the cluster deploys from — a rotation that parsed the route table itself could
1687
+ // cover a different fleet than the one running. An explicit list separates on `|`, `;` or
1688
+ // whitespace, because `--args` has already claimed the comma.
1689
+ const references = requested === 'dd' ? resolveDeployList('dd') : requested.split(/[,|;\s]+/).filter(Boolean);
1690
+ // The fallback fires for an absent route table *and* for an empty one, so it is detected on
1691
+ // the table itself rather than on the file's existence: rotating one invented deploy while
1692
+ // reporting a fleet rotation is the failure this warning exists to prevent.
1693
+ if (requested === 'dd' && readDeployRoutes().length === 0)
1694
+ logger.warn(
1695
+ `No deploy ids in ./engine-private/deploy/dd.routes; 'dd' fell back to ${references.join(', ')} ` +
1696
+ `rather than the fleet. Check out engine-private, or name the deploys explicitly with ` +
1697
+ `--args "deploy-id=dd-one|dd-two".`,
1698
+ );
1699
+ const candidates = [];
1700
+ // Unioned and deduplicated: deploys share repositories (every one of them pairs with the
1701
+ // same engine-private when ENGINE_SRC_PRIVATE_REPO names it), and a repository listed twice
1702
+ // would be rotated twice.
1703
+ if (requested === TEMPLATE_ALIAS) candidates.push(...TEMPLATE_REPOS.map((repo) => `${owner}/${repo}`));
1704
+ else
1705
+ for (const reference of references.length > 0 ? references : ['']) {
1706
+ const confId = Underpost.repo.confIdFactory(reference);
1707
+ const source = `${owner}/${Underpost.repo.engineRepoFactory(confId)}`;
1708
+ const ghpkg = Underpost.repo.ghpkgRepoFactory(confId);
1709
+ candidates.push(
1710
+ // Paired off the source rather than named on its own, so the conf repository and the
1711
+ // engine it configures can never be resolved apart.
1712
+ Underpost.repo.enginePairFactory({ engine: source, account: owner }).enginePrivate,
1713
+ source,
1714
+ `${owner}/${Underpost.repo.engineRepoFactory(confId, { test: true })}`,
1715
+ // The ghpkg mirror and the instance repositories run their own workflows against the
1716
+ // same token, so a rotation that skipped them would leave half the deploy behind.
1717
+ ...(ghpkg ? [`${owner}/${ghpkg}`] : []),
1718
+ ...(confId ? Underpost.repo.instanceRepos(`dd-${confId}`) : []),
1719
+ );
1720
+ }
1721
+ candidates.push(
1722
+ `${process.env.ENGINE_SRC_PRIVATE_REPO ?? ''}`.trim(),
1723
+ envSource,
1724
+ ...`${options.repos ?? ''}`.split(/[,|;\s]+/),
1725
+ );
1726
+ const targets = [];
1727
+ for (const candidate of candidates.filter(Boolean)) {
1728
+ let slug;
1729
+ try {
1730
+ slug = Underpost.repo.repoSlugFactory(candidate);
1731
+ } catch (error) {
1732
+ // One malformed extra target must not take down the rotation of the resolvable ones.
1733
+ logger.warn(`Ignoring unresolvable rotation target: ${candidate}`, { error: error.message });
1734
+ continue;
1735
+ }
1736
+ if (!targets.includes(slug)) targets.push(slug);
1737
+ }
1738
+ return targets;
1739
+ },
1740
+
1741
+ /**
1742
+ * @method plannedTokenSource
1743
+ * @description Names the source a rotation would take its token from, without reading,
1744
+ * minting or prompting for anything. Pure, so `--dry-run` can report the plan truthfully.
1745
+ * @param {object} [options={}] - Rotation options.
1746
+ * @param {string} [options.token] - Token supplied through `--args token=`.
1747
+ * @returns {string} Human-readable source name.
1748
+ * @memberof UnderpostSecret
1749
+ */
1750
+ plannedTokenSource(options = {}) {
1751
+ if (`${options.token ?? ''}`.trim()) return '--args token';
1752
+ if (stdinIsRedirected()) return 'piped stdin';
1753
+ if (`${process.env[GIT_AUTH_TOKEN_KEY] ?? ''}`.trim()) return `${GIT_AUTH_TOKEN_KEY} environment`;
1754
+ return process.stdin.isTTY ? 'interactive prompt' : '(unavailable: no token, nothing piped, no terminal)';
1755
+ },
1756
+
1757
+ /**
1758
+ * @method probeGitAuthTokenTargets
1759
+ * @description Splits resolved targets into those the current `gh` credential can actually
1760
+ * reach and those it cannot, without writing anything.
1761
+ *
1762
+ * A deploy does not necessarily own every repository its naming implies — a test source repo
1763
+ * often does not exist — so this is what keeps a fleet fan-out from aborting on the first
1764
+ * absent one, and what lets `--dry-run` report the real target set rather than the derived one.
1765
+ * @param {Array<string>} targets - `owner/repo` slugs.
1766
+ * @returns {{reachable: Array<string>, unreachable: Array<string>}} The split.
1767
+ * @memberof UnderpostSecret
1768
+ */
1769
+ probeGitAuthTokenTargets(targets = []) {
1770
+ const reachable = [];
1771
+ const unreachable = [];
1772
+ for (const repo of targets) {
1773
+ const view = shellExec(`gh repo view "${repo}" --json nameWithOwner,viewerPermission`, {
373
1774
  stdout: true,
374
1775
  silent: true,
375
1776
  silentOnError: true,
376
1777
  disableLog: true,
377
1778
  });
378
- return [...new Set(`${output || ''}`.match(/age1[0-9a-z]+/g) || [])];
379
- },
380
-
381
- /**
382
- * @method recipient
383
- * @description Derives the primary Age public recipient from the private key — the one new
384
- * manifests are encrypted to. The reverse is not possible, so this is safe to log and to
385
- * commit into `.sops.yaml`.
386
- * @returns {string} The `age1…` public recipient.
387
- * @memberof UnderpostSecret
388
- */
389
- recipient() {
390
- const keyFile = Underpost.secret.sops.keyFile();
391
- if (!fs.existsSync(keyFile)) throw new Error(`Age private key not found: ${keyFile}`);
392
- const recipients = Underpost.secret.sops.localRecipients();
393
- if (recipients.length === 0)
394
- throw new Error(`No Age identity could be read from ${keyFile}. Run: underpost secret sops --init`);
395
- return recipients[0];
396
- },
397
-
398
- /**
399
- * @method decryptable
400
- * @description Reports whether the local key can open a stored manifest, by set-intersecting
401
- * the manifest's plaintext `sops:` recipients with the identities this host holds. Needs no
402
- * decrypt attempt and no private key material, so it is safe to call as a pre-flight on every
403
- * manifest before the first mutation.
404
- * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
405
- * @param {Array<string>} [held] - Locally held recipients; resolved from the key file when omitted.
406
- * @returns {boolean} True when at least one recipient of the manifest is held locally.
407
- * @memberof UnderpostSecret
408
- */
409
- decryptable(manifestPath, held = Underpost.secret.sops.localRecipients()) {
410
- if (held.length === 0) return false;
411
- return Underpost.secret.sops.manifestRecipients(manifestPath).some((recipient) => held.includes(recipient));
412
- },
413
-
414
- /**
415
- * @method assertDecryptable
416
- * @description Fails closed, and legibly, on the store-adoption trap: a host that pulled an
417
- * encrypted store created elsewhere holds a key that is not among the manifests' recipients.
418
- * sops reports that as "no identity matched any of the recipients" from inside a decrypt
419
- * pipe, which names neither the manifest nor a way out; this raises first, listing every
420
- * unreadable manifest, the recipients it is sealed to, the identities this host actually
421
- * holds, and the three ways to resolve it.
422
- * @param {Array<{namespace: string, name: string, path: string}>} manifests - Manifests to check.
423
- * @memberof UnderpostSecret
424
- */
425
- assertDecryptable(manifests) {
426
- const held = Underpost.secret.sops.localRecipients();
427
- const unreadable = manifests.filter((manifest) => !Underpost.secret.sops.decryptable(manifest.path, held));
428
- if (unreadable.length === 0) return;
429
- const local =
430
- held.length > 0 ? held.join(', ') : `(none — no readable Age identity at ${Underpost.secret.sops.keyFile()})`;
431
- throw new Error(
432
- `${unreadable.length} encrypted manifest(s) are sealed to Age recipients this host does not hold, ` +
433
- `so they cannot be decrypted here:\n` +
434
- unreadable
435
- .map(
436
- (manifest) =>
437
- ` ${manifest.namespace}/${manifest.name} -> ` +
438
- `${Underpost.secret.sops.manifestRecipients(manifest.path).join(', ') || 'no age recipients'}`,
439
- )
440
- .join('\n') +
441
- `\n this host holds: ${local}\n` +
442
- `Resolve with exactly one of:\n` +
443
- ` 1. Install the key that already opens them — append the origin host's ` +
444
- `${Underpost.secret.sops.keyFile()} to this host's own (one file may hold several identities), ` +
445
- `chmod 600 it, then re-run.\n` +
446
- ` 2. Re-key the store from a host that still holds that key: ` +
447
- `underpost secret sops --rotate --recipient <this host's recipient>, commit engine-private/secrets, ` +
448
- `pull here, then re-run.\n` +
449
- ` 3. Re-onboard from this host's origin seed files, replacing the stored manifests: ` +
450
- `underpost run sops-setup --force. Valid only when those seed files carry the credentials the ` +
451
- `cluster already runs on — any regenerated value must also be applied to the running datastore.`,
452
- );
453
- },
454
-
455
- /**
456
- * @method init
457
- * @description Generates the Age keypair and the `.sops.yaml` creation rule when absent.
458
- * Idempotent, and never overwrites an existing key: regenerating would orphan every manifest
459
- * already encrypted to the previous recipient, with no way to recover them.
460
- * @memberof UnderpostSecret
461
- */
462
- init() {
463
- Underpost.secret.sops.assertTooling(['age-keygen', 'sops']);
464
- const keyFile = Underpost.secret.sops.keyFile();
465
- if (fs.existsSync(keyFile)) logger.info(`Age key already present; reusing ${keyFile}`);
1779
+ if (`${view}`.trim()) reachable.push(repo);
466
1780
  else {
467
- fs.ensureDirSync(keyFile.slice(0, keyFile.lastIndexOf('/')));
468
- shellExec(`umask 077 && age-keygen -o "${keyFile}"`);
1781
+ unreachable.push(repo);
1782
+ logger.warn(`${repo} does not resolve with the current gh credential; skipping.`);
469
1783
  }
470
- shellExec(`chmod 600 "${keyFile}"`);
471
-
472
- const recipient = Underpost.secret.sops.recipient();
473
- const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
474
- if (fs.existsSync(sopsConfPath)) {
475
- logger.info(`Creation rules already present; leaving ${sopsConfPath} intact`);
476
- Underpost.secret.sops.repairCreationRules();
477
- Underpost.secret.sops.ensureCreationRecipient(recipient);
478
- } else {
479
- fs.outputFileSync(
480
- sopsConfPath,
481
- [
482
- 'creation_rules:',
483
- ` - path_regex: ${SOPS_MANIFEST_PATH_REGEX}`,
484
- ` encrypted_regex: '${SOPS_ENCRYPTED_REGEX}'`,
485
- ` age: ${recipient}`,
486
- '',
487
- ].join('\n'),
488
- 'utf8',
489
- );
490
- logger.info(`Created ${sopsConfPath}`);
491
- }
492
- logger.info(`Age recipient: ${recipient}`);
493
- logger.warn(`Back up ${keyFile} offline. Without it every encrypted manifest is unrecoverable.`);
494
- },
495
-
496
- /**
497
- * @method ensureCreationRecipient
498
- * @description Registers this host's recipient in an inherited `.sops.yaml` so anything it
499
- * encrypts from now on, it can also decrypt. Without this, a host that pulled a store created
500
- * elsewhere encrypts to the *other* host's recipient only, producing manifests it cannot read
501
- * back — a failure that surfaces later as an opaque decrypt error rather than at write time.
502
- *
503
- * Strictly additive: no existing recipient loses access, and existing manifests are left
504
- * untouched, since re-keying them requires a private key that can still decrypt (see
505
- * {@link rotate}). Left alone when the rule lists no `age:` recipients at all, which means a
506
- * deliberately non-Age rule rather than a store this host should join.
507
- * @param {string} recipient - This host's `age1…` public recipient.
508
- * @returns {boolean} True when the creation rule was rewritten.
509
- * @memberof UnderpostSecret
510
- */
511
- ensureCreationRecipient(recipient) {
512
- const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
513
- if (!fs.existsSync(confPath) || !recipient) return false;
514
- const current = Underpost.secret.sops.creationRecipients();
515
- if (current.includes(recipient)) return false;
516
- if (current.length === 0) {
517
- logger.warn(
518
- `${confPath} declares no age recipients; leaving it untouched. Add ${recipient} manually if this ` +
519
- `host is meant to encrypt into this store.`,
1784
+ }
1785
+ return { reachable, unreachable };
1786
+ },
1787
+
1788
+ /**
1789
+ * @method stageGitAuthToken
1790
+ * @description Materializes the replacement token onto tmpfs at mode 600 — the single source
1791
+ * both the GitHub write and the manifest write read from.
1792
+ *
1793
+ * The value never travels as a command argument: `gh secret set` takes it on stdin and the
1794
+ * manifest is built by Node, so it reaches neither the process table nor the command log. An
1795
+ * interactive prompt writes straight into the staged file for the same reason — captured
1796
+ * stdout is logged, a file is not.
1797
+ *
1798
+ * Sources, in order: `--args token=`, piped stdin, the `GIT_AUTH_TOKEN` environment, then a
1799
+ * no-echo terminal prompt. Piping is the one that keeps a token out of both the process table
1800
+ * and the shell history, so it is what automation should use.
1801
+ * @param {string} stagePath - tmpfs path to write the token to.
1802
+ * @param {object} [options={}] - Token sources.
1803
+ * @param {string} [options.token] - The token itself, from `--args token=`.
1804
+ * @returns {{token: string, source: string}} The staged token and where it came from.
1805
+ * @memberof UnderpostSecret
1806
+ */
1807
+ stageGitAuthToken(stagePath, options = {}) {
1808
+ // `GITHUB_TOKEN` is deliberately not a source: it is the credential `gh` authenticates
1809
+ // *with*, which during a rotation is the outgoing token. Reading it here would re-set the
1810
+ // value being replaced and report a rotation that never happened.
1811
+ const provided = `${options.token ?? ''}`.trim();
1812
+ const inherited = `${process.env[GIT_AUTH_TOKEN_KEY] ?? ''}`.trim();
1813
+ let source;
1814
+ if (provided) {
1815
+ writeStageFileSync(stagePath, provided);
1816
+ source = '--args token';
1817
+ } else if (stdinIsRedirected()) {
1818
+ // Ahead of the environment: a pipe is what the operator chose for this run, while
1819
+ // GIT_AUTH_TOKEN may be an inherited export still holding the outgoing token.
1820
+ writeStageFileSync(stagePath, fs.readFileSync(0, 'utf8'));
1821
+ source = 'piped stdin';
1822
+ } else if (inherited) {
1823
+ writeStageFileSync(stagePath, inherited);
1824
+ source = `${GIT_AUTH_TOKEN_KEY} environment`;
1825
+ } else {
1826
+ if (!process.stdin.isTTY)
1827
+ throw new Error(
1828
+ `[secret] rotate needs the replacement token: pipe it in ` +
1829
+ `(printf %s "$TOKEN" | node bin secret rotate …), pass --args token=<token>, export ` +
1830
+ `${GIT_AUTH_TOKEN_KEY}, or run this from a terminal to be prompted.`,
520
1831
  );
521
- return false;
522
- }
523
- Underpost.secret.sops.writeCreationRecipients([...current, recipient]);
524
- logger.warn(
525
- `Registered this host's recipient in ${confPath} so manifests it encrypts stay readable here. ` +
526
- `Existing manifests are NOT re-keyed by this — run \`underpost secret sops --rotate --recipient ` +
527
- `${recipient}\` from a host that can still decrypt them, then commit ${SOPS_SECRETS_DIR}.`,
528
- { added: recipient, recipients: [...current, recipient] },
1832
+ // Created empty first so the file exists at mode 600 before anything is read into it.
1833
+ writeStageFileSync(stagePath, '');
1834
+ shellExec(
1835
+ `bash -c 'set -o pipefail; umask 077; read -rsp "New ${GIT_AUTH_TOKEN_KEY}: " value </dev/tty; ` +
1836
+ `echo >/dev/tty; printf %s "$value" > "${stagePath}"'`,
1837
+ { disableLog: true },
529
1838
  );
530
- return true;
531
- },
532
-
533
- /**
534
- * @method repairCreationRules
535
- * @description Rewrites a `path_regex` that can never match, in place, preserving recipients
536
- * and every other setting. Configs written before the relative-path semantics were understood
537
- * carry an `engine-private/secrets/` prefix; because sops matches relative to the directory
538
- * holding `.sops.yaml`, that rule matches nothing and every encrypt fails with
539
- * "no matching creation rules found". Repairs only that known-broken form, so a deliberately
540
- * customized rule is left alone.
541
- * @returns {boolean} True when the file was rewritten.
542
- * @memberof UnderpostSecret
543
- */
544
- repairCreationRules() {
545
- const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
546
- if (!fs.existsSync(confPath)) return false;
547
- const lines = fs.readFileSync(confPath, 'utf8').split('\n');
548
- // The rule is the first key of a YAML list item, so the line carries a `- ` marker that
549
- // has to be preserved: ` - path_regex: …`.
550
- const brokenRule = /^(\s*(?:-\s*)?)path_regex:\s*.*engine-private\/secrets\//;
551
- const index = lines.findIndex((line) => brokenRule.test(line));
552
- if (index === -1) return false;
553
- const prefix = lines[index].match(brokenRule)[1];
554
- const previous = lines[index].trim();
555
- lines[index] = `${prefix}path_regex: ${SOPS_MANIFEST_PATH_REGEX}`;
556
- fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
1839
+ source = 'interactive prompt';
1840
+ }
1841
+
1842
+ const raw = fs.readFileSync(stagePath, 'utf8');
1843
+ const token = raw.replace(/\r?\n$/, '');
1844
+ if (!token) throw new Error(`[secret] the replacement ${GIT_AUTH_TOKEN_KEY} is empty`);
1845
+ if (/\s/.test(token))
1846
+ throw new Error(`[secret] the replacement ${GIT_AUTH_TOKEN_KEY} contains whitespace; it is not a token`);
1847
+ if (token !== raw) writeStageFileSync(stagePath, token);
1848
+ if (!looksLikeGitHubToken(token))
557
1849
  logger.warn(
558
- `Repaired an unmatchable creation rule in ${confPath}: sops matches path_regex relative to ` +
559
- `that file's own directory, so the store prefix never matched.`,
560
- { from: previous, to: `path_regex: ${SOPS_MANIFEST_PATH_REGEX}` },
1850
+ `The replacement ${GIT_AUTH_TOKEN_KEY} does not match a known GitHub token shape ` +
1851
+ `(ghp_…, gho_…, github_pat_…). Continuing GitHub token formats have changed before.`,
561
1852
  );
562
- return true;
563
- },
564
-
565
- /**
566
- * @method encrypt
567
- * @description Encrypts a plaintext Secret manifest into the Git-tracked store and shreds the
568
- * source. Recipients resolve from the committed `.sops.yaml`, so a manifest cannot be
569
- * encrypted to an unlisted key. Author the plaintext under `/dev/shm` so it never touches
570
- * persistent storage.
571
- * Written via a staged temp file and moved into place only after the output validates. A
572
- * bare `sops … > out` redirect has the shell truncate `out` before sops runs, so a failed
573
- * encrypt would destroy an existing manifest and leave a zero-byte file in its place.
574
- * @param {string} plaintextPath - Path to the plaintext Secret manifest.
575
- * @param {string} [namespace='default'] - Target namespace directory in the store.
576
- * @param {object} [options={}] - Encryption options.
577
- * @param {boolean} [options.force=false] - Replace an existing manifest at the target path.
578
- * @returns {string} Path of the written encrypted manifest.
579
- * @memberof UnderpostSecret
580
- */
581
- encrypt(plaintextPath, namespace = 'default', options = {}) {
582
- Underpost.secret.sops.assertTooling(['sops']);
583
- if (!plaintextPath || !fs.existsSync(plaintextPath))
584
- throw new Error(`Plaintext manifest not found: ${plaintextPath}`);
585
- const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
586
- if (!fs.existsSync(sopsConfPath))
587
- throw new Error(`Missing creation rules: ${sopsConfPath} (run: underpost secret sops --init)`);
588
-
589
- const sourceMeta = Underpost.secret.sops.manifestMeta(plaintextPath);
590
- if (sourceMeta.encrypted)
591
- throw new Error(
592
- `${plaintextPath} already carries sops metadata. Re-encrypting would double-wrap it; ` +
593
- `edit it in place with: sops ${plaintextPath}`,
594
- );
1853
+ return { token, source };
1854
+ },
595
1855
 
596
- const name = plaintextPath
597
- .split('/')
598
- .pop()
599
- .replace(/\.ya?ml$/, '');
600
- const outPath = Underpost.secret.sops.manifestPath(name, namespace);
601
- if (fs.existsSync(outPath) && !options.force)
602
- throw new Error(`${outPath} already exists. Edit it with \`sops ${outPath}\`, or pass --force to replace.`);
603
- fs.ensureDirSync(`${SOPS_SECRETS_DIR}/${namespace}`);
604
-
605
- // Encrypt to a temp file and move into place only on success. A bare `sops … > out` has the
606
- // shell truncate `out` before sops runs, so a failed encrypt destroys the manifest that was
607
- // already there and leaves an empty file the apply path would happily skip over.
608
- const stagePath = `${outPath}.staged`;
609
- try {
610
- // `--filename-override` makes sops match creation_rules against the destination path
611
- // rather than the tmpfs source. Without it the rule never matches, because the plaintext
612
- // deliberately lives outside the store (in /dev/shm) and is not named `*.enc.yaml`.
613
- shellExec(
614
- `sops --config "${sopsConfPath}" --filename-override "${outPath}" ` +
615
- `--encrypt "${plaintextPath}" > "${stagePath}"`,
616
- );
617
- Underpost.secret.sops.assertManifest(stagePath, { name });
618
- // Sealing to a recipient held elsewhere is legitimate (encrypting *for* another host),
619
- // so this warns rather than fails — but it is also the shape of the store-adoption trap,
620
- // where it would otherwise only surface at the next apply.
621
- if (!Underpost.secret.sops.decryptable(stagePath))
622
- logger.warn(
623
- `${outPath} is sealed to ${Underpost.secret.sops.manifestRecipients(stagePath).join(', ')}, none of ` +
624
- `which this host holds a private key for — it cannot be decrypted here. Add this host's recipient ` +
625
- `to ${sopsConfPath} and re-encrypt if that is not intended.`,
626
- );
627
- fs.moveSync(stagePath, outPath, { overwrite: true });
628
- } finally {
629
- fs.removeSync(stagePath);
630
- }
1856
+ /**
1857
+ * @method writeGitAuthTokenManifest
1858
+ * @description Records the token in the encrypted store as `git-auth-token`, replacing the
1859
+ * stored manifest.
1860
+ *
1861
+ * The plaintext is written by Node onto tmpfs at mode 600 and handed to {@link encrypt},
1862
+ * which stages to a temp file, validates the envelope, moves into place only on success and
1863
+ * shreds the source — so a failed encrypt cannot leave a truncated manifest where a readable
1864
+ * one was. The data key keeps the `GIT_AUTH_TOKEN` spelling, so a workload reading it through
1865
+ * `envFrom` gets the environment variable its tooling already expects.
1866
+ * @param {string} token - The token to store.
1867
+ * @param {string} [namespace='default'] - Store namespace.
1868
+ * @returns {string} Path of the written encrypted manifest.
1869
+ * @memberof UnderpostSecret
1870
+ */
1871
+ writeGitAuthTokenManifest(token, namespace = 'default') {
1872
+ const stageDir = stageDirSync(GIT_AUTH_TOKEN_STAGE_DIR);
1873
+ const plaintextPath = `${stageDir}/${GIT_AUTH_TOKEN_SECRET}.yaml`;
1874
+ writeStageFileSync(
1875
+ plaintextPath,
1876
+ [
1877
+ 'apiVersion: v1',
1878
+ 'kind: Secret',
1879
+ 'metadata:',
1880
+ ` name: ${GIT_AUTH_TOKEN_SECRET}`,
1881
+ ` namespace: ${namespace}`,
1882
+ ' labels:',
1883
+ ' app.kubernetes.io/managed-by: underpost',
1884
+ 'type: Opaque',
1885
+ 'stringData:',
1886
+ // Single-quoted YAML scalar with doubled internal quotes: a token is opaque and may
1887
+ // carry characters YAML would otherwise interpret.
1888
+ ` ${GIT_AUTH_TOKEN_KEY}: '${`${token}`.replace(/'/g, "''")}'`,
1889
+ '',
1890
+ ].join('\n'),
1891
+ );
1892
+ return Underpost.secret.encrypt(plaintextPath, namespace, { force: true });
1893
+ },
631
1894
 
632
- shellExec(`shred -u "${plaintextPath}" 2>/dev/null || rm -f "${plaintextPath}"`, { silentOnError: true });
633
- logger.info(`Encrypted -> ${outPath}`);
634
- return outPath;
635
- },
636
-
637
- /**
638
- * @method apply
639
- * @description Decrypts every encrypted manifest for a namespace and streams each one
640
- * directly into `kubectl apply -f -`. Plaintext exists only in an anonymous kernel pipe.
641
- * @param {string} [namespace='default'] - Target namespace.
642
- * @param {object} [options={}] - Apply options.
643
- * @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
644
- * @returns {number} Count of manifests applied.
645
- * @memberof UnderpostSecret
646
- */
647
- apply(namespace = 'default', options = {}) {
648
- const dir = `${SOPS_SECRETS_DIR}/${namespace}`;
649
- if (!fs.existsSync(dir)) throw new Error(`No encrypted secrets for namespace: ${namespace}`);
650
- const manifests = Underpost.secret.sops.manifests(namespace);
651
- if (manifests.length === 0) throw new Error(`No *${SOPS_MANIFEST_EXT} manifests under ${dir}`);
652
-
653
- // Validate-then-commit. Applying in a single pass means manifest N failing to decrypt
654
- // leaves 1..N-1 already live — a half-rotated namespace nobody asked for. The envelope
655
- // check plus a server dry run of every manifest catches wrong-key, malformed-YAML, schema
656
- // and RBAC failures before the first mutation.
657
- for (const manifest of manifests)
658
- Underpost.secret.sops.assertManifest(manifest.path, { name: manifest.name, namespace });
659
- Underpost.secret.sops.assertDecryptable(manifests);
660
- if (!options.dryRun)
661
- for (const manifest of manifests)
662
- Underpost.secret.sops.applyManifest(manifest.path, namespace, { ...options, dryRun: true, quiet: true });
663
-
664
- for (const manifest of manifests) Underpost.secret.sops.applyManifest(manifest.path, namespace, options);
665
- logger.info(`${options.dryRun ? 'Validated' : 'Applied'} ${manifests.length} manifest(s) in ns/${namespace}`);
666
- return manifests.length;
667
- },
668
-
669
- /**
670
- * @method applyManifest
671
- * @description Streams one encrypted manifest through `sops --decrypt` into `kubectl apply`.
672
- * Runs under an explicit `bash -c` with `pipefail`, which is load-bearing: without it a sops
673
- * failure yields an empty stream and `kubectl apply -f -` exits 0, silently applying nothing.
674
- * `disableLog` keeps the command (and therefore the key path) out of the log stream.
675
- * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
676
- * @param {string} [namespace='default'] - Target namespace.
677
- * @param {object} [options={}] - Apply options.
678
- * @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
679
- * @param {string} [options.expectName] - Require this `metadata.name` in the manifest envelope.
680
- * @param {boolean} [options.quiet=false] - Suppress the per-manifest log line.
681
- * @memberof UnderpostSecret
682
- */
683
- applyManifest(manifestPath, namespace = 'default', options = {}) {
684
- // Envelope first: it needs no private key, so a malformed or unencrypted store is reported
685
- // as such even on a host whose key is missing or wrongly permissioned.
686
- Underpost.secret.sops.assertManifest(manifestPath, { name: options.expectName, namespace });
687
- // Recipient set next: a manifest sealed to a key this host does not hold fails inside the
688
- // decrypt pipe with an error that names neither the file nor a remedy.
689
- Underpost.secret.sops.assertDecryptable([
690
- { namespace, name: options.expectName || manifestPath.split('/').pop(), path: manifestPath },
691
- ]);
692
- Underpost.secret.sops.assertTooling(['sops']);
693
- const keyFile = Underpost.secret.sops.assertKeyFile();
694
- const dryRun = options.dryRun ? ' --dry-run=server' : '';
695
- shellExec(
696
- `bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifestPath}" ` +
697
- `| kubectl apply -f -${dryRun} -n "${namespace}"'`,
698
- { disableLog: true },
1895
+ /**
1896
+ * @method rotateGitAuthToken
1897
+ * @description Replaces the `GIT_AUTH_TOKEN` Actions secret on every repository a deploy
1898
+ * authenticates with, and records the new value in the encrypted store.
1899
+ *
1900
+ * GitHub is written first and the store second, because the token is only real once GitHub
1901
+ * holds it: a store that leads GitHub records a credential no workflow can use, while a
1902
+ * GitHub that leads the store converges on the next run. Every write is idempotent, so a run
1903
+ * that failed part way is re-runnable with the same token.
1904
+ *
1905
+ * A target that does not resolve is reported and skipped rather than aborting the rotation —
1906
+ * a deploy does not necessarily own every repository its naming implies, and a missing test
1907
+ * source repo must not leave the private conf repo un-rotated. A target that resolves but
1908
+ * fails to write is collected and raised at the end, after the repositories that did succeed
1909
+ * are on record.
1910
+ *
1911
+ * The token never appears as a command argument: `gh secret set` reads it from a tmpfs file
1912
+ * on stdin, so it reaches neither the process table nor the command log.
1913
+ *
1914
+ * Usage:
1915
+ * node bin secret rotate --args "secret=GIT_AUTH_TOKEN,token=<new>,deploy-id=dd-cyberia"
1916
+ * node bin secret rotate --args "secret=GIT_AUTH_TOKEN,deploy-id=dd" --dry-run # whole fleet
1917
+ * node bin secret rotate --args "secret=GIT_AUTH_TOKEN,token=<new>,deploy-id=dd"
1918
+ * node bin secret rotate --args "secret=GIT_AUTH_TOKEN,store=true,apply=true"
1919
+ * @param {object} [options={}] - Rotation options.
1920
+ * @param {string} [options.token] - Replacement token. When omitted: piped stdin, then the
1921
+ * `GIT_AUTH_TOKEN` environment, then a no-echo terminal prompt.
1922
+ * @param {string} [options.deployId] - Deploy id, a list separated by `|`, `;` or whitespace,
1923
+ * `dd` for every deploy in `engine-private/deploy/dd.routes`, or `template`.
1924
+ * @param {string} [options.owner] - GitHub owner for the resolved repository names.
1925
+ * @param {string} [options.repos] - Extra `owner/repo` targets, separated by `|`, `;` or space.
1926
+ * @param {string} [options.namespace='default'] - Store namespace for the mirrored manifest.
1927
+ * @param {boolean} [options.store=false] - Mirror into the encrypted store even when no
1928
+ * manifest is stored yet. An existing manifest is always updated.
1929
+ * @param {boolean} [options.apply=false] - Project the updated manifest into the cluster.
1930
+ * @param {boolean} [options.dryRun=false] - Report the plan without contacting GitHub,
1931
+ * prompting, or writing anything.
1932
+ * @returns {{targets: Array<string>, rotated: Array<string>, unreachable: Array<string>,
1933
+ * failed: Array<string>, manifest: string, store: boolean, tokenSource: string}} Outcome.
1934
+ * @memberof UnderpostSecret
1935
+ */
1936
+ rotateGitAuthToken(options = {}) {
1937
+ const namespace = options.namespace || 'default';
1938
+ const targets = Underpost.secret.gitAuthTokenTargets(options);
1939
+ if (targets.length === 0)
1940
+ throw new Error(
1941
+ `[secret] no repository resolved for ${GIT_AUTH_TOKEN_KEY} rotation. Name the deploy with ` +
1942
+ `--args deploy-id=<id>, or the repositories with --args "repos=owner/repo|owner/other".`,
699
1943
  );
700
- if (!options.quiet) logger.info(`${options.dryRun ? 'Dry-run' : 'Applied'} ${manifestPath} -> ns/${namespace}`);
701
- },
702
-
703
- /**
704
- * @method applyIfPresent
705
- * @description Applies a secret from the SOPS store when an encrypted manifest exists,
706
- * reporting whether it did. Single decision point for callers that keep an origin seed path
707
- * for clusters not yet onboarded to the encrypted store.
708
- *
709
- * Falls back to the seed path only when the manifest is *absent*. A manifest that exists but
710
- * is corrupt, unencrypted, or names a different Secret raises instead: sliding back to the
711
- * seed path there would mask a tampered store and silently deploy stale credentials the
712
- * operator believes were replaced.
713
- * @param {string} name - Secret name.
714
- * @param {string} [namespace='default'] - Kubernetes namespace.
715
- * @param {object} [options={}] - Apply options forwarded to {@link applyManifest}.
716
- * @returns {boolean} True when the encrypted manifest was applied.
717
- * @memberof UnderpostSecret
718
- */
719
- applyIfPresent(name, namespace = 'default', options = {}) {
720
- if (!Underpost.secret.sops.has(name, namespace)) return false;
721
- Underpost.secret.sops.applyManifest(Underpost.secret.sops.manifestPath(name, namespace), namespace, {
722
- ...options,
723
- expectName: name,
1944
+ const stored = Underpost.secret.has(GIT_AUTH_TOKEN_SECRET, namespace);
1945
+ const store = stored || options.store === true || `${options.store}` === 'true';
1946
+ const ghReady = Underpost.secret.hasBinary('gh');
1947
+ // Advisory, not a gate: `gh auth status` also exits non-zero for a logged-in account whose
1948
+ // token merely lacks an optional scope. Reachability of the targets is the real
1949
+ // precondition, so this is captured to explain a failure rather than to cause one.
1950
+ const ghAuth = ghReady
1951
+ ? shellExec(`gh auth status 2>&1`, { silent: true, silentOnError: true, disableLog: true })
1952
+ : null;
1953
+ const ghAuthenticated = ghAuth?.code === 0;
1954
+ const ghAuthOutput = `${ghAuth?.stdout ?? ''}`.trim() || '(no output)';
1955
+ // gh prefers GH_TOKEN/GITHUB_TOKEN over the account `gh auth login` stored, and this engine's
1956
+ // own host store exports GITHUB_TOKEN — so a stale one silently shadows a working login and
1957
+ // every probe fails against a credential the operator never chose.
1958
+ const shadowing = ['GH_TOKEN', 'GITHUB_TOKEN'].filter((key) => `${process.env[key] ?? ''}`.trim());
1959
+
1960
+ if (options.dryRun) {
1961
+ // Probing is a read, so the plan reports the targets that actually exist rather than the
1962
+ // ones the naming derived. Nothing is minted, prompted for, or written.
1963
+ const probed = ghReady ? Underpost.secret.probeGitAuthTokenTargets(targets) : null;
1964
+ logger.info(`[dry-run] ${GIT_AUTH_TOKEN_KEY} rotation plan`, {
1965
+ targets,
1966
+ wouldRotate: probed ? probed.reachable : '(not probed)',
1967
+ unreachable: probed ? probed.unreachable : '(not probed)',
1968
+ namespace,
1969
+ from: Underpost.secret.plannedTokenSource(options),
1970
+ gh: ghReady ? (ghAuthenticated ? 'authenticated' : 'not authenticated') : 'missing',
1971
+ manifest: store ? Underpost.secret.manifestPath(GIT_AUTH_TOKEN_SECRET, namespace) : '(store untouched)',
1972
+ storedManifest: stored,
724
1973
  });
725
- return true;
726
- },
727
-
728
- /**
729
- * @method list
730
- * @description Lists encrypted manifests with their Age recipients. Reads only the plaintext
731
- * `sops:` metadata block, so no private key is required and this is safe to run anywhere.
732
- * @memberof UnderpostSecret
733
- */
734
- list() {
735
- const manifests = Underpost.secret.sops.manifests();
736
- if (manifests.length === 0) return logger.warn(`No encrypted manifests under ${SOPS_SECRETS_DIR}`);
737
- for (const manifest of manifests) {
738
- const recipients = Underpost.secret.sops.manifestRecipients(manifest.path);
739
- console.log(
740
- `${manifest.namespace}/${manifest.name}${SOPS_MANIFEST_EXT} -> ${
741
- recipients.join(', ') || 'no age recipients'
742
- }`,
743
- );
744
- }
745
- },
746
-
747
- /**
748
- * @method manifestRecipients
749
- * @description Extracts the Age recipients an encrypted manifest is sealed to, from its
750
- * plaintext `sops:` metadata block. Requires no private key.
751
- * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
752
- * @returns {Array<string>} Recipients that can decrypt the manifest.
753
- * @memberof UnderpostSecret
754
- */
755
- manifestRecipients(manifestPath) {
756
- const content = fs.readFileSync(manifestPath, 'utf8');
757
- return [...content.matchAll(/recipient:\s*(age1\S+)/g)].map((match) => match[1]);
758
- },
759
-
760
- /**
761
- * @method rotate
762
- * @description Re-keys every encrypted manifest onto a new Age recipient after key
763
- * compromise or scheduled rotation. Secret *values* are untouched: `sops updatekeys` only
764
- * re-wraps each file's data key, so no workload restart is needed. Requires a private key
765
- * that can still decrypt, so rotation must run before the outgoing key is destroyed.
766
- *
767
- * Additive by default (the outgoing recipient keeps working, which is what a scheduled
768
- * rotation wants). `options.pruneRecipients` makes the new recipient the only one, which is
769
- * what a compromise wants — and which also revokes every *other* operator and CI/CD key in
770
- * the rule, so it additionally requires `options.force` after showing exactly what is lost.
771
- * @param {string} recipient - Incoming `age1…` public recipient.
772
- * @param {object} [options={}] - Rotation options.
773
- * @param {boolean} [options.pruneRecipients=false] - Drop all existing recipients.
774
- * @param {Array<string>|string} [options.keepRecipients] - Recipients to retain while pruning
775
- * (e.g. the CI/CD key), as an array or comma-separated list.
776
- * @param {boolean} [options.force=false] - Confirm an irreversible prune.
777
- * @param {boolean} [options.dryRun=false] - Report the plan without rewriting anything.
778
- * @returns {{recipients: Array<string>, revoked: Array<string>, rekeyed: number}} Outcome.
779
- * @memberof UnderpostSecret
780
- */
781
- rotate(recipient, options = {}) {
782
- if (!recipient) throw new Error('Rotation requires --recipient <age-public-key>');
783
- if (!/^age1[0-9a-z]{20,}$/.test(recipient))
784
- throw new Error(`Not a valid Age public recipient: ${recipient} (expected age1…)`);
785
-
786
- Underpost.secret.sops.assertTooling(['sops']);
787
- const keyFile = Underpost.secret.sops.assertKeyFile();
788
-
789
- const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
790
- if (!fs.existsSync(confPath))
791
- throw new Error(`Missing creation rules: ${confPath} (run: underpost secret sops --init)`);
792
-
793
- const keep = (
794
- Array.isArray(options.keepRecipients) ? options.keepRecipients : `${options.keepRecipients || ''}`.split(',')
795
- )
796
- .map((value) => value.trim())
797
- .filter(Boolean);
798
- const current = Underpost.secret.sops.creationRecipients();
799
- const next = options.pruneRecipients
800
- ? [...new Set([recipient, ...keep])]
801
- : [...new Set([...current, recipient])];
802
- const revoked = current.filter((existing) => !next.includes(existing));
803
- const manifests = Underpost.secret.sops.manifests();
804
- // `updatekeys` re-wraps each data key, which means decrypting it first. A host that cannot
805
- // read the store cannot rotate it, dry run included — reporting a plan that can never run
806
- // is what sends an operator down the wrong remedy.
807
- Underpost.secret.sops.assertDecryptable(manifests);
808
-
809
- if (options.dryRun) {
810
- logger.info('Rotation plan (dry run)', { from: current, to: next, revoked, manifests: manifests.length });
811
- if (revoked.length)
812
- logger.warn(
813
- `${revoked.length} recipient(s) would permanently lose access. Confirm none is a CI/CD or ` +
814
- `co-operator key before re-running with --force.`,
815
- { revoked },
1974
+ if (!ghReady) logger.warn('gh is not on PATH; this plan cannot run until the GitHub CLI is installed.');
1975
+ else if (!ghAuthenticated)
1976
+ logger.warn('gh is not authenticated, so no target could be probed. Run `gh auth login`.');
1977
+ return {
1978
+ targets,
1979
+ rotated: [],
1980
+ unreachable: probed ? probed.unreachable : [],
1981
+ failed: [],
1982
+ manifest: '',
1983
+ store,
1984
+ tokenSource: '',
1985
+ };
1986
+ }
1987
+
1988
+ if (!ghReady)
1989
+ throw new Error(
1990
+ `gh not found in PATH. Install the GitHub CLI (https://cli.github.com), then authenticate it with ` +
1991
+ `\`gh auth login\` before rotating ${GIT_AUTH_TOKEN_KEY}.`,
1992
+ );
1993
+ if (!ghAuthenticated)
1994
+ logger.warn(`\`gh auth status\` exited non-zero; continuing if the targets are reachable.`, {
1995
+ status: ghAuthOutput,
1996
+ });
1997
+
1998
+ // Probed before the token is staged: nothing can be written to an unreachable set, and
1999
+ // prompting for a credential that has nowhere to go wastes the operator's paste.
2000
+ const probed = Underpost.secret.probeGitAuthTokenTargets(targets);
2001
+ if (probed.reachable.length === 0)
2002
+ throw new Error(
2003
+ `None of the ${targets.length} target(s) is reachable with the current gh credential, so ` +
2004
+ `${GIT_AUTH_TOKEN_KEY} was not rotated and nothing was written. Writing an Actions secret ` +
2005
+ `needs the \`repo\` scope and admin on each repository.\n` +
2006
+ `Targets: ${targets.join(', ')}\n` +
2007
+ (shadowing.length
2008
+ ? `${shadowing.join(' and ')} is set here, and gh uses it in preference to the account ` +
2009
+ `\`gh auth login\` stored. If gh calls it invalid below, run \`unset ` +
2010
+ `${shadowing.join(' ')}\` and try again.\n`
2011
+ : '') +
2012
+ `\`gh auth status\` reports:\n${ghAuthOutput}`,
2013
+ );
2014
+
2015
+ stageDirSync(GIT_AUTH_TOKEN_STAGE_DIR);
2016
+ const stagePath = `${GIT_AUTH_TOKEN_STAGE_DIR}/${GIT_AUTH_TOKEN_KEY}`;
2017
+ const rotated = [];
2018
+ const unreachable = probed.unreachable;
2019
+ const failed = [];
2020
+ let manifest = '';
2021
+ let tokenSource = '';
2022
+ try {
2023
+ const staged = Underpost.secret.stageGitAuthToken(stagePath, options);
2024
+ tokenSource = staged.source;
2025
+ logger.info(`Rotating ${GIT_AUTH_TOKEN_KEY}`, { targets, namespace, from: tokenSource, store });
2026
+
2027
+ for (const repo of probed.reachable) {
2028
+ try {
2029
+ // The token arrives on stdin from the staged file, never as an argument.
2030
+ shellExec(
2031
+ `bash -c 'set -o pipefail; gh secret set ${GIT_AUTH_TOKEN_KEY} --repo "${repo}" < "${stagePath}"'`,
2032
+ { silent: true, disableLog: true },
816
2033
  );
817
- return { recipients: next, revoked, rekeyed: 0 };
2034
+ rotated.push(repo);
2035
+ logger.info(`${GIT_AUTH_TOKEN_KEY} set on ${repo}`);
2036
+ } catch (error) {
2037
+ failed.push(repo);
2038
+ logger.error(`${GIT_AUTH_TOKEN_KEY} could not be set on ${repo}`, { error: error.message });
2039
+ }
818
2040
  }
819
2041
 
820
- // A prune revokes every recipient not explicitly retained — including CI/CD keys the
821
- // operator may not have in mind. Irreversible for anyone holding only a revoked key, so it
822
- // is gated behind an explicit confirmation that has to be made after seeing the list.
823
- if (revoked.length && !options.force)
2042
+ if (rotated.length === 0)
824
2043
  throw new Error(
825
- `Refusing to revoke ${revoked.length} recipient(s) without --force: ${revoked.join(', ')}. ` +
826
- `Review with --dry-run, retain any CI/CD key via --keep-recipients <age1…>, then re-run with --force.`,
2044
+ `${GIT_AUTH_TOKEN_KEY} could not be written to any of the ${probed.reachable.length} reachable ` +
2045
+ `target(s): ${probed.reachable.join(', ')}. The encrypted store was left untouched, so it still ` +
2046
+ `records the credential GitHub is running on.`,
827
2047
  );
828
2048
 
829
- Underpost.secret.sops.writeCreationRecipients(next);
830
- for (const manifest of manifests) {
831
- // `updatekeys` decrypts the data key with a held private key and re-wraps it for the
832
- // recipients now in `.sops.yaml`. disableLog keeps the key path out of the log stream.
833
- shellExec(
834
- `bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" ` +
835
- `sops --config "${confPath}" updatekeys --yes "${manifest.path}"'`,
836
- { disableLog: true, silent: true },
2049
+ if (store) manifest = Underpost.secret.writeGitAuthTokenManifest(staged.token, namespace);
2050
+ else
2051
+ logger.info(
2052
+ `No ${GIT_AUTH_TOKEN_SECRET} manifest in ns/${namespace}; the store was left untouched. ` +
2053
+ `Pass --args store=true to mirror this token into it.`,
837
2054
  );
838
- // updatekeys is a no-op when it decides nothing changed, and its exit code does not
839
- // distinguish that from a successful re-key. Confirm against the file itself, so a
840
- // rotation can never be reported as done while a manifest stays on the old recipient.
841
- const sealed = Underpost.secret.sops.manifestRecipients(manifest.path);
842
- if (!sealed.includes(recipient))
843
- throw new Error(
844
- `${manifest.path} is still sealed to ${sealed.join(', ') || 'no recipients'} after updatekeys; ` +
845
- `expected ${recipient}. Rotation aborted with the store partially re-keyed — re-run once resolved.`,
846
- );
847
- logger.info(`Re-keyed ${manifest.namespace}/${manifest.name}`);
848
- }
849
2055
 
850
- logger.info(`Rotated ${manifests.length} manifest(s)`, { recipients: next, revoked });
851
- if (revoked.length)
852
- logger.warn(
853
- `Revoked ${revoked.length} recipient(s); those keys can no longer decrypt any manifest. ` +
854
- `Every host applying these secrets now needs a private key for one of: ${next.join(', ')}.`,
855
- { revoked },
856
- );
857
- return { recipients: next, revoked, rekeyed: manifests.length };
858
- },
859
-
860
- /**
861
- * @method purge
862
- * @description Emergency removal of one secret: deletes the live Kubernetes Secret and takes
863
- * its encrypted manifest out of the store. The manifest is archived rather than deleted so
864
- * the purge stays reversible; `options.force` deletes it outright.
865
- *
866
- * Removing the manifest is what re-arms the origin seed path — with no `.enc.yaml`,
867
- * `applyIfPresent` returns false and cluster init seeds the secret from the plaintext
868
- * credential files instead. Whether that seed path is actually available is reported, not
869
- * assumed: purging a secret whose seed files are gone leaves workloads with an unresolvable
870
- * `secretKeyRef`, so the gap is surfaced at purge time rather than at the next deploy.
871
- * @param {string} name - Secret name (e.g. 'postgres-secret').
872
- * @param {object} [options={}] - Purge options.
873
- * @param {string} [options.namespace='default'] - Namespace of the live Secret.
874
- * @param {boolean} [options.force=false] - Delete the manifest instead of archiving it.
875
- * @param {boolean} [options.dryRun=false] - Report what would happen without changing anything.
876
- * @returns {{deleted: boolean, archived: string, seedFallback: boolean}} Purge outcome.
877
- * @memberof UnderpostSecret
878
- */
879
- purge(name, options = {}) {
880
- if (!name) throw new Error('Purge requires a secret name');
881
- const namespace = options.namespace || 'default';
882
- const manifestPath = Underpost.secret.sops.manifestPath(name, namespace);
883
- const seedSources = Object.values(Underpost.secret.sops.seedSources(name));
884
- const seedFallback = seedSources.length > 0 && seedSources.every((source) => fs.existsSync(source));
885
-
886
- if (options.dryRun) {
887
- logger.info('Purge plan (dry run)', {
888
- secret: `${namespace}/${name}`,
889
- manifest: fs.existsSync(manifestPath) ? manifestPath : 'absent',
890
- disposition: options.force ? 'delete' : 'archive',
891
- seedFallbackAvailable: seedFallback,
892
- });
893
- return { deleted: false, archived: '', seedFallback };
894
- }
2056
+ if (manifest && (options.apply === true || `${options.apply}` === 'true'))
2057
+ Underpost.secret.applyIfPresent(GIT_AUTH_TOKEN_SECRET, namespace);
2058
+ } finally {
2059
+ shellExec(`shred -u "${stagePath}" 2>/dev/null || rm -f "${stagePath}"`, {
2060
+ silentOnError: true,
2061
+ silent: true,
2062
+ disableLog: true,
2063
+ });
2064
+ fs.removeSync(GIT_AUTH_TOKEN_STAGE_DIR);
2065
+ }
895
2066
 
896
- shellExec(`kubectl delete secret ${name} -n ${namespace} --ignore-not-found`);
897
-
898
- let archived = '';
899
- if (!fs.existsSync(manifestPath)) logger.warn(`No encrypted manifest to remove at ${manifestPath}`);
900
- else if (options.force) {
901
- fs.removeSync(manifestPath);
902
- logger.warn(`Deleted ${manifestPath}`);
903
- } else {
904
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
905
- archived = `${SOPS_ARCHIVE_DIR}/${namespace}/${name}.${stamp}${SOPS_MANIFEST_EXT}`;
906
- fs.ensureDirSync(`${SOPS_ARCHIVE_DIR}/${namespace}`);
907
- fs.moveSync(manifestPath, archived);
908
- logger.info(`Archived ${manifestPath} -> ${archived}`);
909
- }
2067
+ const report = { targets, rotated, unreachable, failed, manifest, store, tokenSource };
2068
+ if (failed.length > 0)
2069
+ throw new Error(
2070
+ `${GIT_AUTH_TOKEN_KEY} was rotated on ${rotated.join(', ')} but failed on ${failed.join(', ')}. ` +
2071
+ `Those repositories still hold the previous token, so the fleet is split across two credentials. ` +
2072
+ `Re-run once resolved: every target is written again, and a minted token is reissued, so the ` +
2073
+ `fleet converges on one value either way.`,
2074
+ );
2075
+ // Built explicitly rather than spread from `report`: the redactor blanks any field whose
2076
+ // name carries "token", which would hide the source label behind [REDACTED].
2077
+ logger.info(`${GIT_AUTH_TOKEN_KEY} rotation complete`, {
2078
+ targets,
2079
+ rotated,
2080
+ unreachable,
2081
+ failed,
2082
+ manifest,
2083
+ store,
2084
+ from: tokenSource,
2085
+ });
2086
+ return report;
2087
+ },
910
2088
 
911
- if (seedFallback)
912
- logger.info(`Origin seed path is available for ${name}; cluster init will seed from it.`, {
913
- sources: seedSources,
914
- });
915
- else if (seedSources.length > 0)
916
- logger.warn(
917
- `No origin seed path for ${name}. Re-encrypt a manifest or create the secret manually ` +
918
- `before redeploying workloads that mount it.`,
919
- { expected: seedSources },
920
- );
2089
+ /**
2090
+ * @method purge
2091
+ * @description Emergency removal of one secret: deletes the live Kubernetes Secret and takes
2092
+ * its encrypted manifest out of the store. The manifest is archived rather than deleted so
2093
+ * the purge stays reversible; `options.force` deletes it outright.
2094
+ *
2095
+ * Removing the manifest is what re-arms the origin seed path with no `.enc.yaml`,
2096
+ * `applyIfPresent` returns false and cluster init seeds the secret from the plaintext
2097
+ * credential files instead. Whether that seed path is actually available is reported, not
2098
+ * assumed: purging a secret whose seed files are gone leaves workloads with an unresolvable
2099
+ * `secretKeyRef`, so the gap is surfaced at purge time rather than at the next deploy.
2100
+ * @param {string} name - Secret name (e.g. 'postgres-secret').
2101
+ * @param {object} [options={}] - Purge options.
2102
+ * @param {string} [options.namespace='default'] - Namespace of the live Secret.
2103
+ * @param {boolean} [options.force=false] - Delete the manifest instead of archiving it.
2104
+ * @param {boolean} [options.dryRun=false] - Report what would happen without changing anything.
2105
+ * @returns {{deleted: boolean, archived: string, seedFallback: boolean}} Purge outcome.
2106
+ * @memberof UnderpostSecret
2107
+ */
2108
+ purge(name, options = {}) {
2109
+ if (!name) throw new Error('Purge requires a secret name');
2110
+ const namespace = options.namespace || 'default';
2111
+ const manifestPath = Underpost.secret.manifestPath(name, namespace);
2112
+ const seedSources = Object.values(Underpost.secret.seedSources(name));
2113
+ const envKeys = Object.keys(Underpost.secret.seedEnvKeys(name));
2114
+ const envValues = Underpost.secret.seedEnvValues(name);
2115
+ const seedFallback =
2116
+ (seedSources.length > 0 && seedSources.every((source) => fs.existsSync(source))) ||
2117
+ (envKeys.length > 0 && envKeys.every((key) => envValues[key] !== undefined));
921
2118
 
922
- return { deleted: true, archived, seedFallback };
923
- },
924
-
925
- /**
926
- * @method manifests
927
- * @description Enumerates every encrypted manifest in the store, or in one namespace.
928
- * Dot-prefixed entries (`.archive`, `.sops.yaml`) are never treated as namespaces.
929
- * @param {string} [namespace] - Restrict to one namespace; omit for the whole store.
930
- * @returns {Array<{namespace: string, name: string, path: string}>} Manifest descriptors, sorted.
931
- * @memberof UnderpostSecret
932
- */
933
- manifests(namespace) {
934
- if (!fs.existsSync(SOPS_SECRETS_DIR)) return [];
935
- const namespaces = namespace
936
- ? [namespace]
937
- : fs
938
- .readdirSync(SOPS_SECRETS_DIR)
939
- .filter((entry) => !entry.startsWith('.') && fs.statSync(`${SOPS_SECRETS_DIR}/${entry}`).isDirectory())
940
- .sort();
941
- const found = [];
942
- for (const ns of namespaces) {
943
- const dir = `${SOPS_SECRETS_DIR}/${ns}`;
944
- if (!fs.existsSync(dir)) continue;
945
- for (const file of fs
946
- .readdirSync(dir)
947
- .filter((entry) => entry.endsWith(SOPS_MANIFEST_EXT))
948
- .sort())
949
- found.push({ namespace: ns, name: file.slice(0, -SOPS_MANIFEST_EXT.length), path: `${dir}/${file}` });
950
- }
951
- return found;
952
- },
953
-
954
- /**
955
- * @method creationRecipients
956
- * @description Reads the Age recipients from the committed `.sops.yaml` creation rule,
957
- * accepting both the single-line (`age: k1,k2`) and folded (`age: >-`) forms sops permits.
958
- * @returns {Array<string>} Recipients currently configured for encryption.
959
- * @memberof UnderpostSecret
960
- */
961
- creationRecipients() {
962
- const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
963
- if (!fs.existsSync(confPath)) return [];
964
- const lines = fs.readFileSync(confPath, 'utf8').split('\n');
965
- const index = lines.findIndex((line) => /^\s*age:/.test(line));
966
- if (index === -1) return [];
967
- const indent = lines[index].match(/^\s*/)[0].length;
968
- const chunk = [lines[index].replace(/^\s*age:\s*>?-?\s*/, '')];
969
- for (let i = index + 1; i < lines.length; i++) {
970
- if (!lines[i].trim()) break;
971
- if (lines[i].match(/^\s*/)[0].length <= indent) break;
972
- chunk.push(lines[i].trim());
973
- }
974
- return chunk
975
- .join(',')
976
- .split(',')
977
- .map((value) => value.trim())
978
- .filter(Boolean);
979
- },
980
-
981
- /**
982
- * @method writeCreationRecipients
983
- * @description Rewrites the `age:` recipients of the `.sops.yaml` creation rule in place,
984
- * collapsing any folded form to a single canonical line. Line-scoped on purpose: a YAML
985
- * round-trip would strip the comments operators keep in this file.
986
- * @param {Array<string>} recipients - Recipients to encrypt to from now on.
987
- * @memberof UnderpostSecret
988
- */
989
- writeCreationRecipients(recipients) {
990
- const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
991
- if (!fs.existsSync(confPath))
992
- throw new Error(`Missing creation rules: ${confPath} (run: underpost secret sops --init)`);
993
- const lines = fs.readFileSync(confPath, 'utf8').split('\n');
994
- const index = lines.findIndex((line) => /^\s*age:/.test(line));
995
- if (index === -1) throw new Error(`No 'age:' recipients entry in ${confPath}`);
996
- const indent = lines[index].match(/^\s*/)[0];
997
- let end = index + 1;
998
- while (end < lines.length && lines[end].trim() && lines[end].match(/^\s*/)[0].length > indent.length) end++;
999
- lines.splice(index, end - index, `${indent}age: ${recipients.join(',')}`);
1000
- fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
1001
- },
1002
-
1003
- /**
1004
- * @method hasBinary
1005
- * @description Reports whether a binary resolves on PATH. Single probe reused by
1006
- * {@link assertTooling} and {@link installTooling} so both agree on what "installed" means.
1007
- * @param {string} bin - Binary name.
1008
- * @returns {boolean} True when the binary is on PATH.
1009
- * @memberof UnderpostSecret
1010
- */
1011
- hasBinary(bin) {
1012
- return (
1013
- shellExec(`command -v ${bin} >/dev/null 2>&1 && echo exists || echo missing`, {
1014
- stdout: true,
1015
- silent: true,
1016
- disableLog: true,
1017
- }).trim() === 'exists'
2119
+ if (options.dryRun) {
2120
+ logger.info('Purge plan (dry run)', {
2121
+ secret: `${namespace}/${name}`,
2122
+ manifest: fs.existsSync(manifestPath) ? manifestPath : 'absent',
2123
+ disposition: options.force ? 'delete' : 'archive',
2124
+ seedFallbackAvailable: seedFallback,
2125
+ });
2126
+ return { deleted: false, archived: '', seedFallback };
2127
+ }
2128
+
2129
+ shellExec(`kubectl delete secret ${name} -n ${namespace} --ignore-not-found`);
2130
+
2131
+ let archived = '';
2132
+ if (!fs.existsSync(manifestPath)) logger.warn(`No encrypted manifest to remove at ${manifestPath}`);
2133
+ else if (options.force) {
2134
+ fs.removeSync(manifestPath);
2135
+ logger.warn(`Deleted ${manifestPath}`);
2136
+ } else {
2137
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
2138
+ archived = `${SOPS_ARCHIVE_DIR}/${namespace}/${name}.${stamp}${SOPS_MANIFEST_EXT}`;
2139
+ fs.ensureDirSync(`${SOPS_ARCHIVE_DIR}/${namespace}`);
2140
+ fs.moveSync(manifestPath, archived);
2141
+ logger.info(`Archived ${manifestPath} -> ${archived}`);
2142
+ }
2143
+
2144
+ if (seedFallback)
2145
+ logger.info(`Origin seed path is available for ${name}; cluster init will seed from it.`, {
2146
+ sources: seedSources,
2147
+ });
2148
+ else if (seedSources.length > 0)
2149
+ logger.warn(
2150
+ `No origin seed path for ${name}. Re-encrypt a manifest or create the secret manually ` +
2151
+ `before redeploying workloads that mount it.`,
2152
+ { expected: seedSources },
1018
2153
  );
1019
- },
1020
-
1021
- /**
1022
- * @method assertTooling
1023
- * @description Fails fast with an actionable message when a required binary is missing,
1024
- * rather than surfacing an opaque shell exit code mid-apply.
1025
- * @param {Array<string>} bins - Binaries that must resolve on PATH.
1026
- * @memberof UnderpostSecret
1027
- */
1028
- assertTooling(bins) {
1029
- for (const bin of bins)
1030
- if (!Underpost.secret.sops.hasBinary(bin))
1031
- throw new Error(`${bin} not found in PATH (install via: underpost secret --install-tools)`);
1032
- },
1033
-
1034
- /**
1035
- * @method installTooling
1036
- * @description Installs the `sops` and `age` host binaries from their pinned upstream static
1037
- * builds. Idempotent: an already-resolvable binary is left untouched, so this is safe to
1038
- * re-run and safe to call from both the secrets CLI and cluster host initialization.
1039
- * Verifies both binaries resolve before returning, so a partial install fails loudly here
1040
- * rather than mid-decrypt.
1041
- * @returns {{sops: boolean, age: boolean}} Which binaries this run actually installed.
1042
- * @memberof UnderpostSecret
1043
- */
1044
- installTooling() {
1045
- const archData = Underpost.baremetal.getHostArch();
1046
- logger.info('Installing SOPS and Age host tooling...', { ...archData, SOPS_VERSION, AGE_VERSION });
1047
- const installed = { sops: false, age: false };
1048
-
1049
- if (Underpost.secret.sops.hasBinary('sops')) logger.info('SOPS is already installed; skipping.');
1050
- else {
1051
- shellExec(
1052
- `curl -fsSL -o /tmp/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${archData.alias}`,
1053
- );
1054
- shellExec(`sudo install -m 0755 /tmp/sops /usr/local/bin/sops`);
1055
- shellExec(`sudo ln -sf /usr/local/bin/sops /bin/sops`);
1056
- shellExec(`sudo rm -f /tmp/sops`);
1057
- installed.sops = true;
1058
- }
1059
2154
 
1060
- if (Underpost.secret.sops.hasBinary('age-keygen')) logger.info('Age is already installed; skipping.');
1061
- else {
1062
- shellExec(
1063
- `curl -fsSL -o /tmp/age.tar.gz https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-${archData.alias}.tar.gz`,
1064
- );
1065
- shellExec(`tar -xzf /tmp/age.tar.gz -C /tmp`);
1066
- shellExec(`sudo install -m 0755 /tmp/age/age /usr/local/bin/age`);
1067
- shellExec(`sudo install -m 0755 /tmp/age/age-keygen /usr/local/bin/age-keygen`);
1068
- shellExec(`sudo ln -sf /usr/local/bin/age /bin/age`);
1069
- shellExec(`sudo ln -sf /usr/local/bin/age-keygen /bin/age-keygen`);
1070
- shellExec(`sudo rm -rf /tmp/age /tmp/age.tar.gz`);
1071
- installed.age = true;
1072
- }
2155
+ return { deleted: true, archived, seedFallback };
2156
+ },
1073
2157
 
1074
- Underpost.secret.sops.assertTooling(['sops', 'age', 'age-keygen']);
1075
- logger.info('SOPS and Age tooling ready.', installed);
1076
- return installed;
1077
- },
2158
+ /**
2159
+ * @method manifests
2160
+ * @description Enumerates every encrypted manifest in the store, or in one namespace.
2161
+ * Dot-prefixed entries (`.archive`, `.sops.yaml`) are never treated as namespaces.
2162
+ * @param {string} [namespace] - Restrict to one namespace; omit for the whole store.
2163
+ * @returns {Array<{namespace: string, name: string, path: string}>} Manifest descriptors, sorted.
2164
+ * @memberof UnderpostSecret
2165
+ */
2166
+ manifests(namespace) {
2167
+ if (!fs.existsSync(SOPS_SECRETS_DIR)) return [];
2168
+ const namespaces = namespace
2169
+ ? [namespace]
2170
+ : fs
2171
+ .readdirSync(SOPS_SECRETS_DIR)
2172
+ .filter((entry) => !entry.startsWith('.') && fs.statSync(`${SOPS_SECRETS_DIR}/${entry}`).isDirectory())
2173
+ .sort();
2174
+ const found = [];
2175
+ for (const ns of namespaces) {
2176
+ const dir = `${SOPS_SECRETS_DIR}/${ns}`;
2177
+ if (!fs.existsSync(dir)) continue;
2178
+ for (const file of fs
2179
+ .readdirSync(dir)
2180
+ .filter((entry) => entry.endsWith(SOPS_MANIFEST_EXT))
2181
+ .sort())
2182
+ found.push({ namespace: ns, name: file.slice(0, -SOPS_MANIFEST_EXT.length), path: `${dir}/${file}` });
2183
+ }
2184
+ return found;
1078
2185
  },
1079
2186
 
1080
2187
  /**
1081
- * @method sanitizeSecretEnvFile
1082
- * @description Strips shell/runtime-critical and Kubernetes-injected keys (PATH, HOME, …) from
1083
- * raw `.env` file content so the resulting `underpost-config` secret can be safely injected via
1084
- * `envFrom` without clobbering the container image's own PATH. Blank lines and comments are
1085
- * preserved. Uses the same {@link RESERVED_ENV_KEYS} blocklist as container-env capture.
1086
- * @param {string} envFileContent - Raw contents of a `.env.<env>` file.
1087
- * @returns {string} Filtered env-file content.
2188
+ * @method creationRecipients
2189
+ * @description Reads the Age recipients from the committed `.sops.yaml` creation rule,
2190
+ * accepting both the single-line (`age: k1,k2`) and folded (`age: >-`) forms sops permits.
2191
+ * @returns {Array<string>} Recipients currently configured for encryption.
1088
2192
  * @memberof UnderpostSecret
1089
2193
  */
1090
- sanitizeSecretEnvFile(envFileContent) {
1091
- return envFileContent
1092
- .split('\n')
1093
- .filter((line) => {
1094
- const trimmed = line.trimStart();
1095
- if (!trimmed || trimmed.startsWith('#')) return true;
1096
- const key = line.slice(0, line.indexOf('=')).trim();
1097
- return !key || !isReservedEnvKey(key);
1098
- })
1099
- .join('\n');
2194
+ creationRecipients() {
2195
+ const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
2196
+ if (!fs.existsSync(confPath)) return [];
2197
+ const lines = fs.readFileSync(confPath, 'utf8').split('\n');
2198
+ const index = lines.findIndex((line) => /^\s*age:/.test(line));
2199
+ if (index === -1) return [];
2200
+ const indent = lines[index].match(/^\s*/)[0].length;
2201
+ const chunk = [lines[index].replace(/^\s*age:\s*>?-?\s*/, '')];
2202
+ for (let i = index + 1; i < lines.length; i++) {
2203
+ if (!lines[i].trim()) break;
2204
+ if (lines[i].match(/^\s*/)[0].length <= indent) break;
2205
+ chunk.push(lines[i].trim());
2206
+ }
2207
+ return chunk
2208
+ .join(',')
2209
+ .split(',')
2210
+ .map((value) => value.trim())
2211
+ .filter(Boolean);
1100
2212
  },
1101
2213
 
1102
2214
  /**
1103
- * Removes all filesystem traces of secrets after deployment startup.
1104
- * Centralizes the defense-in-depth cleanup performed
1105
- * @param {object} options - Options for cleaning the environment.
1106
- * @param {Array<string>} [options.keepKeys=[]] - List of keys to keep in the environment file. If provided, only these keys will be retained.
2215
+ * @method writeCreationRecipients
2216
+ * @description Rewrites the `age:` recipients of the `.sops.yaml` creation rule in place,
2217
+ * collapsing any folded form to a single canonical line. Line-scoped on purpose: a YAML
2218
+ * round-trip would strip the comments operators keep in this file.
2219
+ * @param {Array<string>} recipients - Recipients to encrypt to from now on.
1107
2220
  * @memberof UnderpostSecret
1108
2221
  */
1109
- globalSecretClean(options = { keepKeys: [] }) {
1110
- const { keepKeys } = options;
1111
- loadConf('clean');
1112
- Underpost.repo.cleanupPrivateEngineRepo();
1113
- Underpost.env.clean({
1114
- keepKeys: keepKeys.length > 0 ? keepKeys : ['container-status', 'start-container-status'],
1115
- });
2222
+ writeCreationRecipients(recipients) {
2223
+ const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
2224
+ if (!fs.existsSync(confPath))
2225
+ throw new Error(`Missing creation rules: ${confPath} (run: underpost secret setup)`);
2226
+ const lines = fs.readFileSync(confPath, 'utf8').split('\n');
2227
+ const index = lines.findIndex((line) => /^\s*age:/.test(line));
2228
+ if (index === -1) throw new Error(`No 'age:' recipients entry in ${confPath}`);
2229
+ const indent = lines[index].match(/^\s*/)[0];
2230
+ let end = index + 1;
2231
+ while (end < lines.length && lines[end].trim() && lines[end].match(/^\s*/)[0].length > indent.length) end++;
2232
+ lines.splice(index, end - index, `${indent}age: ${recipients.join(',')}`);
2233
+ fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
2234
+ },
2235
+
2236
+ /**
2237
+ * @method hasBinary
2238
+ * @description Reports whether a binary resolves on PATH. Single probe reused by
2239
+ * {@link assertTooling} and {@link installTooling} so both agree on what "installed" means.
2240
+ * @param {string} bin - Binary name.
2241
+ * @returns {boolean} True when the binary is on PATH.
2242
+ * @memberof UnderpostSecret
2243
+ */
2244
+ hasBinary(bin) {
2245
+ return (
2246
+ shellExec(`command -v ${bin} >/dev/null 2>&1 && echo exists || echo missing`, {
2247
+ stdout: true,
2248
+ silent: true,
2249
+ disableLog: true,
2250
+ }).trim() === 'exists'
2251
+ );
2252
+ },
2253
+
2254
+ /**
2255
+ * @method assertTooling
2256
+ * @description Fails fast with an actionable message when a required binary is missing,
2257
+ * rather than surfacing an opaque shell exit code mid-apply.
2258
+ * @param {Array<string>} bins - Binaries that must resolve on PATH.
2259
+ * @memberof UnderpostSecret
2260
+ */
2261
+ assertTooling(bins) {
2262
+ for (const bin of bins)
2263
+ if (!Underpost.secret.hasBinary(bin))
2264
+ throw new Error(`${bin} not found in PATH (install via: underpost secret --install-tools)`);
2265
+ },
2266
+
2267
+ /**
2268
+ * @method installTooling
2269
+ * @description Installs the `sops` and `age` host binaries from their pinned upstream static
2270
+ * builds. Idempotent: an already-resolvable binary is left untouched, so this is safe to
2271
+ * re-run and safe to call from both the secrets CLI and cluster host initialization.
2272
+ * Verifies both binaries resolve before returning, so a partial install fails loudly here
2273
+ * rather than mid-decrypt.
2274
+ * @returns {{sops: boolean, age: boolean}} Which binaries this run actually installed.
2275
+ * @memberof UnderpostSecret
2276
+ */
2277
+ installTooling() {
2278
+ const archData = Underpost.baremetal.getHostArch();
2279
+ logger.info('Installing SOPS and Age host tooling...', { ...archData, SOPS_VERSION, AGE_VERSION });
2280
+ const installed = { sops: false, age: false };
2281
+
2282
+ if (Underpost.secret.hasBinary('sops')) logger.info('SOPS is already installed; skipping.');
2283
+ else {
2284
+ shellExec(
2285
+ `curl -fsSL -o /tmp/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${archData.alias}`,
2286
+ );
2287
+ shellExec(`sudo install -m 0755 /tmp/sops /usr/local/bin/sops`);
2288
+ shellExec(`sudo ln -sf /usr/local/bin/sops /bin/sops`);
2289
+ shellExec(`sudo rm -f /tmp/sops`);
2290
+ installed.sops = true;
2291
+ }
2292
+
2293
+ if (Underpost.secret.hasBinary('age-keygen')) logger.info('Age is already installed; skipping.');
2294
+ else {
2295
+ shellExec(
2296
+ `curl -fsSL -o /tmp/age.tar.gz https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-${archData.alias}.tar.gz`,
2297
+ );
2298
+ shellExec(`tar -xzf /tmp/age.tar.gz -C /tmp`);
2299
+ shellExec(`sudo install -m 0755 /tmp/age/age /usr/local/bin/age`);
2300
+ shellExec(`sudo install -m 0755 /tmp/age/age-keygen /usr/local/bin/age-keygen`);
2301
+ shellExec(`sudo ln -sf /usr/local/bin/age /bin/age`);
2302
+ shellExec(`sudo ln -sf /usr/local/bin/age-keygen /bin/age-keygen`);
2303
+ shellExec(`sudo rm -rf /tmp/age /tmp/age.tar.gz`);
2304
+ installed.age = true;
2305
+ }
2306
+
2307
+ Underpost.secret.assertTooling(['sops', 'age', 'age-keygen']);
2308
+ logger.info('SOPS and Age tooling ready.', { installedThisRun: installed });
2309
+ return installed;
1116
2310
  },
1117
2311
  };
1118
2312
  }