@nocobase/app-plugin-releases 0.0.0-stage → 0.1.0-beta.0

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 (584) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +118 -2
  3. package/dist/client/components/actor-name.d.ts +5 -0
  4. package/dist/client/components/actor-name.d.ts.map +1 -0
  5. package/dist/client/components/actor-name.js +11 -0
  6. package/dist/client/components/actor-name.js.map +1 -0
  7. package/dist/client/components/advanced-section.d.ts +8 -0
  8. package/dist/client/components/advanced-section.d.ts.map +1 -0
  9. package/dist/client/components/advanced-section.js +13 -0
  10. package/dist/client/components/advanced-section.js.map +1 -0
  11. package/dist/client/components/app-settings-card.d.ts +7 -0
  12. package/dist/client/components/app-settings-card.d.ts.map +1 -0
  13. package/dist/client/components/app-settings-card.js +69 -0
  14. package/dist/client/components/app-settings-card.js.map +1 -0
  15. package/dist/client/components/artifacts.d.ts +9 -0
  16. package/dist/client/components/artifacts.d.ts.map +1 -0
  17. package/dist/client/components/artifacts.js +26 -0
  18. package/dist/client/components/artifacts.js.map +1 -0
  19. package/dist/client/components/config-secrets.d.ts +10 -0
  20. package/dist/client/components/config-secrets.d.ts.map +1 -0
  21. package/dist/client/components/config-secrets.js +34 -0
  22. package/dist/client/components/config-secrets.js.map +1 -0
  23. package/dist/client/components/confirm-dialog.d.ts +22 -0
  24. package/dist/client/components/confirm-dialog.d.ts.map +1 -0
  25. package/dist/client/components/confirm-dialog.js +47 -0
  26. package/dist/client/components/confirm-dialog.js.map +1 -0
  27. package/dist/client/components/connection-check.d.ts +13 -0
  28. package/dist/client/components/connection-check.d.ts.map +1 -0
  29. package/dist/client/components/connection-check.js +69 -0
  30. package/dist/client/components/connection-check.js.map +1 -0
  31. package/dist/client/components/data-table.d.ts +27 -0
  32. package/dist/client/components/data-table.d.ts.map +1 -0
  33. package/dist/client/components/data-table.js +41 -0
  34. package/dist/client/components/data-table.js.map +1 -0
  35. package/dist/client/components/driver-settings.d.ts +28 -0
  36. package/dist/client/components/driver-settings.d.ts.map +1 -0
  37. package/dist/client/components/driver-settings.js +158 -0
  38. package/dist/client/components/driver-settings.js.map +1 -0
  39. package/dist/client/components/environment-form.d.ts +18 -0
  40. package/dist/client/components/environment-form.d.ts.map +1 -0
  41. package/dist/client/components/environment-form.js +284 -0
  42. package/dist/client/components/environment-form.js.map +1 -0
  43. package/dist/client/components/image-source-fields.d.ts +16 -0
  44. package/dist/client/components/image-source-fields.d.ts.map +1 -0
  45. package/dist/client/components/image-source-fields.js +53 -0
  46. package/dist/client/components/image-source-fields.js.map +1 -0
  47. package/dist/client/components/labels-editor.d.ts +15 -0
  48. package/dist/client/components/labels-editor.d.ts.map +1 -0
  49. package/dist/client/components/labels-editor.js +40 -0
  50. package/dist/client/components/labels-editor.js.map +1 -0
  51. package/dist/client/components/page-container.d.ts +11 -0
  52. package/dist/client/components/page-container.d.ts.map +1 -0
  53. package/dist/client/components/page-container.js +13 -0
  54. package/dist/client/components/page-container.js.map +1 -0
  55. package/dist/client/components/page-header.d.ts +14 -0
  56. package/dist/client/components/page-header.d.ts.map +1 -0
  57. package/dist/client/components/page-header.js +10 -0
  58. package/dist/client/components/page-header.js.map +1 -0
  59. package/dist/client/components/page-tabs.d.ts +20 -0
  60. package/dist/client/components/page-tabs.d.ts.map +1 -0
  61. package/dist/client/components/page-tabs.js +7 -0
  62. package/dist/client/components/page-tabs.js.map +1 -0
  63. package/dist/client/components/people-picker.d.ts +5 -0
  64. package/dist/client/components/people-picker.d.ts.map +1 -0
  65. package/dist/client/components/people-picker.js +22 -0
  66. package/dist/client/components/people-picker.js.map +1 -0
  67. package/dist/client/components/refresh-button.d.ts +7 -0
  68. package/dist/client/components/refresh-button.d.ts.map +1 -0
  69. package/dist/client/components/refresh-button.js +21 -0
  70. package/dist/client/components/refresh-button.js.map +1 -0
  71. package/dist/client/components/registry-fields.d.ts +20 -0
  72. package/dist/client/components/registry-fields.d.ts.map +1 -0
  73. package/dist/client/components/registry-fields.js +47 -0
  74. package/dist/client/components/registry-fields.js.map +1 -0
  75. package/dist/client/components/release-badges.d.ts +42 -0
  76. package/dist/client/components/release-badges.d.ts.map +1 -0
  77. package/dist/client/components/release-badges.js +100 -0
  78. package/dist/client/components/release-badges.js.map +1 -0
  79. package/dist/client/components/request-decision.d.ts +8 -0
  80. package/dist/client/components/request-decision.d.ts.map +1 -0
  81. package/dist/client/components/request-decision.js +7 -0
  82. package/dist/client/components/request-decision.js.map +1 -0
  83. package/dist/client/components/route-dialog.d.ts +6 -0
  84. package/dist/client/components/route-dialog.d.ts.map +1 -0
  85. package/dist/client/components/route-dialog.js +7 -0
  86. package/dist/client/components/route-dialog.js.map +1 -0
  87. package/dist/client/components/route-overlay.d.ts +21 -0
  88. package/dist/client/components/route-overlay.d.ts.map +1 -0
  89. package/dist/client/components/route-overlay.js +87 -0
  90. package/dist/client/components/route-overlay.js.map +1 -0
  91. package/dist/client/components/row-actions.d.ts +9 -0
  92. package/dist/client/components/row-actions.d.ts.map +1 -0
  93. package/dist/client/components/row-actions.js +16 -0
  94. package/dist/client/components/row-actions.js.map +1 -0
  95. package/dist/client/components/runtime-policy-fields.d.ts +9 -0
  96. package/dist/client/components/runtime-policy-fields.d.ts.map +1 -0
  97. package/dist/client/components/runtime-policy-fields.js +26 -0
  98. package/dist/client/components/runtime-policy-fields.js.map +1 -0
  99. package/dist/client/components/section.d.ts +15 -0
  100. package/dist/client/components/section.d.ts.map +1 -0
  101. package/dist/client/components/section.js +6 -0
  102. package/dist/client/components/section.js.map +1 -0
  103. package/dist/client/components/states.d.ts +21 -0
  104. package/dist/client/components/states.d.ts.map +1 -0
  105. package/dist/client/components/states.js +29 -0
  106. package/dist/client/components/states.js.map +1 -0
  107. package/dist/client/components/tag.d.ts +15 -0
  108. package/dist/client/components/tag.d.ts.map +1 -0
  109. package/dist/client/components/tag.js +15 -0
  110. package/dist/client/components/tag.js.map +1 -0
  111. package/dist/client/components/ui/alert-dialog.d.ts +19 -0
  112. package/dist/client/components/ui/alert-dialog.d.ts.map +1 -0
  113. package/dist/client/components/ui/alert-dialog.js +42 -0
  114. package/dist/client/components/ui/alert-dialog.js.map +1 -0
  115. package/dist/client/components/ui/alert.d.ts +13 -0
  116. package/dist/client/components/ui/alert.d.ts.map +1 -0
  117. package/dist/client/components/ui/alert.js +31 -0
  118. package/dist/client/components/ui/alert.js.map +1 -0
  119. package/dist/client/components/ui/badge.d.ts +11 -0
  120. package/dist/client/components/ui/badge.d.ts.map +1 -0
  121. package/dist/client/components/ui/badge.js +37 -0
  122. package/dist/client/components/ui/badge.js.map +1 -0
  123. package/dist/client/components/ui/button.d.ts +12 -0
  124. package/dist/client/components/ui/button.d.ts.map +1 -0
  125. package/dist/client/components/ui/button.js +38 -0
  126. package/dist/client/components/ui/button.js.map +1 -0
  127. package/dist/client/components/ui/card.d.ts +12 -0
  128. package/dist/client/components/ui/card.d.ts.map +1 -0
  129. package/dist/client/components/ui/card.js +25 -0
  130. package/dist/client/components/ui/card.js.map +1 -0
  131. package/dist/client/components/ui/checkbox.d.ts +5 -0
  132. package/dist/client/components/ui/checkbox.d.ts.map +1 -0
  133. package/dist/client/components/ui/checkbox.js +9 -0
  134. package/dist/client/components/ui/checkbox.js.map +1 -0
  135. package/dist/client/components/ui/collapsible.d.ts +7 -0
  136. package/dist/client/components/ui/collapsible.d.ts.map +1 -0
  137. package/dist/client/components/ui/collapsible.js +13 -0
  138. package/dist/client/components/ui/collapsible.js.map +1 -0
  139. package/dist/client/components/ui/dialog.d.ts +18 -0
  140. package/dist/client/components/ui/dialog.d.ts.map +1 -0
  141. package/dist/client/components/ui/dialog.js +40 -0
  142. package/dist/client/components/ui/dialog.js.map +1 -0
  143. package/dist/client/components/ui/dropdown-menu.d.ts +30 -0
  144. package/dist/client/components/ui/dropdown-menu.d.ts.map +1 -0
  145. package/dist/client/components/ui/dropdown-menu.js +51 -0
  146. package/dist/client/components/ui/dropdown-menu.js.map +1 -0
  147. package/dist/client/components/ui/empty.d.ts +15 -0
  148. package/dist/client/components/ui/empty.d.ts.map +1 -0
  149. package/dist/client/components/ui/empty.js +37 -0
  150. package/dist/client/components/ui/empty.js.map +1 -0
  151. package/dist/client/components/ui/field.d.ts +28 -0
  152. package/dist/client/components/ui/field.d.ts.map +1 -0
  153. package/dist/client/components/ui/field.js +71 -0
  154. package/dist/client/components/ui/field.js.map +1 -0
  155. package/dist/client/components/ui/input-group.d.ts +22 -0
  156. package/dist/client/components/ui/input-group.d.ts.map +1 -0
  157. package/dist/client/components/ui/input-group.js +63 -0
  158. package/dist/client/components/ui/input-group.js.map +1 -0
  159. package/dist/client/components/ui/input.d.ts +4 -0
  160. package/dist/client/components/ui/input.d.ts.map +1 -0
  161. package/dist/client/components/ui/input.js +8 -0
  162. package/dist/client/components/ui/input.js.map +1 -0
  163. package/dist/client/components/ui/label.d.ts +4 -0
  164. package/dist/client/components/ui/label.d.ts.map +1 -0
  165. package/dist/client/components/ui/label.js +7 -0
  166. package/dist/client/components/ui/label.js.map +1 -0
  167. package/dist/client/components/ui/radio-group.d.ts +7 -0
  168. package/dist/client/components/ui/radio-group.d.ts.map +1 -0
  169. package/dist/client/components/ui/radio-group.js +12 -0
  170. package/dist/client/components/ui/radio-group.js.map +1 -0
  171. package/dist/client/components/ui/select.d.ts +16 -0
  172. package/dist/client/components/ui/select.d.ts.map +1 -0
  173. package/dist/client/components/ui/select.js +34 -0
  174. package/dist/client/components/ui/select.js.map +1 -0
  175. package/dist/client/components/ui/separator.d.ts +5 -0
  176. package/dist/client/components/ui/separator.d.ts.map +1 -0
  177. package/dist/client/components/ui/separator.js +8 -0
  178. package/dist/client/components/ui/separator.js.map +1 -0
  179. package/dist/client/components/ui/sheet.d.ts +15 -0
  180. package/dist/client/components/ui/sheet.d.ts.map +1 -0
  181. package/dist/client/components/ui/sheet.js +39 -0
  182. package/dist/client/components/ui/sheet.js.map +1 -0
  183. package/dist/client/components/ui/skeleton.d.ts +4 -0
  184. package/dist/client/components/ui/skeleton.d.ts.map +1 -0
  185. package/dist/client/components/ui/skeleton.js +7 -0
  186. package/dist/client/components/ui/skeleton.js.map +1 -0
  187. package/dist/client/components/ui/spinner.d.ts +4 -0
  188. package/dist/client/components/ui/spinner.d.ts.map +1 -0
  189. package/dist/client/components/ui/spinner.js +11 -0
  190. package/dist/client/components/ui/spinner.js.map +1 -0
  191. package/dist/client/components/ui/table.d.ts +11 -0
  192. package/dist/client/components/ui/table.d.ts.map +1 -0
  193. package/dist/client/components/ui/table.js +28 -0
  194. package/dist/client/components/ui/table.js.map +1 -0
  195. package/dist/client/components/ui/tabs.d.ts +14 -0
  196. package/dist/client/components/ui/tabs.d.ts.map +1 -0
  197. package/dist/client/components/ui/tabs.js +32 -0
  198. package/dist/client/components/ui/tabs.js.map +1 -0
  199. package/dist/client/components/ui/textarea.d.ts +4 -0
  200. package/dist/client/components/ui/textarea.d.ts.map +1 -0
  201. package/dist/client/components/ui/textarea.js +7 -0
  202. package/dist/client/components/ui/textarea.js.map +1 -0
  203. package/dist/client/components/ui/tooltip.d.ts +8 -0
  204. package/dist/client/components/ui/tooltip.d.ts.map +1 -0
  205. package/dist/client/components/ui/tooltip.js +17 -0
  206. package/dist/client/components/ui/tooltip.js.map +1 -0
  207. package/dist/client/components/upload-release-dialog.d.ts +8 -0
  208. package/dist/client/components/upload-release-dialog.d.ts.map +1 -0
  209. package/dist/client/components/upload-release-dialog.js +39 -0
  210. package/dist/client/components/upload-release-dialog.js.map +1 -0
  211. package/dist/client/components/use-route-overlay.d.ts +15 -0
  212. package/dist/client/components/use-route-overlay.d.ts.map +1 -0
  213. package/dist/client/components/use-route-overlay.js +15 -0
  214. package/dist/client/components/use-route-overlay.js.map +1 -0
  215. package/dist/client/components/variable-alerts.d.ts +24 -0
  216. package/dist/client/components/variable-alerts.d.ts.map +1 -0
  217. package/dist/client/components/variable-alerts.js +85 -0
  218. package/dist/client/components/variable-alerts.js.map +1 -0
  219. package/dist/client/components/variable-dialogs.d.ts +30 -0
  220. package/dist/client/components/variable-dialogs.d.ts.map +1 -0
  221. package/dist/client/components/variable-dialogs.js +155 -0
  222. package/dist/client/components/variable-dialogs.js.map +1 -0
  223. package/dist/client/components/variables-table.d.ts +26 -0
  224. package/dist/client/components/variables-table.d.ts.map +1 -0
  225. package/dist/client/components/variables-table.js +374 -0
  226. package/dist/client/components/variables-table.js.map +1 -0
  227. package/dist/client/driver-forms/host.d.ts +3 -0
  228. package/dist/client/driver-forms/host.d.ts.map +1 -0
  229. package/dist/client/driver-forms/host.js +100 -0
  230. package/dist/client/driver-forms/host.js.map +1 -0
  231. package/dist/client/driver-forms/index.d.ts +25 -0
  232. package/dist/client/driver-forms/index.d.ts.map +1 -0
  233. package/dist/client/driver-forms/index.js +40 -0
  234. package/dist/client/driver-forms/index.js.map +1 -0
  235. package/dist/client/driver-forms/mapping.d.ts +56 -0
  236. package/dist/client/driver-forms/mapping.d.ts.map +1 -0
  237. package/dist/client/driver-forms/mapping.js +308 -0
  238. package/dist/client/driver-forms/mapping.js.map +1 -0
  239. package/dist/client/driver-forms/types.d.ts +168 -0
  240. package/dist/client/driver-forms/types.d.ts.map +1 -0
  241. package/dist/client/driver-forms/types.js +16 -0
  242. package/dist/client/driver-forms/types.js.map +1 -0
  243. package/dist/client/hooks/use-connection-check.d.ts +18 -0
  244. package/dist/client/hooks/use-connection-check.d.ts.map +1 -0
  245. package/dist/client/hooks/use-connection-check.js +19 -0
  246. package/dist/client/hooks/use-connection-check.js.map +1 -0
  247. package/dist/client/hooks/use-driver-forms.d.ts +4 -0
  248. package/dist/client/hooks/use-driver-forms.d.ts.map +1 -0
  249. package/dist/client/hooks/use-driver-forms.js +12 -0
  250. package/dist/client/hooks/use-driver-forms.js.map +1 -0
  251. package/dist/client/hooks/use-driver-names.d.ts +14 -0
  252. package/dist/client/hooks/use-driver-names.d.ts.map +1 -0
  253. package/dist/client/hooks/use-driver-names.js +33 -0
  254. package/dist/client/hooks/use-driver-names.js.map +1 -0
  255. package/dist/client/hooks/use-environment-check.d.ts +8 -0
  256. package/dist/client/hooks/use-environment-check.d.ts.map +1 -0
  257. package/dist/client/hooks/use-environment-check.js +28 -0
  258. package/dist/client/hooks/use-environment-check.js.map +1 -0
  259. package/dist/client/hooks/use-notify.d.ts +7 -0
  260. package/dist/client/hooks/use-notify.d.ts.map +1 -0
  261. package/dist/client/hooks/use-notify.js +22 -0
  262. package/dist/client/hooks/use-notify.js.map +1 -0
  263. package/dist/client/hooks/use-registries.d.ts +6 -0
  264. package/dist/client/hooks/use-registries.d.ts.map +1 -0
  265. package/dist/client/hooks/use-registries.js +9 -0
  266. package/dist/client/hooks/use-registries.js.map +1 -0
  267. package/dist/client/hooks/use-registry-check.d.ts +16 -0
  268. package/dist/client/hooks/use-registry-check.d.ts.map +1 -0
  269. package/dist/client/hooks/use-registry-check.js +29 -0
  270. package/dist/client/hooks/use-registry-check.js.map +1 -0
  271. package/dist/client/hooks/use-releases.d.ts +42 -0
  272. package/dist/client/hooks/use-releases.d.ts.map +1 -0
  273. package/dist/client/hooks/use-releases.js +82 -0
  274. package/dist/client/hooks/use-releases.js.map +1 -0
  275. package/dist/client/hooks/use-request-decisions.d.ts +15 -0
  276. package/dist/client/hooks/use-request-decisions.d.ts.map +1 -0
  277. package/dist/client/hooks/use-request-decisions.js +69 -0
  278. package/dist/client/hooks/use-request-decisions.js.map +1 -0
  279. package/dist/client/hooks/use-tab-param.d.ts +5 -0
  280. package/dist/client/hooks/use-tab-param.d.ts.map +1 -0
  281. package/dist/client/hooks/use-tab-param.js +33 -0
  282. package/dist/client/hooks/use-tab-param.js.map +1 -0
  283. package/dist/client/index.d.ts +12 -0
  284. package/dist/client/index.d.ts.map +1 -0
  285. package/dist/client/index.js +13 -0
  286. package/dist/client/index.js.map +1 -0
  287. package/dist/client/lib/actor-names.d.ts +16 -0
  288. package/dist/client/lib/actor-names.d.ts.map +1 -0
  289. package/dist/client/lib/actor-names.js +8 -0
  290. package/dist/client/lib/actor-names.js.map +1 -0
  291. package/dist/client/lib/app-groups.d.ts +20 -0
  292. package/dist/client/lib/app-groups.d.ts.map +1 -0
  293. package/dist/client/lib/app-groups.js +36 -0
  294. package/dist/client/lib/app-groups.js.map +1 -0
  295. package/dist/client/lib/app-origin.d.ts +22 -0
  296. package/dist/client/lib/app-origin.d.ts.map +1 -0
  297. package/dist/client/lib/app-origin.js +10 -0
  298. package/dist/client/lib/app-origin.js.map +1 -0
  299. package/dist/client/lib/app-state.d.ts +7 -0
  300. package/dist/client/lib/app-state.d.ts.map +1 -0
  301. package/dist/client/lib/app-state.js +15 -0
  302. package/dist/client/lib/app-state.js.map +1 -0
  303. package/dist/client/lib/config-secrets.d.ts +12 -0
  304. package/dist/client/lib/config-secrets.d.ts.map +1 -0
  305. package/dist/client/lib/config-secrets.js +16 -0
  306. package/dist/client/lib/config-secrets.js.map +1 -0
  307. package/dist/client/lib/delete-app-impact.d.ts +17 -0
  308. package/dist/client/lib/delete-app-impact.d.ts.map +1 -0
  309. package/dist/client/lib/delete-app-impact.js +10 -0
  310. package/dist/client/lib/delete-app-impact.js.map +1 -0
  311. package/dist/client/lib/errors.d.ts +9 -0
  312. package/dist/client/lib/errors.d.ts.map +1 -0
  313. package/dist/client/lib/errors.js +76 -0
  314. package/dist/client/lib/errors.js.map +1 -0
  315. package/dist/client/lib/format.d.ts +8 -0
  316. package/dist/client/lib/format.d.ts.map +1 -0
  317. package/dist/client/lib/format.js +29 -0
  318. package/dist/client/lib/format.js.map +1 -0
  319. package/dist/client/lib/labels.d.ts +14 -0
  320. package/dist/client/lib/labels.d.ts.map +1 -0
  321. package/dist/client/lib/labels.js +42 -0
  322. package/dist/client/lib/labels.js.map +1 -0
  323. package/dist/client/lib/paths.d.ts +25 -0
  324. package/dist/client/lib/paths.d.ts.map +1 -0
  325. package/dist/client/lib/paths.js +31 -0
  326. package/dist/client/lib/paths.js.map +1 -0
  327. package/dist/client/lib/pending-requests.d.ts +7 -0
  328. package/dist/client/lib/pending-requests.d.ts.map +1 -0
  329. package/dist/client/lib/pending-requests.js +12 -0
  330. package/dist/client/lib/pending-requests.js.map +1 -0
  331. package/dist/client/lib/people-picker.d.ts +19 -0
  332. package/dist/client/lib/people-picker.d.ts.map +1 -0
  333. package/dist/client/lib/people-picker.js +8 -0
  334. package/dist/client/lib/people-picker.js.map +1 -0
  335. package/dist/client/lib/permissions.d.ts +6 -0
  336. package/dist/client/lib/permissions.d.ts.map +1 -0
  337. package/dist/client/lib/permissions.js +14 -0
  338. package/dist/client/lib/permissions.js.map +1 -0
  339. package/dist/client/lib/registry-draft.d.ts +26 -0
  340. package/dist/client/lib/registry-draft.d.ts.map +1 -0
  341. package/dist/client/lib/registry-draft.js +41 -0
  342. package/dist/client/lib/registry-draft.js.map +1 -0
  343. package/dist/client/lib/runtime-policy.d.ts +21 -0
  344. package/dist/client/lib/runtime-policy.d.ts.map +1 -0
  345. package/dist/client/lib/runtime-policy.js +63 -0
  346. package/dist/client/lib/runtime-policy.js.map +1 -0
  347. package/dist/client/lib/system-labels.d.ts +15 -0
  348. package/dist/client/lib/system-labels.d.ts.map +1 -0
  349. package/dist/client/lib/system-labels.js +14 -0
  350. package/dist/client/lib/system-labels.js.map +1 -0
  351. package/dist/client/lib/utils.d.ts +2 -0
  352. package/dist/client/lib/utils.d.ts.map +1 -0
  353. package/dist/client/lib/utils.js +2 -0
  354. package/dist/client/lib/utils.js.map +1 -0
  355. package/dist/client/lib/variables-text.d.ts +67 -0
  356. package/dist/client/lib/variables-text.d.ts.map +1 -0
  357. package/dist/client/lib/variables-text.js +156 -0
  358. package/dist/client/lib/variables-text.js.map +1 -0
  359. package/dist/client/locales/en-US.d.ts +670 -0
  360. package/dist/client/locales/en-US.d.ts.map +1 -0
  361. package/dist/client/locales/en-US.js +655 -0
  362. package/dist/client/locales/en-US.js.map +1 -0
  363. package/dist/client/locales/index.d.ts +4 -0
  364. package/dist/client/locales/index.d.ts.map +1 -0
  365. package/dist/client/locales/index.js +7 -0
  366. package/dist/client/locales/index.js.map +1 -0
  367. package/dist/client/locales/zh-CN.d.ts +4 -0
  368. package/dist/client/locales/zh-CN.d.ts.map +1 -0
  369. package/dist/client/locales/zh-CN.js +650 -0
  370. package/dist/client/locales/zh-CN.js.map +1 -0
  371. package/dist/client/pages/app-page.d.ts +3 -0
  372. package/dist/client/pages/app-page.d.ts.map +1 -0
  373. package/dist/client/pages/app-page.js +532 -0
  374. package/dist/client/pages/app-page.js.map +1 -0
  375. package/dist/client/pages/apps-page.d.ts +4 -0
  376. package/dist/client/pages/apps-page.d.ts.map +1 -0
  377. package/dist/client/pages/apps-page.js +116 -0
  378. package/dist/client/pages/apps-page.js.map +1 -0
  379. package/dist/client/pages/environment-page.d.ts +3 -0
  380. package/dist/client/pages/environment-page.d.ts.map +1 -0
  381. package/dist/client/pages/environment-page.js +147 -0
  382. package/dist/client/pages/environment-page.js.map +1 -0
  383. package/dist/client/pages/environments-page.d.ts +4 -0
  384. package/dist/client/pages/environments-page.d.ts.map +1 -0
  385. package/dist/client/pages/environments-page.js +115 -0
  386. package/dist/client/pages/environments-page.js.map +1 -0
  387. package/dist/client/pages/new-app-page.d.ts +10 -0
  388. package/dist/client/pages/new-app-page.d.ts.map +1 -0
  389. package/dist/client/pages/new-app-page.js +101 -0
  390. package/dist/client/pages/new-app-page.js.map +1 -0
  391. package/dist/client/pages/request-page.d.ts +7 -0
  392. package/dist/client/pages/request-page.d.ts.map +1 -0
  393. package/dist/client/pages/request-page.js +70 -0
  394. package/dist/client/pages/request-page.js.map +1 -0
  395. package/dist/client/pages.d.ts +10 -0
  396. package/dist/client/pages.d.ts.map +1 -0
  397. package/dist/client/pages.js +25 -0
  398. package/dist/client/pages.js.map +1 -0
  399. package/dist/client/plugin.d.ts +12 -0
  400. package/dist/client/plugin.d.ts.map +1 -0
  401. package/dist/client/plugin.js +12 -0
  402. package/dist/client/plugin.js.map +1 -0
  403. package/dist/client/routes.d.ts +19 -0
  404. package/dist/client/routes.d.ts.map +1 -0
  405. package/dist/client/routes.js +89 -0
  406. package/dist/client/routes.js.map +1 -0
  407. package/dist/client/service-provider.d.ts +11 -0
  408. package/dist/client/service-provider.d.ts.map +1 -0
  409. package/dist/client/service-provider.js +14 -0
  410. package/dist/client/service-provider.js.map +1 -0
  411. package/dist/database/migrations/.manifest.json +21 -0
  412. package/dist/database/migrations/202610020001_rel_create_tables.d.ts +8 -0
  413. package/dist/database/migrations/202610020001_rel_create_tables.d.ts.map +1 -0
  414. package/dist/database/migrations/202610020001_rel_create_tables.js +247 -0
  415. package/dist/database/migrations/202610020001_rel_create_tables.js.map +1 -0
  416. package/dist/database/migrations/202610100001_rel_variables_initial_admin.d.ts +16 -0
  417. package/dist/database/migrations/202610100001_rel_variables_initial_admin.d.ts.map +1 -0
  418. package/dist/database/migrations/202610100001_rel_variables_initial_admin.js +94 -0
  419. package/dist/database/migrations/202610100001_rel_variables_initial_admin.js.map +1 -0
  420. package/dist/database/migrations/202610170001_rel_drop_app_preview_values.d.ts +8 -0
  421. package/dist/database/migrations/202610170001_rel_drop_app_preview_values.d.ts.map +1 -0
  422. package/dist/database/migrations/202610170001_rel_drop_app_preview_values.js +25 -0
  423. package/dist/database/migrations/202610170001_rel_drop_app_preview_values.js.map +1 -0
  424. package/dist/database/migrations/202610210001_rel_protected_requires_approval.d.ts +9 -0
  425. package/dist/database/migrations/202610210001_rel_protected_requires_approval.d.ts.map +1 -0
  426. package/dist/database/migrations/202610210001_rel_protected_requires_approval.js +32 -0
  427. package/dist/database/migrations/202610210001_rel_protected_requires_approval.js.map +1 -0
  428. package/dist/package.d.ts +2 -0
  429. package/dist/package.d.ts.map +1 -0
  430. package/dist/package.js +2 -0
  431. package/dist/package.js.map +1 -0
  432. package/dist/server/access/caller.d.ts +74 -0
  433. package/dist/server/access/caller.d.ts.map +1 -0
  434. package/dist/server/access/caller.js +141 -0
  435. package/dist/server/access/caller.js.map +1 -0
  436. package/dist/server/access/key-scope-objects.d.ts +20 -0
  437. package/dist/server/access/key-scope-objects.d.ts.map +1 -0
  438. package/dist/server/access/key-scope-objects.js +42 -0
  439. package/dist/server/access/key-scope-objects.js.map +1 -0
  440. package/dist/server/composition.d.ts +52 -0
  441. package/dist/server/composition.d.ts.map +1 -0
  442. package/dist/server/composition.js +105 -0
  443. package/dist/server/composition.js.map +1 -0
  444. package/dist/server/config.d.ts +127 -0
  445. package/dist/server/config.d.ts.map +1 -0
  446. package/dist/server/config.js +2 -0
  447. package/dist/server/config.js.map +1 -0
  448. package/dist/server/drivers/host/driver.d.ts +87 -0
  449. package/dist/server/drivers/host/driver.d.ts.map +1 -0
  450. package/dist/server/drivers/host/driver.js +636 -0
  451. package/dist/server/drivers/host/driver.js.map +1 -0
  452. package/dist/server/drivers/types.d.ts +221 -0
  453. package/dist/server/drivers/types.d.ts.map +1 -0
  454. package/dist/server/drivers/types.js +27 -0
  455. package/dist/server/drivers/types.js.map +1 -0
  456. package/dist/server/errors.d.ts +20 -0
  457. package/dist/server/errors.d.ts.map +1 -0
  458. package/dist/server/errors.js +44 -0
  459. package/dist/server/errors.js.map +1 -0
  460. package/dist/server/index.d.ts +14 -0
  461. package/dist/server/index.d.ts.map +1 -0
  462. package/dist/server/index.js +13 -0
  463. package/dist/server/index.js.map +1 -0
  464. package/dist/server/locales/en-US.d.ts +6 -0
  465. package/dist/server/locales/en-US.d.ts.map +1 -0
  466. package/dist/server/locales/en-US.js +4 -0
  467. package/dist/server/locales/en-US.js.map +1 -0
  468. package/dist/server/locales/index.d.ts +4 -0
  469. package/dist/server/locales/index.d.ts.map +1 -0
  470. package/dist/server/locales/index.js +6 -0
  471. package/dist/server/locales/index.js.map +1 -0
  472. package/dist/server/locales/zh-CN.d.ts +4 -0
  473. package/dist/server/locales/zh-CN.d.ts.map +1 -0
  474. package/dist/server/locales/zh-CN.js +4 -0
  475. package/dist/server/locales/zh-CN.js.map +1 -0
  476. package/dist/server/plugin.d.ts +4 -0
  477. package/dist/server/plugin.d.ts.map +1 -0
  478. package/dist/server/plugin.js +16 -0
  479. package/dist/server/plugin.js.map +1 -0
  480. package/dist/server/providers/authorization.d.ts +17 -0
  481. package/dist/server/providers/authorization.d.ts.map +1 -0
  482. package/dist/server/providers/authorization.js +60 -0
  483. package/dist/server/providers/authorization.js.map +1 -0
  484. package/dist/server/providers/index.d.ts +4 -0
  485. package/dist/server/providers/index.d.ts.map +1 -0
  486. package/dist/server/providers/index.js +8 -0
  487. package/dist/server/providers/index.js.map +1 -0
  488. package/dist/server/providers/releases.d.ts +23 -0
  489. package/dist/server/providers/releases.d.ts.map +1 -0
  490. package/dist/server/providers/releases.js +219 -0
  491. package/dist/server/providers/releases.js.map +1 -0
  492. package/dist/server/routes/api.d.ts +24 -0
  493. package/dist/server/routes/api.d.ts.map +1 -0
  494. package/dist/server/routes/api.js +1610 -0
  495. package/dist/server/routes/api.js.map +1 -0
  496. package/dist/server/routes/index.d.ts +7 -0
  497. package/dist/server/routes/index.d.ts.map +1 -0
  498. package/dist/server/routes/index.js +39 -0
  499. package/dist/server/routes/index.js.map +1 -0
  500. package/dist/server/routes/schemas.d.ts +184 -0
  501. package/dist/server/routes/schemas.d.ts.map +1 -0
  502. package/dist/server/routes/schemas.js +763 -0
  503. package/dist/server/routes/schemas.js.map +1 -0
  504. package/dist/server/secrets-stores.d.ts +9 -0
  505. package/dist/server/secrets-stores.d.ts.map +1 -0
  506. package/dist/server/secrets-stores.js +80 -0
  507. package/dist/server/secrets-stores.js.map +1 -0
  508. package/dist/server/services/artifact.d.ts +30 -0
  509. package/dist/server/services/artifact.d.ts.map +1 -0
  510. package/dist/server/services/artifact.js +234 -0
  511. package/dist/server/services/artifact.js.map +1 -0
  512. package/dist/server/services/codec.d.ts +15 -0
  513. package/dist/server/services/codec.d.ts.map +1 -0
  514. package/dist/server/services/codec.js +58 -0
  515. package/dist/server/services/codec.js.map +1 -0
  516. package/dist/server/services/config-file.d.ts +26 -0
  517. package/dist/server/services/config-file.d.ts.map +1 -0
  518. package/dist/server/services/config-file.js +154 -0
  519. package/dist/server/services/config-file.js.map +1 -0
  520. package/dist/server/services/config-secrets.d.ts +23 -0
  521. package/dist/server/services/config-secrets.d.ts.map +1 -0
  522. package/dist/server/services/config-secrets.js +269 -0
  523. package/dist/server/services/config-secrets.js.map +1 -0
  524. package/dist/server/services/credentials.d.ts +44 -0
  525. package/dist/server/services/credentials.d.ts.map +1 -0
  526. package/dist/server/services/credentials.js +137 -0
  527. package/dist/server/services/credentials.js.map +1 -0
  528. package/dist/server/services/environments.d.ts +75 -0
  529. package/dist/server/services/environments.d.ts.map +1 -0
  530. package/dist/server/services/environments.js +508 -0
  531. package/dist/server/services/environments.js.map +1 -0
  532. package/dist/server/services/events.d.ts +11 -0
  533. package/dist/server/services/events.d.ts.map +1 -0
  534. package/dist/server/services/events.js +26 -0
  535. package/dist/server/services/events.js.map +1 -0
  536. package/dist/server/services/registries.d.ts +67 -0
  537. package/dist/server/services/registries.d.ts.map +1 -0
  538. package/dist/server/services/registries.js +409 -0
  539. package/dist/server/services/registries.js.map +1 -0
  540. package/dist/server/services/release-artifacts.d.ts +42 -0
  541. package/dist/server/services/release-artifacts.d.ts.map +1 -0
  542. package/dist/server/services/release-artifacts.js +165 -0
  543. package/dist/server/services/release-artifacts.js.map +1 -0
  544. package/dist/server/services/releases.d.ts +417 -0
  545. package/dist/server/services/releases.d.ts.map +1 -0
  546. package/dist/server/services/releases.js +2725 -0
  547. package/dist/server/services/releases.js.map +1 -0
  548. package/dist/server/services/requests.d.ts +54 -0
  549. package/dist/server/services/requests.d.ts.map +1 -0
  550. package/dist/server/services/requests.js +342 -0
  551. package/dist/server/services/requests.js.map +1 -0
  552. package/dist/server/services/secrets.d.ts +11 -0
  553. package/dist/server/services/secrets.d.ts.map +1 -0
  554. package/dist/server/services/secrets.js +36 -0
  555. package/dist/server/services/secrets.js.map +1 -0
  556. package/dist/server/services/validation.d.ts +21 -0
  557. package/dist/server/services/validation.d.ts.map +1 -0
  558. package/dist/server/services/validation.js +111 -0
  559. package/dist/server/services/validation.js.map +1 -0
  560. package/dist/server/services/variables.d.ts +132 -0
  561. package/dist/server/services/variables.d.ts.map +1 -0
  562. package/dist/server/services/variables.js +501 -0
  563. package/dist/server/services/variables.js.map +1 -0
  564. package/dist/server/tokens.d.ts +110 -0
  565. package/dist/server/tokens.d.ts.map +1 -0
  566. package/dist/server/tokens.js +9 -0
  567. package/dist/server/tokens.js.map +1 -0
  568. package/dist/shared/access.d.ts +212 -0
  569. package/dist/shared/access.d.ts.map +1 -0
  570. package/dist/shared/access.js +228 -0
  571. package/dist/shared/access.js.map +1 -0
  572. package/dist/shared/locales/access.en-US.d.ts +137 -0
  573. package/dist/shared/locales/access.en-US.d.ts.map +1 -0
  574. package/dist/shared/locales/access.en-US.js +133 -0
  575. package/dist/shared/locales/access.en-US.js.map +1 -0
  576. package/dist/shared/locales/access.zh-CN.d.ts +4 -0
  577. package/dist/shared/locales/access.zh-CN.d.ts.map +1 -0
  578. package/dist/shared/locales/access.zh-CN.js +127 -0
  579. package/dist/shared/locales/access.zh-CN.js.map +1 -0
  580. package/dist/shared/releases.d.ts +657 -0
  581. package/dist/shared/releases.d.ts.map +1 -0
  582. package/dist/shared/releases.js +56 -0
  583. package/dist/shared/releases.js.map +1 -0
  584. package/package.json +118 -3
