@geekmidas/cli 10.0.0-alpha.6 → 10.0.0-alpha.60
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/README.md +225 -268
- package/bin/adjacent-tsconfig.mjs +97 -0
- package/bin/gkm.mjs +16 -2
- package/bin/owning-tsconfig-jsx-register.mjs +18 -0
- package/bin/owning-tsconfig-jsx.mjs +108 -0
- package/dist/{HostingerProvider-h3EbEp-Z.cjs → HostingerProvider-BTZd2u0f.cjs} +21 -22
- package/dist/HostingerProvider-BTZd2u0f.cjs.map +1 -0
- package/dist/{HostingerProvider-Cft7lEYr.mjs → HostingerProvider-DgzKa_Tj.mjs} +20 -21
- package/dist/HostingerProvider-DgzKa_Tj.mjs.map +1 -0
- package/dist/LegacyStateStore-CToa2hga.mjs +89 -0
- package/dist/LegacyStateStore-CToa2hga.mjs.map +1 -0
- package/dist/LegacyStateStore-CidlPT0N.cjs +89 -0
- package/dist/LegacyStateStore-CidlPT0N.cjs.map +1 -0
- package/dist/LocalStateStore-BFLiOocU.mjs +165 -0
- package/dist/LocalStateStore-BFLiOocU.mjs.map +1 -0
- package/dist/LocalStateStore-Dpuqka1N.cjs +176 -0
- package/dist/LocalStateStore-Dpuqka1N.cjs.map +1 -0
- package/dist/{openapi-D-dx9Akj.cjs → OpenApiTsGenerator-BTuUumsV.cjs} +330 -469
- package/dist/OpenApiTsGenerator-BTuUumsV.cjs.map +1 -0
- package/dist/{openapi-Bw8DWFWz.mjs → OpenApiTsGenerator-CvVr1Ilz.mjs} +295 -403
- package/dist/OpenApiTsGenerator-CvVr1Ilz.mjs.map +1 -0
- package/dist/{Route53Provider-RdpUDJQk.mjs → Route53Provider-Cvj40A8I.mjs} +12 -12
- package/dist/Route53Provider-Cvj40A8I.mjs.map +1 -0
- package/dist/{Route53Provider-di2fMM5N.cjs → Route53Provider-DVILBsvV.cjs} +20 -21
- package/dist/Route53Provider-DVILBsvV.cjs.map +1 -0
- package/dist/S3StateStore-aaGkjcxO.mjs +161 -0
- package/dist/S3StateStore-aaGkjcxO.mjs.map +1 -0
- package/dist/S3StateStore-cnm813Xn.cjs +160 -0
- package/dist/S3StateStore-cnm813Xn.cjs.map +1 -0
- package/dist/SSMStateStore-BllaJKGo.mjs +154 -0
- package/dist/SSMStateStore-BllaJKGo.mjs.map +1 -0
- package/dist/SSMStateStore-DwaGprVX.cjs +153 -0
- package/dist/SSMStateStore-DwaGprVX.cjs.map +1 -0
- package/dist/appPorts-C9z9Pzn9.mjs +223 -0
- package/dist/appPorts-C9z9Pzn9.mjs.map +1 -0
- package/dist/appPorts-Cu79hJF6.cjs +264 -0
- package/dist/appPorts-Cu79hJF6.cjs.map +1 -0
- package/dist/aws-DkHdTV-p.cjs +61 -0
- package/dist/aws-DkHdTV-p.cjs.map +1 -0
- package/dist/aws-DojzQnLd.mjs +61 -0
- package/dist/aws-DojzQnLd.mjs.map +1 -0
- package/dist/{backup-provisioner-BrsHwYG0.cjs → backup-provisioner-BmhIO_Yr.cjs} +21 -19
- package/dist/backup-provisioner-BmhIO_Yr.cjs.map +1 -0
- package/dist/{backup-provisioner-Bgj5Vi_L.mjs → backup-provisioner-DIVCbfee.mjs} +10 -7
- package/dist/backup-provisioner-DIVCbfee.mjs.map +1 -0
- package/dist/{bundler-BY1OmV1e.cjs → bundler-CzXZPSBJ.cjs} +20 -32
- package/dist/bundler-CzXZPSBJ.cjs.map +1 -0
- package/dist/{bundler-Be8Zg4Wd.mjs → bundler-DJVR4QlW.mjs} +18 -29
- package/dist/bundler-DJVR4QlW.mjs.map +1 -0
- package/dist/cli-D_qTl20Z.cjs +69 -0
- package/dist/cli-D_qTl20Z.cjs.map +1 -0
- package/dist/cli-DzR1hpum.mjs +69 -0
- package/dist/cli-DzR1hpum.mjs.map +1 -0
- package/dist/{config-CxP7P-qG.mjs → config-C5-EEdmr.mjs} +144 -127
- package/dist/config-C5-EEdmr.mjs.map +1 -0
- package/dist/{config-CNz5KaSR.cjs → config-DXnkCEq0.cjs} +212 -184
- package/dist/config-DXnkCEq0.cjs.map +1 -0
- package/dist/config.cjs +9 -6
- package/dist/config.d.cts +124 -33
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +124 -33
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +3 -5
- package/dist/deploy/public.cjs +22 -0
- package/dist/deploy/public.d.cts +459 -0
- package/dist/deploy/public.d.cts.map +1 -0
- package/dist/deploy/public.d.mts +459 -0
- package/dist/deploy/public.d.mts.map +1 -0
- package/dist/deploy/public.mjs +5 -0
- package/dist/deploy/sniffer-envkit-patch.cjs +18 -10
- package/dist/deploy/sniffer-envkit-patch.cjs.map +1 -1
- package/dist/deploy/sniffer-envkit-patch.d.cts +0 -2
- package/dist/deploy/sniffer-envkit-patch.d.cts.map +1 -1
- package/dist/deploy/sniffer-envkit-patch.d.mts +0 -2
- package/dist/deploy/sniffer-envkit-patch.d.mts.map +1 -1
- package/dist/deploy/sniffer-envkit-patch.mjs +10 -2
- package/dist/deploy/sniffer-envkit-patch.mjs.map +1 -1
- package/dist/deploy/sniffer-hooks.cjs +13 -7
- package/dist/deploy/sniffer-hooks.cjs.map +1 -1
- package/dist/deploy/sniffer-hooks.d.cts +1 -2
- package/dist/deploy/sniffer-hooks.d.cts.map +1 -1
- package/dist/deploy/sniffer-hooks.d.mts +1 -2
- package/dist/deploy/sniffer-hooks.d.mts.map +1 -1
- package/dist/deploy/sniffer-hooks.mjs +10 -3
- package/dist/deploy/sniffer-hooks.mjs.map +1 -1
- package/dist/deploy/sniffer-loader.cjs +16 -7
- package/dist/deploy/sniffer-loader.cjs.map +1 -1
- package/dist/deploy/sniffer-loader.d.cts +1 -1
- package/dist/deploy/sniffer-loader.d.mts +1 -1
- package/dist/deploy/sniffer-loader.mjs +14 -3
- package/dist/deploy/sniffer-loader.mjs.map +1 -1
- package/dist/deploy/sniffer-routes-worker.cjs +11 -11
- package/dist/deploy/sniffer-routes-worker.cjs.map +1 -1
- package/dist/deploy/sniffer-routes-worker.d.cts +1 -1
- package/dist/deploy/sniffer-routes-worker.d.mts +1 -1
- package/dist/deploy/sniffer-routes-worker.mjs +3 -3
- package/dist/deploy/sniffer-routes-worker.mjs.map +1 -1
- package/dist/deploy/sniffer-worker.cjs +15 -6
- package/dist/deploy/sniffer-worker.cjs.map +1 -1
- package/dist/deploy/sniffer-worker.d.cts +1 -3
- package/dist/deploy/sniffer-worker.d.cts.map +1 -1
- package/dist/deploy/sniffer-worker.d.mts +1 -3
- package/dist/deploy/sniffer-worker.d.mts.map +1 -1
- package/dist/deploy/sniffer-worker.mjs +15 -4
- package/dist/deploy/sniffer-worker.mjs.map +1 -1
- package/dist/deploy-0fBVeWO-.cjs +3518 -0
- package/dist/deploy-0fBVeWO-.cjs.map +1 -0
- package/dist/deploy-D6Q09OkA.mjs +3429 -0
- package/dist/deploy-D6Q09OkA.mjs.map +1 -0
- package/dist/{discover-DTmFeloL.cjs → discover-Bc-4eA_p.cjs} +131 -103
- package/dist/discover-Bc-4eA_p.cjs.map +1 -0
- package/dist/{discover-CAlFgYec.mjs → discover-FrYvzkeC.mjs} +91 -58
- package/dist/discover-FrYvzkeC.mjs.map +1 -0
- package/dist/{emit-CiZS_5Ub.mjs → emit-C1e8qD08.mjs} +13 -26
- package/dist/emit-C1e8qD08.mjs.map +1 -0
- package/dist/{emit-CbcRolsa.cjs → emit-Dmc1nvdS.cjs} +31 -57
- package/dist/emit-Dmc1nvdS.cjs.map +1 -0
- package/dist/encryption-CML4UaPi.cjs +91 -0
- package/dist/encryption-CML4UaPi.cjs.map +1 -0
- package/dist/encryption-aDA2AzVx.mjs +90 -0
- package/dist/encryption-aDA2AzVx.mjs.map +1 -0
- package/dist/fakes-C9RiT6XG.mjs +125 -0
- package/dist/fakes-C9RiT6XG.mjs.map +1 -0
- package/dist/fakes-zW-jNAbQ.cjs +148 -0
- package/dist/fakes-zW-jNAbQ.cjs.map +1 -0
- package/dist/github-Cq57poPj.cjs +213 -0
- package/dist/github-Cq57poPj.cjs.map +1 -0
- package/dist/github-DmCj2u95.mjs +213 -0
- package/dist/github-DmCj2u95.mjs.map +1 -0
- package/dist/home-DOzBaWaL.cjs +33 -0
- package/dist/home-DOzBaWaL.cjs.map +1 -0
- package/dist/home-GI7pJl1M.mjs +22 -0
- package/dist/home-GI7pJl1M.mjs.map +1 -0
- package/dist/{index-DEbaleny.d.mts → index-Da1-oa9W.d.mts} +4 -23
- package/dist/index-Da1-oa9W.d.mts.map +1 -0
- package/dist/{index-BGGdjueF.d.cts → index-UiWCaGzU.d.cts} +4 -23
- package/dist/index-UiWCaGzU.d.cts.map +1 -0
- package/dist/index.cjs +9096 -12213
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +9067 -12192
- package/dist/index.mjs.map +1 -1
- package/dist/migrate-BTqIflwc.cjs +743 -0
- package/dist/migrate-BTqIflwc.cjs.map +1 -0
- package/dist/migrate-B_6KofUo.mjs +736 -0
- package/dist/migrate-B_6KofUo.mjs.map +1 -0
- package/dist/openapi.cjs +198 -10
- package/dist/openapi.cjs.map +1 -0
- package/dist/openapi.d.cts +21 -19
- package/dist/openapi.d.cts.map +1 -1
- package/dist/openapi.d.mts +21 -19
- package/dist/openapi.d.mts.map +1 -1
- package/dist/openapi.mjs +193 -5
- package/dist/openapi.mjs.map +1 -0
- package/dist/ready-CCLIAkwR.cjs +807 -0
- package/dist/ready-CCLIAkwR.cjs.map +1 -0
- package/dist/ready-CIHpnUc5.mjs +760 -0
- package/dist/ready-CIHpnUc5.mjs.map +1 -0
- package/dist/reconcile/public.cjs +3 -5
- package/dist/reconcile/public.d.cts +30 -33
- package/dist/reconcile/public.d.cts.map +1 -1
- package/dist/reconcile/public.d.mts +30 -33
- package/dist/reconcile/public.d.mts.map +1 -1
- package/dist/reconcile/public.mjs +3 -4
- package/dist/{reconcile-CFs8C_1a.mjs → reconcile--I7V4Klj.mjs} +4 -7
- package/dist/reconcile--I7V4Klj.mjs.map +1 -0
- package/dist/{reconcile-D0pimNiy.cjs → reconcile-CuCQBa5-.cjs} +5 -8
- package/dist/reconcile-CuCQBa5-.cjs.map +1 -0
- package/dist/rolldown-runtime-BhDjJH2R.mjs +15 -0
- package/dist/{chunk-CUT6urMc.cjs → rolldown-runtime-C0BPl7ul.cjs} +23 -10
- package/dist/sniffer-BM8fpCas.mjs +6321 -0
- package/dist/sniffer-BM8fpCas.mjs.map +1 -0
- package/dist/sniffer-DIv12ncU.cjs +6707 -0
- package/dist/sniffer-DIv12ncU.cjs.map +1 -0
- package/dist/storage-BzaUxmqe.mjs +124 -0
- package/dist/storage-BzaUxmqe.mjs.map +1 -0
- package/dist/storage-CSGoC3bK.cjs +152 -0
- package/dist/storage-CSGoC3bK.cjs.map +1 -0
- package/dist/store-BS8A7NcA.mjs +415 -0
- package/dist/store-BS8A7NcA.mjs.map +1 -0
- package/dist/store-KvNQwYaB.cjs +510 -0
- package/dist/store-KvNQwYaB.cjs.map +1 -0
- package/dist/types-Bp1mVxG0.d.mts +6410 -0
- package/dist/types-Bp1mVxG0.d.mts.map +1 -0
- package/dist/types-efNLu9xu.d.cts +6411 -0
- package/dist/types-efNLu9xu.d.cts.map +1 -0
- package/dist/vitest.cjs +65 -0
- package/dist/vitest.cjs.map +1 -0
- package/dist/vitest.d.cts +29 -0
- package/dist/vitest.d.cts.map +1 -0
- package/dist/vitest.d.mts +29 -0
- package/dist/vitest.d.mts.map +1 -0
- package/dist/vitest.mjs +65 -0
- package/dist/vitest.mjs.map +1 -0
- package/dist/workspace/index.cjs +3 -4
- package/dist/workspace/index.d.cts +3 -3
- package/dist/workspace/index.d.mts +3 -3
- package/dist/workspace/index.mjs +2 -3
- package/dist/{workspace-LKiNyaDy.cjs → workspace-B64Xk1Mb.cjs} +1070 -1118
- package/dist/workspace-B64Xk1Mb.cjs.map +1 -0
- package/dist/{workspace-DIuZx-cc.mjs → workspace-Bp1xVaOz.mjs} +901 -972
- package/dist/workspace-Bp1xVaOz.mjs.map +1 -0
- package/package.json +38 -20
- package/dist/CachedStateProvider-BLo7zv7S.cjs +0 -3
- package/dist/CachedStateProvider-BxoAm-B2.mjs +0 -3
- package/dist/CachedStateProvider-CqPdW7CB.cjs +0 -60
- package/dist/CachedStateProvider-CqPdW7CB.cjs.map +0 -1
- package/dist/CachedStateProvider-DTniehMP.mjs +0 -54
- package/dist/CachedStateProvider-DTniehMP.mjs.map +0 -1
- package/dist/HostingerProvider-Cft7lEYr.mjs.map +0 -1
- package/dist/HostingerProvider-h3EbEp-Z.cjs.map +0 -1
- package/dist/LocalStateProvider-Bn6tS-oL.mjs +0 -42
- package/dist/LocalStateProvider-Bn6tS-oL.mjs.map +0 -1
- package/dist/LocalStateProvider-Db9SEjyH.cjs +0 -43
- package/dist/LocalStateProvider-Db9SEjyH.cjs.map +0 -1
- package/dist/Route53Provider-RdpUDJQk.mjs.map +0 -1
- package/dist/Route53Provider-di2fMM5N.cjs.map +0 -1
- package/dist/SSMStateProvider-BRt2py-A.mjs +0 -66
- package/dist/SSMStateProvider-BRt2py-A.mjs.map +0 -1
- package/dist/SSMStateProvider-DyX8s9HI.cjs +0 -66
- package/dist/SSMStateProvider-DyX8s9HI.cjs.map +0 -1
- package/dist/backup-provisioner-Bgj5Vi_L.mjs.map +0 -1
- package/dist/backup-provisioner-BrsHwYG0.cjs.map +0 -1
- package/dist/bundler-BY1OmV1e.cjs.map +0 -1
- package/dist/bundler-Be8Zg4Wd.mjs.map +0 -1
- package/dist/chunk-Duj1WY3L.mjs +0 -7
- package/dist/config-CNz5KaSR.cjs.map +0 -1
- package/dist/config-CxP7P-qG.mjs.map +0 -1
- package/dist/credentials-B5zRHdAl.mjs +0 -141
- package/dist/credentials-B5zRHdAl.mjs.map +0 -1
- package/dist/credentials-BK3Z93MX.mjs +0 -2548
- package/dist/credentials-BK3Z93MX.mjs.map +0 -1
- package/dist/credentials-BapzjyV5.cjs +0 -195
- package/dist/credentials-BapzjyV5.cjs.map +0 -1
- package/dist/credentials-DzsXZzkx.cjs +0 -2680
- package/dist/credentials-DzsXZzkx.cjs.map +0 -1
- package/dist/discover-CAlFgYec.mjs.map +0 -1
- package/dist/discover-DTmFeloL.cjs.map +0 -1
- package/dist/dokploy-api-BS2xnJLZ.mjs +0 -3
- package/dist/dokploy-api-BsVH0y_s.cjs +0 -718
- package/dist/dokploy-api-BsVH0y_s.cjs.map +0 -1
- package/dist/dokploy-api-BuxDRo3l.cjs +0 -3
- package/dist/dokploy-api-aTFPz7_4.mjs +0 -706
- package/dist/dokploy-api-aTFPz7_4.mjs.map +0 -1
- package/dist/emit-CbcRolsa.cjs.map +0 -1
- package/dist/emit-CiZS_5Ub.mjs.map +0 -1
- package/dist/encryption-C6jaU0hE.cjs +0 -54
- package/dist/encryption-C6jaU0hE.cjs.map +0 -1
- package/dist/encryption-CHzzeR8a.cjs +0 -4
- package/dist/encryption-CQUncu7v.mjs +0 -3
- package/dist/encryption-D-abyhZx.mjs +0 -42
- package/dist/encryption-D-abyhZx.mjs.map +0 -1
- package/dist/fullstack-secrets-C-GGohke.cjs +0 -111
- package/dist/fullstack-secrets-C-GGohke.cjs.map +0 -1
- package/dist/fullstack-secrets-D1o9-G5l.mjs +0 -87
- package/dist/fullstack-secrets-D1o9-G5l.mjs.map +0 -1
- package/dist/generator-CNzBw7g5.cjs +0 -289
- package/dist/generator-CNzBw7g5.cjs.map +0 -1
- package/dist/generator-VE2JrFlY.mjs +0 -253
- package/dist/generator-VE2JrFlY.mjs.map +0 -1
- package/dist/index-BGGdjueF.d.cts.map +0 -1
- package/dist/index-DEbaleny.d.mts.map +0 -1
- package/dist/openapi-Bw8DWFWz.mjs.map +0 -1
- package/dist/openapi-D-dx9Akj.cjs.map +0 -1
- package/dist/openapi-react-query-Byltvm_7.mjs +0 -167
- package/dist/openapi-react-query-Byltvm_7.mjs.map +0 -1
- package/dist/openapi-react-query-CmJ1xDbn.cjs +0 -173
- package/dist/openapi-react-query-CmJ1xDbn.cjs.map +0 -1
- package/dist/openapi-react-query.cjs +0 -4
- package/dist/openapi-react-query.d.cts +0 -11
- package/dist/openapi-react-query.d.cts.map +0 -1
- package/dist/openapi-react-query.d.mts +0 -11
- package/dist/openapi-react-query.d.mts.map +0 -1
- package/dist/openapi-react-query.mjs +0 -4
- package/dist/reconcile-CFs8C_1a.mjs.map +0 -1
- package/dist/reconcile-D0pimNiy.cjs.map +0 -1
- package/dist/setup-8IWPsRIN.cjs +0 -11
- package/dist/setup-BWMhbW-p.mjs +0 -443
- package/dist/setup-BWMhbW-p.mjs.map +0 -1
- package/dist/setup-D7L4BgBT.cjs +0 -473
- package/dist/setup-D7L4BgBT.cjs.map +0 -1
- package/dist/setup-DGIPd2fh.mjs +0 -11
- package/dist/storage-BJmzYxt8.cjs +0 -363
- package/dist/storage-BJmzYxt8.cjs.map +0 -1
- package/dist/storage-BM9hGpgv.cjs +0 -7
- package/dist/storage-Bi24WfoW.mjs +0 -297
- package/dist/storage-Bi24WfoW.mjs.map +0 -1
- package/dist/storage-CYlGwWTj.mjs +0 -3
- package/dist/sync-CmEuNZsB.mjs +0 -76
- package/dist/sync-CmEuNZsB.mjs.map +0 -1
- package/dist/sync-D306yOWu.cjs +0 -4
- package/dist/sync-SizaChmC.cjs +0 -93
- package/dist/sync-SizaChmC.cjs.map +0 -1
- package/dist/sync-hOqAWHR6.mjs +0 -3
- package/dist/types-BLSNdrk0.d.cts +0 -2176
- package/dist/types-BLSNdrk0.d.cts.map +0 -1
- package/dist/types-C3POXhw3.d.mts +0 -2176
- package/dist/types-C3POXhw3.d.mts.map +0 -1
- package/dist/workspace-CGaUAw5A.cjs +0 -9
- package/dist/workspace-CnTqIlB3.mjs +0 -8
- package/dist/workspace-DIuZx-cc.mjs.map +0 -1
- package/dist/workspace-LKiNyaDy.cjs.map +0 -1
|
@@ -0,0 +1,3429 @@
|
|
|
1
|
+
import { B as providerOf, C as run, D as constructGlobs, L as resourceName, N as applyPostgres, R as cacheBackendFor, S as keyFingerprint, V as storageBackendFor, a as dockerCommand, b as encryptSecrets, c as resolveHost, ct as storeDokployCredentials, f as StateStoreBusy, g as createStateStore, i as DockerPushFailed, lt as canPrompt, m as StateVersionConflict, o as validateImageRef, r as DockerBuildFailed, s as isMainFrontendApp, st as readCredentials, t as sniffAllApps, tt as validateDokployToken, ut as prompt, v as DokployApi, w as runOutput, y as DokployApiError, z as eventsBackendFor } from "./sniffer-BM8fpCas.mjs";
|
|
2
|
+
import { n as gkmHome } from "./home-GI7pJl1M.mjs";
|
|
3
|
+
import { a as output, o as withOutput, t as discover } from "./discover-FrYvzkeC.mjs";
|
|
4
|
+
import { f as appKey, l as loadWorkspaceConfig, p as derivedApps } from "./config-C5-EEdmr.mjs";
|
|
5
|
+
import { D as assertDeployedStage, a as getAppBuildOrder, b as getDeployTargetError, m as stripPublicPrefix, p as getPublicEnvPrefix, x as isDeployTargetSupported } from "./workspace-Bp1xVaOz.mjs";
|
|
6
|
+
import { i as toEmbeddableSecrets, t as initStageSecrets } from "./storage-BzaUxmqe.mjs";
|
|
7
|
+
import { d as imageName, f as imageRef, g as projectName, h as ownershipMarker, l as applicationName, m as markerOf, n as secretsStoreFor, p as isOwnedBy, u as deployIdentity } from "./store-BS8A7NcA.mjs";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { cacheTable, cloudName, cookieDomain, dependentsOf, externalApiUrl, mobileOrigins, provideKey, provisionOrder, schemeBase } from "@geekmidas/manifest";
|
|
12
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
13
|
+
import { generateKeyring } from "@geekmidas/constructs/encryption";
|
|
14
|
+
import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
|
|
15
|
+
import { Client } from "pg";
|
|
16
|
+
import { cacheTableStatements } from "@geekmidas/cache/postgres";
|
|
17
|
+
import { lookup } from "node:dns/promises";
|
|
18
|
+
//#region src/deploy/credentials.ts
|
|
19
|
+
/**
|
|
20
|
+
* How a deploy gets the credentials it acts with.
|
|
21
|
+
*
|
|
22
|
+
* `deploy()` never prompts and never reads a terminal: it asks a
|
|
23
|
+
* `CredentialProvider` and, when the provider has nothing, stops with
|
|
24
|
+
* `MissingCredential` naming what it needed and how to supply it. Whether to
|
|
25
|
+
* ask a person, read a vault or fail is the caller's decision — `gkm deploy`
|
|
26
|
+
* prompts at a terminal and stores what was typed; a CI job sets the
|
|
27
|
+
* environment; a host passes its own provider.
|
|
28
|
+
*/
|
|
29
|
+
/** How to supply each kind, for the error that says it is missing. */
|
|
30
|
+
const HOW_TO_PROVIDE = {
|
|
31
|
+
dokploy: "Set DOKPLOY_API_TOKEN (and DOKPLOY_ENDPOINT, or deploy.dokploy.endpoint in gkm.config.ts), run `gkm login --provider dokploy`, or pass them through the deploy's CredentialProvider.",
|
|
32
|
+
registry: "Add the registry in Dokploy (Settings → Docker Registry) and set deploy.dokploy.registryId, set DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_PASSWORD, or run `gkm deploy` at a terminal to be asked for them."
|
|
33
|
+
};
|
|
34
|
+
/** A deploy needed a credential, and its provider had none. */
|
|
35
|
+
var MissingCredential = class extends Error {
|
|
36
|
+
kind;
|
|
37
|
+
target;
|
|
38
|
+
howToProvide;
|
|
39
|
+
constructor(kind, target, howToProvide = HOW_TO_PROVIDE[kind]) {
|
|
40
|
+
const what = kind === "dokploy" ? `No Dokploy credentials${target ? ` for ${target}` : ""}.` : `Dokploy has no registry for ${target ?? "the configured registry"}, and there are no credentials to create one with.`;
|
|
41
|
+
super(`${what} ${howToProvide}`);
|
|
42
|
+
this.kind = kind;
|
|
43
|
+
this.target = target;
|
|
44
|
+
this.howToProvide = howToProvide;
|
|
45
|
+
this.name = "MissingCredential";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
/** Whatever was asked for, from the first provider that has it. */
|
|
49
|
+
function chainCredentials(...providers) {
|
|
50
|
+
return { async get(request, options) {
|
|
51
|
+
for (const provider of providers) {
|
|
52
|
+
const found = await provider.get(request, options);
|
|
53
|
+
if (found) return found;
|
|
54
|
+
}
|
|
55
|
+
} };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Credentials from the environment, then from what `gkm login` stored. What
|
|
59
|
+
* `deploy()` uses when it is handed no provider.
|
|
60
|
+
*
|
|
61
|
+
* - Dokploy: `DOKPLOY_API_TOKEN` / `DOKPLOY_ENDPOINT`, each falling back to
|
|
62
|
+
* the stored login, and the endpoint finally to `deploy.dokploy.endpoint`.
|
|
63
|
+
* Each half is read on its own: a token is a secret and belongs in the
|
|
64
|
+
* environment, an endpoint is configuration and belongs in the config.
|
|
65
|
+
* - Registry: `DOCKER_REGISTRY_USERNAME` / `DOCKER_REGISTRY_PASSWORD`.
|
|
66
|
+
*/
|
|
67
|
+
function storedCredentials(options = {}) {
|
|
68
|
+
const env = options.env ?? process.env;
|
|
69
|
+
return { async get(asked) {
|
|
70
|
+
const request = asked;
|
|
71
|
+
switch (request.kind) {
|
|
72
|
+
case "dokploy": {
|
|
73
|
+
const stored = (await readCredentials(options.home ? { home: options.home } : void 0)).dokploy;
|
|
74
|
+
const token = env.DOKPLOY_API_TOKEN ?? stored?.token;
|
|
75
|
+
const endpoint = env.DOKPLOY_ENDPOINT ?? stored?.endpoint ?? request.endpoint;
|
|
76
|
+
if (!token || !endpoint) return void 0;
|
|
77
|
+
return {
|
|
78
|
+
endpoint: endpoint.replace(/\/$/, ""),
|
|
79
|
+
token
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
case "registry": {
|
|
83
|
+
const username = env.DOCKER_REGISTRY_USERNAME;
|
|
84
|
+
const password = env.DOCKER_REGISTRY_PASSWORD;
|
|
85
|
+
if (!username || !password) return void 0;
|
|
86
|
+
return {
|
|
87
|
+
username,
|
|
88
|
+
password
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} };
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/deploy/events.ts
|
|
96
|
+
/** An error as an event carries it: its name and message, nothing else. */
|
|
97
|
+
function eventError(error) {
|
|
98
|
+
if (error instanceof Error) return {
|
|
99
|
+
name: error.name,
|
|
100
|
+
message: error.message
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
name: "Error",
|
|
104
|
+
message: String(error)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/deploy/fromManifest.ts
|
|
109
|
+
/**
|
|
110
|
+
* The Dokploy target, read off the construct manifest.
|
|
111
|
+
*
|
|
112
|
+
* The AWS target has done this since `fromManifest` landed: a table keyed by
|
|
113
|
+
* declaration kind, each entry turning one declaration into infrastructure and
|
|
114
|
+
* the env keys it resolves. Dokploy had no such thing — it resolved environment
|
|
115
|
+
* from the *sniffer*, which walks application code for `get('X')` calls, and a
|
|
116
|
+
* construct reads its own key inside `@geekmidas/constructs` where there is
|
|
117
|
+
* nothing to find. So a declared app deployed here got none of the model, and
|
|
118
|
+
* nothing reported the absence: the deploy succeeded and the first request that
|
|
119
|
+
* needed a URL failed at runtime.
|
|
120
|
+
*
|
|
121
|
+
* This is the same table, over the REST wrapper instead of over Pulumi.
|
|
122
|
+
*
|
|
123
|
+
* **Two phases, and the split is not incidental.** Creating a Postgres and
|
|
124
|
+
* creating a role inside it are different kinds of act: the first is an API
|
|
125
|
+
* call, the second needs a connection to a database that does not exist yet
|
|
126
|
+
* when the call is made. So provisioners *accumulate* DDL rather than running
|
|
127
|
+
* it, and it is applied once against a cluster that is up — which is exactly
|
|
128
|
+
* what `DatabaseBootstrap` does on AWS, for exactly the same reason.
|
|
129
|
+
*
|
|
130
|
+
* **The DDL itself is shared.** `roleStatements` and `cacheTableStatements` are
|
|
131
|
+
* the same generators the local target and the AWS target use. Dokploy used to
|
|
132
|
+
* write its own `DO $$` block, which meant three targets applying three
|
|
133
|
+
* different definitions of "the role split" and no way to notice they had
|
|
134
|
+
* drifted.
|
|
135
|
+
*
|
|
136
|
+
* Entries are deliberately small and free of I/O ordering: each takes what it
|
|
137
|
+
* needs and returns what it resolved. That is what would let a Pulumi dynamic
|
|
138
|
+
* provider wrap one later without the table itself changing — see §6b.
|
|
139
|
+
*/
|
|
140
|
+
/**
|
|
141
|
+
* The internal hostname a Dokploy Postgres answers on.
|
|
142
|
+
*
|
|
143
|
+
* Its own service name on the Docker network — not the server's public address.
|
|
144
|
+
* An app reaching its database over the public internet would be a credential
|
|
145
|
+
* crossing a network that did not need to see it.
|
|
146
|
+
*/
|
|
147
|
+
function clusterHost(postgres) {
|
|
148
|
+
return postgres.appName;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* What a resource is called in the provider.
|
|
152
|
+
*
|
|
153
|
+
* `cloudName` and nothing else — the same scoped kebab rule the AWS target
|
|
154
|
+
* uses, so a name reads identically on both: `production-shop-orders`.
|
|
155
|
+
*
|
|
156
|
+
* It briefly carried a `-postgres` suffix on the theory that a Dokploy project
|
|
157
|
+
* is one flat list where an application and its database would collide. It is
|
|
158
|
+
* not: `project.one` returns typed collections — `applications`, `postgres`,
|
|
159
|
+
* `redis`, `compose`, `mariadb`, `mongo`, `mysql` — so the kind is already in
|
|
160
|
+
* the shape of the response and an application and a Postgres of the same name
|
|
161
|
+
* are different objects with different ids. The suffix was answering a question
|
|
162
|
+
* nobody had asked.
|
|
163
|
+
*/
|
|
164
|
+
function serviceName(scope, id) {
|
|
165
|
+
return cloudName(scope, id);
|
|
166
|
+
}
|
|
167
|
+
const PROVISIONERS = {
|
|
168
|
+
/**
|
|
169
|
+
* A Postgres service, plus the roles that reach it.
|
|
170
|
+
*
|
|
171
|
+
* The service is `findOrCreate`, because a deploy that recreated the
|
|
172
|
+
* database would be a deploy that lost the data. The roles are deferred:
|
|
173
|
+
* they need a connection to a cluster this call has only just asked for.
|
|
174
|
+
*/
|
|
175
|
+
database: async (declaration, context) => {
|
|
176
|
+
if (declaration.kind !== "database") throw new WrongKind(declaration.kind);
|
|
177
|
+
const name = resourceName(declaration.id, declaration.kind, context.stage);
|
|
178
|
+
const { postgres } = await context.api.findOrCreatePostgres(serviceName({
|
|
179
|
+
stage: context.stage,
|
|
180
|
+
app: context.scope ?? context.project
|
|
181
|
+
}, declaration.id), context.projectId, context.environmentId, {
|
|
182
|
+
databaseName: name,
|
|
183
|
+
databasePassword: derivedPassword(context, `${name}:master`)
|
|
184
|
+
});
|
|
185
|
+
const host = clusterHost(postgres);
|
|
186
|
+
context.clusters[postgres.databaseName] = postgres;
|
|
187
|
+
const schema = declaration.schema ?? "app";
|
|
188
|
+
const runtime = name;
|
|
189
|
+
const owner = ownerRole(runtime);
|
|
190
|
+
const reads = readsFrom(context.manifest, declaration.id);
|
|
191
|
+
context.deferred.push(...statementsFor({
|
|
192
|
+
id: declaration.id,
|
|
193
|
+
database: postgres.databaseName,
|
|
194
|
+
ownsDatabase: true,
|
|
195
|
+
runtime,
|
|
196
|
+
owner,
|
|
197
|
+
...reads ? { reader: readerRole(runtime) } : {},
|
|
198
|
+
schema,
|
|
199
|
+
context
|
|
200
|
+
}));
|
|
201
|
+
return { provides: {
|
|
202
|
+
[provideKey(declaration.id, "url")]: postgresUrl(runtime, derivedPassword(context, runtime), host, postgres.databaseName),
|
|
203
|
+
[provideKey(declaration.id, "ownerUrl")]: postgresUrl(owner, derivedPassword(context, owner), host, postgres.databaseName)
|
|
204
|
+
} };
|
|
205
|
+
},
|
|
206
|
+
/**
|
|
207
|
+
* A schema in the parent's database, with its own role and its own URL — so
|
|
208
|
+
* the parent's role holds no grant on these tables at all.
|
|
209
|
+
*/
|
|
210
|
+
"database-schema": async (declaration, context) => {
|
|
211
|
+
if (declaration.kind !== "database-schema") throw new WrongKind(declaration.kind);
|
|
212
|
+
const parent = context.provisioned[declaration.of];
|
|
213
|
+
if (!parent) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
214
|
+
const runtime = resourceName(declaration.id, declaration.kind, context.stage);
|
|
215
|
+
const owner = ownerRole(runtime);
|
|
216
|
+
const reads = readsFrom(context.manifest, declaration.id);
|
|
217
|
+
const parentUrl = new URL(parent.provides[provideKey(declaration.of, "url")] ?? "");
|
|
218
|
+
context.deferred.push(...statementsFor({
|
|
219
|
+
id: declaration.id,
|
|
220
|
+
database: parentUrl.pathname.slice(1),
|
|
221
|
+
runtime,
|
|
222
|
+
owner,
|
|
223
|
+
...reads ? { reader: readerRole(runtime) } : {},
|
|
224
|
+
schema: declaration.schema,
|
|
225
|
+
context
|
|
226
|
+
}));
|
|
227
|
+
return { provides: {
|
|
228
|
+
[provideKey(declaration.id, "url")]: postgresUrl(runtime, derivedPassword(context, runtime), parentUrl.hostname, parentUrl.pathname.slice(1)),
|
|
229
|
+
[provideKey(declaration.id, "ownerUrl")]: postgresUrl(owner, derivedPassword(context, owner), parentUrl.hostname, parentUrl.pathname.slice(1))
|
|
230
|
+
} };
|
|
231
|
+
},
|
|
232
|
+
/**
|
|
233
|
+
* A read-only endpoint on the same cluster.
|
|
234
|
+
*
|
|
235
|
+
* Nothing is provisioned: a Dokploy Postgres has one endpoint, so this
|
|
236
|
+
* resolves to the writer's address — and that is safe rather than a
|
|
237
|
+
* loophole, because read-only is enforced by the reader role's grants and
|
|
238
|
+
* not by which host the URL names. The role itself was created with its
|
|
239
|
+
* parent.
|
|
240
|
+
*/
|
|
241
|
+
"database-reader": async (declaration, context) => {
|
|
242
|
+
if (declaration.kind !== "database-reader") throw new WrongKind(declaration.kind);
|
|
243
|
+
const parent = context.provisioned[declaration.of];
|
|
244
|
+
if (!parent) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
245
|
+
const parentUrl = new URL(parent.provides[provideKey(declaration.of, "url")] ?? "");
|
|
246
|
+
const reader = readerRole(parentUrl.username);
|
|
247
|
+
return { provides: { [provideKey(declaration.id, "url")]: postgresUrl(reader, derivedPassword(context, reader), parentUrl.hostname, parentUrl.pathname.slice(1)) } };
|
|
248
|
+
},
|
|
249
|
+
/**
|
|
250
|
+
* A cache, wherever the project said it lives.
|
|
251
|
+
*
|
|
252
|
+
* `db` is a table in a database the manifest already declares, so it
|
|
253
|
+
* provisions no service and costs nothing to run — the same relationship
|
|
254
|
+
* pg-boss has. The table's DDL is deferred with the roles, and the table
|
|
255
|
+
* name travels in the URL because two caches in one database resolve the
|
|
256
|
+
* same connection string.
|
|
257
|
+
*/
|
|
258
|
+
cache: async (declaration, context) => {
|
|
259
|
+
if (declaration.kind !== "cache") throw new WrongKind(declaration.kind);
|
|
260
|
+
const parentId = declaration.of ?? (context.cache === "db" ? soleDatabase(context) : void 0);
|
|
261
|
+
if (!parentId) throw new CacheNeedsAHome(declaration.id, context.cache);
|
|
262
|
+
const parent = context.provisioned[parentId];
|
|
263
|
+
if (!parent) throw new UnresolvedParent(declaration.id, parentId);
|
|
264
|
+
const parentUrl = parent.provides[provideKey(parentId, "url")];
|
|
265
|
+
if (!parentUrl) throw new UnresolvedParent(declaration.id, parentId);
|
|
266
|
+
const table = declaration.table ?? cacheTable(declaration.id);
|
|
267
|
+
const schema = schemaOf(context.manifest, parentId);
|
|
268
|
+
const qualified = schema ? `${schema}.${table}` : table;
|
|
269
|
+
context.deferred.push(...cacheTableStatements({ table: qualified }).map((statement) => ({
|
|
270
|
+
id: declaration.id,
|
|
271
|
+
database: new URL(parentUrl).pathname.slice(1),
|
|
272
|
+
describe: statement.describe,
|
|
273
|
+
sql: statement.sql,
|
|
274
|
+
...statement.exists ? { exists: statement.exists } : {}
|
|
275
|
+
})), {
|
|
276
|
+
id: declaration.id,
|
|
277
|
+
database: new URL(parentUrl).pathname.slice(1),
|
|
278
|
+
describe: `${qualified} is owned by its schema's owner`,
|
|
279
|
+
sql: `ALTER TABLE ${quoted(qualified)} OWNER TO ${quoted(ownerRole(new URL(parentUrl).username))}`
|
|
280
|
+
}, {
|
|
281
|
+
id: declaration.id,
|
|
282
|
+
database: new URL(parentUrl).pathname.slice(1),
|
|
283
|
+
describe: `${new URL(parentUrl).username} may read and write ${qualified}`,
|
|
284
|
+
sql: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${quoted(qualified)} TO ${quoted(new URL(parentUrl).username)}`
|
|
285
|
+
});
|
|
286
|
+
const url = new URL(parentUrl);
|
|
287
|
+
url.searchParams.set("table", table);
|
|
288
|
+
return { provides: { [provideKey(declaration.id, "url")]: url.toString() } };
|
|
289
|
+
},
|
|
290
|
+
/**
|
|
291
|
+
* A bucket, as a MinIO stack on the box.
|
|
292
|
+
*
|
|
293
|
+
* Dokploy has first-class primitives for Postgres and Redis and none for
|
|
294
|
+
* object storage, so this is the one kind whose infrastructure this target
|
|
295
|
+
* *writes* rather than configures — a compose file with one service in it.
|
|
296
|
+
*
|
|
297
|
+
* The service is named `cloudName(...)` like everything else, which is not
|
|
298
|
+
* cosmetic here: containers on `dokploy-network` resolve each other by
|
|
299
|
+
* service name, so the name is the address. Calling it `minio` would work
|
|
300
|
+
* for exactly one project on the box and then collide.
|
|
301
|
+
*
|
|
302
|
+
* `s3://` carries no credentials, deliberately — deployed on AWS the SDK
|
|
303
|
+
* reads them from an execution role, so a URL that embedded a key would be
|
|
304
|
+
* one more thing to rotate and leak. There is no role here, so the same
|
|
305
|
+
* chain reads `AWS_ACCESS_KEY_ID` beside the URL, exactly as it does
|
|
306
|
+
* locally.
|
|
307
|
+
*/
|
|
308
|
+
objects: async (declaration, context) => {
|
|
309
|
+
if (declaration.kind !== "objects") throw new WrongKind(declaration.kind);
|
|
310
|
+
const backend = context.storage ?? "minio";
|
|
311
|
+
if (backend !== "minio") throw new UnprovisionableBucket(declaration.id, backend);
|
|
312
|
+
const service = serviceName({
|
|
313
|
+
stage: context.stage,
|
|
314
|
+
app: context.scope ?? context.project
|
|
315
|
+
}, declaration.id);
|
|
316
|
+
const bucket = resourceName(declaration.id, declaration.kind, context.stage);
|
|
317
|
+
const user = `${bucket}-root`;
|
|
318
|
+
const password = derivedPassword(context, `objects:${declaration.id}`);
|
|
319
|
+
const { compose } = await context.api.findOrCreateCompose(service, context.projectId, context.environmentId, minioCompose({
|
|
320
|
+
service,
|
|
321
|
+
bucket,
|
|
322
|
+
user,
|
|
323
|
+
password
|
|
324
|
+
}));
|
|
325
|
+
await context.api.deployCompose(compose.composeId);
|
|
326
|
+
return { provides: {
|
|
327
|
+
[provideKey(declaration.id, "url")]: s3Url(service, bucket),
|
|
328
|
+
AWS_ACCESS_KEY_ID: user,
|
|
329
|
+
AWS_SECRET_ACCESS_KEY: password,
|
|
330
|
+
AWS_REGION: MINIO_REGION
|
|
331
|
+
} };
|
|
332
|
+
},
|
|
333
|
+
/**
|
|
334
|
+
* The address a bucket's objects are served on.
|
|
335
|
+
*
|
|
336
|
+
* Nothing is provisioned: Dokploy runs Traefik, so a second reverse proxy
|
|
337
|
+
* behind the first would terminate TLS twice — which is why the local
|
|
338
|
+
* target's Caddy has no equivalent here. What this resolves is the address,
|
|
339
|
+
* and the `open` prefixes are applied to the bucket rather than to an edge,
|
|
340
|
+
* so a policy is what makes them public wherever the bucket lives.
|
|
341
|
+
*
|
|
342
|
+
* Until a Traefik rule exists it answers on MinIO directly, path-style,
|
|
343
|
+
* which is honest about the shape it has today: the bucket is in the path
|
|
344
|
+
* rather than fronted by a host of its own.
|
|
345
|
+
*/
|
|
346
|
+
"file-server": async (declaration, context) => {
|
|
347
|
+
if (declaration.kind !== "file-server") throw new WrongKind(declaration.kind);
|
|
348
|
+
const parentUrl = context.provisioned[declaration.of]?.provides[provideKey(declaration.of, "url")];
|
|
349
|
+
if (!parentUrl) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
350
|
+
const endpoint = new URL(parentUrl).searchParams.get("endpoint");
|
|
351
|
+
const bucket = new URL(parentUrl).hostname;
|
|
352
|
+
if (!endpoint) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
353
|
+
return { provides: { [provideKey(declaration.id, "url")]: `${endpoint}/${bucket}` } };
|
|
354
|
+
},
|
|
355
|
+
/**
|
|
356
|
+
* A topic, and below it a queue — the same broker either way.
|
|
357
|
+
*
|
|
358
|
+
* Under pg-boss there is nothing to create in Dokploy: the broker is a
|
|
359
|
+
* schema tenant of the database the app already declared, exactly as it is
|
|
360
|
+
* locally, so what this resolves is an address rather than a service. Which
|
|
361
|
+
* is the point of putting the backend in config — the same handlers drain
|
|
362
|
+
* pg-boss here and SQS on AWS because the string they were handed said so.
|
|
363
|
+
*
|
|
364
|
+
* The tenant gets its own role rather than the cluster master, for the
|
|
365
|
+
* reason every other tenant does: pg-boss creates and owns its tables, and
|
|
366
|
+
* a broker that could also read the application's is a broker with a grant
|
|
367
|
+
* nothing asked for.
|
|
368
|
+
*/
|
|
369
|
+
topic: async (declaration, context) => {
|
|
370
|
+
if (declaration.kind !== "topic") throw new WrongKind(declaration.kind);
|
|
371
|
+
return broker(declaration.id, context);
|
|
372
|
+
},
|
|
373
|
+
queue: async (declaration, context) => {
|
|
374
|
+
if (declaration.kind !== "queue") throw new WrongKind(declaration.kind);
|
|
375
|
+
return broker(declaration.id, context);
|
|
376
|
+
},
|
|
377
|
+
/**
|
|
378
|
+
* A signing key, generated once and remembered in the stage's store.
|
|
379
|
+
*
|
|
380
|
+
* Random, never derived: a key computed from the project and stage names
|
|
381
|
+
* signs sessions anyone reading the repo could forge. The deploy generates
|
|
382
|
+
* it before provisioning (`withGeneratedSecrets`) and every later deploy
|
|
383
|
+
* reads the same one, so live sessions survive a redeploy. Nothing is
|
|
384
|
+
* created in Dokploy: a secret has no address.
|
|
385
|
+
*/
|
|
386
|
+
secret: async (declaration, context) => {
|
|
387
|
+
if (declaration.kind !== "secret") throw new WrongKind(declaration.kind);
|
|
388
|
+
const key = declaration.provides?.[0];
|
|
389
|
+
if (!key) return { provides: {} };
|
|
390
|
+
return { provides: { [key]: supplied(declaration.id, key, context) } };
|
|
391
|
+
},
|
|
392
|
+
/**
|
|
393
|
+
* The keyring the stage keeps in its secrets.
|
|
394
|
+
*
|
|
395
|
+
* Generated on the first deploy (`withGeneratedSecrets`) and rotated only
|
|
396
|
+
* by `gkm encryption:rotate`, so a redeploy never changes a key and nothing
|
|
397
|
+
* written becomes unreadable. Nothing is created in Dokploy: the process
|
|
398
|
+
* holds the keyring.
|
|
399
|
+
*/
|
|
400
|
+
encryption: async (declaration, context) => {
|
|
401
|
+
if (declaration.kind !== "encryption") throw new WrongKind(declaration.kind);
|
|
402
|
+
const key = provideKey(declaration.id, "url");
|
|
403
|
+
return { provides: { [key]: supplied(declaration.id, key, context) } };
|
|
404
|
+
},
|
|
405
|
+
/**
|
|
406
|
+
* A third party's credentials, as the stage was given them.
|
|
407
|
+
*
|
|
408
|
+
* Nothing is created and nothing derived: the value was issued by somebody
|
|
409
|
+
* else. A stage that was never given one fails the deploy here, naming the
|
|
410
|
+
* command that sets it — not the first request that needs it.
|
|
411
|
+
*/
|
|
412
|
+
credential: async (declaration, context) => {
|
|
413
|
+
if (declaration.kind !== "credential") throw new WrongKind(declaration.kind);
|
|
414
|
+
const key = provideKey(declaration.id, "credentials");
|
|
415
|
+
return { provides: { [key]: supplied(declaration.id, key, context) } };
|
|
416
|
+
},
|
|
417
|
+
/**
|
|
418
|
+
* An API somebody else runs: the URL this stage calls it at, and the
|
|
419
|
+
* credentials it was issued for this stage.
|
|
420
|
+
*/
|
|
421
|
+
"external-api": async (declaration, context) => {
|
|
422
|
+
if (declaration.kind !== "external-api") throw new WrongKind(declaration.kind);
|
|
423
|
+
const credentials = provideKey(declaration.id, "credentials");
|
|
424
|
+
return { provides: {
|
|
425
|
+
[provideKey(declaration.id, "url")]: externalApiUrl(declaration, context.stage),
|
|
426
|
+
[credentials]: supplied(declaration.id, credentials, context)
|
|
427
|
+
} };
|
|
428
|
+
},
|
|
429
|
+
/**
|
|
430
|
+
* A surface, at the address whatever created its domain assigned.
|
|
431
|
+
*
|
|
432
|
+
* Nothing is provisioned here: the application and its domain are the
|
|
433
|
+
* existing engine's, and Dokploy's own Traefik terminates TLS with a Let's
|
|
434
|
+
* Encrypt certificate. What this adds is the *derivation* — who may call it,
|
|
435
|
+
* and where a cookie it sets is readable — from the graph rather than from a
|
|
436
|
+
* hand-maintained list.
|
|
437
|
+
*/
|
|
438
|
+
"rest-api": async (declaration, context) => {
|
|
439
|
+
if (declaration.kind !== "rest-api") throw new WrongKind(declaration.kind);
|
|
440
|
+
const url = context.addresses?.[declaration.id];
|
|
441
|
+
if (!url) throw new SurfaceHasNoAddress(declaration.id);
|
|
442
|
+
const origins = [...new Set(dependentsOf(context.manifest, declaration.id).map((caller) => context.addresses?.[caller]).filter((address) => Boolean(address)))].sort();
|
|
443
|
+
const domain = cookieDomain([url, ...origins]);
|
|
444
|
+
const schemes = dependentsOf(context.manifest, declaration.id).flatMap((caller) => {
|
|
445
|
+
const app = context.manifest[caller];
|
|
446
|
+
return app?.kind === "mobile-app" ? mobileOrigins(schemeBase(context.project, app.scheme)) : [];
|
|
447
|
+
});
|
|
448
|
+
return { provides: {
|
|
449
|
+
[provideKey(declaration.id, "url")]: url,
|
|
450
|
+
[provideKey(declaration.id, "trustedOrigins")]: [...origins, ...schemes].join(","),
|
|
451
|
+
...domain ? { [provideKey(declaration.id, "cookieDomain")]: domain } : {}
|
|
452
|
+
} };
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
/** The provisioner for a kind, or nothing where this target has none yet. */
|
|
456
|
+
function provisionerFor(kind) {
|
|
457
|
+
return PROVISIONERS[kind];
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The role DDL for one database or tenant, from the shared generator.
|
|
461
|
+
*
|
|
462
|
+
* The same statements the local target applies and the same ones the AWS
|
|
463
|
+
* bootstrap runs. Dokploy used to write its own, which is how three targets
|
|
464
|
+
* came to hold three definitions of the same split.
|
|
465
|
+
*/
|
|
466
|
+
function statementsFor(options) {
|
|
467
|
+
const { id, database, runtime, owner, reader, schema, context } = options;
|
|
468
|
+
return roleStatements({
|
|
469
|
+
runtime,
|
|
470
|
+
owner,
|
|
471
|
+
...reader ? { reader } : {},
|
|
472
|
+
schema,
|
|
473
|
+
...options.ownsDatabase ? { database } : {},
|
|
474
|
+
passwords: {
|
|
475
|
+
runtime: derivedPassword(context, runtime),
|
|
476
|
+
owner: derivedPassword(context, owner),
|
|
477
|
+
...reader ? { reader: derivedPassword(context, reader) } : {}
|
|
478
|
+
}
|
|
479
|
+
}).map((statement) => ({
|
|
480
|
+
...statement,
|
|
481
|
+
id,
|
|
482
|
+
database
|
|
483
|
+
}));
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The connection string a declared queue or topic publishes on.
|
|
487
|
+
*
|
|
488
|
+
* One broker for the whole project, which is what the local target does and
|
|
489
|
+
* what the runtime expects: the generated pollers open a single connection and
|
|
490
|
+
* subscribe each worker by name on it. So every carrier resolves to the same
|
|
491
|
+
* address, and the DDL that backs it is pushed once however many are declared.
|
|
492
|
+
*
|
|
493
|
+
* Only pg-boss is answered here. SNS needs a topic ARN that has to exist
|
|
494
|
+
* first, and RabbitMQ needs a broker Dokploy has no primitive for — both are
|
|
495
|
+
* the §4.3 question, and composing a plausible URL for either would fail at the
|
|
496
|
+
* first publish instead of here.
|
|
497
|
+
*/
|
|
498
|
+
function broker(id, context) {
|
|
499
|
+
const backend = context.events ?? "pgboss";
|
|
500
|
+
if (backend !== "pgboss") throw new UnprovisionableCarrier(id, backend);
|
|
501
|
+
const parentId = soleDatabase(context);
|
|
502
|
+
if (!parentId) throw new BrokerNeedsADatabase(id);
|
|
503
|
+
const parentUrl = context.provisioned[parentId]?.provides[provideKey(parentId, "url")];
|
|
504
|
+
if (!parentUrl) throw new UnresolvedParent(id, parentId);
|
|
505
|
+
const url = new URL(parentUrl);
|
|
506
|
+
const database = url.pathname.slice(1);
|
|
507
|
+
const runtime = `${database}_${PGBOSS_SCHEMA}`;
|
|
508
|
+
const owner = ownerRole(runtime);
|
|
509
|
+
if (!context.provisioned[BROKER_ID]) {
|
|
510
|
+
context.deferred.push(...statementsFor({
|
|
511
|
+
id: BROKER_ID,
|
|
512
|
+
database,
|
|
513
|
+
runtime,
|
|
514
|
+
owner,
|
|
515
|
+
schema: PGBOSS_SCHEMA,
|
|
516
|
+
context
|
|
517
|
+
}));
|
|
518
|
+
context.provisioned[BROKER_ID] = { provides: {} };
|
|
519
|
+
}
|
|
520
|
+
const connection = pgbossUrl(runtime, derivedPassword(context, runtime), url.host, database);
|
|
521
|
+
return { provides: { [provideKey(id, "publisherConnectionString")]: connection } };
|
|
522
|
+
}
|
|
523
|
+
/** The schema pg-boss creates its tables in, here as locally. */
|
|
524
|
+
const PGBOSS_SCHEMA = "pgboss";
|
|
525
|
+
/**
|
|
526
|
+
* The id the broker's DDL is pushed under.
|
|
527
|
+
*
|
|
528
|
+
* Not a construct — no declaration names it — but the deferred statements need
|
|
529
|
+
* an id, and every carrier in the project shares this one.
|
|
530
|
+
*/
|
|
531
|
+
const BROKER_ID = "PgBoss";
|
|
532
|
+
/**
|
|
533
|
+
* A pg-boss address: a Postgres URL under a scheme that picks the transport.
|
|
534
|
+
*
|
|
535
|
+
* The protocol is what a producer branches on, so it never branches — the same
|
|
536
|
+
* `.publish()` reaches pg-boss here and SQS on AWS.
|
|
537
|
+
*/
|
|
538
|
+
function pgbossUrl(role, password, host, database) {
|
|
539
|
+
return `pgboss://${role}:${encodeURIComponent(password)}@${host}/${database}?schema=${PGBOSS_SCHEMA}`;
|
|
540
|
+
}
|
|
541
|
+
/** The region MinIO is addressed with. It has no regions; the SDK wants one. */
|
|
542
|
+
const MINIO_REGION = "us-east-1";
|
|
543
|
+
/** An `s3://` URL pointing the same S3 client at MinIO on the internal network. */
|
|
544
|
+
function s3Url(service, bucket) {
|
|
545
|
+
return `s3://${bucket}?region=${MINIO_REGION}&endpoint=http://${service}:9000&forcePathStyle=true`;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* A one-service compose file for a bucket.
|
|
549
|
+
*
|
|
550
|
+
* `dokploy-network` is external and already exists — it is what every Dokploy
|
|
551
|
+
* service is attached to, and joining it is what makes the app able to resolve
|
|
552
|
+
* this one by name. The bucket itself is created by MinIO's entrypoint rather
|
|
553
|
+
* than by a second call, so there is nothing to reconcile afterwards.
|
|
554
|
+
*/
|
|
555
|
+
function minioCompose(options) {
|
|
556
|
+
const { service, bucket, user, password } = options;
|
|
557
|
+
return [
|
|
558
|
+
"services:",
|
|
559
|
+
` ${service}:`,
|
|
560
|
+
" image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
|
|
561
|
+
` command: server /data --console-address ":9001"`,
|
|
562
|
+
" environment:",
|
|
563
|
+
` MINIO_ROOT_USER: ${user}`,
|
|
564
|
+
` MINIO_ROOT_PASSWORD: ${password}`,
|
|
565
|
+
" volumes:",
|
|
566
|
+
` - ${service}-data:/data`,
|
|
567
|
+
" networks:",
|
|
568
|
+
" - dokploy-network",
|
|
569
|
+
" healthcheck:",
|
|
570
|
+
" test: [\"CMD\", \"mc\", \"ready\", \"local\"]",
|
|
571
|
+
" interval: 10s",
|
|
572
|
+
" retries: 5",
|
|
573
|
+
` ${service}-bucket:`,
|
|
574
|
+
" image: minio/mc:latest",
|
|
575
|
+
" depends_on:",
|
|
576
|
+
` ${service}:`,
|
|
577
|
+
" condition: service_healthy",
|
|
578
|
+
" entrypoint: >",
|
|
579
|
+
` /bin/sh -c "mc alias set s3 http://${service}:9000 ${user} ${password} &&`,
|
|
580
|
+
` mc mb --ignore-existing s3/${bucket}"`,
|
|
581
|
+
" networks:",
|
|
582
|
+
" - dokploy-network",
|
|
583
|
+
"volumes:",
|
|
584
|
+
` ${service}-data:`,
|
|
585
|
+
"networks:",
|
|
586
|
+
" dokploy-network:",
|
|
587
|
+
" external: true",
|
|
588
|
+
""
|
|
589
|
+
].join("\n");
|
|
590
|
+
}
|
|
591
|
+
/** Whether anything reads through a reader on this construct. */
|
|
592
|
+
function readsFrom(manifest, id) {
|
|
593
|
+
return Object.values(manifest).some((declaration) => declaration.kind === "database-reader" && declaration.of === id);
|
|
594
|
+
}
|
|
595
|
+
/** The schema a database or tenant pins on its roles. */
|
|
596
|
+
function schemaOf(manifest, id) {
|
|
597
|
+
const declaration = manifest[id];
|
|
598
|
+
if (declaration?.kind === "database") return declaration.schema ?? "app";
|
|
599
|
+
if (declaration?.kind === "database-schema") return declaration.schema;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* The one declared database, where `cache: 'db'` has to pick.
|
|
603
|
+
*
|
|
604
|
+
* Unambiguous with one and arbitrary with two, so two is an error rather than a
|
|
605
|
+
* coin toss — a cache landing in a database nobody chose surfaces as entries
|
|
606
|
+
* that are never found, long after the deploy reported success.
|
|
607
|
+
*/
|
|
608
|
+
function soleDatabase(context) {
|
|
609
|
+
const databases = Object.entries(context.manifest).filter(([, declaration]) => declaration.kind === "database").map(([id]) => id);
|
|
610
|
+
if (databases.length > 1) throw new CacheIsAmbiguous(databases);
|
|
611
|
+
return databases[0];
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* A password derived from the stage's seed, the project, the stage and the role.
|
|
615
|
+
*
|
|
616
|
+
* Derived rather than stored per role so a redeploy hands the running
|
|
617
|
+
* application the password it already has, and two projects never share one.
|
|
618
|
+
* The seed is what makes it secret: random, generated once and kept in the
|
|
619
|
+
* stage's store. Without it the inputs are all in the repo, and so was every
|
|
620
|
+
* production password.
|
|
621
|
+
*/
|
|
622
|
+
function derivedPassword(context, role) {
|
|
623
|
+
return createHash("sha256").update(`${context.seed}:${context.project}:${context.stage}:role:${role}`).digest("base64url").slice(0, 32);
|
|
624
|
+
}
|
|
625
|
+
/** A connection string for a role on a cluster. */
|
|
626
|
+
function postgresUrl(role, password, host, database) {
|
|
627
|
+
return `postgres://${role}:${encodeURIComponent(password)}@${host}:5432/${database}`;
|
|
628
|
+
}
|
|
629
|
+
/** Quote an identifier, per segment, for DDL that cannot be parameterised. */
|
|
630
|
+
function quoted(name) {
|
|
631
|
+
return name.split(".").map((part) => `"${part.replace(/"/g, "\"\"")}"`).join(".");
|
|
632
|
+
}
|
|
633
|
+
/** A provisioner was handed a declaration of the wrong kind. */
|
|
634
|
+
/**
|
|
635
|
+
* A value only the stage's own secrets can hold.
|
|
636
|
+
*
|
|
637
|
+
* @throws {MissingSuppliedSecret} when the stage was never given it.
|
|
638
|
+
*/
|
|
639
|
+
function supplied(id, key, context) {
|
|
640
|
+
const value = context.supplied?.[key];
|
|
641
|
+
if (value === void 0) throw new MissingSuppliedSecret(id, key, context.stage);
|
|
642
|
+
return value;
|
|
643
|
+
}
|
|
644
|
+
/** A construct needs a value the stage was never given. */
|
|
645
|
+
var MissingSuppliedSecret = class extends Error {
|
|
646
|
+
id;
|
|
647
|
+
key;
|
|
648
|
+
stage;
|
|
649
|
+
constructor(id, key, stage) {
|
|
650
|
+
super(`'${id}' needs ${key}, and the stage '${stage}' has none. Nothing can derive it; set it in the stage's secrets: gkm secrets:set ${key} '…' --stage ${stage}`);
|
|
651
|
+
this.id = id;
|
|
652
|
+
this.key = key;
|
|
653
|
+
this.stage = stage;
|
|
654
|
+
this.name = "MissingSuppliedSecret";
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
var WrongKind = class extends Error {
|
|
658
|
+
kind;
|
|
659
|
+
constructor(kind) {
|
|
660
|
+
super(`No Dokploy provisioner handles '${kind}'`);
|
|
661
|
+
this.kind = kind;
|
|
662
|
+
this.name = "WrongKind";
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
/** A derived construct whose parent has not been provisioned. */
|
|
666
|
+
var UnresolvedParent = class extends Error {
|
|
667
|
+
id;
|
|
668
|
+
parent;
|
|
669
|
+
constructor(id, parent) {
|
|
670
|
+
super(`'${id}' derives from '${parent}', which has not been provisioned. Provision in \`provisionOrder\`, which puts parents first.`);
|
|
671
|
+
this.id = id;
|
|
672
|
+
this.parent = parent;
|
|
673
|
+
this.name = "UnresolvedParent";
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
/** A cache with nowhere to live. */
|
|
677
|
+
var CacheNeedsAHome = class extends Error {
|
|
678
|
+
id;
|
|
679
|
+
backend;
|
|
680
|
+
constructor(id, backend) {
|
|
681
|
+
super(`'${id}' is a cache and nothing says where it lives. Declare it from its database — \`orders.cache('${id}')\`.` + (backend ? ` The deploy target's backend is '${backend}'.` : ""));
|
|
682
|
+
this.id = id;
|
|
683
|
+
this.backend = backend;
|
|
684
|
+
this.name = "CacheNeedsAHome";
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
/** A `'db'` cache (a server target's default) with more than one database. */
|
|
688
|
+
var CacheIsAmbiguous = class extends Error {
|
|
689
|
+
databases;
|
|
690
|
+
constructor(databases) {
|
|
691
|
+
super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database rather than as a bare new Cache().`);
|
|
692
|
+
this.databases = databases;
|
|
693
|
+
this.name = "CacheIsAmbiguous";
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
/** A surface reached provisioning before its domain existed. */
|
|
697
|
+
var SurfaceHasNoAddress = class extends Error {
|
|
698
|
+
id;
|
|
699
|
+
constructor(id) {
|
|
700
|
+
super(`'${id}' is a surface and no address was supplied for it. The domain is created before the manifest is provisioned, so this means the two ran out of order.`);
|
|
701
|
+
this.id = id;
|
|
702
|
+
this.name = "SurfaceHasNoAddress";
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
/** A queue or topic on a backend this target has no primitive for. */
|
|
706
|
+
var UnprovisionableCarrier = class extends Error {
|
|
707
|
+
id;
|
|
708
|
+
backend;
|
|
709
|
+
constructor(id, backend) {
|
|
710
|
+
super(`'${id}' is carried by '${backend}', which this target cannot provision. SNS needs a topic ARN that has to exist first and RabbitMQ needs a broker Dokploy has no primitive for; pg-boss is a schema tenant of a database this app already declares, which is why it is the one that works here. Deploy this app to a target that has the backend.`);
|
|
711
|
+
this.id = id;
|
|
712
|
+
this.backend = backend;
|
|
713
|
+
this.name = "UnprovisionableCarrier";
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
/** A pg-boss carrier in an app that declares no database to host it. */
|
|
717
|
+
var BrokerNeedsADatabase = class extends Error {
|
|
718
|
+
id;
|
|
719
|
+
constructor(id) {
|
|
720
|
+
super(`'${id}' is carried by pg-boss, which lives in the database the app declares — and this app declares none. Declare a database, or deploy to a target whose broker is its own.`);
|
|
721
|
+
this.id = id;
|
|
722
|
+
this.name = "BrokerNeedsADatabase";
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
/** A bucket on a backend this target has no primitive for. */
|
|
726
|
+
var UnprovisionableBucket = class extends Error {
|
|
727
|
+
id;
|
|
728
|
+
backend;
|
|
729
|
+
constructor(id, backend) {
|
|
730
|
+
super(`'${id}' is stored on '${backend}', which this target cannot provision: an S3 or R2 bucket belongs to an account this deploy does not hold, the way an API key does. Supply the bucket's URL.`);
|
|
731
|
+
this.id = id;
|
|
732
|
+
this.backend = backend;
|
|
733
|
+
this.name = "UnprovisionableBucket";
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
//#endregion
|
|
737
|
+
//#region src/deploy/declared.ts
|
|
738
|
+
/**
|
|
739
|
+
* The declared half of a Dokploy deploy.
|
|
740
|
+
*
|
|
741
|
+
* `fromManifest.ts` decides what each construct resolves to; this runs it —
|
|
742
|
+
* discovers the manifest, walks it parents-first, and applies the DDL the
|
|
743
|
+
* provisioners deferred.
|
|
744
|
+
*
|
|
745
|
+
* It is deliberately separate from the engine in `index.ts`. That file deploys
|
|
746
|
+
* *applications*: images, registries, domains, the things a project has whether
|
|
747
|
+
* or not it declares anything. This is the part that exists because the app
|
|
748
|
+
* declared it, and keeping the two apart is what lets a project adopt the model
|
|
749
|
+
* without rewriting its deploy — and lets this one be tested without one.
|
|
750
|
+
*/
|
|
751
|
+
/**
|
|
752
|
+
* Provision everything the workspace declares, and return what it resolved.
|
|
753
|
+
*/
|
|
754
|
+
async function provisionDeclared(options) {
|
|
755
|
+
const { workspace } = options;
|
|
756
|
+
const empty = {
|
|
757
|
+
env: {},
|
|
758
|
+
statements: [],
|
|
759
|
+
provisioned: {},
|
|
760
|
+
clusters: {}
|
|
761
|
+
};
|
|
762
|
+
const manifest = options.manifest ?? await discover({
|
|
763
|
+
patterns: constructGlobs(workspace),
|
|
764
|
+
cwd: workspace.root
|
|
765
|
+
});
|
|
766
|
+
if (Object.keys(manifest).length === 0) return empty;
|
|
767
|
+
const context = {
|
|
768
|
+
manifest,
|
|
769
|
+
provisioned: {},
|
|
770
|
+
api: options.api,
|
|
771
|
+
projectId: options.projectId,
|
|
772
|
+
environmentId: options.environmentId,
|
|
773
|
+
stage: options.stage,
|
|
774
|
+
project: workspace.name,
|
|
775
|
+
scope: options.scope ?? workspace.name,
|
|
776
|
+
cache: cacheBackendFor(providerOf(workspace)),
|
|
777
|
+
events: eventsBackendFor(providerOf(workspace)),
|
|
778
|
+
storage: storageBackendFor(providerOf(workspace)),
|
|
779
|
+
addresses: surfaceAddresses(manifest, options.appUrls),
|
|
780
|
+
seed: options.seed,
|
|
781
|
+
...options.supplied ? { supplied: options.supplied } : {},
|
|
782
|
+
deferred: [],
|
|
783
|
+
clusters: {}
|
|
784
|
+
};
|
|
785
|
+
const env = {};
|
|
786
|
+
for (const id of carriersLast(provisionOrder(manifest), manifest)) {
|
|
787
|
+
const declaration = manifest[id];
|
|
788
|
+
if (!declaration) continue;
|
|
789
|
+
const provisioner = provisionerFor(declaration.kind);
|
|
790
|
+
if (!provisioner) continue;
|
|
791
|
+
const result = await provisioner(declaration, context);
|
|
792
|
+
context.provisioned[id] = result;
|
|
793
|
+
Object.assign(env, result.provides);
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
env,
|
|
797
|
+
statements: context.deferred.map((statement) => ({
|
|
798
|
+
id: statement.id,
|
|
799
|
+
describe: statement.describe,
|
|
800
|
+
...statement.database ? { database: statement.database } : {},
|
|
801
|
+
...statement.exists ? { exists: statement.exists } : {},
|
|
802
|
+
create: statement.sql
|
|
803
|
+
})),
|
|
804
|
+
provisioned: context.provisioned,
|
|
805
|
+
clusters: context.clusters
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Run the deferred DDL against a cluster that is now reachable.
|
|
810
|
+
*
|
|
811
|
+
* The same applier the local target uses: every statement asks whether it is
|
|
812
|
+
* needed first, so a redeploy is free and a half-applied run recovers by being
|
|
813
|
+
* run again.
|
|
814
|
+
*/
|
|
815
|
+
async function applyDeclared(client, statements) {
|
|
816
|
+
return (await applyPostgres(client, statements)).filter((entry) => entry.created).length;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* The provisioning order, with queues and topics moved to the end.
|
|
820
|
+
*
|
|
821
|
+
* `provisionOrder` walks `of` edges, and a carrier has none: a topic is not
|
|
822
|
+
* *derived from* a database, because under SNS it does not live in one. Where
|
|
823
|
+
* it lives is a fact about the backend, which is config — so the dependency is
|
|
824
|
+
* real under pg-boss and absent under SNS, and writing it into the declaration
|
|
825
|
+
* would state it in the one place that cannot know.
|
|
826
|
+
*
|
|
827
|
+
* Here it can. This runs where the backend is known, and the rule needs no
|
|
828
|
+
* branch on it: nothing depends on a carrier. A queue's worker and a topic's
|
|
829
|
+
* subscribers are *bound* to it rather than dependent on it, which is why
|
|
830
|
+
* neither holds a key of its own — so a carrier can always go last, and under
|
|
831
|
+
* pg-boss that is exactly late enough for the database it reads to be there.
|
|
832
|
+
*/
|
|
833
|
+
function carriersLast(ordered, manifest) {
|
|
834
|
+
const carries = (id) => {
|
|
835
|
+
const kind = manifest[id]?.kind;
|
|
836
|
+
return kind === "queue" || kind === "topic";
|
|
837
|
+
};
|
|
838
|
+
return [...ordered.filter((id) => !carries(id)), ...ordered.filter(carries)];
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Where each declared surface answers: its own app's address.
|
|
842
|
+
*
|
|
843
|
+
* Every surface is an app of its own, keyed by its id the way the derivation
|
|
844
|
+
* keyed it — `Api` is `api`, `Webhooks` is `webhooks` — and answers on that
|
|
845
|
+
* app's host, `{subdomain}.{domain}`. It used to be handed the first backend's
|
|
846
|
+
* address, so a workspace with two APIs pointed both at the same one.
|
|
847
|
+
*/
|
|
848
|
+
function surfaceAddresses(manifest, appUrls) {
|
|
849
|
+
const addresses = {};
|
|
850
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
851
|
+
if (declaration.kind !== "rest-api") continue;
|
|
852
|
+
const url = appUrls[appKey(id)];
|
|
853
|
+
if (url) addresses[id] = url;
|
|
854
|
+
}
|
|
855
|
+
return addresses;
|
|
856
|
+
}
|
|
857
|
+
//#endregion
|
|
858
|
+
//#region src/deploy/state.ts
|
|
859
|
+
/**
|
|
860
|
+
* Create a new empty state for a stage
|
|
861
|
+
*/
|
|
862
|
+
function createEmptyState(stage, projectId, environmentId) {
|
|
863
|
+
return {
|
|
864
|
+
provider: "dokploy",
|
|
865
|
+
stage,
|
|
866
|
+
projectId,
|
|
867
|
+
environmentId,
|
|
868
|
+
applications: {},
|
|
869
|
+
services: {},
|
|
870
|
+
lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Set application ID in state (mutates state)
|
|
875
|
+
*/
|
|
876
|
+
function setApplicationId(state, appName, applicationId) {
|
|
877
|
+
state.applications[appName] = applicationId;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Record the image an app was deployed with (mutates state)
|
|
881
|
+
*/
|
|
882
|
+
function setDeployedImage(state, appName, image) {
|
|
883
|
+
state.images = {
|
|
884
|
+
...state.images,
|
|
885
|
+
[appName]: image
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Get a generated secret for an app
|
|
890
|
+
*/
|
|
891
|
+
function getGeneratedSecret(state, appName, secretName) {
|
|
892
|
+
return state?.generatedSecrets?.[appName]?.[secretName];
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Set a generated secret for an app (mutates state)
|
|
896
|
+
*/
|
|
897
|
+
function setGeneratedSecret(state, appName, secretName, value) {
|
|
898
|
+
if (!state.generatedSecrets) state.generatedSecrets = {};
|
|
899
|
+
if (!state.generatedSecrets[appName]) state.generatedSecrets[appName] = {};
|
|
900
|
+
state.generatedSecrets[appName][secretName] = value;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Set DNS verification record for a hostname (mutates state)
|
|
904
|
+
*/
|
|
905
|
+
function setDnsVerification(state, hostname, serverIp) {
|
|
906
|
+
if (!state.dnsVerified) state.dnsVerified = {};
|
|
907
|
+
state.dnsVerified[hostname] = {
|
|
908
|
+
serverIp,
|
|
909
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Check if a hostname is already verified with the given IP
|
|
914
|
+
*/
|
|
915
|
+
function isDnsVerified(state, hostname, serverIp) {
|
|
916
|
+
return (state?.dnsVerified?.[hostname])?.serverIp === serverIp;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Get the key for a DNS record in state
|
|
920
|
+
*/
|
|
921
|
+
function getDnsRecordKey(name, type) {
|
|
922
|
+
return `${name}:${type}`;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Set a created DNS record in state (mutates state)
|
|
926
|
+
*/
|
|
927
|
+
function setDnsRecord(state, record) {
|
|
928
|
+
if (!state.dnsRecords) state.dnsRecords = {};
|
|
929
|
+
const key = getDnsRecordKey(record.name, record.type);
|
|
930
|
+
state.dnsRecords[key] = {
|
|
931
|
+
...record,
|
|
932
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Get backup state from state
|
|
937
|
+
*/
|
|
938
|
+
function getBackupState(state) {
|
|
939
|
+
return state?.backups;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Set backup state (mutates state)
|
|
943
|
+
*/
|
|
944
|
+
function setBackupState(state, backupState) {
|
|
945
|
+
state.backups = backupState;
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Set postgres backup ID in state (mutates state)
|
|
949
|
+
*/
|
|
950
|
+
function setPostgresBackupId(state, backupId) {
|
|
951
|
+
if (state.backups) state.backups.postgresBackupId = backupId;
|
|
952
|
+
}
|
|
953
|
+
//#endregion
|
|
954
|
+
//#region src/deploy/dns/DnsProvider.ts
|
|
955
|
+
/**
|
|
956
|
+
* Check if value is a DnsProvider implementation.
|
|
957
|
+
*/
|
|
958
|
+
function isDnsProvider(value) {
|
|
959
|
+
return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.getRecords === "function" && typeof value.upsertRecords === "function" && typeof value.deleteRecords === "function";
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Create a DNS provider based on configuration.
|
|
963
|
+
*
|
|
964
|
+
* - 'hostinger': HostingerProvider
|
|
965
|
+
* - 'route53': Route53Provider
|
|
966
|
+
* - 'manual': Returns null (user handles DNS)
|
|
967
|
+
* - Custom: Use provided DnsProvider implementation
|
|
968
|
+
*/
|
|
969
|
+
async function createDnsProvider(options) {
|
|
970
|
+
const { config } = options;
|
|
971
|
+
if (config.provider === "manual") return null;
|
|
972
|
+
if (isDnsProvider(config.provider)) return config.provider;
|
|
973
|
+
const provider = config.provider;
|
|
974
|
+
if (provider === "hostinger") {
|
|
975
|
+
const { HostingerProvider } = await import("./HostingerProvider-DgzKa_Tj.mjs");
|
|
976
|
+
return new HostingerProvider();
|
|
977
|
+
}
|
|
978
|
+
if (provider === "route53") {
|
|
979
|
+
const { Route53Provider } = await import("./Route53Provider-Cvj40A8I.mjs");
|
|
980
|
+
const route53Config = config;
|
|
981
|
+
return new Route53Provider({
|
|
982
|
+
region: route53Config.region,
|
|
983
|
+
profile: route53Config.profile,
|
|
984
|
+
hostedZoneId: route53Config.hostedZoneId
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
if (provider === "cloudflare") throw new Error("Cloudflare DNS provider not yet implemented");
|
|
988
|
+
throw new Error(`Unknown DNS provider: ${JSON.stringify(config)}`);
|
|
989
|
+
}
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region src/deploy/dns/index.ts
|
|
992
|
+
/**
|
|
993
|
+
* DNS orchestration for deployments
|
|
994
|
+
*
|
|
995
|
+
* Handles automatic DNS record creation for deployed applications.
|
|
996
|
+
*/
|
|
997
|
+
const logger$3 = output;
|
|
998
|
+
/**
|
|
999
|
+
* Check if DNS config is legacy format (single domain with `domain` property)
|
|
1000
|
+
*/
|
|
1001
|
+
function isLegacyDnsConfig(config) {
|
|
1002
|
+
return typeof config === "object" && config !== null && "provider" in config && "domain" in config;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Normalize DNS config to new multi-domain format
|
|
1006
|
+
*/
|
|
1007
|
+
function normalizeDnsConfig(config) {
|
|
1008
|
+
if (isLegacyDnsConfig(config)) {
|
|
1009
|
+
const { domain, ...providerConfig } = config;
|
|
1010
|
+
return { [domain]: providerConfig };
|
|
1011
|
+
}
|
|
1012
|
+
return config;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Find the root domain for a hostname from available DNS configs
|
|
1016
|
+
*
|
|
1017
|
+
* @example
|
|
1018
|
+
* findRootDomain('api.geekmidas.com', { 'geekmidas.com': {...}, 'geekmidas.dev': {...} })
|
|
1019
|
+
* // Returns 'geekmidas.com'
|
|
1020
|
+
*/
|
|
1021
|
+
function findRootDomain(hostname, dnsConfig) {
|
|
1022
|
+
const domains = Object.keys(dnsConfig).sort((a, b) => b.length - a.length);
|
|
1023
|
+
for (const domain of domains) if (hostname === domain || hostname.endsWith(`.${domain}`)) return domain;
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Group hostnames by their root domain
|
|
1028
|
+
*/
|
|
1029
|
+
function groupHostnamesByDomain(appHostnames, dnsConfig) {
|
|
1030
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1031
|
+
for (const [appName, hostname] of appHostnames) {
|
|
1032
|
+
const rootDomain = findRootDomain(hostname, dnsConfig);
|
|
1033
|
+
if (!rootDomain) {
|
|
1034
|
+
logger$3.log(` ⚠ No DNS config found for hostname: ${hostname}`);
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
if (!grouped.has(rootDomain)) grouped.set(rootDomain, /* @__PURE__ */ new Map());
|
|
1038
|
+
grouped.get(rootDomain).set(appName, hostname);
|
|
1039
|
+
}
|
|
1040
|
+
return grouped;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Resolve IP address from a hostname
|
|
1044
|
+
*/
|
|
1045
|
+
async function resolveHostnameToIp(hostname) {
|
|
1046
|
+
try {
|
|
1047
|
+
return (await lookup(hostname, { family: 4 })).address;
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
throw new Error(`Failed to resolve IP for ${hostname}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Extract subdomain from full hostname relative to root domain
|
|
1054
|
+
*
|
|
1055
|
+
* @example
|
|
1056
|
+
* extractSubdomain('api.joemoer.traflabs.io', 'traflabs.io') => 'api.joemoer'
|
|
1057
|
+
* extractSubdomain('joemoer.traflabs.io', 'traflabs.io') => 'joemoer'
|
|
1058
|
+
*/
|
|
1059
|
+
function extractSubdomain(hostname, rootDomain) {
|
|
1060
|
+
if (!hostname.endsWith(rootDomain)) throw new Error(`Hostname ${hostname} is not under root domain ${rootDomain}`);
|
|
1061
|
+
return hostname.slice(0, -(rootDomain.length + 1)) || "@";
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Generate required DNS records for a deployment
|
|
1065
|
+
*/
|
|
1066
|
+
function generateRequiredRecords(appHostnames, rootDomain, serverIp) {
|
|
1067
|
+
const records = [];
|
|
1068
|
+
for (const [appName, hostname] of appHostnames) {
|
|
1069
|
+
const subdomain = extractSubdomain(hostname, rootDomain);
|
|
1070
|
+
records.push({
|
|
1071
|
+
hostname,
|
|
1072
|
+
subdomain,
|
|
1073
|
+
type: "A",
|
|
1074
|
+
value: serverIp,
|
|
1075
|
+
appName
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
return records;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Print DNS records table
|
|
1082
|
+
*/
|
|
1083
|
+
function printDnsRecordsTable(records, rootDomain) {
|
|
1084
|
+
logger$3.log(`\n 📋 DNS Records for ${rootDomain}:`);
|
|
1085
|
+
logger$3.log(" ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐");
|
|
1086
|
+
logger$3.log(" │ Subdomain │ Type │ Value │ Status │");
|
|
1087
|
+
logger$3.log(" ├─────────────────────────────────────┼──────┼─────────────────┼────────┤");
|
|
1088
|
+
for (const record of records) {
|
|
1089
|
+
const subdomain = record.subdomain.padEnd(35);
|
|
1090
|
+
const type = record.type.padEnd(4);
|
|
1091
|
+
const value = record.value.padEnd(15);
|
|
1092
|
+
let status;
|
|
1093
|
+
if (record.error) status = "✗";
|
|
1094
|
+
else if (record.created) status = "✓ new";
|
|
1095
|
+
else if (record.existed) status = "✓";
|
|
1096
|
+
else status = "?";
|
|
1097
|
+
logger$3.log(` │ ${subdomain} │ ${type} │ ${value} │ ${status.padEnd(6)} │`);
|
|
1098
|
+
}
|
|
1099
|
+
logger$3.log(" └─────────────────────────────────────┴──────┴─────────────────┴────────┘");
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Print DNS records in a simple format for manual setup
|
|
1103
|
+
*/
|
|
1104
|
+
function printDnsRecordsSimple(records, rootDomain) {
|
|
1105
|
+
logger$3.log("\n 📋 Required DNS Records:");
|
|
1106
|
+
logger$3.log(` Add these A records to your DNS provider (${rootDomain}):\n`);
|
|
1107
|
+
for (const record of records) logger$3.log(` ${record.subdomain} → ${record.value} (A record)`);
|
|
1108
|
+
logger$3.log("");
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Create DNS records for a single domain using its configured provider
|
|
1112
|
+
*/
|
|
1113
|
+
async function createDnsRecordsForDomain(records, rootDomain, providerConfig) {
|
|
1114
|
+
const ttl = "ttl" in providerConfig && providerConfig.ttl ? providerConfig.ttl : 300;
|
|
1115
|
+
let provider;
|
|
1116
|
+
try {
|
|
1117
|
+
provider = await createDnsProvider({ config: providerConfig });
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1120
|
+
logger$3.log(` ⚠ Failed to create DNS provider for ${rootDomain}: ${message}`);
|
|
1121
|
+
return records.map((r) => ({
|
|
1122
|
+
...r,
|
|
1123
|
+
error: message
|
|
1124
|
+
}));
|
|
1125
|
+
}
|
|
1126
|
+
if (!provider) return records.map((r) => ({
|
|
1127
|
+
...r,
|
|
1128
|
+
created: false,
|
|
1129
|
+
existed: false
|
|
1130
|
+
}));
|
|
1131
|
+
const results = [];
|
|
1132
|
+
const upsertRecords = records.map((r) => ({
|
|
1133
|
+
name: r.subdomain,
|
|
1134
|
+
type: r.type,
|
|
1135
|
+
ttl,
|
|
1136
|
+
value: r.value
|
|
1137
|
+
}));
|
|
1138
|
+
try {
|
|
1139
|
+
const upsertResults = await provider.upsertRecords(rootDomain, upsertRecords);
|
|
1140
|
+
for (const [i, record] of records.entries()) {
|
|
1141
|
+
const result = upsertResults[i];
|
|
1142
|
+
if (!result) {
|
|
1143
|
+
results.push({
|
|
1144
|
+
hostname: record.hostname,
|
|
1145
|
+
subdomain: record.subdomain,
|
|
1146
|
+
type: record.type,
|
|
1147
|
+
value: record.value,
|
|
1148
|
+
appName: record.appName,
|
|
1149
|
+
error: "No result returned from provider"
|
|
1150
|
+
});
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
if (result.unchanged) results.push({
|
|
1154
|
+
hostname: record.hostname,
|
|
1155
|
+
subdomain: record.subdomain,
|
|
1156
|
+
type: record.type,
|
|
1157
|
+
value: record.value,
|
|
1158
|
+
appName: record.appName,
|
|
1159
|
+
existed: true,
|
|
1160
|
+
created: false
|
|
1161
|
+
});
|
|
1162
|
+
else results.push({
|
|
1163
|
+
hostname: record.hostname,
|
|
1164
|
+
subdomain: record.subdomain,
|
|
1165
|
+
type: record.type,
|
|
1166
|
+
value: record.value,
|
|
1167
|
+
appName: record.appName,
|
|
1168
|
+
created: result.created,
|
|
1169
|
+
existed: !result.created
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1174
|
+
logger$3.log(` ⚠ Failed to create DNS records for ${rootDomain}: ${message}`);
|
|
1175
|
+
return records.map((r) => ({
|
|
1176
|
+
hostname: r.hostname,
|
|
1177
|
+
subdomain: r.subdomain,
|
|
1178
|
+
type: r.type,
|
|
1179
|
+
value: r.value,
|
|
1180
|
+
appName: r.appName,
|
|
1181
|
+
error: message
|
|
1182
|
+
}));
|
|
1183
|
+
}
|
|
1184
|
+
return results;
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Main DNS orchestration function for deployments
|
|
1188
|
+
*
|
|
1189
|
+
* Supports both legacy single-domain format and new multi-domain format:
|
|
1190
|
+
* - Legacy: { provider: 'hostinger', domain: 'example.com' }
|
|
1191
|
+
* - Multi: { 'example.com': { provider: 'hostinger' }, 'example.dev': { provider: 'route53' } }
|
|
1192
|
+
*
|
|
1193
|
+
* @param appHostnames - Map of app names to hostnames
|
|
1194
|
+
* @param dnsConfig - DNS configuration (legacy or multi-domain)
|
|
1195
|
+
* @param dokployEndpoint - Dokploy server endpoint to resolve IP from
|
|
1196
|
+
* @param state - Optional state to save created records for later deletion
|
|
1197
|
+
*/
|
|
1198
|
+
async function orchestrateDns(appHostnames, dnsConfig, dokployEndpoint, state) {
|
|
1199
|
+
if (!dnsConfig) return null;
|
|
1200
|
+
const normalizedConfig = normalizeDnsConfig(dnsConfig);
|
|
1201
|
+
logger$3.log("\n🌐 Setting up DNS records...");
|
|
1202
|
+
let serverIp;
|
|
1203
|
+
try {
|
|
1204
|
+
const endpointUrl = new URL(dokployEndpoint);
|
|
1205
|
+
serverIp = await resolveHostnameToIp(endpointUrl.hostname);
|
|
1206
|
+
logger$3.log(` Server IP: ${serverIp} (from ${endpointUrl.hostname})`);
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1209
|
+
logger$3.log(` ⚠ Failed to resolve server IP: ${message}`);
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
const groupedHostnames = groupHostnamesByDomain(appHostnames, normalizedConfig);
|
|
1213
|
+
if (groupedHostnames.size === 0) {
|
|
1214
|
+
logger$3.log(" No DNS records needed (no hostnames match configured domains)");
|
|
1215
|
+
return {
|
|
1216
|
+
records: [],
|
|
1217
|
+
success: true,
|
|
1218
|
+
serverIp
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
const allRecords = [];
|
|
1222
|
+
let hasFailures = false;
|
|
1223
|
+
for (const [rootDomain, domainHostnames] of groupedHostnames) {
|
|
1224
|
+
const providerConfig = normalizedConfig[rootDomain];
|
|
1225
|
+
if (!providerConfig) {
|
|
1226
|
+
logger$3.log(` ⚠ No provider config for ${rootDomain}`);
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
const providerName = typeof providerConfig.provider === "string" ? providerConfig.provider : "custom";
|
|
1230
|
+
const requiredRecords = generateRequiredRecords(domainHostnames, rootDomain, serverIp);
|
|
1231
|
+
if (requiredRecords.length === 0) continue;
|
|
1232
|
+
logger$3.log(` Creating DNS records for ${rootDomain} (${providerName})...`);
|
|
1233
|
+
const domainRecords = await createDnsRecordsForDomain(requiredRecords, rootDomain, providerConfig);
|
|
1234
|
+
allRecords.push(...domainRecords);
|
|
1235
|
+
const created = domainRecords.filter((r) => r.created).length;
|
|
1236
|
+
const existed = domainRecords.filter((r) => r.existed).length;
|
|
1237
|
+
const failed = domainRecords.filter((r) => r.error).length;
|
|
1238
|
+
if (created > 0) logger$3.log(` ✓ Created ${created} DNS record(s) for ${rootDomain}`);
|
|
1239
|
+
if (existed > 0) logger$3.log(` ✓ ${existed} record(s) already exist for ${rootDomain}`);
|
|
1240
|
+
if (failed > 0) {
|
|
1241
|
+
logger$3.log(` ⚠ ${failed} record(s) failed for ${rootDomain}`);
|
|
1242
|
+
hasFailures = true;
|
|
1243
|
+
}
|
|
1244
|
+
if (state) {
|
|
1245
|
+
for (const record of domainRecords) if (record.created || record.existed) setDnsRecord(state, {
|
|
1246
|
+
domain: rootDomain,
|
|
1247
|
+
name: record.subdomain,
|
|
1248
|
+
type: record.type,
|
|
1249
|
+
value: record.value,
|
|
1250
|
+
ttl: "ttl" in providerConfig && providerConfig.ttl ? providerConfig.ttl : 300
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
printDnsRecordsTable(domainRecords, rootDomain);
|
|
1254
|
+
if (providerConfig.provider === "manual" || failed > 0) printDnsRecordsSimple(domainRecords.filter((r) => !r.created && !r.existed), rootDomain);
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
records: allRecords,
|
|
1258
|
+
success: !hasFailures,
|
|
1259
|
+
serverIp
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Verify DNS records resolve correctly after deployment.
|
|
1264
|
+
*
|
|
1265
|
+
* This function:
|
|
1266
|
+
* 1. Checks state for previously verified hostnames (skips if already verified with same IP)
|
|
1267
|
+
* 2. Attempts to resolve each hostname to an IP
|
|
1268
|
+
* 3. Compares resolved IP with expected server IP
|
|
1269
|
+
* 4. Updates state with verification results
|
|
1270
|
+
*
|
|
1271
|
+
* @param appHostnames - Map of app names to hostnames
|
|
1272
|
+
* @param serverIp - Expected IP address the hostnames should resolve to
|
|
1273
|
+
* @param state - Deploy state for caching verification results
|
|
1274
|
+
* @returns Array of verification results
|
|
1275
|
+
*/
|
|
1276
|
+
async function verifyDnsRecords(appHostnames, serverIp, state) {
|
|
1277
|
+
const results = [];
|
|
1278
|
+
logger$3.log("\n🔍 Verifying DNS records...");
|
|
1279
|
+
for (const [appName, hostname] of appHostnames) {
|
|
1280
|
+
if (isDnsVerified(state, hostname, serverIp)) {
|
|
1281
|
+
logger$3.log(` ✓ ${hostname} (previously verified)`);
|
|
1282
|
+
results.push({
|
|
1283
|
+
hostname,
|
|
1284
|
+
appName,
|
|
1285
|
+
verified: true,
|
|
1286
|
+
expectedIp: serverIp,
|
|
1287
|
+
skipped: true
|
|
1288
|
+
});
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
const resolvedIp = await resolveHostnameToIp(hostname);
|
|
1293
|
+
if (resolvedIp === serverIp) {
|
|
1294
|
+
setDnsVerification(state, hostname, serverIp);
|
|
1295
|
+
logger$3.log(` ✓ ${hostname} → ${resolvedIp}`);
|
|
1296
|
+
results.push({
|
|
1297
|
+
hostname,
|
|
1298
|
+
appName,
|
|
1299
|
+
verified: true,
|
|
1300
|
+
resolvedIp,
|
|
1301
|
+
expectedIp: serverIp
|
|
1302
|
+
});
|
|
1303
|
+
} else {
|
|
1304
|
+
logger$3.log(` ⚠ ${hostname} resolves to ${resolvedIp}, expected ${serverIp}`);
|
|
1305
|
+
results.push({
|
|
1306
|
+
hostname,
|
|
1307
|
+
appName,
|
|
1308
|
+
verified: false,
|
|
1309
|
+
resolvedIp,
|
|
1310
|
+
expectedIp: serverIp
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1315
|
+
logger$3.log(` ⚠ ${hostname} DNS not propagated (${message})`);
|
|
1316
|
+
results.push({
|
|
1317
|
+
hostname,
|
|
1318
|
+
appName,
|
|
1319
|
+
verified: false,
|
|
1320
|
+
expectedIp: serverIp,
|
|
1321
|
+
error: message
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
const verified = results.filter((r) => r.verified).length;
|
|
1326
|
+
const skipped = results.filter((r) => r.skipped).length;
|
|
1327
|
+
const pending = results.filter((r) => !r.verified).length;
|
|
1328
|
+
if (pending > 0) {
|
|
1329
|
+
logger$3.log(`\n ${verified} verified, ${pending} pending propagation`);
|
|
1330
|
+
logger$3.log(" DNS changes may take 5-30 minutes to propagate");
|
|
1331
|
+
} else if (skipped > 0) logger$3.log(` ${verified} verified (${skipped} from cache)`);
|
|
1332
|
+
return results;
|
|
1333
|
+
}
|
|
1334
|
+
//#endregion
|
|
1335
|
+
//#region src/deploy/docker.ts
|
|
1336
|
+
const logger$2 = output;
|
|
1337
|
+
/**
|
|
1338
|
+
* The id the Dockerfile templates mount the encrypted credentials under:
|
|
1339
|
+
* `RUN --mount=type=secret,id=gkm_credentials`.
|
|
1340
|
+
*/
|
|
1341
|
+
const CREDENTIALS_SECRET_ID = "gkm_credentials";
|
|
1342
|
+
/**
|
|
1343
|
+
* Get the full image reference
|
|
1344
|
+
*/
|
|
1345
|
+
function getImageRef(registry, imageName, tag) {
|
|
1346
|
+
if (registry) return `${registry}/${imageName}:${tag}`;
|
|
1347
|
+
return `${imageName}:${tag}`;
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* The `docker build` argv for an image.
|
|
1351
|
+
*
|
|
1352
|
+
* One element per value, so nothing in a ref or a build arg is read by a
|
|
1353
|
+
* shell, and `--opt=value` rather than `--opt value`, so a value that starts
|
|
1354
|
+
* with `-` cannot be taken for one of docker's own flags.
|
|
1355
|
+
*
|
|
1356
|
+
* @internal Exported for testing
|
|
1357
|
+
*/
|
|
1358
|
+
function dockerBuildArgs(options) {
|
|
1359
|
+
const { dockerfilePath, imageRef, buildArgs = [], credentialsFile } = options;
|
|
1360
|
+
return [
|
|
1361
|
+
"build",
|
|
1362
|
+
"--platform=linux/amd64",
|
|
1363
|
+
`--file=${dockerfilePath}`,
|
|
1364
|
+
`--tag=${imageRef}`,
|
|
1365
|
+
...buildArgs.map((arg) => `--build-arg=${arg}`),
|
|
1366
|
+
...credentialsFile ? [`--secret=id=${CREDENTIALS_SECRET_ID},src=${credentialsFile}`] : [],
|
|
1367
|
+
"."
|
|
1368
|
+
];
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Write the encrypted credentials where `docker build --secret` reads them.
|
|
1372
|
+
*
|
|
1373
|
+
* Two lines — ciphertext, then IV — so a template can split them with `sed`
|
|
1374
|
+
* in any base image. Owner-only, in a directory of its own, and the returned
|
|
1375
|
+
* cleanup removes both whatever the build did.
|
|
1376
|
+
*
|
|
1377
|
+
* @internal Exported for testing
|
|
1378
|
+
*/
|
|
1379
|
+
async function writeCredentialsFile(credentials) {
|
|
1380
|
+
const dir = await mkdtemp(join(tmpdir(), "gkm-credentials-"));
|
|
1381
|
+
const path = join(dir, CREDENTIALS_SECRET_ID);
|
|
1382
|
+
await writeFile(path, `${credentials.encrypted}\n${credentials.iv}\n`, { mode: 384 });
|
|
1383
|
+
await chmod(path, 384);
|
|
1384
|
+
return {
|
|
1385
|
+
path,
|
|
1386
|
+
cleanup: () => rm(dir, {
|
|
1387
|
+
recursive: true,
|
|
1388
|
+
force: true
|
|
1389
|
+
})
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Build Docker image
|
|
1394
|
+
* @param imageRef - Full image reference (registry/name:tag)
|
|
1395
|
+
* @param buildArgs - Build arguments to pass to docker build
|
|
1396
|
+
* @param credentials - Encrypted credentials, passed as a build secret
|
|
1397
|
+
*/
|
|
1398
|
+
async function buildImage(imageRef, buildArgs, credentials, { cwd, signal, stdio }) {
|
|
1399
|
+
logger$2.log(`\n🔨 Building Docker image: ${imageRef}`);
|
|
1400
|
+
logger$2.log(" Generating Dockerfile for the pre-built bundle...");
|
|
1401
|
+
await dockerCommand({
|
|
1402
|
+
slim: true,
|
|
1403
|
+
cwd
|
|
1404
|
+
});
|
|
1405
|
+
const dockerfilePath = join(cwd, ".gkm", "docker", "Dockerfile");
|
|
1406
|
+
const secret = credentials ? await writeCredentialsFile(credentials) : void 0;
|
|
1407
|
+
try {
|
|
1408
|
+
await run("docker", dockerBuildArgs({
|
|
1409
|
+
dockerfilePath,
|
|
1410
|
+
imageRef,
|
|
1411
|
+
buildArgs,
|
|
1412
|
+
credentialsFile: secret?.path
|
|
1413
|
+
}), {
|
|
1414
|
+
cwd,
|
|
1415
|
+
env: {
|
|
1416
|
+
...process.env,
|
|
1417
|
+
DOCKER_BUILDKIT: "1"
|
|
1418
|
+
},
|
|
1419
|
+
...signal ? { signal } : {},
|
|
1420
|
+
...stdio ? { stdio } : {}
|
|
1421
|
+
});
|
|
1422
|
+
logger$2.log(`✅ Image built: ${imageRef}`);
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
throw new DockerBuildFailed(imageRef, error);
|
|
1425
|
+
} finally {
|
|
1426
|
+
await secret?.cleanup();
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Push Docker image to registry
|
|
1431
|
+
*/
|
|
1432
|
+
async function pushImage(imageRef, { cwd, signal, stdio }) {
|
|
1433
|
+
logger$2.log(`\n☁️ Pushing image: ${imageRef}`);
|
|
1434
|
+
try {
|
|
1435
|
+
await run("docker", ["push", imageRef], {
|
|
1436
|
+
cwd,
|
|
1437
|
+
...signal ? { signal } : {},
|
|
1438
|
+
...stdio ? { stdio } : {}
|
|
1439
|
+
});
|
|
1440
|
+
logger$2.log(`✅ Image pushed: ${imageRef}`);
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
throw new DockerPushFailed(imageRef, error);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* The digest the registry gave a pushed image, `sha256:…`.
|
|
1447
|
+
*
|
|
1448
|
+
* A tag can be pushed over — by the next deploy, or by anything else that
|
|
1449
|
+
* shares the repository — and the digest is what says which image a stage
|
|
1450
|
+
* actually ran. `RepoDigests` has one entry per repository the image was
|
|
1451
|
+
* pushed to, so the one for this ref's repository is picked by name.
|
|
1452
|
+
*
|
|
1453
|
+
* @internal Exported for testing
|
|
1454
|
+
*/
|
|
1455
|
+
async function pushedDigest(imageRef, { cwd, signal } = { cwd: process.cwd() }) {
|
|
1456
|
+
const repository = imageRef.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
1457
|
+
const output = await runOutput("docker", [
|
|
1458
|
+
"image",
|
|
1459
|
+
"inspect",
|
|
1460
|
+
"--format={{json .RepoDigests}}",
|
|
1461
|
+
imageRef
|
|
1462
|
+
], {
|
|
1463
|
+
cwd,
|
|
1464
|
+
...signal ? { signal } : {}
|
|
1465
|
+
});
|
|
1466
|
+
let digests;
|
|
1467
|
+
try {
|
|
1468
|
+
digests = JSON.parse(output.trim() || "[]");
|
|
1469
|
+
} catch {
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (!Array.isArray(digests)) return void 0;
|
|
1473
|
+
return digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`))?.slice(repository.length + 1);
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Deploy using Docker (build and optionally push image)
|
|
1477
|
+
*/
|
|
1478
|
+
async function deployDocker(options) {
|
|
1479
|
+
const { stage, tag, skipPush, masterKey, config, buildArgs, credentials } = options;
|
|
1480
|
+
const imageName = config.imageName;
|
|
1481
|
+
const imageRef = validateImageRef(getImageRef(config.registry, imageName, tag));
|
|
1482
|
+
const docker = {
|
|
1483
|
+
cwd: options.cwd ?? process.cwd(),
|
|
1484
|
+
...options.signal ? { signal: options.signal } : {},
|
|
1485
|
+
...options.stdio ? { stdio: options.stdio } : {}
|
|
1486
|
+
};
|
|
1487
|
+
await buildImage(imageRef, buildArgs, credentials, docker);
|
|
1488
|
+
let digest;
|
|
1489
|
+
if (!skipPush) {
|
|
1490
|
+
if (!config.registry) logger$2.warn("\n⚠️ No registry configured. Use --skip-push or configure docker.registry in gkm.config.ts");
|
|
1491
|
+
else {
|
|
1492
|
+
await pushImage(imageRef, docker);
|
|
1493
|
+
digest = await pushedDigest(imageRef, docker);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
logger$2.log("\n✅ Docker deployment ready!");
|
|
1497
|
+
logger$2.log(`\n📋 Deployment details:`);
|
|
1498
|
+
logger$2.log(` Image: ${imageRef}`);
|
|
1499
|
+
logger$2.log(` Stage: ${stage}`);
|
|
1500
|
+
if (masterKey) logger$2.log(`\n🔐 Run the container with GKM_MASTER_KEY set to the key with fingerprint ${keyFingerprint(masterKey)}.`);
|
|
1501
|
+
return {
|
|
1502
|
+
imageRef,
|
|
1503
|
+
...digest ? { digest } : {},
|
|
1504
|
+
masterKey
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
//#endregion
|
|
1508
|
+
//#region src/deploy/env-resolver.ts
|
|
1509
|
+
/**
|
|
1510
|
+
* Environment Variable Resolution for Dokploy Deployments
|
|
1511
|
+
*
|
|
1512
|
+
* Resolves sniffed environment variables to actual values during deployment.
|
|
1513
|
+
* Auto-supports common variables like DATABASE_URL, REDIS_URL, BETTER_AUTH_*,
|
|
1514
|
+
* and falls back to user-provided secrets.
|
|
1515
|
+
*/
|
|
1516
|
+
/**
|
|
1517
|
+
* Generate a secure random secret (64 hex characters = 32 bytes)
|
|
1518
|
+
*/
|
|
1519
|
+
function generateSecret() {
|
|
1520
|
+
return randomBytes(32).toString("hex");
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Get or generate a secret for an app.
|
|
1524
|
+
* If the secret already exists in state, returns it.
|
|
1525
|
+
* Otherwise generates a new one and stores it.
|
|
1526
|
+
*/
|
|
1527
|
+
function getOrGenerateSecret(state, appName, secretName) {
|
|
1528
|
+
const existing = getGeneratedSecret(state, appName, secretName);
|
|
1529
|
+
if (existing) return existing;
|
|
1530
|
+
const generated = generateSecret();
|
|
1531
|
+
setGeneratedSecret(state, appName, secretName, generated);
|
|
1532
|
+
return generated;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Resolve a single environment variable
|
|
1536
|
+
*/
|
|
1537
|
+
function resolveEnvVar(varName, context) {
|
|
1538
|
+
switch (varName) {
|
|
1539
|
+
case "PORT": return String(context.app.port);
|
|
1540
|
+
case "NODE_ENV": return "production";
|
|
1541
|
+
case "STAGE": return context.stage;
|
|
1542
|
+
case "BETTER_AUTH_URL": return `https://${context.appHostname}`;
|
|
1543
|
+
case "BETTER_AUTH_SECRET": return getOrGenerateSecret(context.state, context.appName, "BETTER_AUTH_SECRET");
|
|
1544
|
+
case "BETTER_AUTH_TRUSTED_ORIGINS":
|
|
1545
|
+
if (context.frontendUrls.length > 0) return context.frontendUrls.join(",");
|
|
1546
|
+
break;
|
|
1547
|
+
case "GKM_MASTER_KEY": if (context.masterKey) return context.masterKey;
|
|
1548
|
+
}
|
|
1549
|
+
if (context.dependencyUrls && varName.endsWith("_URL")) {
|
|
1550
|
+
const depName = (stripPublicPrefix(varName) ?? varName).slice(0, -4).toLowerCase();
|
|
1551
|
+
if (context.dependencyUrls[depName]) return context.dependencyUrls[depName];
|
|
1552
|
+
}
|
|
1553
|
+
if (context.userSecrets) {
|
|
1554
|
+
if (context.userSecrets.custom[varName]) return context.userSecrets.custom[varName];
|
|
1555
|
+
if (varName in context.userSecrets.urls) return context.userSecrets.urls[varName];
|
|
1556
|
+
if (varName === "POSTGRES_PASSWORD" && context.userSecrets.services.postgres) return context.userSecrets.services.postgres.password;
|
|
1557
|
+
if (varName === "REDIS_PASSWORD" && context.userSecrets.services.redis) return context.userSecrets.services.redis.password;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Resolve all environment variables for an app
|
|
1562
|
+
*/
|
|
1563
|
+
function resolveEnvVars(requiredVars, context) {
|
|
1564
|
+
const resolved = {};
|
|
1565
|
+
const missing = [];
|
|
1566
|
+
for (const varName of requiredVars) {
|
|
1567
|
+
const value = resolveEnvVar(varName, context);
|
|
1568
|
+
if (value !== void 0) resolved[varName] = value;
|
|
1569
|
+
else missing.push(varName);
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
resolved,
|
|
1573
|
+
missing
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Format missing variables error message
|
|
1578
|
+
*/
|
|
1579
|
+
function formatMissingVarsError(appName, missing, stage) {
|
|
1580
|
+
return `Deployment failed: ${appName} is missing required environment variables:\n${missing.map((v) => ` - ${v}`).join("\n")}\n\nAdd them with:\n gkm secrets:set <VAR_NAME> <value> --stage ${stage}\n\nOr add them to the app's requiredEnv in gkm.config.ts to have them auto-resolved.`;
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Validate that all required environment variables can be resolved
|
|
1584
|
+
*/
|
|
1585
|
+
function validateEnvVars(requiredVars, context) {
|
|
1586
|
+
const { resolved, missing } = resolveEnvVars(requiredVars, context);
|
|
1587
|
+
return {
|
|
1588
|
+
valid: missing.length === 0,
|
|
1589
|
+
missing,
|
|
1590
|
+
resolved
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
//#endregion
|
|
1594
|
+
//#region src/deploy/generated.ts
|
|
1595
|
+
/**
|
|
1596
|
+
* The values a deployed stage generates once and keeps: what nothing can
|
|
1597
|
+
* provision and nobody else issues.
|
|
1598
|
+
*
|
|
1599
|
+
* - **A seed**, which salts every password a deploy derives — a database's
|
|
1600
|
+
* master and roles, a bucket's root user. Derived from repo facts alone
|
|
1601
|
+
* (project, stage, construct id), those passwords were computable by anyone
|
|
1602
|
+
* who could read the repo.
|
|
1603
|
+
* - **Each `secret` construct's value** — an auth server's signing secret —
|
|
1604
|
+
* random, rather than derived.
|
|
1605
|
+
* - **Each `encryption` construct's keyring** — one random key and an index
|
|
1606
|
+
* key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
|
|
1607
|
+
*
|
|
1608
|
+
* Both are generated on the first deploy that needs them and written to the
|
|
1609
|
+
* stage's store, so every later deploy reads the same values: sessions survive
|
|
1610
|
+
* a redeploy, and a role's password does not move. A value set by hand with
|
|
1611
|
+
* `gkm secrets:set` is kept, never replaced.
|
|
1612
|
+
*/
|
|
1613
|
+
/**
|
|
1614
|
+
* The stage's secrets with its seed and every declared `secret`'s value
|
|
1615
|
+
* present, generating what is missing. Pure: the caller writes the result
|
|
1616
|
+
* back to the stage's store when `generated` is not empty.
|
|
1617
|
+
*/
|
|
1618
|
+
function withGeneratedSecrets(secrets, manifest) {
|
|
1619
|
+
const generated = [];
|
|
1620
|
+
const custom = { ...secrets.custom };
|
|
1621
|
+
for (const declaration of Object.values(manifest)) {
|
|
1622
|
+
if (declaration?.kind !== "secret" && declaration?.kind !== "encryption") continue;
|
|
1623
|
+
const key = declaration.provides?.[0];
|
|
1624
|
+
if (!key || custom[key]) continue;
|
|
1625
|
+
custom[key] = declaration.kind === "encryption" ? generateKeyring() : random();
|
|
1626
|
+
generated.push(key);
|
|
1627
|
+
}
|
|
1628
|
+
const seed = secrets.seed ?? random();
|
|
1629
|
+
if (!secrets.seed) generated.push("seed");
|
|
1630
|
+
return {
|
|
1631
|
+
secrets: generated.length ? {
|
|
1632
|
+
...secrets,
|
|
1633
|
+
seed,
|
|
1634
|
+
custom,
|
|
1635
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1636
|
+
} : secrets,
|
|
1637
|
+
generated
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
/** 256 bits, URL-safe. */
|
|
1641
|
+
function random() {
|
|
1642
|
+
return randomBytes(32).toString("base64url");
|
|
1643
|
+
}
|
|
1644
|
+
//#endregion
|
|
1645
|
+
//#region src/deploy/journal.ts
|
|
1646
|
+
var DeployJournal = class DeployJournal {
|
|
1647
|
+
store;
|
|
1648
|
+
stage;
|
|
1649
|
+
state;
|
|
1650
|
+
resources;
|
|
1651
|
+
version;
|
|
1652
|
+
existed;
|
|
1653
|
+
constructor(store, stage, state, resources, version, existed) {
|
|
1654
|
+
this.store = store;
|
|
1655
|
+
this.stage = stage;
|
|
1656
|
+
this.state = state;
|
|
1657
|
+
this.resources = resources;
|
|
1658
|
+
this.version = version;
|
|
1659
|
+
this.existed = existed;
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* The stage's journal — its stored state, or `initial` written as the
|
|
1663
|
+
* stage's first state, so there is somewhere to record the very first
|
|
1664
|
+
* resource before it is created.
|
|
1665
|
+
*/
|
|
1666
|
+
static async open(store, stage, initial) {
|
|
1667
|
+
const stored = await store.read(stage);
|
|
1668
|
+
if (stored) return new DeployJournal(store, stage, stored.state, stored.resources, stored.version, true);
|
|
1669
|
+
const state = initial();
|
|
1670
|
+
const version = await store.write(stage, state, { expectedVersion: null });
|
|
1671
|
+
return new DeployJournal(store, stage, state, {}, version, false);
|
|
1672
|
+
}
|
|
1673
|
+
record(key) {
|
|
1674
|
+
return this.resources[key];
|
|
1675
|
+
}
|
|
1676
|
+
/** The keys a previous run marked `pending` and never saw created. */
|
|
1677
|
+
unfinished() {
|
|
1678
|
+
return Object.values(this.resources).filter((record) => record.status === "pending").map((record) => record.key);
|
|
1679
|
+
}
|
|
1680
|
+
/** Records that `entry` is about to be created. */
|
|
1681
|
+
async pending(entry) {
|
|
1682
|
+
await this.put({
|
|
1683
|
+
...entry,
|
|
1684
|
+
status: "pending"
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
/** Records `entry` as existing with `id`; a no-op if it already is. */
|
|
1688
|
+
async ready(entry, id) {
|
|
1689
|
+
const current = this.resources[entry.key];
|
|
1690
|
+
if (current?.status === "ready" && current.id === id) return;
|
|
1691
|
+
await this.put({
|
|
1692
|
+
...entry,
|
|
1693
|
+
status: "ready",
|
|
1694
|
+
id
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* The resource `entry` names: by its recorded id, else by looking it up,
|
|
1699
|
+
* else created — with `pending` written before the create and `ready`
|
|
1700
|
+
* after, so a run that dies in between leaves a record the next run
|
|
1701
|
+
* resolves by looking the resource up rather than creating another.
|
|
1702
|
+
*/
|
|
1703
|
+
async ensure(entry, steps) {
|
|
1704
|
+
const record = this.resources[entry.key];
|
|
1705
|
+
let staleId;
|
|
1706
|
+
if (record?.status === "ready" && record.id && steps.get) {
|
|
1707
|
+
const known = await steps.get(record.id);
|
|
1708
|
+
if (known) {
|
|
1709
|
+
await this.ready(entry, steps.id(known));
|
|
1710
|
+
return {
|
|
1711
|
+
resource: known,
|
|
1712
|
+
via: "recorded"
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
staleId = record.id;
|
|
1716
|
+
}
|
|
1717
|
+
const existing = await steps.find();
|
|
1718
|
+
if (existing) {
|
|
1719
|
+
await this.ready(entry, steps.id(existing));
|
|
1720
|
+
return {
|
|
1721
|
+
resource: existing,
|
|
1722
|
+
via: record?.status === "pending" ? "resumed" : "found",
|
|
1723
|
+
...staleId ? { staleId } : {}
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
await this.pending(entry);
|
|
1727
|
+
const created = await steps.create();
|
|
1728
|
+
await this.ready(entry, steps.id(created));
|
|
1729
|
+
return {
|
|
1730
|
+
resource: created,
|
|
1731
|
+
via: "created",
|
|
1732
|
+
...staleId ? { staleId } : {}
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
/** Writes the stage's state, keeping its resource records. */
|
|
1736
|
+
async save() {
|
|
1737
|
+
this.state.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1738
|
+
this.version = await this.store.write(this.stage, this.state, { expectedVersion: this.version });
|
|
1739
|
+
}
|
|
1740
|
+
async put(record) {
|
|
1741
|
+
this.version = await this.store.putResource(this.stage, record, { expectedVersion: this.version });
|
|
1742
|
+
this.resources = {
|
|
1743
|
+
...this.resources,
|
|
1744
|
+
[record.key]: {
|
|
1745
|
+
...record,
|
|
1746
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1747
|
+
}
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
//#endregion
|
|
1752
|
+
//#region src/deploy/ownership.ts
|
|
1753
|
+
/**
|
|
1754
|
+
* Which Dokploy project a deploy is allowed to deploy into.
|
|
1755
|
+
*
|
|
1756
|
+
* Deploy used to take any project whose name matched the workspace's, in any
|
|
1757
|
+
* case — so a second workspace called `shop` (or `Shop`) on the same server
|
|
1758
|
+
* deployed into the first one's project, over its images and applications. A
|
|
1759
|
+
* project is now claimed by an ownership marker in its description, and a
|
|
1760
|
+
* project with the right name and no marker is somebody else's.
|
|
1761
|
+
*/
|
|
1762
|
+
/**
|
|
1763
|
+
* A Dokploy project with the name this deploy would use exists, and nothing
|
|
1764
|
+
* says this workspace created it.
|
|
1765
|
+
*/
|
|
1766
|
+
var ProjectNotOwned = class extends Error {
|
|
1767
|
+
projectName;
|
|
1768
|
+
projectId;
|
|
1769
|
+
identity;
|
|
1770
|
+
ownedBy;
|
|
1771
|
+
constructor(projectName, projectId, identity, ownedBy) {
|
|
1772
|
+
super(`The Dokploy project '${projectName}' (${projectId}) was not created by this workspace (${identity})${ownedBy ? ` — it is marked '${ownedBy}'` : ""}, so deploy will not use it. Set deploy.namespace in gkm.config.ts to deploy under a name of your own, or, if this project really is this workspace's, add 'gkm:${identity}' to its description in Dokploy.`);
|
|
1773
|
+
this.projectName = projectName;
|
|
1774
|
+
this.projectId = projectId;
|
|
1775
|
+
this.identity = identity;
|
|
1776
|
+
this.ownedBy = ownedBy;
|
|
1777
|
+
this.name = "ProjectNotOwned";
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
/** A description with the marker added, keeping what was there. */
|
|
1781
|
+
function withMarker(description, marker) {
|
|
1782
|
+
const existing = (description ?? "").trim();
|
|
1783
|
+
return existing ? `${existing}\n${marker}` : marker;
|
|
1784
|
+
}
|
|
1785
|
+
/** `project.one`, or `null` when Dokploy no longer has the project. */
|
|
1786
|
+
async function projectById(api, projectId) {
|
|
1787
|
+
try {
|
|
1788
|
+
return await api.getProject(projectId) ?? null;
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
if (error instanceof DokployApiError) return null;
|
|
1791
|
+
throw error;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* The project this identity owns, if there is one — read only, so a dry run
|
|
1796
|
+
* asks the same question a deploy does:
|
|
1797
|
+
*
|
|
1798
|
+
* 1. The one the stage's state names, if Dokploy still has it. State written
|
|
1799
|
+
* before markers existed is trusted — it holds an id this workspace was
|
|
1800
|
+
* given when it created the project.
|
|
1801
|
+
* 2. One with this name *and* this identity's marker.
|
|
1802
|
+
*
|
|
1803
|
+
* A project that matches by name (in any case) but carries no marker, or
|
|
1804
|
+
* another identity's, raises `ProjectNotOwned` — it is never adopted.
|
|
1805
|
+
*/
|
|
1806
|
+
async function findProject(api, identity, stateProjectId) {
|
|
1807
|
+
const name = projectName(identity);
|
|
1808
|
+
const marker = ownershipMarker(identity);
|
|
1809
|
+
if (stateProjectId) {
|
|
1810
|
+
const known = await projectById(api, stateProjectId);
|
|
1811
|
+
if (known) {
|
|
1812
|
+
const claimed = markerOf(known.description);
|
|
1813
|
+
if (claimed && claimed !== marker) throw new ProjectNotOwned(known.name, known.projectId, identity.key, claimed);
|
|
1814
|
+
return {
|
|
1815
|
+
projectId: known.projectId,
|
|
1816
|
+
name: known.name,
|
|
1817
|
+
description: known.description ?? null,
|
|
1818
|
+
environments: known.environments ?? [],
|
|
1819
|
+
via: "state",
|
|
1820
|
+
needsClaim: !claimed
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
const projects = await api.listProjects();
|
|
1825
|
+
const owned = projects.find((p) => p.name.toLowerCase() === name.toLowerCase() && isOwnedBy(p.description, identity));
|
|
1826
|
+
if (owned) {
|
|
1827
|
+
const details = await api.getProject(owned.projectId);
|
|
1828
|
+
return {
|
|
1829
|
+
projectId: owned.projectId,
|
|
1830
|
+
name: owned.name,
|
|
1831
|
+
description: owned.description ?? null,
|
|
1832
|
+
environments: details.environments ?? [],
|
|
1833
|
+
via: "marker",
|
|
1834
|
+
needsClaim: false
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
const clash = projects.find((p) => p.name.toLowerCase() === name.toLowerCase());
|
|
1838
|
+
if (clash) throw new ProjectNotOwned(clash.name, clash.projectId, identity.key, markerOf(clash.description));
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* The project this deploy deploys into: the one {@link findProject} finds —
|
|
1843
|
+
* claimed with this identity's marker if it carries none yet, so the next
|
|
1844
|
+
* deploy without state still finds it — or a new one, marked.
|
|
1845
|
+
*/
|
|
1846
|
+
async function resolveProject(api, identity, stateProjectId, log = () => {}, hooks = {}) {
|
|
1847
|
+
const marker = ownershipMarker(identity);
|
|
1848
|
+
const found = await findProject(api, identity, stateProjectId);
|
|
1849
|
+
if (found) {
|
|
1850
|
+
if (found.needsClaim) {
|
|
1851
|
+
await api.updateProject(found.projectId, {
|
|
1852
|
+
name: found.name,
|
|
1853
|
+
description: withMarker(found.description, marker)
|
|
1854
|
+
});
|
|
1855
|
+
log(` Claimed project ${found.name} for ${identity.key}`);
|
|
1856
|
+
}
|
|
1857
|
+
return {
|
|
1858
|
+
projectId: found.projectId,
|
|
1859
|
+
name: found.name,
|
|
1860
|
+
environments: found.environments,
|
|
1861
|
+
via: found.via
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
await hooks.beforeCreate?.();
|
|
1865
|
+
const created = await api.createProject(projectName(identity), withMarker("Deployed by gkm.", marker));
|
|
1866
|
+
return {
|
|
1867
|
+
projectId: created.project.projectId,
|
|
1868
|
+
name: created.project.name,
|
|
1869
|
+
environments: [created.environment],
|
|
1870
|
+
via: "created"
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
//#endregion
|
|
1874
|
+
//#region src/deploy/registry.ts
|
|
1875
|
+
/**
|
|
1876
|
+
* Which Dokploy registry a stage's images are pulled through.
|
|
1877
|
+
*
|
|
1878
|
+
* It used to be one id stored per machine (`~/.gkm/credentials.json`), and
|
|
1879
|
+
* when that was empty, the first registry Dokploy listed — so an image could be
|
|
1880
|
+
* deployed with credentials for a registry nobody chose, and two workspaces
|
|
1881
|
+
* deployed from one machine shared whichever was stored last. The id is now
|
|
1882
|
+
* kept in the stage's state, and found only from what the workspace
|
|
1883
|
+
* configured.
|
|
1884
|
+
*/
|
|
1885
|
+
/** There is nowhere to push images to, so Dokploy has nothing to pull. */
|
|
1886
|
+
var RegistryNotConfigured = class extends Error {
|
|
1887
|
+
stage;
|
|
1888
|
+
constructor(stage) {
|
|
1889
|
+
super(`Deploying '${stage}' needs a container registry: Dokploy pulls each app's image from one. Set deploy.dokploy.registry in gkm.config.ts (e.g. 'ghcr.io/acme'), and deploy.dokploy.registryId if Dokploy already holds that registry's credentials.`);
|
|
1890
|
+
this.stage = stage;
|
|
1891
|
+
this.name = "RegistryNotConfigured";
|
|
1892
|
+
}
|
|
1893
|
+
};
|
|
1894
|
+
/** `deploy.dokploy.registryId` names a registry Dokploy does not have. */
|
|
1895
|
+
var RegistryNotFound = class extends Error {
|
|
1896
|
+
registryId;
|
|
1897
|
+
constructor(registryId) {
|
|
1898
|
+
super(`deploy.dokploy.registryId '${registryId}' is not a registry on this Dokploy server. Find the right id under Settings → Docker Registry in Dokploy and set it in gkm.config.ts, or remove it to have deploy find the registry by its URL.`);
|
|
1899
|
+
this.registryId = registryId;
|
|
1900
|
+
this.name = "RegistryNotFound";
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
/** More than one Dokploy registry serves the configured registry. */
|
|
1904
|
+
var RegistryAmbiguous = class extends Error {
|
|
1905
|
+
registry;
|
|
1906
|
+
registryIds;
|
|
1907
|
+
constructor(registry, registryIds) {
|
|
1908
|
+
super(`Dokploy has ${registryIds.length} registries for '${registry}' (${registryIds.join(", ")}). Set deploy.dokploy.registryId in gkm.config.ts to the one this workspace pushes with.`);
|
|
1909
|
+
this.registry = registry;
|
|
1910
|
+
this.registryIds = registryIds;
|
|
1911
|
+
this.name = "RegistryAmbiguous";
|
|
1912
|
+
}
|
|
1913
|
+
};
|
|
1914
|
+
/** `https://ghcr.io/acme/` → `{ host: 'ghcr.io', path: 'acme' }`. */
|
|
1915
|
+
function parts(url) {
|
|
1916
|
+
const [host = "", ...path] = url.replace(/^[a-z]+:\/\//i, "").replace(/\/+$/, "").toLowerCase().split("/");
|
|
1917
|
+
return {
|
|
1918
|
+
host,
|
|
1919
|
+
path: path.join("/")
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* The Dokploy registries that serve `registry`: the same host, and — where
|
|
1924
|
+
* Dokploy's own entry names one — the same path.
|
|
1925
|
+
*/
|
|
1926
|
+
function serving(registries, registry) {
|
|
1927
|
+
const wanted = parts(registry);
|
|
1928
|
+
const sameHost = registries.filter((r) => parts(r.registryUrl ?? "").host === wanted.host);
|
|
1929
|
+
if (sameHost.length <= 1) return sameHost;
|
|
1930
|
+
return sameHost.filter((r) => {
|
|
1931
|
+
return (parts(r.registryUrl ?? "").path || (r.imagePrefix ?? "").toLowerCase()) === wanted.path;
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
/** `registry.one`, or `null` when Dokploy says it has no such registry. */
|
|
1935
|
+
async function registryById(api, registryId) {
|
|
1936
|
+
try {
|
|
1937
|
+
return await api.getRegistry(registryId) ?? null;
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
if (error instanceof DokployApiError) return null;
|
|
1940
|
+
throw error;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* The registry id for a stage, in order: the configured id, the one in the
|
|
1945
|
+
* stage's state, the one Dokploy has for the configured registry URL, and
|
|
1946
|
+
* otherwise a new one. Never a registry the workspace did not point at.
|
|
1947
|
+
*/
|
|
1948
|
+
async function resolveRegistry(api, options) {
|
|
1949
|
+
const { stage, registry, configuredId, stateId, log = () => {} } = options;
|
|
1950
|
+
if (!registry) throw new RegistryNotConfigured(stage);
|
|
1951
|
+
if (configuredId) {
|
|
1952
|
+
const configured = await registryById(api, configuredId);
|
|
1953
|
+
if (!configured) throw new RegistryNotFound(configuredId);
|
|
1954
|
+
return configured;
|
|
1955
|
+
}
|
|
1956
|
+
if (stateId) {
|
|
1957
|
+
const known = await registryById(api, stateId);
|
|
1958
|
+
if (known) return known;
|
|
1959
|
+
log(` ⚠ The stage's registry ${stateId} no longer exists in Dokploy`);
|
|
1960
|
+
}
|
|
1961
|
+
const matches = serving(await api.listRegistries(), registry);
|
|
1962
|
+
if (matches.length > 1) throw new RegistryAmbiguous(registry, matches.map((r) => r.registryId));
|
|
1963
|
+
if (matches[0]) return matches[0];
|
|
1964
|
+
return options.create(registry);
|
|
1965
|
+
}
|
|
1966
|
+
//#endregion
|
|
1967
|
+
//#region src/deploy/secrets.ts
|
|
1968
|
+
/**
|
|
1969
|
+
* Filter secrets to only include the env vars that an app requires.
|
|
1970
|
+
*
|
|
1971
|
+
* @param stageSecrets - All secrets for the stage
|
|
1972
|
+
* @param sniffedEnv - The sniffed environment requirements for the app
|
|
1973
|
+
* @returns Filtered secrets with found/missing tracking
|
|
1974
|
+
*/
|
|
1975
|
+
function filterSecretsForApp(stageSecrets, sniffedEnv) {
|
|
1976
|
+
const allSecrets = toEmbeddableSecrets(stageSecrets);
|
|
1977
|
+
const filtered = {};
|
|
1978
|
+
const found = [];
|
|
1979
|
+
const missing = [];
|
|
1980
|
+
for (const key of sniffedEnv.requiredEnvVars) if (key in allSecrets) {
|
|
1981
|
+
filtered[key] = allSecrets[key];
|
|
1982
|
+
found.push(key);
|
|
1983
|
+
} else missing.push(key);
|
|
1984
|
+
return {
|
|
1985
|
+
appName: sniffedEnv.appName,
|
|
1986
|
+
secrets: filtered,
|
|
1987
|
+
found: found.sort(),
|
|
1988
|
+
missing: missing.sort()
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Encrypt filtered secrets for an app.
|
|
1993
|
+
* Generates an ephemeral master key that should be injected into Dokploy.
|
|
1994
|
+
*
|
|
1995
|
+
* @param filteredSecrets - The filtered secrets for the app
|
|
1996
|
+
* @returns Encrypted payload with master key
|
|
1997
|
+
*/
|
|
1998
|
+
function encryptSecretsForApp(filteredSecrets) {
|
|
1999
|
+
const payload = encryptSecrets(filteredSecrets.secrets);
|
|
2000
|
+
return {
|
|
2001
|
+
appName: filteredSecrets.appName,
|
|
2002
|
+
payload,
|
|
2003
|
+
masterKey: payload.masterKey,
|
|
2004
|
+
secretCount: Object.keys(filteredSecrets.secrets).length,
|
|
2005
|
+
missingSecrets: filteredSecrets.missing
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Filter and encrypt secrets for an app in one step.
|
|
2010
|
+
*
|
|
2011
|
+
* @param stageSecrets - All secrets for the stage
|
|
2012
|
+
* @param sniffedEnv - The sniffed environment requirements for the app
|
|
2013
|
+
* @returns Encrypted secrets with master key
|
|
2014
|
+
*/
|
|
2015
|
+
function prepareSecretsForApp(stageSecrets, sniffedEnv) {
|
|
2016
|
+
return encryptSecretsForApp(filterSecretsForApp(stageSecrets, sniffedEnv));
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Prepare secrets for multiple apps.
|
|
2020
|
+
*
|
|
2021
|
+
* @param stageSecrets - All secrets for the stage
|
|
2022
|
+
* @param sniffedApps - Map of app name to sniffed environment
|
|
2023
|
+
* @returns Map of app name to encrypted secrets
|
|
2024
|
+
*/
|
|
2025
|
+
function prepareSecretsForAllApps(stageSecrets, sniffedApps) {
|
|
2026
|
+
const results = /* @__PURE__ */ new Map();
|
|
2027
|
+
for (const [appName, sniffedEnv] of sniffedApps) if (sniffedEnv.requiredEnvVars.length > 0) {
|
|
2028
|
+
const encrypted = prepareSecretsForApp(stageSecrets, sniffedEnv);
|
|
2029
|
+
results.set(appName, encrypted);
|
|
2030
|
+
}
|
|
2031
|
+
return results;
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Generate a report on secrets preparation.
|
|
2035
|
+
*/
|
|
2036
|
+
function generateSecretsReport(encryptedApps, sniffedApps) {
|
|
2037
|
+
const appsWithSecrets = [];
|
|
2038
|
+
const appsWithoutSecrets = [];
|
|
2039
|
+
const appsWithMissingSecrets = [];
|
|
2040
|
+
for (const [appName, sniffedEnv] of sniffedApps) {
|
|
2041
|
+
if (sniffedEnv.requiredEnvVars.length === 0) {
|
|
2042
|
+
appsWithoutSecrets.push(appName);
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
const encrypted = encryptedApps.get(appName);
|
|
2046
|
+
if (encrypted) {
|
|
2047
|
+
appsWithSecrets.push(appName);
|
|
2048
|
+
if (encrypted.missingSecrets.length > 0) appsWithMissingSecrets.push({
|
|
2049
|
+
appName,
|
|
2050
|
+
missing: encrypted.missingSecrets
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
return {
|
|
2055
|
+
totalApps: sniffedApps.size,
|
|
2056
|
+
appsWithSecrets: appsWithSecrets.sort(),
|
|
2057
|
+
appsWithoutSecrets: appsWithoutSecrets.sort(),
|
|
2058
|
+
appsWithMissingSecrets
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
//#endregion
|
|
2062
|
+
//#region src/deploy/terminal.ts
|
|
2063
|
+
/**
|
|
2064
|
+
* The credentials `gkm deploy` can ask a person for. CLI layer only.
|
|
2065
|
+
*
|
|
2066
|
+
* `deploy()` asks its `CredentialProvider` and stops with `MissingCredential`
|
|
2067
|
+
* when the answer is nothing. At a terminal, `gkm deploy` would rather ask:
|
|
2068
|
+
* this provider reads the environment and the stored login first, and only
|
|
2069
|
+
* when both are empty prompts — and stores a Dokploy login it validated, so
|
|
2070
|
+
* the next deploy does not ask again. With no terminal it answers nothing, and
|
|
2071
|
+
* the deploy says what was missing instead of waiting on a prompt nobody sees.
|
|
2072
|
+
*/
|
|
2073
|
+
const logger$1 = output;
|
|
2074
|
+
/** What answers a deploy prompt without a terminal. */
|
|
2075
|
+
const INSTEAD = "Please configure manually.";
|
|
2076
|
+
/** The Dokploy URL typed at the prompt is not a URL. */
|
|
2077
|
+
var DokployEndpointInvalid = class extends Error {
|
|
2078
|
+
endpoint;
|
|
2079
|
+
constructor(endpoint) {
|
|
2080
|
+
super("Invalid URL format");
|
|
2081
|
+
this.endpoint = endpoint;
|
|
2082
|
+
this.name = "DokployEndpointInvalid";
|
|
2083
|
+
}
|
|
2084
|
+
};
|
|
2085
|
+
/** Dokploy refused the token typed at the prompt; nothing was stored. */
|
|
2086
|
+
var DokployTokenRejected = class extends Error {
|
|
2087
|
+
endpoint;
|
|
2088
|
+
constructor(endpoint) {
|
|
2089
|
+
super("Invalid credentials. Please check your token.");
|
|
2090
|
+
this.endpoint = endpoint;
|
|
2091
|
+
this.name = "DokployTokenRejected";
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
2094
|
+
/** Asks the person at the terminal for what nothing else supplied. */
|
|
2095
|
+
function asking() {
|
|
2096
|
+
return { async get(asked) {
|
|
2097
|
+
if (!canPrompt()) return void 0;
|
|
2098
|
+
const request = asked;
|
|
2099
|
+
if (request.kind === "dokploy") {
|
|
2100
|
+
logger$1.log("\n📋 Dokploy credentials not found. Let's set them up.");
|
|
2101
|
+
const endpoint = (await prompt("Dokploy URL (e.g., https://dokploy.example.com): ", { instead: INSTEAD })).replace(/\/$/, "");
|
|
2102
|
+
try {
|
|
2103
|
+
new URL(endpoint);
|
|
2104
|
+
} catch {
|
|
2105
|
+
throw new DokployEndpointInvalid(endpoint);
|
|
2106
|
+
}
|
|
2107
|
+
logger$1.log(`\nGenerate a token at: ${endpoint}/settings/profile\n`);
|
|
2108
|
+
const token = await prompt("API Token: ", {
|
|
2109
|
+
hidden: true,
|
|
2110
|
+
instead: INSTEAD
|
|
2111
|
+
});
|
|
2112
|
+
logger$1.log("\nValidating credentials...");
|
|
2113
|
+
if (!await validateDokployToken(endpoint, token)) throw new DokployTokenRejected(endpoint);
|
|
2114
|
+
await storeDokployCredentials(token, endpoint);
|
|
2115
|
+
logger$1.log("✓ Credentials saved");
|
|
2116
|
+
return {
|
|
2117
|
+
endpoint,
|
|
2118
|
+
token
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
logger$1.log(` Dokploy has no registry for ${request.url}. Let's create one.`);
|
|
2122
|
+
return {
|
|
2123
|
+
username: await prompt("Registry username: ", { instead: INSTEAD }),
|
|
2124
|
+
password: await prompt("Registry password/token: ", {
|
|
2125
|
+
hidden: true,
|
|
2126
|
+
instead: INSTEAD
|
|
2127
|
+
})
|
|
2128
|
+
};
|
|
2129
|
+
} };
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* The environment, then the stored login, then — at a terminal — the person
|
|
2133
|
+
* running the command.
|
|
2134
|
+
*/
|
|
2135
|
+
function terminalCredentials(base = storedCredentials()) {
|
|
2136
|
+
return chainCredentials(base, asking());
|
|
2137
|
+
}
|
|
2138
|
+
//#endregion
|
|
2139
|
+
//#region src/deploy/index.ts
|
|
2140
|
+
/**
|
|
2141
|
+
* Deploy Module
|
|
2142
|
+
*
|
|
2143
|
+
* Handles deployment of GKM workspaces to various providers (Docker, Dokploy).
|
|
2144
|
+
*
|
|
2145
|
+
* ## Per-App Database Credentials
|
|
2146
|
+
*
|
|
2147
|
+
* When deploying to Dokploy with Postgres, this module creates per-app database
|
|
2148
|
+
* users with isolated schemas. This follows the same pattern as local dev mode
|
|
2149
|
+
* (docker/postgres/init.sh).
|
|
2150
|
+
*
|
|
2151
|
+
* ### How It Works
|
|
2152
|
+
*
|
|
2153
|
+
* 1. **Provisioning**: Creates Postgres service with master credentials
|
|
2154
|
+
* 2. **User Creation**: For each backend app that needs DATABASE_URL:
|
|
2155
|
+
* - Generates a unique password (stored in deploy state)
|
|
2156
|
+
* - Creates a database user with that password
|
|
2157
|
+
* - Assigns schema permissions based on app name
|
|
2158
|
+
* 3. **Schema Assignment**:
|
|
2159
|
+
* - `api` app: Uses `public` schema (shared tables)
|
|
2160
|
+
* - Other apps (e.g., `auth`): Get their own schema with `search_path` set
|
|
2161
|
+
* 4. **Environment Injection**: Each app receives its own DATABASE_URL
|
|
2162
|
+
*
|
|
2163
|
+
* ### Security
|
|
2164
|
+
*
|
|
2165
|
+
* - External Postgres port is enabled only during user creation, then disabled
|
|
2166
|
+
* - Each app can only access its own schema
|
|
2167
|
+
* - Credentials are stored in `.gkm/deploy-{stage}.json` (gitignored)
|
|
2168
|
+
* - Subsequent deploys reuse existing credentials from state
|
|
2169
|
+
*
|
|
2170
|
+
* ### Example Flow
|
|
2171
|
+
*
|
|
2172
|
+
* ```
|
|
2173
|
+
* gkm deploy --stage production
|
|
2174
|
+
* ├─ Create Postgres (user: postgres, db: myproject)
|
|
2175
|
+
* ├─ Enable external port temporarily
|
|
2176
|
+
* ├─ Create user "api" → public schema
|
|
2177
|
+
* ├─ Create user "auth" → auth schema (search_path=auth)
|
|
2178
|
+
* ├─ Disable external port
|
|
2179
|
+
* ├─ Deploy "api" with DATABASE_URL=postgresql://api:xxx@postgres:5432/myproject
|
|
2180
|
+
* └─ Deploy "auth" with DATABASE_URL=postgresql://auth:yyy@postgres:5432/myproject
|
|
2181
|
+
* ```
|
|
2182
|
+
*
|
|
2183
|
+
* @module deploy
|
|
2184
|
+
*/
|
|
2185
|
+
const logger = output;
|
|
2186
|
+
/**
|
|
2187
|
+
* Wait for Postgres to be ready to accept connections.
|
|
2188
|
+
*
|
|
2189
|
+
* Polls the Postgres server until it accepts a connection or max retries reached.
|
|
2190
|
+
* Used after enabling the external port to ensure the database is accessible
|
|
2191
|
+
* before creating users.
|
|
2192
|
+
*
|
|
2193
|
+
* @param host - The Postgres server hostname
|
|
2194
|
+
* @param port - The external port (typically 5432)
|
|
2195
|
+
* @param user - Master database user (postgres)
|
|
2196
|
+
* @param password - Master database password
|
|
2197
|
+
* @param database - Database name to connect to
|
|
2198
|
+
* @param maxRetries - Maximum number of connection attempts (default: 30)
|
|
2199
|
+
* @param retryIntervalMs - Milliseconds between retries (default: 2000)
|
|
2200
|
+
* @throws Error if Postgres is not ready after maxRetries
|
|
2201
|
+
*/
|
|
2202
|
+
async function waitForPostgres(host, port, user, password, database, maxRetries = 30, retryIntervalMs = 2e3) {
|
|
2203
|
+
for (let i = 0; i < maxRetries; i++) try {
|
|
2204
|
+
const client = new Client({
|
|
2205
|
+
host,
|
|
2206
|
+
port,
|
|
2207
|
+
user,
|
|
2208
|
+
password,
|
|
2209
|
+
database,
|
|
2210
|
+
connectionTimeoutMillis: 5e3
|
|
2211
|
+
});
|
|
2212
|
+
client.on("error", () => {});
|
|
2213
|
+
await client.connect();
|
|
2214
|
+
await client.end();
|
|
2215
|
+
return;
|
|
2216
|
+
} catch {
|
|
2217
|
+
if (i < maxRetries - 1) {
|
|
2218
|
+
logger.log(` Waiting for Postgres... (${i + 1}/${maxRetries})`);
|
|
2219
|
+
await new Promise((r) => setTimeout(r, retryIntervalMs));
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
throw new PostgresNotReady(host, port, maxRetries);
|
|
2223
|
+
}
|
|
2224
|
+
/** A Postgres published for the role DDL never accepted a connection. */
|
|
2225
|
+
var PostgresNotReady = class extends Error {
|
|
2226
|
+
host;
|
|
2227
|
+
port;
|
|
2228
|
+
attempts;
|
|
2229
|
+
constructor(host, port, attempts) {
|
|
2230
|
+
super(`Postgres not ready after ${attempts} retries (${host}:${port}). Check that the port is reachable from here — a firewall in front of the server drops it silently — and deploy again.`);
|
|
2231
|
+
this.host = host;
|
|
2232
|
+
this.port = port;
|
|
2233
|
+
this.attempts = attempts;
|
|
2234
|
+
this.name = "PostgresNotReady";
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
/** No port could be published for a cluster, so its DDL cannot reach it. */
|
|
2238
|
+
var PostgresPortUnavailable = class extends Error {
|
|
2239
|
+
appName;
|
|
2240
|
+
taken;
|
|
2241
|
+
constructor(appName, taken) {
|
|
2242
|
+
super(`Could not publish a port for ${appName}. In use on this server: ${[...taken].sort((a, b) => a - b).join(", ") || "none reported"}. The role DDL needs to reach the cluster from here.`);
|
|
2243
|
+
this.appName = appName;
|
|
2244
|
+
this.taken = taken;
|
|
2245
|
+
this.name = "PostgresPortUnavailable";
|
|
2246
|
+
}
|
|
2247
|
+
};
|
|
2248
|
+
/**
|
|
2249
|
+
* Run the manifest's DDL against a Dokploy Postgres.
|
|
2250
|
+
*
|
|
2251
|
+
* The cluster is only reachable from outside while an external port is
|
|
2252
|
+
* published, so this opens one, applies, and leaves it as it found it. The
|
|
2253
|
+
* alternative — running DDL from inside the network — needs a container to run
|
|
2254
|
+
* it in, which is what `DatabaseBootstrap` is on AWS and what a Dokploy target
|
|
2255
|
+
* has no equivalent for yet.
|
|
2256
|
+
*
|
|
2257
|
+
* The applier is the local target's, so every statement asks whether it is
|
|
2258
|
+
* needed first: a redeploy is free, and a half-applied run recovers by being
|
|
2259
|
+
* run again.
|
|
2260
|
+
*/
|
|
2261
|
+
/**
|
|
2262
|
+
* A high port for one service, the same one every time.
|
|
2263
|
+
*
|
|
2264
|
+
* Derived from the service name rather than random so two deploys of the same
|
|
2265
|
+
* database agree and two different databases do not collide — and in the
|
|
2266
|
+
* ephemeral range, above anything a server is likely to have bound
|
|
2267
|
+
* deliberately.
|
|
2268
|
+
*/
|
|
2269
|
+
function derivedPort(appName) {
|
|
2270
|
+
const digest = createHash("sha256").update(appName).digest();
|
|
2271
|
+
return 49152 + (digest[0] << 8 | digest[1]) % 16e3;
|
|
2272
|
+
}
|
|
2273
|
+
async function applyDeclaredStatements(api, postgres, serverHostname, statements) {
|
|
2274
|
+
let externalPort = await api.getPostgres(postgres.postgresId).then((current) => current.externalPort).catch(() => null) ?? void 0;
|
|
2275
|
+
const opened = externalPort === void 0;
|
|
2276
|
+
if (externalPort === void 0) {
|
|
2277
|
+
const taken = await api.publishedPorts().catch(() => /* @__PURE__ */ new Set());
|
|
2278
|
+
const candidates = [5432, derivedPort(postgres.appName)].filter((port) => !taken.has(port));
|
|
2279
|
+
for (const candidate of candidates) try {
|
|
2280
|
+
await api.savePostgresExternalPort(postgres.postgresId, candidate);
|
|
2281
|
+
externalPort = candidate;
|
|
2282
|
+
break;
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
if (!(error instanceof Error ? error.message : String(error)).includes("already in use")) throw error;
|
|
2285
|
+
logger.log(` Port ${candidate} is taken; trying another...`);
|
|
2286
|
+
}
|
|
2287
|
+
if (externalPort === void 0) throw new PostgresPortUnavailable(postgres.appName, [...taken]);
|
|
2288
|
+
logger.log(` Publishing ${postgres.appName} on ${externalPort}...`);
|
|
2289
|
+
}
|
|
2290
|
+
await api.deployPostgres(postgres.postgresId);
|
|
2291
|
+
await waitForPostgres(serverHostname, externalPort, postgres.databaseUser, postgres.databasePassword, postgres.databaseName);
|
|
2292
|
+
const client = { async query(database, sql, values) {
|
|
2293
|
+
const connection = new Client({
|
|
2294
|
+
host: serverHostname,
|
|
2295
|
+
port: externalPort,
|
|
2296
|
+
user: postgres.databaseUser,
|
|
2297
|
+
password: postgres.databasePassword,
|
|
2298
|
+
database: database ?? postgres.databaseName,
|
|
2299
|
+
connectionTimeoutMillis: 15e3
|
|
2300
|
+
});
|
|
2301
|
+
connection.on("error", () => {});
|
|
2302
|
+
await connection.connect();
|
|
2303
|
+
try {
|
|
2304
|
+
return (await connection.query(sql, values)).rows;
|
|
2305
|
+
} finally {
|
|
2306
|
+
await connection.end();
|
|
2307
|
+
}
|
|
2308
|
+
} };
|
|
2309
|
+
const unpublish = async () => {
|
|
2310
|
+
if (!opened) return;
|
|
2311
|
+
await api.savePostgresExternalPort(postgres.postgresId, null).then(() => api.deployPostgres(postgres.postgresId)).catch(() => {
|
|
2312
|
+
logger.log(` ⚠ Could not close external port ${externalPort} on ${postgres.appName} — close it in Dokploy.`);
|
|
2313
|
+
});
|
|
2314
|
+
};
|
|
2315
|
+
let lastError;
|
|
2316
|
+
try {
|
|
2317
|
+
for (let attempt = 1; attempt <= 3; attempt++) try {
|
|
2318
|
+
return await applyDeclared(client, statements);
|
|
2319
|
+
} catch (error) {
|
|
2320
|
+
lastError = error;
|
|
2321
|
+
if (attempt === 3) break;
|
|
2322
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2323
|
+
logger.log(` ⏳ Cluster still settling (${message}); retrying (${attempt}/2)...`);
|
|
2324
|
+
await new Promise((resolve) => setTimeout(resolve, 1e4));
|
|
2325
|
+
await waitForPostgres(serverHostname, externalPort, postgres.databaseUser, postgres.databasePassword, postgres.databaseName).catch(() => {});
|
|
2326
|
+
}
|
|
2327
|
+
throw lastError;
|
|
2328
|
+
} finally {
|
|
2329
|
+
await unpublish();
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Get the server hostname from the Dokploy endpoint URL
|
|
2334
|
+
*/
|
|
2335
|
+
function getServerHostname(endpoint) {
|
|
2336
|
+
return new URL(endpoint).hostname;
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Generate image tag from stage and timestamp
|
|
2340
|
+
*/
|
|
2341
|
+
function generateTag(stage) {
|
|
2342
|
+
return `${stage}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* The things this deploy creates a container for.
|
|
2346
|
+
*
|
|
2347
|
+
* A deploy unit is a *declaration*, not a config entry — and since that is now
|
|
2348
|
+
* true of every app, not just the ones being deployed, this is the workspace's
|
|
2349
|
+
* own derivation under the name the deploy phases call it by.
|
|
2350
|
+
*/
|
|
2351
|
+
function deployUnits(manifest, workspace) {
|
|
2352
|
+
return derivedApps(manifest, workspace);
|
|
2353
|
+
}
|
|
2354
|
+
/**
|
|
2355
|
+
* The variables an app cannot start without.
|
|
2356
|
+
*
|
|
2357
|
+
* The sniffer reports every key it saw in `requiredEnvVars` and says which of
|
|
2358
|
+
* them were read through `.optional()` or `.default()` in `optionalEnvVars`.
|
|
2359
|
+
* Reading only the first treats a key that is *absent by design* as a missing
|
|
2360
|
+
* secret — `AUTH_COOKIE_DOMAIN` is published only when a surface has a domain
|
|
2361
|
+
* to widen a cookie to, and one host has nothing to share it with.
|
|
2362
|
+
*
|
|
2363
|
+
* The same distinction `bundleServer` draws. It is drawn twice because the
|
|
2364
|
+
* bundle and the deploy each validate, and the deploy was the half still
|
|
2365
|
+
* failing on an answer that was correct.
|
|
2366
|
+
*/
|
|
2367
|
+
function requiredOf(requirements) {
|
|
2368
|
+
if (!requirements) return [];
|
|
2369
|
+
const optional = new Set(requirements.optionalEnvVars);
|
|
2370
|
+
return requirements.requiredEnvVars.map((name) => name.endsWith("?") ? name.slice(0, -1) : name).filter((name) => !optional.has(name));
|
|
2371
|
+
}
|
|
2372
|
+
/** Apps were asked for by name that the workspace does not have. */
|
|
2373
|
+
var UnknownDeployApps = class extends Error {
|
|
2374
|
+
apps;
|
|
2375
|
+
available;
|
|
2376
|
+
constructor(apps, available) {
|
|
2377
|
+
super(`Unknown apps: ${apps.join(", ")}\nAvailable apps: ${available.join(", ")}`);
|
|
2378
|
+
this.apps = apps;
|
|
2379
|
+
this.available = available;
|
|
2380
|
+
this.name = "UnknownDeployApps";
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
/** Every app asked for deploys somewhere other than Dokploy. */
|
|
2384
|
+
var NoDeployableApps = class extends Error {
|
|
2385
|
+
stage;
|
|
2386
|
+
constructor(stage) {
|
|
2387
|
+
super("No apps to deploy. All selected apps have unsupported deploy targets.");
|
|
2388
|
+
this.stage = stage;
|
|
2389
|
+
this.name = "NoDeployableApps";
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
/** A backend failed, so the run stopped before deploying anything after it. */
|
|
2393
|
+
var BackendDeployFailed = class extends Error {
|
|
2394
|
+
app;
|
|
2395
|
+
constructor(app, cause) {
|
|
2396
|
+
super(`Backend deployment failed for ${app}. Aborting to prevent partial deployment.`, { cause });
|
|
2397
|
+
this.app = app;
|
|
2398
|
+
this.name = "BackendDeployFailed";
|
|
2399
|
+
}
|
|
2400
|
+
};
|
|
2401
|
+
/** An app's environment needs values the stage does not have. */
|
|
2402
|
+
var MissingEnvVars = class extends Error {
|
|
2403
|
+
app;
|
|
2404
|
+
missing;
|
|
2405
|
+
stage;
|
|
2406
|
+
constructor(app, missing, stage) {
|
|
2407
|
+
super(formatMissingVarsError(app, [...missing], stage));
|
|
2408
|
+
this.app = app;
|
|
2409
|
+
this.missing = missing;
|
|
2410
|
+
this.stage = stage;
|
|
2411
|
+
this.name = "MissingEnvVars";
|
|
2412
|
+
}
|
|
2413
|
+
};
|
|
2414
|
+
/** Workspace deploys go to Dokploy; another provider was asked for. */
|
|
2415
|
+
var DeployProviderUnsupported = class extends Error {
|
|
2416
|
+
provider;
|
|
2417
|
+
constructor(provider) {
|
|
2418
|
+
super(`Workspace deployment only supports Dokploy. Got: ${provider}`);
|
|
2419
|
+
this.provider = provider;
|
|
2420
|
+
this.name = "DeployProviderUnsupported";
|
|
2421
|
+
}
|
|
2422
|
+
};
|
|
2423
|
+
/** How `resolveProject` found a project, in the journal's words. */
|
|
2424
|
+
const PROJECT_VIA = {
|
|
2425
|
+
state: "recorded",
|
|
2426
|
+
marker: "found",
|
|
2427
|
+
created: "created"
|
|
2428
|
+
};
|
|
2429
|
+
/**
|
|
2430
|
+
* Deploy every app in a workspace to Dokploy.
|
|
2431
|
+
*
|
|
2432
|
+
* Two-phase orchestration:
|
|
2433
|
+
* - PHASE 1: Deploy backend apps (with encrypted secrets)
|
|
2434
|
+
* - PHASE 2: Deploy frontend apps (with public URLs from backends)
|
|
2435
|
+
*
|
|
2436
|
+
* Security model:
|
|
2437
|
+
* - Backend apps get encrypted secrets embedded at build time
|
|
2438
|
+
* - Only GKM_MASTER_KEY is injected as Dokploy env var
|
|
2439
|
+
* - Frontend apps get public URLs baked in at build time (no secrets)
|
|
2440
|
+
*
|
|
2441
|
+
* The engine under `deploy()` and `gkm deploy`. It never prompts and never
|
|
2442
|
+
* exits: credentials come from `ctx.credentials`, progress goes to
|
|
2443
|
+
* `ctx.emit` and to `output`, and every failure is thrown.
|
|
2444
|
+
*
|
|
2445
|
+
* @internal
|
|
2446
|
+
*/
|
|
2447
|
+
async function runDeploy(source, request, ctx) {
|
|
2448
|
+
const { stage, tag, apps: selectedApps } = request;
|
|
2449
|
+
ctx.emit({
|
|
2450
|
+
type: "phase.started",
|
|
2451
|
+
phase: "validate"
|
|
2452
|
+
});
|
|
2453
|
+
const configured = typeof source === "function" ? await source() : source;
|
|
2454
|
+
const manifest = await discover({
|
|
2455
|
+
patterns: constructGlobs(configured),
|
|
2456
|
+
cwd: configured.root
|
|
2457
|
+
});
|
|
2458
|
+
const units = deployUnits(manifest, configured);
|
|
2459
|
+
const workspace = Object.keys(units).length > 0 ? {
|
|
2460
|
+
...configured,
|
|
2461
|
+
apps: units
|
|
2462
|
+
} : configured;
|
|
2463
|
+
const identity = deployIdentity(workspace, stage);
|
|
2464
|
+
logger.log(`\n🚀 Deploying workspace "${workspace.name}" to Dokploy...`);
|
|
2465
|
+
logger.log(` Stage: ${stage}`);
|
|
2466
|
+
const imageTag = tag ?? generateTag(stage);
|
|
2467
|
+
logger.log(` Tag: ${imageTag}`);
|
|
2468
|
+
const buildOrder = getAppBuildOrder(workspace);
|
|
2469
|
+
let appsToDeployNames = buildOrder;
|
|
2470
|
+
if (selectedApps && selectedApps.length > 0) {
|
|
2471
|
+
const invalidApps = selectedApps.filter((name) => !workspace.apps[name]);
|
|
2472
|
+
if (invalidApps.length > 0) throw new UnknownDeployApps(invalidApps, Object.keys(workspace.apps));
|
|
2473
|
+
appsToDeployNames = buildOrder.filter((name) => selectedApps.includes(name));
|
|
2474
|
+
logger.log(` Deploying apps: ${appsToDeployNames.join(", ")}`);
|
|
2475
|
+
} else logger.log(` Deploying all apps: ${appsToDeployNames.join(", ")}`);
|
|
2476
|
+
const skipped = [];
|
|
2477
|
+
const skip = (app, reason) => {
|
|
2478
|
+
skipped.push({
|
|
2479
|
+
app,
|
|
2480
|
+
reason
|
|
2481
|
+
});
|
|
2482
|
+
ctx.emit({
|
|
2483
|
+
type: "app.skipped",
|
|
2484
|
+
app,
|
|
2485
|
+
reason
|
|
2486
|
+
});
|
|
2487
|
+
};
|
|
2488
|
+
const dokployApps = appsToDeployNames.filter((name) => {
|
|
2489
|
+
const target = workspace.apps[name].resolvedDeployTarget;
|
|
2490
|
+
if (target === "sst") {
|
|
2491
|
+
const reason = `it deploys with SST — run \`gkm build && sst deploy --stage ${stage}\``;
|
|
2492
|
+
logger.log(` ⚠️ Skipping ${name}: ${reason}`);
|
|
2493
|
+
skip(name, reason);
|
|
2494
|
+
return false;
|
|
2495
|
+
}
|
|
2496
|
+
if (!isDeployTargetSupported(target)) {
|
|
2497
|
+
const reason = getDeployTargetError(target, name);
|
|
2498
|
+
logger.log(` ⚠️ Skipping ${name}: ${reason}`);
|
|
2499
|
+
skip(name, reason);
|
|
2500
|
+
return false;
|
|
2501
|
+
}
|
|
2502
|
+
return true;
|
|
2503
|
+
});
|
|
2504
|
+
if (dokployApps.length === 0) throw new NoDeployableApps(stage);
|
|
2505
|
+
appsToDeployNames = dokployApps;
|
|
2506
|
+
for (const name of appsToDeployNames) if (workspace.apps[name].type === "mobile") skip(name, MOBILE_SKIP_REASON);
|
|
2507
|
+
ctx.emit({
|
|
2508
|
+
type: "deploy.started",
|
|
2509
|
+
stage,
|
|
2510
|
+
identity: identity.key,
|
|
2511
|
+
tag: imageTag,
|
|
2512
|
+
apps: appsToDeployNames.filter((name) => workspace.apps[name].type !== "mobile"),
|
|
2513
|
+
dryRun: ctx.dryRun
|
|
2514
|
+
});
|
|
2515
|
+
const store = await createStateStore({
|
|
2516
|
+
config: workspace.state,
|
|
2517
|
+
workspaceRoot: workspace.root,
|
|
2518
|
+
workspaceName: workspace.name
|
|
2519
|
+
});
|
|
2520
|
+
const run = {
|
|
2521
|
+
workspace,
|
|
2522
|
+
manifest,
|
|
2523
|
+
stage,
|
|
2524
|
+
imageTag,
|
|
2525
|
+
appsToDeployNames,
|
|
2526
|
+
identity,
|
|
2527
|
+
store,
|
|
2528
|
+
skipped,
|
|
2529
|
+
ctx
|
|
2530
|
+
};
|
|
2531
|
+
if (ctx.dryRun) return planDeploy(run);
|
|
2532
|
+
const lock = await store.lock(stage, { operation: "deploy" });
|
|
2533
|
+
try {
|
|
2534
|
+
return await deployLocked(run);
|
|
2535
|
+
} finally {
|
|
2536
|
+
await lock.release();
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* The stage's secrets and every app's environment requirements. A dry run
|
|
2541
|
+
* reads the secrets and generates nothing into the store.
|
|
2542
|
+
*/
|
|
2543
|
+
async function preflight({ workspace, manifest, stage, ctx }) {
|
|
2544
|
+
logger.log("\n🔐 Loading secrets and analyzing environment requirements...");
|
|
2545
|
+
const secretsStore = await secretsStoreFor(workspace, stage, { ...ctx.home ? { home: ctx.home } : {} });
|
|
2546
|
+
const stored = await secretsStore.read(stage);
|
|
2547
|
+
if (!stored) logger.log(` ⚠️ No secrets found for stage "${stage}"; starting them`);
|
|
2548
|
+
const { secrets: stageSecrets, generated } = withGeneratedSecrets(stored ?? initStageSecrets(stage), manifest);
|
|
2549
|
+
if (generated.length > 0) {
|
|
2550
|
+
if (ctx.dryRun) logger.log(` 🔑 Would generate for "${stage}" (${secretsStore.name}): ${generated.join(", ")}`);
|
|
2551
|
+
else {
|
|
2552
|
+
await secretsStore.write(stage, stageSecrets);
|
|
2553
|
+
logger.log(` 🔑 Generated for "${stage}" (${secretsStore.name}): ${generated.join(", ")}`);
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
const sniffedApps = await sniffAllApps(workspace.apps, workspace.root);
|
|
2557
|
+
const encryptedSecrets = stageSecrets ? prepareSecretsForAllApps(stageSecrets, sniffedApps) : /* @__PURE__ */ new Map();
|
|
2558
|
+
if (stageSecrets) {
|
|
2559
|
+
const report = generateSecretsReport(encryptedSecrets, sniffedApps);
|
|
2560
|
+
if (report.appsWithSecrets.length > 0) logger.log(` ✓ Encrypted secrets for: ${report.appsWithSecrets.join(", ")}`);
|
|
2561
|
+
if (report.appsWithMissingSecrets.length > 0) for (const { appName, missing } of report.appsWithMissingSecrets) logger.log(` ⚠️ ${appName}: Missing secrets: ${missing.join(", ")}`);
|
|
2562
|
+
}
|
|
2563
|
+
return {
|
|
2564
|
+
stageSecrets,
|
|
2565
|
+
sniffedApps,
|
|
2566
|
+
encryptedSecrets
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
/** Everything a deploy does while it holds the stage's lock. */
|
|
2570
|
+
async function deployLocked(run) {
|
|
2571
|
+
const { workspace, manifest, stage, imageTag, appsToDeployNames, identity, store, ctx } = run;
|
|
2572
|
+
const { stageSecrets, sniffedApps, encryptedSecrets } = await preflight(run);
|
|
2573
|
+
ctx.emit({
|
|
2574
|
+
type: "phase.finished",
|
|
2575
|
+
phase: "validate"
|
|
2576
|
+
});
|
|
2577
|
+
const changes = [];
|
|
2578
|
+
const applied = (change, via) => {
|
|
2579
|
+
changes.push(change);
|
|
2580
|
+
ctx.emit({
|
|
2581
|
+
type: "resource.applied",
|
|
2582
|
+
...change,
|
|
2583
|
+
via
|
|
2584
|
+
});
|
|
2585
|
+
};
|
|
2586
|
+
ctx.signal?.throwIfAborted();
|
|
2587
|
+
ctx.emit({
|
|
2588
|
+
type: "phase.started",
|
|
2589
|
+
phase: "provision"
|
|
2590
|
+
});
|
|
2591
|
+
const { api, endpoint } = await dokployApi(workspace, ctx);
|
|
2592
|
+
logger.log("\n📋 Loading deploy state...");
|
|
2593
|
+
const journal = await DeployJournal.open(store, stage, () => createEmptyState(stage, "", ""));
|
|
2594
|
+
const state = journal.state;
|
|
2595
|
+
if (journal.existed) logger.log(` Found existing state for stage "${stage}"`);
|
|
2596
|
+
else logger.log(` Creating new state for stage "${stage}"`);
|
|
2597
|
+
const unfinished = journal.unfinished();
|
|
2598
|
+
if (unfinished.length > 0) logger.log(` ⚠ A previous run stopped while creating: ${unfinished.join(", ")} — looking for them before creating anything`);
|
|
2599
|
+
logger.log("\n📁 Setting up Dokploy project...");
|
|
2600
|
+
logger.log(` Identity: ${identity.key}`);
|
|
2601
|
+
const projectEntry = {
|
|
2602
|
+
key: "project",
|
|
2603
|
+
type: "project",
|
|
2604
|
+
data: {
|
|
2605
|
+
name: projectName(identity),
|
|
2606
|
+
identity: identity.key
|
|
2607
|
+
}
|
|
2608
|
+
};
|
|
2609
|
+
const project = await resolveProject(api, identity, state.projectId || void 0, (message) => logger.log(message), { beforeCreate: () => journal.pending(projectEntry) });
|
|
2610
|
+
await journal.ready(projectEntry, project.projectId);
|
|
2611
|
+
applied({
|
|
2612
|
+
key: "project",
|
|
2613
|
+
resourceType: "project",
|
|
2614
|
+
action: project.via === "created" ? "create" : "reuse",
|
|
2615
|
+
id: project.projectId
|
|
2616
|
+
}, PROJECT_VIA[project.via]);
|
|
2617
|
+
if (project.via === "created") logger.log(` ✓ Created project: ${project.projectId}`);
|
|
2618
|
+
else logger.log(` Found existing project: ${project.name}`);
|
|
2619
|
+
const environmentEntry = {
|
|
2620
|
+
key: "environment",
|
|
2621
|
+
type: "environment",
|
|
2622
|
+
data: { name: stage }
|
|
2623
|
+
};
|
|
2624
|
+
const environment = await journal.ensure(environmentEntry, {
|
|
2625
|
+
find: async () => project.environments.find((e) => e.name.toLowerCase() === stage.toLowerCase()),
|
|
2626
|
+
create: () => {
|
|
2627
|
+
logger.log(` Creating "${stage}" environment...`);
|
|
2628
|
+
return api.createEnvironment(project.projectId, stage);
|
|
2629
|
+
},
|
|
2630
|
+
id: (e) => e.environmentId
|
|
2631
|
+
});
|
|
2632
|
+
const environmentId = environment.resource.environmentId;
|
|
2633
|
+
applied({
|
|
2634
|
+
key: "environment",
|
|
2635
|
+
resourceType: "environment",
|
|
2636
|
+
action: environment.via === "created" ? "create" : "reuse",
|
|
2637
|
+
id: environmentId
|
|
2638
|
+
}, environment.via);
|
|
2639
|
+
if (environment.via === "created") logger.log(` ✓ Created environment: ${stage}`);
|
|
2640
|
+
else logger.log(` Using environment: ${environment.resource.name}`);
|
|
2641
|
+
if (journal.existed) {
|
|
2642
|
+
if (state.projectId && state.projectId !== project.projectId) logger.log(` ⚠ Project ID changed, updating state`);
|
|
2643
|
+
if (state.environmentId && state.environmentId !== environmentId) logger.log(` ⚠ Environment ID changed, updating state`);
|
|
2644
|
+
}
|
|
2645
|
+
state.projectId = project.projectId;
|
|
2646
|
+
state.environmentId = environmentId;
|
|
2647
|
+
state.identity = identity.key;
|
|
2648
|
+
logger.log("\n🐳 Checking registry...");
|
|
2649
|
+
const registry = workspace.deploy.dokploy?.registry;
|
|
2650
|
+
let registryCreated = false;
|
|
2651
|
+
const dokployRegistry = await resolveRegistry(api, {
|
|
2652
|
+
stage,
|
|
2653
|
+
registry,
|
|
2654
|
+
configuredId: workspace.deploy.dokploy?.registryId,
|
|
2655
|
+
stateId: state.registryId,
|
|
2656
|
+
log: (message) => logger.log(message),
|
|
2657
|
+
create: async (url) => {
|
|
2658
|
+
const login = await ctx.credentials.get({
|
|
2659
|
+
kind: "registry",
|
|
2660
|
+
url
|
|
2661
|
+
}, { signal: ctx.signal });
|
|
2662
|
+
if (!login) throw new MissingCredential("registry", url);
|
|
2663
|
+
const created = await api.createRegistry("Default Registry", url, login.username, login.password);
|
|
2664
|
+
registryCreated = true;
|
|
2665
|
+
logger.log(` ✓ Registry created: ${created.registryId}`);
|
|
2666
|
+
return created;
|
|
2667
|
+
}
|
|
2668
|
+
});
|
|
2669
|
+
const registryId = dokployRegistry.registryId;
|
|
2670
|
+
state.registryId = registryId;
|
|
2671
|
+
applied({
|
|
2672
|
+
key: "registry",
|
|
2673
|
+
resourceType: "registry",
|
|
2674
|
+
action: registryCreated ? "create" : "reuse",
|
|
2675
|
+
id: registryId
|
|
2676
|
+
}, registryCreated ? "created" : "found");
|
|
2677
|
+
logger.log(` Using registry: ${dokployRegistry.registryName}`);
|
|
2678
|
+
await journal.save();
|
|
2679
|
+
const skippedMobileApps = appsToDeployNames.filter((name) => workspace.apps[name].type === "mobile");
|
|
2680
|
+
if (skippedMobileApps.length > 0) logger.log(`\n📱 Skipping ${skippedMobileApps.length} mobile app(s) — deploy via framework toolchain: ${skippedMobileApps.join(", ")}`);
|
|
2681
|
+
let declaredEnv = {};
|
|
2682
|
+
let declaredClusters = {};
|
|
2683
|
+
{
|
|
2684
|
+
logger.log("\n📦 Provisioning declared constructs...");
|
|
2685
|
+
const appUrls = {};
|
|
2686
|
+
for (const appName of appsToDeployNames) {
|
|
2687
|
+
if (workspace.apps[appName]?.type !== "backend") continue;
|
|
2688
|
+
appUrls[appName] = `https://${hostOf(workspace, appName, stage)}`;
|
|
2689
|
+
}
|
|
2690
|
+
const declared = await provisionDeclared({
|
|
2691
|
+
api,
|
|
2692
|
+
workspace,
|
|
2693
|
+
projectId: project.projectId,
|
|
2694
|
+
environmentId,
|
|
2695
|
+
stage,
|
|
2696
|
+
scope: identity.scope,
|
|
2697
|
+
appUrls,
|
|
2698
|
+
supplied: stageSecrets.custom,
|
|
2699
|
+
seed: stageSecrets.seed,
|
|
2700
|
+
manifest
|
|
2701
|
+
});
|
|
2702
|
+
declaredEnv = declared.env;
|
|
2703
|
+
declaredClusters = declared.clusters;
|
|
2704
|
+
if (Object.keys(declaredEnv).length > 0) logger.log(` 🔌 Resolved ${Object.keys(declaredEnv).length} declared URL(s)`);
|
|
2705
|
+
if (declared.statements.length > 0) {
|
|
2706
|
+
const serverHostname = getServerHostname(endpoint);
|
|
2707
|
+
for (const [databaseName, cluster] of Object.entries(declared.clusters)) {
|
|
2708
|
+
const statements = declared.statements.filter((statement) => statement.database === databaseName);
|
|
2709
|
+
if (statements.length === 0) continue;
|
|
2710
|
+
const created = await applyDeclaredStatements(api, cluster, serverHostname, statements);
|
|
2711
|
+
logger.log(` 🗄️ ${databaseName}: applied ${statements.length} statement(s), ${created} new`);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
const backupCluster = Object.values(declaredClusters)[0];
|
|
2716
|
+
if (workspace.deploy?.backups && backupCluster) {
|
|
2717
|
+
logger.log("\n💾 Provisioning backup destination...");
|
|
2718
|
+
const { provisionBackupDestination } = await import("./backup-provisioner-DIVCbfee.mjs");
|
|
2719
|
+
const backupState = await provisionBackupDestination({
|
|
2720
|
+
api,
|
|
2721
|
+
projectId: project.projectId,
|
|
2722
|
+
projectName: identity.scope,
|
|
2723
|
+
stage,
|
|
2724
|
+
config: workspace.deploy.backups,
|
|
2725
|
+
existingState: getBackupState(state),
|
|
2726
|
+
logger
|
|
2727
|
+
});
|
|
2728
|
+
setBackupState(state, backupState);
|
|
2729
|
+
if (!backupState.postgresBackupId) {
|
|
2730
|
+
const backupSchedule = workspace.deploy.backups.schedule ?? "0 2 * * *";
|
|
2731
|
+
const backupRetention = workspace.deploy.backups.retention ?? 30;
|
|
2732
|
+
logger.log(" Creating postgres backup schedule...");
|
|
2733
|
+
setPostgresBackupId(state, (await api.createPostgresBackup({
|
|
2734
|
+
schedule: backupSchedule,
|
|
2735
|
+
prefix: `${stage}/postgres`,
|
|
2736
|
+
destinationId: backupState.destinationId,
|
|
2737
|
+
database: backupCluster.databaseName,
|
|
2738
|
+
postgresId: backupCluster.postgresId,
|
|
2739
|
+
enabled: true,
|
|
2740
|
+
keepLatestCount: backupRetention
|
|
2741
|
+
})).backupId);
|
|
2742
|
+
logger.log(` ✓ Postgres backup schedule created (${backupSchedule})`);
|
|
2743
|
+
} else logger.log(" ✓ Using existing postgres backup schedule");
|
|
2744
|
+
}
|
|
2745
|
+
ctx.emit({
|
|
2746
|
+
type: "phase.finished",
|
|
2747
|
+
phase: "provision"
|
|
2748
|
+
});
|
|
2749
|
+
const backendApps = appsToDeployNames.filter((name) => workspace.apps[name].type === "backend");
|
|
2750
|
+
const frontendApps = appsToDeployNames.filter((name) => workspace.apps[name].type === "web");
|
|
2751
|
+
const publicUrls = {};
|
|
2752
|
+
const results = [];
|
|
2753
|
+
const appHostnames = /* @__PURE__ */ new Map();
|
|
2754
|
+
const appDomainIds = /* @__PURE__ */ new Map();
|
|
2755
|
+
/** An app is up: say so, and remember where. */
|
|
2756
|
+
const deployed = (appName, result, host) => {
|
|
2757
|
+
const url = `https://${host}`;
|
|
2758
|
+
results.push({
|
|
2759
|
+
...result,
|
|
2760
|
+
success: true,
|
|
2761
|
+
url
|
|
2762
|
+
});
|
|
2763
|
+
ctx.emit({
|
|
2764
|
+
type: "app.deployed",
|
|
2765
|
+
app: appName,
|
|
2766
|
+
applicationId: result.applicationId,
|
|
2767
|
+
imageRef: result.imageRef,
|
|
2768
|
+
url
|
|
2769
|
+
});
|
|
2770
|
+
};
|
|
2771
|
+
/** An app failed: say so, and record it in the result. */
|
|
2772
|
+
const failed = (appName, app, error) => {
|
|
2773
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2774
|
+
logger.log(` ✗ Failed to deploy ${appName}: ${message}`);
|
|
2775
|
+
results.push({
|
|
2776
|
+
appName,
|
|
2777
|
+
type: app.type,
|
|
2778
|
+
success: false,
|
|
2779
|
+
error: message
|
|
2780
|
+
});
|
|
2781
|
+
ctx.emit({
|
|
2782
|
+
type: "app.failed",
|
|
2783
|
+
app: appName,
|
|
2784
|
+
error: eventError(error)
|
|
2785
|
+
});
|
|
2786
|
+
};
|
|
2787
|
+
/** An image is pushed: worth keeping even if what follows fails. */
|
|
2788
|
+
const built = async (appName, image, ref) => {
|
|
2789
|
+
setDeployedImage(state, appName, {
|
|
2790
|
+
ref: image.imageRef ?? ref,
|
|
2791
|
+
...image.digest ? { digest: image.digest } : {}
|
|
2792
|
+
});
|
|
2793
|
+
await journal.save();
|
|
2794
|
+
ctx.emit({
|
|
2795
|
+
type: "artifact.built",
|
|
2796
|
+
app: appName,
|
|
2797
|
+
imageRef: image.imageRef ?? ref,
|
|
2798
|
+
...image.digest ? { digest: image.digest } : {}
|
|
2799
|
+
});
|
|
2800
|
+
changes.push({
|
|
2801
|
+
key: `image:${appName}`,
|
|
2802
|
+
resourceType: "image",
|
|
2803
|
+
action: "build",
|
|
2804
|
+
id: image.digest ?? image.imageRef ?? ref
|
|
2805
|
+
});
|
|
2806
|
+
};
|
|
2807
|
+
const frontendUrls = [];
|
|
2808
|
+
for (const appName of frontendApps) frontendUrls.push(`https://${hostOf(workspace, appName, stage)}`);
|
|
2809
|
+
ctx.signal?.throwIfAborted();
|
|
2810
|
+
ctx.emit({
|
|
2811
|
+
type: "phase.started",
|
|
2812
|
+
phase: "release"
|
|
2813
|
+
});
|
|
2814
|
+
if (backendApps.length > 0) {
|
|
2815
|
+
logger.log("\n📦 PHASE 1: Deploying backend applications...");
|
|
2816
|
+
for (const appName of backendApps) {
|
|
2817
|
+
ctx.signal?.throwIfAborted();
|
|
2818
|
+
const app = workspace.apps[appName];
|
|
2819
|
+
logger.log(`\n ⚙️ Deploying ${appName}...`);
|
|
2820
|
+
try {
|
|
2821
|
+
const application = await ensureApplication(api, journal, appName, applicationName(identity, appName), project.projectId, environmentId, applied);
|
|
2822
|
+
const appSecrets = encryptedSecrets.get(appName);
|
|
2823
|
+
const credentials = appSecrets && appSecrets.secretCount > 0 ? {
|
|
2824
|
+
encrypted: appSecrets.payload.encrypted,
|
|
2825
|
+
iv: appSecrets.payload.iv
|
|
2826
|
+
} : void 0;
|
|
2827
|
+
if (appSecrets && credentials) logger.log(` Encrypted ${appSecrets.secretCount} secrets`);
|
|
2828
|
+
const imageRef$1 = imageRef(identity, appName, registry, imageTag);
|
|
2829
|
+
logger.log(` Building Docker image: ${imageRef$1}`);
|
|
2830
|
+
const image = await deployDocker({
|
|
2831
|
+
stage,
|
|
2832
|
+
tag: imageTag,
|
|
2833
|
+
skipPush: false,
|
|
2834
|
+
config: {
|
|
2835
|
+
registry,
|
|
2836
|
+
imageName: imageName(identity, appName),
|
|
2837
|
+
appName
|
|
2838
|
+
},
|
|
2839
|
+
credentials,
|
|
2840
|
+
...dockerRun(workspace, app, ctx)
|
|
2841
|
+
});
|
|
2842
|
+
await built(appName, image, imageRef$1);
|
|
2843
|
+
const backendHost = hostOf(workspace, appName, stage);
|
|
2844
|
+
const dependencyUrls = {};
|
|
2845
|
+
if (app.dependencies) {
|
|
2846
|
+
for (const dep of app.dependencies) if (publicUrls[dep]) dependencyUrls[dep] = publicUrls[dep];
|
|
2847
|
+
}
|
|
2848
|
+
const envContext = {
|
|
2849
|
+
app,
|
|
2850
|
+
appName,
|
|
2851
|
+
stage,
|
|
2852
|
+
state,
|
|
2853
|
+
appHostname: backendHost,
|
|
2854
|
+
frontendUrls,
|
|
2855
|
+
userSecrets: stageSecrets ?? void 0,
|
|
2856
|
+
masterKey: appSecrets?.masterKey,
|
|
2857
|
+
dependencyUrls
|
|
2858
|
+
};
|
|
2859
|
+
const sniffedVars = requiredOf(sniffedApps.get(appName));
|
|
2860
|
+
const { valid, missing, resolved } = validateEnvVars([.../* @__PURE__ */ new Set([
|
|
2861
|
+
"PORT",
|
|
2862
|
+
"NODE_ENV",
|
|
2863
|
+
"STAGE",
|
|
2864
|
+
...sniffedVars
|
|
2865
|
+
])], envContext);
|
|
2866
|
+
if (!valid) throw new MissingEnvVars(appName, missing, stage);
|
|
2867
|
+
const withDeclared = {
|
|
2868
|
+
...resolved,
|
|
2869
|
+
...declaredEnv
|
|
2870
|
+
};
|
|
2871
|
+
const envVars = Object.entries(withDeclared).map(([key, value]) => `${key}=${value}`);
|
|
2872
|
+
if (Object.keys(withDeclared).length > 0) logger.log(` Resolved ${Object.keys(withDeclared).length} env vars: ${Object.keys(withDeclared).sort().join(", ")}`);
|
|
2873
|
+
await api.saveDockerProvider(application.applicationId, imageRef$1, { registryId });
|
|
2874
|
+
await api.saveApplicationEnv(application.applicationId, envVars.join("\n"));
|
|
2875
|
+
logger.log(` Deploying to Dokploy...`);
|
|
2876
|
+
await api.deployApplication(application.applicationId);
|
|
2877
|
+
const domainId = await ensureDomain(api, journal, backendHost, app.port, application.applicationId, applied);
|
|
2878
|
+
appHostnames.set(appName, backendHost);
|
|
2879
|
+
if (domainId) appDomainIds.set(appName, domainId);
|
|
2880
|
+
publicUrls[appName] = `https://${backendHost}`;
|
|
2881
|
+
deployed(appName, {
|
|
2882
|
+
appName,
|
|
2883
|
+
type: app.type,
|
|
2884
|
+
applicationId: application.applicationId,
|
|
2885
|
+
imageRef: imageRef$1,
|
|
2886
|
+
...image.digest ? { digest: image.digest } : {}
|
|
2887
|
+
}, backendHost);
|
|
2888
|
+
logger.log(` ✓ ${appName} deployed successfully`);
|
|
2889
|
+
} catch (error) {
|
|
2890
|
+
if (ctx.signal?.aborted) throw ctx.signal.reason;
|
|
2891
|
+
failed(appName, app, error);
|
|
2892
|
+
throw new BackendDeployFailed(appName, error);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
if (frontendApps.length > 0) {
|
|
2897
|
+
logger.log("\n🌐 PHASE 2: Deploying frontend applications...");
|
|
2898
|
+
for (const appName of frontendApps) {
|
|
2899
|
+
ctx.signal?.throwIfAborted();
|
|
2900
|
+
const app = workspace.apps[appName];
|
|
2901
|
+
logger.log(`\n 🌐 Deploying ${appName}...`);
|
|
2902
|
+
try {
|
|
2903
|
+
const application = await ensureApplication(api, journal, appName, applicationName(identity, appName), project.projectId, environmentId, applied);
|
|
2904
|
+
const dependencyUrls = {};
|
|
2905
|
+
if (app.dependencies) {
|
|
2906
|
+
for (const dep of app.dependencies) if (publicUrls[dep]) dependencyUrls[dep] = publicUrls[dep];
|
|
2907
|
+
}
|
|
2908
|
+
const frontendHost = hostOf(workspace, appName, stage);
|
|
2909
|
+
const envContext = {
|
|
2910
|
+
app,
|
|
2911
|
+
appName,
|
|
2912
|
+
stage,
|
|
2913
|
+
state,
|
|
2914
|
+
appHostname: frontendHost,
|
|
2915
|
+
frontendUrls: [],
|
|
2916
|
+
userSecrets: stageSecrets ?? void 0,
|
|
2917
|
+
dependencyUrls
|
|
2918
|
+
};
|
|
2919
|
+
const { valid, missing, resolved } = validateEnvVars(requiredOf(sniffedApps.get(appName)), envContext);
|
|
2920
|
+
if (!valid) throw new MissingEnvVars(appName, missing, stage);
|
|
2921
|
+
if (Object.keys(resolved).length > 0) logger.log(` Resolved ${Object.keys(resolved).length} env vars: ${Object.keys(resolved).join(", ")}`);
|
|
2922
|
+
const publicPrefix = getPublicEnvPrefix(app.framework);
|
|
2923
|
+
const buildArgs = [];
|
|
2924
|
+
const publicUrlArgNames = [];
|
|
2925
|
+
if (publicPrefix) {
|
|
2926
|
+
for (const [key, value] of Object.entries(resolved)) if (key.startsWith(publicPrefix)) {
|
|
2927
|
+
buildArgs.push(`${key}=${value}`);
|
|
2928
|
+
publicUrlArgNames.push(key);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
if (buildArgs.length > 0) logger.log(` Build args: ${publicUrlArgNames.join(", ")}`);
|
|
2932
|
+
const imageRef$2 = imageRef(identity, appName, registry, imageTag);
|
|
2933
|
+
logger.log(` Building Docker image: ${imageRef$2}`);
|
|
2934
|
+
const image = await deployDocker({
|
|
2935
|
+
stage,
|
|
2936
|
+
tag: imageTag,
|
|
2937
|
+
skipPush: false,
|
|
2938
|
+
config: {
|
|
2939
|
+
registry,
|
|
2940
|
+
imageName: imageName(identity, appName),
|
|
2941
|
+
appName
|
|
2942
|
+
},
|
|
2943
|
+
buildArgs,
|
|
2944
|
+
publicUrlArgs: publicUrlArgNames,
|
|
2945
|
+
...dockerRun(workspace, app, ctx)
|
|
2946
|
+
});
|
|
2947
|
+
await built(appName, image, imageRef$2);
|
|
2948
|
+
const envVars = [
|
|
2949
|
+
`NODE_ENV=production`,
|
|
2950
|
+
`PORT=${app.port}`,
|
|
2951
|
+
`STAGE=${stage}`
|
|
2952
|
+
];
|
|
2953
|
+
for (const [key, value] of Object.entries(resolved)) envVars.push(`${key}=${value}`);
|
|
2954
|
+
await api.saveDockerProvider(application.applicationId, imageRef$2, { registryId });
|
|
2955
|
+
await api.saveApplicationEnv(application.applicationId, envVars.join("\n"));
|
|
2956
|
+
logger.log(` Deploying to Dokploy...`);
|
|
2957
|
+
await api.deployApplication(application.applicationId);
|
|
2958
|
+
const domainId = await ensureDomain(api, journal, frontendHost, app.port, application.applicationId, applied);
|
|
2959
|
+
appHostnames.set(appName, frontendHost);
|
|
2960
|
+
if (domainId) appDomainIds.set(appName, domainId);
|
|
2961
|
+
publicUrls[appName] = `https://${frontendHost}`;
|
|
2962
|
+
deployed(appName, {
|
|
2963
|
+
appName,
|
|
2964
|
+
type: app.type,
|
|
2965
|
+
applicationId: application.applicationId,
|
|
2966
|
+
imageRef: imageRef$2,
|
|
2967
|
+
...image.digest ? { digest: image.digest } : {}
|
|
2968
|
+
}, frontendHost);
|
|
2969
|
+
logger.log(` ✓ ${appName} deployed successfully`);
|
|
2970
|
+
} catch (error) {
|
|
2971
|
+
if (ctx.signal?.aborted) throw ctx.signal.reason;
|
|
2972
|
+
failed(appName, app, error);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
logger.log("\n📋 Saving deploy state...");
|
|
2977
|
+
await journal.save();
|
|
2978
|
+
logger.log(" ✓ State saved");
|
|
2979
|
+
ctx.emit({
|
|
2980
|
+
type: "phase.finished",
|
|
2981
|
+
phase: "release"
|
|
2982
|
+
});
|
|
2983
|
+
ctx.signal?.throwIfAborted();
|
|
2984
|
+
ctx.emit({
|
|
2985
|
+
type: "phase.started",
|
|
2986
|
+
phase: "verify"
|
|
2987
|
+
});
|
|
2988
|
+
const dnsConfig = workspace.deploy.dns;
|
|
2989
|
+
if (dnsConfig && appHostnames.size > 0) {
|
|
2990
|
+
const dnsResult = await orchestrateDns(appHostnames, dnsConfig, endpoint);
|
|
2991
|
+
if (dnsResult?.serverIp && appHostnames.size > 0) {
|
|
2992
|
+
await verifyDnsRecords(appHostnames, dnsResult.serverIp, state);
|
|
2993
|
+
await journal.save();
|
|
2994
|
+
}
|
|
2995
|
+
if (dnsResult?.success && appHostnames.size > 0) {
|
|
2996
|
+
logger.log("\n🔒 Validating domains for SSL certificates...");
|
|
2997
|
+
for (const [appName, hostname] of appHostnames) try {
|
|
2998
|
+
const result = await api.validateDomain(hostname);
|
|
2999
|
+
if (result.isValid) logger.log(` ✓ ${appName}: ${hostname} → ${result.resolvedIp}`);
|
|
3000
|
+
else logger.log(` ⚠ ${appName}: ${hostname} not valid`);
|
|
3001
|
+
} catch (validationError) {
|
|
3002
|
+
const message = validationError instanceof Error ? validationError.message : "Unknown error";
|
|
3003
|
+
logger.log(` ⚠ ${appName}: validation failed - ${message}`);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
ctx.emit({
|
|
3008
|
+
type: "phase.finished",
|
|
3009
|
+
phase: "verify"
|
|
3010
|
+
});
|
|
3011
|
+
const successCount = results.filter((r) => r.success).length;
|
|
3012
|
+
const failedCount = results.filter((r) => !r.success).length;
|
|
3013
|
+
logger.log(`\n${"─".repeat(50)}`);
|
|
3014
|
+
logger.log(`\n✅ Workspace deployment complete!`);
|
|
3015
|
+
logger.log(` Project: ${project.projectId}`);
|
|
3016
|
+
logger.log(` Successful: ${successCount}`);
|
|
3017
|
+
if (failedCount > 0) logger.log(` Failed: ${failedCount}`);
|
|
3018
|
+
if (Object.keys(publicUrls).length > 0) {
|
|
3019
|
+
logger.log("\n 📡 Deployed URLs:");
|
|
3020
|
+
for (const [name, url] of Object.entries(publicUrls)) logger.log(` ${name}: ${url}`);
|
|
3021
|
+
}
|
|
3022
|
+
return {
|
|
3023
|
+
apps: results,
|
|
3024
|
+
projectId: project.projectId,
|
|
3025
|
+
environmentId,
|
|
3026
|
+
successCount,
|
|
3027
|
+
failedCount,
|
|
3028
|
+
stage,
|
|
3029
|
+
identity: identity.key,
|
|
3030
|
+
tag: imageTag,
|
|
3031
|
+
dryRun: false,
|
|
3032
|
+
skipped: run.skipped,
|
|
3033
|
+
urls: publicUrls,
|
|
3034
|
+
changes
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
/**
|
|
3038
|
+
* An app's Dokploy application, through the journal: by the id recorded for
|
|
3039
|
+
* it, else by its name in the stage's environment, else created. Its id goes
|
|
3040
|
+
* into `state.applications` too, which the env resolver and `state:show` read.
|
|
3041
|
+
*/
|
|
3042
|
+
async function ensureApplication(api, journal, appName, dokployAppName, projectId, environmentId, applied) {
|
|
3043
|
+
const recorded = journal.record(`application:${appName}`);
|
|
3044
|
+
if (recorded?.status === "ready" && recorded.id) logger.log(` Using cached ID: ${recorded.id}`);
|
|
3045
|
+
const { resource, via, staleId } = await journal.ensure({
|
|
3046
|
+
key: `application:${appName}`,
|
|
3047
|
+
type: "application",
|
|
3048
|
+
data: { name: dokployAppName }
|
|
3049
|
+
}, {
|
|
3050
|
+
get: (id) => api.getApplication(id),
|
|
3051
|
+
find: () => api.findApplicationByName(projectId, dokployAppName, environmentId),
|
|
3052
|
+
create: () => api.createApplication(dokployAppName, projectId, environmentId),
|
|
3053
|
+
id: (application) => application.applicationId
|
|
3054
|
+
});
|
|
3055
|
+
if (staleId) logger.log(` ⚠ Cached ID invalid, will create new`);
|
|
3056
|
+
logger.log(` ${{
|
|
3057
|
+
recorded: "✓ Application found",
|
|
3058
|
+
resumed: "Resumed application a stopped run created",
|
|
3059
|
+
found: "Found existing application",
|
|
3060
|
+
created: "Created application"
|
|
3061
|
+
}[via]}: ${resource.applicationId}`);
|
|
3062
|
+
applied({
|
|
3063
|
+
key: `application:${appName}`,
|
|
3064
|
+
resourceType: "application",
|
|
3065
|
+
action: via === "created" ? "create" : "reuse",
|
|
3066
|
+
id: resource.applicationId
|
|
3067
|
+
}, via);
|
|
3068
|
+
setApplicationId(journal.state, appName, resource.applicationId);
|
|
3069
|
+
return resource;
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* The domain `host` routes to an application, through the journal — found on
|
|
3073
|
+
* the application before it is created, so a re-run never adds a second.
|
|
3074
|
+
*
|
|
3075
|
+
* A domain that cannot be created is reported and skipped rather than
|
|
3076
|
+
* failing the app: the application is deployed and reachable on Dokploy's
|
|
3077
|
+
* own address, and DNS is fixed by hand more easily than a half deploy.
|
|
3078
|
+
*/
|
|
3079
|
+
async function ensureDomain(api, journal, host, port, applicationId, applied) {
|
|
3080
|
+
try {
|
|
3081
|
+
const { resource, via } = await journal.ensure({
|
|
3082
|
+
key: `domain:${host}`,
|
|
3083
|
+
type: "domain",
|
|
3084
|
+
data: {
|
|
3085
|
+
host,
|
|
3086
|
+
applicationId
|
|
3087
|
+
}
|
|
3088
|
+
}, {
|
|
3089
|
+
find: async () => (await api.getDomainsByApplicationId(applicationId)).find((d) => d.host === host),
|
|
3090
|
+
create: () => api.createDomain({
|
|
3091
|
+
host,
|
|
3092
|
+
port,
|
|
3093
|
+
https: true,
|
|
3094
|
+
certificateType: "letsencrypt",
|
|
3095
|
+
applicationId
|
|
3096
|
+
}),
|
|
3097
|
+
id: (domain) => domain.domainId
|
|
3098
|
+
});
|
|
3099
|
+
logger.log(` ✓ Domain: https://${host} (${via === "created" ? "created" : "existing"})`);
|
|
3100
|
+
applied({
|
|
3101
|
+
key: `domain:${host}`,
|
|
3102
|
+
resourceType: "domain",
|
|
3103
|
+
action: via === "created" ? "create" : "reuse",
|
|
3104
|
+
id: resource.domainId
|
|
3105
|
+
}, via);
|
|
3106
|
+
return resource.domainId;
|
|
3107
|
+
} catch (domainError) {
|
|
3108
|
+
if (domainError instanceof StateVersionConflict || domainError instanceof StateStoreBusy || isAbort(domainError)) throw domainError;
|
|
3109
|
+
const message = domainError instanceof Error ? domainError.message : "Unknown error";
|
|
3110
|
+
logger.log(` ⚠ Domain creation failed: ${message}`);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
/** Whether `error` is a run being stopped rather than something failing. */
|
|
3115
|
+
function isAbort(error) {
|
|
3116
|
+
return error instanceof Error && error.name === "AbortError";
|
|
3117
|
+
}
|
|
3118
|
+
/** Where docker runs for one app: in the app's own directory. */
|
|
3119
|
+
function dockerRun(workspace, app, ctx) {
|
|
3120
|
+
return {
|
|
3121
|
+
cwd: resolve(workspace.root, app.path),
|
|
3122
|
+
...ctx.signal ? { signal: ctx.signal } : {},
|
|
3123
|
+
...ctx.stdio ? { stdio: ctx.stdio } : {}
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
/** The Dokploy login, from the run's provider, or `MissingCredential`. */
|
|
3127
|
+
async function dokployApi(workspace, ctx) {
|
|
3128
|
+
const configured = workspace.deploy.dokploy?.endpoint;
|
|
3129
|
+
const creds = await ctx.credentials.get({
|
|
3130
|
+
kind: "dokploy",
|
|
3131
|
+
...configured ? { endpoint: configured } : {}
|
|
3132
|
+
}, { signal: ctx.signal });
|
|
3133
|
+
if (!creds) throw new MissingCredential("dokploy", configured);
|
|
3134
|
+
return {
|
|
3135
|
+
api: new DokployApi({
|
|
3136
|
+
baseUrl: creds.endpoint,
|
|
3137
|
+
token: creds.token,
|
|
3138
|
+
...ctx.signal ? { signal: ctx.signal } : {}
|
|
3139
|
+
}),
|
|
3140
|
+
endpoint: creds.endpoint
|
|
3141
|
+
};
|
|
3142
|
+
}
|
|
3143
|
+
/** Each app's public host, the same way the deploy resolves it. */
|
|
3144
|
+
function hostOf(workspace, appName, stage) {
|
|
3145
|
+
const app = workspace.apps[appName];
|
|
3146
|
+
return resolveHost(appName, app, stage, workspace.deploy?.domains, app.type === "web" && isMainFrontendApp(appName, app, workspace.apps));
|
|
3147
|
+
}
|
|
3148
|
+
/**
|
|
3149
|
+
* What a deploy would do, from the same lookups it starts with — and nothing
|
|
3150
|
+
* else: no lock, no state written, no secret generated, no Dokploy resource
|
|
3151
|
+
* created or changed, no image built or pushed.
|
|
3152
|
+
*/
|
|
3153
|
+
async function planDeploy(run) {
|
|
3154
|
+
const { workspace, manifest, stage, imageTag, appsToDeployNames, identity } = run;
|
|
3155
|
+
const { ctx } = run;
|
|
3156
|
+
await preflight(run);
|
|
3157
|
+
ctx.emit({
|
|
3158
|
+
type: "phase.finished",
|
|
3159
|
+
phase: "validate"
|
|
3160
|
+
});
|
|
3161
|
+
ctx.signal?.throwIfAborted();
|
|
3162
|
+
ctx.emit({
|
|
3163
|
+
type: "phase.started",
|
|
3164
|
+
phase: "plan"
|
|
3165
|
+
});
|
|
3166
|
+
logger.log("\n🔎 Dry run: nothing will be created, changed, built or pushed.");
|
|
3167
|
+
const changes = [];
|
|
3168
|
+
const plan = (change, detail) => {
|
|
3169
|
+
changes.push(change);
|
|
3170
|
+
ctx.emit({
|
|
3171
|
+
type: "resource.planned",
|
|
3172
|
+
...change
|
|
3173
|
+
});
|
|
3174
|
+
const sign = change.action === "reuse" ? "=" : "+";
|
|
3175
|
+
logger.log(` ${sign} ${change.action} ${change.key} (${detail})`);
|
|
3176
|
+
};
|
|
3177
|
+
const { api } = await dokployApi(workspace, ctx);
|
|
3178
|
+
const state = (await run.store.read(stage))?.state;
|
|
3179
|
+
const project = await findProject(api, identity, state?.projectId);
|
|
3180
|
+
if (project) plan({
|
|
3181
|
+
key: "project",
|
|
3182
|
+
resourceType: "project",
|
|
3183
|
+
action: "reuse",
|
|
3184
|
+
id: project.projectId
|
|
3185
|
+
}, project.needsClaim ? `${project.name}, would be claimed for ${identity.key}` : project.name);
|
|
3186
|
+
else plan({
|
|
3187
|
+
key: "project",
|
|
3188
|
+
resourceType: "project",
|
|
3189
|
+
action: "create"
|
|
3190
|
+
}, projectName(identity));
|
|
3191
|
+
const environment = project?.environments.find((e) => e.name.toLowerCase() === stage.toLowerCase());
|
|
3192
|
+
plan(environment ? {
|
|
3193
|
+
key: "environment",
|
|
3194
|
+
resourceType: "environment",
|
|
3195
|
+
action: "reuse",
|
|
3196
|
+
id: environment.environmentId
|
|
3197
|
+
} : {
|
|
3198
|
+
key: "environment",
|
|
3199
|
+
resourceType: "environment",
|
|
3200
|
+
action: "create"
|
|
3201
|
+
}, stage);
|
|
3202
|
+
let registryPlanned;
|
|
3203
|
+
const registry = await resolveRegistry(api, {
|
|
3204
|
+
stage,
|
|
3205
|
+
registry: workspace.deploy.dokploy?.registry,
|
|
3206
|
+
configuredId: workspace.deploy.dokploy?.registryId,
|
|
3207
|
+
stateId: state?.registryId,
|
|
3208
|
+
log: (message) => logger.log(message),
|
|
3209
|
+
create: async (url) => {
|
|
3210
|
+
registryPlanned = {
|
|
3211
|
+
key: "registry",
|
|
3212
|
+
resourceType: "registry",
|
|
3213
|
+
action: "create"
|
|
3214
|
+
};
|
|
3215
|
+
return {
|
|
3216
|
+
registryId: "",
|
|
3217
|
+
registryName: url
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
});
|
|
3221
|
+
plan(registryPlanned ?? {
|
|
3222
|
+
key: "registry",
|
|
3223
|
+
resourceType: "registry",
|
|
3224
|
+
action: "reuse",
|
|
3225
|
+
id: registry.registryId
|
|
3226
|
+
}, registry.registryName);
|
|
3227
|
+
for (const [id, declaration] of Object.entries(manifest)) plan({
|
|
3228
|
+
key: `construct:${id}`,
|
|
3229
|
+
resourceType: declaration.kind,
|
|
3230
|
+
action: "ensure"
|
|
3231
|
+
}, declaration.kind);
|
|
3232
|
+
const apps = [];
|
|
3233
|
+
const urls = {};
|
|
3234
|
+
for (const appName of appsToDeployNames) {
|
|
3235
|
+
const app = workspace.apps[appName];
|
|
3236
|
+
if (app.type === "mobile") continue;
|
|
3237
|
+
const dokployAppName = applicationName(identity, appName);
|
|
3238
|
+
const recordedId = state?.applications?.[appName];
|
|
3239
|
+
const existing = project && environment ? (recordedId ? await api.getApplication(recordedId).catch(() => null) : null) ?? await api.findApplicationByName(project.projectId, dokployAppName, environment.environmentId) : null;
|
|
3240
|
+
plan(existing ? {
|
|
3241
|
+
key: `application:${appName}`,
|
|
3242
|
+
resourceType: "application",
|
|
3243
|
+
action: "reuse",
|
|
3244
|
+
id: existing.applicationId
|
|
3245
|
+
} : {
|
|
3246
|
+
key: `application:${appName}`,
|
|
3247
|
+
resourceType: "application",
|
|
3248
|
+
action: "create"
|
|
3249
|
+
}, dokployAppName);
|
|
3250
|
+
const ref = imageRef(identity, appName, workspace.deploy.dokploy?.registry, imageTag);
|
|
3251
|
+
plan({
|
|
3252
|
+
key: `image:${appName}`,
|
|
3253
|
+
resourceType: "image",
|
|
3254
|
+
action: "build"
|
|
3255
|
+
}, ref);
|
|
3256
|
+
const host = hostOf(workspace, appName, stage);
|
|
3257
|
+
const domain = existing ? (await api.getDomainsByApplicationId(existing.applicationId)).find((d) => d.host === host) : void 0;
|
|
3258
|
+
plan(domain ? {
|
|
3259
|
+
key: `domain:${host}`,
|
|
3260
|
+
resourceType: "domain",
|
|
3261
|
+
action: "reuse",
|
|
3262
|
+
id: domain.domainId
|
|
3263
|
+
} : {
|
|
3264
|
+
key: `domain:${host}`,
|
|
3265
|
+
resourceType: "domain",
|
|
3266
|
+
action: "create"
|
|
3267
|
+
}, `https://${host}`);
|
|
3268
|
+
urls[appName] = `https://${host}`;
|
|
3269
|
+
apps.push({
|
|
3270
|
+
appName,
|
|
3271
|
+
type: app.type,
|
|
3272
|
+
success: true,
|
|
3273
|
+
imageRef: ref,
|
|
3274
|
+
url: urls[appName],
|
|
3275
|
+
...existing ? { applicationId: existing.applicationId } : {}
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3278
|
+
const creates = changes.filter((c) => c.action !== "reuse").length;
|
|
3279
|
+
logger.log(`\n✅ Dry run complete: ${creates} to create or build, ${changes.length - creates} to reuse.`);
|
|
3280
|
+
ctx.emit({
|
|
3281
|
+
type: "phase.finished",
|
|
3282
|
+
phase: "plan"
|
|
3283
|
+
});
|
|
3284
|
+
return {
|
|
3285
|
+
apps,
|
|
3286
|
+
projectId: project?.projectId ?? "",
|
|
3287
|
+
environmentId: environment?.environmentId ?? "",
|
|
3288
|
+
successCount: apps.length,
|
|
3289
|
+
failedCount: 0,
|
|
3290
|
+
stage,
|
|
3291
|
+
identity: identity.key,
|
|
3292
|
+
tag: imageTag,
|
|
3293
|
+
dryRun: true,
|
|
3294
|
+
skipped: run.skipped,
|
|
3295
|
+
urls,
|
|
3296
|
+
changes
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
/** Why a mobile app is left out of a Dokploy deploy. */
|
|
3300
|
+
const MOBILE_SKIP_REASON = "deploys via its framework toolchain";
|
|
3301
|
+
//#endregion
|
|
3302
|
+
//#region src/deploy/deploy.ts
|
|
3303
|
+
/**
|
|
3304
|
+
* `deploy()`: a deploy as a library call.
|
|
3305
|
+
*
|
|
3306
|
+
* `gkm deploy` could only be driven from an interactive terminal: it prompted
|
|
3307
|
+
* for what it lacked and exited the process on failure, worked out of
|
|
3308
|
+
* `process.cwd()`, and reported progress by printing. Here the caller says
|
|
3309
|
+
* where the project is, hands over the credentials and a signal, and gets back
|
|
3310
|
+
* a run — events to iterate while it goes, and a structured result at the end.
|
|
3311
|
+
* Nothing below this asks a person anything or ends the process; `gkm deploy`
|
|
3312
|
+
* is a thin wrapper that does both.
|
|
3313
|
+
*/
|
|
3314
|
+
/**
|
|
3315
|
+
* Every event a run emits, kept so that each iteration — however late it
|
|
3316
|
+
* starts — sees the run from its first event to its last.
|
|
3317
|
+
*/
|
|
3318
|
+
var EventLog = class {
|
|
3319
|
+
events = [];
|
|
3320
|
+
closed = false;
|
|
3321
|
+
waiting = [];
|
|
3322
|
+
push(event) {
|
|
3323
|
+
this.events.push(event);
|
|
3324
|
+
this.wake();
|
|
3325
|
+
}
|
|
3326
|
+
close() {
|
|
3327
|
+
this.closed = true;
|
|
3328
|
+
this.wake();
|
|
3329
|
+
}
|
|
3330
|
+
wake() {
|
|
3331
|
+
const waiting = this.waiting;
|
|
3332
|
+
this.waiting = [];
|
|
3333
|
+
for (const resume of waiting) resume();
|
|
3334
|
+
}
|
|
3335
|
+
async *iterate() {
|
|
3336
|
+
let next = 0;
|
|
3337
|
+
while (true) if (next < this.events.length) yield this.events[next++];
|
|
3338
|
+
else if (this.closed) return;
|
|
3339
|
+
else await new Promise((resume) => this.waiting.push(resume));
|
|
3340
|
+
}
|
|
3341
|
+
};
|
|
3342
|
+
/** `childOutput` as `spawn` reads it, where it is not the default. */
|
|
3343
|
+
const CHILD_STDIO = {
|
|
3344
|
+
stderr: [
|
|
3345
|
+
"ignore",
|
|
3346
|
+
2,
|
|
3347
|
+
2
|
|
3348
|
+
],
|
|
3349
|
+
ignore: "ignore"
|
|
3350
|
+
};
|
|
3351
|
+
function forward(logger, level, message) {
|
|
3352
|
+
if (!logger) return;
|
|
3353
|
+
if (level === "info") logger.info(message);
|
|
3354
|
+
else if (level === "error" && logger.error) logger.error(message);
|
|
3355
|
+
else logger.warn(message);
|
|
3356
|
+
}
|
|
3357
|
+
/**
|
|
3358
|
+
* Deploy a project's stage.
|
|
3359
|
+
*
|
|
3360
|
+
* ```ts
|
|
3361
|
+
* import { deploy } from '@geekmidas/cli/deploy';
|
|
3362
|
+
*
|
|
3363
|
+
* const run = deploy({ cwd: '/srv/checkouts/shop', stage: 'production' });
|
|
3364
|
+
* for await (const event of run) {
|
|
3365
|
+
* if (event.type === 'app.deployed') console.log(event.app, event.url);
|
|
3366
|
+
* }
|
|
3367
|
+
* const result = await run.result;
|
|
3368
|
+
* ```
|
|
3369
|
+
*
|
|
3370
|
+
* The run starts at once; iterating it is optional, and an iteration started
|
|
3371
|
+
* late still sees every event from the first.
|
|
3372
|
+
*/
|
|
3373
|
+
function deploy(input) {
|
|
3374
|
+
const log = new EventLog();
|
|
3375
|
+
const emit = (event) => {
|
|
3376
|
+
log.push(event);
|
|
3377
|
+
if (event.type === "log") forward(input.logger, event.level, event.message);
|
|
3378
|
+
};
|
|
3379
|
+
const ctx = {
|
|
3380
|
+
emit,
|
|
3381
|
+
credentials: input.credentials ?? storedCredentials({ home: input.home ?? gkmHome() }),
|
|
3382
|
+
dryRun: input.dryRun ?? false,
|
|
3383
|
+
home: input.home ?? gkmHome(),
|
|
3384
|
+
...input.signal ? { signal: input.signal } : {},
|
|
3385
|
+
...input.childOutput && input.childOutput !== "inherit" ? { stdio: CHILD_STDIO[input.childOutput] } : {}
|
|
3386
|
+
};
|
|
3387
|
+
const result = withOutput((level, message) => emit({
|
|
3388
|
+
type: "log",
|
|
3389
|
+
level,
|
|
3390
|
+
message
|
|
3391
|
+
}), async () => {
|
|
3392
|
+
try {
|
|
3393
|
+
input.signal?.throwIfAborted();
|
|
3394
|
+
const deployed = await runDeploy(() => load(input), {
|
|
3395
|
+
stage: input.stage,
|
|
3396
|
+
...input.tag ? { tag: input.tag } : {},
|
|
3397
|
+
...input.apps ? { apps: input.apps } : {}
|
|
3398
|
+
}, ctx);
|
|
3399
|
+
emit({
|
|
3400
|
+
type: "deploy.finished",
|
|
3401
|
+
result: deployed
|
|
3402
|
+
});
|
|
3403
|
+
return deployed;
|
|
3404
|
+
} catch (error) {
|
|
3405
|
+
emit({
|
|
3406
|
+
type: "deploy.failed",
|
|
3407
|
+
error: eventError(error)
|
|
3408
|
+
});
|
|
3409
|
+
throw error;
|
|
3410
|
+
} finally {
|
|
3411
|
+
log.close();
|
|
3412
|
+
}
|
|
3413
|
+
});
|
|
3414
|
+
result.catch(() => {});
|
|
3415
|
+
return {
|
|
3416
|
+
result,
|
|
3417
|
+
[Symbol.asyncIterator]: () => log.iterate()
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
/** The workspace at `input.cwd`, refusing a stage it does not deploy. */
|
|
3421
|
+
async function load(input) {
|
|
3422
|
+
const { workspace } = await loadWorkspaceConfig(resolve(input.cwd));
|
|
3423
|
+
assertDeployedStage(workspace.stages, input.stage);
|
|
3424
|
+
return workspace;
|
|
3425
|
+
}
|
|
3426
|
+
//#endregion
|
|
3427
|
+
export { NoDeployableApps as a, RegistryAmbiguous as c, ProjectNotOwned as d, MissingCredential as f, MissingEnvVars as i, RegistryNotConfigured as l, storedCredentials as m, BackendDeployFailed as n, UnknownDeployApps as o, chainCredentials as p, DeployProviderUnsupported as r, terminalCredentials as s, deploy as t, RegistryNotFound as u };
|
|
3428
|
+
|
|
3429
|
+
//# sourceMappingURL=deploy-D6Q09OkA.mjs.map
|