@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.
- package/CHANGELOG.md +71 -0
- package/README.md +118 -2
- package/dist/client/components/actor-name.d.ts +5 -0
- package/dist/client/components/actor-name.d.ts.map +1 -0
- package/dist/client/components/actor-name.js +11 -0
- package/dist/client/components/actor-name.js.map +1 -0
- package/dist/client/components/advanced-section.d.ts +8 -0
- package/dist/client/components/advanced-section.d.ts.map +1 -0
- package/dist/client/components/advanced-section.js +13 -0
- package/dist/client/components/advanced-section.js.map +1 -0
- package/dist/client/components/app-settings-card.d.ts +7 -0
- package/dist/client/components/app-settings-card.d.ts.map +1 -0
- package/dist/client/components/app-settings-card.js +69 -0
- package/dist/client/components/app-settings-card.js.map +1 -0
- package/dist/client/components/artifacts.d.ts +9 -0
- package/dist/client/components/artifacts.d.ts.map +1 -0
- package/dist/client/components/artifacts.js +26 -0
- package/dist/client/components/artifacts.js.map +1 -0
- package/dist/client/components/config-secrets.d.ts +10 -0
- package/dist/client/components/config-secrets.d.ts.map +1 -0
- package/dist/client/components/config-secrets.js +34 -0
- package/dist/client/components/config-secrets.js.map +1 -0
- package/dist/client/components/confirm-dialog.d.ts +22 -0
- package/dist/client/components/confirm-dialog.d.ts.map +1 -0
- package/dist/client/components/confirm-dialog.js +47 -0
- package/dist/client/components/confirm-dialog.js.map +1 -0
- package/dist/client/components/connection-check.d.ts +13 -0
- package/dist/client/components/connection-check.d.ts.map +1 -0
- package/dist/client/components/connection-check.js +69 -0
- package/dist/client/components/connection-check.js.map +1 -0
- package/dist/client/components/data-table.d.ts +27 -0
- package/dist/client/components/data-table.d.ts.map +1 -0
- package/dist/client/components/data-table.js +41 -0
- package/dist/client/components/data-table.js.map +1 -0
- package/dist/client/components/driver-settings.d.ts +28 -0
- package/dist/client/components/driver-settings.d.ts.map +1 -0
- package/dist/client/components/driver-settings.js +158 -0
- package/dist/client/components/driver-settings.js.map +1 -0
- package/dist/client/components/environment-form.d.ts +18 -0
- package/dist/client/components/environment-form.d.ts.map +1 -0
- package/dist/client/components/environment-form.js +284 -0
- package/dist/client/components/environment-form.js.map +1 -0
- package/dist/client/components/image-source-fields.d.ts +16 -0
- package/dist/client/components/image-source-fields.d.ts.map +1 -0
- package/dist/client/components/image-source-fields.js +53 -0
- package/dist/client/components/image-source-fields.js.map +1 -0
- package/dist/client/components/labels-editor.d.ts +15 -0
- package/dist/client/components/labels-editor.d.ts.map +1 -0
- package/dist/client/components/labels-editor.js +40 -0
- package/dist/client/components/labels-editor.js.map +1 -0
- package/dist/client/components/page-container.d.ts +11 -0
- package/dist/client/components/page-container.d.ts.map +1 -0
- package/dist/client/components/page-container.js +13 -0
- package/dist/client/components/page-container.js.map +1 -0
- package/dist/client/components/page-header.d.ts +14 -0
- package/dist/client/components/page-header.d.ts.map +1 -0
- package/dist/client/components/page-header.js +10 -0
- package/dist/client/components/page-header.js.map +1 -0
- package/dist/client/components/page-tabs.d.ts +20 -0
- package/dist/client/components/page-tabs.d.ts.map +1 -0
- package/dist/client/components/page-tabs.js +7 -0
- package/dist/client/components/page-tabs.js.map +1 -0
- package/dist/client/components/people-picker.d.ts +5 -0
- package/dist/client/components/people-picker.d.ts.map +1 -0
- package/dist/client/components/people-picker.js +22 -0
- package/dist/client/components/people-picker.js.map +1 -0
- package/dist/client/components/refresh-button.d.ts +7 -0
- package/dist/client/components/refresh-button.d.ts.map +1 -0
- package/dist/client/components/refresh-button.js +21 -0
- package/dist/client/components/refresh-button.js.map +1 -0
- package/dist/client/components/registry-fields.d.ts +20 -0
- package/dist/client/components/registry-fields.d.ts.map +1 -0
- package/dist/client/components/registry-fields.js +47 -0
- package/dist/client/components/registry-fields.js.map +1 -0
- package/dist/client/components/release-badges.d.ts +42 -0
- package/dist/client/components/release-badges.d.ts.map +1 -0
- package/dist/client/components/release-badges.js +100 -0
- package/dist/client/components/release-badges.js.map +1 -0
- package/dist/client/components/request-decision.d.ts +8 -0
- package/dist/client/components/request-decision.d.ts.map +1 -0
- package/dist/client/components/request-decision.js +7 -0
- package/dist/client/components/request-decision.js.map +1 -0
- package/dist/client/components/route-dialog.d.ts +6 -0
- package/dist/client/components/route-dialog.d.ts.map +1 -0
- package/dist/client/components/route-dialog.js +7 -0
- package/dist/client/components/route-dialog.js.map +1 -0
- package/dist/client/components/route-overlay.d.ts +21 -0
- package/dist/client/components/route-overlay.d.ts.map +1 -0
- package/dist/client/components/route-overlay.js +87 -0
- package/dist/client/components/route-overlay.js.map +1 -0
- package/dist/client/components/row-actions.d.ts +9 -0
- package/dist/client/components/row-actions.d.ts.map +1 -0
- package/dist/client/components/row-actions.js +16 -0
- package/dist/client/components/row-actions.js.map +1 -0
- package/dist/client/components/runtime-policy-fields.d.ts +9 -0
- package/dist/client/components/runtime-policy-fields.d.ts.map +1 -0
- package/dist/client/components/runtime-policy-fields.js +26 -0
- package/dist/client/components/runtime-policy-fields.js.map +1 -0
- package/dist/client/components/section.d.ts +15 -0
- package/dist/client/components/section.d.ts.map +1 -0
- package/dist/client/components/section.js +6 -0
- package/dist/client/components/section.js.map +1 -0
- package/dist/client/components/states.d.ts +21 -0
- package/dist/client/components/states.d.ts.map +1 -0
- package/dist/client/components/states.js +29 -0
- package/dist/client/components/states.js.map +1 -0
- package/dist/client/components/tag.d.ts +15 -0
- package/dist/client/components/tag.d.ts.map +1 -0
- package/dist/client/components/tag.js +15 -0
- package/dist/client/components/tag.js.map +1 -0
- package/dist/client/components/ui/alert-dialog.d.ts +19 -0
- package/dist/client/components/ui/alert-dialog.d.ts.map +1 -0
- package/dist/client/components/ui/alert-dialog.js +42 -0
- package/dist/client/components/ui/alert-dialog.js.map +1 -0
- package/dist/client/components/ui/alert.d.ts +13 -0
- package/dist/client/components/ui/alert.d.ts.map +1 -0
- package/dist/client/components/ui/alert.js +31 -0
- package/dist/client/components/ui/alert.js.map +1 -0
- package/dist/client/components/ui/badge.d.ts +11 -0
- package/dist/client/components/ui/badge.d.ts.map +1 -0
- package/dist/client/components/ui/badge.js +37 -0
- package/dist/client/components/ui/badge.js.map +1 -0
- package/dist/client/components/ui/button.d.ts +12 -0
- package/dist/client/components/ui/button.d.ts.map +1 -0
- package/dist/client/components/ui/button.js +38 -0
- package/dist/client/components/ui/button.js.map +1 -0
- package/dist/client/components/ui/card.d.ts +12 -0
- package/dist/client/components/ui/card.d.ts.map +1 -0
- package/dist/client/components/ui/card.js +25 -0
- package/dist/client/components/ui/card.js.map +1 -0
- package/dist/client/components/ui/checkbox.d.ts +5 -0
- package/dist/client/components/ui/checkbox.d.ts.map +1 -0
- package/dist/client/components/ui/checkbox.js +9 -0
- package/dist/client/components/ui/checkbox.js.map +1 -0
- package/dist/client/components/ui/collapsible.d.ts +7 -0
- package/dist/client/components/ui/collapsible.d.ts.map +1 -0
- package/dist/client/components/ui/collapsible.js +13 -0
- package/dist/client/components/ui/collapsible.js.map +1 -0
- package/dist/client/components/ui/dialog.d.ts +18 -0
- package/dist/client/components/ui/dialog.d.ts.map +1 -0
- package/dist/client/components/ui/dialog.js +40 -0
- package/dist/client/components/ui/dialog.js.map +1 -0
- package/dist/client/components/ui/dropdown-menu.d.ts +30 -0
- package/dist/client/components/ui/dropdown-menu.d.ts.map +1 -0
- package/dist/client/components/ui/dropdown-menu.js +51 -0
- package/dist/client/components/ui/dropdown-menu.js.map +1 -0
- package/dist/client/components/ui/empty.d.ts +15 -0
- package/dist/client/components/ui/empty.d.ts.map +1 -0
- package/dist/client/components/ui/empty.js +37 -0
- package/dist/client/components/ui/empty.js.map +1 -0
- package/dist/client/components/ui/field.d.ts +28 -0
- package/dist/client/components/ui/field.d.ts.map +1 -0
- package/dist/client/components/ui/field.js +71 -0
- package/dist/client/components/ui/field.js.map +1 -0
- package/dist/client/components/ui/input-group.d.ts +22 -0
- package/dist/client/components/ui/input-group.d.ts.map +1 -0
- package/dist/client/components/ui/input-group.js +63 -0
- package/dist/client/components/ui/input-group.js.map +1 -0
- package/dist/client/components/ui/input.d.ts +4 -0
- package/dist/client/components/ui/input.d.ts.map +1 -0
- package/dist/client/components/ui/input.js +8 -0
- package/dist/client/components/ui/input.js.map +1 -0
- package/dist/client/components/ui/label.d.ts +4 -0
- package/dist/client/components/ui/label.d.ts.map +1 -0
- package/dist/client/components/ui/label.js +7 -0
- package/dist/client/components/ui/label.js.map +1 -0
- package/dist/client/components/ui/radio-group.d.ts +7 -0
- package/dist/client/components/ui/radio-group.d.ts.map +1 -0
- package/dist/client/components/ui/radio-group.js +12 -0
- package/dist/client/components/ui/radio-group.js.map +1 -0
- package/dist/client/components/ui/select.d.ts +16 -0
- package/dist/client/components/ui/select.d.ts.map +1 -0
- package/dist/client/components/ui/select.js +34 -0
- package/dist/client/components/ui/select.js.map +1 -0
- package/dist/client/components/ui/separator.d.ts +5 -0
- package/dist/client/components/ui/separator.d.ts.map +1 -0
- package/dist/client/components/ui/separator.js +8 -0
- package/dist/client/components/ui/separator.js.map +1 -0
- package/dist/client/components/ui/sheet.d.ts +15 -0
- package/dist/client/components/ui/sheet.d.ts.map +1 -0
- package/dist/client/components/ui/sheet.js +39 -0
- package/dist/client/components/ui/sheet.js.map +1 -0
- package/dist/client/components/ui/skeleton.d.ts +4 -0
- package/dist/client/components/ui/skeleton.d.ts.map +1 -0
- package/dist/client/components/ui/skeleton.js +7 -0
- package/dist/client/components/ui/skeleton.js.map +1 -0
- package/dist/client/components/ui/spinner.d.ts +4 -0
- package/dist/client/components/ui/spinner.d.ts.map +1 -0
- package/dist/client/components/ui/spinner.js +11 -0
- package/dist/client/components/ui/spinner.js.map +1 -0
- package/dist/client/components/ui/table.d.ts +11 -0
- package/dist/client/components/ui/table.d.ts.map +1 -0
- package/dist/client/components/ui/table.js +28 -0
- package/dist/client/components/ui/table.js.map +1 -0
- package/dist/client/components/ui/tabs.d.ts +14 -0
- package/dist/client/components/ui/tabs.d.ts.map +1 -0
- package/dist/client/components/ui/tabs.js +32 -0
- package/dist/client/components/ui/tabs.js.map +1 -0
- package/dist/client/components/ui/textarea.d.ts +4 -0
- package/dist/client/components/ui/textarea.d.ts.map +1 -0
- package/dist/client/components/ui/textarea.js +7 -0
- package/dist/client/components/ui/textarea.js.map +1 -0
- package/dist/client/components/ui/tooltip.d.ts +8 -0
- package/dist/client/components/ui/tooltip.d.ts.map +1 -0
- package/dist/client/components/ui/tooltip.js +17 -0
- package/dist/client/components/ui/tooltip.js.map +1 -0
- package/dist/client/components/upload-release-dialog.d.ts +8 -0
- package/dist/client/components/upload-release-dialog.d.ts.map +1 -0
- package/dist/client/components/upload-release-dialog.js +39 -0
- package/dist/client/components/upload-release-dialog.js.map +1 -0
- package/dist/client/components/use-route-overlay.d.ts +15 -0
- package/dist/client/components/use-route-overlay.d.ts.map +1 -0
- package/dist/client/components/use-route-overlay.js +15 -0
- package/dist/client/components/use-route-overlay.js.map +1 -0
- package/dist/client/components/variable-alerts.d.ts +24 -0
- package/dist/client/components/variable-alerts.d.ts.map +1 -0
- package/dist/client/components/variable-alerts.js +85 -0
- package/dist/client/components/variable-alerts.js.map +1 -0
- package/dist/client/components/variable-dialogs.d.ts +30 -0
- package/dist/client/components/variable-dialogs.d.ts.map +1 -0
- package/dist/client/components/variable-dialogs.js +155 -0
- package/dist/client/components/variable-dialogs.js.map +1 -0
- package/dist/client/components/variables-table.d.ts +26 -0
- package/dist/client/components/variables-table.d.ts.map +1 -0
- package/dist/client/components/variables-table.js +374 -0
- package/dist/client/components/variables-table.js.map +1 -0
- package/dist/client/driver-forms/host.d.ts +3 -0
- package/dist/client/driver-forms/host.d.ts.map +1 -0
- package/dist/client/driver-forms/host.js +100 -0
- package/dist/client/driver-forms/host.js.map +1 -0
- package/dist/client/driver-forms/index.d.ts +25 -0
- package/dist/client/driver-forms/index.d.ts.map +1 -0
- package/dist/client/driver-forms/index.js +40 -0
- package/dist/client/driver-forms/index.js.map +1 -0
- package/dist/client/driver-forms/mapping.d.ts +56 -0
- package/dist/client/driver-forms/mapping.d.ts.map +1 -0
- package/dist/client/driver-forms/mapping.js +308 -0
- package/dist/client/driver-forms/mapping.js.map +1 -0
- package/dist/client/driver-forms/types.d.ts +168 -0
- package/dist/client/driver-forms/types.d.ts.map +1 -0
- package/dist/client/driver-forms/types.js +16 -0
- package/dist/client/driver-forms/types.js.map +1 -0
- package/dist/client/hooks/use-connection-check.d.ts +18 -0
- package/dist/client/hooks/use-connection-check.d.ts.map +1 -0
- package/dist/client/hooks/use-connection-check.js +19 -0
- package/dist/client/hooks/use-connection-check.js.map +1 -0
- package/dist/client/hooks/use-driver-forms.d.ts +4 -0
- package/dist/client/hooks/use-driver-forms.d.ts.map +1 -0
- package/dist/client/hooks/use-driver-forms.js +12 -0
- package/dist/client/hooks/use-driver-forms.js.map +1 -0
- package/dist/client/hooks/use-driver-names.d.ts +14 -0
- package/dist/client/hooks/use-driver-names.d.ts.map +1 -0
- package/dist/client/hooks/use-driver-names.js +33 -0
- package/dist/client/hooks/use-driver-names.js.map +1 -0
- package/dist/client/hooks/use-environment-check.d.ts +8 -0
- package/dist/client/hooks/use-environment-check.d.ts.map +1 -0
- package/dist/client/hooks/use-environment-check.js +28 -0
- package/dist/client/hooks/use-environment-check.js.map +1 -0
- package/dist/client/hooks/use-notify.d.ts +7 -0
- package/dist/client/hooks/use-notify.d.ts.map +1 -0
- package/dist/client/hooks/use-notify.js +22 -0
- package/dist/client/hooks/use-notify.js.map +1 -0
- package/dist/client/hooks/use-registries.d.ts +6 -0
- package/dist/client/hooks/use-registries.d.ts.map +1 -0
- package/dist/client/hooks/use-registries.js +9 -0
- package/dist/client/hooks/use-registries.js.map +1 -0
- package/dist/client/hooks/use-registry-check.d.ts +16 -0
- package/dist/client/hooks/use-registry-check.d.ts.map +1 -0
- package/dist/client/hooks/use-registry-check.js +29 -0
- package/dist/client/hooks/use-registry-check.js.map +1 -0
- package/dist/client/hooks/use-releases.d.ts +42 -0
- package/dist/client/hooks/use-releases.d.ts.map +1 -0
- package/dist/client/hooks/use-releases.js +82 -0
- package/dist/client/hooks/use-releases.js.map +1 -0
- package/dist/client/hooks/use-request-decisions.d.ts +15 -0
- package/dist/client/hooks/use-request-decisions.d.ts.map +1 -0
- package/dist/client/hooks/use-request-decisions.js +69 -0
- package/dist/client/hooks/use-request-decisions.js.map +1 -0
- package/dist/client/hooks/use-tab-param.d.ts +5 -0
- package/dist/client/hooks/use-tab-param.d.ts.map +1 -0
- package/dist/client/hooks/use-tab-param.js +33 -0
- package/dist/client/hooks/use-tab-param.js.map +1 -0
- package/dist/client/index.d.ts +12 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +13 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/lib/actor-names.d.ts +16 -0
- package/dist/client/lib/actor-names.d.ts.map +1 -0
- package/dist/client/lib/actor-names.js +8 -0
- package/dist/client/lib/actor-names.js.map +1 -0
- package/dist/client/lib/app-groups.d.ts +20 -0
- package/dist/client/lib/app-groups.d.ts.map +1 -0
- package/dist/client/lib/app-groups.js +36 -0
- package/dist/client/lib/app-groups.js.map +1 -0
- package/dist/client/lib/app-origin.d.ts +22 -0
- package/dist/client/lib/app-origin.d.ts.map +1 -0
- package/dist/client/lib/app-origin.js +10 -0
- package/dist/client/lib/app-origin.js.map +1 -0
- package/dist/client/lib/app-state.d.ts +7 -0
- package/dist/client/lib/app-state.d.ts.map +1 -0
- package/dist/client/lib/app-state.js +15 -0
- package/dist/client/lib/app-state.js.map +1 -0
- package/dist/client/lib/config-secrets.d.ts +12 -0
- package/dist/client/lib/config-secrets.d.ts.map +1 -0
- package/dist/client/lib/config-secrets.js +16 -0
- package/dist/client/lib/config-secrets.js.map +1 -0
- package/dist/client/lib/delete-app-impact.d.ts +17 -0
- package/dist/client/lib/delete-app-impact.d.ts.map +1 -0
- package/dist/client/lib/delete-app-impact.js +10 -0
- package/dist/client/lib/delete-app-impact.js.map +1 -0
- package/dist/client/lib/errors.d.ts +9 -0
- package/dist/client/lib/errors.d.ts.map +1 -0
- package/dist/client/lib/errors.js +76 -0
- package/dist/client/lib/errors.js.map +1 -0
- package/dist/client/lib/format.d.ts +8 -0
- package/dist/client/lib/format.d.ts.map +1 -0
- package/dist/client/lib/format.js +29 -0
- package/dist/client/lib/format.js.map +1 -0
- package/dist/client/lib/labels.d.ts +14 -0
- package/dist/client/lib/labels.d.ts.map +1 -0
- package/dist/client/lib/labels.js +42 -0
- package/dist/client/lib/labels.js.map +1 -0
- package/dist/client/lib/paths.d.ts +25 -0
- package/dist/client/lib/paths.d.ts.map +1 -0
- package/dist/client/lib/paths.js +31 -0
- package/dist/client/lib/paths.js.map +1 -0
- package/dist/client/lib/pending-requests.d.ts +7 -0
- package/dist/client/lib/pending-requests.d.ts.map +1 -0
- package/dist/client/lib/pending-requests.js +12 -0
- package/dist/client/lib/pending-requests.js.map +1 -0
- package/dist/client/lib/people-picker.d.ts +19 -0
- package/dist/client/lib/people-picker.d.ts.map +1 -0
- package/dist/client/lib/people-picker.js +8 -0
- package/dist/client/lib/people-picker.js.map +1 -0
- package/dist/client/lib/permissions.d.ts +6 -0
- package/dist/client/lib/permissions.d.ts.map +1 -0
- package/dist/client/lib/permissions.js +14 -0
- package/dist/client/lib/permissions.js.map +1 -0
- package/dist/client/lib/registry-draft.d.ts +26 -0
- package/dist/client/lib/registry-draft.d.ts.map +1 -0
- package/dist/client/lib/registry-draft.js +41 -0
- package/dist/client/lib/registry-draft.js.map +1 -0
- package/dist/client/lib/runtime-policy.d.ts +21 -0
- package/dist/client/lib/runtime-policy.d.ts.map +1 -0
- package/dist/client/lib/runtime-policy.js +63 -0
- package/dist/client/lib/runtime-policy.js.map +1 -0
- package/dist/client/lib/system-labels.d.ts +15 -0
- package/dist/client/lib/system-labels.d.ts.map +1 -0
- package/dist/client/lib/system-labels.js +14 -0
- package/dist/client/lib/system-labels.js.map +1 -0
- package/dist/client/lib/utils.d.ts +2 -0
- package/dist/client/lib/utils.d.ts.map +1 -0
- package/dist/client/lib/utils.js +2 -0
- package/dist/client/lib/utils.js.map +1 -0
- package/dist/client/lib/variables-text.d.ts +67 -0
- package/dist/client/lib/variables-text.d.ts.map +1 -0
- package/dist/client/lib/variables-text.js +156 -0
- package/dist/client/lib/variables-text.js.map +1 -0
- package/dist/client/locales/en-US.d.ts +670 -0
- package/dist/client/locales/en-US.d.ts.map +1 -0
- package/dist/client/locales/en-US.js +655 -0
- package/dist/client/locales/en-US.js.map +1 -0
- package/dist/client/locales/index.d.ts +4 -0
- package/dist/client/locales/index.d.ts.map +1 -0
- package/dist/client/locales/index.js +7 -0
- package/dist/client/locales/index.js.map +1 -0
- package/dist/client/locales/zh-CN.d.ts +4 -0
- package/dist/client/locales/zh-CN.d.ts.map +1 -0
- package/dist/client/locales/zh-CN.js +650 -0
- package/dist/client/locales/zh-CN.js.map +1 -0
- package/dist/client/pages/app-page.d.ts +3 -0
- package/dist/client/pages/app-page.d.ts.map +1 -0
- package/dist/client/pages/app-page.js +532 -0
- package/dist/client/pages/app-page.js.map +1 -0
- package/dist/client/pages/apps-page.d.ts +4 -0
- package/dist/client/pages/apps-page.d.ts.map +1 -0
- package/dist/client/pages/apps-page.js +116 -0
- package/dist/client/pages/apps-page.js.map +1 -0
- package/dist/client/pages/environment-page.d.ts +3 -0
- package/dist/client/pages/environment-page.d.ts.map +1 -0
- package/dist/client/pages/environment-page.js +147 -0
- package/dist/client/pages/environment-page.js.map +1 -0
- package/dist/client/pages/environments-page.d.ts +4 -0
- package/dist/client/pages/environments-page.d.ts.map +1 -0
- package/dist/client/pages/environments-page.js +115 -0
- package/dist/client/pages/environments-page.js.map +1 -0
- package/dist/client/pages/new-app-page.d.ts +10 -0
- package/dist/client/pages/new-app-page.d.ts.map +1 -0
- package/dist/client/pages/new-app-page.js +101 -0
- package/dist/client/pages/new-app-page.js.map +1 -0
- package/dist/client/pages/request-page.d.ts +7 -0
- package/dist/client/pages/request-page.d.ts.map +1 -0
- package/dist/client/pages/request-page.js +70 -0
- package/dist/client/pages/request-page.js.map +1 -0
- package/dist/client/pages.d.ts +10 -0
- package/dist/client/pages.d.ts.map +1 -0
- package/dist/client/pages.js +25 -0
- package/dist/client/pages.js.map +1 -0
- package/dist/client/plugin.d.ts +12 -0
- package/dist/client/plugin.d.ts.map +1 -0
- package/dist/client/plugin.js +12 -0
- package/dist/client/plugin.js.map +1 -0
- package/dist/client/routes.d.ts +19 -0
- package/dist/client/routes.d.ts.map +1 -0
- package/dist/client/routes.js +89 -0
- package/dist/client/routes.js.map +1 -0
- package/dist/client/service-provider.d.ts +11 -0
- package/dist/client/service-provider.d.ts.map +1 -0
- package/dist/client/service-provider.js +14 -0
- package/dist/client/service-provider.js.map +1 -0
- package/dist/database/migrations/.manifest.json +21 -0
- package/dist/database/migrations/202610020001_rel_create_tables.d.ts +8 -0
- package/dist/database/migrations/202610020001_rel_create_tables.d.ts.map +1 -0
- package/dist/database/migrations/202610020001_rel_create_tables.js +247 -0
- package/dist/database/migrations/202610020001_rel_create_tables.js.map +1 -0
- package/dist/database/migrations/202610100001_rel_variables_initial_admin.d.ts +16 -0
- package/dist/database/migrations/202610100001_rel_variables_initial_admin.d.ts.map +1 -0
- package/dist/database/migrations/202610100001_rel_variables_initial_admin.js +94 -0
- package/dist/database/migrations/202610100001_rel_variables_initial_admin.js.map +1 -0
- package/dist/database/migrations/202610170001_rel_drop_app_preview_values.d.ts +8 -0
- package/dist/database/migrations/202610170001_rel_drop_app_preview_values.d.ts.map +1 -0
- package/dist/database/migrations/202610170001_rel_drop_app_preview_values.js +25 -0
- package/dist/database/migrations/202610170001_rel_drop_app_preview_values.js.map +1 -0
- package/dist/database/migrations/202610210001_rel_protected_requires_approval.d.ts +9 -0
- package/dist/database/migrations/202610210001_rel_protected_requires_approval.d.ts.map +1 -0
- package/dist/database/migrations/202610210001_rel_protected_requires_approval.js +32 -0
- package/dist/database/migrations/202610210001_rel_protected_requires_approval.js.map +1 -0
- package/dist/package.d.ts +2 -0
- package/dist/package.d.ts.map +1 -0
- package/dist/package.js +2 -0
- package/dist/package.js.map +1 -0
- package/dist/server/access/caller.d.ts +74 -0
- package/dist/server/access/caller.d.ts.map +1 -0
- package/dist/server/access/caller.js +141 -0
- package/dist/server/access/caller.js.map +1 -0
- package/dist/server/access/key-scope-objects.d.ts +20 -0
- package/dist/server/access/key-scope-objects.d.ts.map +1 -0
- package/dist/server/access/key-scope-objects.js +42 -0
- package/dist/server/access/key-scope-objects.js.map +1 -0
- package/dist/server/composition.d.ts +52 -0
- package/dist/server/composition.d.ts.map +1 -0
- package/dist/server/composition.js +105 -0
- package/dist/server/composition.js.map +1 -0
- package/dist/server/config.d.ts +127 -0
- package/dist/server/config.d.ts.map +1 -0
- package/dist/server/config.js +2 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/drivers/host/driver.d.ts +87 -0
- package/dist/server/drivers/host/driver.d.ts.map +1 -0
- package/dist/server/drivers/host/driver.js +636 -0
- package/dist/server/drivers/host/driver.js.map +1 -0
- package/dist/server/drivers/types.d.ts +221 -0
- package/dist/server/drivers/types.d.ts.map +1 -0
- package/dist/server/drivers/types.js +27 -0
- package/dist/server/drivers/types.js.map +1 -0
- package/dist/server/errors.d.ts +20 -0
- package/dist/server/errors.d.ts.map +1 -0
- package/dist/server/errors.js +44 -0
- package/dist/server/errors.js.map +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +13 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/locales/en-US.d.ts +6 -0
- package/dist/server/locales/en-US.d.ts.map +1 -0
- package/dist/server/locales/en-US.js +4 -0
- package/dist/server/locales/en-US.js.map +1 -0
- package/dist/server/locales/index.d.ts +4 -0
- package/dist/server/locales/index.d.ts.map +1 -0
- package/dist/server/locales/index.js +6 -0
- package/dist/server/locales/index.js.map +1 -0
- package/dist/server/locales/zh-CN.d.ts +4 -0
- package/dist/server/locales/zh-CN.d.ts.map +1 -0
- package/dist/server/locales/zh-CN.js +4 -0
- package/dist/server/locales/zh-CN.js.map +1 -0
- package/dist/server/plugin.d.ts +4 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +16 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/providers/authorization.d.ts +17 -0
- package/dist/server/providers/authorization.d.ts.map +1 -0
- package/dist/server/providers/authorization.js +60 -0
- package/dist/server/providers/authorization.js.map +1 -0
- package/dist/server/providers/index.d.ts +4 -0
- package/dist/server/providers/index.d.ts.map +1 -0
- package/dist/server/providers/index.js +8 -0
- package/dist/server/providers/index.js.map +1 -0
- package/dist/server/providers/releases.d.ts +23 -0
- package/dist/server/providers/releases.d.ts.map +1 -0
- package/dist/server/providers/releases.js +219 -0
- package/dist/server/providers/releases.js.map +1 -0
- package/dist/server/routes/api.d.ts +24 -0
- package/dist/server/routes/api.d.ts.map +1 -0
- package/dist/server/routes/api.js +1610 -0
- package/dist/server/routes/api.js.map +1 -0
- package/dist/server/routes/index.d.ts +7 -0
- package/dist/server/routes/index.d.ts.map +1 -0
- package/dist/server/routes/index.js +39 -0
- package/dist/server/routes/index.js.map +1 -0
- package/dist/server/routes/schemas.d.ts +184 -0
- package/dist/server/routes/schemas.d.ts.map +1 -0
- package/dist/server/routes/schemas.js +763 -0
- package/dist/server/routes/schemas.js.map +1 -0
- package/dist/server/secrets-stores.d.ts +9 -0
- package/dist/server/secrets-stores.d.ts.map +1 -0
- package/dist/server/secrets-stores.js +80 -0
- package/dist/server/secrets-stores.js.map +1 -0
- package/dist/server/services/artifact.d.ts +30 -0
- package/dist/server/services/artifact.d.ts.map +1 -0
- package/dist/server/services/artifact.js +234 -0
- package/dist/server/services/artifact.js.map +1 -0
- package/dist/server/services/codec.d.ts +15 -0
- package/dist/server/services/codec.d.ts.map +1 -0
- package/dist/server/services/codec.js +58 -0
- package/dist/server/services/codec.js.map +1 -0
- package/dist/server/services/config-file.d.ts +26 -0
- package/dist/server/services/config-file.d.ts.map +1 -0
- package/dist/server/services/config-file.js +154 -0
- package/dist/server/services/config-file.js.map +1 -0
- package/dist/server/services/config-secrets.d.ts +23 -0
- package/dist/server/services/config-secrets.d.ts.map +1 -0
- package/dist/server/services/config-secrets.js +269 -0
- package/dist/server/services/config-secrets.js.map +1 -0
- package/dist/server/services/credentials.d.ts +44 -0
- package/dist/server/services/credentials.d.ts.map +1 -0
- package/dist/server/services/credentials.js +137 -0
- package/dist/server/services/credentials.js.map +1 -0
- package/dist/server/services/environments.d.ts +75 -0
- package/dist/server/services/environments.d.ts.map +1 -0
- package/dist/server/services/environments.js +508 -0
- package/dist/server/services/environments.js.map +1 -0
- package/dist/server/services/events.d.ts +11 -0
- package/dist/server/services/events.d.ts.map +1 -0
- package/dist/server/services/events.js +26 -0
- package/dist/server/services/events.js.map +1 -0
- package/dist/server/services/registries.d.ts +67 -0
- package/dist/server/services/registries.d.ts.map +1 -0
- package/dist/server/services/registries.js +409 -0
- package/dist/server/services/registries.js.map +1 -0
- package/dist/server/services/release-artifacts.d.ts +42 -0
- package/dist/server/services/release-artifacts.d.ts.map +1 -0
- package/dist/server/services/release-artifacts.js +165 -0
- package/dist/server/services/release-artifacts.js.map +1 -0
- package/dist/server/services/releases.d.ts +417 -0
- package/dist/server/services/releases.d.ts.map +1 -0
- package/dist/server/services/releases.js +2725 -0
- package/dist/server/services/releases.js.map +1 -0
- package/dist/server/services/requests.d.ts +54 -0
- package/dist/server/services/requests.d.ts.map +1 -0
- package/dist/server/services/requests.js +342 -0
- package/dist/server/services/requests.js.map +1 -0
- package/dist/server/services/secrets.d.ts +11 -0
- package/dist/server/services/secrets.d.ts.map +1 -0
- package/dist/server/services/secrets.js +36 -0
- package/dist/server/services/secrets.js.map +1 -0
- package/dist/server/services/validation.d.ts +21 -0
- package/dist/server/services/validation.d.ts.map +1 -0
- package/dist/server/services/validation.js +111 -0
- package/dist/server/services/validation.js.map +1 -0
- package/dist/server/services/variables.d.ts +132 -0
- package/dist/server/services/variables.d.ts.map +1 -0
- package/dist/server/services/variables.js +501 -0
- package/dist/server/services/variables.js.map +1 -0
- package/dist/server/tokens.d.ts +110 -0
- package/dist/server/tokens.d.ts.map +1 -0
- package/dist/server/tokens.js +9 -0
- package/dist/server/tokens.js.map +1 -0
- package/dist/shared/access.d.ts +212 -0
- package/dist/shared/access.d.ts.map +1 -0
- package/dist/shared/access.js +228 -0
- package/dist/shared/access.js.map +1 -0
- package/dist/shared/locales/access.en-US.d.ts +137 -0
- package/dist/shared/locales/access.en-US.d.ts.map +1 -0
- package/dist/shared/locales/access.en-US.js +133 -0
- package/dist/shared/locales/access.en-US.js.map +1 -0
- package/dist/shared/locales/access.zh-CN.d.ts +4 -0
- package/dist/shared/locales/access.zh-CN.d.ts.map +1 -0
- package/dist/shared/locales/access.zh-CN.js +127 -0
- package/dist/shared/locales/access.zh-CN.js.map +1 -0
- package/dist/shared/releases.d.ts +657 -0
- package/dist/shared/releases.d.ts.map +1 -0
- package/dist/shared/releases.js +56 -0
- package/dist/shared/releases.js.map +1 -0
- 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
|