@@ -0,0 +1,2725 @@
1
+ /**
2
+ * Apps, releases and deployments. Adapted from the Hub plugin's service: uploads are deduplicated by checksum and made
3
+ * idempotent by key, every deploy and rollback is an immutable deployment record with phases and a log, configuration
4
+ * starts from the release's `config.example.yml` with generated secrets, and the runtime is reached only through the
5
+ * environment's driver session.
6
+ */
7
+ import { randomUUID, createHash } from 'node:crypto';
8
+ import { createReadStream } from 'node:fs';
9
+ import { readFile, rm } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { Readable } from 'node:stream';
12
+ import { createDriveManager, } from '@nocobase/drive';
13
+ import { appendJournal, createDiagnosticLogger, pruneJournals, readJournal, reportLoggingFailure, } from '@nocobase/logging';
14
+ import { parse as parseYaml } from 'yaml';
15
+ import { BUSINESS_ACTIONS } from '../../shared/access.js';
16
+ import { RESERVED_VARIABLE_NAMES } from '../../shared/releases.js';
17
+ import { SYSTEM_CALLER, actorOf, } from '../access/caller.js';
18
+ import { capabilitiesFor, } from '../drivers/types.js';
19
+ import { ReleasesError, forbidden, notFound } from '../errors.js';
20
+ import { RELEASE_VERSION_PATTERN, assertMountableAt, inspectArtifact, receiveArtifact, validateIdempotencyKey, } from './artifact.js';
21
+ import { decodeDate, decodeLabels, decodeOptionalDate, decodeRecord, iso, matchesLabels, nullableNumber, nullableString, } from './codec.js';
22
+ import { CONFIG_SECRET_PATHS, MAX_CONFIG_BYTES, assertConfigSize, ensureConfigSecrets, ensurePublicOrigin, validateYamlConfig, writeTextAtomic, } from './config-file.js';
23
+ import { maskConfig, restoreConfigSecrets } from './config-secrets.js';
24
+ import { ReleaseArtifactStore, normalizeImage, } from './release-artifacts.js';
25
+ import { decryptText, encryptText } from './secrets.js';
26
+ import { DEFAULT_INITIAL_ADMIN, INITIAL_ADMIN_PATHS, PUBLIC_ORIGIN_PATH, SAMPLE_DATA_PATH, appVariableView, assertVariableName, assertVariableValue, configValueAt, decodeVariablesManifest, generateVariable, hasRealValue, isSecretName, parseVariablesManifest, randomPassword, resolveVariables, } from './variables.js';
27
+ import { APP_ID_PATTERN, assertAppId, assertPagination, normalizeLabels, normalizeName, normalizeRuntimePolicy, } from './validation.js';
28
+ const DEFAULT_STARTUP_WAIT_MS = 5_000;
29
+ export class ReleasesService {
30
+ options;
31
+ diagnostic;
32
+ disk;
33
+ artifacts;
34
+ locks = new Map();
35
+ sessions = new Map();
36
+ running = new Set();
37
+ /** The in-process run of each deployment under way, so a waiter also sees what finishing it announces. */
38
+ runs = new Map();
39
+ restoring = null;
40
+ closed = false;
41
+ constructor(options) {
42
+ this.options = options;
43
+ this.diagnostic = createDiagnosticLogger(options.logger);
44
+ this.artifacts = new ReleaseArtifactStore(options.database);
45
+ this.disk = createDriveManager({
46
+ default: 'artifact',
47
+ disks: { artifact: options.artifact },
48
+ }).use('artifact');
49
+ }
50
+ // --- Apps ---------------------------------------------------------------------------------------------------
51
+ async listApps(caller, options = {}) {
52
+ const filter = await this.options.guard.appFilter(caller, 'view');
53
+ if (!filter)
54
+ throw forbidden();
55
+ const requestedPage = options.page ?? 1;
56
+ const pageSize = options.pageSize ?? 24;
57
+ assertPagination(requestedPage, pageSize);
58
+ const search = options.search?.trim() ?? '';
59
+ if (search.length > 100)
60
+ throw new ReleasesError('Search must be 100 characters or fewer.', 'INVALID_SEARCH', 'INVALID_ARGUMENT');
61
+ const searchIds = search
62
+ ? await this.findAppIdsBySearch(search)
63
+ : undefined;
64
+ let query = this.query()
65
+ .selectFrom('relApps')
66
+ .selectAll()
67
+ .orderBy('createdAt', 'desc')
68
+ .orderBy('id', 'desc');
69
+ // An empty list matches nothing: `id IS NULL` never holds, and unlike a placeholder id it needs no value
70
+ // (PostgreSQL refuses a NUL character in a text parameter) and suits any column type.
71
+ if (searchIds)
72
+ query = query.where((eb) => searchIds.length > 0
73
+ ? eb('id', 'in', [...searchIds])
74
+ : eb('id', 'is', null));
75
+ if (options.environmentId)
76
+ query = query.where('environmentId', '=', options.environmentId);
77
+ const only = filter.only;
78
+ if (only)
79
+ query = query.where((eb) => only.length > 0 ? eb('id', 'in', [...only]) : eb('id', 'is', null));
80
+ if (!filter.all) {
81
+ const { createdBy, ids } = filter;
82
+ query = query.where((eb) => eb.or([
83
+ eb('createdBy', 'in', [...createdBy]),
84
+ ids.length > 0 ? eb('id', 'in', [...ids]) : eb('id', 'is', null),
85
+ ]));
86
+ }
87
+ // Labels are opaque JSON, matched here rather than in SQL.
88
+ const rows = (await query.execute())
89
+ .map(decodeApp)
90
+ .filter((app) => matchesLabels(app.labels, options.labels));
91
+ const total = rows.length;
92
+ const page = Math.min(requestedPage, Math.max(1, Math.ceil(total / pageSize)));
93
+ const slice = rows.slice((page - 1) * pageSize, page * pageSize);
94
+ return { items: await this.summarize(slice), total, page, pageSize };
95
+ }
96
+ async getApp(caller, appId) {
97
+ const app = await this.requireApp(appId);
98
+ await this.options.guard.requireApp(caller, 'view', app);
99
+ const allowed = [];
100
+ for (const action of BUSINESS_ACTIONS['rel.apps'])
101
+ if (action !== 'create' &&
102
+ (await this.options.guard.canApp(caller, action, app)))
103
+ allowed.push(action);
104
+ const variables = await this.appVariablesOf(app).catch(() => null);
105
+ return {
106
+ ...(await this.summarize([app]))[0],
107
+ allowed,
108
+ ...(variables
109
+ ? {
110
+ variables: {
111
+ missing: variables.resolved.missing.map((item) => item.name),
112
+ changed: variables.resolved.changed,
113
+ },
114
+ }
115
+ : {}),
116
+ };
117
+ }
118
+ async createApp(caller, input) {
119
+ this.options.guard.requireCreate(caller);
120
+ if (!input || typeof input.id !== 'string')
121
+ throw new ReleasesError('An App ID is required.', 'INVALID_APP_ID', 'INVALID_ARGUMENT');
122
+ const id = input.id.trim();
123
+ assertAppId(id);
124
+ const reserved = this.options.reservedBasePath ?? '';
125
+ if (reserved && (reserved === `/${id}` || reserved.startsWith(`/${id}/`)))
126
+ throw new ReleasesError('App ID conflicts with this application’s own path.', 'INVALID_APP_ID', 'INVALID_ARGUMENT');
127
+ const name = normalizeName(input.name, 'INVALID_APP_NAME');
128
+ const labels = normalizeLabels(input.labels);
129
+ const previewOf = await this.previewTarget(caller, input.previewOf);
130
+ if (typeof input.environmentId !== 'string')
131
+ throw new ReleasesError('Choose an environment.', 'INVALID_ENVIRONMENT_ID', 'INVALID_ARGUMENT');
132
+ const environment = await this.options.environments.record(input.environmentId);
133
+ // The environment's driver must be available to take an App.
134
+ await this.options.environments.driverEnvironment(environment.id);
135
+ // Left out, the idle stop and dormancy are the environment's defaults.
136
+ const policy = normalizeRuntimePolicy(input, {
137
+ activation: 'eager',
138
+ idleStopMinutes: environment.defaultIdleStopMinutes,
139
+ dormantAfterHours: environment.defaultDormantAfterHours,
140
+ });
141
+ const now = new Date();
142
+ try {
143
+ await this.withLock(`environment:${environment.id}`, async () => {
144
+ if (await this.findApp(id))
145
+ throw new ReleasesError('Application ID is unavailable. Choose a different ID.', 'APP_EXISTS', 'ALREADY_EXISTS');
146
+ await this.assertEnvironmentCapacity(environment);
147
+ await this.query()
148
+ .insertInto('relApps')
149
+ .values({
150
+ id,
151
+ environmentId: environment.id,
152
+ name,
153
+ description: typeof input.description === 'string' && input.description.trim()
154
+ ? input.description.trim()
155
+ : null,
156
+ currentDeploymentId: null,
157
+ enabled: false,
158
+ activation: policy.activation,
159
+ idleStopMinutes: policy.idleStopMinutes,
160
+ dormantAfterHours: policy.dormantAfterHours,
161
+ labels: JSON.stringify(labels),
162
+ previewOf,
163
+ createdBy: caller.userId,
164
+ createdVia: caller.kind,
165
+ createdAt: now,
166
+ updatedAt: now,
167
+ })
168
+ .execute();
169
+ });
170
+ }
171
+ catch (error) {
172
+ if (!(error instanceof ReleasesError) && (await this.findApp(id)))
173
+ throw new ReleasesError('Application ID is unavailable. Choose a different ID.', 'APP_EXISTS', 'ALREADY_EXISTS');
174
+ throw error;
175
+ }
176
+ const app = await this.requireApp(id);
177
+ await this.options.events.emit({
178
+ type: 'app.created',
179
+ app: appView(app),
180
+ actor: actorOf(caller),
181
+ });
182
+ return (await this.summarize([app]))[0];
183
+ }
184
+ async updateApp(caller, appId, input) {
185
+ return await this.withLock(`publish:${appId}`, async () => {
186
+ const app = await this.requireApp(appId);
187
+ await this.options.guard.requireApp(caller, 'configure', app);
188
+ const values = {};
189
+ if (input.name !== undefined)
190
+ values.name = normalizeName(input.name, 'INVALID_APP_NAME');
191
+ if (input.description !== undefined)
192
+ values.description =
193
+ typeof input.description === 'string' && input.description.trim()
194
+ ? input.description.trim()
195
+ : null;
196
+ const policy = normalizeRuntimePolicy(input, app);
197
+ const policyChanged = policy.activation !== app.activation ||
198
+ policy.idleStopMinutes !== app.idleStopMinutes ||
199
+ policy.dormantAfterHours !== app.dormantAfterHours;
200
+ if (policyChanged)
201
+ Object.assign(values, policy);
202
+ if (input.labels !== undefined)
203
+ values.labels = JSON.stringify(normalizeLabels(input.labels));
204
+ await this.updateApp_(appId, values);
205
+ if (policyChanged && app.currentDeploymentId)
206
+ await this.applyRuntimePolicy(app.environmentId);
207
+ return (await this.summarize([await this.requireApp(appId)]))[0];
208
+ });
209
+ }
210
+ /**
211
+ * Sends an environment its desired set again after an App's runtime policy changed, when its driver honours the
212
+ * policy; the runtime applies a policy change without restarting the App. A failure leaves the stored policy for the
213
+ * next restore.
214
+ */
215
+ async applyRuntimePolicy(environmentId) {
216
+ try {
217
+ const { driver, environment } = await this.options.environments.driverEnvironment(environmentId);
218
+ if (!capabilitiesFor(driver, environment.config).onDemand)
219
+ return;
220
+ await (await this.session(environmentId)).restore(await this.desiredSet(environmentId));
221
+ }
222
+ catch (error) {
223
+ this.diagnostic.warn('Failed to apply an App runtime policy; it applies on the next restore', error);
224
+ }
225
+ }
226
+ /** Removes the App, its releases, history and data. A person confirms by typing the App ID. */
227
+ async deleteApp(caller, appId, options = {}) {
228
+ const app = await this.requireApp(appId);
229
+ await this.options.guard.requireApp(caller, 'delete', app);
230
+ if ((caller.kind === 'human' || caller.kind === 'key') &&
231
+ options.confirm !== appId)
232
+ throw new ReleasesError('Type the application ID to confirm deletion.', 'CONFIRMATION_REQUIRED', 'FAILED_PRECONDITION');
233
+ await this.removeApp(app, caller);
234
+ }
235
+ // --- Releases -----------------------------------------------------------------------------------------------
236
+ async listReleases(caller, appId, options = {}) {
237
+ const app = await this.requireApp(appId);
238
+ await this.options.guard.requireApp(caller, 'view', app);
239
+ const rows = await this.query()
240
+ .selectFrom('relReleases')
241
+ .selectAll()
242
+ .where('appId', '=', appId)
243
+ .orderBy('createdAt', 'desc')
244
+ .orderBy('id', 'desc')
245
+ .execute();
246
+ const releases = rows
247
+ .map(decodeRelease)
248
+ .filter((release) => matchesLabels(release.labels, options.labels));
249
+ const images = await this.artifacts.imagesOf(releases.map((release) => release.id));
250
+ const context = releases.some((release) => release.variables)
251
+ ? await this.variablesContext(app).catch(() => null)
252
+ : null;
253
+ return releases.map((release) => ({
254
+ ...releaseView(release, images.get(release.id) ?? []),
255
+ variables: release.variables && context
256
+ ? {
257
+ declared: release.variables.variables.filter((variable) => !variable.runtime).length,
258
+ required: release.variables.variables.filter((variable) => variable.required).length,
259
+ missing: this.resolveFor(release, context).missing.map((variable) => variable.name),
260
+ }
261
+ : null,
262
+ }));
263
+ }
264
+ async getRelease(caller, appId, releaseId) {
265
+ const app = await this.requireApp(appId);
266
+ await this.options.guard.requireApp(caller, 'view', app);
267
+ const release = await this.requireRelease(appId, releaseId);
268
+ const images = await this.artifacts.imagesOf([release.id]);
269
+ return releaseView(release, images.get(release.id) ?? []);
270
+ }
271
+ /**
272
+ * Registers an image CI built and pushed, by digest, as a release of `version` (an image release, which Docker
273
+ * environments pull). It needs `upload` on the App, as uploading an archive does. The same digest again answers its
274
+ * release; an image for another platform joins the release of the same version, and another digest for a platform
275
+ * that has one is refused (`IMAGE_CONFLICT`): a release's image never changes. Releases promoted from it get it too.
276
+ */
277
+ async registerImageRelease(caller, appId, input) {
278
+ const image = normalizeImage(input);
279
+ const version = normalizeVersion(input.version);
280
+ const labels = normalizeLabels(input.labels);
281
+ const variables = input.variables === undefined
282
+ ? null
283
+ : parseVariablesManifest(input.variables);
284
+ const app = await this.requireApp(appId);
285
+ await this.options.guard.requireApp(caller, 'upload', app);
286
+ return await this.withLock(`publish:${appId}`, async () => {
287
+ const byDigest = await this.query()
288
+ .selectFrom('relReleaseArtifacts')
289
+ .select('releaseId')
290
+ .where('appId', '=', appId)
291
+ .where('digest', '=', image.digest)
292
+ .executeTakeFirst();
293
+ const sameVersion = byDigest
294
+ ? null
295
+ : await this.query()
296
+ .selectFrom('relReleases')
297
+ .selectAll()
298
+ .where('appId', '=', appId)
299
+ .where('kind', '=', 'image')
300
+ .where('version', '=', version)
301
+ .executeTakeFirst();
302
+ const existing = byDigest
303
+ ? await this.requireRelease(appId, String(byDigest.releaseId))
304
+ : sameVersion
305
+ ? decodeRelease(sameVersion)
306
+ : null;
307
+ if (existing) {
308
+ await this.options.database.transaction(async (connection) => {
309
+ await this.artifacts.register(connection, existing, image, actorOf(caller));
310
+ // An image of another platform may bring the manifest the first one came without.
311
+ if (variables && !existing.variables)
312
+ await connection.query
313
+ .updateTable('relReleases')
314
+ .set({ variables: JSON.stringify(variables) })
315
+ .where('id', '=', existing.id)
316
+ .execute();
317
+ });
318
+ const images = await this.artifacts.imagesOf([existing.id]);
319
+ return {
320
+ ...releaseView(existing, images.get(existing.id) ?? []),
321
+ reused: true,
322
+ };
323
+ }
324
+ const release = {
325
+ id: randomUUID(),
326
+ appId,
327
+ kind: 'image',
328
+ version,
329
+ artifactKey: null,
330
+ checksum: image.digest.replace(/^sha256:/u, ''),
331
+ size: null,
332
+ configTemplate: null,
333
+ manifest: {},
334
+ variables,
335
+ labels,
336
+ sourceReleaseId: null,
337
+ sourceCommit: image.sourceCommit,
338
+ build: normalizeBuild(input.build),
339
+ createdBy: caller.userId,
340
+ createdVia: caller.kind,
341
+ createdAt: new Date(),
342
+ };
343
+ await this.options.database.transaction(async (connection) => {
344
+ await connection.query
345
+ .insertInto('relReleases')
346
+ .values(encodeRelease(release))
347
+ .execute();
348
+ await connection.query
349
+ .insertInto('relReleaseChecksums')
350
+ .values({
351
+ appId,
352
+ checksum: release.checksum,
353
+ releaseId: release.id,
354
+ deploymentId: null,
355
+ configFingerprint: null,
356
+ })
357
+ .execute();
358
+ await this.artifacts.register(connection, release, image, actorOf(caller));
359
+ await connection.query
360
+ .updateTable('relApps')
361
+ .set({ updatedAt: new Date() })
362
+ .where('id', '=', appId)
363
+ .execute();
364
+ });
365
+ const images = await this.artifacts.imagesOf([release.id]);
366
+ const view = releaseView(release, images.get(release.id) ?? []);
367
+ await this.options.events.emit({
368
+ type: 'release.created',
369
+ app: appView(app),
370
+ release: view,
371
+ actor: actorOf(caller),
372
+ });
373
+ return { ...view, reused: false };
374
+ });
375
+ }
376
+ /** The release's `config.example.yml`, which may hold sample credentials: `configure` only, its secrets masked. */
377
+ async readConfigTemplate(caller, appId, releaseId) {
378
+ const app = await this.requireApp(appId);
379
+ await this.options.guard.requireApp(caller, 'configure', app);
380
+ const template = (await this.requireRelease(appId, releaseId))
381
+ .configTemplate;
382
+ return template === null ? null : maskConfig(template).content;
383
+ }
384
+ /**
385
+ * Stores an uploaded archive as a release. An archive already uploaded to the App (same checksum) answers with the
386
+ * existing release; an idempotency key replays the first answer and refuses another archive. With `deploy`, the
387
+ * release is deployed right away under the same rules as `deploy`.
388
+ */
389
+ async uploadRelease(caller, appId, input) {
390
+ validateIdempotencyKey(input.idempotencyKey);
391
+ const labels = normalizeLabels(input.labels);
392
+ const app = await this.requireApp(appId);
393
+ await this.options.guard.requireApp(caller, 'upload', app);
394
+ const environment = await this.options.environments.record(app.environmentId);
395
+ if (input.deploy) {
396
+ if (input.deploy.config !== undefined)
397
+ validateDeployConfig(input.deploy.config);
398
+ await this.authorizeDeploy(caller, app, environment);
399
+ }
400
+ const configFingerprint = input.deploy?.config?.content !== undefined
401
+ ? sha256(input.deploy.config.content)
402
+ : null;
403
+ const staged = await receiveArtifact(input.stream, {
404
+ expectedChecksum: input.checksum,
405
+ maxBytes: this.options.maxArtifactBytes,
406
+ });
407
+ try {
408
+ const promoted = input.deploy
409
+ ? null
410
+ : await this.promotable(caller, input.promoteFrom, staged.checksum);
411
+ if (promoted)
412
+ return await this.promoteRelease(caller, promoted.appId, promoted.id, appId, { labels });
413
+ const metadata = await inspectArtifact(staged.path);
414
+ assertMountableAt(metadata.manifest, `/${appId}`);
415
+ return await this.withLock(`publish:${appId}`, async () => {
416
+ const current = await this.requireApp(appId);
417
+ const existing = await this.existingRelease(appId, staged.checksum, input.idempotencyKey, configFingerprint, Boolean(input.deploy));
418
+ if (existing)
419
+ return existing;
420
+ const id = randomUUID();
421
+ const artifactKey = `${appId}/${id}.tar.gz`;
422
+ const release = {
423
+ id,
424
+ appId,
425
+ kind: 'archive',
426
+ version: metadata.version,
427
+ artifactKey,
428
+ checksum: staged.checksum,
429
+ size: staged.size,
430
+ configTemplate: metadata.configTemplate,
431
+ manifest: metadata.manifest,
432
+ variables: metadata.variables,
433
+ labels,
434
+ sourceReleaseId: null,
435
+ sourceCommit: normalizeSourceCommit(input.sourceCommit),
436
+ build: normalizeBuild(input.build),
437
+ createdBy: caller.userId,
438
+ createdVia: caller.kind,
439
+ createdAt: new Date(),
440
+ };
441
+ if (input.deploy)
442
+ await this.requireNoPendingDeployment(appId);
443
+ // A release whose required variables are not all set is kept, and not deployed: once they are set, it
444
+ // deploys by its ID without another upload.
445
+ const missing = input.deploy
446
+ ? await this.missingVariables(current, release, input.deploy.config)
447
+ : [];
448
+ const deployment = input.deploy && missing.length === 0
449
+ ? await this.buildDeploymentRecord(current, release, caller, {
450
+ kind: 'deploy',
451
+ config: input.deploy.config,
452
+ })
453
+ : null;
454
+ try {
455
+ await this.disk.putStream(artifactKey, createReadStream(staged.path), {
456
+ visibility: 'private',
457
+ contentType: 'application/gzip',
458
+ });
459
+ await this.options.database.transaction(async (connection) => {
460
+ if (deployment)
461
+ await this.requireNoPendingDeployment(appId, connection);
462
+ await connection.query
463
+ .insertInto('relReleases')
464
+ .values(encodeRelease(release))
465
+ .execute();
466
+ await connection.query
467
+ .insertInto('relReleaseChecksums')
468
+ .values({
469
+ appId,
470
+ checksum: staged.checksum,
471
+ releaseId: id,
472
+ deploymentId: deployment?.id ?? null,
473
+ configFingerprint,
474
+ })
475
+ .execute();
476
+ if (input.idempotencyKey)
477
+ await connection.query
478
+ .insertInto('relUploadRequests')
479
+ .values({
480
+ appId,
481
+ requestKey: input.idempotencyKey,
482
+ checksum: staged.checksum,
483
+ releaseId: id,
484
+ })
485
+ .execute();
486
+ if (deployment)
487
+ await connection.query
488
+ .insertInto('relDeployments')
489
+ .values(encodeDeployment(deployment))
490
+ .execute();
491
+ await connection.query
492
+ .updateTable('relApps')
493
+ .set({ updatedAt: new Date() })
494
+ .where('id', '=', appId)
495
+ .execute();
496
+ });
497
+ }
498
+ catch (error) {
499
+ await this.disk.delete(artifactKey).catch(() => undefined);
500
+ if (deployment?.configPath)
501
+ await rm(deployment.configPath, { force: true });
502
+ // Another writer may have won the uniqueness race.
503
+ const winner = await this.existingRelease(appId, staged.checksum, input.idempotencyKey, configFingerprint, Boolean(input.deploy));
504
+ if (winner)
505
+ return winner;
506
+ throw error;
507
+ }
508
+ const view = releaseView(release);
509
+ await this.options.events.emit({
510
+ type: 'release.created',
511
+ app: appView(current),
512
+ release: view,
513
+ actor: actorOf(caller),
514
+ });
515
+ if (missing.length > 0)
516
+ throw variablesMissing(current, release, missing);
517
+ if (deployment)
518
+ this.schedule(deployment);
519
+ return { ...view, reused: false, deploymentId: deployment?.id ?? null };
520
+ });
521
+ }
522
+ finally {
523
+ await staged.dispose();
524
+ }
525
+ }
526
+ /** The release an upload may be promoted from: the one named, when it holds the same bytes and the caller may see it. */
527
+ async promotable(caller, from, checksum) {
528
+ if (!from)
529
+ return null;
530
+ const release = await this.findRelease(from.appId, from.releaseId);
531
+ if (!release || release.checksum !== checksum)
532
+ return null;
533
+ const source = await this.findApp(from.appId);
534
+ if (!source || !(await this.options.guard.canApp(caller, 'view', source)))
535
+ return null;
536
+ return release;
537
+ }
538
+ /** Replaces a release's labels, for an application linking releases to its own records. */
539
+ async labelRelease(caller, appId, releaseId, labels) {
540
+ const app = await this.requireApp(appId);
541
+ await this.options.guard.requireApp(caller, 'upload', app);
542
+ await this.requireRelease(appId, releaseId);
543
+ await this.query()
544
+ .updateTable('relReleases')
545
+ .set({ labels: JSON.stringify(normalizeLabels(labels)) })
546
+ .where('id', '=', releaseId)
547
+ .where('appId', '=', appId)
548
+ .execute();
549
+ return releaseView(await this.requireRelease(appId, releaseId));
550
+ }
551
+ /**
552
+ * Copies a release into another App (staging to production) without uploading it again. The same archive already
553
+ * in the target App answers with that release.
554
+ */
555
+ async promoteRelease(caller, sourceAppId, releaseId, targetAppId, options = {}) {
556
+ const source = await this.requireApp(sourceAppId);
557
+ await this.options.guard.requireApp(caller, 'view', source);
558
+ const target = await this.requireApp(targetAppId);
559
+ await this.options.guard.requireApp(caller, 'upload', target);
560
+ const release = await this.requireRelease(sourceAppId, releaseId);
561
+ const labels = options.labels
562
+ ? normalizeLabels(options.labels)
563
+ : release.labels;
564
+ assertMountableAt(release.manifest, `/${targetAppId}`);
565
+ return await this.withLock(`publish:${targetAppId}`, async () => {
566
+ const existing = await this.existingRelease(targetAppId, release.checksum);
567
+ if (existing) {
568
+ // The release is there already; it gets the images of the release it was promoted from now, if it lacks them.
569
+ await this.options.database.transaction((connection) => this.artifacts.copy(connection, release.id, {
570
+ id: existing.id,
571
+ appId: targetAppId,
572
+ }));
573
+ return existing;
574
+ }
575
+ const id = randomUUID();
576
+ const artifactKey = release.artifactKey
577
+ ? `${targetAppId}/${id}.tar.gz`
578
+ : null;
579
+ const copy = {
580
+ ...release,
581
+ id,
582
+ appId: targetAppId,
583
+ artifactKey,
584
+ labels,
585
+ sourceReleaseId: release.id,
586
+ createdBy: caller.userId,
587
+ createdVia: caller.kind,
588
+ createdAt: new Date(),
589
+ };
590
+ if (release.artifactKey && artifactKey)
591
+ await this.disk.copy(release.artifactKey, artifactKey);
592
+ try {
593
+ await this.options.database.transaction(async (connection) => {
594
+ await connection.query
595
+ .insertInto('relReleases')
596
+ .values(encodeRelease(copy))
597
+ .execute();
598
+ await connection.query
599
+ .insertInto('relReleaseChecksums')
600
+ .values({
601
+ appId: targetAppId,
602
+ checksum: copy.checksum,
603
+ releaseId: id,
604
+ deploymentId: null,
605
+ configFingerprint: null,
606
+ })
607
+ .execute();
608
+ await this.artifacts.copy(connection, release.id, {
609
+ id,
610
+ appId: targetAppId,
611
+ });
612
+ });
613
+ }
614
+ catch (error) {
615
+ if (artifactKey)
616
+ await this.disk.delete(artifactKey).catch(() => undefined);
617
+ const winner = await this.existingRelease(targetAppId, release.checksum);
618
+ if (winner)
619
+ return winner;
620
+ throw error;
621
+ }
622
+ const images = await this.artifacts.imagesOf([id]);
623
+ const view = releaseView(copy, images.get(id) ?? []);
624
+ await this.options.events.emit({
625
+ type: 'release.created',
626
+ app: appView(target),
627
+ release: view,
628
+ actor: actorOf(caller),
629
+ });
630
+ return { ...view, reused: false };
631
+ });
632
+ }
633
+ // --- Configuration ------------------------------------------------------------------------------------------
634
+ /**
635
+ * The App's `config.yml` with its secrets masked (`config-secrets.ts`). Their values never leave the server: the
636
+ * runtime reads the stored file, and an operator who needs one reads it there.
637
+ */
638
+ async readConfig(caller, appId) {
639
+ const app = await this.requireApp(appId);
640
+ await this.options.guard.requireApp(caller, 'configure', app);
641
+ const deployment = await this.currentDeployment(app);
642
+ if (!deployment)
643
+ return { mode: 'file', content: '', secrets: [] };
644
+ if (deployment.configMode !== 'file')
645
+ return { mode: deployment.configMode, content: null, secrets: [] };
646
+ return configDocument((await this.readDeploymentConfig(deployment)) ?? '');
647
+ }
648
+ /**
649
+ * Saves the running deployment's configuration and asks the runtime to reload it. Masked values keep the stored
650
+ * secrets at their paths; `secretChanges` replace or remove secrets. Answers the saved configuration, masked.
651
+ */
652
+ async updateConfig(caller, appId, content, options = {}) {
653
+ if (typeof content !== 'string')
654
+ throw new ReleasesError('Configuration content is required.', 'INVALID_CONFIG_FILE', 'INVALID_ARGUMENT');
655
+ assertConfigSize(content);
656
+ return await this.withLock(appId, async () => {
657
+ const app = await this.requireApp(appId);
658
+ await this.options.guard.requireApp(caller, 'configure', app);
659
+ const deployment = await this.currentDeployment(app);
660
+ if (!deployment ||
661
+ deployment.configMode !== 'file' ||
662
+ !deployment.configPath)
663
+ throw new ReleasesError('Only an active file configuration can be edited.', 'CONFIG_NOT_EDITABLE', 'FAILED_PRECONDITION');
664
+ validateYamlConfig(content);
665
+ const stored = await this.readDeploymentConfig(deployment);
666
+ const restored = restoreConfigSecrets(content, stored, options.secretChanges);
667
+ assertConfigSize(restored);
668
+ validateYamlConfig(restored);
669
+ const release = await this.findRelease(appId, deployment.releaseId);
670
+ const published = ensureConfigSecrets(restored, stored ?? undefined, {
671
+ skip: declaredSecretSections(release?.variables ?? null),
672
+ });
673
+ await writeTextAtomic(deployment.configPath, published);
674
+ const session = await this.session(app.environmentId);
675
+ if (session.reloadConfig) {
676
+ try {
677
+ await session.reloadConfig(appId, published);
678
+ }
679
+ catch (error) {
680
+ throw new ReleasesError(`Configuration was saved, but the runtime could not reload it: ${errorMessage(error)}`, 'CONFIG_RELOAD_FAILED', 'UNAVAILABLE');
681
+ }
682
+ }
683
+ return configDocument(published);
684
+ });
685
+ }
686
+ // --- Deployments --------------------------------------------------------------------------------------------
687
+ async deploy(caller, appId, input) {
688
+ if (!input ||
689
+ typeof input.releaseId !== 'string' ||
690
+ !input.releaseId.trim())
691
+ throw new ReleasesError('A release ID is required.', 'INVALID_DEPLOYMENT_INPUT', 'INVALID_ARGUMENT');
692
+ if (input.config !== undefined)
693
+ validateDeployConfig(input.config);
694
+ validateIdempotencyKey(input.idempotencyKey);
695
+ const app = await this.requireApp(appId);
696
+ const environment = await this.options.environments.record(app.environmentId);
697
+ await this.authorizeDeploy(caller, app, environment);
698
+ const fingerprint = sha256(JSON.stringify({
699
+ releaseId: input.releaseId,
700
+ config: input.config ?? null,
701
+ }));
702
+ return await this.withLock(`publish:${appId}`, async () => {
703
+ if (input.idempotencyKey) {
704
+ const replay = await this.replayDeployRequest(appId, input.idempotencyKey, fingerprint);
705
+ if (replay)
706
+ return replay;
707
+ }
708
+ const release = await this.requireRelease(appId, input.releaseId);
709
+ const deployment = await this.startDeployment(caller, await this.requireApp(appId), release, { kind: 'deploy' }, input.config, input.idempotencyKey
710
+ ? { key: input.idempotencyKey, fingerprint }
711
+ : undefined).catch(async (error) => {
712
+ if (input.idempotencyKey) {
713
+ const winner = await this.replayDeployRequest(appId, input.idempotencyKey, fingerprint);
714
+ if (winner)
715
+ return winner;
716
+ }
717
+ throw error;
718
+ });
719
+ return deployment;
720
+ });
721
+ }
722
+ async rollback(caller, appId, input) {
723
+ if (!input || typeof input.deploymentId !== 'string')
724
+ throw new ReleasesError('A deployment ID is required.', 'INVALID_ROLLBACK_TARGET', 'INVALID_ARGUMENT');
725
+ const app = await this.requireApp(appId);
726
+ const environment = await this.options.environments.record(app.environmentId);
727
+ await this.authorizeDeploy(caller, app, environment);
728
+ return await this.withLock(`publish:${appId}`, async () => {
729
+ const target = await this.requireRollbackTarget(appId, input.deploymentId);
730
+ const release = await this.requireRelease(appId, target.releaseId);
731
+ return await this.startDeployment(caller, await this.requireApp(appId), release, {
732
+ kind: 'rollback',
733
+ rollbackTargetDeploymentId: target.id,
734
+ });
735
+ });
736
+ }
737
+ /**
738
+ * Starts a deployment the caller has already been authorized for: the deployment request service calls it when an
739
+ * approver approves. Only people start deployments this way.
740
+ */
741
+ async startApprovedDeployment(approver, appId, releaseId, options) {
742
+ if (approver.kind !== 'human')
743
+ throw forbidden('Only a person can approve a deployment.', 'HUMAN_REQUIRED');
744
+ return await this.withLock(`publish:${appId}`, async () => {
745
+ const app = await this.requireApp(appId);
746
+ if (options.kind === 'rollback' && options.rollbackTargetDeploymentId)
747
+ await this.requireRollbackTarget(appId, options.rollbackTargetDeploymentId);
748
+ const release = await this.requireRelease(appId, releaseId);
749
+ return await this.startDeployment(approver, app, release, options);
750
+ });
751
+ }
752
+ async listDeployments(caller, appId, options = {}) {
753
+ const requestedPage = options.page ?? 1;
754
+ const pageSize = options.pageSize ?? 20;
755
+ assertPagination(requestedPage, pageSize);
756
+ const app = await this.requireApp(appId);
757
+ await this.options.guard.requireApp(caller, 'view', app);
758
+ const count = await this.query()
759
+ .selectFrom('relDeployments')
760
+ .select((eb) => [eb.fn.countAll().as('total')])
761
+ .where('appId', '=', appId)
762
+ .executeTakeFirst();
763
+ const total = Number(count?.total ?? 0);
764
+ const page = Math.min(requestedPage, Math.max(1, Math.ceil(total / pageSize)));
765
+ const rows = await this.query()
766
+ .selectFrom('relDeployments')
767
+ .leftJoin('relReleases as release', 'relDeployments.releaseId', 'release.id')
768
+ .selectAll('relDeployments')
769
+ .select([
770
+ 'release.version as releaseVersion',
771
+ 'release.checksum as releaseChecksum',
772
+ ])
773
+ .where('relDeployments.appId', '=', appId)
774
+ .orderBy('relDeployments.createdAt', 'desc')
775
+ .orderBy('relDeployments.id', 'desc')
776
+ .limit(pageSize)
777
+ .offset((page - 1) * pageSize)
778
+ .execute();
779
+ return {
780
+ items: rows.map((row) => deploymentView(decodeDeployment(row), typeof row.releaseVersion === 'string'
781
+ ? {
782
+ version: row.releaseVersion,
783
+ checksum: String(row.releaseChecksum),
784
+ }
785
+ : null)),
786
+ total,
787
+ page,
788
+ pageSize,
789
+ };
790
+ }
791
+ /**
792
+ * One deployment. Whoever may deploy the App reads it too, so CI holding only a deploying key can wait for the
793
+ * result; it sees status and phase, never configuration or logs.
794
+ */
795
+ async getDeployment(caller, appId, deploymentId) {
796
+ const app = await this.requireApp(appId);
797
+ if (!(await this.options.guard.canApp(caller, 'view', app)))
798
+ await this.options.guard.requireApp(caller, 'deploy', app);
799
+ const deployment = await this.requireDeployment(appId, deploymentId);
800
+ const release = await this.findRelease(appId, deployment.releaseId);
801
+ return deploymentView(deployment, release ? { version: release.version, checksum: release.checksum } : null);
802
+ }
803
+ /** Resolves once the deployment has finished, or with its current state at the timeout. */
804
+ async waitForDeployment(deploymentId, timeoutMs = 120_000) {
805
+ const deadline = Date.now() + timeoutMs;
806
+ for (;;) {
807
+ const row = await this.query()
808
+ .selectFrom('relDeployments')
809
+ .selectAll()
810
+ .where('id', '=', deploymentId)
811
+ .executeTakeFirst();
812
+ if (!row)
813
+ throw notFound('Deployment', 'DEPLOYMENT_NOT_FOUND', deploymentId);
814
+ const deployment = decodeDeployment(row);
815
+ const finished = deployment.status !== 'queued' && deployment.status !== 'deploying';
816
+ if (finished || Date.now() >= deadline) {
817
+ // A finished deployment's run still announces it (events, the request it settled); wait for that too, so a
818
+ // waiter never sees the deployment done but its consequences pending.
819
+ const run = finished ? this.runs.get(deploymentId) : undefined;
820
+ if (run)
821
+ await Promise.race([run, delay(Math.max(0, deadline - Date.now()))]);
822
+ const release = await this.findRelease(deployment.appId, deployment.releaseId);
823
+ return deploymentView(deployment, release
824
+ ? { version: release.version, checksum: release.checksum }
825
+ : null);
826
+ }
827
+ await new Promise((resolve) => setTimeout(resolve, 50));
828
+ }
829
+ }
830
+ async readLogs(caller, appId, query = {}, deploymentId) {
831
+ const app = await this.requireApp(appId);
832
+ await this.options.guard.requireApp(caller, 'read-logs', app);
833
+ try {
834
+ if (deploymentId) {
835
+ const deployment = await this.requireDeployment(appId, deploymentId);
836
+ const policy = this.options.logging?.deployments;
837
+ const directory = path.dirname(this.deploymentLogPath(appId, deployment.id));
838
+ // A read never prunes: each deployment run prunes the journals when it finishes.
839
+ const result = await readJournal(directory, query, `${deployment.id}.log`);
840
+ const lastPhase = result.entries.at(-1)?.phase;
841
+ return {
842
+ ...result,
843
+ enabled: policy?.enabled !== false,
844
+ status: deployment.status,
845
+ phase: typeof lastPhase === 'string' ? lastPhase : deployment.phase,
846
+ };
847
+ }
848
+ const { driver, environment } = await this.options.environments.driverEnvironment(app.environmentId);
849
+ if (!capabilitiesFor(driver, environment.config).logs)
850
+ return {
851
+ entries: [],
852
+ cursor: '',
853
+ hasMore: false,
854
+ available: false,
855
+ reset: false,
856
+ enabled: false,
857
+ };
858
+ const session = await this.session(app.environmentId);
859
+ return { ...(await session.logs(appId, query)), enabled: true };
860
+ }
861
+ catch (error) {
862
+ if (error instanceof Error && error.message === 'Invalid log cursor')
863
+ throw new ReleasesError(error.message, 'INVALID_LOG_CURSOR', 'INVALID_ARGUMENT');
864
+ throw error;
865
+ }
866
+ }
867
+ // --- Lifecycle ----------------------------------------------------------------------------------------------
868
+ async start(caller, appId) {
869
+ return await this.withLock(appId, async () => {
870
+ const app = await this.requireApp(appId);
871
+ await this.options.guard.requireApp(caller, 'operate', app);
872
+ const deployment = await this.currentDeployment(app);
873
+ if (!deployment)
874
+ throw new ReleasesError('App must be deployed before it can be started.', 'APP_NOT_DEPLOYED', 'FAILED_PRECONDITION');
875
+ await this.updateApp_(appId, { enabled: true });
876
+ try {
877
+ const status = await (await this.session(app.environmentId)).start(await this.deploymentSpec(app, deployment, 'running'));
878
+ if (status.state === 'failed' || status.state === 'unknown')
879
+ throw new Error(status.error ?? 'The runtime did not report the application.');
880
+ }
881
+ catch (error) {
882
+ await this.updateApp_(appId, { enabled: false });
883
+ throw new ReleasesError(`Start failed: ${errorMessage(error)}`, 'START_FAILED', 'UNAVAILABLE');
884
+ }
885
+ return (await this.summarize([await this.requireApp(appId)]))[0];
886
+ });
887
+ }
888
+ async stop(caller, appId) {
889
+ return await this.withLock(appId, async () => {
890
+ const app = await this.requireApp(appId);
891
+ await this.options.guard.requireApp(caller, 'operate', app);
892
+ if (!(await this.currentDeployment(app)))
893
+ throw new ReleasesError('App is not deployed.', 'APP_NOT_DEPLOYED', 'FAILED_PRECONDITION');
894
+ await this.updateApp_(appId, { enabled: false });
895
+ try {
896
+ await (await this.session(app.environmentId)).stop(appId);
897
+ }
898
+ catch (error) {
899
+ await this.updateApp_(appId, { enabled: true });
900
+ throw new ReleasesError(`Stop failed: ${errorMessage(error)}`, 'STOP_FAILED', 'UNAVAILABLE');
901
+ }
902
+ return (await this.summarize([await this.requireApp(appId)]))[0];
903
+ });
904
+ }
905
+ async restart(caller, appId) {
906
+ return await this.withLock(appId, async () => {
907
+ const app = await this.requireApp(appId);
908
+ await this.options.guard.requireApp(caller, 'operate', app);
909
+ if (!(await this.currentDeployment(app)))
910
+ throw new ReleasesError('App is not deployed.', 'APP_NOT_DEPLOYED', 'FAILED_PRECONDITION');
911
+ try {
912
+ const status = await (await this.session(app.environmentId)).restart(appId);
913
+ if (status.state !== 'running')
914
+ throw new Error(status.error ?? 'The runtime did not report a running application.');
915
+ }
916
+ catch (error) {
917
+ throw new ReleasesError(`Restart failed: ${errorMessage(error)}`, 'RESTART_FAILED', 'UNAVAILABLE');
918
+ }
919
+ return (await this.summarize([await this.requireApp(appId)]))[0];
920
+ });
921
+ }
922
+ /**
923
+ * At startup: finalises the deployments a restart caught `deploying` from what the target recorded, marks the others
924
+ * failed, then sends every environment its desired set. An environment whose driver keeps
925
+ * outcomes (`operation`) gets its set only after its deployments are finalised, so one that finished during the
926
+ * restart is restored as the App's current deployment. Readers wait a little for all of it, so a fresh start does
927
+ * not report every App as stopped or a finished deployment as still running.
928
+ */
929
+ async restore() {
930
+ await this.clearExpiredInitialAdmins().catch((error) => this.diagnostic.warn('Failed to clear expired first administrators', error));
931
+ const interrupted = (await this.query()
932
+ .selectFrom('relDeployments')
933
+ .selectAll()
934
+ .where('status', 'in', ['queued', 'deploying'])
935
+ .execute()).map(decodeDeployment);
936
+ // A deployment the target recorded (the App Host's operation log) is finalised from what it reports; every other
937
+ // one was cut short.
938
+ const resumed = new Set();
939
+ const recorded = new Map();
940
+ for (const deployment of interrupted) {
941
+ if (deployment.status !== 'deploying')
942
+ continue;
943
+ const app = await this.findApp(deployment.appId);
944
+ if (!app)
945
+ continue;
946
+ const session = await this.session(app.environmentId).catch(() => null);
947
+ if (session?.operation) {
948
+ resumed.add(deployment.id);
949
+ this.logDeployment(deployment, 'switching', 'Restarted during the deployment; reading its outcome from the runtime');
950
+ recorded.set(app.environmentId, [
951
+ ...(recorded.get(app.environmentId) ?? []),
952
+ deployment,
953
+ ]);
954
+ continue;
955
+ }
956
+ }
957
+ const cutShort = interrupted.filter((deployment) => !resumed.has(deployment.id));
958
+ for (const deployment of cutShort)
959
+ this.logDeployment(deployment, 'completed', 'Deployment interrupted by a restart', new Error('The application restarted before the deployment completed'));
960
+ if (cutShort.length)
961
+ await this.query()
962
+ .updateTable('relDeployments')
963
+ .set({
964
+ status: 'failed',
965
+ phase: 'completed',
966
+ error: 'Deployment was interrupted by a restart.',
967
+ finishedAt: new Date(),
968
+ })
969
+ .where('id', 'in', cutShort.map((deployment) => deployment.id))
970
+ .execute();
971
+ const environments = await this.query()
972
+ .selectFrom('relApps')
973
+ .select('environmentId')
974
+ .distinct()
975
+ .execute();
976
+ const restoring = Promise.allSettled([
977
+ ...environments.map(async (row) => {
978
+ const environmentId = String(row.environmentId);
979
+ const session = await this.session(environmentId);
980
+ for (const deployment of recorded.get(environmentId) ?? [])
981
+ await this.finaliseDeployment(deployment, () => session.operation({
982
+ deploymentId: deployment.id,
983
+ appId: deployment.appId,
984
+ }));
985
+ await session.restore(await this.desiredSet(environmentId));
986
+ }),
987
+ ]).then((results) => {
988
+ for (const result of results)
989
+ if (result.status === 'rejected')
990
+ this.diagnostic.error('Failed to restore an environment', result.reason);
991
+ });
992
+ const current = restoring.finally(() => {
993
+ if (this.restoring === current)
994
+ this.restoring = null;
995
+ });
996
+ this.restoring = current;
997
+ }
998
+ /** Closes a changed or removed environment's session; the next use opens it with the new settings. */
999
+ async environmentChanged(environmentId) {
1000
+ const session = this.sessions.get(environmentId);
1001
+ this.sessions.delete(environmentId);
1002
+ if (session)
1003
+ await (await session.catch(() => null))?.close();
1004
+ if (await this.countApps(environmentId)) {
1005
+ const reopened = await this.session(environmentId);
1006
+ await reopened.restore(await this.desiredSet(environmentId));
1007
+ }
1008
+ }
1009
+ async countApps(environmentId) {
1010
+ const row = await this.query()
1011
+ .selectFrom('relApps')
1012
+ .select((eb) => [eb.fn.countAll().as('total')])
1013
+ .where('environmentId', '=', environmentId)
1014
+ .executeTakeFirst();
1015
+ return Number(row?.total ?? 0);
1016
+ }
1017
+ async checkEnvironment(environmentId) {
1018
+ return await (await this.session(environmentId)).check();
1019
+ }
1020
+ async shutdown() {
1021
+ this.closed = true;
1022
+ await this.restoring?.catch(() => undefined);
1023
+ await Promise.allSettled([...this.running]);
1024
+ await Promise.allSettled(this.locks.values());
1025
+ for (const session of this.sessions.values())
1026
+ await (await session.catch(() => null))?.close().catch(() => undefined);
1027
+ this.sessions.clear();
1028
+ }
1029
+ /** The App record without access checks, for the other services; read on `conn` when given. */
1030
+ async requireApp(appId, conn) {
1031
+ const app = await this.findApp(appId, conn);
1032
+ if (!app)
1033
+ throw notFound('App', 'APP_NOT_FOUND', appId);
1034
+ return app;
1035
+ }
1036
+ async findApp(appId, conn) {
1037
+ const row = await (conn?.query ?? this.query())
1038
+ .selectFrom('relApps')
1039
+ .selectAll()
1040
+ .where('id', '=', appId)
1041
+ .executeTakeFirst();
1042
+ return row ? decodeApp(row) : null;
1043
+ }
1044
+ async requireRelease(appId, releaseId) {
1045
+ const release = await this.findRelease(appId, releaseId);
1046
+ if (!release)
1047
+ throw notFound('Release', 'RELEASE_NOT_FOUND', releaseId);
1048
+ return release;
1049
+ }
1050
+ async requireRollbackTarget(appId, deploymentId) {
1051
+ const target = await this.requireDeployment(appId, deploymentId);
1052
+ if (target.status !== 'succeeded')
1053
+ throw new ReleasesError('Only a successful deployment can be rolled back to.', 'INVALID_ROLLBACK_TARGET', 'FAILED_PRECONDITION');
1054
+ return target;
1055
+ }
1056
+ /**
1057
+ * The deploy rules: `deploy` on the App, and nobody deploys directly to a protected environment, which takes a
1058
+ * deployment request instead (`DeploymentRequestService.create`).
1059
+ */
1060
+ async authorizeDeploy(caller, app, environment) {
1061
+ if (caller.kind === 'system')
1062
+ return;
1063
+ await this.options.guard.requireApp(caller, 'deploy', app);
1064
+ if (environment.protected)
1065
+ throw new ReleasesError('This environment is protected: request the deployment, and an approver deploys it.', 'APPROVAL_REQUIRED', 'FAILED_PRECONDITION');
1066
+ }
1067
+ // --- Variables ----------------------------------------------------------------------------------------------
1068
+ /**
1069
+ * The App's variables as a release declares them (the given one, else the App's most recent build: see
1070
+ * `declaringRelease`) and as a deployment of it would get them now. A secret's value never leaves the server.
1071
+ */
1072
+ async listAppVariables(caller, appId, options = {}) {
1073
+ const app = await this.requireApp(appId);
1074
+ await this.options.guard.requireApp(caller, 'view', app);
1075
+ const release = options.releaseId
1076
+ ? await this.requireRelease(appId, options.releaseId)
1077
+ : await this.declaringRelease(app);
1078
+ const context = await this.variablesContext(app);
1079
+ const resolved = release
1080
+ ? this.resolveFor(release, context)
1081
+ : resolveVariables({
1082
+ manifest: null,
1083
+ environment: context.environmentVariables,
1084
+ app: context.appVariables,
1085
+ configFile: context.configFile,
1086
+ running: context.running,
1087
+ });
1088
+ const items = resolved.variables.map((variable) => appVariableView(variable));
1089
+ return {
1090
+ items,
1091
+ meta: {
1092
+ total: items.length,
1093
+ releaseId: release?.id ?? null,
1094
+ releaseVersion: release?.version ?? null,
1095
+ environmentId: app.environmentId,
1096
+ declared: Boolean(release?.variables),
1097
+ missing: resolved.missing.map((variable) => variable.name),
1098
+ changed: resolved.changed,
1099
+ },
1100
+ };
1101
+ }
1102
+ /**
1103
+ * Sets one of the App's variables. It reaches the App with its next deployment.
1104
+ */
1105
+ async setAppVariable(caller, appId, name, input) {
1106
+ const app = await this.requireApp(appId);
1107
+ await this.options.guard.requireApp(caller, 'configure', app);
1108
+ assertVariableName(name);
1109
+ assertVariableValue(input?.value);
1110
+ const declared = (await this.declaringRelease(app))?.variables?.variables.find((variable) => variable.name === name);
1111
+ await this.options.variables.setAppVariable(appId, name, {
1112
+ value: input.value,
1113
+ secret: input.secret ?? declared?.secret ?? isSecretName(name),
1114
+ by: caller.userId,
1115
+ });
1116
+ const { items } = await this.listAppVariables(SYSTEM_CALLER, appId);
1117
+ return items.find((item) => item.name === name);
1118
+ }
1119
+ async unsetAppVariable(caller, appId, name) {
1120
+ const app = await this.requireApp(appId);
1121
+ await this.options.guard.requireApp(caller, 'configure', app);
1122
+ if (!(await this.options.variables.unsetAppVariable(appId, name)))
1123
+ throw notFound('Variable', 'VARIABLE_NOT_FOUND', name);
1124
+ }
1125
+ /**
1126
+ * The variables the most recent build of each App of the environment declares (`declaringRelease`), merged by
1127
+ * name, with the Apps declaring each and those that require it and would get no value. Only Apps the caller may
1128
+ * view count. A secret's value never leaves the server; neither does any value here.
1129
+ */
1130
+ async listEnvironmentDeclaredVariables(caller, environmentId) {
1131
+ this.options.guard.requireSetting(caller, 'rel.environments', 'read');
1132
+ await this.options.environments.record(environmentId);
1133
+ const rows = await this.query()
1134
+ .selectFrom('relApps')
1135
+ .selectAll()
1136
+ .where('environmentId', '=', environmentId)
1137
+ .orderBy('id', 'asc')
1138
+ .execute();
1139
+ const environmentValues = new Set((await this.options.variables.environmentVariables(environmentId))
1140
+ .filter((variable) => variable.value !== null)
1141
+ .map((variable) => variable.name));
1142
+ const merged = new Map();
1143
+ for (const app of rows.map(decodeApp)) {
1144
+ if (!(await this.options.guard.canApp(caller, 'view', app)))
1145
+ continue;
1146
+ const release = await this.declaringRelease(app);
1147
+ if (!release?.variables)
1148
+ continue;
1149
+ const resolved = this.resolveFor(release, await this.variablesContext(app));
1150
+ const missing = new Set(resolved.missing.map((variable) => variable.name));
1151
+ for (const variable of release.variables.variables) {
1152
+ if (RESERVED_VARIABLE_NAMES.includes(variable.name))
1153
+ continue;
1154
+ const entry = merged.get(variable.name) ?? {
1155
+ description: null,
1156
+ secret: false,
1157
+ required: false,
1158
+ apps: [],
1159
+ missingIn: [],
1160
+ };
1161
+ entry.description ??= variable.description ?? null;
1162
+ entry.secret ||= variable.secret;
1163
+ entry.required ||= variable.required;
1164
+ entry.apps.push(app.id);
1165
+ if (missing.has(variable.name))
1166
+ entry.missingIn.push(app.id);
1167
+ merged.set(variable.name, entry);
1168
+ }
1169
+ }
1170
+ return [...merged.entries()]
1171
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
1172
+ .map(([name, entry]) => ({
1173
+ name,
1174
+ description: entry.description,
1175
+ secret: entry.secret,
1176
+ required: entry.required,
1177
+ set: environmentValues.has(name),
1178
+ apps: entry.apps,
1179
+ missingIn: entry.missingIn,
1180
+ }));
1181
+ }
1182
+ /** The release's variables manifest, as the build wrote it (checked); null for a build without one. */
1183
+ async readReleaseVariables(caller, appId, releaseId) {
1184
+ const app = await this.requireApp(appId);
1185
+ await this.options.guard.requireApp(caller, 'view', app);
1186
+ return (await this.requireRelease(appId, releaseId)).variables;
1187
+ }
1188
+ /**
1189
+ * Refuses a deployment of the release while a variable it requires has no value, as a deployment itself would:
1190
+ * for a deployment request, before anybody is asked to approve it.
1191
+ */
1192
+ async assertVariables(appId, releaseId) {
1193
+ const app = await this.requireApp(appId);
1194
+ const release = await this.requireRelease(appId, releaseId);
1195
+ const missing = await this.missingVariables(app, release);
1196
+ if (missing.length > 0)
1197
+ throw variablesMissing(app, release, missing);
1198
+ }
1199
+ /**
1200
+ * The first administrator the App's first deployment generated, for a person who may deploy it: until someone
1201
+ * says it is saved, and for 24 hours (a preview App's until the App goes).
1202
+ */
1203
+ async readInitialAdmin(caller, appId) {
1204
+ const app = await this.requireApp(appId);
1205
+ await this.requireInitialAdminReader(caller, app);
1206
+ await this.clearExpiredInitialAdmins();
1207
+ const found = await this.initialAdminOf(app);
1208
+ if (!found)
1209
+ throw new ReleasesError('This App has no first administrator to show: it was saved, it expired, or the application did not generate one.', 'INITIAL_ADMIN_UNAVAILABLE', 'NOT_FOUND');
1210
+ return found;
1211
+ }
1212
+ /** Forgets the generated first administrator once someone has saved it. */
1213
+ async dismissInitialAdmin(caller, appId) {
1214
+ const app = await this.requireApp(appId);
1215
+ await this.requireInitialAdminReader(caller, app);
1216
+ await this.query()
1217
+ .updateTable('relDeployments')
1218
+ .set({
1219
+ initialAdmin: null,
1220
+ initialAdminSavedBy: caller.userId,
1221
+ initialAdminSavedAt: new Date(),
1222
+ })
1223
+ .where('appId', '=', appId)
1224
+ .where('initialAdmin', 'is not', null)
1225
+ .execute();
1226
+ }
1227
+ /** Clears the first administrators whose 24 hours are over. */
1228
+ async clearExpiredInitialAdmins(now = new Date()) {
1229
+ await this.query()
1230
+ .updateTable('relDeployments')
1231
+ .set({ initialAdmin: null })
1232
+ .where('initialAdmin', 'is not', null)
1233
+ .where('initialAdminExpiresAt', 'is not', null)
1234
+ .where('initialAdminExpiresAt', '<=', now)
1235
+ .execute();
1236
+ }
1237
+ async requireInitialAdminReader(caller, app) {
1238
+ // A password a person copies: never an agent's or a key's, whatever they may deploy.
1239
+ if (caller.kind !== 'human' &&
1240
+ caller.kind !== 'system' &&
1241
+ caller.kind !== 'rule')
1242
+ throw forbidden('Only a person can read the first administrator.', 'HUMAN_REQUIRED');
1243
+ await this.options.guard.requireApp(caller, 'deploy', app);
1244
+ }
1245
+ async initialAdminOf(app) {
1246
+ const rows = await this.query()
1247
+ .selectFrom('relDeployments')
1248
+ .selectAll()
1249
+ .where('appId', '=', app.id)
1250
+ .where('initialAdmin', 'is not', null)
1251
+ .orderBy('createdAt', 'desc')
1252
+ .execute();
1253
+ const now = new Date();
1254
+ for (const deployment of rows.map(decodeDeployment)) {
1255
+ if (deployment.initialAdminExpiresAt &&
1256
+ deployment.initialAdminExpiresAt <= now)
1257
+ continue;
1258
+ const admin = this.openInitialAdmin(deployment);
1259
+ if (!admin)
1260
+ continue;
1261
+ return {
1262
+ deploymentId: deployment.id,
1263
+ ...admin,
1264
+ expiresAt: iso(deployment.initialAdminExpiresAt),
1265
+ };
1266
+ }
1267
+ return null;
1268
+ }
1269
+ openInitialAdmin(deployment) {
1270
+ if (!deployment.initialAdmin)
1271
+ return null;
1272
+ try {
1273
+ const value = JSON.parse(decryptText(deployment.initialAdmin, [deployment.id], this.options.secrets, 'initial-admin'));
1274
+ return typeof value.username === 'string' &&
1275
+ typeof value.email === 'string' &&
1276
+ typeof value.password === 'string'
1277
+ ? {
1278
+ username: value.username,
1279
+ email: value.email,
1280
+ password: value.password,
1281
+ }
1282
+ : null;
1283
+ }
1284
+ catch {
1285
+ return null;
1286
+ }
1287
+ }
1288
+ /** The variables a deployment runs with; none when they no longer open. */
1289
+ openDeploymentEnv(deployment) {
1290
+ if (!deployment.env)
1291
+ return {};
1292
+ try {
1293
+ const value = JSON.parse(decryptText(deployment.env, [deployment.id], this.options.secrets, 'deployment-env'));
1294
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1295
+ return {};
1296
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === 'string'));
1297
+ }
1298
+ catch (error) {
1299
+ this.diagnostic.warn(`The variables of deployment ${deployment.id} could not be opened; it runs without them`, error);
1300
+ return {};
1301
+ }
1302
+ }
1303
+ /** The release whose manifest describes the App now: the one it runs, else its newest. */
1304
+ /**
1305
+ * The App's most recent build, which declares what variables it has: its newest release (uploaded or promoted to
1306
+ * it) that carries a variables manifest; failing that the release it runs, else its newest.
1307
+ */
1308
+ async declaringRelease(app) {
1309
+ const rows = await this.query()
1310
+ .selectFrom('relReleases')
1311
+ .selectAll()
1312
+ .where('appId', '=', app.id)
1313
+ .where('variables', 'is not', null)
1314
+ .orderBy('createdAt', 'desc')
1315
+ .orderBy('id', 'desc')
1316
+ .execute();
1317
+ for (const row of rows) {
1318
+ const release = decodeRelease(row);
1319
+ if (release.variables)
1320
+ return release;
1321
+ }
1322
+ return this.releaseOfApp(app);
1323
+ }
1324
+ async releaseOfApp(app) {
1325
+ const current = await this.currentDeployment(app);
1326
+ if (current) {
1327
+ const release = await this.findRelease(app.id, current.releaseId);
1328
+ if (release)
1329
+ return release;
1330
+ }
1331
+ const row = await this.query()
1332
+ .selectFrom('relReleases')
1333
+ .selectAll()
1334
+ .where('appId', '=', app.id)
1335
+ .orderBy('createdAt', 'desc')
1336
+ .orderBy('id', 'desc')
1337
+ .executeTakeFirst();
1338
+ return row ? decodeRelease(row) : null;
1339
+ }
1340
+ /** What resolving an App's variables reads, once for every release it is resolved against. */
1341
+ async variablesContext(app) {
1342
+ const [environment, environmentVariables, appVariables] = await Promise.all([
1343
+ this.options.environments.record(app.environmentId),
1344
+ this.options.variables.environmentVariables(app.environmentId),
1345
+ this.options.variables.appVariables(app.id),
1346
+ ]);
1347
+ const current = await this.currentDeployment(app);
1348
+ const content = current ? await this.readDeploymentConfig(current) : null;
1349
+ return {
1350
+ environment,
1351
+ environmentVariables,
1352
+ appVariables,
1353
+ configFile: content === null ? null : parseConfig(content),
1354
+ running: current ? this.openDeploymentEnv(current) : null,
1355
+ url: await this.appUrl(app),
1356
+ first: app.currentDeploymentId === null,
1357
+ };
1358
+ }
1359
+ /** Resolves the App's variables against a release, as a deployment of it would get them now (nothing generated). */
1360
+ resolveFor(release, context, configFile) {
1361
+ const file = configFile ??
1362
+ context.configFile ??
1363
+ (release.configTemplate ? parseConfig(release.configTemplate) : {});
1364
+ const plan = planGenerated(release.variables, context, file);
1365
+ return resolveVariables({
1366
+ manifest: release.variables,
1367
+ environment: context.environmentVariables,
1368
+ app: context.appVariables,
1369
+ configFile: file,
1370
+ generated: plan.computed,
1371
+ generatable: new Set([
1372
+ ...plan.persistent.keys(),
1373
+ ...plan.oneShot.keys(),
1374
+ ...(plan.initialAdmin ? [plan.initialAdmin.name] : []),
1375
+ ]),
1376
+ running: context.running,
1377
+ });
1378
+ }
1379
+ /** The required variables nothing would give a deployment of the release now. */
1380
+ async missingVariables(app, release, config) {
1381
+ if (!release.variables)
1382
+ return [];
1383
+ const context = await this.variablesContext(app);
1384
+ return this.resolveFor(release, context, config?.content !== undefined ? parseConfig(config.content) : undefined).missing;
1385
+ }
1386
+ /** The variables summary of the single-App read: of its most recent build (`declaringRelease`). */
1387
+ async appVariablesOf(app) {
1388
+ const release = await this.declaringRelease(app);
1389
+ if (!release)
1390
+ return null;
1391
+ return {
1392
+ resolved: this.resolveFor(release, await this.variablesContext(app)),
1393
+ };
1394
+ }
1395
+ /**
1396
+ * What a deployment keeps of its variables: they are resolved against its configuration, a deployment missing a
1397
+ * required one is refused (`VARIABLES_MISSING`), secrets the build lets a deployment generate are generated once and
1398
+ * kept as the App's values, and a first deployment gets a first administrator of its own when nothing names one.
1399
+ */
1400
+ async prepareVariables(app, release, configFile, deploymentId) {
1401
+ let context = await this.variablesContext(app);
1402
+ const plan = planGenerated(release.variables, context, configFile);
1403
+ const missing = resolveVariables({
1404
+ manifest: release.variables,
1405
+ environment: context.environmentVariables,
1406
+ app: context.appVariables,
1407
+ configFile,
1408
+ generated: plan.computed,
1409
+ generatable: new Set([
1410
+ ...plan.persistent.keys(),
1411
+ ...plan.oneShot.keys(),
1412
+ ...(plan.initialAdmin ? [plan.initialAdmin.name] : []),
1413
+ ]),
1414
+ }).missing;
1415
+ if (missing.length > 0)
1416
+ throw variablesMissing(app, release, missing);
1417
+ if (plan.persistent.size > 0) {
1418
+ for (const [name, variable] of plan.persistent)
1419
+ await this.options.variables.setAppVariable(app.id, name, {
1420
+ value: generateVariable(variable.generate),
1421
+ secret: variable.secret || isSecretName(name),
1422
+ generated: true,
1423
+ by: null,
1424
+ });
1425
+ context = {
1426
+ ...context,
1427
+ appVariables: await this.options.variables.appVariables(app.id),
1428
+ };
1429
+ }
1430
+ const generated = { ...plan.computed };
1431
+ for (const [name, variable] of plan.oneShot)
1432
+ generated[name] = generateVariable(variable.generate);
1433
+ let initialAdmin = null;
1434
+ let initialAdminExpiresAt = null;
1435
+ if (plan.initialAdmin) {
1436
+ const password = (await this.earlierInitialAdmin(app))?.password ?? randomPassword();
1437
+ generated[plan.initialAdmin.name] = password;
1438
+ const admin = {
1439
+ username: plan.initialAdmin.username,
1440
+ email: plan.initialAdmin.email,
1441
+ password,
1442
+ };
1443
+ initialAdmin = encryptText(JSON.stringify(admin), [deploymentId], this.options.secrets, 'initial-admin');
1444
+ // A preview App's goes with the preview; any other's is deleted after a day.
1445
+ initialAdminExpiresAt = app.previewOf
1446
+ ? null
1447
+ : new Date(Date.now() + INITIAL_ADMIN_TTL_MS);
1448
+ }
1449
+ const resolved = resolveVariables({
1450
+ manifest: release.variables,
1451
+ environment: context.environmentVariables,
1452
+ app: context.appVariables,
1453
+ configFile,
1454
+ generated,
1455
+ });
1456
+ const names = Object.keys(resolved.env);
1457
+ if (names.length > 0)
1458
+ this.logDeploymentVariables(app.id, deploymentId, resolved);
1459
+ return {
1460
+ env: names.length === 0
1461
+ ? null
1462
+ : encryptText(JSON.stringify(resolved.env), [deploymentId], this.options.secrets, 'deployment-env'),
1463
+ variablesFingerprint: release.variables || names.length > 0 ? resolved.fingerprint : null,
1464
+ initialAdmin,
1465
+ initialAdminExpiresAt,
1466
+ };
1467
+ }
1468
+ /**
1469
+ * The first administrator an earlier first deployment that did not succeed generated: the database it reached may
1470
+ * already hold it, so a retry keeps the same password.
1471
+ */
1472
+ async earlierInitialAdmin(app) {
1473
+ if (app.currentDeploymentId)
1474
+ return null;
1475
+ const row = await this.query()
1476
+ .selectFrom('relDeployments')
1477
+ .selectAll()
1478
+ .where('appId', '=', app.id)
1479
+ .where('initialAdmin', 'is not', null)
1480
+ .orderBy('createdAt', 'desc')
1481
+ .executeTakeFirst();
1482
+ return row ? this.openInitialAdmin(decodeDeployment(row)) : null;
1483
+ }
1484
+ /** Records which variables a deployment got and from where; never their values. */
1485
+ logDeploymentVariables(appId, deploymentId, resolved) {
1486
+ if (this.options.logging?.deployments?.enabled === false)
1487
+ return;
1488
+ const sources = resolved.variables
1489
+ .filter((variable) => variable.value !== undefined)
1490
+ .map((variable) => `${variable.name} (${variable.source})`);
1491
+ try {
1492
+ appendJournal(this.deploymentLogPath(appId, deploymentId), {
1493
+ time: new Date().toISOString(),
1494
+ level: 'info',
1495
+ appId,
1496
+ deploymentId,
1497
+ phase: 'queued',
1498
+ msg: `Variables: ${sources.join(', ')}`,
1499
+ }, this.options.logging?.deployments?.maxFileSizeMB ?? 50);
1500
+ }
1501
+ catch (error) {
1502
+ reportLoggingFailure('Failed to persist deployment log', error);
1503
+ }
1504
+ }
1505
+ /** The App a preview App previews: one the caller may see. */
1506
+ async previewTarget(caller, previewOf) {
1507
+ if (previewOf === undefined || previewOf === null || previewOf === '')
1508
+ return null;
1509
+ const target = typeof previewOf === 'string' ? await this.findApp(previewOf) : null;
1510
+ if (!target || !(await this.options.guard.canApp(caller, 'view', target)))
1511
+ throw new ReleasesError('previewOf names an App you may see.', 'INVALID_PREVIEW_OF', 'INVALID_ARGUMENT', {
1512
+ fieldViolations: [
1513
+ { field: 'previewOf', description: 'No such App.' },
1514
+ ],
1515
+ });
1516
+ return target.id;
1517
+ }
1518
+ // --- Internals ----------------------------------------------------------------------------------------------
1519
+ async startDeployment(caller, app, release, options, config, idempotency) {
1520
+ await this.requireNoPendingDeployment(app.id);
1521
+ const deployment = await this.buildDeploymentRecord(app, release, caller, {
1522
+ kind: options.kind,
1523
+ rollbackTargetDeploymentId: options.rollbackTargetDeploymentId ?? null,
1524
+ requestId: options.requestId ?? null,
1525
+ config,
1526
+ });
1527
+ try {
1528
+ await this.options.database.transaction(async (connection) => {
1529
+ await this.requireNoPendingDeployment(app.id, connection);
1530
+ await connection.query
1531
+ .insertInto('relDeployments')
1532
+ .values(encodeDeployment(deployment))
1533
+ .execute();
1534
+ if (idempotency)
1535
+ await connection.query
1536
+ .insertInto('relDeployRequests')
1537
+ .values({
1538
+ appId: app.id,
1539
+ requestKey: idempotency.key,
1540
+ fingerprint: idempotency.fingerprint,
1541
+ deploymentId: deployment.id,
1542
+ })
1543
+ .execute();
1544
+ await connection.query
1545
+ .updateTable('relApps')
1546
+ .set({ updatedAt: new Date() })
1547
+ .where('id', '=', app.id)
1548
+ .execute();
1549
+ });
1550
+ }
1551
+ catch (error) {
1552
+ if (deployment.configPath)
1553
+ await rm(deployment.configPath, { force: true });
1554
+ throw error;
1555
+ }
1556
+ this.schedule(deployment);
1557
+ return {
1558
+ ...deploymentView(deployment, {
1559
+ version: release.version,
1560
+ checksum: release.checksum,
1561
+ }),
1562
+ reused: false,
1563
+ };
1564
+ }
1565
+ async replayDeployRequest(appId, requestKey, fingerprint) {
1566
+ const existing = await this.query()
1567
+ .selectFrom('relDeployRequests')
1568
+ .selectAll()
1569
+ .where('appId', '=', appId)
1570
+ .where('requestKey', '=', requestKey)
1571
+ .executeTakeFirst();
1572
+ if (!existing)
1573
+ return null;
1574
+ if (existing.fingerprint !== fingerprint)
1575
+ throw new ReleasesError('Idempotency key was used for another deployment.', 'IDEMPOTENCY_CONFLICT', 'ABORTED');
1576
+ const deployment = await this.requireDeployment(appId, String(existing.deploymentId));
1577
+ const release = await this.findRelease(appId, deployment.releaseId);
1578
+ return {
1579
+ ...deploymentView(deployment, release
1580
+ ? { version: release.version, checksum: release.checksum }
1581
+ : null),
1582
+ reused: true,
1583
+ };
1584
+ }
1585
+ async existingRelease(appId, checksum, requestKey, configFingerprint = null, requiresDeployment = false) {
1586
+ const request = requestKey
1587
+ ? await this.query()
1588
+ .selectFrom('relUploadRequests')
1589
+ .selectAll()
1590
+ .where('appId', '=', appId)
1591
+ .where('requestKey', '=', requestKey)
1592
+ .executeTakeFirst()
1593
+ : undefined;
1594
+ if (request && request.checksum !== checksum)
1595
+ throw new ReleasesError('Idempotency key was used for another artifact.', 'IDEMPOTENCY_CONFLICT', 'ABORTED');
1596
+ const canonical = await this.query()
1597
+ .selectFrom('relReleaseChecksums')
1598
+ .selectAll()
1599
+ .where('appId', '=', appId)
1600
+ .where('checksum', '=', checksum)
1601
+ .executeTakeFirst();
1602
+ if (!canonical)
1603
+ return null;
1604
+ const deploymentId = nullableString(canonical.deploymentId);
1605
+ if (requiresDeployment && !deploymentId)
1606
+ throw new ReleasesError(`Release ${String(canonical.releaseId)} already exists without a deployment; deploy it by its release ID.`, 'RELEASE_EXISTS', 'ALREADY_EXISTS');
1607
+ if (requiresDeployment &&
1608
+ nullableString(canonical.configFingerprint) !== configFingerprint)
1609
+ throw new ReleasesError('This artifact was already uploaded with different configuration; deploy it by its release ID instead.', 'IDEMPOTENCY_CONFLICT', 'ABORTED');
1610
+ if (requestKey && !request) {
1611
+ try {
1612
+ await this.query()
1613
+ .insertInto('relUploadRequests')
1614
+ .values({
1615
+ appId,
1616
+ requestKey,
1617
+ checksum,
1618
+ releaseId: canonical.releaseId,
1619
+ })
1620
+ .execute();
1621
+ }
1622
+ catch (error) {
1623
+ const winner = await this.query()
1624
+ .selectFrom('relUploadRequests')
1625
+ .selectAll()
1626
+ .where('appId', '=', appId)
1627
+ .where('requestKey', '=', requestKey)
1628
+ .executeTakeFirst();
1629
+ if (!winner)
1630
+ throw error;
1631
+ if (winner.checksum !== checksum)
1632
+ throw new ReleasesError('Idempotency key was used for another artifact.', 'IDEMPOTENCY_CONFLICT', 'ABORTED');
1633
+ }
1634
+ }
1635
+ const release = await this.requireRelease(appId, String(canonical.releaseId));
1636
+ return { ...releaseView(release), reused: true, deploymentId };
1637
+ }
1638
+ async assertEnvironmentCapacity(environment) {
1639
+ if (environment.maxApps !== null &&
1640
+ (await this.countApps(environment.id)) >= environment.maxApps)
1641
+ throw new ReleasesError('This environment holds as many applications as it may.', 'ENVIRONMENT_FULL', 'FAILED_PRECONDITION');
1642
+ }
1643
+ async removeApp(app, caller) {
1644
+ await this.withLock(`publish:${app.id}`, () => this.withLock(app.id, async () => {
1645
+ const current = await this.findApp(app.id);
1646
+ if (!current)
1647
+ return;
1648
+ const releases = await this.query()
1649
+ .selectFrom('relReleases')
1650
+ .select('artifactKey')
1651
+ .where('appId', '=', app.id)
1652
+ .execute();
1653
+ await (await this.session(app.environmentId)).remove(app.id, { purgeData: true });
1654
+ await this.options.database.transaction(async (connection) => {
1655
+ for (const table of [
1656
+ 'relReleaseArtifacts',
1657
+ 'relReleaseChecksums',
1658
+ 'relUploadRequests',
1659
+ 'relDeployRequests',
1660
+ 'relDeploymentRequests',
1661
+ 'relUploadTickets',
1662
+ 'relDeployments',
1663
+ 'relReleases',
1664
+ ])
1665
+ await connection.query
1666
+ .deleteFrom(table)
1667
+ .where('appId', '=', app.id)
1668
+ .execute();
1669
+ await this.options.variables.removeApp(app.id, connection);
1670
+ await connection.query
1671
+ .deleteFrom('relApps')
1672
+ .where('id', '=', app.id)
1673
+ .execute();
1674
+ });
1675
+ await Promise.allSettled([
1676
+ ...releases
1677
+ .filter((row) => row.artifactKey)
1678
+ .map((row) => this.disk.delete(String(row.artifactKey))),
1679
+ rm(path.join(this.deploymentLogsDir(), app.id), {
1680
+ recursive: true,
1681
+ force: true,
1682
+ }),
1683
+ rm(path.join(this.configsDir(), app.id), {
1684
+ recursive: true,
1685
+ force: true,
1686
+ }),
1687
+ ]);
1688
+ }));
1689
+ await this.options.events.emit({
1690
+ type: 'app.removed',
1691
+ app: appView(app),
1692
+ actor: actorOf(caller),
1693
+ });
1694
+ }
1695
+ async summarize(apps) {
1696
+ if (!apps.length)
1697
+ return [];
1698
+ if (this.restoring)
1699
+ await Promise.race([this.restoring, delay(DEFAULT_STARTUP_WAIT_MS)]);
1700
+ const ids = apps.map((app) => app.id);
1701
+ const [releases, pending, currentReleases] = await Promise.all([
1702
+ this.query()
1703
+ .selectFrom('relReleases')
1704
+ .select('appId')
1705
+ .distinct()
1706
+ .where('appId', 'in', ids)
1707
+ .execute(),
1708
+ this.query()
1709
+ .selectFrom('relDeployments')
1710
+ .select('appId')
1711
+ .distinct()
1712
+ .where('appId', 'in', ids)
1713
+ .where('status', 'in', ['queued', 'deploying'])
1714
+ .execute(),
1715
+ this.query()
1716
+ .selectFrom('relApps')
1717
+ .innerJoin('relDeployments as current', 'relApps.currentDeploymentId', 'current.id')
1718
+ .innerJoin('relReleases as release', 'current.releaseId', 'release.id')
1719
+ .select(['relApps.id as appId', 'release.version as version'])
1720
+ .where('relApps.id', 'in', ids)
1721
+ .execute(),
1722
+ ]);
1723
+ const released = new Set(releases.map((row) => String(row.appId)));
1724
+ const pendingApps = new Set(pending.map((row) => String(row.appId)));
1725
+ const versions = new Map(currentReleases.map((row) => [String(row.appId), String(row.version)]));
1726
+ const environments = new Map();
1727
+ const statuses = new Map();
1728
+ const sessions = new Map();
1729
+ for (const environmentId of new Set(apps.map((app) => app.environmentId))) {
1730
+ environments.set(environmentId, await this.options.environments.find(environmentId));
1731
+ const environmentApps = apps.filter((app) => app.environmentId === environmentId);
1732
+ try {
1733
+ const session = await this.session(environmentId);
1734
+ sessions.set(environmentId, session);
1735
+ statuses.set(environmentId, await session.status(environmentApps.map((app) => app.id)));
1736
+ }
1737
+ catch (error) {
1738
+ sessions.set(environmentId, null);
1739
+ statuses.set(environmentId, error instanceof Error ? error : new Error(String(error)));
1740
+ }
1741
+ }
1742
+ return apps.map((app) => {
1743
+ const environment = environments.get(app.environmentId) ?? null;
1744
+ const status = statuses.get(app.environmentId);
1745
+ const session = sessions.get(app.environmentId) ?? null;
1746
+ return {
1747
+ app: appView(app),
1748
+ environment: {
1749
+ id: app.environmentId,
1750
+ name: environment?.name ?? app.environmentId,
1751
+ protected: environment?.protected ?? false,
1752
+ runsImages: environment
1753
+ ? this.options.environments.runsImages(environment)
1754
+ : false,
1755
+ },
1756
+ runtime: runtimeStatus(status, app.id),
1757
+ url: session ? session.url(app.id) : null,
1758
+ currentVersion: versions.get(app.id) ?? null,
1759
+ hasReleases: released.has(app.id),
1760
+ hasPendingDeployment: pendingApps.has(app.id),
1761
+ };
1762
+ });
1763
+ }
1764
+ async findAppIdsBySearch(search) {
1765
+ const connection = this.options.database.connection();
1766
+ const physical = await connection.collections.getPhysical('relApps');
1767
+ if (!physical)
1768
+ throw new Error('The releases schema is unavailable.');
1769
+ const knex = await connection.client();
1770
+ // A raw query bypasses the repository that scopes a table to its schema, so name the schema here. The columns are
1771
+ // literals bound as identifiers; the only untrusted value is the bound search term.
1772
+ const rows = await knex(physical.tableName)
1773
+ .withSchema(physical.schema)
1774
+ .select('id')
1775
+ .whereRaw('lower(??) like lower(?) or lower(??) like lower(?)', [
1776
+ 'id',
1777
+ `%${search}%`,
1778
+ 'name',
1779
+ `%${search}%`,
1780
+ ]);
1781
+ return rows.map((row) => String(row['id']));
1782
+ }
1783
+ /** Opens (once) the driver session of an environment. */
1784
+ session(environmentId) {
1785
+ if (this.closed)
1786
+ return Promise.reject(new Error('Release management is shut down.'));
1787
+ let session = this.sessions.get(environmentId);
1788
+ if (!session) {
1789
+ session = this.options.environments
1790
+ .driverEnvironment(environmentId)
1791
+ .then(({ driver, target }) => driver.open(target, {
1792
+ desired: () => this.desiredSet(environmentId),
1793
+ }));
1794
+ session.catch(() => {
1795
+ if (this.sessions.get(environmentId) === session)
1796
+ this.sessions.delete(environmentId);
1797
+ });
1798
+ this.sessions.set(environmentId, session);
1799
+ }
1800
+ return session;
1801
+ }
1802
+ async desiredSet(environmentId) {
1803
+ const rows = await this.query()
1804
+ .selectFrom('relApps')
1805
+ .selectAll()
1806
+ .where('environmentId', '=', environmentId)
1807
+ .orderBy('id', 'asc')
1808
+ .execute();
1809
+ const specs = [];
1810
+ for (const app of rows.map(decodeApp)) {
1811
+ const deployment = await this.currentDeployment(app);
1812
+ if (deployment)
1813
+ specs.push(await this.deploymentSpec(app, deployment));
1814
+ }
1815
+ return specs;
1816
+ }
1817
+ /**
1818
+ * The release's images a Docker environment pulls: those in its registry (by host and namespace) with the
1819
+ * registry's pull credentials, or every image of the release, pulled anonymously, when it names no registry. Null
1820
+ * for an environment that runs archives.
1821
+ */
1822
+ async imagesFor(environmentId, releaseId) {
1823
+ const { environment, driver } = await this.options.environments.driverEnvironment(environmentId);
1824
+ if (capabilitiesFor(driver, environment.config).images !== true)
1825
+ return null;
1826
+ const pull = environment.registryId
1827
+ ? await this.options.registries.pullAuth(environment.registryId)
1828
+ : null;
1829
+ const prefix = pull
1830
+ ? `${pull.registry.host}/${pull.registry.namespace ? `${pull.registry.namespace}/` : ''}`
1831
+ : '';
1832
+ const images = ((await this.artifacts.imagesOf([releaseId])).get(releaseId) ?? [])
1833
+ .filter((image) => image.ref.startsWith(prefix))
1834
+ .map((image) => ({
1835
+ ref: image.ref,
1836
+ digest: image.digest,
1837
+ platform: image.platform,
1838
+ }));
1839
+ return {
1840
+ images,
1841
+ auth: pull?.auth,
1842
+ registry: pull?.registry.name ?? null,
1843
+ };
1844
+ }
1845
+ /**
1846
+ * Refuses a release the environment cannot run, before a deployment starts: an in-process environment runs release
1847
+ * archives, a Docker environment release images (from its registry when it names one).
1848
+ */
1849
+ async assertRunnable(app, release) {
1850
+ const found = await this.imagesFor(app.environmentId, release.id);
1851
+ if (found === null) {
1852
+ if (release.kind === 'archive')
1853
+ return;
1854
+ throw new ReleasesError(`This environment runs release archives in process; release ${release.version} is an image. Upload the archive (\`release upload\` in the CLI), or deploy the image to a Docker environment.`, 'ARCHIVE_REQUIRED', 'FAILED_PRECONDITION');
1855
+ }
1856
+ if (found.images.length > 0)
1857
+ return;
1858
+ throw new ReleasesError(release.kind === 'archive'
1859
+ ? `This environment runs release images; release ${release.version} is an archive. Register the image your CI pushed (\`release image\` in the CLI), then deploy it.`
1860
+ : `Release ${release.version} has no image in this environment's registry (${found.registry ?? 'none'}).`, 'IMAGE_REQUIRED', 'FAILED_PRECONDITION');
1861
+ }
1862
+ async deploymentSpec(app, deployment, desiredState = app.enabled ? 'running' : 'stopped') {
1863
+ const release = await this.requireRelease(app.id, deployment.releaseId);
1864
+ const images = await this.imagesFor(app.environmentId, release.id).catch(() => null);
1865
+ return {
1866
+ deploymentId: deployment.id,
1867
+ appId: app.id,
1868
+ kind: deployment.kind,
1869
+ release: {
1870
+ id: release.id,
1871
+ version: release.version,
1872
+ checksum: release.checksum,
1873
+ },
1874
+ ...(release.artifactKey
1875
+ ? {
1876
+ artifact: {
1877
+ key: release.artifactKey,
1878
+ checksum: release.checksum,
1879
+ version: release.version,
1880
+ size: release.size ?? 0,
1881
+ open: async () => Readable.from(await this.disk.getStream(release.artifactKey)),
1882
+ },
1883
+ }
1884
+ : {}),
1885
+ config: deployment.configMode === 'file'
1886
+ ? {
1887
+ mode: 'file',
1888
+ content: (await this.readDeploymentConfig(deployment)) ?? '',
1889
+ revision: deployment.id,
1890
+ }
1891
+ : { mode: 'external' },
1892
+ ...(deployment.env ? { env: this.openDeploymentEnv(deployment) } : {}),
1893
+ desiredState,
1894
+ activation: app.activation,
1895
+ idleStopMinutes: app.idleStopMinutes,
1896
+ dormantAfterHours: app.dormantAfterHours,
1897
+ ...(images
1898
+ ? {
1899
+ images: images.images,
1900
+ ...(images.auth ? { registryAuth: images.auth } : {}),
1901
+ }
1902
+ : {}),
1903
+ };
1904
+ }
1905
+ async buildDeploymentRecord(app, release, caller, options) {
1906
+ await this.assertRunnable(app, release);
1907
+ const id = randomUUID();
1908
+ const current = await this.currentDeployment(app);
1909
+ let configMode = options.config?.mode ?? current?.configMode ?? 'file';
1910
+ if (options.kind === 'rollback' && options.rollbackTargetDeploymentId) {
1911
+ const target = await this.requireDeployment(app.id, options.rollbackTargetDeploymentId);
1912
+ configMode = target.configMode;
1913
+ }
1914
+ const content = configMode === 'file'
1915
+ ? await this.deploymentConfig(app, release, current, options.config)
1916
+ : null;
1917
+ const prepared = await this.prepareVariables(app, release, content === null ? {} : parseConfig(content), id);
1918
+ let configPath = null;
1919
+ if (content !== null) {
1920
+ configPath = path.join(this.configsDir(), app.id, `${id}.yml`);
1921
+ await writeTextAtomic(configPath, content);
1922
+ }
1923
+ return {
1924
+ id,
1925
+ appId: app.id,
1926
+ releaseId: release.id,
1927
+ kind: options.kind,
1928
+ rollbackTargetDeploymentId: options.rollbackTargetDeploymentId ?? null,
1929
+ previousDeploymentId: app.currentDeploymentId,
1930
+ status: 'queued',
1931
+ phase: 'queued',
1932
+ configMode,
1933
+ configPath,
1934
+ error: null,
1935
+ actorId: caller.userId,
1936
+ actorKind: caller.kind,
1937
+ requestId: options.requestId ?? null,
1938
+ artifactKind: null,
1939
+ imageRef: null,
1940
+ imageDigest: null,
1941
+ imagePlatform: null,
1942
+ ...prepared,
1943
+ initialAdminSavedBy: null,
1944
+ initialAdminSavedAt: null,
1945
+ createdAt: new Date(),
1946
+ startedAt: null,
1947
+ finishedAt: null,
1948
+ };
1949
+ }
1950
+ /**
1951
+ * The `config.yml` a deployment starts with: the caller's (its masked secrets keeping the stored values), else the
1952
+ * App's current one, else the release's template for a new App. A secret the build declares a variable for is left
1953
+ * to that variable; the others are generated into the file when missing, as is the public origin.
1954
+ */
1955
+ async deploymentConfig(app, release, current, config) {
1956
+ const currentContent = current
1957
+ ? await this.readDeploymentConfig(current)
1958
+ : null;
1959
+ // A release template initializes a new App; it never replaces configuration the App already uses. Content the
1960
+ // caller gives may carry masked secrets from `readConfig`, which keep the App's stored values.
1961
+ let content = config?.content !== undefined
1962
+ ? restoreConfigSecrets(config.content, currentContent)
1963
+ : (currentContent ?? release.configTemplate ?? '');
1964
+ content = ensureConfigSecrets(content, currentContent ?? undefined, {
1965
+ skip: declaredSecretSections(release.variables),
1966
+ });
1967
+ if (!declaresPath(release.variables, PUBLIC_ORIGIN_PATH))
1968
+ content = ensurePublicOrigin(content, await this.appUrl(app));
1969
+ validateYamlConfig(content);
1970
+ return content;
1971
+ }
1972
+ /** The App's public URL, absolute: one under a path of this application's origin takes that origin. */
1973
+ async appUrl(app) {
1974
+ const url = await this.session(app.environmentId)
1975
+ .then((session) => session.url(app.id))
1976
+ .catch(() => null);
1977
+ if (!url)
1978
+ return null;
1979
+ if (/^https?:\/\//iu.test(url))
1980
+ return url;
1981
+ if (!this.options.publicOrigin || !url.startsWith('/'))
1982
+ return null;
1983
+ try {
1984
+ return new URL(url, this.options.publicOrigin).toString();
1985
+ }
1986
+ catch {
1987
+ return null;
1988
+ }
1989
+ }
1990
+ schedule(deployment) {
1991
+ this.logDeployment(deployment, 'queued', 'Deployment queued');
1992
+ const run = this.withLock(deployment.appId, () => this.runDeployment(deployment.id))
1993
+ .catch((error) => this.diagnostic.error(`Deployment ${deployment.id} failed unexpectedly`, error))
1994
+ .finally(() => {
1995
+ this.running.delete(run);
1996
+ if (this.runs.get(deployment.id) === run)
1997
+ this.runs.delete(deployment.id);
1998
+ });
1999
+ this.running.add(run);
2000
+ this.runs.set(deployment.id, run);
2001
+ }
2002
+ async runDeployment(deploymentId) {
2003
+ const deployment = await this.requireDeploymentById(deploymentId);
2004
+ const app = await this.requireApp(deployment.appId);
2005
+ const release = await this.requireRelease(app.id, deployment.releaseId);
2006
+ const previous = await this.currentDeployment(app);
2007
+ let phaseWrites = Promise.resolve();
2008
+ let lastSequence = 0;
2009
+ let phaseError;
2010
+ await this.updateDeployment(deploymentId, {
2011
+ status: 'deploying',
2012
+ phase: 'resolving',
2013
+ startedAt: new Date(),
2014
+ error: null,
2015
+ previousDeploymentId: app.currentDeploymentId,
2016
+ });
2017
+ let outcome;
2018
+ try {
2019
+ this.logDeployment(deployment, 'resolving', 'Deployment started');
2020
+ const session = await this.session(app.environmentId);
2021
+ const logsEnabled = this.options.logging?.deployments?.enabled !== false;
2022
+ const spec = await this.deploymentSpec({ ...app, enabled: true }, deployment, 'running');
2023
+ // A driver that runs no images runs the archive; one that does says what it ran (`DeploymentEvent.artifact`).
2024
+ if (spec.images === undefined)
2025
+ await this.updateDeployment(deploymentId, artifactColumns({ kind: 'tarball' }));
2026
+ const observed = await session.apply(spec, (entry) => {
2027
+ if (typeof entry.sequence === 'number') {
2028
+ if (entry.sequence <= lastSequence)
2029
+ return;
2030
+ lastSequence = entry.sequence;
2031
+ }
2032
+ const artifact = deploymentArtifactOf(entry);
2033
+ if (artifact)
2034
+ phaseWrites = phaseWrites
2035
+ .then(() => this.updateDeployment(deploymentId, artifactColumns(artifact)))
2036
+ .catch((error) => {
2037
+ phaseError =
2038
+ error instanceof Error ? error : new Error(String(error));
2039
+ });
2040
+ if (typeof entry.phase === 'string') {
2041
+ const phase = entry.phase;
2042
+ phaseWrites = phaseWrites
2043
+ .then(() => this.updateDeployment(deploymentId, { phase }))
2044
+ .catch((error) => {
2045
+ phaseError =
2046
+ error instanceof Error ? error : new Error(String(error));
2047
+ });
2048
+ }
2049
+ if (logsEnabled)
2050
+ this.appendDeploymentLog(deployment, {
2051
+ time: new Date().toISOString(),
2052
+ level: 'info',
2053
+ msg: '',
2054
+ ...entry,
2055
+ appId: app.id,
2056
+ deploymentId: deployment.id,
2057
+ });
2058
+ });
2059
+ await phaseWrites;
2060
+ if (phaseError)
2061
+ throw phaseError;
2062
+ if (observed.state === 'failed' || observed.state === 'unknown')
2063
+ throw new Error(observed.error ?? 'The runtime did not report the deployment.');
2064
+ await this.markSucceeded(deployment, app.id, previous);
2065
+ outcome = { ok: true };
2066
+ }
2067
+ catch (error) {
2068
+ await phaseWrites.catch(() => undefined);
2069
+ outcome = { ok: false, error: errorMessage(error) };
2070
+ await this.markFailed(deployment, error);
2071
+ }
2072
+ finally {
2073
+ const policy = this.options.logging?.deployments;
2074
+ await pruneJournals(path.dirname(this.deploymentLogPath(app.id, deployment.id)), {
2075
+ retentionDays: policy?.retentionDays ?? 30,
2076
+ maxSizeMB: policy?.maxTotalSizeMB ?? 1024,
2077
+ }, `${deployment.id}.log`).catch((error) => reportLoggingFailure('Deployment log cleanup failed', error));
2078
+ }
2079
+ await this.emitOutcome(deployment, app, release, outcome);
2080
+ }
2081
+ async markSucceeded(deployment, appId, previous) {
2082
+ this.logDeployment(deployment, 'completed', 'Deployment succeeded');
2083
+ const finishedAt = new Date();
2084
+ await this.options.database.transaction(async (connection) => {
2085
+ await connection.query
2086
+ .updateTable('relDeployments')
2087
+ .set({ status: 'succeeded', phase: 'completed', finishedAt })
2088
+ .where('id', '=', deployment.id)
2089
+ .execute();
2090
+ await connection.query
2091
+ .updateTable('relApps')
2092
+ .set({
2093
+ currentDeploymentId: deployment.id,
2094
+ enabled: true,
2095
+ updatedAt: finishedAt,
2096
+ })
2097
+ .where('id', '=', appId)
2098
+ .execute();
2099
+ await this.settleRequest(connection, deployment, 'deployed', finishedAt);
2100
+ });
2101
+ if (previous?.configPath && previous.id !== deployment.id)
2102
+ await rm(previous.configPath, { force: true }).catch(() => undefined);
2103
+ }
2104
+ async markFailed(deployment, error) {
2105
+ this.logDeployment(deployment, 'completed', 'Deployment failed', error);
2106
+ const finishedAt = new Date();
2107
+ await this.options.database.transaction(async (connection) => {
2108
+ await connection.query
2109
+ .updateTable('relDeployments')
2110
+ .set({
2111
+ status: 'failed',
2112
+ phase: 'completed',
2113
+ error: errorMessage(error),
2114
+ finishedAt,
2115
+ })
2116
+ .where('id', '=', deployment.id)
2117
+ .execute();
2118
+ await this.settleRequest(connection, deployment, 'failed', finishedAt);
2119
+ });
2120
+ }
2121
+ /**
2122
+ * An approved deployment request follows the deployment it started, in the same transaction that finishes the
2123
+ * deployment: a reader who sees the deployment finished never sees its request still `approved`.
2124
+ */
2125
+ async settleRequest(connection, deployment, status, at) {
2126
+ if (!deployment.requestId)
2127
+ return;
2128
+ await connection.query
2129
+ .updateTable('relDeploymentRequests')
2130
+ .set({ status, updatedAt: at })
2131
+ .where('id', '=', deployment.requestId)
2132
+ .where('status', '=', 'approved')
2133
+ .execute();
2134
+ }
2135
+ async emitOutcome(deployment, app, release, outcome) {
2136
+ const finished = await this.requireDeploymentById(deployment.id);
2137
+ const view = deploymentView(finished, {
2138
+ version: release.version,
2139
+ checksum: release.checksum,
2140
+ });
2141
+ const actor = { userId: deployment.actorId, kind: deployment.actorKind };
2142
+ const appAfter = (await this.findApp(app.id)) ?? app;
2143
+ await this.options.events.emit(outcome.ok
2144
+ ? {
2145
+ type: deployment.kind === 'rollback'
2146
+ ? 'deployment.rolledBack'
2147
+ : 'deployment.succeeded',
2148
+ app: appView(appAfter),
2149
+ deployment: view,
2150
+ release: releaseView(release),
2151
+ actor,
2152
+ }
2153
+ : {
2154
+ type: 'deployment.failed',
2155
+ app: appView(appAfter),
2156
+ deployment: view,
2157
+ release: releaseView(release),
2158
+ error: outcome.error,
2159
+ actor,
2160
+ });
2161
+ }
2162
+ /**
2163
+ * Finalises a deployment the previous process left `deploying`, from the outcome the target recorded (a Host's
2164
+ * operation log). Resolves once the deployment is recorded.
2165
+ */
2166
+ finaliseDeployment(deployment, read) {
2167
+ const run = this.withLock(deployment.appId, async () => {
2168
+ let status;
2169
+ try {
2170
+ status = await read();
2171
+ }
2172
+ catch (error) {
2173
+ status = {
2174
+ state: 'failed',
2175
+ version: null,
2176
+ deploymentId: deployment.id,
2177
+ startedAt: null,
2178
+ error: `Could not read the outcome: ${errorMessage(error)}`,
2179
+ };
2180
+ }
2181
+ const current = await this.requireDeploymentById(deployment.id);
2182
+ if (current.status !== 'deploying' && current.status !== 'queued')
2183
+ return;
2184
+ const app = await this.requireApp(deployment.appId);
2185
+ const release = await this.requireRelease(app.id, deployment.releaseId);
2186
+ let outcome;
2187
+ if (status?.state === 'running') {
2188
+ const previous = deployment.previousDeploymentId
2189
+ ? await this.requireDeployment(app.id, deployment.previousDeploymentId).catch(() => null)
2190
+ : null;
2191
+ this.logDeployment(deployment, 'switching', 'The runtime reports the deployment finished');
2192
+ await this.markSucceeded(deployment, app.id, previous);
2193
+ outcome = { ok: true };
2194
+ }
2195
+ else {
2196
+ const error = status
2197
+ ? (status.error ?? 'The deployment failed.')
2198
+ : 'Deployment was interrupted by a restart.';
2199
+ await this.markFailed(deployment, new Error(error));
2200
+ outcome = { ok: false, error };
2201
+ }
2202
+ await this.emitOutcome(deployment, app, release, outcome);
2203
+ })
2204
+ .catch((error) => this.diagnostic.error(`Failed to finalise deployment ${deployment.id}`, error))
2205
+ .finally(() => {
2206
+ this.running.delete(run);
2207
+ if (this.runs.get(deployment.id) === run)
2208
+ this.runs.delete(deployment.id);
2209
+ });
2210
+ this.running.add(run);
2211
+ this.runs.set(deployment.id, run);
2212
+ return run;
2213
+ }
2214
+ async requireNoPendingDeployment(appId, connection) {
2215
+ const pending = await (connection?.query ?? this.query())
2216
+ .selectFrom('relDeployments')
2217
+ .select('id')
2218
+ .where('appId', '=', appId)
2219
+ .where('status', 'in', ['queued', 'deploying'])
2220
+ .executeTakeFirst();
2221
+ if (pending)
2222
+ throw new ReleasesError('A deployment is already in progress.', 'DEPLOYMENT_IN_PROGRESS', 'FAILED_PRECONDITION');
2223
+ }
2224
+ async findRelease(appId, releaseId) {
2225
+ const row = await this.query()
2226
+ .selectFrom('relReleases')
2227
+ .selectAll()
2228
+ .where('id', '=', releaseId)
2229
+ .where('appId', '=', appId)
2230
+ .executeTakeFirst();
2231
+ return row ? decodeRelease(row) : null;
2232
+ }
2233
+ async requireDeployment(appId, deploymentId) {
2234
+ const row = await this.query()
2235
+ .selectFrom('relDeployments')
2236
+ .selectAll()
2237
+ .where('appId', '=', appId)
2238
+ .where('id', '=', deploymentId)
2239
+ .executeTakeFirst();
2240
+ if (!row)
2241
+ throw notFound('Deployment', 'DEPLOYMENT_NOT_FOUND', deploymentId);
2242
+ return decodeDeployment(row);
2243
+ }
2244
+ async requireDeploymentById(deploymentId) {
2245
+ const row = await this.query()
2246
+ .selectFrom('relDeployments')
2247
+ .selectAll()
2248
+ .where('id', '=', deploymentId)
2249
+ .executeTakeFirst();
2250
+ if (!row)
2251
+ throw notFound('Deployment', 'DEPLOYMENT_NOT_FOUND', deploymentId);
2252
+ return decodeDeployment(row);
2253
+ }
2254
+ async currentDeployment(app) {
2255
+ return app.currentDeploymentId
2256
+ ? await this.requireDeployment(app.id, app.currentDeploymentId).catch(() => null)
2257
+ : null;
2258
+ }
2259
+ async readDeploymentConfig(deployment) {
2260
+ if (deployment.configMode !== 'file' || !deployment.configPath)
2261
+ return null;
2262
+ try {
2263
+ return await readFile(deployment.configPath, 'utf8');
2264
+ }
2265
+ catch (error) {
2266
+ if (error.code === 'ENOENT')
2267
+ return null;
2268
+ throw error;
2269
+ }
2270
+ }
2271
+ async updateDeployment(deploymentId, values) {
2272
+ await this.query()
2273
+ .updateTable('relDeployments')
2274
+ .set(values)
2275
+ .where('id', '=', deploymentId)
2276
+ .execute();
2277
+ }
2278
+ async updateApp_(appId, values) {
2279
+ await this.query()
2280
+ .updateTable('relApps')
2281
+ .set({ ...values, updatedAt: new Date() })
2282
+ .where('id', '=', appId)
2283
+ .execute();
2284
+ }
2285
+ deploymentLogsDir() {
2286
+ return path.join(this.options.dataDir, 'deployment-logs');
2287
+ }
2288
+ configsDir() {
2289
+ return path.join(this.options.dataDir, 'app-configs');
2290
+ }
2291
+ deploymentLogPath(appId, deploymentId) {
2292
+ if (!APP_ID_PATTERN.test(appId) || !/^[a-zA-Z0-9_-]+$/.test(deploymentId))
2293
+ throw new ReleasesError('Invalid log identity.', 'INVALID_LOG_ID', 'INVALID_ARGUMENT');
2294
+ return path.join(this.deploymentLogsDir(), appId, `${deploymentId}.log`);
2295
+ }
2296
+ logDeployment(deployment, phase, msg, err) {
2297
+ if (this.options.logging?.deployments?.enabled === false)
2298
+ return;
2299
+ this.appendDeploymentLog(deployment, {
2300
+ time: new Date().toISOString(),
2301
+ level: err ? 'error' : 'info',
2302
+ appId: deployment.appId,
2303
+ deploymentId: deployment.id,
2304
+ phase,
2305
+ msg,
2306
+ ...(err ? { err: errorMessage(err) } : {}),
2307
+ });
2308
+ }
2309
+ appendDeploymentLog(deployment, entry) {
2310
+ try {
2311
+ appendJournal(this.deploymentLogPath(deployment.appId, deployment.id), entry, this.options.logging?.deployments?.maxFileSizeMB ?? 50);
2312
+ }
2313
+ catch (error) {
2314
+ reportLoggingFailure('Failed to persist deployment log', error);
2315
+ }
2316
+ }
2317
+ async withLock(key, work) {
2318
+ const previous = this.locks.get(key) ?? Promise.resolve();
2319
+ const current = previous.catch(() => undefined).then(work);
2320
+ this.locks.set(key, current);
2321
+ try {
2322
+ return await current;
2323
+ }
2324
+ finally {
2325
+ if (this.locks.get(key) === current)
2326
+ this.locks.delete(key);
2327
+ }
2328
+ }
2329
+ query() {
2330
+ return this.options.database.connection().query;
2331
+ }
2332
+ }
2333
+ // --- Variables --------------------------------------------------------------------------------------------------
2334
+ const INITIAL_ADMIN_TTL_MS = 24 * 60 * 60 * 1000;
2335
+ /** A variable's value from the App or the environment, when one of them sets it. */
2336
+ function suppliedValue(context, name) {
2337
+ return (context.appVariables.find((variable) => variable.name === name)?.value ??
2338
+ context.environmentVariables.find((variable) => variable.name === name)
2339
+ ?.value ??
2340
+ undefined);
2341
+ }
2342
+ function planGenerated(manifest, context, configFile) {
2343
+ const plan = {
2344
+ persistent: new Map(),
2345
+ oneShot: new Map(),
2346
+ computed: {},
2347
+ initialAdmin: null,
2348
+ };
2349
+ if (!manifest)
2350
+ return plan;
2351
+ /** A field of the first administrator: a variable's value, else the configuration's, else the default. */
2352
+ const adminField = (field) => {
2353
+ const path = INITIAL_ADMIN_PATHS[field];
2354
+ const variable = manifest.variables.find((item) => item.path === path);
2355
+ const supplied = variable
2356
+ ? suppliedValue(context, variable.name)
2357
+ : undefined;
2358
+ if (supplied)
2359
+ return supplied;
2360
+ const configured = configValueAt(configFile, path);
2361
+ return typeof configured === 'string' && hasRealValue(configFile, path)
2362
+ ? configured
2363
+ : DEFAULT_INITIAL_ADMIN[field];
2364
+ };
2365
+ for (const variable of manifest.variables) {
2366
+ const { name } = variable;
2367
+ if (variable.runtime || RESERVED_VARIABLE_NAMES.includes(name))
2368
+ continue;
2369
+ if (suppliedValue(context, name) !== undefined)
2370
+ continue;
2371
+ if (variable.path && hasRealValue(configFile, variable.path))
2372
+ continue;
2373
+ if (variable.path === INITIAL_ADMIN_PATHS.password) {
2374
+ if (context.first)
2375
+ plan.initialAdmin = {
2376
+ name,
2377
+ username: adminField('username'),
2378
+ email: adminField('email'),
2379
+ };
2380
+ continue;
2381
+ }
2382
+ if (variable.path === PUBLIC_ORIGIN_PATH) {
2383
+ if (context.url)
2384
+ plan.computed[name] = new URL(context.url).origin;
2385
+ continue;
2386
+ }
2387
+ if (variable.path === SAMPLE_DATA_PATH) {
2388
+ if (context.first && context.environment.sampleDataOnFirstDeploy)
2389
+ plan.computed[name] = 'true';
2390
+ continue;
2391
+ }
2392
+ if (!variable.generate)
2393
+ continue;
2394
+ if (variable.firstStartOnly) {
2395
+ if (context.first)
2396
+ plan.oneShot.set(name, variable);
2397
+ continue;
2398
+ }
2399
+ plan.persistent.set(name, variable);
2400
+ }
2401
+ return plan;
2402
+ }
2403
+ /** The sections whose secret the build lets a deployment generate as a variable, which `config.yml` is then not given. */
2404
+ function declaredSecretSections(manifest) {
2405
+ return Object.keys(CONFIG_SECRET_PATHS).filter((section) => declaresPath(manifest, CONFIG_SECRET_PATHS[section]));
2406
+ }
2407
+ function declaresPath(manifest, path) {
2408
+ return (manifest?.variables.some((variable) => variable.path === path) ?? false);
2409
+ }
2410
+ function parseConfig(content) {
2411
+ try {
2412
+ return content.trim() === '' ? {} : parseYaml(content);
2413
+ }
2414
+ catch {
2415
+ return {};
2416
+ }
2417
+ }
2418
+ /** The refusal of a deployment whose required variables are not all set; it names them, never a value. */
2419
+ export function variablesMissing(app, release, missing) {
2420
+ const names = missing.map((variable) => variable.name);
2421
+ const subject = names.length === 1
2422
+ ? 'A required variable is'
2423
+ : `${names.length} required variables are`;
2424
+ return new ReleasesError(`${subject} not set for ${app.id} (release ${release.version}): ${names.join(', ')}. Set ${names.length === 1 ? 'it' : 'them'} in the App's or its environment's variables, then deploy again.`, 'VARIABLES_MISSING', 'FAILED_PRECONDITION', {
2425
+ metadata: {
2426
+ appId: app.id,
2427
+ environmentId: app.environmentId,
2428
+ releaseId: release.id,
2429
+ variables: missing.map((variable) => ({
2430
+ name: variable.name,
2431
+ description: variable.description ?? null,
2432
+ secret: variable.secret,
2433
+ })),
2434
+ },
2435
+ });
2436
+ }
2437
+ // --- Views and codecs -------------------------------------------------------------------------------------------
2438
+ /** A stored file configuration as it leaves the server: masked. */
2439
+ function configDocument(content) {
2440
+ const masked = maskConfig(content);
2441
+ return { mode: 'file', content: masked.content, secrets: masked.secrets };
2442
+ }
2443
+ export function appView(app) {
2444
+ return {
2445
+ id: app.id,
2446
+ environmentId: app.environmentId,
2447
+ name: app.name,
2448
+ description: app.description,
2449
+ currentDeploymentId: app.currentDeploymentId,
2450
+ enabled: app.enabled,
2451
+ activation: app.activation,
2452
+ idleStopMinutes: app.idleStopMinutes,
2453
+ dormantAfterHours: app.dormantAfterHours,
2454
+ labels: app.labels,
2455
+ previewOf: app.previewOf,
2456
+ createdBy: app.createdBy,
2457
+ createdVia: app.createdVia,
2458
+ createdAt: app.createdAt.toISOString(),
2459
+ updatedAt: app.updatedAt.toISOString(),
2460
+ };
2461
+ }
2462
+ export function releaseView(release, images) {
2463
+ return {
2464
+ id: release.id,
2465
+ appId: release.appId,
2466
+ kind: release.kind,
2467
+ version: release.version,
2468
+ checksum: release.checksum,
2469
+ size: release.size,
2470
+ hasConfigTemplate: release.configTemplate !== null,
2471
+ labels: release.labels,
2472
+ sourceReleaseId: release.sourceReleaseId,
2473
+ sourceCommit: release.sourceCommit,
2474
+ build: release.build,
2475
+ ...(images
2476
+ ? {
2477
+ artifacts: release.kind === 'archive'
2478
+ ? [
2479
+ {
2480
+ kind: 'tarball',
2481
+ checksum: release.checksum,
2482
+ size: release.size ?? 0,
2483
+ },
2484
+ ]
2485
+ : images,
2486
+ }
2487
+ : {}),
2488
+ createdBy: release.createdBy,
2489
+ createdVia: release.createdVia,
2490
+ createdAt: release.createdAt.toISOString(),
2491
+ };
2492
+ }
2493
+ export function deploymentView(deployment, release) {
2494
+ return {
2495
+ id: deployment.id,
2496
+ appId: deployment.appId,
2497
+ releaseId: deployment.releaseId,
2498
+ kind: deployment.kind,
2499
+ rollbackTargetDeploymentId: deployment.rollbackTargetDeploymentId,
2500
+ previousDeploymentId: deployment.previousDeploymentId,
2501
+ status: deployment.status,
2502
+ phase: deployment.phase,
2503
+ configMode: deployment.configMode,
2504
+ error: deployment.error,
2505
+ actorId: deployment.actorId,
2506
+ actorKind: deployment.actorKind,
2507
+ requestId: deployment.requestId,
2508
+ createdAt: deployment.createdAt.toISOString(),
2509
+ startedAt: iso(deployment.startedAt),
2510
+ finishedAt: iso(deployment.finishedAt),
2511
+ release,
2512
+ artifact: deploymentArtifact(deployment),
2513
+ };
2514
+ }
2515
+ /** What a deployment ran, from its columns. */
2516
+ function deploymentArtifact(deployment) {
2517
+ switch (deployment.artifactKind) {
2518
+ case 'tarball':
2519
+ return { kind: 'tarball' };
2520
+ case 'image':
2521
+ return deployment.imageRef && deployment.imageDigest
2522
+ ? {
2523
+ kind: 'image',
2524
+ ref: deployment.imageRef,
2525
+ digest: deployment.imageDigest,
2526
+ platform: deployment.imagePlatform ?? '',
2527
+ }
2528
+ : null;
2529
+ default:
2530
+ return null;
2531
+ }
2532
+ }
2533
+ function artifactColumns(artifact) {
2534
+ return {
2535
+ artifactKind: artifact.kind,
2536
+ imageRef: artifact.kind === 'image' ? artifact.ref : null,
2537
+ imageDigest: artifact.kind === 'image' ? artifact.digest : null,
2538
+ imagePlatform: artifact.kind === 'image' ? artifact.platform : null,
2539
+ };
2540
+ }
2541
+ /** The artifact a driver's event reports, when it reports one. */
2542
+ function deploymentArtifactOf(entry) {
2543
+ const artifact = entry.artifact;
2544
+ if (!artifact || typeof artifact !== 'object')
2545
+ return null;
2546
+ if (artifact.kind === 'image' &&
2547
+ typeof artifact.ref === 'string' &&
2548
+ typeof artifact.digest === 'string')
2549
+ return {
2550
+ kind: 'image',
2551
+ ref: artifact.ref.slice(0, 500),
2552
+ digest: artifact.digest.slice(0, 80),
2553
+ platform: typeof artifact.platform === 'string'
2554
+ ? artifact.platform.slice(0, 64)
2555
+ : '',
2556
+ };
2557
+ return null;
2558
+ }
2559
+ function normalizeSourceCommit(value) {
2560
+ if (value === undefined || value === null || value === '')
2561
+ return null;
2562
+ if (typeof value !== 'string' || !/^[0-9a-f]{7,64}$/u.test(value))
2563
+ throw new ReleasesError('The source commit is a commit hash.', 'INVALID_SOURCE_COMMIT', 'INVALID_ARGUMENT');
2564
+ return value;
2565
+ }
2566
+ /** An image release's version, as CI names it (a semantic version, a date, a tag). */
2567
+ function normalizeVersion(value) {
2568
+ if (typeof value !== 'string' || !RELEASE_VERSION_PATTERN.test(value))
2569
+ throw new ReleasesError('The version is letters, digits, dots, dashes, underscores and plus signs.', 'INVALID_VERSION', 'INVALID_ARGUMENT');
2570
+ return value;
2571
+ }
2572
+ function normalizeBuild(value) {
2573
+ if (value === undefined || value === null || value === '')
2574
+ return null;
2575
+ if (typeof value !== 'string' || value.length > 255)
2576
+ throw new ReleasesError('The build is text of at most 255 characters.', 'INVALID_BUILD', 'INVALID_ARGUMENT');
2577
+ return value;
2578
+ }
2579
+ function runtimeStatus(status, appId) {
2580
+ if (!status || status instanceof Error)
2581
+ return {
2582
+ available: false,
2583
+ state: 'unknown',
2584
+ version: null,
2585
+ startedAt: null,
2586
+ error: status instanceof Error ? status.message : null,
2587
+ lastAccessedAt: null,
2588
+ };
2589
+ const item = status.get(appId);
2590
+ if (!item)
2591
+ return {
2592
+ available: true,
2593
+ state: 'stopped',
2594
+ version: null,
2595
+ startedAt: null,
2596
+ error: null,
2597
+ lastAccessedAt: null,
2598
+ };
2599
+ return {
2600
+ available: true,
2601
+ state: item.state,
2602
+ version: item.version,
2603
+ startedAt: item.startedAt,
2604
+ error: item.error,
2605
+ lastAccessedAt: item.lastAccessedAt ?? null,
2606
+ };
2607
+ }
2608
+ function decodeActorKind(value) {
2609
+ return value === 'agent' ||
2610
+ value === 'key' ||
2611
+ value === 'rule' ||
2612
+ value === 'system'
2613
+ ? value
2614
+ : 'human';
2615
+ }
2616
+ export function decodeApp(row) {
2617
+ return {
2618
+ id: String(row.id),
2619
+ environmentId: String(row.environmentId),
2620
+ name: String(row.name),
2621
+ description: nullableString(row.description),
2622
+ currentDeploymentId: nullableString(row.currentDeploymentId),
2623
+ enabled: Boolean(row.enabled),
2624
+ activation: row.activation === 'onDemand' ? 'onDemand' : 'eager',
2625
+ idleStopMinutes: nullableNumber(row.idleStopMinutes),
2626
+ dormantAfterHours: nullableNumber(row.dormantAfterHours),
2627
+ labels: decodeLabels(row.labels),
2628
+ previewOf: nullableString(row.previewOf),
2629
+ createdBy: nullableString(row.createdBy),
2630
+ createdVia: decodeActorKind(row.createdVia),
2631
+ createdAt: decodeDate(row.createdAt),
2632
+ updatedAt: decodeDate(row.updatedAt),
2633
+ };
2634
+ }
2635
+ function decodeRelease(row) {
2636
+ return {
2637
+ id: String(row.id),
2638
+ appId: String(row.appId),
2639
+ kind: row.kind === 'image' ? 'image' : 'archive',
2640
+ version: String(row.version),
2641
+ artifactKey: nullableString(row.artifactKey),
2642
+ checksum: String(row.checksum),
2643
+ size: nullableNumber(row.size),
2644
+ configTemplate: nullableString(row.configTemplate),
2645
+ manifest: decodeRecord(row.manifest),
2646
+ variables: decodeVariablesManifest(row.variables),
2647
+ labels: decodeLabels(row.labels),
2648
+ sourceReleaseId: nullableString(row.sourceReleaseId),
2649
+ sourceCommit: nullableString(row.sourceCommit),
2650
+ build: nullableString(row.build),
2651
+ createdBy: nullableString(row.createdBy),
2652
+ createdVia: decodeActorKind(row.createdVia),
2653
+ createdAt: decodeDate(row.createdAt),
2654
+ };
2655
+ }
2656
+ function encodeRelease(release) {
2657
+ return {
2658
+ ...release,
2659
+ manifest: JSON.stringify(release.manifest),
2660
+ variables: release.variables ? JSON.stringify(release.variables) : null,
2661
+ labels: JSON.stringify(release.labels),
2662
+ };
2663
+ }
2664
+ function decodeDeployment(row) {
2665
+ return {
2666
+ id: String(row.id),
2667
+ appId: String(row.appId),
2668
+ releaseId: String(row.releaseId),
2669
+ kind: row.kind === 'rollback' ? 'rollback' : 'deploy',
2670
+ rollbackTargetDeploymentId: nullableString(row.rollbackTargetDeploymentId),
2671
+ previousDeploymentId: nullableString(row.previousDeploymentId),
2672
+ status: String(row.status),
2673
+ phase: String(row.phase),
2674
+ configMode: row.configMode === 'external' ? 'external' : 'file',
2675
+ configPath: nullableString(row.configPath),
2676
+ error: nullableString(row.error),
2677
+ actorId: nullableString(row.actorId),
2678
+ actorKind: decodeActorKind(row.actorKind),
2679
+ requestId: nullableString(row.requestId),
2680
+ artifactKind: row.artifactKind === 'tarball' || row.artifactKind === 'image'
2681
+ ? row.artifactKind
2682
+ : null,
2683
+ imageRef: nullableString(row.imageRef),
2684
+ imageDigest: nullableString(row.imageDigest),
2685
+ imagePlatform: nullableString(row.imagePlatform),
2686
+ env: nullableString(row.env),
2687
+ variablesFingerprint: nullableString(row.variablesFingerprint),
2688
+ initialAdmin: nullableString(row.initialAdmin),
2689
+ initialAdminExpiresAt: decodeOptionalDate(row.initialAdminExpiresAt),
2690
+ initialAdminSavedBy: nullableString(row.initialAdminSavedBy),
2691
+ initialAdminSavedAt: decodeOptionalDate(row.initialAdminSavedAt),
2692
+ createdAt: decodeDate(row.createdAt),
2693
+ startedAt: decodeOptionalDate(row.startedAt),
2694
+ finishedAt: decodeOptionalDate(row.finishedAt),
2695
+ };
2696
+ }
2697
+ function encodeDeployment(deployment) {
2698
+ return { ...deployment };
2699
+ }
2700
+ function validateDeployConfig(config) {
2701
+ if (!config ||
2702
+ typeof config !== 'object' ||
2703
+ (config.mode !== 'file' &&
2704
+ config.mode !== 'external'))
2705
+ throw new ReleasesError('Configuration mode must be file or external.', 'INVALID_CONFIG_MODE', 'INVALID_ARGUMENT');
2706
+ const content = config.content;
2707
+ if (content !== undefined) {
2708
+ if (typeof content !== 'string' ||
2709
+ Buffer.byteLength(content) > MAX_CONFIG_BYTES)
2710
+ throw new ReleasesError('Configuration content must be text of at most 1 MiB.', 'INVALID_DEPLOYMENT_INPUT', 'INVALID_ARGUMENT');
2711
+ validateYamlConfig(content);
2712
+ }
2713
+ }
2714
+ function sha256(value) {
2715
+ return createHash('sha256').update(value).digest('hex');
2716
+ }
2717
+ function errorMessage(error) {
2718
+ return error instanceof Error ? error.message : String(error);
2719
+ }
2720
+ function delay(ms) {
2721
+ return new Promise((resolve) => {
2722
+ setTimeout(resolve, ms).unref?.();
2723
+ });
2724
+ }
2725
+ //# sourceMappingURL=releases.js.map