@geekmidas/cli 10.0.0-alpha.65 → 10.0.0-alpha.66
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 +14 -0
- package/dist/{HostingerProvider-Dlmbfass.cjs → HostingerProvider-BznqRC2S.cjs} +3 -3
- package/dist/HostingerProvider-BznqRC2S.cjs.map +1 -0
- package/dist/{HostingerProvider-x9gQTAGZ.mjs → HostingerProvider-CY_VpVud.mjs} +3 -3
- package/dist/HostingerProvider-CY_VpVud.mjs.map +1 -0
- package/dist/{LegacyStateStore-BGoo__sO.cjs → LegacyStateStore-BFcTT2QL.cjs} +2 -2
- package/dist/{LegacyStateStore-BGoo__sO.cjs.map → LegacyStateStore-BFcTT2QL.cjs.map} +1 -1
- package/dist/{LegacyStateStore-BUu1nQ5u.mjs → LegacyStateStore-CFheFo62.mjs} +2 -2
- package/dist/{LegacyStateStore-BUu1nQ5u.mjs.map → LegacyStateStore-CFheFo62.mjs.map} +1 -1
- package/dist/{LocalStateStore-93urXyQe.cjs → LocalStateStore-BVUexSvu.cjs} +2 -2
- package/dist/{LocalStateStore-93urXyQe.cjs.map → LocalStateStore-BVUexSvu.cjs.map} +1 -1
- package/dist/{LocalStateStore-BRhMQRuy.mjs → LocalStateStore-Bm3x9oOg.mjs} +2 -2
- package/dist/{LocalStateStore-BRhMQRuy.mjs.map → LocalStateStore-Bm3x9oOg.mjs.map} +1 -1
- package/dist/{OpenApiTsGenerator-BWuDRHe9.cjs → OpenApiTsGenerator-B2zrdyP4.cjs} +2 -2
- package/dist/{OpenApiTsGenerator-BWuDRHe9.cjs.map → OpenApiTsGenerator-B2zrdyP4.cjs.map} +1 -1
- package/dist/{OpenApiTsGenerator-C6v-IeHt.mjs → OpenApiTsGenerator-BmUAcR7d.mjs} +2 -2
- package/dist/{OpenApiTsGenerator-C6v-IeHt.mjs.map → OpenApiTsGenerator-BmUAcR7d.mjs.map} +1 -1
- package/dist/{Route53Provider-DVILBsvV.cjs → Route53Provider-Bllus2NR.cjs} +2 -2
- package/dist/Route53Provider-Bllus2NR.cjs.map +1 -0
- package/dist/{Route53Provider-Cvj40A8I.mjs → Route53Provider-QXxjkZO1.mjs} +2 -2
- package/dist/Route53Provider-QXxjkZO1.mjs.map +1 -0
- package/dist/{S3StateStore-pw1GhWwt.mjs → S3StateStore-BFHuctWM.mjs} +2 -2
- package/dist/{S3StateStore-pw1GhWwt.mjs.map → S3StateStore-BFHuctWM.mjs.map} +1 -1
- package/dist/{S3StateStore-F4dxw6kS.cjs → S3StateStore-CoXBhILd.cjs} +2 -2
- package/dist/{S3StateStore-F4dxw6kS.cjs.map → S3StateStore-CoXBhILd.cjs.map} +1 -1
- package/dist/{SSMStateStore-Bt9acuAF.cjs → SSMStateStore-BqCA9TP2.cjs} +2 -2
- package/dist/{SSMStateStore-Bt9acuAF.cjs.map → SSMStateStore-BqCA9TP2.cjs.map} +1 -1
- package/dist/{SSMStateStore-Dmj7zzFO.mjs → SSMStateStore-C1kvHScY.mjs} +2 -2
- package/dist/{SSMStateStore-Dmj7zzFO.mjs.map → SSMStateStore-C1kvHScY.mjs.map} +1 -1
- package/dist/{backup-provisioner-DIVCbfee.mjs → backup-provisioner-C8DnCmxK.mjs} +2 -2
- package/dist/backup-provisioner-C8DnCmxK.mjs.map +1 -0
- package/dist/{backup-provisioner-BmhIO_Yr.cjs → backup-provisioner-oDEsPj02.cjs} +2 -2
- package/dist/backup-provisioner-oDEsPj02.cjs.map +1 -0
- package/dist/{bundler-D_14JLZF.mjs → bundler-Dvaiud0h.mjs} +4 -4
- package/dist/{bundler-D_14JLZF.mjs.map → bundler-Dvaiud0h.mjs.map} +1 -1
- package/dist/{bundler-D6MlIfBT.cjs → bundler-orRqUJNZ.cjs} +4 -4
- package/dist/{bundler-D6MlIfBT.cjs.map → bundler-orRqUJNZ.cjs.map} +1 -1
- package/dist/{cli-P56vvrDQ.mjs → cli-CfHm0AO7.mjs} +5 -4
- package/dist/cli-CfHm0AO7.mjs.map +1 -0
- package/dist/{cli-BfH1deAT.cjs → cli-D1JkkOwg.cjs} +5 -4
- package/dist/cli-D1JkkOwg.cjs.map +1 -0
- package/dist/{compose-DprE3ASm.mjs → compose-CNQL8BnO.mjs} +18 -482
- package/dist/compose-CNQL8BnO.mjs.map +1 -0
- package/dist/{compose-391oEKj1.cjs → compose-jrLy7KGO.cjs} +16 -571
- package/dist/compose-jrLy7KGO.cjs.map +1 -0
- package/dist/{config-V_X_BMpm.d.cts → config-4XFxX1al.d.cts} +3 -3
- package/dist/{config-BonvTW8p.d.mts.map → config-4XFxX1al.d.cts.map} +1 -1
- package/dist/{config-BonvTW8p.d.mts → config-UtKzhpFb.d.mts} +3 -3
- package/dist/{config-V_X_BMpm.d.cts.map → config-UtKzhpFb.d.mts.map} +1 -1
- package/dist/config.cjs +3 -3
- package/dist/config.d.cts +3 -3
- package/dist/config.d.mts +3 -3
- package/dist/config.mjs +3 -3
- package/dist/databases-C2p5LGAp.cjs +419 -0
- package/dist/databases-C2p5LGAp.cjs.map +1 -0
- package/dist/databases-hUHTuwhx.mjs +346 -0
- package/dist/databases-hUHTuwhx.mjs.map +1 -0
- package/dist/deploy/public.cjs +26 -17
- package/dist/deploy/public.d.cts +136 -19
- package/dist/deploy/public.d.cts.map +1 -1
- package/dist/deploy/public.d.mts +136 -19
- package/dist/deploy/public.d.mts.map +1 -1
- package/dist/deploy/public.mjs +8 -8
- package/dist/deploy-B7aJi_Ah.cjs +511 -0
- package/dist/deploy-B7aJi_Ah.cjs.map +1 -0
- package/dist/deploy-DWn7YfIc.mjs +488 -0
- package/dist/deploy-DWn7YfIc.mjs.map +1 -0
- package/dist/{discover-LMrqsWSU.mjs → discover-BFoJWKGD.mjs} +4 -2
- package/dist/discover-BFoJWKGD.mjs.map +1 -0
- package/dist/{discover-DHdECueV.cjs → discover-OEfZ9OkA.cjs} +4 -2
- package/dist/discover-OEfZ9OkA.cjs.map +1 -0
- package/dist/dokploy-B4LcYG1E.cjs +38 -0
- package/dist/dokploy-B4LcYG1E.cjs.map +1 -0
- package/dist/dokploy-DalwLML5.mjs +38 -0
- package/dist/dokploy-DalwLML5.mjs.map +1 -0
- package/dist/{encryption-B1wJwgae.cjs → encryption-3KlGS1sl.cjs} +5 -7
- package/dist/{encryption-B1wJwgae.cjs.map → encryption-3KlGS1sl.cjs.map} +1 -1
- package/dist/{encryption-DF_hgIpl.mjs → encryption-CPfENwWO.mjs} +3 -5
- package/dist/{encryption-DF_hgIpl.mjs.map → encryption-CPfENwWO.mjs.map} +1 -1
- package/dist/{deploy-CZ5nlpsu.mjs → engine-CunvOG3s.mjs} +2100 -1733
- package/dist/engine-CunvOG3s.mjs.map +1 -0
- package/dist/{deploy-Cy8igpQF.cjs → engine-DR6KurWX.cjs} +2136 -1727
- package/dist/engine-DR6KurWX.cjs.map +1 -0
- package/dist/{github-CYcmTdFs.cjs → github-BJ5gh9bw.cjs} +7 -7
- package/dist/{github-CYcmTdFs.cjs.map → github-BJ5gh9bw.cjs.map} +1 -1
- package/dist/{github-B0YYE98x.mjs → github-MXA9v7Ge.mjs} +3 -3
- package/dist/{github-B0YYE98x.mjs.map → github-MXA9v7Ge.mjs.map} +1 -1
- package/dist/{index-DwKD_8lt.d.mts → index-CyApaHVe.d.cts} +2 -2
- package/dist/{index-DPUAGJaK.d.cts.map → index-CyApaHVe.d.cts.map} +1 -1
- package/dist/{index-DPUAGJaK.d.cts → index-DD1FfUHh.d.mts} +2 -2
- package/dist/{index-DwKD_8lt.d.mts.map → index-DD1FfUHh.d.mts.map} +1 -1
- package/dist/index.cjs +276 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +254 -50
- package/dist/index.mjs.map +1 -1
- package/dist/{migrate-B2qL5wj4.mjs → migrate-BJBcm4sG.mjs} +6 -6
- package/dist/{migrate-B2qL5wj4.mjs.map → migrate-BJBcm4sG.mjs.map} +1 -1
- package/dist/{migrate-q4pPAXtO.cjs → migrate-CF4J4GQS.cjs} +16 -16
- package/dist/{migrate-q4pPAXtO.cjs.map → migrate-CF4J4GQS.cjs.map} +1 -1
- package/dist/openapi.cjs +1 -1
- package/dist/openapi.d.cts +1 -1
- package/dist/openapi.d.mts +1 -1
- package/dist/openapi.mjs +1 -1
- package/dist/provider-B3Sv_a74.cjs +173 -0
- package/dist/provider-B3Sv_a74.cjs.map +1 -0
- package/dist/provider-D7rQzzzf.mjs +138 -0
- package/dist/provider-D7rQzzzf.mjs.map +1 -0
- package/dist/{ready-bQfu9GES.mjs → ready-2s3-CndS.mjs} +3 -3
- package/dist/{ready-bQfu9GES.mjs.map → ready-2s3-CndS.mjs.map} +1 -1
- package/dist/{ready-brEhh68f.cjs → ready-B6uHr4sm.cjs} +3 -3
- package/dist/{ready-brEhh68f.cjs.map → ready-B6uHr4sm.cjs.map} +1 -1
- package/dist/reconcile/public.cjs +1 -1
- package/dist/reconcile/public.d.cts +1 -1
- package/dist/reconcile/public.d.mts +1 -1
- package/dist/reconcile/public.mjs +1 -1
- package/dist/{resolve-N_i4XNd-.cjs → resolve-CVmch0wI.cjs} +3 -3
- package/dist/{resolve-N_i4XNd-.cjs.map → resolve-CVmch0wI.cjs.map} +1 -1
- package/dist/{resolve-DLkIXTct.mjs → resolve-Db0tNlrW.mjs} +3 -3
- package/dist/{resolve-DLkIXTct.mjs.map → resolve-Db0tNlrW.mjs.map} +1 -1
- package/dist/rollback-DvAo4OKS.mjs +133 -0
- package/dist/rollback-DvAo4OKS.mjs.map +1 -0
- package/dist/rollback-DxJK84hy.cjs +162 -0
- package/dist/rollback-DxJK84hy.cjs.map +1 -0
- package/dist/{runtime-BnD7Nn96.d.cts → runtime-BRqFOvPM.d.cts} +2 -2
- package/dist/{runtime-BnD7Nn96.d.cts.map → runtime-BRqFOvPM.d.cts.map} +1 -1
- package/dist/{runtime-j-tmFL65.d.mts → runtime-CEk5n4VH.d.mts} +2 -2
- package/dist/{runtime-j-tmFL65.d.mts.map → runtime-CEk5n4VH.d.mts.map} +1 -1
- package/dist/sandbox/discover-worker.cjs +1 -1
- package/dist/sandbox/discover-worker.mjs +1 -1
- package/dist/sandbox/migrate-worker.cjs +61 -0
- package/dist/sandbox/migrate-worker.cjs.map +1 -0
- package/dist/sandbox/migrate-worker.d.cts +1 -0
- package/dist/sandbox/migrate-worker.d.mts +1 -0
- package/dist/sandbox/migrate-worker.mjs +62 -0
- package/dist/sandbox/migrate-worker.mjs.map +1 -0
- package/dist/{schemas-D2LCF689.mjs → schemas-Cfhtpdo0.mjs} +10 -2
- package/dist/{schemas-Ckm_aBnF.cjs.map → schemas-Cfhtpdo0.mjs.map} +1 -1
- package/dist/{schemas-Ckm_aBnF.cjs → schemas-RUlKHu27.cjs} +15 -1
- package/dist/{schemas-D2LCF689.mjs.map → schemas-RUlKHu27.cjs.map} +1 -1
- package/dist/{sniffer-BSwaL-dX.cjs → sniffer-5xmrkh2a.cjs} +112 -606
- package/dist/sniffer-5xmrkh2a.cjs.map +1 -0
- package/dist/{sniffer-BZ-OJO4Y.mjs → sniffer-BAQhLEsI.mjs} +95 -546
- package/dist/sniffer-BAQhLEsI.mjs.map +1 -0
- package/dist/{state-DF26e_lo.cjs → state-Y6ATakMX.cjs} +744 -20
- package/dist/state-Y6ATakMX.cjs.map +1 -0
- package/dist/{state-B8YwmUZU.mjs → state-uuYqnRVO.mjs} +605 -19
- package/dist/state-uuYqnRVO.mjs.map +1 -0
- package/dist/target/public.cjs +1 -1
- package/dist/target/public.d.cts +2 -2
- package/dist/target/public.d.mts +2 -2
- package/dist/target/public.mjs +1 -1
- package/dist/{terminal-Bs5E1oxm.cjs → terminal-CHy_MdjG.cjs} +11 -15
- package/dist/{terminal-Bs5E1oxm.cjs.map → terminal-CHy_MdjG.cjs.map} +1 -1
- package/dist/{terminal-CfbiYUbZ.mjs → terminal-DUhcZ1ND.mjs} +4 -4
- package/dist/{terminal-CfbiYUbZ.mjs.map → terminal-DUhcZ1ND.mjs.map} +1 -1
- package/dist/{types-ByMWkXKP.d.cts → types-CSw2w69a.d.mts} +68 -10
- package/dist/types-CSw2w69a.d.mts.map +1 -0
- package/dist/{types-BCzMWBb6.d.mts → types-D5WIxRDP.d.cts} +69 -9
- package/dist/types-D5WIxRDP.d.cts.map +1 -0
- package/dist/vitest.cjs +1 -1
- package/dist/vitest.mjs +1 -1
- package/dist/workspace/index.cjs +1 -1
- package/dist/workspace/index.d.cts +2 -2
- package/dist/workspace/index.d.mts +2 -2
- package/dist/workspace/index.mjs +1 -1
- package/dist/{workspace-C06_pW4g.mjs → workspace-Be6uBhlF.mjs} +10 -3
- package/dist/workspace-Be6uBhlF.mjs.map +1 -0
- package/dist/{workspace-BewAHZIo.cjs → workspace-ON-bqiJz.cjs} +10 -3
- package/dist/workspace-ON-bqiJz.cjs.map +1 -0
- package/package.json +13 -13
- package/dist/HostingerProvider-Dlmbfass.cjs.map +0 -1
- package/dist/HostingerProvider-x9gQTAGZ.mjs.map +0 -1
- package/dist/Route53Provider-Cvj40A8I.mjs.map +0 -1
- package/dist/Route53Provider-DVILBsvV.cjs.map +0 -1
- package/dist/backup-provisioner-BmhIO_Yr.cjs.map +0 -1
- package/dist/backup-provisioner-DIVCbfee.mjs.map +0 -1
- package/dist/cli-BfH1deAT.cjs.map +0 -1
- package/dist/cli-P56vvrDQ.mjs.map +0 -1
- package/dist/compose-391oEKj1.cjs.map +0 -1
- package/dist/compose-DprE3ASm.mjs.map +0 -1
- package/dist/deploy-9MBG_QLd.mjs +0 -137
- package/dist/deploy-9MBG_QLd.mjs.map +0 -1
- package/dist/deploy-B80idPUZ.cjs +0 -142
- package/dist/deploy-B80idPUZ.cjs.map +0 -1
- package/dist/deploy-CZ5nlpsu.mjs.map +0 -1
- package/dist/deploy-Cy8igpQF.cjs.map +0 -1
- package/dist/discover-DHdECueV.cjs.map +0 -1
- package/dist/discover-LMrqsWSU.mjs.map +0 -1
- package/dist/dokploy-BdtBePjp.mjs +0 -32
- package/dist/dokploy-BdtBePjp.mjs.map +0 -1
- package/dist/dokploy-iFY8MY-L.cjs +0 -32
- package/dist/dokploy-iFY8MY-L.cjs.map +0 -1
- package/dist/sniffer-BSwaL-dX.cjs.map +0 -1
- package/dist/sniffer-BZ-OJO4Y.mjs.map +0 -1
- package/dist/state-B8YwmUZU.mjs.map +0 -1
- package/dist/state-DF26e_lo.cjs.map +0 -1
- package/dist/storage-BzaUxmqe.mjs +0 -124
- package/dist/storage-BzaUxmqe.mjs.map +0 -1
- package/dist/storage-CSGoC3bK.cjs +0 -152
- package/dist/storage-CSGoC3bK.cjs.map +0 -1
- package/dist/store-CJF1Tqkq.cjs +0 -510
- package/dist/store-CJF1Tqkq.cjs.map +0 -1
- package/dist/store-CLiHnqOY.mjs +0 -415
- package/dist/store-CLiHnqOY.mjs.map +0 -1
- package/dist/types-BCzMWBb6.d.mts.map +0 -1
- package/dist/types-ByMWkXKP.d.cts.map +0 -1
- package/dist/workspace-BewAHZIo.cjs.map +0 -1
- package/dist/workspace-C06_pW4g.mjs.map +0 -1
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
const require_sniffer = require("./sniffer-BSwaL-dX.cjs");
|
|
2
1
|
const require_local = require("./local-BdH4XN0E.cjs");
|
|
3
2
|
const require_credentials = require("./credentials-Bzbg23n8.cjs");
|
|
4
|
-
const
|
|
3
|
+
const require_schemas = require("./schemas-RUlKHu27.cjs");
|
|
4
|
+
const require_discover = require("./discover-OEfZ9OkA.cjs");
|
|
5
|
+
const require_sandbox = require("./sandbox-CL--ZDui.cjs");
|
|
5
6
|
const require_derive = require("./derive-DAbo49sm.cjs");
|
|
6
|
-
const require_workspace = require("./workspace-
|
|
7
|
-
require("./
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const require_state = require("./state-DF26e_lo.cjs");
|
|
11
|
-
require("./terminal-Bs5E1oxm.cjs");
|
|
7
|
+
const require_workspace = require("./workspace-ON-bqiJz.cjs");
|
|
8
|
+
const require_state = require("./state-Y6ATakMX.cjs");
|
|
9
|
+
const require_sniffer = require("./sniffer-5xmrkh2a.cjs");
|
|
10
|
+
const require_provider = require("./provider-B3Sv_a74.cjs");
|
|
12
11
|
let node_path = require("node:path");
|
|
13
12
|
let node_fs_promises = require("node:fs/promises");
|
|
14
13
|
let node_os = require("node:os");
|
|
@@ -18,1753 +17,1884 @@ let _geekmidas_db_pg_roles = require("@geekmidas/db/pg/roles");
|
|
|
18
17
|
let pg = require("pg");
|
|
19
18
|
let _geekmidas_cache_postgres = require("@geekmidas/cache/postgres");
|
|
20
19
|
let node_dns_promises = require("node:dns/promises");
|
|
21
|
-
//#region src/deploy/
|
|
20
|
+
//#region src/deploy/docker.ts
|
|
21
|
+
const logger$3 = require_discover.output;
|
|
22
22
|
/**
|
|
23
|
-
* The
|
|
23
|
+
* The id the Dockerfile templates mount the encrypted credentials under:
|
|
24
|
+
* `RUN --mount=type=secret,id=gkm_credentials`.
|
|
25
|
+
*/
|
|
26
|
+
const CREDENTIALS_SECRET_ID = "gkm_credentials";
|
|
27
|
+
/**
|
|
28
|
+
* Get the full image reference
|
|
29
|
+
*/
|
|
30
|
+
function getImageRef(registry, imageName, tag) {
|
|
31
|
+
if (registry) return `${registry}/${imageName}:${tag}`;
|
|
32
|
+
return `${imageName}:${tag}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The `docker build` argv for an image.
|
|
24
36
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* from the *sniffer*, which walks application code for `get('X')` calls, and a
|
|
29
|
-
* construct reads its own key inside `@geekmidas/constructs` where there is
|
|
30
|
-
* nothing to find. So a declared app deployed here got none of the model, and
|
|
31
|
-
* nothing reported the absence: the deploy succeeded and the first request that
|
|
32
|
-
* needed a URL failed at runtime.
|
|
37
|
+
* One element per value, so nothing in a ref or a build arg is read by a
|
|
38
|
+
* shell, and `--opt=value` rather than `--opt value`, so a value that starts
|
|
39
|
+
* with `-` cannot be taken for one of docker's own flags.
|
|
33
40
|
*
|
|
34
|
-
*
|
|
41
|
+
* @internal Exported for testing
|
|
42
|
+
*/
|
|
43
|
+
function dockerBuildArgs(options) {
|
|
44
|
+
const { dockerfilePath, imageRef, buildArgs = [], credentialsFile } = options;
|
|
45
|
+
return [
|
|
46
|
+
"build",
|
|
47
|
+
"--platform=linux/amd64",
|
|
48
|
+
`--file=${dockerfilePath}`,
|
|
49
|
+
`--tag=${imageRef}`,
|
|
50
|
+
...buildArgs.map((arg) => `--build-arg=${arg}`),
|
|
51
|
+
...credentialsFile ? [`--secret=id=${CREDENTIALS_SECRET_ID},src=${credentialsFile}`] : [],
|
|
52
|
+
"."
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Write the encrypted credentials where `docker build --secret` reads them.
|
|
35
57
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* when the call is made. So provisioners *accumulate* DDL rather than running
|
|
40
|
-
* it, and it is applied once against a cluster that is up — which is exactly
|
|
41
|
-
* what `DatabaseBootstrap` does on AWS, for exactly the same reason.
|
|
58
|
+
* Two lines — ciphertext, then IV — so a template can split them with `sed`
|
|
59
|
+
* in any base image. Owner-only, in a directory of its own, and the returned
|
|
60
|
+
* cleanup removes both whatever the build did.
|
|
42
61
|
*
|
|
43
|
-
*
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
* @internal Exported for testing
|
|
63
|
+
*/
|
|
64
|
+
async function writeCredentialsFile(credentials) {
|
|
65
|
+
const dir = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "gkm-credentials-"));
|
|
66
|
+
const path = (0, node_path.join)(dir, CREDENTIALS_SECRET_ID);
|
|
67
|
+
await (0, node_fs_promises.writeFile)(path, `${credentials.encrypted}\n${credentials.iv}\n`, { mode: 384 });
|
|
68
|
+
await (0, node_fs_promises.chmod)(path, 384);
|
|
69
|
+
return {
|
|
70
|
+
path,
|
|
71
|
+
cleanup: () => (0, node_fs_promises.rm)(dir, {
|
|
72
|
+
recursive: true,
|
|
73
|
+
force: true
|
|
74
|
+
})
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build Docker image
|
|
79
|
+
* @param imageRef - Full image reference (registry/name:tag)
|
|
80
|
+
* @param buildArgs - Build arguments to pass to docker build
|
|
81
|
+
* @param credentials - Encrypted credentials, passed as a build secret
|
|
82
|
+
*/
|
|
83
|
+
async function buildImage(imageRef, buildArgs, credentials, { cwd, dockerfile, signal, stdio }) {
|
|
84
|
+
logger$3.log(`\n🔨 Building Docker image: ${imageRef}`);
|
|
85
|
+
logger$3.log(" Generating Dockerfile...");
|
|
86
|
+
await require_state.dockerCommand({
|
|
87
|
+
slim: true,
|
|
88
|
+
cwd
|
|
89
|
+
});
|
|
90
|
+
const dockerfilePath = dockerfile;
|
|
91
|
+
const secret = credentials ? await writeCredentialsFile(credentials) : void 0;
|
|
92
|
+
try {
|
|
93
|
+
await require_local.run("docker", dockerBuildArgs({
|
|
94
|
+
dockerfilePath,
|
|
95
|
+
imageRef,
|
|
96
|
+
buildArgs,
|
|
97
|
+
credentialsFile: secret?.path
|
|
98
|
+
}), {
|
|
99
|
+
cwd,
|
|
100
|
+
env: {
|
|
101
|
+
...process.env,
|
|
102
|
+
DOCKER_BUILDKIT: "1"
|
|
103
|
+
},
|
|
104
|
+
...signal ? { signal } : {},
|
|
105
|
+
...stdio ? { stdio } : {}
|
|
106
|
+
});
|
|
107
|
+
logger$3.log(`✅ Image built: ${imageRef}`);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw new require_state.DockerBuildFailed(imageRef, error);
|
|
110
|
+
} finally {
|
|
111
|
+
await secret?.cleanup();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Push Docker image to registry
|
|
116
|
+
*/
|
|
117
|
+
async function pushImage(imageRef, { cwd, signal, stdio }) {
|
|
118
|
+
logger$3.log(`\n☁️ Pushing image: ${imageRef}`);
|
|
119
|
+
try {
|
|
120
|
+
await require_local.run("docker", ["push", imageRef], {
|
|
121
|
+
cwd,
|
|
122
|
+
...signal ? { signal } : {},
|
|
123
|
+
...stdio ? { stdio } : {}
|
|
124
|
+
});
|
|
125
|
+
logger$3.log(`✅ Image pushed: ${imageRef}`);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw new require_state.DockerPushFailed(imageRef, error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The digest the registry gave a pushed image, `sha256:…`.
|
|
48
132
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
133
|
+
* A tag can be pushed over — by the next deploy, or by anything else that
|
|
134
|
+
* shares the repository — and the digest is what says which image a stage
|
|
135
|
+
* actually ran. `RepoDigests` has one entry per repository the image was
|
|
136
|
+
* pushed to, so the one for this ref's repository is picked by name.
|
|
137
|
+
*
|
|
138
|
+
* @internal Exported for testing
|
|
52
139
|
*/
|
|
140
|
+
async function pushedDigest(imageRef, { cwd, signal } = { cwd: process.cwd() }) {
|
|
141
|
+
const repository = imageRef.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
142
|
+
const output = await require_local.runOutput("docker", [
|
|
143
|
+
"image",
|
|
144
|
+
"inspect",
|
|
145
|
+
"--format={{json .RepoDigests}}",
|
|
146
|
+
imageRef
|
|
147
|
+
], {
|
|
148
|
+
cwd,
|
|
149
|
+
...signal ? { signal } : {}
|
|
150
|
+
});
|
|
151
|
+
let digests;
|
|
152
|
+
try {
|
|
153
|
+
digests = JSON.parse(output.trim() || "[]");
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!Array.isArray(digests)) return void 0;
|
|
158
|
+
return digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`))?.slice(repository.length + 1);
|
|
159
|
+
}
|
|
53
160
|
/**
|
|
54
|
-
*
|
|
161
|
+
* Deploy using Docker (build and optionally push image)
|
|
162
|
+
*/
|
|
163
|
+
async function deployDocker(options) {
|
|
164
|
+
const { stage, tag, skipPush, masterKey, config, buildArgs, credentials } = options;
|
|
165
|
+
const imageName = config.imageName;
|
|
166
|
+
const imageRef = require_state.validateImageRef(getImageRef(config.registry, imageName, tag));
|
|
167
|
+
const cwd = options.cwd ?? process.cwd();
|
|
168
|
+
const docker = {
|
|
169
|
+
cwd,
|
|
170
|
+
dockerfile: (0, node_path.join)(cwd, require_state.dockerfileOf(config.appName ?? imageName, options.appPath ?? ".")),
|
|
171
|
+
...options.signal ? { signal: options.signal } : {},
|
|
172
|
+
...options.stdio ? { stdio: options.stdio } : {}
|
|
173
|
+
};
|
|
174
|
+
await buildImage(imageRef, buildArgs, credentials, docker);
|
|
175
|
+
let digest;
|
|
176
|
+
if (!skipPush) {
|
|
177
|
+
if (!config.registry) logger$3.warn("\n⚠️ No registry configured. Use --skip-push or configure docker.registry in gkm.config.ts");
|
|
178
|
+
else {
|
|
179
|
+
await pushImage(imageRef, docker);
|
|
180
|
+
digest = await pushedDigest(imageRef, docker);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
logger$3.log("\n✅ Docker deployment ready!");
|
|
184
|
+
logger$3.log(`\n📋 Deployment details:`);
|
|
185
|
+
logger$3.log(` Image: ${imageRef}`);
|
|
186
|
+
logger$3.log(` Stage: ${stage}`);
|
|
187
|
+
if (masterKey) logger$3.log(`\n🔐 Run the container with GKM_MASTER_KEY set to the key with fingerprint ${require_sniffer.keyFingerprint(masterKey)}.`);
|
|
188
|
+
return {
|
|
189
|
+
imageRef,
|
|
190
|
+
...digest ? { digest } : {},
|
|
191
|
+
masterKey
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/deploy/ownership.ts
|
|
196
|
+
/**
|
|
197
|
+
* Which Dokploy project a deploy is allowed to deploy into.
|
|
55
198
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
199
|
+
* Deploy used to take any project whose name matched the workspace's, in any
|
|
200
|
+
* case — so a second workspace called `shop` (or `Shop`) on the same server
|
|
201
|
+
* deployed into the first one's project, over its images and applications. A
|
|
202
|
+
* project is now claimed by an ownership marker in its description, and a
|
|
203
|
+
* project with the right name and no marker is somebody else's.
|
|
59
204
|
*/
|
|
60
|
-
|
|
61
|
-
|
|
205
|
+
/**
|
|
206
|
+
* A Dokploy project with the name this deploy would use exists, and nothing
|
|
207
|
+
* says this workspace created it.
|
|
208
|
+
*/
|
|
209
|
+
var ProjectNotOwned = class extends Error {
|
|
210
|
+
projectName;
|
|
211
|
+
projectId;
|
|
212
|
+
identity;
|
|
213
|
+
ownedBy;
|
|
214
|
+
constructor(projectName, projectId, identity, ownedBy) {
|
|
215
|
+
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.`);
|
|
216
|
+
this.projectName = projectName;
|
|
217
|
+
this.projectId = projectId;
|
|
218
|
+
this.identity = identity;
|
|
219
|
+
this.ownedBy = ownedBy;
|
|
220
|
+
this.name = "ProjectNotOwned";
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
/** A description with the marker added, keeping what was there. */
|
|
224
|
+
function withMarker(description, marker) {
|
|
225
|
+
const existing = (description ?? "").trim();
|
|
226
|
+
return existing ? `${existing}\n${marker}` : marker;
|
|
227
|
+
}
|
|
228
|
+
/** `project.one`, or `null` when Dokploy no longer has the project. */
|
|
229
|
+
async function projectById(api, projectId) {
|
|
230
|
+
try {
|
|
231
|
+
return await api.getProject(projectId) ?? null;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error instanceof require_sniffer.DokployApiError) return null;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
62
236
|
}
|
|
63
237
|
/**
|
|
64
|
-
*
|
|
238
|
+
* The project this identity owns, if there is one — read only, so a dry run
|
|
239
|
+
* asks the same question a deploy does:
|
|
65
240
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
241
|
+
* 1. The one the stage's state names, if Dokploy still has it. State written
|
|
242
|
+
* before markers existed is trusted — it holds an id this workspace was
|
|
243
|
+
* given when it created the project.
|
|
244
|
+
* 2. One with this name *and* this identity's marker.
|
|
68
245
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* not: `project.one` returns typed collections — `applications`, `postgres`,
|
|
72
|
-
* `redis`, `compose`, `mariadb`, `mongo`, `mysql` — so the kind is already in
|
|
73
|
-
* the shape of the response and an application and a Postgres of the same name
|
|
74
|
-
* are different objects with different ids. The suffix was answering a question
|
|
75
|
-
* nobody had asked.
|
|
246
|
+
* A project that matches by name (in any case) but carries no marker, or
|
|
247
|
+
* another identity's, raises `ProjectNotOwned` — it is never adopted.
|
|
76
248
|
*/
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
* A cache, wherever the project said it lives.
|
|
164
|
-
*
|
|
165
|
-
* `db` is a table in a database the manifest already declares, so it
|
|
166
|
-
* provisions no service and costs nothing to run — the same relationship
|
|
167
|
-
* pg-boss has. The table's DDL is deferred with the roles, and the table
|
|
168
|
-
* name travels in the URL because two caches in one database resolve the
|
|
169
|
-
* same connection string.
|
|
170
|
-
*/
|
|
171
|
-
cache: async (declaration, context) => {
|
|
172
|
-
if (declaration.kind !== "cache") throw new WrongKind(declaration.kind);
|
|
173
|
-
const parentId = declaration.of ?? (context.cache === "db" ? soleDatabase(context) : void 0);
|
|
174
|
-
if (!parentId) throw new CacheNeedsAHome(declaration.id, context.cache);
|
|
175
|
-
const parent = context.provisioned[parentId];
|
|
176
|
-
if (!parent) throw new UnresolvedParent(declaration.id, parentId);
|
|
177
|
-
const parentUrl = parent.provides[(0, _geekmidas_manifest.provideKey)(parentId, "url")];
|
|
178
|
-
if (!parentUrl) throw new UnresolvedParent(declaration.id, parentId);
|
|
179
|
-
const table = declaration.table ?? (0, _geekmidas_manifest.cacheTable)(declaration.id);
|
|
180
|
-
const schema = schemaOf(context.manifest, parentId);
|
|
181
|
-
const qualified = schema ? `${schema}.${table}` : table;
|
|
182
|
-
context.deferred.push(...(0, _geekmidas_cache_postgres.cacheTableStatements)({ table: qualified }).map((statement) => ({
|
|
183
|
-
id: declaration.id,
|
|
184
|
-
database: new URL(parentUrl).pathname.slice(1),
|
|
185
|
-
describe: statement.describe,
|
|
186
|
-
sql: statement.sql,
|
|
187
|
-
...statement.exists ? { exists: statement.exists } : {}
|
|
188
|
-
})), {
|
|
189
|
-
id: declaration.id,
|
|
190
|
-
database: new URL(parentUrl).pathname.slice(1),
|
|
191
|
-
describe: `${qualified} is owned by its schema's owner`,
|
|
192
|
-
sql: `ALTER TABLE ${quoted(qualified)} OWNER TO ${quoted((0, _geekmidas_db_pg_roles.ownerRole)(new URL(parentUrl).username))}`
|
|
193
|
-
}, {
|
|
194
|
-
id: declaration.id,
|
|
195
|
-
database: new URL(parentUrl).pathname.slice(1),
|
|
196
|
-
describe: `${new URL(parentUrl).username} may read and write ${qualified}`,
|
|
197
|
-
sql: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${quoted(qualified)} TO ${quoted(new URL(parentUrl).username)}`
|
|
198
|
-
});
|
|
199
|
-
const url = new URL(parentUrl);
|
|
200
|
-
url.searchParams.set("table", table);
|
|
201
|
-
return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: url.toString() } };
|
|
202
|
-
},
|
|
203
|
-
/**
|
|
204
|
-
* A bucket, as a MinIO stack on the box.
|
|
205
|
-
*
|
|
206
|
-
* Dokploy has first-class primitives for Postgres and Redis and none for
|
|
207
|
-
* object storage, so this is the one kind whose infrastructure this target
|
|
208
|
-
* *writes* rather than configures — a compose file with one service in it.
|
|
209
|
-
*
|
|
210
|
-
* The service is named `cloudName(...)` like everything else, which is not
|
|
211
|
-
* cosmetic here: containers on `dokploy-network` resolve each other by
|
|
212
|
-
* service name, so the name is the address. Calling it `minio` would work
|
|
213
|
-
* for exactly one project on the box and then collide.
|
|
214
|
-
*
|
|
215
|
-
* `s3://` carries no credentials, deliberately — deployed on AWS the SDK
|
|
216
|
-
* reads them from an execution role, so a URL that embedded a key would be
|
|
217
|
-
* one more thing to rotate and leak. There is no role here, so the same
|
|
218
|
-
* chain reads `AWS_ACCESS_KEY_ID` beside the URL, exactly as it does
|
|
219
|
-
* locally.
|
|
220
|
-
*/
|
|
221
|
-
objects: async (declaration, context) => {
|
|
222
|
-
if (declaration.kind !== "objects") throw new WrongKind(declaration.kind);
|
|
223
|
-
const backend = context.storage ?? "minio";
|
|
224
|
-
if (backend !== "minio") throw new UnprovisionableBucket(declaration.id, backend);
|
|
225
|
-
const service = serviceName({
|
|
226
|
-
stage: context.stage,
|
|
227
|
-
app: context.scope ?? context.project
|
|
228
|
-
}, declaration.id);
|
|
229
|
-
const bucket = require_state.resourceName(declaration.id, declaration.kind, context.stage);
|
|
230
|
-
const user = `${bucket}-root`;
|
|
231
|
-
const password = derivedPassword(context, `objects:${declaration.id}`);
|
|
232
|
-
const { compose } = await context.api.findOrCreateCompose(service, context.projectId, context.environmentId, minioCompose({
|
|
233
|
-
service,
|
|
234
|
-
bucket,
|
|
235
|
-
user,
|
|
236
|
-
password
|
|
237
|
-
}));
|
|
238
|
-
await context.api.deployCompose(compose.composeId);
|
|
239
|
-
return { provides: {
|
|
240
|
-
[(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: s3Url(service, bucket),
|
|
241
|
-
AWS_ACCESS_KEY_ID: user,
|
|
242
|
-
AWS_SECRET_ACCESS_KEY: password,
|
|
243
|
-
AWS_REGION: MINIO_REGION
|
|
244
|
-
} };
|
|
245
|
-
},
|
|
246
|
-
/**
|
|
247
|
-
* The address a bucket's objects are served on.
|
|
248
|
-
*
|
|
249
|
-
* Nothing is provisioned: Dokploy runs Traefik, so a second reverse proxy
|
|
250
|
-
* behind the first would terminate TLS twice — which is why the local
|
|
251
|
-
* target's Caddy has no equivalent here. What this resolves is the address,
|
|
252
|
-
* and the `open` prefixes are applied to the bucket rather than to an edge,
|
|
253
|
-
* so a policy is what makes them public wherever the bucket lives.
|
|
254
|
-
*
|
|
255
|
-
* Until a Traefik rule exists it answers on MinIO directly, path-style,
|
|
256
|
-
* which is honest about the shape it has today: the bucket is in the path
|
|
257
|
-
* rather than fronted by a host of its own.
|
|
258
|
-
*/
|
|
259
|
-
"file-server": async (declaration, context) => {
|
|
260
|
-
if (declaration.kind !== "file-server") throw new WrongKind(declaration.kind);
|
|
261
|
-
const parentUrl = context.provisioned[declaration.of]?.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")];
|
|
262
|
-
if (!parentUrl) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
263
|
-
const endpoint = new URL(parentUrl).searchParams.get("endpoint");
|
|
264
|
-
const bucket = new URL(parentUrl).hostname;
|
|
265
|
-
if (!endpoint) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
266
|
-
return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: `${endpoint}/${bucket}` } };
|
|
267
|
-
},
|
|
268
|
-
/**
|
|
269
|
-
* A topic, and below it a queue — the same broker either way.
|
|
270
|
-
*
|
|
271
|
-
* Under pg-boss there is nothing to create in Dokploy: the broker is a
|
|
272
|
-
* schema tenant of the database the app already declared, exactly as it is
|
|
273
|
-
* locally, so what this resolves is an address rather than a service. Which
|
|
274
|
-
* is the point of putting the backend in config — the same handlers drain
|
|
275
|
-
* pg-boss here and SQS on AWS because the string they were handed said so.
|
|
276
|
-
*
|
|
277
|
-
* The tenant gets its own role rather than the cluster master, for the
|
|
278
|
-
* reason every other tenant does: pg-boss creates and owns its tables, and
|
|
279
|
-
* a broker that could also read the application's is a broker with a grant
|
|
280
|
-
* nothing asked for.
|
|
281
|
-
*/
|
|
282
|
-
topic: async (declaration, context) => {
|
|
283
|
-
if (declaration.kind !== "topic") throw new WrongKind(declaration.kind);
|
|
284
|
-
return broker(declaration.id, context);
|
|
285
|
-
},
|
|
286
|
-
queue: async (declaration, context) => {
|
|
287
|
-
if (declaration.kind !== "queue") throw new WrongKind(declaration.kind);
|
|
288
|
-
return broker(declaration.id, context);
|
|
289
|
-
},
|
|
290
|
-
/**
|
|
291
|
-
* A signing key, generated once and remembered in the stage's store.
|
|
292
|
-
*
|
|
293
|
-
* Random, never derived: a key computed from the project and stage names
|
|
294
|
-
* signs sessions anyone reading the repo could forge. The deploy generates
|
|
295
|
-
* it before provisioning (`withGeneratedSecrets`) and every later deploy
|
|
296
|
-
* reads the same one, so live sessions survive a redeploy. Nothing is
|
|
297
|
-
* created in Dokploy: a secret has no address.
|
|
298
|
-
*/
|
|
299
|
-
secret: async (declaration, context) => {
|
|
300
|
-
if (declaration.kind !== "secret") throw new WrongKind(declaration.kind);
|
|
301
|
-
const key = declaration.provides?.[0];
|
|
302
|
-
if (!key) return { provides: {} };
|
|
303
|
-
return { provides: { [key]: supplied(declaration.id, key, context) } };
|
|
304
|
-
},
|
|
305
|
-
/**
|
|
306
|
-
* The keyring the stage keeps in its secrets.
|
|
307
|
-
*
|
|
308
|
-
* Generated on the first deploy (`withGeneratedSecrets`) and rotated only
|
|
309
|
-
* by `gkm encryption:rotate`, so a redeploy never changes a key and nothing
|
|
310
|
-
* written becomes unreadable. Nothing is created in Dokploy: the process
|
|
311
|
-
* holds the keyring.
|
|
312
|
-
*/
|
|
313
|
-
encryption: async (declaration, context) => {
|
|
314
|
-
if (declaration.kind !== "encryption") throw new WrongKind(declaration.kind);
|
|
315
|
-
const key = (0, _geekmidas_manifest.provideKey)(declaration.id, "url");
|
|
316
|
-
return { provides: { [key]: supplied(declaration.id, key, context) } };
|
|
317
|
-
},
|
|
318
|
-
/**
|
|
319
|
-
* A third party's credentials, as the stage was given them.
|
|
320
|
-
*
|
|
321
|
-
* Nothing is created and nothing derived: the value was issued by somebody
|
|
322
|
-
* else. A stage that was never given one fails the deploy here, naming the
|
|
323
|
-
* command that sets it — not the first request that needs it.
|
|
324
|
-
*/
|
|
325
|
-
credential: async (declaration, context) => {
|
|
326
|
-
if (declaration.kind !== "credential") throw new WrongKind(declaration.kind);
|
|
327
|
-
const key = (0, _geekmidas_manifest.provideKey)(declaration.id, "credentials");
|
|
328
|
-
return { provides: { [key]: supplied(declaration.id, key, context) } };
|
|
329
|
-
},
|
|
330
|
-
/**
|
|
331
|
-
* An API somebody else runs: the URL this stage calls it at, and the
|
|
332
|
-
* credentials it was issued for this stage.
|
|
333
|
-
*/
|
|
334
|
-
"external-api": async (declaration, context) => {
|
|
335
|
-
if (declaration.kind !== "external-api") throw new WrongKind(declaration.kind);
|
|
336
|
-
const credentials = (0, _geekmidas_manifest.provideKey)(declaration.id, "credentials");
|
|
337
|
-
return { provides: {
|
|
338
|
-
[(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: (0, _geekmidas_manifest.externalApiUrl)(declaration, context.stage),
|
|
339
|
-
[credentials]: supplied(declaration.id, credentials, context)
|
|
340
|
-
} };
|
|
341
|
-
},
|
|
342
|
-
/**
|
|
343
|
-
* A surface, at the address whatever created its domain assigned.
|
|
344
|
-
*
|
|
345
|
-
* Nothing is provisioned here: the application and its domain are the
|
|
346
|
-
* existing engine's, and Dokploy's own Traefik terminates TLS with a Let's
|
|
347
|
-
* Encrypt certificate. What this adds is the *derivation* — who may call it,
|
|
348
|
-
* and where a cookie it sets is readable — from the graph rather than from a
|
|
349
|
-
* hand-maintained list.
|
|
350
|
-
*/
|
|
351
|
-
"rest-api": async (declaration, context) => {
|
|
352
|
-
if (declaration.kind !== "rest-api") throw new WrongKind(declaration.kind);
|
|
353
|
-
const url = context.addresses?.[declaration.id];
|
|
354
|
-
if (!url) throw new SurfaceHasNoAddress(declaration.id);
|
|
355
|
-
const origins = [...new Set((0, _geekmidas_manifest.dependentsOf)(context.manifest, declaration.id).map((caller) => context.addresses?.[caller]).filter((address) => Boolean(address)))].sort();
|
|
356
|
-
const domain = (0, _geekmidas_manifest.cookieDomain)([url, ...origins]);
|
|
357
|
-
const schemes = (0, _geekmidas_manifest.dependentsOf)(context.manifest, declaration.id).flatMap((caller) => {
|
|
358
|
-
const app = context.manifest[caller];
|
|
359
|
-
return app?.kind === "mobile-app" ? (0, _geekmidas_manifest.mobileOrigins)((0, _geekmidas_manifest.schemeBase)(context.project, app.scheme)) : [];
|
|
360
|
-
});
|
|
361
|
-
return { provides: {
|
|
362
|
-
[(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: url,
|
|
363
|
-
[(0, _geekmidas_manifest.provideKey)(declaration.id, "trustedOrigins")]: [...origins, ...schemes].join(","),
|
|
364
|
-
...domain ? { [(0, _geekmidas_manifest.provideKey)(declaration.id, "cookieDomain")]: domain } : {}
|
|
365
|
-
} };
|
|
249
|
+
async function findProject(api, identity, stateProjectId) {
|
|
250
|
+
const name = require_state.projectName(identity);
|
|
251
|
+
const marker = require_state.ownershipMarker(identity);
|
|
252
|
+
if (stateProjectId) {
|
|
253
|
+
const known = await projectById(api, stateProjectId);
|
|
254
|
+
if (known) {
|
|
255
|
+
const claimed = require_state.markerOf(known.description);
|
|
256
|
+
if (claimed && claimed !== marker) throw new ProjectNotOwned(known.name, known.projectId, identity.key, claimed);
|
|
257
|
+
return {
|
|
258
|
+
projectId: known.projectId,
|
|
259
|
+
name: known.name,
|
|
260
|
+
description: known.description ?? null,
|
|
261
|
+
environments: known.environments ?? [],
|
|
262
|
+
via: "state",
|
|
263
|
+
needsClaim: !claimed
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const projects = await api.listProjects();
|
|
268
|
+
const owned = projects.find((p) => p.name.toLowerCase() === name.toLowerCase() && require_state.isOwnedBy(p.description, identity));
|
|
269
|
+
if (owned) {
|
|
270
|
+
const details = await api.getProject(owned.projectId);
|
|
271
|
+
return {
|
|
272
|
+
projectId: owned.projectId,
|
|
273
|
+
name: owned.name,
|
|
274
|
+
description: owned.description ?? null,
|
|
275
|
+
environments: details.environments ?? [],
|
|
276
|
+
via: "marker",
|
|
277
|
+
needsClaim: false
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const clash = projects.find((p) => p.name.toLowerCase() === name.toLowerCase());
|
|
281
|
+
if (clash) throw new ProjectNotOwned(clash.name, clash.projectId, identity.key, require_state.markerOf(clash.description));
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The project this deploy deploys into: the one {@link findProject} finds —
|
|
286
|
+
* claimed with this identity's marker if it carries none yet, so the next
|
|
287
|
+
* deploy without state still finds it — or a new one, marked.
|
|
288
|
+
*/
|
|
289
|
+
async function resolveProject(api, identity, stateProjectId, log = () => {}, hooks = {}) {
|
|
290
|
+
const marker = require_state.ownershipMarker(identity);
|
|
291
|
+
const found = await findProject(api, identity, stateProjectId);
|
|
292
|
+
if (found) {
|
|
293
|
+
if (found.needsClaim) {
|
|
294
|
+
await api.updateProject(found.projectId, {
|
|
295
|
+
name: found.name,
|
|
296
|
+
description: withMarker(found.description, marker)
|
|
297
|
+
});
|
|
298
|
+
log(` Claimed project ${found.name} for ${identity.key}`);
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
projectId: found.projectId,
|
|
302
|
+
name: found.name,
|
|
303
|
+
environments: found.environments,
|
|
304
|
+
via: found.via
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
await hooks.beforeCreate?.();
|
|
308
|
+
const created = await api.createProject(require_state.projectName(identity), withMarker("Deployed by gkm.", marker));
|
|
309
|
+
return {
|
|
310
|
+
projectId: created.project.projectId,
|
|
311
|
+
name: created.project.name,
|
|
312
|
+
environments: [created.environment],
|
|
313
|
+
via: "created"
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/deploy/registry.ts
|
|
318
|
+
/**
|
|
319
|
+
* Which Dokploy registry a stage's images are pulled through.
|
|
320
|
+
*
|
|
321
|
+
* It used to be one id stored per machine (`~/.gkm/credentials.json`), and
|
|
322
|
+
* when that was empty, the first registry Dokploy listed — so an image could be
|
|
323
|
+
* deployed with credentials for a registry nobody chose, and two workspaces
|
|
324
|
+
* deployed from one machine shared whichever was stored last. The id is now
|
|
325
|
+
* kept in the stage's state, and found only from what the workspace
|
|
326
|
+
* configured.
|
|
327
|
+
*/
|
|
328
|
+
/** There is nowhere to push images to, so Dokploy has nothing to pull. */
|
|
329
|
+
var RegistryNotConfigured = class extends Error {
|
|
330
|
+
stage;
|
|
331
|
+
constructor(stage) {
|
|
332
|
+
super(`Deploying '${stage}' needs a container registry: Dokploy pulls each app's image from one. Set deploy.registry in gkm.config.ts (e.g. 'ghcr.io/acme'), and deploy.dokploy.registryId if Dokploy already holds that registry's credentials.`);
|
|
333
|
+
this.stage = stage;
|
|
334
|
+
this.name = "RegistryNotConfigured";
|
|
366
335
|
}
|
|
367
336
|
};
|
|
368
|
-
/**
|
|
369
|
-
|
|
370
|
-
|
|
337
|
+
/** `deploy.dokploy.registryId` names a registry Dokploy does not have. */
|
|
338
|
+
var RegistryNotFound = class extends Error {
|
|
339
|
+
registryId;
|
|
340
|
+
constructor(registryId) {
|
|
341
|
+
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.`);
|
|
342
|
+
this.registryId = registryId;
|
|
343
|
+
this.name = "RegistryNotFound";
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
/** More than one Dokploy registry serves the configured registry. */
|
|
347
|
+
var RegistryAmbiguous = class extends Error {
|
|
348
|
+
registry;
|
|
349
|
+
registryIds;
|
|
350
|
+
constructor(registry, registryIds) {
|
|
351
|
+
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.`);
|
|
352
|
+
this.registry = registry;
|
|
353
|
+
this.registryIds = registryIds;
|
|
354
|
+
this.name = "RegistryAmbiguous";
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
/** `https://ghcr.io/acme/` → `{ host: 'ghcr.io', path: 'acme' }`. */
|
|
358
|
+
function parts(url) {
|
|
359
|
+
const [host = "", ...path] = url.replace(/^[a-z]+:\/\//i, "").replace(/\/+$/, "").toLowerCase().split("/");
|
|
360
|
+
return {
|
|
361
|
+
host,
|
|
362
|
+
path: path.join("/")
|
|
363
|
+
};
|
|
371
364
|
}
|
|
372
365
|
/**
|
|
373
|
-
* The
|
|
374
|
-
*
|
|
375
|
-
* The same statements the local target applies and the same ones the AWS
|
|
376
|
-
* bootstrap runs. Dokploy used to write its own, which is how three targets
|
|
377
|
-
* came to hold three definitions of the same split.
|
|
366
|
+
* The Dokploy registries that serve `registry`: the same host, and — where
|
|
367
|
+
* Dokploy's own entry names one — the same path.
|
|
378
368
|
*/
|
|
379
|
-
function
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
database
|
|
396
|
-
}));
|
|
369
|
+
function serving(registries, registry) {
|
|
370
|
+
const wanted = parts(registry);
|
|
371
|
+
const sameHost = registries.filter((r) => parts(r.registryUrl ?? "").host === wanted.host);
|
|
372
|
+
if (sameHost.length <= 1) return sameHost;
|
|
373
|
+
return sameHost.filter((r) => {
|
|
374
|
+
return (parts(r.registryUrl ?? "").path || (r.imagePrefix ?? "").toLowerCase()) === wanted.path;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/** `registry.one`, or `null` when Dokploy says it has no such registry. */
|
|
378
|
+
async function registryById(api, registryId) {
|
|
379
|
+
try {
|
|
380
|
+
return await api.getRegistry(registryId) ?? null;
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (error instanceof require_sniffer.DokployApiError) return null;
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
397
385
|
}
|
|
398
386
|
/**
|
|
399
|
-
* The
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
* what the runtime expects: the generated pollers open a single connection and
|
|
403
|
-
* subscribe each worker by name on it. So every carrier resolves to the same
|
|
404
|
-
* address, and the DDL that backs it is pushed once however many are declared.
|
|
405
|
-
*
|
|
406
|
-
* Only pg-boss is answered here. SNS needs a topic ARN that has to exist
|
|
407
|
-
* first, and RabbitMQ needs a broker Dokploy has no primitive for — both are
|
|
408
|
-
* the §4.3 question, and composing a plausible URL for either would fail at the
|
|
409
|
-
* first publish instead of here.
|
|
387
|
+
* The registry id for a stage, in order: the configured id, the one in the
|
|
388
|
+
* stage's state, the one Dokploy has for the configured registry URL, and
|
|
389
|
+
* otherwise a new one. Never a registry the workspace did not point at.
|
|
410
390
|
*/
|
|
411
|
-
function
|
|
412
|
-
const
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const url = new URL(parentUrl);
|
|
419
|
-
const database = url.pathname.slice(1);
|
|
420
|
-
const runtime = `${database}_${PGBOSS_SCHEMA}`;
|
|
421
|
-
const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
|
|
422
|
-
if (!context.provisioned[BROKER_ID]) {
|
|
423
|
-
context.deferred.push(...statementsFor({
|
|
424
|
-
id: BROKER_ID,
|
|
425
|
-
database,
|
|
426
|
-
runtime,
|
|
427
|
-
owner,
|
|
428
|
-
schema: PGBOSS_SCHEMA,
|
|
429
|
-
context
|
|
430
|
-
}));
|
|
431
|
-
context.provisioned[BROKER_ID] = { provides: {} };
|
|
391
|
+
async function resolveRegistry(api, options) {
|
|
392
|
+
const { stage, registry, configuredId, stateId, log = () => {} } = options;
|
|
393
|
+
if (!registry) throw new RegistryNotConfigured(stage);
|
|
394
|
+
if (configuredId) {
|
|
395
|
+
const configured = await registryById(api, configuredId);
|
|
396
|
+
if (!configured) throw new RegistryNotFound(configuredId);
|
|
397
|
+
return configured;
|
|
432
398
|
}
|
|
433
|
-
|
|
434
|
-
|
|
399
|
+
if (stateId) {
|
|
400
|
+
const known = await registryById(api, stateId);
|
|
401
|
+
if (known) return known;
|
|
402
|
+
log(` ⚠ The stage's registry ${stateId} no longer exists in Dokploy`);
|
|
403
|
+
}
|
|
404
|
+
const matches = serving(await api.listRegistries(), registry);
|
|
405
|
+
if (matches.length > 1) throw new RegistryAmbiguous(registry, matches.map((r) => r.registryId));
|
|
406
|
+
if (matches[0]) return matches[0];
|
|
407
|
+
return options.create(registry);
|
|
435
408
|
}
|
|
436
|
-
|
|
437
|
-
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/deploy/secrets.ts
|
|
438
411
|
/**
|
|
439
|
-
*
|
|
412
|
+
* Filter secrets to only include the env vars that an app requires.
|
|
440
413
|
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
414
|
+
* @param stageSecrets - All secrets for the stage
|
|
415
|
+
* @param sniffedEnv - The sniffed environment requirements for the app
|
|
416
|
+
* @returns Filtered secrets with found/missing tracking
|
|
443
417
|
*/
|
|
444
|
-
|
|
418
|
+
function filterSecretsForApp(stageSecrets, sniffedEnv) {
|
|
419
|
+
const allSecrets = require_state.toEmbeddableSecrets(stageSecrets);
|
|
420
|
+
const filtered = {};
|
|
421
|
+
const found = [];
|
|
422
|
+
const missing = [];
|
|
423
|
+
for (const key of sniffedEnv.requiredEnvVars) if (key in allSecrets) {
|
|
424
|
+
filtered[key] = allSecrets[key];
|
|
425
|
+
found.push(key);
|
|
426
|
+
} else missing.push(key);
|
|
427
|
+
return {
|
|
428
|
+
appName: sniffedEnv.appName,
|
|
429
|
+
secrets: filtered,
|
|
430
|
+
found: found.sort(),
|
|
431
|
+
missing: missing.sort()
|
|
432
|
+
};
|
|
433
|
+
}
|
|
445
434
|
/**
|
|
446
|
-
*
|
|
435
|
+
* Encrypt filtered secrets for an app.
|
|
436
|
+
* Generates an ephemeral master key that should be injected into Dokploy.
|
|
447
437
|
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
438
|
+
* @param filteredSecrets - The filtered secrets for the app
|
|
439
|
+
* @returns Encrypted payload with master key
|
|
450
440
|
*/
|
|
451
|
-
function
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
441
|
+
function encryptSecretsForApp(filteredSecrets) {
|
|
442
|
+
const payload = require_sniffer.encryptSecrets(filteredSecrets.secrets);
|
|
443
|
+
return {
|
|
444
|
+
appName: filteredSecrets.appName,
|
|
445
|
+
payload,
|
|
446
|
+
masterKey: payload.masterKey,
|
|
447
|
+
secretCount: Object.keys(filteredSecrets.secrets).length,
|
|
448
|
+
missingSecrets: filteredSecrets.missing
|
|
449
|
+
};
|
|
459
450
|
}
|
|
460
451
|
/**
|
|
461
|
-
*
|
|
452
|
+
* Filter and encrypt secrets for an app in one step.
|
|
462
453
|
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
* than by a second call, so there is nothing to reconcile afterwards.
|
|
454
|
+
* @param stageSecrets - All secrets for the stage
|
|
455
|
+
* @param sniffedEnv - The sniffed environment requirements for the app
|
|
456
|
+
* @returns Encrypted secrets with master key
|
|
467
457
|
*/
|
|
468
|
-
function
|
|
469
|
-
|
|
470
|
-
return [
|
|
471
|
-
"services:",
|
|
472
|
-
` ${service}:`,
|
|
473
|
-
" image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
|
|
474
|
-
` command: server /data --console-address ":9001"`,
|
|
475
|
-
" environment:",
|
|
476
|
-
` MINIO_ROOT_USER: ${user}`,
|
|
477
|
-
` MINIO_ROOT_PASSWORD: ${password}`,
|
|
478
|
-
" volumes:",
|
|
479
|
-
` - ${service}-data:/data`,
|
|
480
|
-
" networks:",
|
|
481
|
-
" - dokploy-network",
|
|
482
|
-
" healthcheck:",
|
|
483
|
-
" test: [\"CMD\", \"mc\", \"ready\", \"local\"]",
|
|
484
|
-
" interval: 10s",
|
|
485
|
-
" retries: 5",
|
|
486
|
-
` ${service}-bucket:`,
|
|
487
|
-
" image: minio/mc:latest",
|
|
488
|
-
" depends_on:",
|
|
489
|
-
` ${service}:`,
|
|
490
|
-
" condition: service_healthy",
|
|
491
|
-
" entrypoint: >",
|
|
492
|
-
` /bin/sh -c "mc alias set s3 http://${service}:9000 ${user} ${password} &&`,
|
|
493
|
-
` mc mb --ignore-existing s3/${bucket}"`,
|
|
494
|
-
" networks:",
|
|
495
|
-
" - dokploy-network",
|
|
496
|
-
"volumes:",
|
|
497
|
-
` ${service}-data:`,
|
|
498
|
-
"networks:",
|
|
499
|
-
" dokploy-network:",
|
|
500
|
-
" external: true",
|
|
501
|
-
""
|
|
502
|
-
].join("\n");
|
|
503
|
-
}
|
|
504
|
-
/** Whether anything reads through a reader on this construct. */
|
|
505
|
-
function readsFrom(manifest, id) {
|
|
506
|
-
return Object.values(manifest).some((declaration) => declaration.kind === "database-reader" && declaration.of === id);
|
|
507
|
-
}
|
|
508
|
-
/** The schema a database or tenant pins on its roles. */
|
|
509
|
-
function schemaOf(manifest, id) {
|
|
510
|
-
const declaration = manifest[id];
|
|
511
|
-
if (declaration?.kind === "database") return declaration.schema ?? "app";
|
|
512
|
-
if (declaration?.kind === "database-schema") return declaration.schema;
|
|
458
|
+
function prepareSecretsForApp(stageSecrets, sniffedEnv) {
|
|
459
|
+
return encryptSecretsForApp(filterSecretsForApp(stageSecrets, sniffedEnv));
|
|
513
460
|
}
|
|
514
461
|
/**
|
|
515
|
-
*
|
|
462
|
+
* Prepare secrets for multiple apps.
|
|
516
463
|
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
464
|
+
* @param stageSecrets - All secrets for the stage
|
|
465
|
+
* @param sniffedApps - Map of app name to sniffed environment
|
|
466
|
+
* @returns Map of app name to encrypted secrets
|
|
520
467
|
*/
|
|
521
|
-
function
|
|
522
|
-
const
|
|
523
|
-
if (
|
|
524
|
-
|
|
468
|
+
function prepareSecretsForAllApps(stageSecrets, sniffedApps) {
|
|
469
|
+
const results = /* @__PURE__ */ new Map();
|
|
470
|
+
for (const [appName, sniffedEnv] of sniffedApps) if (sniffedEnv.requiredEnvVars.length > 0) {
|
|
471
|
+
const encrypted = prepareSecretsForApp(stageSecrets, sniffedEnv);
|
|
472
|
+
results.set(appName, encrypted);
|
|
473
|
+
}
|
|
474
|
+
return results;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Generate a report on secrets preparation.
|
|
478
|
+
*/
|
|
479
|
+
function generateSecretsReport(encryptedApps, sniffedApps) {
|
|
480
|
+
const appsWithSecrets = [];
|
|
481
|
+
const appsWithoutSecrets = [];
|
|
482
|
+
const appsWithMissingSecrets = [];
|
|
483
|
+
for (const [appName, sniffedEnv] of sniffedApps) {
|
|
484
|
+
if (sniffedEnv.requiredEnvVars.length === 0) {
|
|
485
|
+
appsWithoutSecrets.push(appName);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const encrypted = encryptedApps.get(appName);
|
|
489
|
+
if (encrypted) {
|
|
490
|
+
appsWithSecrets.push(appName);
|
|
491
|
+
if (encrypted.missingSecrets.length > 0) appsWithMissingSecrets.push({
|
|
492
|
+
appName,
|
|
493
|
+
missing: encrypted.missingSecrets
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
totalApps: sniffedApps.size,
|
|
499
|
+
appsWithSecrets: appsWithSecrets.sort(),
|
|
500
|
+
appsWithoutSecrets: appsWithoutSecrets.sort(),
|
|
501
|
+
appsWithMissingSecrets
|
|
502
|
+
};
|
|
525
503
|
}
|
|
504
|
+
//#endregion
|
|
505
|
+
//#region src/target/dokploy/fromManifest.ts
|
|
526
506
|
/**
|
|
527
|
-
*
|
|
507
|
+
* The Dokploy target, read off the construct manifest.
|
|
528
508
|
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
509
|
+
* The AWS target has done this since `fromManifest` landed: a table keyed by
|
|
510
|
+
* declaration kind, each entry turning one declaration into infrastructure and
|
|
511
|
+
* the env keys it resolves. Dokploy had no such thing — it resolved environment
|
|
512
|
+
* from the *sniffer*, which walks application code for `get('X')` calls, and a
|
|
513
|
+
* construct reads its own key inside `@geekmidas/constructs` where there is
|
|
514
|
+
* nothing to find. So a declared app deployed here got none of the model, and
|
|
515
|
+
* nothing reported the absence: the deploy succeeded and the first request that
|
|
516
|
+
* needed a URL failed at runtime.
|
|
517
|
+
*
|
|
518
|
+
* This is the same table, over the REST wrapper instead of over Pulumi.
|
|
519
|
+
*
|
|
520
|
+
* **Two phases, and the split is not incidental.** Creating a Postgres and
|
|
521
|
+
* creating a role inside it are different kinds of act: the first is an API
|
|
522
|
+
* call, the second needs a connection to a database that does not exist yet
|
|
523
|
+
* when the call is made. So provisioners *accumulate* DDL rather than running
|
|
524
|
+
* it, and it is applied once against a cluster that is up — which is exactly
|
|
525
|
+
* what `DatabaseBootstrap` does on AWS, for exactly the same reason.
|
|
526
|
+
*
|
|
527
|
+
* **The DDL itself is shared.** `roleStatements` and `cacheTableStatements` are
|
|
528
|
+
* the same generators the local target and the AWS target use. Dokploy used to
|
|
529
|
+
* write its own `DO $$` block, which meant three targets applying three
|
|
530
|
+
* different definitions of "the role split" and no way to notice they had
|
|
531
|
+
* drifted.
|
|
532
|
+
*
|
|
533
|
+
* Entries are deliberately small and free of I/O ordering: each takes what it
|
|
534
|
+
* needs and returns what it resolved. That is what would let a Pulumi dynamic
|
|
535
|
+
* provider wrap one later without the table itself changing — see §6b.
|
|
534
536
|
*/
|
|
535
|
-
function derivedPassword(context, role) {
|
|
536
|
-
return (0, node_crypto.createHash)("sha256").update(`${context.seed}:${context.project}:${context.stage}:role:${role}`).digest("base64url").slice(0, 32);
|
|
537
|
-
}
|
|
538
|
-
/** A connection string for a role on a cluster. */
|
|
539
|
-
function postgresUrl(role, password, host, database) {
|
|
540
|
-
return `postgres://${role}:${encodeURIComponent(password)}@${host}:5432/${database}`;
|
|
541
|
-
}
|
|
542
|
-
/** Quote an identifier, per segment, for DDL that cannot be parameterised. */
|
|
543
|
-
function quoted(name) {
|
|
544
|
-
return name.split(".").map((part) => `"${part.replace(/"/g, "\"\"")}"`).join(".");
|
|
545
|
-
}
|
|
546
|
-
/** A provisioner was handed a declaration of the wrong kind. */
|
|
547
537
|
/**
|
|
548
|
-
*
|
|
538
|
+
* The internal hostname a Dokploy Postgres answers on.
|
|
549
539
|
*
|
|
550
|
-
*
|
|
540
|
+
* Its own service name on the Docker network — not the server's public address.
|
|
541
|
+
* An app reaching its database over the public internet would be a credential
|
|
542
|
+
* crossing a network that did not need to see it.
|
|
551
543
|
*/
|
|
552
|
-
function
|
|
553
|
-
|
|
554
|
-
if (value === void 0) throw new MissingSuppliedSecret(id, key, context.stage);
|
|
555
|
-
return value;
|
|
544
|
+
function clusterHost(postgres) {
|
|
545
|
+
return postgres.appName;
|
|
556
546
|
}
|
|
557
|
-
/** A construct needs a value the stage was never given. */
|
|
558
|
-
var MissingSuppliedSecret = class extends Error {
|
|
559
|
-
id;
|
|
560
|
-
key;
|
|
561
|
-
stage;
|
|
562
|
-
constructor(id, key, stage) {
|
|
563
|
-
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}`);
|
|
564
|
-
this.id = id;
|
|
565
|
-
this.key = key;
|
|
566
|
-
this.stage = stage;
|
|
567
|
-
this.name = "MissingSuppliedSecret";
|
|
568
|
-
}
|
|
569
|
-
};
|
|
570
|
-
var WrongKind = class extends Error {
|
|
571
|
-
kind;
|
|
572
|
-
constructor(kind) {
|
|
573
|
-
super(`No Dokploy provisioner handles '${kind}'`);
|
|
574
|
-
this.kind = kind;
|
|
575
|
-
this.name = "WrongKind";
|
|
576
|
-
}
|
|
577
|
-
};
|
|
578
|
-
/** A derived construct whose parent has not been provisioned. */
|
|
579
|
-
var UnresolvedParent = class extends Error {
|
|
580
|
-
id;
|
|
581
|
-
parent;
|
|
582
|
-
constructor(id, parent) {
|
|
583
|
-
super(`'${id}' derives from '${parent}', which has not been provisioned. Provision in \`provisionOrder\`, which puts parents first.`);
|
|
584
|
-
this.id = id;
|
|
585
|
-
this.parent = parent;
|
|
586
|
-
this.name = "UnresolvedParent";
|
|
587
|
-
}
|
|
588
|
-
};
|
|
589
|
-
/** A cache with nowhere to live. */
|
|
590
|
-
var CacheNeedsAHome = class extends Error {
|
|
591
|
-
id;
|
|
592
|
-
backend;
|
|
593
|
-
constructor(id, backend) {
|
|
594
|
-
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}'.` : ""));
|
|
595
|
-
this.id = id;
|
|
596
|
-
this.backend = backend;
|
|
597
|
-
this.name = "CacheNeedsAHome";
|
|
598
|
-
}
|
|
599
|
-
};
|
|
600
|
-
/** A `'db'` cache (a server target's default) with more than one database. */
|
|
601
|
-
var CacheIsAmbiguous = class extends Error {
|
|
602
|
-
databases;
|
|
603
|
-
constructor(databases) {
|
|
604
|
-
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().`);
|
|
605
|
-
this.databases = databases;
|
|
606
|
-
this.name = "CacheIsAmbiguous";
|
|
607
|
-
}
|
|
608
|
-
};
|
|
609
|
-
/** A surface reached provisioning before its domain existed. */
|
|
610
|
-
var SurfaceHasNoAddress = class extends Error {
|
|
611
|
-
id;
|
|
612
|
-
constructor(id) {
|
|
613
|
-
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.`);
|
|
614
|
-
this.id = id;
|
|
615
|
-
this.name = "SurfaceHasNoAddress";
|
|
616
|
-
}
|
|
617
|
-
};
|
|
618
|
-
/** A queue or topic on a backend this target has no primitive for. */
|
|
619
|
-
var UnprovisionableCarrier = class extends Error {
|
|
620
|
-
id;
|
|
621
|
-
backend;
|
|
622
|
-
constructor(id, backend) {
|
|
623
|
-
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.`);
|
|
624
|
-
this.id = id;
|
|
625
|
-
this.backend = backend;
|
|
626
|
-
this.name = "UnprovisionableCarrier";
|
|
627
|
-
}
|
|
628
|
-
};
|
|
629
|
-
/** A pg-boss carrier in an app that declares no database to host it. */
|
|
630
|
-
var BrokerNeedsADatabase = class extends Error {
|
|
631
|
-
id;
|
|
632
|
-
constructor(id) {
|
|
633
|
-
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.`);
|
|
634
|
-
this.id = id;
|
|
635
|
-
this.name = "BrokerNeedsADatabase";
|
|
636
|
-
}
|
|
637
|
-
};
|
|
638
|
-
/** A bucket on a backend this target has no primitive for. */
|
|
639
|
-
var UnprovisionableBucket = class extends Error {
|
|
640
|
-
id;
|
|
641
|
-
backend;
|
|
642
|
-
constructor(id, backend) {
|
|
643
|
-
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.`);
|
|
644
|
-
this.id = id;
|
|
645
|
-
this.backend = backend;
|
|
646
|
-
this.name = "UnprovisionableBucket";
|
|
647
|
-
}
|
|
648
|
-
};
|
|
649
|
-
//#endregion
|
|
650
|
-
//#region src/deploy/declared.ts
|
|
651
547
|
/**
|
|
652
|
-
*
|
|
548
|
+
* What a resource is called in the provider.
|
|
653
549
|
*
|
|
654
|
-
* `
|
|
655
|
-
*
|
|
656
|
-
* provisioners deferred.
|
|
550
|
+
* `cloudName` and nothing else — the same scoped kebab rule the AWS target
|
|
551
|
+
* uses, so a name reads identically on both: `production-shop-orders`.
|
|
657
552
|
*
|
|
658
|
-
* It
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
* Provision everything the workspace declares, and return what it resolved.
|
|
553
|
+
* It briefly carried a `-postgres` suffix on the theory that a Dokploy project
|
|
554
|
+
* is one flat list where an application and its database would collide. It is
|
|
555
|
+
* not: `project.one` returns typed collections — `applications`, `postgres`,
|
|
556
|
+
* `redis`, `compose`, `mariadb`, `mongo`, `mysql` — so the kind is already in
|
|
557
|
+
* the shape of the response and an application and a Postgres of the same name
|
|
558
|
+
* are different objects with different ids. The suffix was answering a question
|
|
559
|
+
* nobody had asked.
|
|
666
560
|
*/
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
561
|
+
function serviceName(scope, id) {
|
|
562
|
+
return (0, _geekmidas_manifest.cloudName)(scope, id);
|
|
563
|
+
}
|
|
564
|
+
const PROVISIONERS = {
|
|
565
|
+
/**
|
|
566
|
+
* A Postgres service, plus the roles that reach it.
|
|
567
|
+
*
|
|
568
|
+
* The service is `findOrCreate`, because a deploy that recreated the
|
|
569
|
+
* database would be a deploy that lost the data. The roles are deferred:
|
|
570
|
+
* they need a connection to a cluster this call has only just asked for.
|
|
571
|
+
*/
|
|
572
|
+
database: async (declaration, context) => {
|
|
573
|
+
if (declaration.kind !== "database") throw new WrongKind(declaration.kind);
|
|
574
|
+
const name = require_state.resourceName(declaration.id, declaration.kind, context.stage);
|
|
575
|
+
const { postgres } = await context.api.findOrCreatePostgres(serviceName({
|
|
576
|
+
stage: context.stage,
|
|
577
|
+
app: context.scope ?? context.project
|
|
578
|
+
}, declaration.id), context.projectId, context.environmentId, {
|
|
579
|
+
databaseName: name,
|
|
580
|
+
databasePassword: derivedPassword(context, `${name}:master`)
|
|
581
|
+
});
|
|
582
|
+
const host = clusterHost(postgres);
|
|
583
|
+
context.clusters[postgres.databaseName] = postgres;
|
|
584
|
+
const schema = declaration.schema ?? "app";
|
|
585
|
+
const runtime = name;
|
|
586
|
+
const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
|
|
587
|
+
const reads = readsFrom(context.manifest, declaration.id);
|
|
588
|
+
context.deferred.push(...statementsFor({
|
|
589
|
+
id: declaration.id,
|
|
590
|
+
database: postgres.databaseName,
|
|
591
|
+
ownsDatabase: true,
|
|
592
|
+
runtime,
|
|
593
|
+
owner,
|
|
594
|
+
...reads ? { reader: (0, _geekmidas_db_pg_roles.readerRole)(runtime) } : {},
|
|
595
|
+
schema,
|
|
596
|
+
context
|
|
597
|
+
}));
|
|
598
|
+
return { provides: {
|
|
599
|
+
[(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: postgresUrl(runtime, derivedPassword(context, runtime), host, postgres.databaseName),
|
|
600
|
+
[(0, _geekmidas_manifest.provideKey)(declaration.id, "ownerUrl")]: postgresUrl(owner, derivedPassword(context, owner), host, postgres.databaseName)
|
|
601
|
+
} };
|
|
602
|
+
},
|
|
603
|
+
/**
|
|
604
|
+
* A schema in the parent's database, with its own role and its own URL — so
|
|
605
|
+
* the parent's role holds no grant on these tables at all.
|
|
606
|
+
*/
|
|
607
|
+
"database-schema": async (declaration, context) => {
|
|
608
|
+
if (declaration.kind !== "database-schema") throw new WrongKind(declaration.kind);
|
|
609
|
+
const parent = context.provisioned[declaration.of];
|
|
610
|
+
if (!parent) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
611
|
+
const runtime = require_state.resourceName(declaration.id, declaration.kind, context.stage);
|
|
612
|
+
const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
|
|
613
|
+
const reads = readsFrom(context.manifest, declaration.id);
|
|
614
|
+
const parentUrl = new URL(parent.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")] ?? "");
|
|
615
|
+
context.deferred.push(...statementsFor({
|
|
616
|
+
id: declaration.id,
|
|
617
|
+
database: parentUrl.pathname.slice(1),
|
|
618
|
+
runtime,
|
|
619
|
+
owner,
|
|
620
|
+
...reads ? { reader: (0, _geekmidas_db_pg_roles.readerRole)(runtime) } : {},
|
|
621
|
+
schema: declaration.schema,
|
|
622
|
+
context
|
|
623
|
+
}));
|
|
624
|
+
return { provides: {
|
|
625
|
+
[(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: postgresUrl(runtime, derivedPassword(context, runtime), parentUrl.hostname, parentUrl.pathname.slice(1)),
|
|
626
|
+
[(0, _geekmidas_manifest.provideKey)(declaration.id, "ownerUrl")]: postgresUrl(owner, derivedPassword(context, owner), parentUrl.hostname, parentUrl.pathname.slice(1))
|
|
627
|
+
} };
|
|
628
|
+
},
|
|
629
|
+
/**
|
|
630
|
+
* A read-only endpoint on the same cluster.
|
|
631
|
+
*
|
|
632
|
+
* Nothing is provisioned: a Dokploy Postgres has one endpoint, so this
|
|
633
|
+
* resolves to the writer's address — and that is safe rather than a
|
|
634
|
+
* loophole, because read-only is enforced by the reader role's grants and
|
|
635
|
+
* not by which host the URL names. The role itself was created with its
|
|
636
|
+
* parent.
|
|
637
|
+
*/
|
|
638
|
+
"database-reader": async (declaration, context) => {
|
|
639
|
+
if (declaration.kind !== "database-reader") throw new WrongKind(declaration.kind);
|
|
640
|
+
const parent = context.provisioned[declaration.of];
|
|
641
|
+
if (!parent) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
642
|
+
const parentUrl = new URL(parent.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")] ?? "");
|
|
643
|
+
const reader = (0, _geekmidas_db_pg_roles.readerRole)(parentUrl.username);
|
|
644
|
+
return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: postgresUrl(reader, derivedPassword(context, reader), parentUrl.hostname, parentUrl.pathname.slice(1)) } };
|
|
645
|
+
},
|
|
646
|
+
/**
|
|
647
|
+
* A cache, wherever the project said it lives.
|
|
648
|
+
*
|
|
649
|
+
* `db` is a table in a database the manifest already declares, so it
|
|
650
|
+
* provisions no service and costs nothing to run — the same relationship
|
|
651
|
+
* pg-boss has. The table's DDL is deferred with the roles, and the table
|
|
652
|
+
* name travels in the URL because two caches in one database resolve the
|
|
653
|
+
* same connection string.
|
|
654
|
+
*/
|
|
655
|
+
cache: async (declaration, context) => {
|
|
656
|
+
if (declaration.kind !== "cache") throw new WrongKind(declaration.kind);
|
|
657
|
+
const parentId = declaration.of ?? (context.cache === "db" ? soleDatabase(context) : void 0);
|
|
658
|
+
if (!parentId) throw new CacheNeedsAHome(declaration.id, context.cache);
|
|
659
|
+
const parent = context.provisioned[parentId];
|
|
660
|
+
if (!parent) throw new UnresolvedParent(declaration.id, parentId);
|
|
661
|
+
const parentUrl = parent.provides[(0, _geekmidas_manifest.provideKey)(parentId, "url")];
|
|
662
|
+
if (!parentUrl) throw new UnresolvedParent(declaration.id, parentId);
|
|
663
|
+
const table = declaration.table ?? (0, _geekmidas_manifest.cacheTable)(declaration.id);
|
|
664
|
+
const schema = schemaOf(context.manifest, parentId);
|
|
665
|
+
const qualified = schema ? `${schema}.${table}` : table;
|
|
666
|
+
context.deferred.push(...(0, _geekmidas_cache_postgres.cacheTableStatements)({ table: qualified }).map((statement) => ({
|
|
667
|
+
id: declaration.id,
|
|
668
|
+
database: new URL(parentUrl).pathname.slice(1),
|
|
712
669
|
describe: statement.describe,
|
|
713
|
-
|
|
714
|
-
...statement.exists ? { exists: statement.exists } : {}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
670
|
+
sql: statement.sql,
|
|
671
|
+
...statement.exists ? { exists: statement.exists } : {}
|
|
672
|
+
})), {
|
|
673
|
+
id: declaration.id,
|
|
674
|
+
database: new URL(parentUrl).pathname.slice(1),
|
|
675
|
+
describe: `${qualified} is owned by its schema's owner`,
|
|
676
|
+
sql: `ALTER TABLE ${quoted(qualified)} OWNER TO ${quoted((0, _geekmidas_db_pg_roles.ownerRole)(new URL(parentUrl).username))}`
|
|
677
|
+
}, {
|
|
678
|
+
id: declaration.id,
|
|
679
|
+
database: new URL(parentUrl).pathname.slice(1),
|
|
680
|
+
describe: `${new URL(parentUrl).username} may read and write ${qualified}`,
|
|
681
|
+
sql: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${quoted(qualified)} TO ${quoted(new URL(parentUrl).username)}`
|
|
682
|
+
});
|
|
683
|
+
const url = new URL(parentUrl);
|
|
684
|
+
url.searchParams.set("table", table);
|
|
685
|
+
return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: url.toString() } };
|
|
686
|
+
},
|
|
687
|
+
/**
|
|
688
|
+
* A bucket, as a MinIO stack on the box.
|
|
689
|
+
*
|
|
690
|
+
* Dokploy has first-class primitives for Postgres and Redis and none for
|
|
691
|
+
* object storage, so this is the one kind whose infrastructure this target
|
|
692
|
+
* *writes* rather than configures — a compose file with one service in it.
|
|
693
|
+
*
|
|
694
|
+
* The service is named `cloudName(...)` like everything else, which is not
|
|
695
|
+
* cosmetic here: containers on `dokploy-network` resolve each other by
|
|
696
|
+
* service name, so the name is the address. Calling it `minio` would work
|
|
697
|
+
* for exactly one project on the box and then collide.
|
|
698
|
+
*
|
|
699
|
+
* `s3://` carries no credentials, deliberately — deployed on AWS the SDK
|
|
700
|
+
* reads them from an execution role, so a URL that embedded a key would be
|
|
701
|
+
* one more thing to rotate and leak. There is no role here, so the same
|
|
702
|
+
* chain reads `AWS_ACCESS_KEY_ID` beside the URL, exactly as it does
|
|
703
|
+
* locally.
|
|
704
|
+
*/
|
|
705
|
+
objects: async (declaration, context) => {
|
|
706
|
+
if (declaration.kind !== "objects") throw new WrongKind(declaration.kind);
|
|
707
|
+
const backend = context.storage ?? "minio";
|
|
708
|
+
if (backend !== "minio") throw new UnprovisionableBucket(declaration.id, backend);
|
|
709
|
+
const service = serviceName({
|
|
710
|
+
stage: context.stage,
|
|
711
|
+
app: context.scope ?? context.project
|
|
712
|
+
}, declaration.id);
|
|
713
|
+
const bucket = require_state.resourceName(declaration.id, declaration.kind, context.stage);
|
|
714
|
+
const user = `${bucket}-root`;
|
|
715
|
+
const password = derivedPassword(context, `objects:${declaration.id}`);
|
|
716
|
+
const { compose } = await context.api.findOrCreateCompose(service, context.projectId, context.environmentId, minioCompose({
|
|
717
|
+
service,
|
|
718
|
+
bucket,
|
|
719
|
+
user,
|
|
720
|
+
password
|
|
721
|
+
}));
|
|
722
|
+
await context.api.deployCompose(compose.composeId);
|
|
723
|
+
return { provides: {
|
|
724
|
+
[(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: s3Url(service, bucket),
|
|
725
|
+
AWS_ACCESS_KEY_ID: user,
|
|
726
|
+
AWS_SECRET_ACCESS_KEY: password,
|
|
727
|
+
AWS_REGION: MINIO_REGION
|
|
728
|
+
} };
|
|
729
|
+
},
|
|
730
|
+
/**
|
|
731
|
+
* The address a bucket's objects are served on.
|
|
732
|
+
*
|
|
733
|
+
* Nothing is provisioned: Dokploy runs Traefik, so a second reverse proxy
|
|
734
|
+
* behind the first would terminate TLS twice — which is why the local
|
|
735
|
+
* target's Caddy has no equivalent here. What this resolves is the address,
|
|
736
|
+
* and the `open` prefixes are applied to the bucket rather than to an edge,
|
|
737
|
+
* so a policy is what makes them public wherever the bucket lives.
|
|
738
|
+
*
|
|
739
|
+
* Until a Traefik rule exists it answers on MinIO directly, path-style,
|
|
740
|
+
* which is honest about the shape it has today: the bucket is in the path
|
|
741
|
+
* rather than fronted by a host of its own.
|
|
742
|
+
*/
|
|
743
|
+
"file-server": async (declaration, context) => {
|
|
744
|
+
if (declaration.kind !== "file-server") throw new WrongKind(declaration.kind);
|
|
745
|
+
const parentUrl = context.provisioned[declaration.of]?.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")];
|
|
746
|
+
if (!parentUrl) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
747
|
+
const endpoint = new URL(parentUrl).searchParams.get("endpoint");
|
|
748
|
+
const bucket = new URL(parentUrl).hostname;
|
|
749
|
+
if (!endpoint) throw new UnresolvedParent(declaration.id, declaration.of);
|
|
750
|
+
return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: `${endpoint}/${bucket}` } };
|
|
751
|
+
},
|
|
752
|
+
/**
|
|
753
|
+
* A topic, and below it a queue — the same broker either way.
|
|
754
|
+
*
|
|
755
|
+
* Under pg-boss there is nothing to create in Dokploy: the broker is a
|
|
756
|
+
* schema tenant of the database the app already declared, exactly as it is
|
|
757
|
+
* locally, so what this resolves is an address rather than a service. Which
|
|
758
|
+
* is the point of putting the backend in config — the same handlers drain
|
|
759
|
+
* pg-boss here and SQS on AWS because the string they were handed said so.
|
|
760
|
+
*
|
|
761
|
+
* The tenant gets its own role rather than the cluster master, for the
|
|
762
|
+
* reason every other tenant does: pg-boss creates and owns its tables, and
|
|
763
|
+
* a broker that could also read the application's is a broker with a grant
|
|
764
|
+
* nothing asked for.
|
|
765
|
+
*/
|
|
766
|
+
topic: async (declaration, context) => {
|
|
767
|
+
if (declaration.kind !== "topic") throw new WrongKind(declaration.kind);
|
|
768
|
+
return broker(declaration.id, context);
|
|
769
|
+
},
|
|
770
|
+
queue: async (declaration, context) => {
|
|
771
|
+
if (declaration.kind !== "queue") throw new WrongKind(declaration.kind);
|
|
772
|
+
return broker(declaration.id, context);
|
|
773
|
+
},
|
|
774
|
+
/**
|
|
775
|
+
* A signing key, generated once and remembered in the stage's store.
|
|
776
|
+
*
|
|
777
|
+
* Random, never derived: a key computed from the project and stage names
|
|
778
|
+
* signs sessions anyone reading the repo could forge. The deploy generates
|
|
779
|
+
* it before provisioning (`withGeneratedSecrets`) and every later deploy
|
|
780
|
+
* reads the same one, so live sessions survive a redeploy. Nothing is
|
|
781
|
+
* created in Dokploy: a secret has no address.
|
|
782
|
+
*/
|
|
783
|
+
secret: async (declaration, context) => {
|
|
784
|
+
if (declaration.kind !== "secret") throw new WrongKind(declaration.kind);
|
|
785
|
+
const key = declaration.provides?.[0];
|
|
786
|
+
if (!key) return { provides: {} };
|
|
787
|
+
return { provides: { [key]: supplied(declaration.id, key, context) } };
|
|
788
|
+
},
|
|
789
|
+
/**
|
|
790
|
+
* The keyring the stage keeps in its secrets.
|
|
791
|
+
*
|
|
792
|
+
* Generated on the first deploy (`withGeneratedSecrets`) and rotated only
|
|
793
|
+
* by `gkm encryption:rotate`, so a redeploy never changes a key and nothing
|
|
794
|
+
* written becomes unreadable. Nothing is created in Dokploy: the process
|
|
795
|
+
* holds the keyring.
|
|
796
|
+
*/
|
|
797
|
+
encryption: async (declaration, context) => {
|
|
798
|
+
if (declaration.kind !== "encryption") throw new WrongKind(declaration.kind);
|
|
799
|
+
const key = (0, _geekmidas_manifest.provideKey)(declaration.id, "url");
|
|
800
|
+
return { provides: { [key]: supplied(declaration.id, key, context) } };
|
|
801
|
+
},
|
|
802
|
+
/**
|
|
803
|
+
* A third party's credentials, as the stage was given them.
|
|
804
|
+
*
|
|
805
|
+
* Nothing is created and nothing derived: the value was issued by somebody
|
|
806
|
+
* else. A stage that was never given one fails the deploy here, naming the
|
|
807
|
+
* command that sets it — not the first request that needs it.
|
|
808
|
+
*/
|
|
809
|
+
credential: async (declaration, context) => {
|
|
810
|
+
if (declaration.kind !== "credential") throw new WrongKind(declaration.kind);
|
|
811
|
+
const key = (0, _geekmidas_manifest.provideKey)(declaration.id, "credentials");
|
|
812
|
+
return { provides: { [key]: supplied(declaration.id, key, context) } };
|
|
813
|
+
},
|
|
814
|
+
/**
|
|
815
|
+
* An API somebody else runs: the URL this stage calls it at, and the
|
|
816
|
+
* credentials it was issued for this stage.
|
|
817
|
+
*/
|
|
818
|
+
"external-api": async (declaration, context) => {
|
|
819
|
+
if (declaration.kind !== "external-api") throw new WrongKind(declaration.kind);
|
|
820
|
+
const credentials = (0, _geekmidas_manifest.provideKey)(declaration.id, "credentials");
|
|
821
|
+
return { provides: {
|
|
822
|
+
[(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: (0, _geekmidas_manifest.externalApiUrl)(declaration, context.stage),
|
|
823
|
+
[credentials]: supplied(declaration.id, credentials, context)
|
|
824
|
+
} };
|
|
825
|
+
},
|
|
826
|
+
/**
|
|
827
|
+
* A surface, at the address whatever created its domain assigned.
|
|
828
|
+
*
|
|
829
|
+
* Nothing is provisioned here: the application and its domain are the
|
|
830
|
+
* existing engine's, and Dokploy's own Traefik terminates TLS with a Let's
|
|
831
|
+
* Encrypt certificate. What this adds is the *derivation* — who may call it,
|
|
832
|
+
* and where a cookie it sets is readable — from the graph rather than from a
|
|
833
|
+
* hand-maintained list.
|
|
834
|
+
*/
|
|
835
|
+
"rest-api": async (declaration, context) => {
|
|
836
|
+
if (declaration.kind !== "rest-api") throw new WrongKind(declaration.kind);
|
|
837
|
+
const url = context.addresses?.[declaration.id];
|
|
838
|
+
if (!url) throw new SurfaceHasNoAddress(declaration.id);
|
|
839
|
+
const origins = [...new Set((0, _geekmidas_manifest.dependentsOf)(context.manifest, declaration.id).map((caller) => context.addresses?.[caller]).filter((address) => Boolean(address)))].sort();
|
|
840
|
+
const domain = (0, _geekmidas_manifest.cookieDomain)([url, ...origins]);
|
|
841
|
+
const schemes = (0, _geekmidas_manifest.dependentsOf)(context.manifest, declaration.id).flatMap((caller) => {
|
|
842
|
+
const app = context.manifest[caller];
|
|
843
|
+
return app?.kind === "mobile-app" ? (0, _geekmidas_manifest.mobileOrigins)((0, _geekmidas_manifest.schemeBase)(context.project, app.scheme)) : [];
|
|
844
|
+
});
|
|
845
|
+
return { provides: {
|
|
846
|
+
[(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: url,
|
|
847
|
+
[(0, _geekmidas_manifest.provideKey)(declaration.id, "trustedOrigins")]: [...origins, ...schemes].join(","),
|
|
848
|
+
...domain ? { [(0, _geekmidas_manifest.provideKey)(declaration.id, "cookieDomain")]: domain } : {}
|
|
849
|
+
} };
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
/** The provisioner for a kind, or nothing where this target has none yet. */
|
|
853
|
+
function provisionerFor(kind) {
|
|
854
|
+
return PROVISIONERS[kind];
|
|
720
855
|
}
|
|
721
856
|
/**
|
|
722
|
-
*
|
|
857
|
+
* The role DDL for one database or tenant, from the shared generator.
|
|
723
858
|
*
|
|
724
|
-
* The same
|
|
725
|
-
*
|
|
726
|
-
*
|
|
859
|
+
* The same statements the local target applies and the same ones the AWS
|
|
860
|
+
* bootstrap runs. Dokploy used to write its own, which is how three targets
|
|
861
|
+
* came to hold three definitions of the same split.
|
|
727
862
|
*/
|
|
728
|
-
|
|
729
|
-
|
|
863
|
+
function statementsFor(options) {
|
|
864
|
+
const { id, database, runtime, owner, reader, schema, context } = options;
|
|
865
|
+
return (0, _geekmidas_db_pg_roles.roleStatements)({
|
|
866
|
+
runtime,
|
|
867
|
+
owner,
|
|
868
|
+
...reader ? { reader } : {},
|
|
869
|
+
schema,
|
|
870
|
+
...options.ownsDatabase ? { database } : {},
|
|
871
|
+
passwords: {
|
|
872
|
+
runtime: derivedPassword(context, runtime),
|
|
873
|
+
owner: derivedPassword(context, owner),
|
|
874
|
+
...reader ? { reader: derivedPassword(context, reader) } : {}
|
|
875
|
+
}
|
|
876
|
+
}).map((statement) => ({
|
|
877
|
+
...statement,
|
|
878
|
+
id,
|
|
879
|
+
database
|
|
880
|
+
}));
|
|
730
881
|
}
|
|
731
882
|
/**
|
|
732
|
-
* The
|
|
733
|
-
*
|
|
734
|
-
* `provisionOrder` walks `of` edges, and a carrier has none: a topic is not
|
|
735
|
-
* *derived from* a database, because under SNS it does not live in one. Where
|
|
736
|
-
* it lives is a fact about the backend, which is config — so the dependency is
|
|
737
|
-
* real under pg-boss and absent under SNS, and writing it into the declaration
|
|
738
|
-
* would state it in the one place that cannot know.
|
|
883
|
+
* The connection string a declared queue or topic publishes on.
|
|
739
884
|
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
* pg-boss that is exactly late enough for the database it reads to be there.
|
|
745
|
-
*/
|
|
746
|
-
function carriersLast(ordered, manifest) {
|
|
747
|
-
const carries = (id) => {
|
|
748
|
-
const kind = manifest[id]?.kind;
|
|
749
|
-
return kind === "queue" || kind === "topic";
|
|
750
|
-
};
|
|
751
|
-
return [...ordered.filter((id) => !carries(id)), ...ordered.filter(carries)];
|
|
752
|
-
}
|
|
753
|
-
/**
|
|
754
|
-
* Where each declared surface answers: its own app's address.
|
|
885
|
+
* One broker for the whole project, which is what the local target does and
|
|
886
|
+
* what the runtime expects: the generated pollers open a single connection and
|
|
887
|
+
* subscribe each worker by name on it. So every carrier resolves to the same
|
|
888
|
+
* address, and the DDL that backs it is pushed once however many are declared.
|
|
755
889
|
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
890
|
+
* Only pg-boss is answered here. SNS needs a topic ARN that has to exist
|
|
891
|
+
* first, and RabbitMQ needs a broker Dokploy has no primitive for — both are
|
|
892
|
+
* the §4.3 question, and composing a plausible URL for either would fail at the
|
|
893
|
+
* first publish instead of here.
|
|
760
894
|
*/
|
|
761
|
-
function
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
895
|
+
function broker(id, context) {
|
|
896
|
+
const backend = context.events ?? "pgboss";
|
|
897
|
+
if (backend !== "pgboss") throw new UnprovisionableCarrier(id, backend);
|
|
898
|
+
const parentId = soleDatabase(context);
|
|
899
|
+
if (!parentId) throw new BrokerNeedsADatabase(id);
|
|
900
|
+
const parentUrl = context.provisioned[parentId]?.provides[(0, _geekmidas_manifest.provideKey)(parentId, "url")];
|
|
901
|
+
if (!parentUrl) throw new UnresolvedParent(id, parentId);
|
|
902
|
+
const url = new URL(parentUrl);
|
|
903
|
+
const database = url.pathname.slice(1);
|
|
904
|
+
const runtime = `${database}_${PGBOSS_SCHEMA}`;
|
|
905
|
+
const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
|
|
906
|
+
if (!context.provisioned[BROKER_ID]) {
|
|
907
|
+
context.deferred.push(...statementsFor({
|
|
908
|
+
id: BROKER_ID,
|
|
909
|
+
database,
|
|
910
|
+
runtime,
|
|
911
|
+
owner,
|
|
912
|
+
schema: PGBOSS_SCHEMA,
|
|
913
|
+
context
|
|
914
|
+
}));
|
|
915
|
+
context.provisioned[BROKER_ID] = { provides: {} };
|
|
767
916
|
}
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
//#endregion
|
|
771
|
-
//#region src/deploy/dns/DnsProvider.ts
|
|
772
|
-
/**
|
|
773
|
-
* Check if value is a DnsProvider implementation.
|
|
774
|
-
*/
|
|
775
|
-
function isDnsProvider(value) {
|
|
776
|
-
return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.getRecords === "function" && typeof value.upsertRecords === "function" && typeof value.deleteRecords === "function";
|
|
917
|
+
const connection = pgbossUrl(runtime, derivedPassword(context, runtime), url.host, database);
|
|
918
|
+
return { provides: { [(0, _geekmidas_manifest.provideKey)(id, "publisherConnectionString")]: connection } };
|
|
777
919
|
}
|
|
920
|
+
/** The schema pg-boss creates its tables in, here as locally. */
|
|
921
|
+
const PGBOSS_SCHEMA = "pgboss";
|
|
778
922
|
/**
|
|
779
|
-
*
|
|
923
|
+
* The id the broker's DDL is pushed under.
|
|
780
924
|
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
* - 'manual': Returns null (user handles DNS)
|
|
784
|
-
* - Custom: Use provided DnsProvider implementation
|
|
925
|
+
* Not a construct — no declaration names it — but the deferred statements need
|
|
926
|
+
* an id, and every carrier in the project shares this one.
|
|
785
927
|
*/
|
|
786
|
-
|
|
787
|
-
const { config } = options;
|
|
788
|
-
if (config.provider === "manual") return null;
|
|
789
|
-
if (isDnsProvider(config.provider)) return config.provider;
|
|
790
|
-
const provider = config.provider;
|
|
791
|
-
if (provider === "hostinger") {
|
|
792
|
-
const { HostingerProvider } = await Promise.resolve().then(() => require("./HostingerProvider-Dlmbfass.cjs"));
|
|
793
|
-
return new HostingerProvider();
|
|
794
|
-
}
|
|
795
|
-
if (provider === "route53") {
|
|
796
|
-
const { Route53Provider } = await Promise.resolve().then(() => require("./Route53Provider-DVILBsvV.cjs"));
|
|
797
|
-
const route53Config = config;
|
|
798
|
-
return new Route53Provider({
|
|
799
|
-
region: route53Config.region,
|
|
800
|
-
profile: route53Config.profile,
|
|
801
|
-
hostedZoneId: route53Config.hostedZoneId
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
if (provider === "cloudflare") throw new Error("Cloudflare DNS provider not yet implemented");
|
|
805
|
-
throw new Error(`Unknown DNS provider: ${JSON.stringify(config)}`);
|
|
806
|
-
}
|
|
807
|
-
//#endregion
|
|
808
|
-
//#region src/deploy/dns/index.ts
|
|
928
|
+
const BROKER_ID = "PgBoss";
|
|
809
929
|
/**
|
|
810
|
-
*
|
|
930
|
+
* A pg-boss address: a Postgres URL under a scheme that picks the transport.
|
|
811
931
|
*
|
|
812
|
-
*
|
|
813
|
-
|
|
814
|
-
const logger$2 = require_discover.output;
|
|
815
|
-
/**
|
|
816
|
-
* Check if DNS config is legacy format (single domain with `domain` property)
|
|
932
|
+
* The protocol is what a producer branches on, so it never branches — the same
|
|
933
|
+
* `.publish()` reaches pg-boss here and SQS on AWS.
|
|
817
934
|
*/
|
|
818
|
-
function
|
|
819
|
-
return
|
|
935
|
+
function pgbossUrl(role, password, host, database) {
|
|
936
|
+
return `pgboss://${role}:${encodeURIComponent(password)}@${host}/${database}?schema=${PGBOSS_SCHEMA}`;
|
|
820
937
|
}
|
|
821
|
-
/**
|
|
822
|
-
|
|
823
|
-
*/
|
|
824
|
-
function
|
|
825
|
-
|
|
826
|
-
const { domain, ...providerConfig } = config;
|
|
827
|
-
return { [domain]: providerConfig };
|
|
828
|
-
}
|
|
829
|
-
return config;
|
|
938
|
+
/** The region MinIO is addressed with. It has no regions; the SDK wants one. */
|
|
939
|
+
const MINIO_REGION = "us-east-1";
|
|
940
|
+
/** An `s3://` URL pointing the same S3 client at MinIO on the internal network. */
|
|
941
|
+
function s3Url(service, bucket) {
|
|
942
|
+
return `s3://${bucket}?region=${MINIO_REGION}&endpoint=http://${service}:9000&forcePathStyle=true`;
|
|
830
943
|
}
|
|
831
944
|
/**
|
|
832
|
-
*
|
|
945
|
+
* A one-service compose file for a bucket.
|
|
833
946
|
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
|
|
838
|
-
function findRootDomain(hostname, dnsConfig) {
|
|
839
|
-
const domains = Object.keys(dnsConfig).sort((a, b) => b.length - a.length);
|
|
840
|
-
for (const domain of domains) if (hostname === domain || hostname.endsWith(`.${domain}`)) return domain;
|
|
841
|
-
return null;
|
|
842
|
-
}
|
|
843
|
-
/**
|
|
844
|
-
* Group hostnames by their root domain
|
|
947
|
+
* `dokploy-network` is external and already exists — it is what every Dokploy
|
|
948
|
+
* service is attached to, and joining it is what makes the app able to resolve
|
|
949
|
+
* this one by name. The bucket itself is created by MinIO's entrypoint rather
|
|
950
|
+
* than by a second call, so there is nothing to reconcile afterwards.
|
|
845
951
|
*/
|
|
846
|
-
function
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
952
|
+
function minioCompose(options) {
|
|
953
|
+
const { service, bucket, user, password } = options;
|
|
954
|
+
return [
|
|
955
|
+
"services:",
|
|
956
|
+
` ${service}:`,
|
|
957
|
+
" image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
|
|
958
|
+
` command: server /data --console-address ":9001"`,
|
|
959
|
+
" environment:",
|
|
960
|
+
` MINIO_ROOT_USER: ${user}`,
|
|
961
|
+
` MINIO_ROOT_PASSWORD: ${password}`,
|
|
962
|
+
" volumes:",
|
|
963
|
+
` - ${service}-data:/data`,
|
|
964
|
+
" networks:",
|
|
965
|
+
" - dokploy-network",
|
|
966
|
+
" healthcheck:",
|
|
967
|
+
" test: [\"CMD\", \"mc\", \"ready\", \"local\"]",
|
|
968
|
+
" interval: 10s",
|
|
969
|
+
" retries: 5",
|
|
970
|
+
` ${service}-bucket:`,
|
|
971
|
+
" image: minio/mc:latest",
|
|
972
|
+
" depends_on:",
|
|
973
|
+
` ${service}:`,
|
|
974
|
+
" condition: service_healthy",
|
|
975
|
+
" entrypoint: >",
|
|
976
|
+
` /bin/sh -c "mc alias set s3 http://${service}:9000 ${user} ${password} &&`,
|
|
977
|
+
` mc mb --ignore-existing s3/${bucket}"`,
|
|
978
|
+
" networks:",
|
|
979
|
+
" - dokploy-network",
|
|
980
|
+
"volumes:",
|
|
981
|
+
` ${service}-data:`,
|
|
982
|
+
"networks:",
|
|
983
|
+
" dokploy-network:",
|
|
984
|
+
" external: true",
|
|
985
|
+
""
|
|
986
|
+
].join("\n");
|
|
858
987
|
}
|
|
859
|
-
/**
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
async function resolveHostnameToIp(hostname) {
|
|
863
|
-
try {
|
|
864
|
-
return (await (0, node_dns_promises.lookup)(hostname, { family: 4 })).address;
|
|
865
|
-
} catch (error) {
|
|
866
|
-
throw new Error(`Failed to resolve IP for ${hostname}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
867
|
-
}
|
|
988
|
+
/** Whether anything reads through a reader on this construct. */
|
|
989
|
+
function readsFrom(manifest, id) {
|
|
990
|
+
return Object.values(manifest).some((declaration) => declaration.kind === "database-reader" && declaration.of === id);
|
|
868
991
|
}
|
|
869
|
-
/**
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
* extractSubdomain('joemoer.traflabs.io', 'traflabs.io') => 'joemoer'
|
|
875
|
-
*/
|
|
876
|
-
function extractSubdomain(hostname, rootDomain) {
|
|
877
|
-
if (!hostname.endsWith(rootDomain)) throw new Error(`Hostname ${hostname} is not under root domain ${rootDomain}`);
|
|
878
|
-
return hostname.slice(0, -(rootDomain.length + 1)) || "@";
|
|
992
|
+
/** The schema a database or tenant pins on its roles. */
|
|
993
|
+
function schemaOf(manifest, id) {
|
|
994
|
+
const declaration = manifest[id];
|
|
995
|
+
if (declaration?.kind === "database") return declaration.schema ?? "app";
|
|
996
|
+
if (declaration?.kind === "database-schema") return declaration.schema;
|
|
879
997
|
}
|
|
880
998
|
/**
|
|
881
|
-
*
|
|
999
|
+
* The one declared database, where `cache: 'db'` has to pick.
|
|
1000
|
+
*
|
|
1001
|
+
* Unambiguous with one and arbitrary with two, so two is an error rather than a
|
|
1002
|
+
* coin toss — a cache landing in a database nobody chose surfaces as entries
|
|
1003
|
+
* that are never found, long after the deploy reported success.
|
|
882
1004
|
*/
|
|
883
|
-
function
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
records.push({
|
|
888
|
-
hostname,
|
|
889
|
-
subdomain,
|
|
890
|
-
type: "A",
|
|
891
|
-
value: serverIp,
|
|
892
|
-
appName
|
|
893
|
-
});
|
|
894
|
-
}
|
|
895
|
-
return records;
|
|
1005
|
+
function soleDatabase(context) {
|
|
1006
|
+
const databases = Object.entries(context.manifest).filter(([, declaration]) => declaration.kind === "database").map(([id]) => id);
|
|
1007
|
+
if (databases.length > 1) throw new CacheIsAmbiguous(databases);
|
|
1008
|
+
return databases[0];
|
|
896
1009
|
}
|
|
897
1010
|
/**
|
|
898
|
-
*
|
|
1011
|
+
* A password derived from the stage's seed, the project, the stage and the role.
|
|
1012
|
+
*
|
|
1013
|
+
* Derived rather than stored per role so a redeploy hands the running
|
|
1014
|
+
* application the password it already has, and two projects never share one.
|
|
1015
|
+
* The seed is what makes it secret: random, generated once and kept in the
|
|
1016
|
+
* stage's store. Without it the inputs are all in the repo, and so was every
|
|
1017
|
+
* production password.
|
|
899
1018
|
*/
|
|
900
|
-
function
|
|
901
|
-
|
|
902
|
-
logger$2.log(" ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐");
|
|
903
|
-
logger$2.log(" │ Subdomain │ Type │ Value │ Status │");
|
|
904
|
-
logger$2.log(" ├─────────────────────────────────────┼──────┼─────────────────┼────────┤");
|
|
905
|
-
for (const record of records) {
|
|
906
|
-
const subdomain = record.subdomain.padEnd(35);
|
|
907
|
-
const type = record.type.padEnd(4);
|
|
908
|
-
const value = record.value.padEnd(15);
|
|
909
|
-
let status;
|
|
910
|
-
if (record.error) status = "✗";
|
|
911
|
-
else if (record.created) status = "✓ new";
|
|
912
|
-
else if (record.existed) status = "✓";
|
|
913
|
-
else status = "?";
|
|
914
|
-
logger$2.log(` │ ${subdomain} │ ${type} │ ${value} │ ${status.padEnd(6)} │`);
|
|
915
|
-
}
|
|
916
|
-
logger$2.log(" └─────────────────────────────────────┴──────┴─────────────────┴────────┘");
|
|
1019
|
+
function derivedPassword(context, role) {
|
|
1020
|
+
return (0, node_crypto.createHash)("sha256").update(`${context.seed}:${context.project}:${context.stage}:role:${role}`).digest("base64url").slice(0, 32);
|
|
917
1021
|
}
|
|
918
|
-
/**
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
function printDnsRecordsSimple(records, rootDomain) {
|
|
922
|
-
logger$2.log("\n 📋 Required DNS Records:");
|
|
923
|
-
logger$2.log(` Add these A records to your DNS provider (${rootDomain}):\n`);
|
|
924
|
-
for (const record of records) logger$2.log(` ${record.subdomain} → ${record.value} (A record)`);
|
|
925
|
-
logger$2.log("");
|
|
1022
|
+
/** A connection string for a role on a cluster. */
|
|
1023
|
+
function postgresUrl(role, password, host, database) {
|
|
1024
|
+
return `postgres://${role}:${encodeURIComponent(password)}@${host}:5432/${database}`;
|
|
926
1025
|
}
|
|
927
|
-
/**
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
async function createDnsRecordsForDomain(records, rootDomain, providerConfig) {
|
|
931
|
-
const ttl = "ttl" in providerConfig && providerConfig.ttl ? providerConfig.ttl : 300;
|
|
932
|
-
let provider;
|
|
933
|
-
try {
|
|
934
|
-
provider = await createDnsProvider({ config: providerConfig });
|
|
935
|
-
} catch (error) {
|
|
936
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
937
|
-
logger$2.log(` ⚠ Failed to create DNS provider for ${rootDomain}: ${message}`);
|
|
938
|
-
return records.map((r) => ({
|
|
939
|
-
...r,
|
|
940
|
-
error: message
|
|
941
|
-
}));
|
|
942
|
-
}
|
|
943
|
-
if (!provider) return records.map((r) => ({
|
|
944
|
-
...r,
|
|
945
|
-
created: false,
|
|
946
|
-
existed: false
|
|
947
|
-
}));
|
|
948
|
-
const results = [];
|
|
949
|
-
const upsertRecords = records.map((r) => ({
|
|
950
|
-
name: r.subdomain,
|
|
951
|
-
type: r.type,
|
|
952
|
-
ttl,
|
|
953
|
-
value: r.value
|
|
954
|
-
}));
|
|
955
|
-
try {
|
|
956
|
-
const upsertResults = await provider.upsertRecords(rootDomain, upsertRecords);
|
|
957
|
-
for (const [i, record] of records.entries()) {
|
|
958
|
-
const result = upsertResults[i];
|
|
959
|
-
if (!result) {
|
|
960
|
-
results.push({
|
|
961
|
-
hostname: record.hostname,
|
|
962
|
-
subdomain: record.subdomain,
|
|
963
|
-
type: record.type,
|
|
964
|
-
value: record.value,
|
|
965
|
-
appName: record.appName,
|
|
966
|
-
error: "No result returned from provider"
|
|
967
|
-
});
|
|
968
|
-
continue;
|
|
969
|
-
}
|
|
970
|
-
if (result.unchanged) results.push({
|
|
971
|
-
hostname: record.hostname,
|
|
972
|
-
subdomain: record.subdomain,
|
|
973
|
-
type: record.type,
|
|
974
|
-
value: record.value,
|
|
975
|
-
appName: record.appName,
|
|
976
|
-
existed: true,
|
|
977
|
-
created: false
|
|
978
|
-
});
|
|
979
|
-
else results.push({
|
|
980
|
-
hostname: record.hostname,
|
|
981
|
-
subdomain: record.subdomain,
|
|
982
|
-
type: record.type,
|
|
983
|
-
value: record.value,
|
|
984
|
-
appName: record.appName,
|
|
985
|
-
created: result.created,
|
|
986
|
-
existed: !result.created
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
} catch (error) {
|
|
990
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
991
|
-
logger$2.log(` ⚠ Failed to create DNS records for ${rootDomain}: ${message}`);
|
|
992
|
-
return records.map((r) => ({
|
|
993
|
-
hostname: r.hostname,
|
|
994
|
-
subdomain: r.subdomain,
|
|
995
|
-
type: r.type,
|
|
996
|
-
value: r.value,
|
|
997
|
-
appName: r.appName,
|
|
998
|
-
error: message
|
|
999
|
-
}));
|
|
1000
|
-
}
|
|
1001
|
-
return results;
|
|
1026
|
+
/** Quote an identifier, per segment, for DDL that cannot be parameterised. */
|
|
1027
|
+
function quoted(name) {
|
|
1028
|
+
return name.split(".").map((part) => `"${part.replace(/"/g, "\"\"")}"`).join(".");
|
|
1002
1029
|
}
|
|
1030
|
+
/** A provisioner was handed a declaration of the wrong kind. */
|
|
1003
1031
|
/**
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
* Supports both legacy single-domain format and new multi-domain format:
|
|
1007
|
-
* - Legacy: { provider: 'hostinger', domain: 'example.com' }
|
|
1008
|
-
* - Multi: { 'example.com': { provider: 'hostinger' }, 'example.dev': { provider: 'route53' } }
|
|
1032
|
+
* A value only the stage's own secrets can hold.
|
|
1009
1033
|
*
|
|
1010
|
-
* @
|
|
1011
|
-
* @param dnsConfig - DNS configuration (legacy or multi-domain)
|
|
1012
|
-
* @param dokployEndpoint - Dokploy server endpoint to resolve IP from
|
|
1013
|
-
* @param state - Optional state to save created records for later deletion
|
|
1034
|
+
* @throws {MissingSuppliedSecret} when the stage was never given it.
|
|
1014
1035
|
*/
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1036
|
+
function supplied(id, key, context) {
|
|
1037
|
+
const value = context.supplied?.[key];
|
|
1038
|
+
if (value === void 0) throw new MissingSuppliedSecret(id, key, context.stage);
|
|
1039
|
+
return value;
|
|
1040
|
+
}
|
|
1041
|
+
/** A construct needs a value the stage was never given. */
|
|
1042
|
+
var MissingSuppliedSecret = class extends Error {
|
|
1043
|
+
id;
|
|
1044
|
+
key;
|
|
1045
|
+
stage;
|
|
1046
|
+
constructor(id, key, stage) {
|
|
1047
|
+
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}`);
|
|
1048
|
+
this.id = id;
|
|
1049
|
+
this.key = key;
|
|
1050
|
+
this.stage = stage;
|
|
1051
|
+
this.name = "MissingSuppliedSecret";
|
|
1028
1052
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
};
|
|
1053
|
+
};
|
|
1054
|
+
var WrongKind = class extends Error {
|
|
1055
|
+
kind;
|
|
1056
|
+
constructor(kind) {
|
|
1057
|
+
super(`No Dokploy provisioner handles '${kind}'`);
|
|
1058
|
+
this.kind = kind;
|
|
1059
|
+
this.name = "WrongKind";
|
|
1037
1060
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
});
|
|
1069
|
-
}
|
|
1070
|
-
printDnsRecordsTable(domainRecords, rootDomain);
|
|
1071
|
-
if (providerConfig.provider === "manual" || failed > 0) printDnsRecordsSimple(domainRecords.filter((r) => !r.created && !r.existed), rootDomain);
|
|
1061
|
+
};
|
|
1062
|
+
/** A derived construct whose parent has not been provisioned. */
|
|
1063
|
+
var UnresolvedParent = class extends Error {
|
|
1064
|
+
id;
|
|
1065
|
+
parent;
|
|
1066
|
+
constructor(id, parent) {
|
|
1067
|
+
super(`'${id}' derives from '${parent}', which has not been provisioned. Provision in \`provisionOrder\`, which puts parents first.`);
|
|
1068
|
+
this.id = id;
|
|
1069
|
+
this.parent = parent;
|
|
1070
|
+
this.name = "UnresolvedParent";
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
/** A cache with nowhere to live. */
|
|
1074
|
+
var CacheNeedsAHome = class extends Error {
|
|
1075
|
+
id;
|
|
1076
|
+
backend;
|
|
1077
|
+
constructor(id, backend) {
|
|
1078
|
+
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}'.` : ""));
|
|
1079
|
+
this.id = id;
|
|
1080
|
+
this.backend = backend;
|
|
1081
|
+
this.name = "CacheNeedsAHome";
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
/** A `'db'` cache (a server target's default) with more than one database. */
|
|
1085
|
+
var CacheIsAmbiguous = class extends Error {
|
|
1086
|
+
databases;
|
|
1087
|
+
constructor(databases) {
|
|
1088
|
+
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().`);
|
|
1089
|
+
this.databases = databases;
|
|
1090
|
+
this.name = "CacheIsAmbiguous";
|
|
1072
1091
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
*
|
|
1082
|
-
* This function:
|
|
1083
|
-
* 1. Checks state for previously verified hostnames (skips if already verified with same IP)
|
|
1084
|
-
* 2. Attempts to resolve each hostname to an IP
|
|
1085
|
-
* 3. Compares resolved IP with expected server IP
|
|
1086
|
-
* 4. Updates state with verification results
|
|
1087
|
-
*
|
|
1088
|
-
* @param appHostnames - Map of app names to hostnames
|
|
1089
|
-
* @param serverIp - Expected IP address the hostnames should resolve to
|
|
1090
|
-
* @param state - Deploy state for caching verification results
|
|
1091
|
-
* @returns Array of verification results
|
|
1092
|
-
*/
|
|
1093
|
-
async function verifyDnsRecords(appHostnames, serverIp, state) {
|
|
1094
|
-
const results = [];
|
|
1095
|
-
logger$2.log("\n🔍 Verifying DNS records...");
|
|
1096
|
-
for (const [appName, hostname] of appHostnames) {
|
|
1097
|
-
if (require_state.isDnsVerified(state, hostname, serverIp)) {
|
|
1098
|
-
logger$2.log(` ✓ ${hostname} (previously verified)`);
|
|
1099
|
-
results.push({
|
|
1100
|
-
hostname,
|
|
1101
|
-
appName,
|
|
1102
|
-
verified: true,
|
|
1103
|
-
expectedIp: serverIp,
|
|
1104
|
-
skipped: true
|
|
1105
|
-
});
|
|
1106
|
-
continue;
|
|
1107
|
-
}
|
|
1108
|
-
try {
|
|
1109
|
-
const resolvedIp = await resolveHostnameToIp(hostname);
|
|
1110
|
-
if (resolvedIp === serverIp) {
|
|
1111
|
-
require_state.setDnsVerification(state, hostname, serverIp);
|
|
1112
|
-
logger$2.log(` ✓ ${hostname} → ${resolvedIp}`);
|
|
1113
|
-
results.push({
|
|
1114
|
-
hostname,
|
|
1115
|
-
appName,
|
|
1116
|
-
verified: true,
|
|
1117
|
-
resolvedIp,
|
|
1118
|
-
expectedIp: serverIp
|
|
1119
|
-
});
|
|
1120
|
-
} else {
|
|
1121
|
-
logger$2.log(` ⚠ ${hostname} resolves to ${resolvedIp}, expected ${serverIp}`);
|
|
1122
|
-
results.push({
|
|
1123
|
-
hostname,
|
|
1124
|
-
appName,
|
|
1125
|
-
verified: false,
|
|
1126
|
-
resolvedIp,
|
|
1127
|
-
expectedIp: serverIp
|
|
1128
|
-
});
|
|
1129
|
-
}
|
|
1130
|
-
} catch (error) {
|
|
1131
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1132
|
-
logger$2.log(` ⚠ ${hostname} DNS not propagated (${message})`);
|
|
1133
|
-
results.push({
|
|
1134
|
-
hostname,
|
|
1135
|
-
appName,
|
|
1136
|
-
verified: false,
|
|
1137
|
-
expectedIp: serverIp,
|
|
1138
|
-
error: message
|
|
1139
|
-
});
|
|
1140
|
-
}
|
|
1092
|
+
};
|
|
1093
|
+
/** A surface reached provisioning before its domain existed. */
|
|
1094
|
+
var SurfaceHasNoAddress = class extends Error {
|
|
1095
|
+
id;
|
|
1096
|
+
constructor(id) {
|
|
1097
|
+
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.`);
|
|
1098
|
+
this.id = id;
|
|
1099
|
+
this.name = "SurfaceHasNoAddress";
|
|
1141
1100
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1101
|
+
};
|
|
1102
|
+
/** A queue or topic on a backend this target has no primitive for. */
|
|
1103
|
+
var UnprovisionableCarrier = class extends Error {
|
|
1104
|
+
id;
|
|
1105
|
+
backend;
|
|
1106
|
+
constructor(id, backend) {
|
|
1107
|
+
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.`);
|
|
1108
|
+
this.id = id;
|
|
1109
|
+
this.backend = backend;
|
|
1110
|
+
this.name = "UnprovisionableCarrier";
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
/** A pg-boss carrier in an app that declares no database to host it. */
|
|
1114
|
+
var BrokerNeedsADatabase = class extends Error {
|
|
1115
|
+
id;
|
|
1116
|
+
constructor(id) {
|
|
1117
|
+
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.`);
|
|
1118
|
+
this.id = id;
|
|
1119
|
+
this.name = "BrokerNeedsADatabase";
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
/** A bucket on a backend this target has no primitive for. */
|
|
1123
|
+
var UnprovisionableBucket = class extends Error {
|
|
1124
|
+
id;
|
|
1125
|
+
backend;
|
|
1126
|
+
constructor(id, backend) {
|
|
1127
|
+
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.`);
|
|
1128
|
+
this.id = id;
|
|
1129
|
+
this.backend = backend;
|
|
1130
|
+
this.name = "UnprovisionableBucket";
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1151
1133
|
//#endregion
|
|
1152
|
-
//#region src/
|
|
1153
|
-
const logger$1 = require_discover.output;
|
|
1134
|
+
//#region src/target/dokploy/declared.ts
|
|
1154
1135
|
/**
|
|
1155
|
-
* The
|
|
1156
|
-
*
|
|
1136
|
+
* The declared half of a Dokploy deploy.
|
|
1137
|
+
*
|
|
1138
|
+
* `fromManifest.ts` decides what each construct resolves to; this runs it —
|
|
1139
|
+
* discovers the manifest, walks it parents-first, and applies the DDL the
|
|
1140
|
+
* provisioners deferred.
|
|
1141
|
+
*
|
|
1142
|
+
* It is deliberately separate from the engine in `index.ts`. That file deploys
|
|
1143
|
+
* *applications*: images, registries, domains, the things a project has whether
|
|
1144
|
+
* or not it declares anything. This is the part that exists because the app
|
|
1145
|
+
* declared it, and keeping the two apart is what lets a project adopt the model
|
|
1146
|
+
* without rewriting its deploy — and lets this one be tested without one.
|
|
1157
1147
|
*/
|
|
1158
|
-
const CREDENTIALS_SECRET_ID = "gkm_credentials";
|
|
1159
1148
|
/**
|
|
1160
|
-
*
|
|
1149
|
+
* Provision everything the workspace declares, and return what it resolved.
|
|
1161
1150
|
*/
|
|
1162
|
-
function
|
|
1163
|
-
|
|
1164
|
-
|
|
1151
|
+
async function provisionDeclared(options) {
|
|
1152
|
+
const { workspace } = options;
|
|
1153
|
+
const empty = {
|
|
1154
|
+
env: {},
|
|
1155
|
+
statements: [],
|
|
1156
|
+
provisioned: {},
|
|
1157
|
+
clusters: {}
|
|
1158
|
+
};
|
|
1159
|
+
const manifest = options.manifest ?? await require_discover.discover({
|
|
1160
|
+
patterns: require_state.constructGlobs(workspace),
|
|
1161
|
+
cwd: workspace.root
|
|
1162
|
+
});
|
|
1163
|
+
if (Object.keys(manifest).length === 0) return empty;
|
|
1164
|
+
const context = {
|
|
1165
|
+
manifest,
|
|
1166
|
+
provisioned: {},
|
|
1167
|
+
api: options.api,
|
|
1168
|
+
projectId: options.projectId,
|
|
1169
|
+
environmentId: options.environmentId,
|
|
1170
|
+
stage: options.stage,
|
|
1171
|
+
project: workspace.name,
|
|
1172
|
+
scope: options.scope ?? workspace.name,
|
|
1173
|
+
cache: require_state.cacheBackendFor(require_state.providerOf(workspace)),
|
|
1174
|
+
events: require_state.eventsBackendFor(require_state.providerOf(workspace)),
|
|
1175
|
+
storage: require_state.storageBackendFor(require_state.providerOf(workspace)),
|
|
1176
|
+
addresses: surfaceAddresses(manifest, options.appUrls),
|
|
1177
|
+
seed: options.seed,
|
|
1178
|
+
...options.supplied ? { supplied: options.supplied } : {},
|
|
1179
|
+
deferred: [],
|
|
1180
|
+
clusters: {}
|
|
1181
|
+
};
|
|
1182
|
+
const env = {};
|
|
1183
|
+
for (const id of carriersLast((0, _geekmidas_manifest.provisionOrder)(manifest), manifest)) {
|
|
1184
|
+
const declaration = manifest[id];
|
|
1185
|
+
if (!declaration) continue;
|
|
1186
|
+
const provisioner = provisionerFor(declaration.kind);
|
|
1187
|
+
if (!provisioner) continue;
|
|
1188
|
+
const result = await provisioner(declaration, context);
|
|
1189
|
+
context.provisioned[id] = result;
|
|
1190
|
+
Object.assign(env, result.provides);
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
env,
|
|
1194
|
+
statements: context.deferred.map((statement) => ({
|
|
1195
|
+
id: statement.id,
|
|
1196
|
+
describe: statement.describe,
|
|
1197
|
+
...statement.database ? { database: statement.database } : {},
|
|
1198
|
+
...statement.exists ? { exists: statement.exists } : {},
|
|
1199
|
+
create: statement.sql
|
|
1200
|
+
})),
|
|
1201
|
+
provisioned: context.provisioned,
|
|
1202
|
+
clusters: context.clusters
|
|
1203
|
+
};
|
|
1165
1204
|
}
|
|
1166
1205
|
/**
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
* One element per value, so nothing in a ref or a build arg is read by a
|
|
1170
|
-
* shell, and `--opt=value` rather than `--opt value`, so a value that starts
|
|
1171
|
-
* with `-` cannot be taken for one of docker's own flags.
|
|
1206
|
+
* Run the deferred DDL against a cluster that is now reachable.
|
|
1172
1207
|
*
|
|
1173
|
-
*
|
|
1208
|
+
* The same applier the local target uses: every statement asks whether it is
|
|
1209
|
+
* needed first, so a redeploy is free and a half-applied run recovers by being
|
|
1210
|
+
* run again.
|
|
1174
1211
|
*/
|
|
1175
|
-
function
|
|
1176
|
-
|
|
1177
|
-
return [
|
|
1178
|
-
"build",
|
|
1179
|
-
"--platform=linux/amd64",
|
|
1180
|
-
`--file=${dockerfilePath}`,
|
|
1181
|
-
`--tag=${imageRef}`,
|
|
1182
|
-
...buildArgs.map((arg) => `--build-arg=${arg}`),
|
|
1183
|
-
...credentialsFile ? [`--secret=id=${CREDENTIALS_SECRET_ID},src=${credentialsFile}`] : [],
|
|
1184
|
-
"."
|
|
1185
|
-
];
|
|
1212
|
+
async function applyDeclared(client, statements) {
|
|
1213
|
+
return (await require_state.applyPostgres(client, statements)).filter((entry) => entry.created).length;
|
|
1186
1214
|
}
|
|
1187
1215
|
/**
|
|
1188
|
-
*
|
|
1216
|
+
* The provisioning order, with queues and topics moved to the end.
|
|
1189
1217
|
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1218
|
+
* `provisionOrder` walks `of` edges, and a carrier has none: a topic is not
|
|
1219
|
+
* *derived from* a database, because under SNS it does not live in one. Where
|
|
1220
|
+
* it lives is a fact about the backend, which is config — so the dependency is
|
|
1221
|
+
* real under pg-boss and absent under SNS, and writing it into the declaration
|
|
1222
|
+
* would state it in the one place that cannot know.
|
|
1193
1223
|
*
|
|
1194
|
-
*
|
|
1224
|
+
* Here it can. This runs where the backend is known, and the rule needs no
|
|
1225
|
+
* branch on it: nothing depends on a carrier. A queue's worker and a topic's
|
|
1226
|
+
* subscribers are *bound* to it rather than dependent on it, which is why
|
|
1227
|
+
* neither holds a key of its own — so a carrier can always go last, and under
|
|
1228
|
+
* pg-boss that is exactly late enough for the database it reads to be there.
|
|
1195
1229
|
*/
|
|
1196
|
-
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
await (0, node_fs_promises.chmod)(path, 384);
|
|
1201
|
-
return {
|
|
1202
|
-
path,
|
|
1203
|
-
cleanup: () => (0, node_fs_promises.rm)(dir, {
|
|
1204
|
-
recursive: true,
|
|
1205
|
-
force: true
|
|
1206
|
-
})
|
|
1230
|
+
function carriersLast(ordered, manifest) {
|
|
1231
|
+
const carries = (id) => {
|
|
1232
|
+
const kind = manifest[id]?.kind;
|
|
1233
|
+
return kind === "queue" || kind === "topic";
|
|
1207
1234
|
};
|
|
1235
|
+
return [...ordered.filter((id) => !carries(id)), ...ordered.filter(carries)];
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Where each declared surface answers: its own app's address.
|
|
1239
|
+
*
|
|
1240
|
+
* Every surface is an app of its own, keyed by its id the way the derivation
|
|
1241
|
+
* keyed it — `Api` is `api`, `Webhooks` is `webhooks` — and answers on that
|
|
1242
|
+
* app's host, `{subdomain}.{domain}`. It used to be handed the first backend's
|
|
1243
|
+
* address, so a workspace with two APIs pointed both at the same one.
|
|
1244
|
+
*/
|
|
1245
|
+
function surfaceAddresses(manifest, appUrls) {
|
|
1246
|
+
const addresses = {};
|
|
1247
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
1248
|
+
if (declaration.kind !== "rest-api") continue;
|
|
1249
|
+
const url = appUrls[require_derive.appKey(id)];
|
|
1250
|
+
if (url) addresses[id] = url;
|
|
1251
|
+
}
|
|
1252
|
+
return addresses;
|
|
1208
1253
|
}
|
|
1254
|
+
//#endregion
|
|
1255
|
+
//#region src/target/dokploy/dns/DnsProvider.ts
|
|
1209
1256
|
/**
|
|
1210
|
-
*
|
|
1211
|
-
* @param imageRef - Full image reference (registry/name:tag)
|
|
1212
|
-
* @param buildArgs - Build arguments to pass to docker build
|
|
1213
|
-
* @param credentials - Encrypted credentials, passed as a build secret
|
|
1257
|
+
* Check if value is a DnsProvider implementation.
|
|
1214
1258
|
*/
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
logger$1.log(" Generating Dockerfile for the pre-built bundle...");
|
|
1218
|
-
await require_state.dockerCommand({
|
|
1219
|
-
slim: true,
|
|
1220
|
-
cwd
|
|
1221
|
-
});
|
|
1222
|
-
const dockerfilePath = (0, node_path.join)(cwd, ".gkm", "docker", "Dockerfile");
|
|
1223
|
-
const secret = credentials ? await writeCredentialsFile(credentials) : void 0;
|
|
1224
|
-
try {
|
|
1225
|
-
await require_local.run("docker", dockerBuildArgs({
|
|
1226
|
-
dockerfilePath,
|
|
1227
|
-
imageRef,
|
|
1228
|
-
buildArgs,
|
|
1229
|
-
credentialsFile: secret?.path
|
|
1230
|
-
}), {
|
|
1231
|
-
cwd,
|
|
1232
|
-
env: {
|
|
1233
|
-
...process.env,
|
|
1234
|
-
DOCKER_BUILDKIT: "1"
|
|
1235
|
-
},
|
|
1236
|
-
...signal ? { signal } : {},
|
|
1237
|
-
...stdio ? { stdio } : {}
|
|
1238
|
-
});
|
|
1239
|
-
logger$1.log(`✅ Image built: ${imageRef}`);
|
|
1240
|
-
} catch (error) {
|
|
1241
|
-
throw new require_state.DockerBuildFailed(imageRef, error);
|
|
1242
|
-
} finally {
|
|
1243
|
-
await secret?.cleanup();
|
|
1244
|
-
}
|
|
1259
|
+
function isDnsProvider(value) {
|
|
1260
|
+
return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.getRecords === "function" && typeof value.upsertRecords === "function" && typeof value.deleteRecords === "function";
|
|
1245
1261
|
}
|
|
1246
1262
|
/**
|
|
1247
|
-
*
|
|
1263
|
+
* Create a DNS provider based on configuration.
|
|
1264
|
+
*
|
|
1265
|
+
* - 'hostinger': HostingerProvider
|
|
1266
|
+
* - 'route53': Route53Provider
|
|
1267
|
+
* - 'manual': Returns null (user handles DNS)
|
|
1268
|
+
* - Custom: Use provided DnsProvider implementation
|
|
1248
1269
|
*/
|
|
1249
|
-
async function
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1270
|
+
async function createDnsProvider(options) {
|
|
1271
|
+
const { config } = options;
|
|
1272
|
+
if (config.provider === "manual") return null;
|
|
1273
|
+
if (isDnsProvider(config.provider)) return config.provider;
|
|
1274
|
+
const provider = config.provider;
|
|
1275
|
+
if (provider === "hostinger") {
|
|
1276
|
+
const { HostingerProvider } = await Promise.resolve().then(() => require("./HostingerProvider-BznqRC2S.cjs"));
|
|
1277
|
+
return new HostingerProvider();
|
|
1278
|
+
}
|
|
1279
|
+
if (provider === "route53") {
|
|
1280
|
+
const { Route53Provider } = await Promise.resolve().then(() => require("./Route53Provider-Bllus2NR.cjs"));
|
|
1281
|
+
const route53Config = config;
|
|
1282
|
+
return new Route53Provider({
|
|
1283
|
+
region: route53Config.region,
|
|
1284
|
+
profile: route53Config.profile,
|
|
1285
|
+
hostedZoneId: route53Config.hostedZoneId
|
|
1256
1286
|
});
|
|
1257
|
-
logger$1.log(`✅ Image pushed: ${imageRef}`);
|
|
1258
|
-
} catch (error) {
|
|
1259
|
-
throw new require_state.DockerPushFailed(imageRef, error);
|
|
1260
1287
|
}
|
|
1288
|
+
if (provider === "cloudflare") throw new Error("Cloudflare DNS provider not yet implemented");
|
|
1289
|
+
throw new Error(`Unknown DNS provider: ${JSON.stringify(config)}`);
|
|
1261
1290
|
}
|
|
1291
|
+
//#endregion
|
|
1292
|
+
//#region src/target/dokploy/dns/index.ts
|
|
1262
1293
|
/**
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
* A tag can be pushed over — by the next deploy, or by anything else that
|
|
1266
|
-
* shares the repository — and the digest is what says which image a stage
|
|
1267
|
-
* actually ran. `RepoDigests` has one entry per repository the image was
|
|
1268
|
-
* pushed to, so the one for this ref's repository is picked by name.
|
|
1294
|
+
* DNS orchestration for deployments
|
|
1269
1295
|
*
|
|
1270
|
-
*
|
|
1296
|
+
* Handles automatic DNS record creation for deployed applications.
|
|
1271
1297
|
*/
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
imageRef
|
|
1279
|
-
], {
|
|
1280
|
-
cwd,
|
|
1281
|
-
...signal ? { signal } : {}
|
|
1282
|
-
});
|
|
1283
|
-
let digests;
|
|
1284
|
-
try {
|
|
1285
|
-
digests = JSON.parse(output.trim() || "[]");
|
|
1286
|
-
} catch {
|
|
1287
|
-
return;
|
|
1288
|
-
}
|
|
1289
|
-
if (!Array.isArray(digests)) return void 0;
|
|
1290
|
-
return digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`))?.slice(repository.length + 1);
|
|
1298
|
+
const logger$2 = require_discover.output;
|
|
1299
|
+
/**
|
|
1300
|
+
* Check if DNS config is legacy format (single domain with `domain` property)
|
|
1301
|
+
*/
|
|
1302
|
+
function isLegacyDnsConfig(config) {
|
|
1303
|
+
return typeof config === "object" && config !== null && "provider" in config && "domain" in config;
|
|
1291
1304
|
}
|
|
1292
1305
|
/**
|
|
1293
|
-
*
|
|
1306
|
+
* Normalize DNS config to new multi-domain format
|
|
1294
1307
|
*/
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
const docker = {
|
|
1300
|
-
cwd: options.cwd ?? process.cwd(),
|
|
1301
|
-
...options.signal ? { signal: options.signal } : {},
|
|
1302
|
-
...options.stdio ? { stdio: options.stdio } : {}
|
|
1303
|
-
};
|
|
1304
|
-
await buildImage(imageRef, buildArgs, credentials, docker);
|
|
1305
|
-
let digest;
|
|
1306
|
-
if (!skipPush) {
|
|
1307
|
-
if (!config.registry) logger$1.warn("\n⚠️ No registry configured. Use --skip-push or configure docker.registry in gkm.config.ts");
|
|
1308
|
-
else {
|
|
1309
|
-
await pushImage(imageRef, docker);
|
|
1310
|
-
digest = await pushedDigest(imageRef, docker);
|
|
1311
|
-
}
|
|
1308
|
+
function normalizeDnsConfig(config) {
|
|
1309
|
+
if (isLegacyDnsConfig(config)) {
|
|
1310
|
+
const { domain, ...providerConfig } = config;
|
|
1311
|
+
return { [domain]: providerConfig };
|
|
1312
1312
|
}
|
|
1313
|
-
|
|
1314
|
-
logger$1.log(`\n📋 Deployment details:`);
|
|
1315
|
-
logger$1.log(` Image: ${imageRef}`);
|
|
1316
|
-
logger$1.log(` Stage: ${stage}`);
|
|
1317
|
-
if (masterKey) logger$1.log(`\n🔐 Run the container with GKM_MASTER_KEY set to the key with fingerprint ${require_sniffer.keyFingerprint(masterKey)}.`);
|
|
1318
|
-
return {
|
|
1319
|
-
imageRef,
|
|
1320
|
-
...digest ? { digest } : {},
|
|
1321
|
-
masterKey
|
|
1322
|
-
};
|
|
1313
|
+
return config;
|
|
1323
1314
|
}
|
|
1324
|
-
//#endregion
|
|
1325
|
-
//#region src/deploy/env-resolver.ts
|
|
1326
1315
|
/**
|
|
1327
|
-
*
|
|
1316
|
+
* Find the root domain for a hostname from available DNS configs
|
|
1328
1317
|
*
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1318
|
+
* @example
|
|
1319
|
+
* findRootDomain('api.geekmidas.com', { 'geekmidas.com': {...}, 'geekmidas.dev': {...} })
|
|
1320
|
+
* // Returns 'geekmidas.com'
|
|
1332
1321
|
*/
|
|
1322
|
+
function findRootDomain(hostname, dnsConfig) {
|
|
1323
|
+
const domains = Object.keys(dnsConfig).sort((a, b) => b.length - a.length);
|
|
1324
|
+
for (const domain of domains) if (hostname === domain || hostname.endsWith(`.${domain}`)) return domain;
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1333
1327
|
/**
|
|
1334
|
-
*
|
|
1328
|
+
* Group hostnames by their root domain
|
|
1335
1329
|
*/
|
|
1336
|
-
function
|
|
1337
|
-
|
|
1330
|
+
function groupHostnamesByDomain(appHostnames, dnsConfig) {
|
|
1331
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1332
|
+
for (const [appName, hostname] of appHostnames) {
|
|
1333
|
+
const rootDomain = findRootDomain(hostname, dnsConfig);
|
|
1334
|
+
if (!rootDomain) {
|
|
1335
|
+
logger$2.log(` ⚠ No DNS config found for hostname: ${hostname}`);
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
if (!grouped.has(rootDomain)) grouped.set(rootDomain, /* @__PURE__ */ new Map());
|
|
1339
|
+
grouped.get(rootDomain).set(appName, hostname);
|
|
1340
|
+
}
|
|
1341
|
+
return grouped;
|
|
1338
1342
|
}
|
|
1339
1343
|
/**
|
|
1340
|
-
*
|
|
1341
|
-
* If the secret already exists in state, returns it.
|
|
1342
|
-
* Otherwise generates a new one and stores it.
|
|
1344
|
+
* Resolve IP address from a hostname
|
|
1343
1345
|
*/
|
|
1344
|
-
function
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1346
|
+
async function resolveHostnameToIp(hostname) {
|
|
1347
|
+
try {
|
|
1348
|
+
return (await (0, node_dns_promises.lookup)(hostname, { family: 4 })).address;
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
throw new Error(`Failed to resolve IP for ${hostname}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1351
|
+
}
|
|
1350
1352
|
}
|
|
1351
1353
|
/**
|
|
1352
|
-
*
|
|
1354
|
+
* Extract subdomain from full hostname relative to root domain
|
|
1355
|
+
*
|
|
1356
|
+
* @example
|
|
1357
|
+
* extractSubdomain('api.joemoer.traflabs.io', 'traflabs.io') => 'api.joemoer'
|
|
1358
|
+
* extractSubdomain('joemoer.traflabs.io', 'traflabs.io') => 'joemoer'
|
|
1353
1359
|
*/
|
|
1354
|
-
function
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
case "NODE_ENV": return "production";
|
|
1358
|
-
case "STAGE": return context.stage;
|
|
1359
|
-
case "BETTER_AUTH_URL": return `https://${context.appHostname}`;
|
|
1360
|
-
case "BETTER_AUTH_SECRET": return getOrGenerateSecret(context.state, context.appName, "BETTER_AUTH_SECRET");
|
|
1361
|
-
case "BETTER_AUTH_TRUSTED_ORIGINS":
|
|
1362
|
-
if (context.frontendUrls.length > 0) return context.frontendUrls.join(",");
|
|
1363
|
-
break;
|
|
1364
|
-
case "GKM_MASTER_KEY": if (context.masterKey) return context.masterKey;
|
|
1365
|
-
}
|
|
1366
|
-
if (context.dependencyUrls && varName.endsWith("_URL")) {
|
|
1367
|
-
const depName = (require_workspace.stripPublicPrefix(varName) ?? varName).slice(0, -4).toLowerCase();
|
|
1368
|
-
if (context.dependencyUrls[depName]) return context.dependencyUrls[depName];
|
|
1369
|
-
}
|
|
1370
|
-
if (context.userSecrets) {
|
|
1371
|
-
if (context.userSecrets.custom[varName]) return context.userSecrets.custom[varName];
|
|
1372
|
-
if (varName in context.userSecrets.urls) return context.userSecrets.urls[varName];
|
|
1373
|
-
if (varName === "POSTGRES_PASSWORD" && context.userSecrets.services.postgres) return context.userSecrets.services.postgres.password;
|
|
1374
|
-
if (varName === "REDIS_PASSWORD" && context.userSecrets.services.redis) return context.userSecrets.services.redis.password;
|
|
1375
|
-
}
|
|
1360
|
+
function extractSubdomain(hostname, rootDomain) {
|
|
1361
|
+
if (!hostname.endsWith(rootDomain)) throw new Error(`Hostname ${hostname} is not under root domain ${rootDomain}`);
|
|
1362
|
+
return hostname.slice(0, -(rootDomain.length + 1)) || "@";
|
|
1376
1363
|
}
|
|
1377
1364
|
/**
|
|
1378
|
-
*
|
|
1365
|
+
* Generate required DNS records for a deployment
|
|
1379
1366
|
*/
|
|
1380
|
-
function
|
|
1381
|
-
const
|
|
1382
|
-
const
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1367
|
+
function generateRequiredRecords(appHostnames, rootDomain, serverIp) {
|
|
1368
|
+
const records = [];
|
|
1369
|
+
for (const [appName, hostname] of appHostnames) {
|
|
1370
|
+
const subdomain = extractSubdomain(hostname, rootDomain);
|
|
1371
|
+
records.push({
|
|
1372
|
+
hostname,
|
|
1373
|
+
subdomain,
|
|
1374
|
+
type: "A",
|
|
1375
|
+
value: serverIp,
|
|
1376
|
+
appName
|
|
1377
|
+
});
|
|
1387
1378
|
}
|
|
1388
|
-
return
|
|
1389
|
-
resolved,
|
|
1390
|
-
missing
|
|
1391
|
-
};
|
|
1379
|
+
return records;
|
|
1392
1380
|
}
|
|
1393
1381
|
/**
|
|
1394
|
-
*
|
|
1382
|
+
* Print DNS records table
|
|
1395
1383
|
*/
|
|
1396
|
-
function
|
|
1397
|
-
|
|
1384
|
+
function printDnsRecordsTable(records, rootDomain) {
|
|
1385
|
+
logger$2.log(`\n 📋 DNS Records for ${rootDomain}:`);
|
|
1386
|
+
logger$2.log(" ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐");
|
|
1387
|
+
logger$2.log(" │ Subdomain │ Type │ Value │ Status │");
|
|
1388
|
+
logger$2.log(" ├─────────────────────────────────────┼──────┼─────────────────┼────────┤");
|
|
1389
|
+
for (const record of records) {
|
|
1390
|
+
const subdomain = record.subdomain.padEnd(35);
|
|
1391
|
+
const type = record.type.padEnd(4);
|
|
1392
|
+
const value = record.value.padEnd(15);
|
|
1393
|
+
let status;
|
|
1394
|
+
if (record.error) status = "✗";
|
|
1395
|
+
else if (record.created) status = "✓ new";
|
|
1396
|
+
else if (record.existed) status = "✓";
|
|
1397
|
+
else status = "?";
|
|
1398
|
+
logger$2.log(` │ ${subdomain} │ ${type} │ ${value} │ ${status.padEnd(6)} │`);
|
|
1399
|
+
}
|
|
1400
|
+
logger$2.log(" └─────────────────────────────────────┴──────┴─────────────────┴────────┘");
|
|
1398
1401
|
}
|
|
1399
1402
|
/**
|
|
1400
|
-
*
|
|
1403
|
+
* Print DNS records in a simple format for manual setup
|
|
1401
1404
|
*/
|
|
1402
|
-
function
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
resolved
|
|
1408
|
-
};
|
|
1405
|
+
function printDnsRecordsSimple(records, rootDomain) {
|
|
1406
|
+
logger$2.log("\n 📋 Required DNS Records:");
|
|
1407
|
+
logger$2.log(` Add these A records to your DNS provider (${rootDomain}):\n`);
|
|
1408
|
+
for (const record of records) logger$2.log(` ${record.subdomain} → ${record.value} (A record)`);
|
|
1409
|
+
logger$2.log("");
|
|
1409
1410
|
}
|
|
1410
|
-
//#endregion
|
|
1411
|
-
//#region src/deploy/ownership.ts
|
|
1412
|
-
/**
|
|
1413
|
-
* Which Dokploy project a deploy is allowed to deploy into.
|
|
1414
|
-
*
|
|
1415
|
-
* Deploy used to take any project whose name matched the workspace's, in any
|
|
1416
|
-
* case — so a second workspace called `shop` (or `Shop`) on the same server
|
|
1417
|
-
* deployed into the first one's project, over its images and applications. A
|
|
1418
|
-
* project is now claimed by an ownership marker in its description, and a
|
|
1419
|
-
* project with the right name and no marker is somebody else's.
|
|
1420
|
-
*/
|
|
1421
1411
|
/**
|
|
1422
|
-
*
|
|
1423
|
-
* says this workspace created it.
|
|
1412
|
+
* Create DNS records for a single domain using its configured provider
|
|
1424
1413
|
*/
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1414
|
+
async function createDnsRecordsForDomain(records, rootDomain, providerConfig) {
|
|
1415
|
+
const ttl = "ttl" in providerConfig && providerConfig.ttl ? providerConfig.ttl : 300;
|
|
1416
|
+
let provider;
|
|
1417
|
+
try {
|
|
1418
|
+
provider = await createDnsProvider({ config: providerConfig });
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1421
|
+
logger$2.log(` ⚠ Failed to create DNS provider for ${rootDomain}: ${message}`);
|
|
1422
|
+
return records.map((r) => ({
|
|
1423
|
+
...r,
|
|
1424
|
+
error: message
|
|
1425
|
+
}));
|
|
1437
1426
|
}
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1427
|
+
if (!provider) return records.map((r) => ({
|
|
1428
|
+
...r,
|
|
1429
|
+
created: false,
|
|
1430
|
+
existed: false
|
|
1431
|
+
}));
|
|
1432
|
+
const results = [];
|
|
1433
|
+
const upsertRecords = records.map((r) => ({
|
|
1434
|
+
name: r.subdomain,
|
|
1435
|
+
type: r.type,
|
|
1436
|
+
ttl,
|
|
1437
|
+
value: r.value
|
|
1438
|
+
}));
|
|
1446
1439
|
try {
|
|
1447
|
-
|
|
1440
|
+
const upsertResults = await provider.upsertRecords(rootDomain, upsertRecords);
|
|
1441
|
+
for (const [i, record] of records.entries()) {
|
|
1442
|
+
const result = upsertResults[i];
|
|
1443
|
+
if (!result) {
|
|
1444
|
+
results.push({
|
|
1445
|
+
hostname: record.hostname,
|
|
1446
|
+
subdomain: record.subdomain,
|
|
1447
|
+
type: record.type,
|
|
1448
|
+
value: record.value,
|
|
1449
|
+
appName: record.appName,
|
|
1450
|
+
error: "No result returned from provider"
|
|
1451
|
+
});
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
if (result.unchanged) results.push({
|
|
1455
|
+
hostname: record.hostname,
|
|
1456
|
+
subdomain: record.subdomain,
|
|
1457
|
+
type: record.type,
|
|
1458
|
+
value: record.value,
|
|
1459
|
+
appName: record.appName,
|
|
1460
|
+
existed: true,
|
|
1461
|
+
created: false
|
|
1462
|
+
});
|
|
1463
|
+
else results.push({
|
|
1464
|
+
hostname: record.hostname,
|
|
1465
|
+
subdomain: record.subdomain,
|
|
1466
|
+
type: record.type,
|
|
1467
|
+
value: record.value,
|
|
1468
|
+
appName: record.appName,
|
|
1469
|
+
created: result.created,
|
|
1470
|
+
existed: !result.created
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1448
1473
|
} catch (error) {
|
|
1449
|
-
|
|
1450
|
-
|
|
1474
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1475
|
+
logger$2.log(` ⚠ Failed to create DNS records for ${rootDomain}: ${message}`);
|
|
1476
|
+
return records.map((r) => ({
|
|
1477
|
+
hostname: r.hostname,
|
|
1478
|
+
subdomain: r.subdomain,
|
|
1479
|
+
type: r.type,
|
|
1480
|
+
value: r.value,
|
|
1481
|
+
appName: r.appName,
|
|
1482
|
+
error: message
|
|
1483
|
+
}));
|
|
1451
1484
|
}
|
|
1485
|
+
return results;
|
|
1452
1486
|
}
|
|
1453
1487
|
/**
|
|
1454
|
-
*
|
|
1455
|
-
* asks the same question a deploy does:
|
|
1488
|
+
* Main DNS orchestration function for deployments
|
|
1456
1489
|
*
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1460
|
-
* 2. One with this name *and* this identity's marker.
|
|
1490
|
+
* Supports both legacy single-domain format and new multi-domain format:
|
|
1491
|
+
* - Legacy: { provider: 'hostinger', domain: 'example.com' }
|
|
1492
|
+
* - Multi: { 'example.com': { provider: 'hostinger' }, 'example.dev': { provider: 'route53' } }
|
|
1461
1493
|
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1494
|
+
* @param appHostnames - Map of app names to hostnames
|
|
1495
|
+
* @param dnsConfig - DNS configuration (legacy or multi-domain)
|
|
1496
|
+
* @param dokployEndpoint - Dokploy server endpoint to resolve IP from
|
|
1497
|
+
* @param state - Optional state to save created records for later deletion
|
|
1464
1498
|
*/
|
|
1465
|
-
async function
|
|
1466
|
-
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
via: "state",
|
|
1479
|
-
needsClaim: !claimed
|
|
1480
|
-
};
|
|
1481
|
-
}
|
|
1499
|
+
async function orchestrateDns(appHostnames, dnsConfig, dokployEndpoint, state) {
|
|
1500
|
+
if (!dnsConfig) return null;
|
|
1501
|
+
const normalizedConfig = normalizeDnsConfig(dnsConfig);
|
|
1502
|
+
logger$2.log("\n🌐 Setting up DNS records...");
|
|
1503
|
+
let serverIp;
|
|
1504
|
+
try {
|
|
1505
|
+
const endpointUrl = new URL(dokployEndpoint);
|
|
1506
|
+
serverIp = await resolveHostnameToIp(endpointUrl.hostname);
|
|
1507
|
+
logger$2.log(` Server IP: ${serverIp} (from ${endpointUrl.hostname})`);
|
|
1508
|
+
} catch (error) {
|
|
1509
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1510
|
+
logger$2.log(` ⚠ Failed to resolve server IP: ${message}`);
|
|
1511
|
+
return null;
|
|
1482
1512
|
}
|
|
1483
|
-
const
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
const details = await api.getProject(owned.projectId);
|
|
1513
|
+
const groupedHostnames = groupHostnamesByDomain(appHostnames, normalizedConfig);
|
|
1514
|
+
if (groupedHostnames.size === 0) {
|
|
1515
|
+
logger$2.log(" No DNS records needed (no hostnames match configured domains)");
|
|
1487
1516
|
return {
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
environments: details.environments ?? [],
|
|
1492
|
-
via: "marker",
|
|
1493
|
-
needsClaim: false
|
|
1517
|
+
records: [],
|
|
1518
|
+
success: true,
|
|
1519
|
+
serverIp
|
|
1494
1520
|
};
|
|
1495
1521
|
}
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
1522
|
+
const allRecords = [];
|
|
1523
|
+
let hasFailures = false;
|
|
1524
|
+
for (const [rootDomain, domainHostnames] of groupedHostnames) {
|
|
1525
|
+
const providerConfig = normalizedConfig[rootDomain];
|
|
1526
|
+
if (!providerConfig) {
|
|
1527
|
+
logger$2.log(` ⚠ No provider config for ${rootDomain}`);
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
const providerName = typeof providerConfig.provider === "string" ? providerConfig.provider : "custom";
|
|
1531
|
+
const requiredRecords = generateRequiredRecords(domainHostnames, rootDomain, serverIp);
|
|
1532
|
+
if (requiredRecords.length === 0) continue;
|
|
1533
|
+
logger$2.log(` Creating DNS records for ${rootDomain} (${providerName})...`);
|
|
1534
|
+
const domainRecords = await createDnsRecordsForDomain(requiredRecords, rootDomain, providerConfig);
|
|
1535
|
+
allRecords.push(...domainRecords);
|
|
1536
|
+
const created = domainRecords.filter((r) => r.created).length;
|
|
1537
|
+
const existed = domainRecords.filter((r) => r.existed).length;
|
|
1538
|
+
const failed = domainRecords.filter((r) => r.error).length;
|
|
1539
|
+
if (created > 0) logger$2.log(` ✓ Created ${created} DNS record(s) for ${rootDomain}`);
|
|
1540
|
+
if (existed > 0) logger$2.log(` ✓ ${existed} record(s) already exist for ${rootDomain}`);
|
|
1541
|
+
if (failed > 0) {
|
|
1542
|
+
logger$2.log(` ⚠ ${failed} record(s) failed for ${rootDomain}`);
|
|
1543
|
+
hasFailures = true;
|
|
1544
|
+
}
|
|
1545
|
+
if (state) {
|
|
1546
|
+
for (const record of domainRecords) if (record.created || record.existed) require_state.setDnsRecord(state, {
|
|
1547
|
+
domain: rootDomain,
|
|
1548
|
+
name: record.subdomain,
|
|
1549
|
+
type: record.type,
|
|
1550
|
+
value: record.value,
|
|
1551
|
+
ttl: "ttl" in providerConfig && providerConfig.ttl ? providerConfig.ttl : 300
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
printDnsRecordsTable(domainRecords, rootDomain);
|
|
1555
|
+
if (providerConfig.provider === "manual" || failed > 0) printDnsRecordsSimple(domainRecords.filter((r) => !r.created && !r.existed), rootDomain);
|
|
1556
|
+
}
|
|
1557
|
+
return {
|
|
1558
|
+
records: allRecords,
|
|
1559
|
+
success: !hasFailures,
|
|
1560
|
+
serverIp
|
|
1561
|
+
};
|
|
1499
1562
|
}
|
|
1500
1563
|
/**
|
|
1501
|
-
*
|
|
1502
|
-
*
|
|
1503
|
-
*
|
|
1564
|
+
* Verify DNS records resolve correctly after deployment.
|
|
1565
|
+
*
|
|
1566
|
+
* This function:
|
|
1567
|
+
* 1. Checks state for previously verified hostnames (skips if already verified with same IP)
|
|
1568
|
+
* 2. Attempts to resolve each hostname to an IP
|
|
1569
|
+
* 3. Compares resolved IP with expected server IP
|
|
1570
|
+
* 4. Updates state with verification results
|
|
1571
|
+
*
|
|
1572
|
+
* @param appHostnames - Map of app names to hostnames
|
|
1573
|
+
* @param serverIp - Expected IP address the hostnames should resolve to
|
|
1574
|
+
* @param state - Deploy state for caching verification results
|
|
1575
|
+
* @returns Array of verification results
|
|
1504
1576
|
*/
|
|
1505
|
-
async function
|
|
1506
|
-
const
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
if (
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1577
|
+
async function verifyDnsRecords(appHostnames, serverIp, state) {
|
|
1578
|
+
const results = [];
|
|
1579
|
+
logger$2.log("\n🔍 Verifying DNS records...");
|
|
1580
|
+
for (const [appName, hostname] of appHostnames) {
|
|
1581
|
+
if (require_state.isDnsVerified(state, hostname, serverIp)) {
|
|
1582
|
+
logger$2.log(` ✓ ${hostname} (previously verified)`);
|
|
1583
|
+
results.push({
|
|
1584
|
+
hostname,
|
|
1585
|
+
appName,
|
|
1586
|
+
verified: true,
|
|
1587
|
+
expectedIp: serverIp,
|
|
1588
|
+
skipped: true
|
|
1589
|
+
});
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
try {
|
|
1593
|
+
const resolvedIp = await resolveHostnameToIp(hostname);
|
|
1594
|
+
if (resolvedIp === serverIp) {
|
|
1595
|
+
require_state.setDnsVerification(state, hostname, serverIp);
|
|
1596
|
+
logger$2.log(` ✓ ${hostname} → ${resolvedIp}`);
|
|
1597
|
+
results.push({
|
|
1598
|
+
hostname,
|
|
1599
|
+
appName,
|
|
1600
|
+
verified: true,
|
|
1601
|
+
resolvedIp,
|
|
1602
|
+
expectedIp: serverIp
|
|
1603
|
+
});
|
|
1604
|
+
} else {
|
|
1605
|
+
logger$2.log(` ⚠ ${hostname} resolves to ${resolvedIp}, expected ${serverIp}`);
|
|
1606
|
+
results.push({
|
|
1607
|
+
hostname,
|
|
1608
|
+
appName,
|
|
1609
|
+
verified: false,
|
|
1610
|
+
resolvedIp,
|
|
1611
|
+
expectedIp: serverIp
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1616
|
+
logger$2.log(` ⚠ ${hostname} DNS not propagated (${message})`);
|
|
1617
|
+
results.push({
|
|
1618
|
+
hostname,
|
|
1619
|
+
appName,
|
|
1620
|
+
verified: false,
|
|
1621
|
+
expectedIp: serverIp,
|
|
1622
|
+
error: message
|
|
1513
1623
|
});
|
|
1514
|
-
log(` Claimed project ${found.name} for ${identity.key}`);
|
|
1515
1624
|
}
|
|
1516
|
-
return {
|
|
1517
|
-
projectId: found.projectId,
|
|
1518
|
-
name: found.name,
|
|
1519
|
-
environments: found.environments,
|
|
1520
|
-
via: found.via
|
|
1521
|
-
};
|
|
1522
1625
|
}
|
|
1523
|
-
|
|
1524
|
-
const
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1626
|
+
const verified = results.filter((r) => r.verified).length;
|
|
1627
|
+
const skipped = results.filter((r) => r.skipped).length;
|
|
1628
|
+
const pending = results.filter((r) => !r.verified).length;
|
|
1629
|
+
if (pending > 0) {
|
|
1630
|
+
logger$2.log(`\n ${verified} verified, ${pending} pending propagation`);
|
|
1631
|
+
logger$2.log(" DNS changes may take 5-30 minutes to propagate");
|
|
1632
|
+
} else if (skipped > 0) logger$2.log(` ${verified} verified (${skipped} from cache)`);
|
|
1633
|
+
return results;
|
|
1531
1634
|
}
|
|
1532
1635
|
//#endregion
|
|
1533
|
-
//#region src/
|
|
1636
|
+
//#region src/target/dokploy/env-resolver.ts
|
|
1534
1637
|
/**
|
|
1535
|
-
*
|
|
1638
|
+
* Environment Variable Resolution for Dokploy Deployments
|
|
1536
1639
|
*
|
|
1537
|
-
*
|
|
1538
|
-
*
|
|
1539
|
-
*
|
|
1540
|
-
* deployed from one machine shared whichever was stored last. The id is now
|
|
1541
|
-
* kept in the stage's state, and found only from what the workspace
|
|
1542
|
-
* configured.
|
|
1640
|
+
* Resolves sniffed environment variables to actual values during deployment.
|
|
1641
|
+
* Auto-supports common variables like DATABASE_URL, REDIS_URL, BETTER_AUTH_*,
|
|
1642
|
+
* and falls back to user-provided secrets.
|
|
1543
1643
|
*/
|
|
1544
|
-
/** There is nowhere to push images to, so Dokploy has nothing to pull. */
|
|
1545
|
-
var RegistryNotConfigured = class extends Error {
|
|
1546
|
-
stage;
|
|
1547
|
-
constructor(stage) {
|
|
1548
|
-
super(`Deploying '${stage}' needs a container registry: Dokploy pulls each app's image from one. Set deploy.registry in gkm.config.ts (e.g. 'ghcr.io/acme'), and deploy.dokploy.registryId if Dokploy already holds that registry's credentials.`);
|
|
1549
|
-
this.stage = stage;
|
|
1550
|
-
this.name = "RegistryNotConfigured";
|
|
1551
|
-
}
|
|
1552
|
-
};
|
|
1553
|
-
/** `deploy.dokploy.registryId` names a registry Dokploy does not have. */
|
|
1554
|
-
var RegistryNotFound = class extends Error {
|
|
1555
|
-
registryId;
|
|
1556
|
-
constructor(registryId) {
|
|
1557
|
-
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.`);
|
|
1558
|
-
this.registryId = registryId;
|
|
1559
|
-
this.name = "RegistryNotFound";
|
|
1560
|
-
}
|
|
1561
|
-
};
|
|
1562
|
-
/** More than one Dokploy registry serves the configured registry. */
|
|
1563
|
-
var RegistryAmbiguous = class extends Error {
|
|
1564
|
-
registry;
|
|
1565
|
-
registryIds;
|
|
1566
|
-
constructor(registry, registryIds) {
|
|
1567
|
-
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.`);
|
|
1568
|
-
this.registry = registry;
|
|
1569
|
-
this.registryIds = registryIds;
|
|
1570
|
-
this.name = "RegistryAmbiguous";
|
|
1571
|
-
}
|
|
1572
|
-
};
|
|
1573
|
-
/** `https://ghcr.io/acme/` → `{ host: 'ghcr.io', path: 'acme' }`. */
|
|
1574
|
-
function parts(url) {
|
|
1575
|
-
const [host = "", ...path] = url.replace(/^[a-z]+:\/\//i, "").replace(/\/+$/, "").toLowerCase().split("/");
|
|
1576
|
-
return {
|
|
1577
|
-
host,
|
|
1578
|
-
path: path.join("/")
|
|
1579
|
-
};
|
|
1580
|
-
}
|
|
1581
1644
|
/**
|
|
1582
|
-
*
|
|
1583
|
-
* Dokploy's own entry names one — the same path.
|
|
1645
|
+
* Generate a secure random secret (64 hex characters = 32 bytes)
|
|
1584
1646
|
*/
|
|
1585
|
-
function
|
|
1586
|
-
|
|
1587
|
-
const sameHost = registries.filter((r) => parts(r.registryUrl ?? "").host === wanted.host);
|
|
1588
|
-
if (sameHost.length <= 1) return sameHost;
|
|
1589
|
-
return sameHost.filter((r) => {
|
|
1590
|
-
return (parts(r.registryUrl ?? "").path || (r.imagePrefix ?? "").toLowerCase()) === wanted.path;
|
|
1591
|
-
});
|
|
1647
|
+
function generateSecret() {
|
|
1648
|
+
return (0, node_crypto.randomBytes)(32).toString("hex");
|
|
1592
1649
|
}
|
|
1593
|
-
/**
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1650
|
+
/**
|
|
1651
|
+
* Get or generate a secret for an app.
|
|
1652
|
+
* If the secret already exists in state, returns it.
|
|
1653
|
+
* Otherwise generates a new one and stores it.
|
|
1654
|
+
*/
|
|
1655
|
+
function getOrGenerateSecret(state, appName, secretName) {
|
|
1656
|
+
const existing = require_state.getGeneratedSecret(state, appName, secretName);
|
|
1657
|
+
if (existing) return existing;
|
|
1658
|
+
const generated = generateSecret();
|
|
1659
|
+
require_state.setGeneratedSecret(state, appName, secretName, generated);
|
|
1660
|
+
return generated;
|
|
1601
1661
|
}
|
|
1602
1662
|
/**
|
|
1603
|
-
*
|
|
1604
|
-
* stage's state, the one Dokploy has for the configured registry URL, and
|
|
1605
|
-
* otherwise a new one. Never a registry the workspace did not point at.
|
|
1663
|
+
* Resolve a single environment variable
|
|
1606
1664
|
*/
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
return
|
|
1665
|
+
function resolveEnvVar(varName, context) {
|
|
1666
|
+
switch (varName) {
|
|
1667
|
+
case "PORT": return String(context.app.port);
|
|
1668
|
+
case "NODE_ENV": return "production";
|
|
1669
|
+
case "STAGE": return context.stage;
|
|
1670
|
+
case "BETTER_AUTH_URL": return `https://${context.appHostname}`;
|
|
1671
|
+
case "BETTER_AUTH_SECRET": return getOrGenerateSecret(context.state, context.appName, "BETTER_AUTH_SECRET");
|
|
1672
|
+
case "BETTER_AUTH_TRUSTED_ORIGINS":
|
|
1673
|
+
if (context.frontendUrls.length > 0) return context.frontendUrls.join(",");
|
|
1674
|
+
break;
|
|
1675
|
+
case "GKM_MASTER_KEY": if (context.masterKey) return context.masterKey;
|
|
1614
1676
|
}
|
|
1615
|
-
if (
|
|
1616
|
-
const
|
|
1617
|
-
if (
|
|
1618
|
-
|
|
1677
|
+
if (context.dependencyUrls && varName.endsWith("_URL")) {
|
|
1678
|
+
const depName = (require_workspace.stripPublicPrefix(varName) ?? varName).slice(0, -4).toLowerCase();
|
|
1679
|
+
if (context.dependencyUrls[depName]) return context.dependencyUrls[depName];
|
|
1680
|
+
}
|
|
1681
|
+
if (context.userSecrets) {
|
|
1682
|
+
if (context.userSecrets.custom[varName]) return context.userSecrets.custom[varName];
|
|
1683
|
+
if (varName in context.userSecrets.urls) return context.userSecrets.urls[varName];
|
|
1684
|
+
if (varName === "POSTGRES_PASSWORD" && context.userSecrets.services.postgres) return context.userSecrets.services.postgres.password;
|
|
1685
|
+
if (varName === "REDIS_PASSWORD" && context.userSecrets.services.redis) return context.userSecrets.services.redis.password;
|
|
1619
1686
|
}
|
|
1620
|
-
const matches = serving(await api.listRegistries(), registry);
|
|
1621
|
-
if (matches.length > 1) throw new RegistryAmbiguous(registry, matches.map((r) => r.registryId));
|
|
1622
|
-
if (matches[0]) return matches[0];
|
|
1623
|
-
return options.create(registry);
|
|
1624
1687
|
}
|
|
1625
|
-
//#endregion
|
|
1626
|
-
//#region src/deploy/secrets.ts
|
|
1627
1688
|
/**
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1630
|
-
* @param stageSecrets - All secrets for the stage
|
|
1631
|
-
* @param sniffedEnv - The sniffed environment requirements for the app
|
|
1632
|
-
* @returns Filtered secrets with found/missing tracking
|
|
1689
|
+
* Resolve all environment variables for an app
|
|
1633
1690
|
*/
|
|
1634
|
-
function
|
|
1635
|
-
const
|
|
1636
|
-
const filtered = {};
|
|
1637
|
-
const found = [];
|
|
1691
|
+
function resolveEnvVars(requiredVars, context) {
|
|
1692
|
+
const resolved = {};
|
|
1638
1693
|
const missing = [];
|
|
1639
|
-
for (const
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1694
|
+
for (const varName of requiredVars) {
|
|
1695
|
+
const value = resolveEnvVar(varName, context);
|
|
1696
|
+
if (value !== void 0) resolved[varName] = value;
|
|
1697
|
+
else missing.push(varName);
|
|
1698
|
+
}
|
|
1643
1699
|
return {
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
found: found.sort(),
|
|
1647
|
-
missing: missing.sort()
|
|
1700
|
+
resolved,
|
|
1701
|
+
missing
|
|
1648
1702
|
};
|
|
1649
1703
|
}
|
|
1650
1704
|
/**
|
|
1651
|
-
*
|
|
1652
|
-
* Generates an ephemeral master key that should be injected into Dokploy.
|
|
1653
|
-
*
|
|
1654
|
-
* @param filteredSecrets - The filtered secrets for the app
|
|
1655
|
-
* @returns Encrypted payload with master key
|
|
1705
|
+
* Format missing variables error message
|
|
1656
1706
|
*/
|
|
1657
|
-
function
|
|
1658
|
-
|
|
1659
|
-
return {
|
|
1660
|
-
appName: filteredSecrets.appName,
|
|
1661
|
-
payload,
|
|
1662
|
-
masterKey: payload.masterKey,
|
|
1663
|
-
secretCount: Object.keys(filteredSecrets.secrets).length,
|
|
1664
|
-
missingSecrets: filteredSecrets.missing
|
|
1665
|
-
};
|
|
1707
|
+
function formatMissingVarsError(appName, missing, stage) {
|
|
1708
|
+
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.`;
|
|
1666
1709
|
}
|
|
1667
1710
|
/**
|
|
1668
|
-
*
|
|
1669
|
-
*
|
|
1670
|
-
* @param stageSecrets - All secrets for the stage
|
|
1671
|
-
* @param sniffedEnv - The sniffed environment requirements for the app
|
|
1672
|
-
* @returns Encrypted secrets with master key
|
|
1711
|
+
* Validate that all required environment variables can be resolved
|
|
1673
1712
|
*/
|
|
1674
|
-
function
|
|
1675
|
-
|
|
1713
|
+
function validateEnvVars(requiredVars, context) {
|
|
1714
|
+
const { resolved, missing } = resolveEnvVars(requiredVars, context);
|
|
1715
|
+
return {
|
|
1716
|
+
valid: missing.length === 0,
|
|
1717
|
+
missing,
|
|
1718
|
+
resolved
|
|
1719
|
+
};
|
|
1676
1720
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1721
|
+
//#endregion
|
|
1722
|
+
//#region src/target/dokploy/health.ts
|
|
1723
|
+
/** How long one request may take: a check that hangs is a failed check. */
|
|
1724
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
1725
|
+
/** An app never answered healthy enough times in a row. */
|
|
1726
|
+
var HealthCheckTimedOut = class extends Error {
|
|
1727
|
+
app;
|
|
1728
|
+
url;
|
|
1729
|
+
timeoutMs;
|
|
1730
|
+
lastStatus;
|
|
1731
|
+
constructor(app, url, timeoutMs, lastStatus) {
|
|
1732
|
+
super(`${app} did not become healthy within ${Math.round(timeoutMs / 1e3)}s: ${url} last ${lastStatus === void 0 ? "did not answer" : `answered ${lastStatus}`}. Check the app's logs in Dokploy. If it is only slow to start, raise deploy.dokploy.verify.healthTimeoutMs; if it serves its health route elsewhere, set deploy.dokploy.verify.healthCheckPath.`);
|
|
1733
|
+
this.app = app;
|
|
1734
|
+
this.url = url;
|
|
1735
|
+
this.timeoutMs = timeoutMs;
|
|
1736
|
+
this.lastStatus = lastStatus;
|
|
1737
|
+
this.name = "HealthCheckTimedOut";
|
|
1738
|
+
}
|
|
1739
|
+
};
|
|
1740
|
+
/** Wait `ms`, or reject with the signal's reason once it aborts. */
|
|
1741
|
+
function sleep(ms, signal) {
|
|
1742
|
+
return new Promise((resolve, reject) => {
|
|
1743
|
+
if (signal.aborted) return reject(signal.reason);
|
|
1744
|
+
const onAbort = () => {
|
|
1745
|
+
clearTimeout(timer);
|
|
1746
|
+
reject(signal.reason);
|
|
1747
|
+
};
|
|
1748
|
+
const timer = setTimeout(() => {
|
|
1749
|
+
signal.removeEventListener("abort", onAbort);
|
|
1750
|
+
resolve();
|
|
1751
|
+
}, ms);
|
|
1752
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
/** One request: its status, or undefined when it never answered. */
|
|
1756
|
+
async function probe(url, signal) {
|
|
1757
|
+
try {
|
|
1758
|
+
const response = await fetch(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) });
|
|
1759
|
+
response.body?.cancel().catch(() => {});
|
|
1760
|
+
return response.status;
|
|
1761
|
+
} catch {
|
|
1762
|
+
signal.throwIfAborted();
|
|
1763
|
+
return;
|
|
1689
1764
|
}
|
|
1690
|
-
return results;
|
|
1691
1765
|
}
|
|
1692
1766
|
/**
|
|
1693
|
-
*
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1767
|
+
* Check `url` until it answers 2xx `healthyAfter` times in a row, emitting
|
|
1768
|
+
* `health.checked` for every check.
|
|
1769
|
+
*
|
|
1770
|
+
* @throws {HealthCheckTimedOut} when that has not happened within `timeoutMs`.
|
|
1771
|
+
*/
|
|
1772
|
+
async function verifyHealth(check) {
|
|
1773
|
+
const { app, url, healthyAfter, intervalMs, timeoutMs, signal, emit } = check;
|
|
1774
|
+
const deadline = Date.now() + timeoutMs;
|
|
1775
|
+
let consecutive = 0;
|
|
1776
|
+
let attempt = 0;
|
|
1777
|
+
while (true) {
|
|
1778
|
+
attempt++;
|
|
1779
|
+
const status = await probe(url, signal);
|
|
1780
|
+
const healthy = status !== void 0 && status >= 200 && status < 300;
|
|
1781
|
+
emit({
|
|
1782
|
+
type: "health.checked",
|
|
1783
|
+
app,
|
|
1784
|
+
url,
|
|
1785
|
+
healthy,
|
|
1786
|
+
...status !== void 0 ? { status } : {},
|
|
1787
|
+
attempt
|
|
1788
|
+
});
|
|
1789
|
+
consecutive = healthy ? consecutive + 1 : 0;
|
|
1790
|
+
if (consecutive >= healthyAfter) return;
|
|
1791
|
+
if (Date.now() + intervalMs > deadline) throw new HealthCheckTimedOut(app, url, timeoutMs, status);
|
|
1792
|
+
await sleep(intervalMs, signal);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
/** The migrations of a deployed stage failed; the release stopped before any app. */
|
|
1796
|
+
var DeployMigrationsFailed = class extends Error {
|
|
1797
|
+
stage;
|
|
1798
|
+
reason;
|
|
1799
|
+
constructor(stage, reason, message) {
|
|
1800
|
+
super(`Migrating "${stage}" failed, so no app was released: ${message}. Migrations applied before the failing one stay applied; fix it and deploy again.`);
|
|
1801
|
+
this.stage = stage;
|
|
1802
|
+
this.reason = reason;
|
|
1803
|
+
this.name = "DeployMigrationsFailed";
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
/**
|
|
1807
|
+
* The URLs a stage's migrations connect with, keyed as the migrator reads
|
|
1808
|
+
* them, grouped by the database their cluster serves — for the targets that
|
|
1809
|
+
* have a migration to apply. Empty when none does, so a deploy publishes
|
|
1810
|
+
* nothing for a project that has not written one.
|
|
1811
|
+
*/
|
|
1812
|
+
async function migrationUrls(root, manifest, env) {
|
|
1813
|
+
const byDatabase = /* @__PURE__ */ new Map();
|
|
1814
|
+
for (const target of (0, _geekmidas_manifest.migrationTargets)(manifest)) {
|
|
1815
|
+
if ((await require_provider.migrationFiles((0, node_path.join)(root, target.migrations))).size === 0) continue;
|
|
1816
|
+
for (const role of ["ownerUrl", "url"]) {
|
|
1817
|
+
const key = (0, _geekmidas_manifest.provideKey)(target.id, role);
|
|
1818
|
+
const url = env[key];
|
|
1819
|
+
if (!url) continue;
|
|
1820
|
+
const database = new URL(url).pathname.slice(1);
|
|
1821
|
+
byDatabase.set(database, {
|
|
1822
|
+
...byDatabase.get(database),
|
|
1823
|
+
[key]: url
|
|
1710
1824
|
});
|
|
1711
1825
|
}
|
|
1712
1826
|
}
|
|
1713
|
-
return
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1827
|
+
return byDatabase;
|
|
1828
|
+
}
|
|
1829
|
+
const answer = require_schemas.discriminatedUnion("reason", [require_schemas.object({
|
|
1830
|
+
reason: require_schemas.literal("migrated"),
|
|
1831
|
+
runs: require_schemas.array(require_schemas.object({
|
|
1832
|
+
migrations: require_schemas.string(),
|
|
1833
|
+
applied: require_schemas.array(require_schemas.string())
|
|
1834
|
+
}))
|
|
1835
|
+
}), require_schemas.object({
|
|
1836
|
+
reason: require_schemas.literal("failed"),
|
|
1837
|
+
error: require_schemas.looseObject({
|
|
1838
|
+
name: require_schemas.string(),
|
|
1839
|
+
message: require_schemas.string()
|
|
1840
|
+
})
|
|
1841
|
+
})]);
|
|
1842
|
+
/** Apply every pending migration, in the sandbox, as each construct's owner. */
|
|
1843
|
+
async function runMigrations(options) {
|
|
1844
|
+
const sandbox = options.sandbox ?? require_sandbox.activeSandbox() ?? new require_local.LocalSandbox({ root: options.root });
|
|
1845
|
+
const { value } = await require_discover.runWorker(sandbox, "migrations", {
|
|
1846
|
+
name: "migrate-worker",
|
|
1847
|
+
args: [JSON.stringify({
|
|
1848
|
+
root: options.root,
|
|
1849
|
+
manifest: options.manifest,
|
|
1850
|
+
patterns: options.patterns
|
|
1851
|
+
})],
|
|
1852
|
+
cwd: options.root,
|
|
1853
|
+
timeoutMs: options.timeoutMs ?? 9e5,
|
|
1854
|
+
secrets: options.urls,
|
|
1855
|
+
signal: options.signal,
|
|
1856
|
+
schema: answer
|
|
1857
|
+
});
|
|
1858
|
+
if (value.reason === "failed") throw new DeployMigrationsFailed(options.stage, value.error.name, value.error.message);
|
|
1859
|
+
return value.runs;
|
|
1719
1860
|
}
|
|
1720
1861
|
//#endregion
|
|
1721
|
-
//#region src/
|
|
1862
|
+
//#region src/target/dokploy/postgres.ts
|
|
1722
1863
|
/**
|
|
1723
|
-
*
|
|
1724
|
-
*
|
|
1725
|
-
* Handles deployment of GKM workspaces to various providers (Docker, Dokploy).
|
|
1726
|
-
*
|
|
1727
|
-
* ## Per-App Database Credentials
|
|
1728
|
-
*
|
|
1729
|
-
* When deploying to Dokploy with Postgres, this module creates per-app database
|
|
1730
|
-
* users with isolated schemas. This follows the same pattern as local dev mode
|
|
1731
|
-
* (docker/postgres/init.sh).
|
|
1732
|
-
*
|
|
1733
|
-
* ### How It Works
|
|
1734
|
-
*
|
|
1735
|
-
* 1. **Provisioning**: Creates Postgres service with master credentials
|
|
1736
|
-
* 2. **User Creation**: For each backend app that needs DATABASE_URL:
|
|
1737
|
-
* - Generates a unique password (stored in deploy state)
|
|
1738
|
-
* - Creates a database user with that password
|
|
1739
|
-
* - Assigns schema permissions based on app name
|
|
1740
|
-
* 3. **Schema Assignment**:
|
|
1741
|
-
* - `api` app: Uses `public` schema (shared tables)
|
|
1742
|
-
* - Other apps (e.g., `auth`): Get their own schema with `search_path` set
|
|
1743
|
-
* 4. **Environment Injection**: Each app receives its own DATABASE_URL
|
|
1864
|
+
* Reaching a Dokploy Postgres from where the deploy runs.
|
|
1744
1865
|
*
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
*
|
|
1751
|
-
*
|
|
1752
|
-
* ### Example Flow
|
|
1753
|
-
*
|
|
1754
|
-
* ```
|
|
1755
|
-
* gkm deploy --stage production
|
|
1756
|
-
* ├─ Create Postgres (user: postgres, db: myproject)
|
|
1757
|
-
* ├─ Enable external port temporarily
|
|
1758
|
-
* ├─ Create user "api" → public schema
|
|
1759
|
-
* ├─ Create user "auth" → auth schema (search_path=auth)
|
|
1760
|
-
* ├─ Disable external port
|
|
1761
|
-
* ├─ Deploy "api" with DATABASE_URL=postgresql://api:xxx@postgres:5432/myproject
|
|
1762
|
-
* └─ Deploy "auth" with DATABASE_URL=postgresql://auth:yyy@postgres:5432/myproject
|
|
1763
|
-
* ```
|
|
1764
|
-
*
|
|
1765
|
-
* @module deploy
|
|
1866
|
+
* A Dokploy Postgres answers on the server's Docker network and nowhere else:
|
|
1867
|
+
* its address is its service name, which only another container there
|
|
1868
|
+
* resolves. Two steps of a deploy need it from outside — the role DDL the
|
|
1869
|
+
* provisioners defer, and the migrations `release` applies — so the cluster
|
|
1870
|
+
* is published on an external port for exactly as long as they take, and
|
|
1871
|
+
* closed again whatever happens.
|
|
1766
1872
|
*/
|
|
1767
|
-
const logger = require_discover.output;
|
|
1873
|
+
const logger$1 = require_discover.output;
|
|
1874
|
+
/** A Postgres published for the role DDL never accepted a connection. */
|
|
1875
|
+
var PostgresNotReady = class extends Error {
|
|
1876
|
+
host;
|
|
1877
|
+
port;
|
|
1878
|
+
attempts;
|
|
1879
|
+
constructor(host, port, attempts) {
|
|
1880
|
+
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.`);
|
|
1881
|
+
this.host = host;
|
|
1882
|
+
this.port = port;
|
|
1883
|
+
this.attempts = attempts;
|
|
1884
|
+
this.name = "PostgresNotReady";
|
|
1885
|
+
}
|
|
1886
|
+
};
|
|
1887
|
+
/** No port could be published for a cluster, so its DDL cannot reach it. */
|
|
1888
|
+
var PostgresPortUnavailable = class extends Error {
|
|
1889
|
+
appName;
|
|
1890
|
+
taken;
|
|
1891
|
+
constructor(appName, taken) {
|
|
1892
|
+
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.`);
|
|
1893
|
+
this.appName = appName;
|
|
1894
|
+
this.taken = taken;
|
|
1895
|
+
this.name = "PostgresPortUnavailable";
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1768
1898
|
/**
|
|
1769
1899
|
* Wait for Postgres to be ready to accept connections.
|
|
1770
1900
|
*
|
|
@@ -1772,14 +1902,7 @@ const logger = require_discover.output;
|
|
|
1772
1902
|
* Used after enabling the external port to ensure the database is accessible
|
|
1773
1903
|
* before creating users.
|
|
1774
1904
|
*
|
|
1775
|
-
* @
|
|
1776
|
-
* @param port - The external port (typically 5432)
|
|
1777
|
-
* @param user - Master database user (postgres)
|
|
1778
|
-
* @param password - Master database password
|
|
1779
|
-
* @param database - Database name to connect to
|
|
1780
|
-
* @param maxRetries - Maximum number of connection attempts (default: 30)
|
|
1781
|
-
* @param retryIntervalMs - Milliseconds between retries (default: 2000)
|
|
1782
|
-
* @throws Error if Postgres is not ready after maxRetries
|
|
1905
|
+
* @throws {PostgresNotReady} if Postgres is not ready after maxRetries
|
|
1783
1906
|
*/
|
|
1784
1907
|
async function waitForPostgres(host, port, user, password, database, maxRetries = 30, retryIntervalMs = 2e3) {
|
|
1785
1908
|
for (let i = 0; i < maxRetries; i++) try {
|
|
@@ -1797,49 +1920,12 @@ async function waitForPostgres(host, port, user, password, database, maxRetries
|
|
|
1797
1920
|
return;
|
|
1798
1921
|
} catch {
|
|
1799
1922
|
if (i < maxRetries - 1) {
|
|
1800
|
-
logger.log(` Waiting for Postgres... (${i + 1}/${maxRetries})`);
|
|
1923
|
+
logger$1.log(` Waiting for Postgres... (${i + 1}/${maxRetries})`);
|
|
1801
1924
|
await new Promise((r) => setTimeout(r, retryIntervalMs));
|
|
1802
1925
|
}
|
|
1803
1926
|
}
|
|
1804
1927
|
throw new PostgresNotReady(host, port, maxRetries);
|
|
1805
1928
|
}
|
|
1806
|
-
/** A Postgres published for the role DDL never accepted a connection. */
|
|
1807
|
-
var PostgresNotReady = class extends Error {
|
|
1808
|
-
host;
|
|
1809
|
-
port;
|
|
1810
|
-
attempts;
|
|
1811
|
-
constructor(host, port, attempts) {
|
|
1812
|
-
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.`);
|
|
1813
|
-
this.host = host;
|
|
1814
|
-
this.port = port;
|
|
1815
|
-
this.attempts = attempts;
|
|
1816
|
-
this.name = "PostgresNotReady";
|
|
1817
|
-
}
|
|
1818
|
-
};
|
|
1819
|
-
/** No port could be published for a cluster, so its DDL cannot reach it. */
|
|
1820
|
-
var PostgresPortUnavailable = class extends Error {
|
|
1821
|
-
appName;
|
|
1822
|
-
taken;
|
|
1823
|
-
constructor(appName, taken) {
|
|
1824
|
-
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.`);
|
|
1825
|
-
this.appName = appName;
|
|
1826
|
-
this.taken = taken;
|
|
1827
|
-
this.name = "PostgresPortUnavailable";
|
|
1828
|
-
}
|
|
1829
|
-
};
|
|
1830
|
-
/**
|
|
1831
|
-
* Run the manifest's DDL against a Dokploy Postgres.
|
|
1832
|
-
*
|
|
1833
|
-
* The cluster is only reachable from outside while an external port is
|
|
1834
|
-
* published, so this opens one, applies, and leaves it as it found it. The
|
|
1835
|
-
* alternative — running DDL from inside the network — needs a container to run
|
|
1836
|
-
* it in, which is what `DatabaseBootstrap` is on AWS and what a Dokploy target
|
|
1837
|
-
* has no equivalent for yet.
|
|
1838
|
-
*
|
|
1839
|
-
* The applier is the local target's, so every statement asks whether it is
|
|
1840
|
-
* needed first: a redeploy is free, and a half-applied run recovers by being
|
|
1841
|
-
* run again.
|
|
1842
|
-
*/
|
|
1843
1929
|
/**
|
|
1844
1930
|
* A high port for one service, the same one every time.
|
|
1845
1931
|
*
|
|
@@ -1852,32 +1938,80 @@ function derivedPort(appName) {
|
|
|
1852
1938
|
const digest = (0, node_crypto.createHash)("sha256").update(appName).digest();
|
|
1853
1939
|
return 49152 + (digest[0] << 8 | digest[1]) % 16e3;
|
|
1854
1940
|
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1941
|
+
/** The hostname of the Dokploy server, from its API endpoint. */
|
|
1942
|
+
function serverHostname(endpoint) {
|
|
1943
|
+
return new URL(endpoint).hostname;
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Publish `cluster` on an external port of the server at `host`, and wait
|
|
1947
|
+
* until it accepts a connection there.
|
|
1948
|
+
*/
|
|
1949
|
+
async function publishPostgres(api, cluster, host) {
|
|
1950
|
+
let externalPort = await api.getPostgres(cluster.postgresId).then((current) => current.externalPort).catch(() => null) ?? void 0;
|
|
1857
1951
|
const opened = externalPort === void 0;
|
|
1858
1952
|
if (externalPort === void 0) {
|
|
1859
1953
|
const taken = await api.publishedPorts().catch(() => /* @__PURE__ */ new Set());
|
|
1860
|
-
const candidates = [5432, derivedPort(
|
|
1954
|
+
const candidates = [5432, derivedPort(cluster.appName)].filter((port) => !taken.has(port));
|
|
1861
1955
|
for (const candidate of candidates) try {
|
|
1862
|
-
await api.savePostgresExternalPort(
|
|
1956
|
+
await api.savePostgresExternalPort(cluster.postgresId, candidate);
|
|
1863
1957
|
externalPort = candidate;
|
|
1864
1958
|
break;
|
|
1865
1959
|
} catch (error) {
|
|
1866
1960
|
if (!(error instanceof Error ? error.message : String(error)).includes("already in use")) throw error;
|
|
1867
|
-
logger.log(` Port ${candidate} is taken; trying another...`);
|
|
1961
|
+
logger$1.log(` Port ${candidate} is taken; trying another...`);
|
|
1868
1962
|
}
|
|
1869
|
-
if (externalPort === void 0) throw new PostgresPortUnavailable(
|
|
1870
|
-
logger.log(` Publishing ${
|
|
1963
|
+
if (externalPort === void 0) throw new PostgresPortUnavailable(cluster.appName, [...taken]);
|
|
1964
|
+
logger$1.log(` Publishing ${cluster.appName} on ${externalPort}...`);
|
|
1965
|
+
}
|
|
1966
|
+
const port = externalPort;
|
|
1967
|
+
let closed = false;
|
|
1968
|
+
const close = async () => {
|
|
1969
|
+
if (!opened || closed) return;
|
|
1970
|
+
closed = true;
|
|
1971
|
+
await api.savePostgresExternalPort(cluster.postgresId, null).then(() => api.deployPostgres(cluster.postgresId)).catch(() => {
|
|
1972
|
+
logger$1.log(` ⚠ Could not close external port ${port} on ${cluster.appName} — close it in Dokploy.`);
|
|
1973
|
+
});
|
|
1974
|
+
};
|
|
1975
|
+
try {
|
|
1976
|
+
await api.deployPostgres(cluster.postgresId);
|
|
1977
|
+
await waitForPostgres(host, port, cluster.databaseUser, cluster.databasePassword, cluster.databaseName);
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
await close();
|
|
1980
|
+
throw error;
|
|
1871
1981
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1982
|
+
return {
|
|
1983
|
+
cluster,
|
|
1984
|
+
host,
|
|
1985
|
+
port,
|
|
1986
|
+
close
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* A URL the cluster's containers use — its service name, its internal port —
|
|
1991
|
+
* pointed at where `published` answers from here instead.
|
|
1992
|
+
*/
|
|
1993
|
+
function publishedUrl(url, published) {
|
|
1994
|
+
const parsed = new URL(url);
|
|
1995
|
+
parsed.hostname = published.host;
|
|
1996
|
+
parsed.port = String(published.port);
|
|
1997
|
+
return parsed.toString();
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Run the manifest's DDL against a published Dokploy Postgres.
|
|
2001
|
+
*
|
|
2002
|
+
* The applier is the local target's, so every statement asks whether it is
|
|
2003
|
+
* needed first: a redeploy is free, and a half-applied run recovers by being
|
|
2004
|
+
* run again.
|
|
2005
|
+
*/
|
|
2006
|
+
async function applyDeclaredStatements(published, statements) {
|
|
2007
|
+
const { cluster, host, port } = published;
|
|
1874
2008
|
const client = { async query(database, sql, values) {
|
|
1875
2009
|
const connection = new pg.Client({
|
|
1876
|
-
host
|
|
1877
|
-
port
|
|
1878
|
-
user:
|
|
1879
|
-
password:
|
|
1880
|
-
database: database ??
|
|
2010
|
+
host,
|
|
2011
|
+
port,
|
|
2012
|
+
user: cluster.databaseUser,
|
|
2013
|
+
password: cluster.databasePassword,
|
|
2014
|
+
database: database ?? cluster.databaseName,
|
|
1881
2015
|
connectionTimeoutMillis: 15e3
|
|
1882
2016
|
});
|
|
1883
2017
|
connection.on("error", () => {});
|
|
@@ -1888,35 +2022,22 @@ async function applyDeclaredStatements(api, postgres, serverHostname, statements
|
|
|
1888
2022
|
await connection.end();
|
|
1889
2023
|
}
|
|
1890
2024
|
} };
|
|
1891
|
-
const unpublish = async () => {
|
|
1892
|
-
if (!opened) return;
|
|
1893
|
-
await api.savePostgresExternalPort(postgres.postgresId, null).then(() => api.deployPostgres(postgres.postgresId)).catch(() => {
|
|
1894
|
-
logger.log(` ⚠ Could not close external port ${externalPort} on ${postgres.appName} — close it in Dokploy.`);
|
|
1895
|
-
});
|
|
1896
|
-
};
|
|
1897
2025
|
let lastError;
|
|
1898
|
-
try {
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
await waitForPostgres(serverHostname, externalPort, postgres.databaseUser, postgres.databasePassword, postgres.databaseName).catch(() => {});
|
|
1908
|
-
}
|
|
1909
|
-
throw lastError;
|
|
1910
|
-
} finally {
|
|
1911
|
-
await unpublish();
|
|
2026
|
+
for (let attempt = 1; attempt <= 3; attempt++) try {
|
|
2027
|
+
return await applyDeclared(client, statements);
|
|
2028
|
+
} catch (error) {
|
|
2029
|
+
lastError = error;
|
|
2030
|
+
if (attempt === 3) break;
|
|
2031
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2032
|
+
logger$1.log(` ⏳ Cluster still settling (${message}); retrying (${attempt}/2)...`);
|
|
2033
|
+
await new Promise((resolve) => setTimeout(resolve, 1e4));
|
|
2034
|
+
await waitForPostgres(host, port, cluster.databaseUser, cluster.databasePassword, cluster.databaseName).catch(() => {});
|
|
1912
2035
|
}
|
|
2036
|
+
throw lastError;
|
|
1913
2037
|
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
function getServerHostname(endpoint) {
|
|
1918
|
-
return new URL(endpoint).hostname;
|
|
1919
|
-
}
|
|
2038
|
+
//#endregion
|
|
2039
|
+
//#region src/target/dokploy/engine.ts
|
|
2040
|
+
const logger = require_discover.output;
|
|
1920
2041
|
/**
|
|
1921
2042
|
* The variables an app cannot start without.
|
|
1922
2043
|
*
|
|
@@ -1944,6 +2065,19 @@ var BackendDeployFailed = class extends Error {
|
|
|
1944
2065
|
this.name = "BackendDeployFailed";
|
|
1945
2066
|
}
|
|
1946
2067
|
};
|
|
2068
|
+
/**
|
|
2069
|
+
* Sites failed to release or to answer. Every site is attempted before this
|
|
2070
|
+
* is thrown, so one broken site does not keep the others back — but the run
|
|
2071
|
+
* fails, and what failed is rolled back.
|
|
2072
|
+
*/
|
|
2073
|
+
var FrontendDeployFailed = class extends Error {
|
|
2074
|
+
apps;
|
|
2075
|
+
constructor(apps, cause) {
|
|
2076
|
+
super(`Frontend deployment failed for ${apps.join(", ")}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
2077
|
+
this.apps = apps;
|
|
2078
|
+
this.name = "FrontendDeployFailed";
|
|
2079
|
+
}
|
|
2080
|
+
};
|
|
1947
2081
|
/** An app's environment needs values the stage does not have. */
|
|
1948
2082
|
var MissingEnvVars = class extends Error {
|
|
1949
2083
|
app;
|
|
@@ -1972,6 +2106,16 @@ const CHILD_STDIO = {
|
|
|
1972
2106
|
],
|
|
1973
2107
|
ignore: "ignore"
|
|
1974
2108
|
};
|
|
2109
|
+
/** The defaults of `deploy.dokploy.verify`. */
|
|
2110
|
+
function verifySettings(config) {
|
|
2111
|
+
return {
|
|
2112
|
+
deploymentTimeoutMs: config?.deploymentTimeoutMs ?? 6e5,
|
|
2113
|
+
healthCheckPath: config?.healthCheckPath ?? "/health",
|
|
2114
|
+
healthyAfter: config?.healthyAfter ?? 3,
|
|
2115
|
+
intervalMs: config?.intervalMs ?? 2e3,
|
|
2116
|
+
healthTimeoutMs: config?.healthTimeoutMs ?? 3e5
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
1975
2119
|
/**
|
|
1976
2120
|
* Deploy every app in a workspace to Dokploy: the engine under the built-in
|
|
1977
2121
|
* `dokploy` target.
|
|
@@ -2022,7 +2166,14 @@ async function validateDokploy(phase) {
|
|
|
2022
2166
|
};
|
|
2023
2167
|
return {
|
|
2024
2168
|
...run,
|
|
2025
|
-
preflight: await preflight(run)
|
|
2169
|
+
preflight: await preflight(run),
|
|
2170
|
+
verify: verifySettings(workspace.deploy.dokploy?.verify),
|
|
2171
|
+
atomic: phase.atomic,
|
|
2172
|
+
releasing: {
|
|
2173
|
+
switched: /* @__PURE__ */ new Map(),
|
|
2174
|
+
failed: /* @__PURE__ */ new Set()
|
|
2175
|
+
},
|
|
2176
|
+
published: /* @__PURE__ */ new Map()
|
|
2026
2177
|
};
|
|
2027
2178
|
}
|
|
2028
2179
|
/**
|
|
@@ -2033,7 +2184,7 @@ async function preflight({ workspace, manifest, stage, secrets: secretsStore, ct
|
|
|
2033
2184
|
logger.log("\n🔐 Loading secrets and analyzing environment requirements...");
|
|
2034
2185
|
const stored = await secretsStore.read();
|
|
2035
2186
|
if (!stored) logger.log(` ⚠️ No secrets found for stage "${stage}"; starting them`);
|
|
2036
|
-
const { secrets: stageSecrets, generated } = require_state.withGeneratedSecrets(stored ??
|
|
2187
|
+
const { secrets: stageSecrets, generated } = require_state.withGeneratedSecrets(stored ?? require_state.initStageSecrets(stage), manifest);
|
|
2037
2188
|
if (generated.length > 0) {
|
|
2038
2189
|
if (ctx.dryRun) logger.log(` 🔑 Would generate for "${stage}" (${secretsStore.store}): ${generated.join(", ")}`);
|
|
2039
2190
|
else {
|
|
@@ -2060,6 +2211,19 @@ async function preflight({ workspace, manifest, stage, secrets: secretsStore, ct
|
|
|
2060
2211
|
* before it is created, so a run stopped part way is finished by the next.
|
|
2061
2212
|
*/
|
|
2062
2213
|
async function provisionDokploy(run) {
|
|
2214
|
+
try {
|
|
2215
|
+
await provision(run);
|
|
2216
|
+
} catch (error) {
|
|
2217
|
+
await closePublished(run);
|
|
2218
|
+
throw error;
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
/** Unpublish every cluster the run still has published. */
|
|
2222
|
+
async function closePublished(run) {
|
|
2223
|
+
for (const published of run.published.values()) await published.close();
|
|
2224
|
+
run.published.clear();
|
|
2225
|
+
}
|
|
2226
|
+
async function provision(run) {
|
|
2063
2227
|
const { workspace, manifest, stage, appsToDeployNames, identity, store, ctx } = run;
|
|
2064
2228
|
const { stageSecrets } = run.preflight;
|
|
2065
2229
|
const changes = [];
|
|
@@ -2085,7 +2249,7 @@ async function provisionDokploy(run) {
|
|
|
2085
2249
|
key: "project",
|
|
2086
2250
|
type: "project",
|
|
2087
2251
|
data: {
|
|
2088
|
-
name:
|
|
2252
|
+
name: require_state.projectName(identity),
|
|
2089
2253
|
identity: identity.key
|
|
2090
2254
|
}
|
|
2091
2255
|
};
|
|
@@ -2163,6 +2327,7 @@ async function provisionDokploy(run) {
|
|
|
2163
2327
|
if (skippedMobileApps.length > 0) logger.log(`\n📱 Skipping ${skippedMobileApps.length} mobile app(s) — deploy via framework toolchain: ${skippedMobileApps.join(", ")}`);
|
|
2164
2328
|
let declaredEnv = {};
|
|
2165
2329
|
let declaredClusters = {};
|
|
2330
|
+
let migrations = /* @__PURE__ */ new Map();
|
|
2166
2331
|
{
|
|
2167
2332
|
logger.log("\n📦 Provisioning declared constructs...");
|
|
2168
2333
|
const appUrls = {};
|
|
@@ -2185,20 +2350,25 @@ async function provisionDokploy(run) {
|
|
|
2185
2350
|
declaredEnv = declared.env;
|
|
2186
2351
|
declaredClusters = declared.clusters;
|
|
2187
2352
|
if (Object.keys(declaredEnv).length > 0) logger.log(` 🔌 Resolved ${Object.keys(declaredEnv).length} declared URL(s)`);
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2353
|
+
migrations = await migrationUrls(workspace.root, manifest, declaredEnv);
|
|
2354
|
+
const host = serverHostname(endpoint);
|
|
2355
|
+
for (const [databaseName, cluster] of Object.entries(declared.clusters)) {
|
|
2356
|
+
const statements = declared.statements.filter((statement) => statement.database === databaseName);
|
|
2357
|
+
if (statements.length === 0) continue;
|
|
2358
|
+
const published = await publishPostgres(api, cluster, host);
|
|
2359
|
+
run.published.set(databaseName, published);
|
|
2360
|
+
const created = await applyDeclaredStatements(published, statements);
|
|
2361
|
+
if (!migrations.has(databaseName)) {
|
|
2362
|
+
await published.close();
|
|
2363
|
+
run.published.delete(databaseName);
|
|
2195
2364
|
}
|
|
2365
|
+
logger.log(` 🗄️ ${databaseName}: applied ${statements.length} statement(s), ${created} new`);
|
|
2196
2366
|
}
|
|
2197
2367
|
}
|
|
2198
2368
|
const backupCluster = Object.values(declaredClusters)[0];
|
|
2199
2369
|
if (workspace.deploy?.backups && backupCluster) {
|
|
2200
2370
|
logger.log("\n💾 Provisioning backup destination...");
|
|
2201
|
-
const { provisionBackupDestination } = await Promise.resolve().then(() => require("./backup-provisioner-
|
|
2371
|
+
const { provisionBackupDestination } = await Promise.resolve().then(() => require("./backup-provisioner-oDEsPj02.cjs"));
|
|
2202
2372
|
const backupState = await provisionBackupDestination({
|
|
2203
2373
|
api,
|
|
2204
2374
|
projectId: project.projectId,
|
|
@@ -2236,14 +2406,22 @@ async function provisionDokploy(run) {
|
|
|
2236
2406
|
registry,
|
|
2237
2407
|
registryId,
|
|
2238
2408
|
declaredEnv,
|
|
2409
|
+
declaredClusters,
|
|
2410
|
+
migrations,
|
|
2239
2411
|
changes,
|
|
2240
2412
|
applied
|
|
2241
2413
|
};
|
|
2242
2414
|
}
|
|
2243
2415
|
/**
|
|
2244
|
-
*
|
|
2245
|
-
* deployed, and its domain attached
|
|
2246
|
-
*
|
|
2416
|
+
* The stage's migrations, then each app's image built and pushed, its
|
|
2417
|
+
* application configured and deployed, and its domain attached — counted as
|
|
2418
|
+
* released only once Dokploy's deployment has finished.
|
|
2419
|
+
*
|
|
2420
|
+
* Backends first, each failure stopping the run; then they are checked, and
|
|
2421
|
+
* a backend that does not answer stops the run too — a site built against an
|
|
2422
|
+
* API that is down is a site that is down. Then the sites, whose build args
|
|
2423
|
+
* are the backends' public URLs: every one is attempted, and any that failed
|
|
2424
|
+
* fails the run. `verify` checks the sites.
|
|
2247
2425
|
*
|
|
2248
2426
|
* Building is here rather than in a `build` phase of its own because an
|
|
2249
2427
|
* image is built beside its application — a site's build args are resolved
|
|
@@ -2254,12 +2432,25 @@ async function releaseDokploy(run) {
|
|
|
2254
2432
|
const { stageSecrets, sniffedApps, encryptedSecrets } = run.preflight;
|
|
2255
2433
|
const { api, journal, state, environmentId, registry, registryId, declaredEnv, changes, applied } = run.provisioned;
|
|
2256
2434
|
const project = { projectId: run.provisioned.projectId };
|
|
2435
|
+
try {
|
|
2436
|
+
await migrate(run);
|
|
2437
|
+
} finally {
|
|
2438
|
+
await closePublished(run);
|
|
2439
|
+
}
|
|
2257
2440
|
const backendApps = appsToDeployNames.filter((name) => workspace.apps[name].type === "backend");
|
|
2258
2441
|
const frontendApps = appsToDeployNames.filter((name) => workspace.apps[name].type === "web");
|
|
2259
2442
|
const publicUrls = {};
|
|
2260
2443
|
const results = [];
|
|
2261
2444
|
const appHostnames = /* @__PURE__ */ new Map();
|
|
2262
2445
|
const appDomainIds = /* @__PURE__ */ new Map();
|
|
2446
|
+
const released = {
|
|
2447
|
+
results,
|
|
2448
|
+
publicUrls,
|
|
2449
|
+
appHostnames,
|
|
2450
|
+
appDomainIds,
|
|
2451
|
+
applicationIds: /* @__PURE__ */ new Map()
|
|
2452
|
+
};
|
|
2453
|
+
run.released = released;
|
|
2263
2454
|
/** An app is up: say so, and remember where. */
|
|
2264
2455
|
const deployed = (appName, result, host) => {
|
|
2265
2456
|
const url = `https://${host}`;
|
|
@@ -2276,29 +2467,8 @@ async function releaseDokploy(run) {
|
|
|
2276
2467
|
url
|
|
2277
2468
|
});
|
|
2278
2469
|
};
|
|
2279
|
-
/** An app failed: say so, and record it in the result. */
|
|
2280
|
-
const failed = (appName, app, error) => {
|
|
2281
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2282
|
-
logger.log(` ✗ Failed to deploy ${appName}: ${message}`);
|
|
2283
|
-
results.push({
|
|
2284
|
-
appName,
|
|
2285
|
-
type: app.type,
|
|
2286
|
-
success: false,
|
|
2287
|
-
error: message
|
|
2288
|
-
});
|
|
2289
|
-
ctx.emit({
|
|
2290
|
-
type: "app.failed",
|
|
2291
|
-
app: appName,
|
|
2292
|
-
error: require_sniffer.eventError(error)
|
|
2293
|
-
});
|
|
2294
|
-
};
|
|
2295
2470
|
/** An image is pushed: worth keeping even if what follows fails. */
|
|
2296
|
-
const built =
|
|
2297
|
-
require_state.setDeployedImage(state, appName, {
|
|
2298
|
-
ref: image.imageRef ?? ref,
|
|
2299
|
-
...image.digest ? { digest: image.digest } : {}
|
|
2300
|
-
});
|
|
2301
|
-
await journal.save();
|
|
2471
|
+
const built = (appName, image, ref) => {
|
|
2302
2472
|
ctx.emit({
|
|
2303
2473
|
type: "artifact.built",
|
|
2304
2474
|
app: appName,
|
|
@@ -2312,6 +2482,41 @@ async function releaseDokploy(run) {
|
|
|
2312
2482
|
id: image.digest ?? image.imageRef ?? ref
|
|
2313
2483
|
});
|
|
2314
2484
|
};
|
|
2485
|
+
/**
|
|
2486
|
+
* Point an app's application at its image and environment, deploy it,
|
|
2487
|
+
* attach its domain, and wait for Dokploy to finish — then record the
|
|
2488
|
+
* release, with what ran before it as the one a rollback restores.
|
|
2489
|
+
*/
|
|
2490
|
+
const release = async (appName, applicationId, image, envVars, host) => {
|
|
2491
|
+
const app = workspace.apps[appName];
|
|
2492
|
+
released.applicationIds.set(appName, applicationId);
|
|
2493
|
+
const since = await api.latestDeploymentAt(applicationId);
|
|
2494
|
+
const before = state.releases?.[appName]?.current;
|
|
2495
|
+
run.releasing.switched.set(appName, {
|
|
2496
|
+
applicationId,
|
|
2497
|
+
...before ? { before } : {}
|
|
2498
|
+
});
|
|
2499
|
+
await api.saveDockerProvider(applicationId, image.ref, { registryId });
|
|
2500
|
+
await api.saveApplicationEnv(applicationId, envVars.join("\n"));
|
|
2501
|
+
logger.log(` Deploying to Dokploy...`);
|
|
2502
|
+
await api.deployApplication(applicationId);
|
|
2503
|
+
const domainId = await ensureDomain(api, journal, host, app.port, applicationId, applied);
|
|
2504
|
+
appHostnames.set(appName, host);
|
|
2505
|
+
if (domainId) appDomainIds.set(appName, domainId);
|
|
2506
|
+
logger.log(` Waiting for the deployment to finish...`);
|
|
2507
|
+
await api.waitForDeployment(applicationId, {
|
|
2508
|
+
since,
|
|
2509
|
+
timeoutMs: run.verify.deploymentTimeoutMs,
|
|
2510
|
+
intervalMs: run.verify.intervalMs,
|
|
2511
|
+
signal: ctx.signal
|
|
2512
|
+
});
|
|
2513
|
+
require_state.recordRelease(state, appName, {
|
|
2514
|
+
...image,
|
|
2515
|
+
tag: imageTag
|
|
2516
|
+
});
|
|
2517
|
+
await journal.save();
|
|
2518
|
+
publicUrls[appName] = `https://${host}`;
|
|
2519
|
+
};
|
|
2315
2520
|
const frontendUrls = [];
|
|
2316
2521
|
for (const appName of frontendApps) frontendUrls.push(`https://${hostOf(workspace, appName, stage)}`);
|
|
2317
2522
|
if (backendApps.length > 0) {
|
|
@@ -2321,14 +2526,14 @@ async function releaseDokploy(run) {
|
|
|
2321
2526
|
const app = workspace.apps[appName];
|
|
2322
2527
|
logger.log(`\n ⚙️ Deploying ${appName}...`);
|
|
2323
2528
|
try {
|
|
2324
|
-
const application = await ensureApplication(api, journal, appName,
|
|
2529
|
+
const application = await ensureApplication(api, journal, appName, require_state.applicationName(identity, appName), project.projectId, environmentId, applied);
|
|
2325
2530
|
const appSecrets = encryptedSecrets.get(appName);
|
|
2326
2531
|
const credentials = appSecrets && appSecrets.secretCount > 0 ? {
|
|
2327
2532
|
encrypted: appSecrets.payload.encrypted,
|
|
2328
2533
|
iv: appSecrets.payload.iv
|
|
2329
2534
|
} : void 0;
|
|
2330
2535
|
if (appSecrets && credentials) logger.log(` Encrypted ${appSecrets.secretCount} secrets`);
|
|
2331
|
-
const imageRef$1 =
|
|
2536
|
+
const imageRef$1 = require_state.imageRef(identity, appName, registry, imageTag);
|
|
2332
2537
|
logger.log(` Building Docker image: ${imageRef$1}`);
|
|
2333
2538
|
const image = await deployDocker({
|
|
2334
2539
|
stage,
|
|
@@ -2336,13 +2541,13 @@ async function releaseDokploy(run) {
|
|
|
2336
2541
|
skipPush: false,
|
|
2337
2542
|
config: {
|
|
2338
2543
|
registry,
|
|
2339
|
-
imageName:
|
|
2544
|
+
imageName: require_state.imageName(identity, appName),
|
|
2340
2545
|
appName
|
|
2341
2546
|
},
|
|
2342
2547
|
credentials,
|
|
2343
2548
|
...dockerRun(workspace, app, ctx)
|
|
2344
2549
|
});
|
|
2345
|
-
|
|
2550
|
+
built(appName, image, imageRef$1);
|
|
2346
2551
|
const backendHost = hostOf(workspace, appName, stage);
|
|
2347
2552
|
const dependencyUrls = {};
|
|
2348
2553
|
if (app.dependencies) {
|
|
@@ -2373,14 +2578,10 @@ async function releaseDokploy(run) {
|
|
|
2373
2578
|
};
|
|
2374
2579
|
const envVars = Object.entries(withDeclared).map(([key, value]) => `${key}=${value}`);
|
|
2375
2580
|
if (Object.keys(withDeclared).length > 0) logger.log(` Resolved ${Object.keys(withDeclared).length} env vars: ${Object.keys(withDeclared).sort().join(", ")}`);
|
|
2376
|
-
await
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
const domainId = await ensureDomain(api, journal, backendHost, app.port, application.applicationId, applied);
|
|
2381
|
-
appHostnames.set(appName, backendHost);
|
|
2382
|
-
if (domainId) appDomainIds.set(appName, domainId);
|
|
2383
|
-
publicUrls[appName] = `https://${backendHost}`;
|
|
2581
|
+
await release(appName, application.applicationId, {
|
|
2582
|
+
ref: image.imageRef ?? imageRef$1,
|
|
2583
|
+
...image.digest ? { digest: image.digest } : {}
|
|
2584
|
+
}, envVars, backendHost);
|
|
2384
2585
|
deployed(appName, {
|
|
2385
2586
|
appName,
|
|
2386
2587
|
type: app.type,
|
|
@@ -2391,11 +2592,20 @@ async function releaseDokploy(run) {
|
|
|
2391
2592
|
logger.log(` ✓ ${appName} deployed successfully`);
|
|
2392
2593
|
} catch (error) {
|
|
2393
2594
|
if (ctx.signal?.aborted) throw ctx.signal.reason;
|
|
2394
|
-
failed(
|
|
2595
|
+
failed(run, appName, error);
|
|
2395
2596
|
throw new BackendDeployFailed(appName, error);
|
|
2396
2597
|
}
|
|
2397
2598
|
}
|
|
2599
|
+
await settleDns(run, backendApps);
|
|
2600
|
+
for (const appName of backendApps) try {
|
|
2601
|
+
await checkHealth(run, appName);
|
|
2602
|
+
} catch (error) {
|
|
2603
|
+
if (ctx.signal?.aborted) throw ctx.signal.reason;
|
|
2604
|
+
failed(run, appName, error);
|
|
2605
|
+
throw new BackendDeployFailed(appName, error);
|
|
2606
|
+
}
|
|
2398
2607
|
}
|
|
2608
|
+
let frontendFailure;
|
|
2399
2609
|
if (frontendApps.length > 0) {
|
|
2400
2610
|
logger.log("\n🌐 PHASE 2: Deploying frontend applications...");
|
|
2401
2611
|
for (const appName of frontendApps) {
|
|
@@ -2403,7 +2613,7 @@ async function releaseDokploy(run) {
|
|
|
2403
2613
|
const app = workspace.apps[appName];
|
|
2404
2614
|
logger.log(`\n 🌐 Deploying ${appName}...`);
|
|
2405
2615
|
try {
|
|
2406
|
-
const application = await ensureApplication(api, journal, appName,
|
|
2616
|
+
const application = await ensureApplication(api, journal, appName, require_state.applicationName(identity, appName), project.projectId, environmentId, applied);
|
|
2407
2617
|
const dependencyUrls = {};
|
|
2408
2618
|
if (app.dependencies) {
|
|
2409
2619
|
for (const dep of app.dependencies) if (publicUrls[dep]) dependencyUrls[dep] = publicUrls[dep];
|
|
@@ -2432,7 +2642,7 @@ async function releaseDokploy(run) {
|
|
|
2432
2642
|
}
|
|
2433
2643
|
}
|
|
2434
2644
|
if (buildArgs.length > 0) logger.log(` Build args: ${publicUrlArgNames.join(", ")}`);
|
|
2435
|
-
const imageRef$2 =
|
|
2645
|
+
const imageRef$2 = require_state.imageRef(identity, appName, registry, imageTag);
|
|
2436
2646
|
logger.log(` Building Docker image: ${imageRef$2}`);
|
|
2437
2647
|
const image = await deployDocker({
|
|
2438
2648
|
stage,
|
|
@@ -2440,28 +2650,24 @@ async function releaseDokploy(run) {
|
|
|
2440
2650
|
skipPush: false,
|
|
2441
2651
|
config: {
|
|
2442
2652
|
registry,
|
|
2443
|
-
imageName:
|
|
2653
|
+
imageName: require_state.imageName(identity, appName),
|
|
2444
2654
|
appName
|
|
2445
2655
|
},
|
|
2446
2656
|
buildArgs,
|
|
2447
2657
|
publicUrlArgs: publicUrlArgNames,
|
|
2448
2658
|
...dockerRun(workspace, app, ctx)
|
|
2449
2659
|
});
|
|
2450
|
-
|
|
2660
|
+
built(appName, image, imageRef$2);
|
|
2451
2661
|
const envVars = [
|
|
2452
2662
|
`NODE_ENV=production`,
|
|
2453
2663
|
`PORT=${app.port}`,
|
|
2454
2664
|
`STAGE=${stage}`
|
|
2455
2665
|
];
|
|
2456
2666
|
for (const [key, value] of Object.entries(resolved)) envVars.push(`${key}=${value}`);
|
|
2457
|
-
await
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
const domainId = await ensureDomain(api, journal, frontendHost, app.port, application.applicationId, applied);
|
|
2462
|
-
appHostnames.set(appName, frontendHost);
|
|
2463
|
-
if (domainId) appDomainIds.set(appName, domainId);
|
|
2464
|
-
publicUrls[appName] = `https://${frontendHost}`;
|
|
2667
|
+
await release(appName, application.applicationId, {
|
|
2668
|
+
ref: image.imageRef ?? imageRef$2,
|
|
2669
|
+
...image.digest ? { digest: image.digest } : {}
|
|
2670
|
+
}, envVars, frontendHost);
|
|
2465
2671
|
deployed(appName, {
|
|
2466
2672
|
appName,
|
|
2467
2673
|
type: app.type,
|
|
@@ -2472,42 +2678,199 @@ async function releaseDokploy(run) {
|
|
|
2472
2678
|
logger.log(` ✓ ${appName} deployed successfully`);
|
|
2473
2679
|
} catch (error) {
|
|
2474
2680
|
if (ctx.signal?.aborted) throw ctx.signal.reason;
|
|
2475
|
-
failed(
|
|
2681
|
+
failed(run, appName, error);
|
|
2682
|
+
frontendFailure ??= error;
|
|
2476
2683
|
}
|
|
2477
2684
|
}
|
|
2478
2685
|
}
|
|
2479
2686
|
logger.log("\n📋 Saving deploy state...");
|
|
2480
2687
|
await journal.save();
|
|
2481
2688
|
logger.log(" ✓ State saved");
|
|
2482
|
-
run.
|
|
2483
|
-
results,
|
|
2484
|
-
publicUrls,
|
|
2485
|
-
appHostnames
|
|
2486
|
-
};
|
|
2689
|
+
if (frontendFailure !== void 0) throw new FrontendDeployFailed(frontendApps.filter((app) => run.releasing.failed.has(app)), frontendFailure);
|
|
2487
2690
|
}
|
|
2488
|
-
/**
|
|
2691
|
+
/** Each released site answering, after its DNS. Backends were checked in `release`. */
|
|
2489
2692
|
async function verifyDokploy(run) {
|
|
2693
|
+
const { workspace, ctx } = run;
|
|
2694
|
+
const sites = run.released.results.filter((result) => result.success).map((result) => result.appName).filter((app) => workspace.apps[app]?.type === "web");
|
|
2695
|
+
if (sites.length === 0) return;
|
|
2696
|
+
await settleDns(run, sites);
|
|
2697
|
+
let failure;
|
|
2698
|
+
for (const appName of sites) try {
|
|
2699
|
+
await checkHealth(run, appName);
|
|
2700
|
+
} catch (error) {
|
|
2701
|
+
if (ctx.signal.aborted) throw ctx.signal.reason;
|
|
2702
|
+
failed(run, appName, error);
|
|
2703
|
+
failure ??= error;
|
|
2704
|
+
}
|
|
2705
|
+
if (failure !== void 0) throw new FrontendDeployFailed(sites.filter((app) => run.releasing.failed.has(app)), failure);
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* An app failed to release or to answer: say so, mark it for rollback, and
|
|
2709
|
+
* record it in the result — over the success it was recorded as, if its
|
|
2710
|
+
* health is what failed.
|
|
2711
|
+
*/
|
|
2712
|
+
function failed(run, appName, error) {
|
|
2713
|
+
const app = run.workspace.apps[appName];
|
|
2714
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2715
|
+
logger.log(` ✗ Failed to deploy ${appName}: ${message}`);
|
|
2716
|
+
const results = run.released.results;
|
|
2717
|
+
const recorded = results.find((result) => result.appName === appName);
|
|
2718
|
+
if (recorded) {
|
|
2719
|
+
recorded.success = false;
|
|
2720
|
+
recorded.error = message;
|
|
2721
|
+
} else results.push({
|
|
2722
|
+
appName,
|
|
2723
|
+
type: app.type,
|
|
2724
|
+
success: false,
|
|
2725
|
+
error: message
|
|
2726
|
+
});
|
|
2727
|
+
run.releasing.failed.add(appName);
|
|
2728
|
+
run.ctx.emit({
|
|
2729
|
+
type: "app.failed",
|
|
2730
|
+
app: appName,
|
|
2731
|
+
error: require_sniffer.eventError(error)
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* The stage's pending migrations, applied in the deploy's sandbox against
|
|
2736
|
+
* each cluster published for them. Nothing is published, and nothing runs,
|
|
2737
|
+
* for a project with no migration to apply.
|
|
2738
|
+
*/
|
|
2739
|
+
async function migrate(run) {
|
|
2740
|
+
const { workspace, manifest, stage, ctx } = run;
|
|
2741
|
+
const { api, endpoint, migrations, declaredClusters } = run.provisioned;
|
|
2742
|
+
if (migrations.size === 0) return;
|
|
2743
|
+
logger.log("\n🗄️ Applying migrations...");
|
|
2744
|
+
const host = serverHostname(endpoint);
|
|
2745
|
+
const urls = {};
|
|
2746
|
+
for (const [database, keys] of migrations) {
|
|
2747
|
+
const cluster = declaredClusters[database];
|
|
2748
|
+
if (!cluster) continue;
|
|
2749
|
+
let published = run.published.get(database);
|
|
2750
|
+
if (!published) {
|
|
2751
|
+
published = await publishPostgres(api, cluster, host);
|
|
2752
|
+
run.published.set(database, published);
|
|
2753
|
+
}
|
|
2754
|
+
for (const [key, url] of Object.entries(keys)) urls[key] = publishedUrl(url, published);
|
|
2755
|
+
}
|
|
2756
|
+
const runs = await runMigrations({
|
|
2757
|
+
root: workspace.root,
|
|
2758
|
+
stage,
|
|
2759
|
+
manifest,
|
|
2760
|
+
patterns: require_state.constructGlobs(workspace),
|
|
2761
|
+
urls,
|
|
2762
|
+
signal: ctx.signal
|
|
2763
|
+
});
|
|
2764
|
+
for (const { migrations: folder, applied } of runs) logger.log(applied.length === 0 ? ` ✓ ${folder}: up to date` : ` ✓ ${folder}: applied ${applied.join(", ")}`);
|
|
2765
|
+
}
|
|
2766
|
+
/**
|
|
2767
|
+
* DNS records for `apps`' hosts, their propagation, and Dokploy's validation
|
|
2768
|
+
* of each domain — before anything is asked of them by name.
|
|
2769
|
+
*/
|
|
2770
|
+
async function settleDns(run, apps) {
|
|
2490
2771
|
const { workspace } = run;
|
|
2491
2772
|
const { api, endpoint, journal, state } = run.provisioned;
|
|
2492
|
-
const
|
|
2773
|
+
const appHostnames = new Map([...run.released.appHostnames].filter(([app]) => apps.includes(app)));
|
|
2493
2774
|
const dnsConfig = workspace.deploy.dns;
|
|
2494
|
-
if (dnsConfig
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2775
|
+
if (!dnsConfig || appHostnames.size === 0) return;
|
|
2776
|
+
const dnsResult = await orchestrateDns(appHostnames, dnsConfig, endpoint);
|
|
2777
|
+
if (dnsResult?.serverIp) {
|
|
2778
|
+
await verifyDnsRecords(appHostnames, dnsResult.serverIp, state);
|
|
2779
|
+
await journal.save();
|
|
2780
|
+
}
|
|
2781
|
+
if (dnsResult?.success) {
|
|
2782
|
+
logger.log("\n🔒 Validating domains for SSL certificates...");
|
|
2783
|
+
for (const [appName, hostname] of appHostnames) try {
|
|
2784
|
+
const result = await api.validateDomain(hostname);
|
|
2785
|
+
if (result.isValid) logger.log(` ✓ ${appName}: ${hostname} → ${result.resolvedIp}`);
|
|
2786
|
+
else logger.log(` ⚠ ${appName}: ${hostname} not valid`);
|
|
2787
|
+
} catch (validationError) {
|
|
2788
|
+
const message = validationError instanceof Error ? validationError.message : "Unknown error";
|
|
2789
|
+
logger.log(` ⚠ ${appName}: validation failed - ${message}`);
|
|
2499
2790
|
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
/**
|
|
2794
|
+
* Whether a released app answers: `healthyAfter` 2xx in a row from its
|
|
2795
|
+
* health route under its domain. An app without a domain has nothing to ask
|
|
2796
|
+
* by name, so Dokploy's finished deployment is its check.
|
|
2797
|
+
*/
|
|
2798
|
+
async function checkHealth(run, appName) {
|
|
2799
|
+
const { workspace, verify, ctx } = run;
|
|
2800
|
+
const { appHostnames, appDomainIds, applicationIds } = run.released;
|
|
2801
|
+
const host = appHostnames.get(appName);
|
|
2802
|
+
if (!host || !appDomainIds.has(appName)) {
|
|
2803
|
+
ctx.emit({
|
|
2804
|
+
type: "health.checked",
|
|
2805
|
+
app: appName,
|
|
2806
|
+
url: `dokploy:application.status/${applicationIds.get(appName)}`,
|
|
2807
|
+
healthy: true,
|
|
2808
|
+
attempt: 1
|
|
2809
|
+
});
|
|
2810
|
+
logger.log(` ✓ ${appName}: no domain, so its finished deployment is its check`);
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
const url = `https://${host}${workspace.apps[appName].type === "web" ? "/" : verify.healthCheckPath}`;
|
|
2814
|
+
logger.log(`\n🩺 Checking ${appName} at ${url}...`);
|
|
2815
|
+
await verifyHealth({
|
|
2816
|
+
app: appName,
|
|
2817
|
+
url,
|
|
2818
|
+
healthyAfter: verify.healthyAfter,
|
|
2819
|
+
intervalMs: verify.intervalMs,
|
|
2820
|
+
timeoutMs: verify.healthTimeoutMs,
|
|
2821
|
+
signal: ctx.signal,
|
|
2822
|
+
emit: ctx.emit
|
|
2823
|
+
});
|
|
2824
|
+
logger.log(` ✓ ${appName} healthy (${verify.healthyAfter} in a row)`);
|
|
2825
|
+
}
|
|
2826
|
+
/**
|
|
2827
|
+
* Point an application back at `image`, deploy it, and wait for Dokploy to
|
|
2828
|
+
* finish. The environment stays the newer release's: it is resolved from the
|
|
2829
|
+
* stage, which a rollback does not change, and holds no image of its own.
|
|
2830
|
+
*/
|
|
2831
|
+
async function restoreImage(api, options) {
|
|
2832
|
+
const { applicationId, image, registryId, verify, signal } = options;
|
|
2833
|
+
const since = await api.latestDeploymentAt(applicationId);
|
|
2834
|
+
await api.saveDockerProvider(applicationId, image.ref, registryId ? { registryId } : void 0);
|
|
2835
|
+
await api.deployApplication(applicationId);
|
|
2836
|
+
await api.waitForDeployment(applicationId, {
|
|
2837
|
+
since,
|
|
2838
|
+
timeoutMs: verify.deploymentTimeoutMs,
|
|
2839
|
+
intervalMs: verify.intervalMs,
|
|
2840
|
+
signal
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Put back what ran before this run, after its `release` or `verify` failed:
|
|
2845
|
+
* the apps that failed — or, `atomic`, every app the run released — each
|
|
2846
|
+
* pointed at its previous image and redeployed. An app released for the
|
|
2847
|
+
* first time has nothing to go back to and is left as it is.
|
|
2848
|
+
*/
|
|
2849
|
+
async function rollbackDokploy(run, failure) {
|
|
2850
|
+
const { switched, failed: failedApps } = run.releasing;
|
|
2851
|
+
const apps = [...switched.keys()].filter((app) => run.atomic || failedApps.has(app));
|
|
2852
|
+
if (!run.provisioned || apps.length === 0) {
|
|
2853
|
+
logger.log(`\n⏪ Nothing to roll back: the ${failure.phase} failed before any app was switched.`);
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
const { api, journal, state, registryId } = run.provisioned;
|
|
2857
|
+
logger.log(`\n⏪ Rolling back ${run.atomic ? "every app this run released" : "what failed"}: ${apps.join(", ")}`);
|
|
2858
|
+
for (const appName of apps) {
|
|
2859
|
+
const { applicationId, before } = switched.get(appName);
|
|
2860
|
+
if (!before) {
|
|
2861
|
+
logger.log(` ⚠ ${appName}: this was its first release, so there is nothing to restore`);
|
|
2862
|
+
continue;
|
|
2510
2863
|
}
|
|
2864
|
+
await restoreImage(api, {
|
|
2865
|
+
applicationId,
|
|
2866
|
+
image: before,
|
|
2867
|
+
registryId,
|
|
2868
|
+
verify: run.verify,
|
|
2869
|
+
signal: run.ctx.signal
|
|
2870
|
+
});
|
|
2871
|
+
require_state.recordRollback(state, appName, before);
|
|
2872
|
+
await journal.save();
|
|
2873
|
+
logger.log(` ✓ ${appName} rolled back to ${before.ref}`);
|
|
2511
2874
|
}
|
|
2512
2875
|
}
|
|
2513
2876
|
/** What the run did, printed as the summary it always ended with. */
|
|
@@ -2624,10 +2987,14 @@ async function ensureDomain(api, journal, host, port, applicationId, applied) {
|
|
|
2624
2987
|
function isAbort(error) {
|
|
2625
2988
|
return error instanceof Error && error.name === "AbortError";
|
|
2626
2989
|
}
|
|
2627
|
-
/**
|
|
2990
|
+
/**
|
|
2991
|
+
* Where docker runs for one app: the workspace root, building from the
|
|
2992
|
+
* Dockerfile `gkm docker` writes for the app there.
|
|
2993
|
+
*/
|
|
2628
2994
|
function dockerRun(workspace, app, ctx) {
|
|
2629
2995
|
return {
|
|
2630
|
-
cwd:
|
|
2996
|
+
cwd: workspace.root,
|
|
2997
|
+
appPath: app.path,
|
|
2631
2998
|
...ctx.signal ? { signal: ctx.signal } : {},
|
|
2632
2999
|
...ctx.stdio ? { stdio: ctx.stdio } : {}
|
|
2633
3000
|
};
|
|
@@ -2686,7 +3053,7 @@ async function planDokploy(run) {
|
|
|
2686
3053
|
key: "project",
|
|
2687
3054
|
resourceType: "project",
|
|
2688
3055
|
action: "create"
|
|
2689
|
-
},
|
|
3056
|
+
}, require_state.projectName(identity));
|
|
2690
3057
|
const environment = project?.environments.find((e) => e.name.toLowerCase() === stage.toLowerCase());
|
|
2691
3058
|
plan(environment ? {
|
|
2692
3059
|
key: "environment",
|
|
@@ -2733,7 +3100,7 @@ async function planDokploy(run) {
|
|
|
2733
3100
|
for (const appName of appsToDeployNames) {
|
|
2734
3101
|
const app = workspace.apps[appName];
|
|
2735
3102
|
if (app.type === "mobile") continue;
|
|
2736
|
-
const dokployAppName =
|
|
3103
|
+
const dokployAppName = require_state.applicationName(identity, appName);
|
|
2737
3104
|
const recordedId = state?.applications?.[appName];
|
|
2738
3105
|
const existing = project && environment ? (recordedId ? await api.getApplication(recordedId).catch(() => null) : null) ?? await api.findApplicationByName(project.projectId, dokployAppName, environment.environmentId) : null;
|
|
2739
3106
|
plan(existing ? {
|
|
@@ -2746,7 +3113,7 @@ async function planDokploy(run) {
|
|
|
2746
3113
|
resourceType: "application",
|
|
2747
3114
|
action: "create"
|
|
2748
3115
|
}, dokployAppName);
|
|
2749
|
-
const ref =
|
|
3116
|
+
const ref = require_state.imageRef(identity, appName, workspace.deploy.registry, imageTag);
|
|
2750
3117
|
plan({
|
|
2751
3118
|
key: `image:${appName}`,
|
|
2752
3119
|
resourceType: "image",
|
|
@@ -2800,6 +3167,24 @@ Object.defineProperty(exports, "BackendDeployFailed", {
|
|
|
2800
3167
|
return BackendDeployFailed;
|
|
2801
3168
|
}
|
|
2802
3169
|
});
|
|
3170
|
+
Object.defineProperty(exports, "DeployMigrationsFailed", {
|
|
3171
|
+
enumerable: true,
|
|
3172
|
+
get: function() {
|
|
3173
|
+
return DeployMigrationsFailed;
|
|
3174
|
+
}
|
|
3175
|
+
});
|
|
3176
|
+
Object.defineProperty(exports, "FrontendDeployFailed", {
|
|
3177
|
+
enumerable: true,
|
|
3178
|
+
get: function() {
|
|
3179
|
+
return FrontendDeployFailed;
|
|
3180
|
+
}
|
|
3181
|
+
});
|
|
3182
|
+
Object.defineProperty(exports, "HealthCheckTimedOut", {
|
|
3183
|
+
enumerable: true,
|
|
3184
|
+
get: function() {
|
|
3185
|
+
return HealthCheckTimedOut;
|
|
3186
|
+
}
|
|
3187
|
+
});
|
|
2803
3188
|
Object.defineProperty(exports, "MissingEnvVars", {
|
|
2804
3189
|
enumerable: true,
|
|
2805
3190
|
get: function() {
|
|
@@ -2830,6 +3215,12 @@ Object.defineProperty(exports, "RegistryNotFound", {
|
|
|
2830
3215
|
return RegistryNotFound;
|
|
2831
3216
|
}
|
|
2832
3217
|
});
|
|
3218
|
+
Object.defineProperty(exports, "dokployApi", {
|
|
3219
|
+
enumerable: true,
|
|
3220
|
+
get: function() {
|
|
3221
|
+
return dokployApi;
|
|
3222
|
+
}
|
|
3223
|
+
});
|
|
2833
3224
|
Object.defineProperty(exports, "dokployResult", {
|
|
2834
3225
|
enumerable: true,
|
|
2835
3226
|
get: function() {
|
|
@@ -2854,6 +3245,18 @@ Object.defineProperty(exports, "releaseDokploy", {
|
|
|
2854
3245
|
return releaseDokploy;
|
|
2855
3246
|
}
|
|
2856
3247
|
});
|
|
3248
|
+
Object.defineProperty(exports, "restoreImage", {
|
|
3249
|
+
enumerable: true,
|
|
3250
|
+
get: function() {
|
|
3251
|
+
return restoreImage;
|
|
3252
|
+
}
|
|
3253
|
+
});
|
|
3254
|
+
Object.defineProperty(exports, "rollbackDokploy", {
|
|
3255
|
+
enumerable: true,
|
|
3256
|
+
get: function() {
|
|
3257
|
+
return rollbackDokploy;
|
|
3258
|
+
}
|
|
3259
|
+
});
|
|
2857
3260
|
Object.defineProperty(exports, "validateDokploy", {
|
|
2858
3261
|
enumerable: true,
|
|
2859
3262
|
get: function() {
|
|
@@ -2866,5 +3269,11 @@ Object.defineProperty(exports, "verifyDokploy", {
|
|
|
2866
3269
|
return verifyDokploy;
|
|
2867
3270
|
}
|
|
2868
3271
|
});
|
|
3272
|
+
Object.defineProperty(exports, "verifySettings", {
|
|
3273
|
+
enumerable: true,
|
|
3274
|
+
get: function() {
|
|
3275
|
+
return verifySettings;
|
|
3276
|
+
}
|
|
3277
|
+
});
|
|
2869
3278
|
|
|
2870
|
-
//# sourceMappingURL=
|
|
3279
|
+
//# sourceMappingURL=engine-DR6KurWX.cjs.map
|