@geekmidas/cli 10.0.0-alpha.63 → 10.0.0-alpha.65
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 +1 -1
- package/dist/{HostingerProvider-mJ7YruxH.cjs → HostingerProvider-Dlmbfass.cjs} +3 -3
- package/dist/{HostingerProvider-mJ7YruxH.cjs.map → HostingerProvider-Dlmbfass.cjs.map} +1 -1
- package/dist/{HostingerProvider-DBBwpDjn.mjs → HostingerProvider-x9gQTAGZ.mjs} +2 -2
- package/dist/{HostingerProvider-DBBwpDjn.mjs.map → HostingerProvider-x9gQTAGZ.mjs.map} +1 -1
- package/dist/{LegacyStateStore-CP7SZwTP.cjs → LegacyStateStore-BGoo__sO.cjs} +2 -2
- package/dist/{LegacyStateStore-CP7SZwTP.cjs.map → LegacyStateStore-BGoo__sO.cjs.map} +1 -1
- package/dist/{LegacyStateStore-Q3GmKOdw.mjs → LegacyStateStore-BUu1nQ5u.mjs} +2 -2
- package/dist/{LegacyStateStore-Q3GmKOdw.mjs.map → LegacyStateStore-BUu1nQ5u.mjs.map} +1 -1
- package/dist/{LocalStateStore-sS8mvLeq.cjs → LocalStateStore-93urXyQe.cjs} +2 -2
- package/dist/{LocalStateStore-sS8mvLeq.cjs.map → LocalStateStore-93urXyQe.cjs.map} +1 -1
- package/dist/{LocalStateStore-BGIXcrvX.mjs → LocalStateStore-BRhMQRuy.mjs} +2 -2
- package/dist/{LocalStateStore-BGIXcrvX.mjs.map → LocalStateStore-BRhMQRuy.mjs.map} +1 -1
- package/dist/{OpenApiTsGenerator-CjHWFRDd.cjs → OpenApiTsGenerator-BWuDRHe9.cjs} +2 -2
- package/dist/{OpenApiTsGenerator-CjHWFRDd.cjs.map → OpenApiTsGenerator-BWuDRHe9.cjs.map} +1 -1
- package/dist/{OpenApiTsGenerator-CnAwhGxi.mjs → OpenApiTsGenerator-C6v-IeHt.mjs} +2 -2
- package/dist/{OpenApiTsGenerator-CnAwhGxi.mjs.map → OpenApiTsGenerator-C6v-IeHt.mjs.map} +1 -1
- package/dist/{S3StateStore-6jqITH2F.cjs → S3StateStore-F4dxw6kS.cjs} +2 -2
- package/dist/{S3StateStore-6jqITH2F.cjs.map → S3StateStore-F4dxw6kS.cjs.map} +1 -1
- package/dist/{S3StateStore-D7l_lWJ3.mjs → S3StateStore-pw1GhWwt.mjs} +2 -2
- package/dist/{S3StateStore-D7l_lWJ3.mjs.map → S3StateStore-pw1GhWwt.mjs.map} +1 -1
- package/dist/{SSMStateStore-B__F7IJa.cjs → SSMStateStore-Bt9acuAF.cjs} +2 -2
- package/dist/{SSMStateStore-B__F7IJa.cjs.map → SSMStateStore-Bt9acuAF.cjs.map} +1 -1
- package/dist/{SSMStateStore-CAQDVpoc.mjs → SSMStateStore-Dmj7zzFO.mjs} +2 -2
- package/dist/{SSMStateStore-CAQDVpoc.mjs.map → SSMStateStore-Dmj7zzFO.mjs.map} +1 -1
- package/dist/{bundler-DLfOn0ls.cjs → bundler-D6MlIfBT.cjs} +3 -3
- package/dist/{bundler-DLfOn0ls.cjs.map → bundler-D6MlIfBT.cjs.map} +1 -1
- package/dist/{bundler-DyWI41Iy.mjs → bundler-D_14JLZF.mjs} +3 -3
- package/dist/{bundler-DyWI41Iy.mjs.map → bundler-D_14JLZF.mjs.map} +1 -1
- package/dist/{cli-D-ZM-X2s.cjs → cli-BfH1deAT.cjs} +8 -7
- package/dist/{cli-D-ZM-X2s.cjs.map → cli-BfH1deAT.cjs.map} +1 -1
- package/dist/{cli-PkVF28LM.mjs → cli-P56vvrDQ.mjs} +5 -4
- package/dist/{cli-PkVF28LM.mjs.map → cli-P56vvrDQ.mjs.map} +1 -1
- package/dist/compose-391oEKj1.cjs +1982 -0
- package/dist/compose-391oEKj1.cjs.map +1 -0
- package/dist/compose-DprE3ASm.mjs +1862 -0
- package/dist/compose-DprE3ASm.mjs.map +1 -0
- package/dist/{config-C2BUosLm.d.mts → config-BonvTW8p.d.mts} +3 -3
- package/dist/{config-C2BUosLm.d.mts.map → config-BonvTW8p.d.mts.map} +1 -1
- package/dist/{config-CemHVcIr.d.cts → config-V_X_BMpm.d.cts} +3 -3
- package/dist/{config-CemHVcIr.d.cts.map → config-V_X_BMpm.d.cts.map} +1 -1
- package/dist/config.cjs +416 -17
- package/dist/config.cjs.map +1 -0
- package/dist/config.d.cts +3 -3
- package/dist/config.d.mts +3 -3
- package/dist/config.mjs +401 -2
- package/dist/config.mjs.map +1 -0
- package/dist/credentials-Bzbg23n8.cjs +298 -0
- package/dist/credentials-Bzbg23n8.cjs.map +1 -0
- package/dist/credentials-Dq1MnWQf.mjs +233 -0
- package/dist/credentials-Dq1MnWQf.mjs.map +1 -0
- package/dist/{define-CHBIEc5j.mjs → define-DR8xGYfL.mjs} +6 -3
- package/dist/define-DR8xGYfL.mjs.map +1 -0
- package/dist/{define-Cv_QvDrg.cjs → define-DzRz8FgH.cjs} +6 -3
- package/dist/define-DzRz8FgH.cjs.map +1 -0
- package/dist/deploy/public.cjs +39 -33
- package/dist/deploy/public.cjs.map +1 -1
- package/dist/deploy/public.d.cts +44 -5
- package/dist/deploy/public.d.cts.map +1 -1
- package/dist/deploy/public.d.mts +44 -5
- package/dist/deploy/public.d.mts.map +1 -1
- package/dist/deploy/public.mjs +15 -12
- package/dist/deploy/public.mjs.map +1 -1
- package/dist/{deploy-CGW-QY9D.mjs → deploy-9MBG_QLd.mjs} +11 -9
- package/dist/deploy-9MBG_QLd.mjs.map +1 -0
- package/dist/{deploy-BiMrP9i7.cjs → deploy-B80idPUZ.cjs} +17 -15
- package/dist/deploy-B80idPUZ.cjs.map +1 -0
- package/dist/{deploy-CG8bfFij.mjs → deploy-CZ5nlpsu.mjs} +16 -119
- package/dist/deploy-CZ5nlpsu.mjs.map +1 -0
- package/dist/{deploy-ABBI0lf1.cjs → deploy-Cy8igpQF.cjs} +48 -151
- package/dist/deploy-Cy8igpQF.cjs.map +1 -0
- package/dist/derive-B4vDpzgP.mjs +150 -0
- package/dist/derive-B4vDpzgP.mjs.map +1 -0
- package/dist/derive-DAbo49sm.cjs +161 -0
- package/dist/derive-DAbo49sm.cjs.map +1 -0
- package/dist/{discover-C_6COHEv.cjs → discover-DHdECueV.cjs} +6 -157
- package/dist/discover-DHdECueV.cjs.map +1 -0
- package/dist/{discover-BaT6bpnA.mjs → discover-LMrqsWSU.mjs} +8 -117
- package/dist/discover-LMrqsWSU.mjs.map +1 -0
- package/dist/{dokploy-CwdmlyQL.mjs → dokploy-BdtBePjp.mjs} +3 -3
- package/dist/{dokploy-CwdmlyQL.mjs.map → dokploy-BdtBePjp.mjs.map} +1 -1
- package/dist/{dokploy-QJxMhxdf.cjs → dokploy-iFY8MY-L.cjs} +3 -3
- package/dist/{dokploy-QJxMhxdf.cjs.map → dokploy-iFY8MY-L.cjs.map} +1 -1
- package/dist/{encryption-DXw3jj0c.cjs → encryption-B1wJwgae.cjs} +6 -6
- package/dist/{encryption-DXw3jj0c.cjs.map → encryption-B1wJwgae.cjs.map} +1 -1
- package/dist/{encryption-CbQG_ayW.mjs → encryption-DF_hgIpl.mjs} +5 -5
- package/dist/{encryption-CbQG_ayW.mjs.map → encryption-DF_hgIpl.mjs.map} +1 -1
- package/dist/{github-ciwbIwVa.mjs → github-B0YYE98x.mjs} +4 -4
- package/dist/{github-ciwbIwVa.mjs.map → github-B0YYE98x.mjs.map} +1 -1
- package/dist/{github-BH_mETav.cjs → github-CYcmTdFs.cjs} +4 -4
- package/dist/{github-BH_mETav.cjs.map → github-CYcmTdFs.cjs.map} +1 -1
- package/dist/{index-DrKzpHl-.d.mts → index-DPUAGJaK.d.cts} +16 -3
- package/dist/index-DPUAGJaK.d.cts.map +1 -0
- package/dist/{index-BtXDZQuF.d.cts → index-DwKD_8lt.d.mts} +16 -3
- package/dist/index-DwKD_8lt.d.mts.map +1 -0
- package/dist/index.cjs +280 -1849
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +221 -1774
- package/dist/index.mjs.map +1 -1
- package/dist/local-BdH4XN0E.cjs +409 -0
- package/dist/local-BdH4XN0E.cjs.map +1 -0
- package/dist/local-CWR4Y_kW.mjs +350 -0
- package/dist/local-CWR4Y_kW.mjs.map +1 -0
- package/dist/{migrate-ImBKTBcW.mjs → migrate-B2qL5wj4.mjs} +8 -7
- package/dist/{migrate-ImBKTBcW.mjs.map → migrate-B2qL5wj4.mjs.map} +1 -1
- package/dist/{migrate-6oa9y_f2.cjs → migrate-q4pPAXtO.cjs} +21 -20
- package/dist/{migrate-6oa9y_f2.cjs.map → migrate-q4pPAXtO.cjs.map} +1 -1
- package/dist/openapi.cjs +2 -2
- package/dist/openapi.d.cts +1 -1
- package/dist/openapi.d.mts +1 -1
- package/dist/openapi.mjs +2 -2
- package/dist/provider-C9_-45d5.cjs +56 -0
- package/dist/provider-C9_-45d5.cjs.map +1 -0
- package/dist/provider-q4lsYzYG.mjs +45 -0
- package/dist/provider-q4lsYzYG.mjs.map +1 -0
- package/dist/{ready-Bpdnn2ON.mjs → ready-bQfu9GES.mjs} +3 -3
- package/dist/{ready-Bpdnn2ON.mjs.map → ready-bQfu9GES.mjs.map} +1 -1
- package/dist/{ready-BiG0rt4h.cjs → ready-brEhh68f.cjs} +3 -3
- package/dist/{ready-BiG0rt4h.cjs.map → ready-brEhh68f.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-DLkIXTct.mjs +262 -0
- package/dist/resolve-DLkIXTct.mjs.map +1 -0
- package/dist/resolve-N_i4XNd-.cjs +315 -0
- package/dist/resolve-N_i4XNd-.cjs.map +1 -0
- package/dist/{runtime-Dn_n-29S.d.cts → runtime-BnD7Nn96.d.cts} +2 -2
- package/dist/{runtime-CXPlkZzs.d.mts.map → runtime-BnD7Nn96.d.cts.map} +1 -1
- package/dist/{runtime-CXPlkZzs.d.mts → runtime-j-tmFL65.d.mts} +2 -2
- package/dist/{runtime-Dn_n-29S.d.cts.map → runtime-j-tmFL65.d.mts.map} +1 -1
- package/dist/sandbox/discover-worker.cjs +1 -1
- package/dist/sandbox/discover-worker.mjs +1 -1
- package/dist/sandbox-CL--ZDui.cjs +164 -0
- package/dist/sandbox-CL--ZDui.cjs.map +1 -0
- package/dist/sandbox-Dm247NGx.mjs +117 -0
- package/dist/sandbox-Dm247NGx.mjs.map +1 -0
- package/dist/sniffer-BSwaL-dX.cjs +2236 -0
- package/dist/sniffer-BSwaL-dX.cjs.map +1 -0
- package/dist/sniffer-BZ-OJO4Y.mjs +2068 -0
- package/dist/sniffer-BZ-OJO4Y.mjs.map +1 -0
- package/dist/sst-B2HbrDR3.mjs +430 -0
- package/dist/sst-B2HbrDR3.mjs.map +1 -0
- package/dist/sst-DcfsVH6x.cjs +453 -0
- package/dist/sst-DcfsVH6x.cjs.map +1 -0
- package/dist/{sniffer-aO_-s5vq.mjs → state-B8YwmUZU.mjs} +1204 -3236
- package/dist/state-B8YwmUZU.mjs.map +1 -0
- package/dist/{sniffer-D_SYc_pO.cjs → state-DF26e_lo.cjs} +1251 -3494
- package/dist/state-DF26e_lo.cjs.map +1 -0
- package/dist/{store-KvNQwYaB.cjs → store-CJF1Tqkq.cjs} +4 -4
- package/dist/{store-KvNQwYaB.cjs.map → store-CJF1Tqkq.cjs.map} +1 -1
- package/dist/{store-BS8A7NcA.mjs → store-CLiHnqOY.mjs} +2 -2
- package/dist/{store-BS8A7NcA.mjs.map → store-CLiHnqOY.mjs.map} +1 -1
- package/dist/target/public.cjs +6 -6
- package/dist/target/public.d.cts +3 -3
- package/dist/target/public.d.mts +3 -3
- package/dist/target/public.mjs +3 -3
- package/dist/terminal-Bs5E1oxm.cjs +88 -0
- package/dist/terminal-Bs5E1oxm.cjs.map +1 -0
- package/dist/terminal-CfbiYUbZ.mjs +84 -0
- package/dist/terminal-CfbiYUbZ.mjs.map +1 -0
- package/dist/{types-DIA7Li-J.d.cts → types-BCzMWBb6.d.mts} +84 -17
- package/dist/types-BCzMWBb6.d.mts.map +1 -0
- package/dist/{types-CfLKGfC_.d.mts → types-ByMWkXKP.d.cts} +85 -16
- package/dist/types-ByMWkXKP.d.cts.map +1 -0
- package/dist/utils-D2Nk1Nv2.cjs +205 -0
- package/dist/utils-D2Nk1Nv2.cjs.map +1 -0
- package/dist/utils-D_fxUqvU.mjs +156 -0
- package/dist/utils-D_fxUqvU.mjs.map +1 -0
- package/dist/vitest.cjs +1 -1
- package/dist/vitest.mjs +1 -1
- package/dist/workspace/index.cjs +3 -1
- package/dist/workspace/index.d.cts +3 -3
- package/dist/workspace/index.d.mts +3 -3
- package/dist/workspace/index.mjs +2 -2
- package/dist/{workspace-Kl4NG9IH.cjs → workspace-BewAHZIo.cjs} +41 -6
- package/dist/workspace-BewAHZIo.cjs.map +1 -0
- package/dist/{workspace-Dh7rr7l4.mjs → workspace-C06_pW4g.mjs} +30 -7
- package/dist/workspace-C06_pW4g.mjs.map +1 -0
- package/package.json +13 -13
- package/dist/config-BEJYKd4s.cjs +0 -648
- package/dist/config-BEJYKd4s.cjs.map +0 -1
- package/dist/config-LKKCVCnC.mjs +0 -547
- package/dist/config-LKKCVCnC.mjs.map +0 -1
- package/dist/define-CHBIEc5j.mjs.map +0 -1
- package/dist/define-Cv_QvDrg.cjs.map +0 -1
- package/dist/deploy-ABBI0lf1.cjs.map +0 -1
- package/dist/deploy-BiMrP9i7.cjs.map +0 -1
- package/dist/deploy-CG8bfFij.mjs.map +0 -1
- package/dist/deploy-CGW-QY9D.mjs.map +0 -1
- package/dist/discover-BaT6bpnA.mjs.map +0 -1
- package/dist/discover-C_6COHEv.cjs.map +0 -1
- package/dist/home-DOzBaWaL.cjs +0 -33
- package/dist/home-DOzBaWaL.cjs.map +0 -1
- package/dist/home-GI7pJl1M.mjs +0 -22
- package/dist/home-GI7pJl1M.mjs.map +0 -1
- package/dist/index-BtXDZQuF.d.cts.map +0 -1
- package/dist/index-DrKzpHl-.d.mts.map +0 -1
- package/dist/resolve-B1T0hUaR.mjs +0 -170
- package/dist/resolve-B1T0hUaR.mjs.map +0 -1
- package/dist/resolve-xytBTtG4.cjs +0 -217
- package/dist/resolve-xytBTtG4.cjs.map +0 -1
- package/dist/runtime-CMwtq0AL.cjs +0 -163
- package/dist/runtime-CMwtq0AL.cjs.map +0 -1
- package/dist/runtime-DbakJS8u.mjs +0 -134
- package/dist/runtime-DbakJS8u.mjs.map +0 -1
- package/dist/sniffer-D_SYc_pO.cjs.map +0 -1
- package/dist/sniffer-aO_-s5vq.mjs.map +0 -1
- package/dist/terminal-D0f1u0LG.cjs +0 -576
- package/dist/terminal-D0f1u0LG.cjs.map +0 -1
- package/dist/terminal-gMxAo9Dj.mjs +0 -517
- package/dist/terminal-gMxAo9Dj.mjs.map +0 -1
- package/dist/types-CfLKGfC_.d.mts.map +0 -1
- package/dist/types-DIA7Li-J.d.cts.map +0 -1
- package/dist/workspace-Dh7rr7l4.mjs.map +0 -1
- package/dist/workspace-Kl4NG9IH.cjs.map +0 -1
|
@@ -0,0 +1,1982 @@
|
|
|
1
|
+
const require_rolldown_runtime = require("./rolldown-runtime-C0BPl7ul.cjs");
|
|
2
|
+
const require_local = require("./local-BdH4XN0E.cjs");
|
|
3
|
+
const require_discover = require("./discover-DHdECueV.cjs");
|
|
4
|
+
const require_sandbox = require("./sandbox-CL--ZDui.cjs");
|
|
5
|
+
const require_derive = require("./derive-DAbo49sm.cjs");
|
|
6
|
+
const require_define = require("./define-DzRz8FgH.cjs");
|
|
7
|
+
const require_storage = require("./storage-CSGoC3bK.cjs");
|
|
8
|
+
const require_store = require("./store-CJF1Tqkq.cjs");
|
|
9
|
+
const require_state = require("./state-DF26e_lo.cjs");
|
|
10
|
+
const require_fakes = require("./fakes-zW-jNAbQ.cjs");
|
|
11
|
+
let node_path = require("node:path");
|
|
12
|
+
let node_fs = require("node:fs");
|
|
13
|
+
let node_fs_promises = require("node:fs/promises");
|
|
14
|
+
let _geekmidas_manifest = require("@geekmidas/manifest");
|
|
15
|
+
let node_module = require("node:module");
|
|
16
|
+
let node_url = require("node:url");
|
|
17
|
+
let node_child_process = require("node:child_process");
|
|
18
|
+
let pg = require("pg");
|
|
19
|
+
pg = require_rolldown_runtime.__toESM(pg, 1);
|
|
20
|
+
let yaml = require("yaml");
|
|
21
|
+
let node_https = require("node:https");
|
|
22
|
+
//#region src/compose/docker.ts
|
|
23
|
+
/**
|
|
24
|
+
* The Docker operations `gkm compose` needs, behind an interface.
|
|
25
|
+
*
|
|
26
|
+
* Small on purpose, like reconcile's: the rules about *when* each runs — the
|
|
27
|
+
* registry is asked before anything is pulled, nothing is started for a tag
|
|
28
|
+
* that is missing — are asserted against a fake, and this file is only the
|
|
29
|
+
* wiring to the real CLI.
|
|
30
|
+
*
|
|
31
|
+
* Every call is an argument array. Image refs, tags and paths come from the
|
|
32
|
+
* workspace, and none of them reaches a shell.
|
|
33
|
+
*/
|
|
34
|
+
/** `output` as `spawn` reads it. */
|
|
35
|
+
const STDIO = {
|
|
36
|
+
inherit: "inherit",
|
|
37
|
+
stderr: [
|
|
38
|
+
"ignore",
|
|
39
|
+
2,
|
|
40
|
+
2
|
|
41
|
+
],
|
|
42
|
+
ignore: "ignore"
|
|
43
|
+
};
|
|
44
|
+
/** How a stack's docker commands run: where it is, where output goes. */
|
|
45
|
+
function runOptions(stack, env) {
|
|
46
|
+
return {
|
|
47
|
+
cwd: stack.cwd,
|
|
48
|
+
stdio: STDIO[stack.output ?? "inherit"],
|
|
49
|
+
...env ? { env } : {},
|
|
50
|
+
...stack.signal ? { signal: stack.signal } : {}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** How long a stack has to pass its health checks. */
|
|
54
|
+
const WAIT_TIMEOUT_S = 180;
|
|
55
|
+
/** `docker compose port` printed nothing a port can be read from. */
|
|
56
|
+
var ServicePortUnknown = class extends Error {
|
|
57
|
+
service;
|
|
58
|
+
inside;
|
|
59
|
+
output;
|
|
60
|
+
constructor(service, inside, output) {
|
|
61
|
+
super(`'${service}' publishes no host port for ${inside} (docker said: ${output.trim() || "nothing"}). Is it running? \`docker compose ps\` shows the stack.`);
|
|
62
|
+
this.service = service;
|
|
63
|
+
this.inside = inside;
|
|
64
|
+
this.output = output;
|
|
65
|
+
this.name = "ServicePortUnknown";
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
function compose(stack, args) {
|
|
69
|
+
return [
|
|
70
|
+
"compose",
|
|
71
|
+
"-p",
|
|
72
|
+
stack.project,
|
|
73
|
+
"-f",
|
|
74
|
+
stack.file,
|
|
75
|
+
...args
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
/** Run a program and keep everything it said, whatever it exits with. */
|
|
79
|
+
function capture(command, args, timeoutMs = 6e4) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const child = (0, node_child_process.spawn)(command, [...args], {
|
|
82
|
+
stdio: [
|
|
83
|
+
"ignore",
|
|
84
|
+
"pipe",
|
|
85
|
+
"pipe"
|
|
86
|
+
],
|
|
87
|
+
shell: false
|
|
88
|
+
});
|
|
89
|
+
let stdout = "";
|
|
90
|
+
let stderr = "";
|
|
91
|
+
child.stdout.on("data", (chunk) => {
|
|
92
|
+
stdout += chunk.toString();
|
|
93
|
+
});
|
|
94
|
+
child.stderr.on("data", (chunk) => {
|
|
95
|
+
stderr += chunk.toString();
|
|
96
|
+
});
|
|
97
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
98
|
+
timer.unref?.();
|
|
99
|
+
child.on("error", (error) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reject(error);
|
|
102
|
+
});
|
|
103
|
+
child.on("close", (code) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
resolve({
|
|
106
|
+
code,
|
|
107
|
+
stdout,
|
|
108
|
+
stderr
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether a failed `docker manifest inspect` means the tag is not there, as
|
|
115
|
+
* opposed to the registry not answering.
|
|
116
|
+
*
|
|
117
|
+
* Read off the message because that is all the CLI gives. Anything not
|
|
118
|
+
* recognisably "no such tag" is treated as the registry being unreachable:
|
|
119
|
+
* a registry that refused credentials must not be reported as a tag that was
|
|
120
|
+
* never pushed, or the fix offered would be the wrong one.
|
|
121
|
+
*/
|
|
122
|
+
function isMissingManifest(output) {
|
|
123
|
+
return /no such manifest|manifest unknown|manifest for .* not found|not found: manifest/i.test(output);
|
|
124
|
+
}
|
|
125
|
+
const dockerCompose = {
|
|
126
|
+
async lookup(ref) {
|
|
127
|
+
const { code, stdout, stderr } = await capture("docker", [
|
|
128
|
+
"manifest",
|
|
129
|
+
"inspect",
|
|
130
|
+
ref
|
|
131
|
+
]);
|
|
132
|
+
if (code === 0) return {
|
|
133
|
+
ref,
|
|
134
|
+
status: "found"
|
|
135
|
+
};
|
|
136
|
+
const output = `${stderr}\n${stdout}`.trim();
|
|
137
|
+
return isMissingManifest(output) ? {
|
|
138
|
+
ref,
|
|
139
|
+
status: "missing"
|
|
140
|
+
} : {
|
|
141
|
+
ref,
|
|
142
|
+
status: "unreachable",
|
|
143
|
+
detail: output
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
async build(stack, services) {
|
|
147
|
+
await require_local.run("docker", compose(stack, ["build", ...services]), runOptions(stack, {
|
|
148
|
+
...process.env,
|
|
149
|
+
DOCKER_BUILDKIT: "1"
|
|
150
|
+
}));
|
|
151
|
+
},
|
|
152
|
+
async pull(stack, services) {
|
|
153
|
+
await require_local.run("docker", compose(stack, ["pull", ...services]), runOptions(stack));
|
|
154
|
+
},
|
|
155
|
+
async up(stack, services) {
|
|
156
|
+
await require_local.run("docker", compose(stack, [
|
|
157
|
+
"up",
|
|
158
|
+
"-d",
|
|
159
|
+
"--wait",
|
|
160
|
+
`--wait-timeout=${WAIT_TIMEOUT_S}`,
|
|
161
|
+
...services ? [] : ["--remove-orphans"],
|
|
162
|
+
...services ?? []
|
|
163
|
+
]), runOptions(stack));
|
|
164
|
+
},
|
|
165
|
+
async down(stack) {
|
|
166
|
+
await require_local.run("docker", compose(stack, ["down"]), runOptions(stack));
|
|
167
|
+
},
|
|
168
|
+
async port(stack, service, inside) {
|
|
169
|
+
const output = await require_local.runOutput("docker", compose(stack, [
|
|
170
|
+
"port",
|
|
171
|
+
service,
|
|
172
|
+
String(inside)
|
|
173
|
+
]), {
|
|
174
|
+
cwd: stack.cwd,
|
|
175
|
+
...stack.signal ? { signal: stack.signal } : {}
|
|
176
|
+
});
|
|
177
|
+
const port = Number(output.trim().split(":").pop());
|
|
178
|
+
if (!Number.isInteger(port) || port <= 0) throw new ServicePortUnknown(service, inside, output);
|
|
179
|
+
return port;
|
|
180
|
+
},
|
|
181
|
+
async copyOut(stack, service, from, to) {
|
|
182
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(to), { recursive: true });
|
|
183
|
+
await require_local.run("docker", compose(stack, [
|
|
184
|
+
"cp",
|
|
185
|
+
`${service}:${from}`,
|
|
186
|
+
to
|
|
187
|
+
]), runOptions(stack));
|
|
188
|
+
},
|
|
189
|
+
async digest(ref) {
|
|
190
|
+
const { code, stdout } = await capture("docker", [
|
|
191
|
+
"image",
|
|
192
|
+
"inspect",
|
|
193
|
+
"--format={{json .RepoDigests}} {{.Id}}",
|
|
194
|
+
ref
|
|
195
|
+
]);
|
|
196
|
+
if (code !== 0) return void 0;
|
|
197
|
+
const [json = "[]", id] = stdout.trim().split(" ");
|
|
198
|
+
const repository = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
199
|
+
try {
|
|
200
|
+
const digests = JSON.parse(json);
|
|
201
|
+
const match = Array.isArray(digests) ? digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`)) : void 0;
|
|
202
|
+
if (match) return match.slice(repository.length + 1);
|
|
203
|
+
} catch {}
|
|
204
|
+
return id || void 0;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/compose/images.ts
|
|
209
|
+
/**
|
|
210
|
+
* The tag a site's image carries for a release.
|
|
211
|
+
*
|
|
212
|
+
* A site's public URLs are inlined when it is built, so one bundle serves one
|
|
213
|
+
* stage: `v1.4.0-production` and `v1.4.0-staging` are two images of the same
|
|
214
|
+
* commit. A backend reads its URLs at runtime, so one image serves every stage
|
|
215
|
+
* and its tag is the release's alone.
|
|
216
|
+
*/
|
|
217
|
+
function siteTag(tag, stage) {
|
|
218
|
+
return `${tag}-${stage}`;
|
|
219
|
+
}
|
|
220
|
+
/** Images a tag names that the registry does not have. */
|
|
221
|
+
var ImageTagNotFound = class extends Error {
|
|
222
|
+
tag;
|
|
223
|
+
refs;
|
|
224
|
+
constructor(tag, refs) {
|
|
225
|
+
super(`The registry has no image for ${refs.length === 1 ? "this app" : `${refs.length} apps`} at '${tag}':\n` + refs.map((ref) => ` - ${ref}`).join("\n") + `\nNothing was pulled or started. Push '${tag}' for every app from CI, pick a tag that exists, or run without --tag to build from this checkout.`);
|
|
226
|
+
this.tag = tag;
|
|
227
|
+
this.refs = refs;
|
|
228
|
+
this.name = "ImageTagNotFound";
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
/** The registry did not answer, or did not let this machine in. */
|
|
232
|
+
var RegistryUnreachable = class extends Error {
|
|
233
|
+
ref;
|
|
234
|
+
detail;
|
|
235
|
+
constructor(ref, detail) {
|
|
236
|
+
super(`Could not ask the registry about ${ref}: ${detail || "no answer"}\nNothing was pulled or started. Check the registry is reachable and that this machine is logged in to it (\`docker login <registry>\`).`);
|
|
237
|
+
this.ref = ref;
|
|
238
|
+
this.detail = detail;
|
|
239
|
+
this.name = "RegistryUnreachable";
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* Assert every image exists, before anything else happens.
|
|
244
|
+
*
|
|
245
|
+
* Asked all at once and reported all at once: a release missing two images is
|
|
246
|
+
* one problem with two names, and finding the second only after fixing the
|
|
247
|
+
* first is a slower way to learn the same thing.
|
|
248
|
+
*
|
|
249
|
+
* @throws {RegistryUnreachable} when the registry cannot say
|
|
250
|
+
* @throws {ImageTagNotFound} listing every missing image
|
|
251
|
+
*/
|
|
252
|
+
async function assertImagesExist(docker, tag, images) {
|
|
253
|
+
const lookups = await Promise.all(images.map((image) => docker.lookup(image.ref)));
|
|
254
|
+
const unreachable = lookups.find((lookup) => lookup.status === "unreachable");
|
|
255
|
+
if (unreachable?.status === "unreachable") throw new RegistryUnreachable(unreachable.ref, unreachable.detail);
|
|
256
|
+
const missing = lookups.filter((lookup) => lookup.status === "missing").map((lookup) => lookup.ref);
|
|
257
|
+
if (missing.length > 0) throw new ImageTagNotFound(tag, missing);
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/compose/caddyfile.ts
|
|
261
|
+
/** The Caddyfile for a stack's edge. */
|
|
262
|
+
function edgeCaddyfile(sites, options) {
|
|
263
|
+
const global = options.local ? `{
|
|
264
|
+
# No ACME and no public DNS: the CA is Caddy's own and the names resolve
|
|
265
|
+
# to this machine.
|
|
266
|
+
local_certs
|
|
267
|
+
auto_https disable_redirects
|
|
268
|
+
}` : options.email ? `{
|
|
269
|
+
email ${options.email}
|
|
270
|
+
}` : "";
|
|
271
|
+
const blocks = sites.map(({ host, upstream }) => `https://${host} {${options.local ? `
|
|
272
|
+
tls internal
|
|
273
|
+
` : ""}
|
|
274
|
+
# Host and X-Forwarded-* are passed through as Caddy does by default, so
|
|
275
|
+
# an app builds redirects, cookies and links on the address a caller used.
|
|
276
|
+
reverse_proxy ${upstream} {
|
|
277
|
+
# Never buffer: a streamed response — server-sent events, a Next.js
|
|
278
|
+
# RSC payload — reaches the client as each chunk is written.
|
|
279
|
+
flush_interval -1
|
|
280
|
+
}
|
|
281
|
+
}`);
|
|
282
|
+
return `${HEADER}${global ? `${global}\n\n` : ""}${blocks.join("\n\n")}\n`;
|
|
283
|
+
}
|
|
284
|
+
const HEADER = `# Generated by gkm compose from the construct manifest — do not edit.
|
|
285
|
+
#
|
|
286
|
+
# One host per API and site, each in front of its own container.
|
|
287
|
+
`;
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/compose/stack.ts
|
|
290
|
+
/**
|
|
291
|
+
* The stack a workspace runs as for one stage — pure.
|
|
292
|
+
*
|
|
293
|
+
* Everything `gkm compose` writes is decided here, as data: the compose file,
|
|
294
|
+
* the Caddyfile, one env file per backend, and the image each app runs. The
|
|
295
|
+
* command around it reads the workspace, asks the registry, writes the files
|
|
296
|
+
* and starts Docker; none of those decide anything, so all of it can be
|
|
297
|
+
* asserted without a daemon.
|
|
298
|
+
*
|
|
299
|
+
* The values come from the same derivations every other target uses. The
|
|
300
|
+
* plan is reconcile's `planFor`, the keys an app reads are `appEnvKeys`, and
|
|
301
|
+
* every URL is `envFor`'s — asked twice, once with each app at its public
|
|
302
|
+
* address and once on the compose network, because a browser and a sibling
|
|
303
|
+
* service reach the same API by different names.
|
|
304
|
+
*/
|
|
305
|
+
/** Where a stack's files are written, relative to the workspace root. */
|
|
306
|
+
function stackDir(stage) {
|
|
307
|
+
return `.gkm/compose/${stage}`;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The compose project a stage's stack runs as.
|
|
311
|
+
*
|
|
312
|
+
* The deploy identity's scope, so two workspaces with one name in different
|
|
313
|
+
* namespaces never share containers or volumes — and the stage, so a
|
|
314
|
+
* machine can run two stages side by side.
|
|
315
|
+
*/
|
|
316
|
+
function composeProject(identity) {
|
|
317
|
+
return `${identity.scope}-${identity.stage}`;
|
|
318
|
+
}
|
|
319
|
+
/** The ports the edge is published on, and the variables that move them. */
|
|
320
|
+
const EDGE_PORT_ENV = {
|
|
321
|
+
https: "GKM_COMPOSE_HTTPS_PORT",
|
|
322
|
+
http: "GKM_COMPOSE_HTTP_PORT"
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* Containers this target does not run.
|
|
326
|
+
*
|
|
327
|
+
* Object storage is the stage's own — an externally hosted bucket whose URL
|
|
328
|
+
* the stage's secrets hold — and so is a deployed stage's mail. The AWS
|
|
329
|
+
* emulator never applies: a stack is a server target, whose events are
|
|
330
|
+
* pg-boss.
|
|
331
|
+
*/
|
|
332
|
+
const NOT_RUN = {
|
|
333
|
+
minio: true,
|
|
334
|
+
localstack: true,
|
|
335
|
+
caddy: true
|
|
336
|
+
};
|
|
337
|
+
/** Kinds whose `<ID>_URL` a stage must be given, because nothing here runs them. */
|
|
338
|
+
const EXTERNAL_KINDS = {
|
|
339
|
+
objects: true,
|
|
340
|
+
"file-server": true
|
|
341
|
+
};
|
|
342
|
+
/** The S3 client's credentials, read beside a bucket's URL when it has them. */
|
|
343
|
+
const STORAGE_KEYS = [
|
|
344
|
+
"AWS_ACCESS_KEY_ID",
|
|
345
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
346
|
+
"AWS_REGION"
|
|
347
|
+
];
|
|
348
|
+
/** A bucket the stack needs and the stage was not given. */
|
|
349
|
+
var BucketNotConfigured = class extends Error {
|
|
350
|
+
app;
|
|
351
|
+
id;
|
|
352
|
+
key;
|
|
353
|
+
stage;
|
|
354
|
+
constructor(app, id, key, stage) {
|
|
355
|
+
super(`'${app}' uses the bucket '${id}', and gkm compose runs no object storage: the stage needs an externally hosted bucket. Set its URL in the stage's secrets: gkm secrets:set ${key} 's3://…' --stage ${stage}`);
|
|
356
|
+
this.app = app;
|
|
357
|
+
this.id = id;
|
|
358
|
+
this.key = key;
|
|
359
|
+
this.stage = stage;
|
|
360
|
+
this.name = "BucketNotConfigured";
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
/** A value only the stage's secrets can hold, and they do not. */
|
|
364
|
+
var StageSecretMissing = class extends Error {
|
|
365
|
+
app;
|
|
366
|
+
key;
|
|
367
|
+
stage;
|
|
368
|
+
constructor(app, key, stage) {
|
|
369
|
+
super(`'${app}' reads ${key}, and the stage '${stage}' has none. Nothing in the stack can derive it; set it in the stage's secrets: gkm secrets:set ${key} '…' --stage ${stage}`);
|
|
370
|
+
this.app = app;
|
|
371
|
+
this.key = key;
|
|
372
|
+
this.stage = stage;
|
|
373
|
+
this.name = "StageSecretMissing";
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
/** A deployed stage's secrets with no seed to derive its passwords from. */
|
|
377
|
+
var StageSeedMissing = class extends Error {
|
|
378
|
+
stage;
|
|
379
|
+
constructor(stage) {
|
|
380
|
+
super(`The stage '${stage}' has no seed in its secrets, and a deployed stage's database passwords are derived from it. Run gkm compose without --dry-run once, or gkm deploy, to generate it.`);
|
|
381
|
+
this.stage = stage;
|
|
382
|
+
this.name = "StageSeedMissing";
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
/** A workspace with no API and no site to run. */
|
|
386
|
+
var NothingToCompose = class extends Error {
|
|
387
|
+
root;
|
|
388
|
+
constructor(root) {
|
|
389
|
+
super(`${root} declares no RestApi and no site, so a stack would run nothing. Workers and mobile apps are not run by gkm compose yet.`);
|
|
390
|
+
this.root = root;
|
|
391
|
+
this.name = "NothingToCompose";
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
/** A value that cannot be written to an env file a line at a time. */
|
|
395
|
+
var EnvValueMultiline = class extends Error {
|
|
396
|
+
app;
|
|
397
|
+
key;
|
|
398
|
+
constructor(app, key) {
|
|
399
|
+
super(`${key} for '${app}' spans more than one line, and an env file holds one value per line. Store it on one line — base64, or JSON — and decode it where it is read.`);
|
|
400
|
+
this.app = app;
|
|
401
|
+
this.key = key;
|
|
402
|
+
this.name = "EnvValueMultiline";
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
/** The services a stack's apps wait on and the edge waits on in turn. */
|
|
406
|
+
const HEALTHY = { condition: "service_healthy" };
|
|
407
|
+
/**
|
|
408
|
+
* An app's health check: its own server, asked on 127.0.0.1 — not
|
|
409
|
+
* `localhost`, which resolves to `::1` first in an Alpine container while a
|
|
410
|
+
* server may listen on IPv4 alone.
|
|
411
|
+
*/
|
|
412
|
+
function probe(port, path) {
|
|
413
|
+
return {
|
|
414
|
+
test: [
|
|
415
|
+
"CMD",
|
|
416
|
+
"wget",
|
|
417
|
+
"-q",
|
|
418
|
+
"-O",
|
|
419
|
+
"/dev/null",
|
|
420
|
+
`http://127.0.0.1:${port}${path}`
|
|
421
|
+
],
|
|
422
|
+
interval: "5s",
|
|
423
|
+
timeout: "3s",
|
|
424
|
+
retries: 20,
|
|
425
|
+
start_period: "10s"
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/** The stack for one stage. */
|
|
429
|
+
function composeStack(input) {
|
|
430
|
+
const { workspace, manifest, stage, identity } = input;
|
|
431
|
+
const local = stage === workspace.stages.local;
|
|
432
|
+
const project = composeProject(identity);
|
|
433
|
+
const custom = input.secrets?.custom ?? {};
|
|
434
|
+
const https = input.ports?.https ?? 443;
|
|
435
|
+
const http = input.ports?.http ?? 80;
|
|
436
|
+
const plan = require_state.planFor(manifest, stage, (0, _geekmidas_manifest.provisionOrder)(manifest), {
|
|
437
|
+
localStage: workspace.stages.local,
|
|
438
|
+
events: require_fakes.DEFAULT_EVENTS.server,
|
|
439
|
+
cache: require_fakes.DEFAULT_CACHE.server,
|
|
440
|
+
edge: false
|
|
441
|
+
});
|
|
442
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
443
|
+
const seed = local ? void 0 : input.secrets?.seed;
|
|
444
|
+
if (!local && !seed) throw new StageSeedMissing(stage);
|
|
445
|
+
const credential = {
|
|
446
|
+
master: local ? "geekmidas" : require_state.localRolePassword(workspace.name, plan, "master", seed),
|
|
447
|
+
...seed ? { seed } : {}
|
|
448
|
+
};
|
|
449
|
+
const infra = plan.containers.filter((c) => !NOT_RUN[c]).filter((c) => local || c !== "mailpit").sort();
|
|
450
|
+
const apps = stackApps(workspace, manifest, plan, {
|
|
451
|
+
local,
|
|
452
|
+
stage,
|
|
453
|
+
https,
|
|
454
|
+
identity,
|
|
455
|
+
images: input.images
|
|
456
|
+
});
|
|
457
|
+
if (apps.length === 0) throw new NothingToCompose(workspace.root);
|
|
458
|
+
const derivation = {
|
|
459
|
+
project: workspace.name,
|
|
460
|
+
master: credential.master,
|
|
461
|
+
...credential.seed ? { seed: credential.seed } : {}
|
|
462
|
+
};
|
|
463
|
+
const outside = require_state.networkEnv(plan, {
|
|
464
|
+
...derivation,
|
|
465
|
+
addresses: Object.fromEntries(apps.map((app) => [app.id, app.url]))
|
|
466
|
+
});
|
|
467
|
+
const inside = require_state.networkEnv(plan, {
|
|
468
|
+
...derivation,
|
|
469
|
+
addresses: Object.fromEntries(apps.map((app) => [app.id, `http://${app.name}:${app.port}`]))
|
|
470
|
+
});
|
|
471
|
+
const owners = /* @__PURE__ */ new Map();
|
|
472
|
+
for (const resource of plan.resources) {
|
|
473
|
+
owners.set(resource.envKey, resource);
|
|
474
|
+
if (resource.kind === "email") {
|
|
475
|
+
owners.set((0, _geekmidas_manifest.provideKey)(resource.id, "from"), resource);
|
|
476
|
+
owners.set((0, _geekmidas_manifest.provideKey)(resource.id, "inboxUrl"), resource);
|
|
477
|
+
}
|
|
478
|
+
if (resource.kind === "external-api") owners.set((0, _geekmidas_manifest.provideKey)(resource.id, "credentials"), resource);
|
|
479
|
+
}
|
|
480
|
+
const surfaceUrls = new Map(plan.resources.filter((r) => r.kind === "rest-api" || r.kind === "site").map((r) => [r.envKey, r.id]));
|
|
481
|
+
const appById = new Map(apps.map((app) => [app.id, app]));
|
|
482
|
+
/**
|
|
483
|
+
* What a key resolves to for one app, from the stage's secrets where only
|
|
484
|
+
* they can hold it, and the derivations otherwise.
|
|
485
|
+
*/
|
|
486
|
+
const valueFor = (app, key, owner) => {
|
|
487
|
+
if (owner && EXTERNAL_KINDS[owner.kind] && key === owner.envKey) {
|
|
488
|
+
const value = custom[key];
|
|
489
|
+
if (value === void 0) throw new BucketNotConfigured(app.name, owner.id, key, stage);
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
if (owner && suppliedBy(owner, key, local)) {
|
|
493
|
+
const value = custom[key];
|
|
494
|
+
if (value === void 0) throw new StageSecretMissing(app.name, key, stage);
|
|
495
|
+
return value;
|
|
496
|
+
}
|
|
497
|
+
if (custom[key] !== void 0) return custom[key];
|
|
498
|
+
const surface = surfaceUrls.get(key);
|
|
499
|
+
if (app.kind === "rest-api" && surface && surface !== app.id) return inside[key];
|
|
500
|
+
return outside[key];
|
|
501
|
+
};
|
|
502
|
+
for (const app of apps) {
|
|
503
|
+
if (app.kind === "site") {
|
|
504
|
+
const site = manifest[app.id];
|
|
505
|
+
if (site?.kind !== "site") continue;
|
|
506
|
+
const args = {};
|
|
507
|
+
for (const [key, source] of Object.entries((0, _geekmidas_manifest.publicEnvFor)(site, manifest))) {
|
|
508
|
+
const value = valueFor(app, source, owners.get(source));
|
|
509
|
+
if (value !== void 0) args[key] = value;
|
|
510
|
+
}
|
|
511
|
+
if (app.build && Object.keys(args).length > 0) app.build.args = args;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const keys = require_state.appEnvKeys(manifest, app.name, input.runnables) ?? /* @__PURE__ */ new Set();
|
|
515
|
+
const values = {};
|
|
516
|
+
for (const key of [...keys].sort()) {
|
|
517
|
+
const owner = owners.get(key) ?? storageOwner(key, plan);
|
|
518
|
+
if (!local && owner?.kind === "email" && key.endsWith("_INBOX_URL")) continue;
|
|
519
|
+
if (STORAGE_KEYS.includes(key)) {
|
|
520
|
+
if (custom[key] !== void 0) values[key] = custom[key];
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const value = valueFor(app, key, owner);
|
|
524
|
+
if (value !== void 0) values[key] = value;
|
|
525
|
+
}
|
|
526
|
+
const trusted = (0, _geekmidas_manifest.provideKey)(app.id, "trustedOrigins");
|
|
527
|
+
if (keys.has(trusted)) {
|
|
528
|
+
const internal = (byId.get(app.id)?.callers ?? []).map((caller) => appById.get(caller)).filter((caller) => caller?.kind === "rest-api").map((caller) => `http://${caller.name}:${caller.port}`);
|
|
529
|
+
const origins = [...(values[trusted] ?? "").split(",").filter(Boolean), ...internal];
|
|
530
|
+
values[trusted] = [...new Set(origins)].join(",");
|
|
531
|
+
}
|
|
532
|
+
app.env = {
|
|
533
|
+
...values,
|
|
534
|
+
NODE_ENV: "production",
|
|
535
|
+
PORT: String(app.port),
|
|
536
|
+
STAGE: stage
|
|
537
|
+
};
|
|
538
|
+
for (const [key, value] of Object.entries(app.env)) if (/[\r\n]/.test(value)) throw new EnvValueMultiline(app.name, key);
|
|
539
|
+
}
|
|
540
|
+
const compose = stackFile({
|
|
541
|
+
project,
|
|
542
|
+
plan,
|
|
543
|
+
infra,
|
|
544
|
+
apps,
|
|
545
|
+
master: credential.master,
|
|
546
|
+
https,
|
|
547
|
+
http
|
|
548
|
+
});
|
|
549
|
+
const caddyfile = edgeCaddyfile(apps.map((app) => ({
|
|
550
|
+
host: app.host,
|
|
551
|
+
upstream: `${app.name}:${app.port}`
|
|
552
|
+
})), { local });
|
|
553
|
+
const dockerfiles = {};
|
|
554
|
+
for (const app of apps) {
|
|
555
|
+
if (!app.build) continue;
|
|
556
|
+
dockerfiles[app.build.dockerfile] = app.kind === "site" ? require_state.siteDockerfile(app.name, workspace.apps[app.name], {
|
|
557
|
+
turboPackage: input.packages?.[app.name] ?? app.name,
|
|
558
|
+
packageManager: input.packageManager ?? "pnpm",
|
|
559
|
+
manifest
|
|
560
|
+
}) : require_state.generateSlimDockerfile({
|
|
561
|
+
imageName: app.name,
|
|
562
|
+
baseImage: "node:22-alpine",
|
|
563
|
+
port: app.port,
|
|
564
|
+
healthCheckPath: "/health",
|
|
565
|
+
prebuilt: true,
|
|
566
|
+
packageManager: input.packageManager ?? "pnpm",
|
|
567
|
+
bundle: `${app.path}/.gkm/server/dist/server.mjs`
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
project,
|
|
572
|
+
stage,
|
|
573
|
+
local,
|
|
574
|
+
apps,
|
|
575
|
+
infra,
|
|
576
|
+
compose,
|
|
577
|
+
caddyfile,
|
|
578
|
+
dockerfiles,
|
|
579
|
+
plan,
|
|
580
|
+
credential
|
|
581
|
+
};
|
|
582
|
+
/** Whether only the stage's secrets can supply `key` to `owner`. */
|
|
583
|
+
function suppliedBy(owner, key, isLocal) {
|
|
584
|
+
if (owner.kind === "credential") return true;
|
|
585
|
+
if (owner.kind === "external-api") return key === (0, _geekmidas_manifest.provideKey)(owner.id, "credentials");
|
|
586
|
+
if (isLocal) return false;
|
|
587
|
+
if (owner.kind === "secret" || owner.kind === "encryption") return true;
|
|
588
|
+
return owner.kind === "email" && !key.endsWith("_INBOX_URL");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** Which bucket an S3 credential key belongs to — the first one declared. */
|
|
592
|
+
function storageOwner(key, plan) {
|
|
593
|
+
if (!STORAGE_KEYS.includes(key)) return void 0;
|
|
594
|
+
return plan.resources.find((r) => r.kind === "objects");
|
|
595
|
+
}
|
|
596
|
+
/** The apps a stack runs: every API and every site, at its stage address. */
|
|
597
|
+
function stackApps(workspace, manifest, plan, options) {
|
|
598
|
+
const apps = [];
|
|
599
|
+
const resources = new Map(plan.resources.map((r) => [r.id, r]));
|
|
600
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
601
|
+
if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
|
|
602
|
+
const name = require_derive.appKey(id);
|
|
603
|
+
const app = workspace.apps[name];
|
|
604
|
+
const resource = resources.get(id);
|
|
605
|
+
if (!app || !resource || !app.port) continue;
|
|
606
|
+
const host = hostOf(name, app, workspace, resource, options);
|
|
607
|
+
const tag = declaration.kind === "site" ? siteTag(options.images.tag, options.stage) : options.images.tag;
|
|
608
|
+
const ref = require_state.validateImageRef(require_store.imageRef(options.identity, name, options.images.registry, tag));
|
|
609
|
+
apps.push({
|
|
610
|
+
app: name,
|
|
611
|
+
name,
|
|
612
|
+
id,
|
|
613
|
+
kind: declaration.kind,
|
|
614
|
+
path: app.path,
|
|
615
|
+
port: app.port,
|
|
616
|
+
host,
|
|
617
|
+
url: `https://${host}${options.local && options.https !== 443 ? `:${options.https}` : ""}`,
|
|
618
|
+
ref,
|
|
619
|
+
tag,
|
|
620
|
+
...options.images.mode === "build" ? { build: { dockerfile: `${stackDir(options.stage)}/Dockerfile.${name}` } } : {}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return apps.sort((a, b) => a.name.localeCompare(b.name));
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The host an app answers on for a stage: the `gkm dev` edge's
|
|
627
|
+
* `*.localhost` names for the local stage, and the stage's domain — by the
|
|
628
|
+
* same rule a deploy follows — for any other.
|
|
629
|
+
*/
|
|
630
|
+
function hostOf(name, app, workspace, resource, options) {
|
|
631
|
+
if (options.local) return require_state.hostFor(resource, workspace.name);
|
|
632
|
+
return require_state.resolveHost(name, app, options.stage, workspace.deploy?.domains, require_state.isMainFrontendApp(name, app, workspace.apps));
|
|
633
|
+
}
|
|
634
|
+
/** The compose document: the infrastructure, the apps, and the edge. */
|
|
635
|
+
function stackFile(options) {
|
|
636
|
+
const { project, plan, infra, apps } = options;
|
|
637
|
+
const derived = require_state.composeFor({
|
|
638
|
+
...plan,
|
|
639
|
+
containers: [...infra]
|
|
640
|
+
}, {
|
|
641
|
+
project,
|
|
642
|
+
ports: Object.fromEntries(require_state.portKeys(infra, plan.fakes).map((key) => [key, 0]))
|
|
643
|
+
});
|
|
644
|
+
const services = {};
|
|
645
|
+
const volumes = {
|
|
646
|
+
...derived.volumes,
|
|
647
|
+
"caddy-data": {},
|
|
648
|
+
"caddy-config": {}
|
|
649
|
+
};
|
|
650
|
+
for (const name of infra) {
|
|
651
|
+
const { ports: _ports, depends_on, profiles: _profiles, ...service } = derived.services[name];
|
|
652
|
+
services[name] = {
|
|
653
|
+
...service,
|
|
654
|
+
...depends_on?.length ? { depends_on: Object.fromEntries(depends_on.map((dep) => [dep, HEALTHY])) } : {}
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
const postgres = services.postgres;
|
|
658
|
+
if (postgres) {
|
|
659
|
+
postgres.environment = {
|
|
660
|
+
...postgres.environment,
|
|
661
|
+
POSTGRES_PASSWORD: options.master
|
|
662
|
+
};
|
|
663
|
+
postgres.ports = ["127.0.0.1::5432"];
|
|
664
|
+
}
|
|
665
|
+
for (const app of apps) services[app.name] = {
|
|
666
|
+
image: app.ref,
|
|
667
|
+
...app.build ? { build: {
|
|
668
|
+
context: "../../..",
|
|
669
|
+
dockerfile: app.build.dockerfile,
|
|
670
|
+
...app.build.args ? { args: app.build.args } : {}
|
|
671
|
+
} } : {},
|
|
672
|
+
restart: "unless-stopped",
|
|
673
|
+
...app.kind === "rest-api" ? {
|
|
674
|
+
env_file: [{
|
|
675
|
+
path: `./${app.name}.env`,
|
|
676
|
+
format: "raw"
|
|
677
|
+
}],
|
|
678
|
+
...infra.length ? { depends_on: Object.fromEntries(infra.map((name) => [name, HEALTHY])) } : {}
|
|
679
|
+
} : {},
|
|
680
|
+
healthcheck: probe(app.port, app.kind === "site" ? "/" : "/health")
|
|
681
|
+
};
|
|
682
|
+
services.caddy = {
|
|
683
|
+
image: require_state.DEFAULT_IMAGES.caddy,
|
|
684
|
+
restart: "unless-stopped",
|
|
685
|
+
ports: [`${options.https}:443`, `${options.http}:80`],
|
|
686
|
+
volumes: [
|
|
687
|
+
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
|
688
|
+
"caddy-data:/data",
|
|
689
|
+
"caddy-config:/config"
|
|
690
|
+
],
|
|
691
|
+
depends_on: Object.fromEntries(apps.map((app) => [app.name, HEALTHY])),
|
|
692
|
+
healthcheck: {
|
|
693
|
+
test: [
|
|
694
|
+
"CMD",
|
|
695
|
+
"caddy",
|
|
696
|
+
"version"
|
|
697
|
+
],
|
|
698
|
+
interval: "10s",
|
|
699
|
+
timeout: "5s",
|
|
700
|
+
retries: 5
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
return {
|
|
704
|
+
name: project,
|
|
705
|
+
services,
|
|
706
|
+
volumes
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
/** Render an app's env file: one `KEY=value` per line, read raw by compose. */
|
|
710
|
+
function envFile(env) {
|
|
711
|
+
return `${Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
|
|
712
|
+
}
|
|
713
|
+
//#endregion
|
|
714
|
+
//#region src/migrate/provider.ts
|
|
715
|
+
/**
|
|
716
|
+
* One construct's migrations and seeds, read from its folders.
|
|
717
|
+
*
|
|
718
|
+
* Two kinds of file, told apart by extension rather than by configuration: a
|
|
719
|
+
* script exports `up` (and may export `down`) — or `seed(db, { stage })`, in
|
|
720
|
+
* `seeds/` — and a
|
|
721
|
+
* `.sql` file is run whole. The second is what makes a folder independent of
|
|
722
|
+
* the client the app uses — Better Auth's schema is SQL whatever the
|
|
723
|
+
* application queries with.
|
|
724
|
+
*/
|
|
725
|
+
const SCRIPTS = /* @__PURE__ */ new Set([
|
|
726
|
+
".ts",
|
|
727
|
+
".mts",
|
|
728
|
+
".js",
|
|
729
|
+
".mjs"
|
|
730
|
+
]);
|
|
731
|
+
/** Two files in one folder that would be one migration. */
|
|
732
|
+
var DuplicateMigration = class extends Error {
|
|
733
|
+
folder;
|
|
734
|
+
migration;
|
|
735
|
+
constructor(folder, migration) {
|
|
736
|
+
super(`${folder} has two migrations named '${migration}' — a script and a .sql file with the same name. A migration is recorded by name, so only one of them could ever run; rename or remove the other.`);
|
|
737
|
+
this.folder = folder;
|
|
738
|
+
this.migration = migration;
|
|
739
|
+
this.name = "DuplicateMigration";
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
/** A script in a migrations folder that exports no `up`. */
|
|
743
|
+
var MigrationHasNoUp = class extends Error {
|
|
744
|
+
file;
|
|
745
|
+
constructor(file) {
|
|
746
|
+
super(`${file} is in a migrations folder but exports no \`up\` function. Export \`async function up(db: Kysely<unknown>)\`, or move the file out of the folder if it is not a migration.`);
|
|
747
|
+
this.file = file;
|
|
748
|
+
this.name = "MigrationHasNoUp";
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
/** A script in a seeds folder that exports no `seed`. */
|
|
752
|
+
var SeedHasNoSeed = class extends Error {
|
|
753
|
+
file;
|
|
754
|
+
constructor(file) {
|
|
755
|
+
super(`${file} is in a seeds folder but exports no \`seed\` function. Export \`async function seed(db, { stage })\`, or move the file out of the folder if it is not a seed.`);
|
|
756
|
+
this.file = file;
|
|
757
|
+
this.name = "SeedHasNoSeed";
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
/**
|
|
761
|
+
* The migration files in a folder, by name, without reading them.
|
|
762
|
+
*
|
|
763
|
+
* Also what `gkm dev` compares against a construct's history to report what
|
|
764
|
+
* is pending, which is why it is separate from loading them.
|
|
765
|
+
*/
|
|
766
|
+
async function migrationFiles(folder) {
|
|
767
|
+
const entries = await (0, node_fs_promises.readdir)(folder, { withFileTypes: true }).catch((error) => {
|
|
768
|
+
if (error.code === "ENOENT") return [];
|
|
769
|
+
throw error;
|
|
770
|
+
});
|
|
771
|
+
const files = /* @__PURE__ */ new Map();
|
|
772
|
+
for (const entry of entries) {
|
|
773
|
+
if (!entry.isFile() || !isMigration(entry.name)) continue;
|
|
774
|
+
const name = entry.name.slice(0, -(0, node_path.extname)(entry.name).length);
|
|
775
|
+
if (files.has(name)) throw new DuplicateMigration(folder, name);
|
|
776
|
+
files.set(name, (0, node_path.join)(folder, entry.name));
|
|
777
|
+
}
|
|
778
|
+
return files;
|
|
779
|
+
}
|
|
780
|
+
/** A Kysely provider over one construct's folder. */
|
|
781
|
+
function folderProvider(folder, raw) {
|
|
782
|
+
return { async getMigrations() {
|
|
783
|
+
const migrations = {};
|
|
784
|
+
for (const [name, file] of await migrationFiles(folder)) migrations[name] = (0, node_path.extname)(file) === ".sql" ? await sqlMigration(file, raw) : await scriptMigration(file);
|
|
785
|
+
return migrations;
|
|
786
|
+
} };
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* The seeds in a folder, loaded, in name order.
|
|
790
|
+
*
|
|
791
|
+
* Every one runs on every pass — there is no history to consult — so the order
|
|
792
|
+
* is the only thing between two seeds where one reads what another writes.
|
|
793
|
+
* Name them to sort that way.
|
|
794
|
+
*/
|
|
795
|
+
async function loadSeeds(folder, raw) {
|
|
796
|
+
const files = [...await migrationFiles(folder)].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
797
|
+
const seeds = [];
|
|
798
|
+
for (const [name, file] of files) seeds.push({
|
|
799
|
+
name,
|
|
800
|
+
run: (0, node_path.extname)(file) === ".sql" ? (await sqlMigration(file, raw)).up : await scriptSeed(file)
|
|
801
|
+
});
|
|
802
|
+
return seeds;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* A seed script, imported as it is on disk now.
|
|
806
|
+
*
|
|
807
|
+
* Keyed by its modification time: a seed is run again after it is edited, and
|
|
808
|
+
* a process that has imported it once would otherwise run the old one.
|
|
809
|
+
*/
|
|
810
|
+
async function scriptSeed(file) {
|
|
811
|
+
const url = (0, node_url.pathToFileURL)(file);
|
|
812
|
+
url.searchParams.set("mtime", String((await (0, node_fs_promises.stat)(file)).mtimeMs));
|
|
813
|
+
const module = await import(url.href);
|
|
814
|
+
if (typeof module.seed !== "function") throw new SeedHasNoSeed(file);
|
|
815
|
+
return module.seed;
|
|
816
|
+
}
|
|
817
|
+
function isMigration(file) {
|
|
818
|
+
if (file.endsWith(".d.ts")) return false;
|
|
819
|
+
if (/\.(spec|test)\.[cm]?[jt]s$/.test(file)) return false;
|
|
820
|
+
const extension = (0, node_path.extname)(file);
|
|
821
|
+
return extension === ".sql" || SCRIPTS.has(extension);
|
|
822
|
+
}
|
|
823
|
+
async function scriptMigration(file) {
|
|
824
|
+
const module = await import((0, node_url.pathToFileURL)(file).href);
|
|
825
|
+
if (typeof module.up !== "function") throw new MigrationHasNoUp(file);
|
|
826
|
+
return {
|
|
827
|
+
up: module.up,
|
|
828
|
+
...typeof module.down === "function" ? { down: module.down } : {}
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* A `.sql` file, run whole.
|
|
833
|
+
*
|
|
834
|
+
* One query rather than split on `;`: with no parameters, Postgres takes
|
|
835
|
+
* several statements in one message, and splitting by hand breaks on the first
|
|
836
|
+
* `;` inside a string or a function body. There is no `down` — a generated
|
|
837
|
+
* schema is rolled back by a migration that says how, not by guessing.
|
|
838
|
+
*/
|
|
839
|
+
async function sqlMigration(file, raw) {
|
|
840
|
+
const statements = await (0, node_fs_promises.readFile)(file, "utf-8");
|
|
841
|
+
return { up: async (db) => {
|
|
842
|
+
await raw.raw(statements).execute(db);
|
|
843
|
+
} };
|
|
844
|
+
}
|
|
845
|
+
//#endregion
|
|
846
|
+
//#region src/migrate/databases.ts
|
|
847
|
+
/**
|
|
848
|
+
* Bring each construct's schema up to its migrations folder.
|
|
849
|
+
*
|
|
850
|
+
* Which constructs, in what order, from which folder, is `migrationTargets`'
|
|
851
|
+
* answer and nobody else's. What is decided here is only how one target is
|
|
852
|
+
* run: as its owner, in its own schema, with a history of its own.
|
|
853
|
+
*/
|
|
854
|
+
/** A folder under `db/` that names no construct. */
|
|
855
|
+
var UnknownMigrationFolder = class extends Error {
|
|
856
|
+
folder;
|
|
857
|
+
known;
|
|
858
|
+
constructor(folder, known) {
|
|
859
|
+
super(`${folder} names no database construct, so its migrations would never run. ${known.length > 0 ? `The folders that do: ${known.join(", ")}.` : "This project declares no database."} Rename the folder after the construct it belongs to.`);
|
|
860
|
+
this.folder = folder;
|
|
861
|
+
this.known = known;
|
|
862
|
+
this.name = "UnknownMigrationFolder";
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
/** A construct was asked for by a name that takes no migrations. */
|
|
866
|
+
var NoSuchMigrationTarget = class extends Error {
|
|
867
|
+
construct;
|
|
868
|
+
known;
|
|
869
|
+
constructor(construct, known) {
|
|
870
|
+
super(`'${construct}' is not a database construct. ` + (known.length > 0 ? `The ones with migrations: ${known.join(", ")}.` : "This project declares no database."));
|
|
871
|
+
this.construct = construct;
|
|
872
|
+
this.known = known;
|
|
873
|
+
this.name = "NoSuchMigrationTarget";
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
/**
|
|
877
|
+
* A migration left directly in a construct's folder, where nothing reads it.
|
|
878
|
+
*
|
|
879
|
+
* Migrations moved into `migrations/` so seeds could sit beside them. A file
|
|
880
|
+
* still at the old level would otherwise never run, and nothing would say so.
|
|
881
|
+
*/
|
|
882
|
+
var MigrationsOutsideFolder = class extends Error {
|
|
883
|
+
folder;
|
|
884
|
+
files;
|
|
885
|
+
constructor(folder, files) {
|
|
886
|
+
super(`${folder} has ${files.join(", ")} directly inside it, where nothing runs them. Migrations live in ${folder}/migrations/ and seeds in ${folder}/seeds/ — move ${files.length === 1 ? "it" : "them"} there.`);
|
|
887
|
+
this.folder = folder;
|
|
888
|
+
this.files = files;
|
|
889
|
+
this.name = "MigrationsOutsideFolder";
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
/** A folder inside a construct's folder that is neither `migrations` nor `seeds`. */
|
|
893
|
+
var UnknownDatabaseFolder = class extends Error {
|
|
894
|
+
folder;
|
|
895
|
+
constructor(folder) {
|
|
896
|
+
super(`${folder} is neither migrations/ nor seeds/, so nothing in it would ever run. A construct's folder holds those two; rename or remove it.`);
|
|
897
|
+
this.folder = folder;
|
|
898
|
+
this.name = "UnknownDatabaseFolder";
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
/** A seed failed; its own writes are rolled back, the ones before it stay. */
|
|
902
|
+
var SeedFailed = class extends Error {
|
|
903
|
+
construct;
|
|
904
|
+
seed;
|
|
905
|
+
cause;
|
|
906
|
+
constructor(construct, seed, cause) {
|
|
907
|
+
super(`${construct}: seed '${seed}' failed — ${cause instanceof Error ? cause.message : String(cause)}. Its writes were rolled back; fix it and run it again.`);
|
|
908
|
+
this.construct = construct;
|
|
909
|
+
this.seed = seed;
|
|
910
|
+
this.cause = cause;
|
|
911
|
+
this.name = "SeedFailed";
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
/** A construct with roles has no owner URL to migrate with. */
|
|
915
|
+
var NoOwnerCredential = class extends Error {
|
|
916
|
+
construct;
|
|
917
|
+
key;
|
|
918
|
+
constructor(construct, key) {
|
|
919
|
+
super(`${construct} has no ${key} to migrate with. Migrations run as the owner role — the runtime one cannot create a table — and nothing resolved its URL. Is the stage reconciled?`);
|
|
920
|
+
this.construct = construct;
|
|
921
|
+
this.key = key;
|
|
922
|
+
this.name = "NoOwnerCredential";
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
/** A migration failed; the ones before it in the run stay applied. */
|
|
926
|
+
var MigrationFailed = class extends Error {
|
|
927
|
+
construct;
|
|
928
|
+
migration;
|
|
929
|
+
cause;
|
|
930
|
+
constructor(construct, migration, cause) {
|
|
931
|
+
super(`${construct}: migration ${migration ? `'${migration}' ` : ""}failed — ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
932
|
+
this.construct = construct;
|
|
933
|
+
this.migration = migration;
|
|
934
|
+
this.cause = cause;
|
|
935
|
+
this.name = "MigrationFailed";
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
/** The project's Kysely could not be found from where a database is declared. */
|
|
939
|
+
var KyselyNotFound = class extends Error {
|
|
940
|
+
from;
|
|
941
|
+
constructor(from) {
|
|
942
|
+
super(`Could not resolve 'kysely' from ${from}. Migrations run with the project's own Kysely — install it where the database is declared.`);
|
|
943
|
+
this.from = from;
|
|
944
|
+
this.name = "KyselyNotFound";
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
/** Apply every pending migration, construct by construct, parents first. */
|
|
948
|
+
async function migrateDatabases(options) {
|
|
949
|
+
const runs = [];
|
|
950
|
+
for (const target of await targetsFor(options)) {
|
|
951
|
+
if ((await migrationFiles((0, node_path.join)(options.root, target.migrations))).size === 0) {
|
|
952
|
+
runs.push({
|
|
953
|
+
target,
|
|
954
|
+
applied: []
|
|
955
|
+
});
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
const applied = await withMigrator(options, target, async (migrator) => {
|
|
959
|
+
const { error, results = [] } = await migrator.migrateToLatest();
|
|
960
|
+
if (error) {
|
|
961
|
+
const failed = results.find((result) => result.status === "Error");
|
|
962
|
+
throw new MigrationFailed(target.id, failed?.migrationName, error);
|
|
963
|
+
}
|
|
964
|
+
return results.filter((result) => result.status === "Success").map((result) => result.migrationName);
|
|
965
|
+
});
|
|
966
|
+
runs.push({
|
|
967
|
+
target,
|
|
968
|
+
applied
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
return runs;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Run every construct's seeds, parents first — each one, every time, on every
|
|
975
|
+
* stage, production included. Each is handed the stage, so a seed decides
|
|
976
|
+
* what belongs where.
|
|
977
|
+
*
|
|
978
|
+
* Seeds are reference data kept up to date by being run again, so there is no
|
|
979
|
+
* history: a seed is an upsert, and a changed one is applied by the next run.
|
|
980
|
+
* Each runs in its own transaction, as the construct's owner, so a failure
|
|
981
|
+
* leaves nothing half-written. Assumes the schema is migrated —
|
|
982
|
+
* {@link migrateAndSeed} is what makes that true.
|
|
983
|
+
*/
|
|
984
|
+
async function seedDatabases(options) {
|
|
985
|
+
const runs = [];
|
|
986
|
+
for (const target of await targetsFor(options)) {
|
|
987
|
+
const folder = (0, node_path.join)(options.root, target.seeds);
|
|
988
|
+
if ((await migrationFiles(folder)).size === 0) {
|
|
989
|
+
runs.push({
|
|
990
|
+
target,
|
|
991
|
+
seeded: []
|
|
992
|
+
});
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
const seeded = await withOwner(options, target, async (db, kysely) => {
|
|
996
|
+
const names = [];
|
|
997
|
+
for (const seed of await loadSeeds(folder, kysely.sql)) {
|
|
998
|
+
try {
|
|
999
|
+
await db.transaction().execute((trx) => seed.run(trx, { stage: options.stage }));
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
throw new SeedFailed(target.id, seed.name, error);
|
|
1002
|
+
}
|
|
1003
|
+
names.push(seed.name);
|
|
1004
|
+
}
|
|
1005
|
+
return names;
|
|
1006
|
+
});
|
|
1007
|
+
runs.push({
|
|
1008
|
+
target,
|
|
1009
|
+
seeded
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
return runs;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Migrate, then seed: a seed only ever runs against the schema it was written
|
|
1016
|
+
* for.
|
|
1017
|
+
*
|
|
1018
|
+
* With `only`, the constructs it lives in are migrated too — a tenant's seed
|
|
1019
|
+
* cannot run before the database that owns its schema exists — and only it is
|
|
1020
|
+
* seeded.
|
|
1021
|
+
*/
|
|
1022
|
+
async function migrateAndSeed(options) {
|
|
1023
|
+
const migrations = [];
|
|
1024
|
+
for (const target of await withParents(options)) migrations.push(...await migrateDatabases({
|
|
1025
|
+
...options,
|
|
1026
|
+
only: target.id
|
|
1027
|
+
}));
|
|
1028
|
+
return {
|
|
1029
|
+
migrations,
|
|
1030
|
+
seeds: await seedDatabases(options)
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
/** The targets `only` names, after the ones it lives in; all without it. */
|
|
1034
|
+
async function withParents(options) {
|
|
1035
|
+
const chosen = await targetsFor(options);
|
|
1036
|
+
if (!options.only) return chosen;
|
|
1037
|
+
const all = (0, _geekmidas_manifest.migrationTargets)(options.manifest);
|
|
1038
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1039
|
+
for (const target of chosen) {
|
|
1040
|
+
let current = target;
|
|
1041
|
+
while (current) {
|
|
1042
|
+
ids.add(current.id);
|
|
1043
|
+
const parent = current.of;
|
|
1044
|
+
current = parent ? all.find((t) => t.id === parent) : void 0;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return all.filter((target) => ids.has(target.id));
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* What each construct's folder has that its history does not — without
|
|
1051
|
+
* applying any of it. What `gkm dev` reports.
|
|
1052
|
+
*/
|
|
1053
|
+
async function pendingMigrations(options) {
|
|
1054
|
+
const found = [];
|
|
1055
|
+
for (const target of await targetsFor(options)) {
|
|
1056
|
+
if ((await migrationFiles((0, node_path.join)(options.root, target.migrations))).size === 0) continue;
|
|
1057
|
+
const pending = await withMigrator(options, target, async (migrator) => (await migrator.getMigrations()).filter((migration) => migration.executedAt === void 0).map((migration) => migration.name));
|
|
1058
|
+
if (pending.length > 0) found.push({
|
|
1059
|
+
target,
|
|
1060
|
+
pending
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
return found;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* The targets this run covers, with every folder under `db/` accounted for.
|
|
1067
|
+
*
|
|
1068
|
+
* A folder that names no construct is an error rather than something to skip:
|
|
1069
|
+
* a typo in its name is otherwise a migration that silently never runs.
|
|
1070
|
+
*/
|
|
1071
|
+
async function targetsFor(options) {
|
|
1072
|
+
const targets = (0, _geekmidas_manifest.migrationTargets)(options.manifest);
|
|
1073
|
+
const folders = targets.map((target) => target.folder);
|
|
1074
|
+
for (const folder of await foldersIn((0, node_path.join)(options.root, _geekmidas_manifest.MIGRATIONS_ROOT))) {
|
|
1075
|
+
const relative = `${_geekmidas_manifest.MIGRATIONS_ROOT}/${folder}`;
|
|
1076
|
+
if (!folders.includes(relative)) throw new UnknownMigrationFolder(relative, folders);
|
|
1077
|
+
await checkLayout(options.root, relative);
|
|
1078
|
+
}
|
|
1079
|
+
if (!options.only) return targets;
|
|
1080
|
+
const id = (0, _geekmidas_manifest.canonicalId)(options.only);
|
|
1081
|
+
const chosen = targets.filter((target) => target.id === id);
|
|
1082
|
+
if (chosen.length === 0) throw new NoSuchMigrationTarget(options.only, targets.map((target) => target.id));
|
|
1083
|
+
return chosen;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* A construct's folder holds `migrations/` and `seeds/`, and nothing else —
|
|
1087
|
+
* anything beside them is a file or folder nothing would ever run.
|
|
1088
|
+
*/
|
|
1089
|
+
async function checkLayout(root, folder) {
|
|
1090
|
+
const entries = await (0, node_fs_promises.readdir)((0, node_path.join)(root, folder), { withFileTypes: true });
|
|
1091
|
+
const loose = entries.filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => entry.name);
|
|
1092
|
+
if (loose.length > 0) throw new MigrationsOutsideFolder(folder, loose);
|
|
1093
|
+
for (const entry of entries) if (entry.isDirectory() && !_geekmidas_manifest.DATABASE_FOLDERS.includes(entry.name)) throw new UnknownDatabaseFolder(`${folder}/${entry.name}`);
|
|
1094
|
+
}
|
|
1095
|
+
async function foldersIn(dir) {
|
|
1096
|
+
return (await (0, node_fs_promises.readdir)(dir, { withFileTypes: true }).catch((error) => {
|
|
1097
|
+
if (error.code === "ENOENT") return [];
|
|
1098
|
+
throw error;
|
|
1099
|
+
})).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* A migrator for one target, connected as its owner.
|
|
1103
|
+
*
|
|
1104
|
+
* No `migrationTableSchema`: the owner role's `search_path` is pinned to the
|
|
1105
|
+
* construct's schema, so the history lands there — `app.kysely_migration` for
|
|
1106
|
+
* the database, `auth_database.kysely_migration` for its tenant — and two
|
|
1107
|
+
* constructs in one Postgres never share one.
|
|
1108
|
+
*
|
|
1109
|
+
* Unordered migrations are allowed. Files are timestamped, so a branch merged
|
|
1110
|
+
* after another's migration ran carries an older one; refusing it would make
|
|
1111
|
+
* every such merge a manual repair.
|
|
1112
|
+
*/
|
|
1113
|
+
async function withMigrator(options, target, run) {
|
|
1114
|
+
return withOwner(options, target, (db, { Migrator, sql }) => run(new Migrator({
|
|
1115
|
+
db,
|
|
1116
|
+
provider: folderProvider((0, node_path.join)(options.root, target.migrations), sql),
|
|
1117
|
+
allowUnorderedMigrations: true
|
|
1118
|
+
})));
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* One target's database, connected as its owner and closed afterwards — what
|
|
1122
|
+
* migrations and seeds both run as.
|
|
1123
|
+
*/
|
|
1124
|
+
async function withOwner(options, target, run) {
|
|
1125
|
+
const kysely = await kyselyFrom(options.sources?.[target.id]?.file ?? (target.of ? options.sources?.[target.of]?.file : void 0) ?? (0, node_path.join)(options.root, "package.json"));
|
|
1126
|
+
const db = new kysely.Kysely({ dialect: new kysely.PostgresDialect({ pool: new pg.default.Pool(poolConfig(ownerUrl(options, target))) }) });
|
|
1127
|
+
try {
|
|
1128
|
+
return await run(db, kysely);
|
|
1129
|
+
} finally {
|
|
1130
|
+
await db.destroy();
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* The owner's URL, with no fallback to the runtime one.
|
|
1135
|
+
*
|
|
1136
|
+
* The runtime role can create nothing, so falling back would only move the
|
|
1137
|
+
* failure to the first `CREATE TABLE` and make it read like a permissions
|
|
1138
|
+
* bug. The one exception is a database declared `roles: false`, where there is
|
|
1139
|
+
* no owner — both URLs are the master credential by design.
|
|
1140
|
+
*/
|
|
1141
|
+
function ownerUrl(options, target) {
|
|
1142
|
+
const key = (0, _geekmidas_manifest.provideKey)(target.id, "ownerUrl");
|
|
1143
|
+
const owner = options.env[key];
|
|
1144
|
+
if (owner) return owner;
|
|
1145
|
+
const database = options.manifest[target.of ?? target.id];
|
|
1146
|
+
if (database?.kind === "database" && database.roles === false) {
|
|
1147
|
+
const url = options.env[(0, _geekmidas_manifest.provideKey)(target.id, "url")];
|
|
1148
|
+
if (url) return url;
|
|
1149
|
+
}
|
|
1150
|
+
throw new NoOwnerCredential(target.id, key);
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Pool settings for a URL, with `?search_path=` as the startup option Postgres
|
|
1154
|
+
* reads. Only a `roles: false` URL carries it — a role pins its own.
|
|
1155
|
+
*/
|
|
1156
|
+
function poolConfig(url) {
|
|
1157
|
+
const parsed = new URL(url);
|
|
1158
|
+
const searchPath = parsed.searchParams.get("search_path");
|
|
1159
|
+
if (!searchPath) return { connectionString: url };
|
|
1160
|
+
parsed.searchParams.delete("search_path");
|
|
1161
|
+
return {
|
|
1162
|
+
connectionString: parsed.toString(),
|
|
1163
|
+
options: `-c search_path=${searchPath}`
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
/** Kysely as the project resolves it from `from`. */
|
|
1167
|
+
async function kyselyFrom(from) {
|
|
1168
|
+
const require$1 = (0, node_module.createRequire)(from);
|
|
1169
|
+
try {
|
|
1170
|
+
const [main, migration] = await Promise.all([import((0, node_url.pathToFileURL)(require$1.resolve("kysely")).href), import((0, node_url.pathToFileURL)(require$1.resolve("kysely/migration")).href)]);
|
|
1171
|
+
return {
|
|
1172
|
+
Kysely: main.Kysely,
|
|
1173
|
+
PostgresDialect: main.PostgresDialect,
|
|
1174
|
+
sql: main.sql,
|
|
1175
|
+
Migrator: migration.Migrator
|
|
1176
|
+
};
|
|
1177
|
+
} catch {
|
|
1178
|
+
throw new KyselyNotFound(from);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
//#endregion
|
|
1182
|
+
//#region src/target/compose/health.ts
|
|
1183
|
+
/**
|
|
1184
|
+
* Asking each app of a stack whether it answers — through Caddy, over HTTPS,
|
|
1185
|
+
* the way a browser reaches it.
|
|
1186
|
+
*
|
|
1187
|
+
* `docker compose up --wait` already knows each container passed its own
|
|
1188
|
+
* health check, asked on 127.0.0.1 inside it. What that cannot say is whether
|
|
1189
|
+
* the stack is reachable: whether the edge routes the host to the app, and
|
|
1190
|
+
* whether the certificate it presents is one a client accepts. So the check
|
|
1191
|
+
* goes through the edge, by hostname, and verifies the certificate.
|
|
1192
|
+
*/
|
|
1193
|
+
/** A request that got no answer in time. */
|
|
1194
|
+
var HealthRequestTimedOut = class extends Error {
|
|
1195
|
+
url;
|
|
1196
|
+
timeoutMs;
|
|
1197
|
+
constructor(url, timeoutMs) {
|
|
1198
|
+
super(`${url} did not answer within ${timeoutMs}ms. Check the stack is up with \`docker compose ps\`.`);
|
|
1199
|
+
this.url = url;
|
|
1200
|
+
this.timeoutMs = timeoutMs;
|
|
1201
|
+
this.name = "HealthRequestTimedOut";
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
/** The real probe: one HTTPS GET, the certificate verified. */
|
|
1205
|
+
const httpsProbe = ({ url, ca, connectTo, timeoutMs, signal }) => {
|
|
1206
|
+
const target = new URL(url);
|
|
1207
|
+
return new Promise((resolve, reject) => {
|
|
1208
|
+
const req = (0, node_https.request)({
|
|
1209
|
+
host: connectTo ?? target.hostname,
|
|
1210
|
+
port: target.port || 443,
|
|
1211
|
+
servername: target.hostname,
|
|
1212
|
+
path: `${target.pathname}${target.search}`,
|
|
1213
|
+
method: "GET",
|
|
1214
|
+
headers: { host: target.host },
|
|
1215
|
+
...ca ? { ca } : {},
|
|
1216
|
+
...signal ? { signal } : {},
|
|
1217
|
+
timeout: timeoutMs
|
|
1218
|
+
}, (res) => {
|
|
1219
|
+
res.resume();
|
|
1220
|
+
res.on("end", () => resolve(res.statusCode ?? 0));
|
|
1221
|
+
res.on("error", reject);
|
|
1222
|
+
});
|
|
1223
|
+
req.on("timeout", () => req.destroy(new HealthRequestTimedOut(url, timeoutMs)));
|
|
1224
|
+
req.on("error", reject);
|
|
1225
|
+
req.end();
|
|
1226
|
+
});
|
|
1227
|
+
};
|
|
1228
|
+
/** Whether a status means the app answered as it should. */
|
|
1229
|
+
function isHealthy(status) {
|
|
1230
|
+
return status >= 200 && status < 400;
|
|
1231
|
+
}
|
|
1232
|
+
/** Apps that did not answer through the edge. */
|
|
1233
|
+
var ComposeAppsUnhealthy = class extends Error {
|
|
1234
|
+
project;
|
|
1235
|
+
apps;
|
|
1236
|
+
constructor(project, apps) {
|
|
1237
|
+
super(`${project} started, but ${apps.length === 1 ? "this app does" : "these apps do"} not answer through Caddy:\n` + apps.map(({ url, last }) => ` - ${url}: ${last}`).join("\n") + `\nSee why with \`docker compose -p ${project} logs caddy ${apps.map((a) => a.app).join(" ")}\`.`);
|
|
1238
|
+
this.project = project;
|
|
1239
|
+
this.apps = apps;
|
|
1240
|
+
this.name = "ComposeAppsUnhealthy";
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
//#endregion
|
|
1244
|
+
//#region src/target/compose/phases.ts
|
|
1245
|
+
/**
|
|
1246
|
+
* The compose target's phases: a stage as one Docker Compose stack behind
|
|
1247
|
+
* Caddy, on the machine the deploy runs on.
|
|
1248
|
+
*
|
|
1249
|
+
* The order is the point of this file, and the rules it keeps are these:
|
|
1250
|
+
*
|
|
1251
|
+
* - A tag is checked before anything happens (`validate`). Every app's image
|
|
1252
|
+
* is asked of the registry first; one missing image stops the run naming
|
|
1253
|
+
* them all, and nothing is written, pulled or started.
|
|
1254
|
+
* - The databases exist before the apps do (`provision`). Postgres comes up
|
|
1255
|
+
* alone, its databases, roles and grants are created and its migrations
|
|
1256
|
+
* applied from this machine, and only then do the apps start — so an app
|
|
1257
|
+
* never boots against a schema that is not there yet.
|
|
1258
|
+
* - What a stage runs is recorded (`release`). Each app's image, the tag it
|
|
1259
|
+
* was released under and the digest it resolved to are written to the
|
|
1260
|
+
* stage's state, so "what is this stage running" has an exact answer.
|
|
1261
|
+
* - What was started answers (`verify`): each app is asked through Caddy,
|
|
1262
|
+
* over HTTPS, with the certificate verified.
|
|
1263
|
+
*
|
|
1264
|
+
* Docker, the registry, the bundler, Postgres and the health probe are
|
|
1265
|
+
* injected, so the rules are asserted without a daemon; the defaults are the
|
|
1266
|
+
* real ones.
|
|
1267
|
+
*/
|
|
1268
|
+
/** A port variable that does not hold a port. */
|
|
1269
|
+
var EdgePortInvalid = class extends Error {
|
|
1270
|
+
variable;
|
|
1271
|
+
value;
|
|
1272
|
+
constructor(variable, value) {
|
|
1273
|
+
super(`${variable}=${value} is not a port. Set it to a number between 1 and 65535, or unset it for the default.`);
|
|
1274
|
+
this.variable = variable;
|
|
1275
|
+
this.value = value;
|
|
1276
|
+
this.name = "EdgePortInvalid";
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
/** A build with no commit to tag its images with. */
|
|
1280
|
+
var NoGitRevision = class extends Error {
|
|
1281
|
+
root;
|
|
1282
|
+
constructor(root) {
|
|
1283
|
+
super(`Images built here are tagged with the commit they were built from, and ${root} is not in a git repository with a commit. Commit, or pass --tag to name the images yourself.`);
|
|
1284
|
+
this.root = root;
|
|
1285
|
+
this.name = "NoGitRevision";
|
|
1286
|
+
}
|
|
1287
|
+
};
|
|
1288
|
+
/** A backend's production bundle did not build. */
|
|
1289
|
+
var BundleFailed = class extends Error {
|
|
1290
|
+
app;
|
|
1291
|
+
exitCode;
|
|
1292
|
+
stderr;
|
|
1293
|
+
constructor(app, exitCode, stderr) {
|
|
1294
|
+
super(`Bundling '${app}' for production failed (exit code ${exitCode}).${stderr.trim() ? `\n${stderr.trim().slice(-4e3)}` : ""}\nRun \`gkm build --provider server --production\` in the app's directory to see it again.`);
|
|
1295
|
+
this.app = app;
|
|
1296
|
+
this.exitCode = exitCode;
|
|
1297
|
+
this.stderr = stderr;
|
|
1298
|
+
this.name = "BundleFailed";
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
/** The CLI's own entry, which the bundle step runs, is not where it should be. */
|
|
1302
|
+
var CliEntryNotFound = class extends Error {
|
|
1303
|
+
from;
|
|
1304
|
+
constructor(from) {
|
|
1305
|
+
super(`Could not find bin/gkm.mjs above ${from}, which bundling a backend runs. Reinstall @geekmidas/cli.`);
|
|
1306
|
+
this.from = from;
|
|
1307
|
+
this.name = "CliEntryNotFound";
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
/** The commit HEAD is at, short — and `-dirty` when the tree has changes. */
|
|
1311
|
+
async function gitRevision(root) {
|
|
1312
|
+
let sha;
|
|
1313
|
+
try {
|
|
1314
|
+
sha = (await require_local.runOutput("git", [
|
|
1315
|
+
"rev-parse",
|
|
1316
|
+
"--short",
|
|
1317
|
+
"HEAD"
|
|
1318
|
+
], { cwd: root })).trim();
|
|
1319
|
+
} catch {
|
|
1320
|
+
throw new NoGitRevision(root);
|
|
1321
|
+
}
|
|
1322
|
+
if (!sha) throw new NoGitRevision(root);
|
|
1323
|
+
return (await require_local.runOutput("git", ["status", "--porcelain"], { cwd: root }).catch(() => "")).trim() ? `${sha}-dirty` : sha;
|
|
1324
|
+
}
|
|
1325
|
+
/** The edge's ports, from the environment — 443 and 80 when unset. */
|
|
1326
|
+
function edgePorts(env = process.env) {
|
|
1327
|
+
const read = (variable, fallback) => {
|
|
1328
|
+
const value = env[variable];
|
|
1329
|
+
if (value === void 0 || value === "") return fallback;
|
|
1330
|
+
const port = Number(value);
|
|
1331
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new EdgePortInvalid(variable, value);
|
|
1332
|
+
return port;
|
|
1333
|
+
};
|
|
1334
|
+
return {
|
|
1335
|
+
https: read(EDGE_PORT_ENV.https, 443),
|
|
1336
|
+
http: read(EDGE_PORT_ENV.http, 80)
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
/** `bin/gkm.mjs` of the CLI that is running. */
|
|
1340
|
+
function cliEntry() {
|
|
1341
|
+
const from = (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
1342
|
+
for (let dir = from; (0, node_path.dirname)(dir) !== dir; dir = (0, node_path.dirname)(dir)) {
|
|
1343
|
+
const entry = (0, node_path.join)(dir, "bin", "gkm.mjs");
|
|
1344
|
+
if ((0, node_fs.existsSync)(entry)) return entry;
|
|
1345
|
+
}
|
|
1346
|
+
throw new CliEntryNotFound(from);
|
|
1347
|
+
}
|
|
1348
|
+
/** A cold production bundle is seconds; a hung one must not hold the lock. */
|
|
1349
|
+
const BUNDLE_TIMEOUT_MS = 6e5;
|
|
1350
|
+
/**
|
|
1351
|
+
* Bundle a backend with the gkm that is running — the same build a deploy
|
|
1352
|
+
* makes, so the image holds one file and no dependencies — in the run's
|
|
1353
|
+
* sandbox, because the build imports the project's code.
|
|
1354
|
+
*/
|
|
1355
|
+
async function bundleInSandbox(appRoot, ctx) {
|
|
1356
|
+
const sandbox = require_sandbox.activeSandbox() ?? new require_local.LocalSandbox({ root: ctx.cwd });
|
|
1357
|
+
const result = await sandbox.exec("node", [
|
|
1358
|
+
cliEntry(),
|
|
1359
|
+
"build",
|
|
1360
|
+
"--provider",
|
|
1361
|
+
"server",
|
|
1362
|
+
"--production"
|
|
1363
|
+
], {
|
|
1364
|
+
cwd: appRoot,
|
|
1365
|
+
env: sandbox.env,
|
|
1366
|
+
timeoutMs: BUNDLE_TIMEOUT_MS,
|
|
1367
|
+
output: ctx.childOutput,
|
|
1368
|
+
signal: ctx.signal
|
|
1369
|
+
});
|
|
1370
|
+
if (result.exitCode !== 0) throw new BundleFailed(appRoot, result.exitCode, result.stderr);
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* The real ones. Each app is asked for three minutes before it counts as
|
|
1374
|
+
* down: a deployed stage's first ACME certificate can take that long.
|
|
1375
|
+
*/
|
|
1376
|
+
const defaultDeps = {
|
|
1377
|
+
docker: dockerCompose,
|
|
1378
|
+
bundle: bundleInSandbox,
|
|
1379
|
+
revision: gitRevision,
|
|
1380
|
+
sql: require_state.pgClient,
|
|
1381
|
+
migrate: migrateDatabases,
|
|
1382
|
+
probe: httpsProbe,
|
|
1383
|
+
healthAttempts: 90,
|
|
1384
|
+
healthIntervalMs: 2e3,
|
|
1385
|
+
env: process.env
|
|
1386
|
+
};
|
|
1387
|
+
/**
|
|
1388
|
+
* The stack the stage would run, and — for a tag — the registry's word that
|
|
1389
|
+
* every image it names exists. Nothing is written: a missing image stops the
|
|
1390
|
+
* run here, before a file, a secret or a container is touched.
|
|
1391
|
+
*/
|
|
1392
|
+
async function validateCompose(ctx, deps, forced) {
|
|
1393
|
+
const { workspace, stage, identity, tag } = ctx;
|
|
1394
|
+
const root = workspace.root;
|
|
1395
|
+
const mode = forced ?? (ctx.tagGiven ? "pull" : "build");
|
|
1396
|
+
const runnables = {};
|
|
1397
|
+
const manifest = await require_discover.discover({
|
|
1398
|
+
patterns: require_state.constructGlobs(workspace),
|
|
1399
|
+
cwd: root,
|
|
1400
|
+
runnables
|
|
1401
|
+
});
|
|
1402
|
+
const { secrets, generated } = await stageSecrets(ctx, manifest);
|
|
1403
|
+
const stack = composeStack({
|
|
1404
|
+
workspace,
|
|
1405
|
+
manifest,
|
|
1406
|
+
runnables,
|
|
1407
|
+
stage,
|
|
1408
|
+
identity,
|
|
1409
|
+
images: {
|
|
1410
|
+
mode,
|
|
1411
|
+
tag,
|
|
1412
|
+
...workspace.deploy?.registry ? { registry: workspace.deploy.registry } : {}
|
|
1413
|
+
},
|
|
1414
|
+
secrets,
|
|
1415
|
+
ports: edgePorts(deps.env),
|
|
1416
|
+
packageManager: require_state.detectPackageManager(root),
|
|
1417
|
+
packages: Object.fromEntries(Object.entries(workspace.apps).map(([name, app]) => [name, require_state.appPackageName(root, name, app.path)]))
|
|
1418
|
+
});
|
|
1419
|
+
if (mode === "pull" && !ctx.dryRun) await assertImagesExist(deps.docker, tag, stack.apps);
|
|
1420
|
+
const dir = (0, node_path.join)(root, stackDir(stage));
|
|
1421
|
+
const previous = (await ctx.state.read(stage))?.state.images ?? {};
|
|
1422
|
+
return {
|
|
1423
|
+
mode,
|
|
1424
|
+
stack,
|
|
1425
|
+
dir,
|
|
1426
|
+
ref: {
|
|
1427
|
+
project: composeProject(identity),
|
|
1428
|
+
file: (0, node_path.join)(dir, "docker-compose.yml"),
|
|
1429
|
+
cwd: root,
|
|
1430
|
+
output: ctx.childOutput,
|
|
1431
|
+
signal: ctx.signal
|
|
1432
|
+
},
|
|
1433
|
+
secrets,
|
|
1434
|
+
generated,
|
|
1435
|
+
previous,
|
|
1436
|
+
files: [],
|
|
1437
|
+
images: {},
|
|
1438
|
+
changes: []
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* The stage's secrets — and, for a deployed stage, everything it generates
|
|
1443
|
+
* once: its seed, and each declared secret and keyring. Kept only once the
|
|
1444
|
+
* run goes ahead (`provision`), so a dry run, or a tag that is not there,
|
|
1445
|
+
* leaves the stage's secrets as it found them.
|
|
1446
|
+
*/
|
|
1447
|
+
async function stageSecrets(ctx, manifest) {
|
|
1448
|
+
const stored = await ctx.secrets.read();
|
|
1449
|
+
if (ctx.stage === ctx.workspace.stages.local) return {
|
|
1450
|
+
secrets: stored,
|
|
1451
|
+
generated: []
|
|
1452
|
+
};
|
|
1453
|
+
const { secrets, generated } = require_state.withGeneratedSecrets(stored ?? require_storage.initStageSecrets(ctx.stage), manifest);
|
|
1454
|
+
if (secrets.seed) ctx.secrets.mask(secrets.seed);
|
|
1455
|
+
for (const value of Object.values(secrets.custom ?? {})) ctx.secrets.mask(value);
|
|
1456
|
+
return {
|
|
1457
|
+
secrets,
|
|
1458
|
+
generated
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* A dry run: the stack's files, written so they can be read, and what a real
|
|
1463
|
+
* run would do — no image is built or pulled, no container touched, nothing
|
|
1464
|
+
* recorded and no secret kept.
|
|
1465
|
+
*/
|
|
1466
|
+
async function planCompose(ctx, run) {
|
|
1467
|
+
if (run.generated.length > 0) ctx.logger.info(`🔑 "${ctx.stage}" has no ${run.generated.join(", ")} yet; this dry run used values it did not keep.`);
|
|
1468
|
+
run.files = await writeStack(ctx.cwd, run.dir, run.stack);
|
|
1469
|
+
printPlan(ctx, run);
|
|
1470
|
+
const planned = (change) => {
|
|
1471
|
+
run.changes.push(change);
|
|
1472
|
+
ctx.emit({
|
|
1473
|
+
type: "resource.planned",
|
|
1474
|
+
...change
|
|
1475
|
+
});
|
|
1476
|
+
};
|
|
1477
|
+
for (const service of run.stack.infra) planned({
|
|
1478
|
+
key: `service:${service}`,
|
|
1479
|
+
resourceType: "service",
|
|
1480
|
+
action: "ensure"
|
|
1481
|
+
});
|
|
1482
|
+
for (const app of run.stack.apps) {
|
|
1483
|
+
planned({
|
|
1484
|
+
key: `image:${app.name}`,
|
|
1485
|
+
resourceType: "image",
|
|
1486
|
+
action: run.mode === "build" ? "build" : "reuse",
|
|
1487
|
+
id: app.ref
|
|
1488
|
+
});
|
|
1489
|
+
planned({
|
|
1490
|
+
key: `service:${app.name}`,
|
|
1491
|
+
resourceType: "service",
|
|
1492
|
+
action: run.previous[app.name] ? "reuse" : "create"
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
planned({
|
|
1496
|
+
key: "service:caddy",
|
|
1497
|
+
resourceType: "service",
|
|
1498
|
+
action: "ensure"
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* What the apps run on: the stage's generated secrets kept, the stack's files
|
|
1503
|
+
* written, its infrastructure started, and its databases, roles, grants and
|
|
1504
|
+
* migrations applied.
|
|
1505
|
+
*/
|
|
1506
|
+
async function provisionCompose(ctx, run, deps) {
|
|
1507
|
+
const { stack, ref } = run;
|
|
1508
|
+
if (run.generated.length > 0 && run.secrets) {
|
|
1509
|
+
await ctx.secrets.write(run.secrets);
|
|
1510
|
+
ctx.logger.info(`🔑 Generated for "${ctx.stage}" (${ctx.secrets.store}): ${run.generated.join(", ")}`);
|
|
1511
|
+
}
|
|
1512
|
+
run.files = await writeStack(ctx.cwd, run.dir, stack);
|
|
1513
|
+
printPlan(ctx, run);
|
|
1514
|
+
if (stack.infra.length > 0) {
|
|
1515
|
+
ctx.logger.info(`\n🗄️ Starting ${stack.infra.join(", ")}…`);
|
|
1516
|
+
await deps.docker.up(ref, stack.infra);
|
|
1517
|
+
for (const service of stack.infra) applied(ctx, run, {
|
|
1518
|
+
key: `service:${service}`,
|
|
1519
|
+
resourceType: "service",
|
|
1520
|
+
action: "ensure",
|
|
1521
|
+
id: `${stack.project}/${service}`,
|
|
1522
|
+
via: Object.keys(run.previous).length > 0 ? "recorded" : "created"
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
if (stack.infra.includes("postgres")) await prepareDatabases(ctx, run, deps);
|
|
1526
|
+
}
|
|
1527
|
+
/** Create what the plan names in the stack's Postgres, then migrate it. */
|
|
1528
|
+
async function prepareDatabases(ctx, run, deps) {
|
|
1529
|
+
const { stack } = run;
|
|
1530
|
+
const workspace = ctx.workspace;
|
|
1531
|
+
const port = await deps.docker.port(run.ref, "postgres", 5432);
|
|
1532
|
+
const statements = require_state.postgresStatements(stack.plan, workspace.name, stack.credential.seed);
|
|
1533
|
+
if (statements.length > 0) {
|
|
1534
|
+
ctx.logger.info("🗄️ Creating databases, roles and grants…");
|
|
1535
|
+
await require_state.applyPostgres(deps.sql(port, stack.credential.master), statements);
|
|
1536
|
+
}
|
|
1537
|
+
const sources = {};
|
|
1538
|
+
const manifest = await require_discover.discover({
|
|
1539
|
+
patterns: require_state.constructGlobs(workspace),
|
|
1540
|
+
cwd: workspace.root,
|
|
1541
|
+
sources
|
|
1542
|
+
});
|
|
1543
|
+
const env = require_state.envFor(stack.plan, {
|
|
1544
|
+
ports: { [require_state.primaryPortKey("postgres")]: port },
|
|
1545
|
+
project: workspace.name,
|
|
1546
|
+
master: stack.credential.master,
|
|
1547
|
+
...stack.credential.seed ? { seed: stack.credential.seed } : {}
|
|
1548
|
+
});
|
|
1549
|
+
const runs = await deps.migrate({
|
|
1550
|
+
root: workspace.root,
|
|
1551
|
+
manifest,
|
|
1552
|
+
sources,
|
|
1553
|
+
env
|
|
1554
|
+
});
|
|
1555
|
+
for (const { target, applied } of runs) if (applied.length > 0) ctx.logger.info(`🗄️ ${target.migrations}: applied ${applied.length}`);
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Each app's image: built from this checkout — every backend bundled first,
|
|
1559
|
+
* on the host, with this CLI — or pulled at the tag. Nothing live changes.
|
|
1560
|
+
*/
|
|
1561
|
+
async function buildCompose(ctx, run, deps) {
|
|
1562
|
+
const { stack, ref } = run;
|
|
1563
|
+
const apps = stack.apps.map((app) => app.name);
|
|
1564
|
+
if (run.mode === "build") {
|
|
1565
|
+
for (const app of stack.apps.filter((a) => a.kind === "rest-api")) {
|
|
1566
|
+
ctx.signal.throwIfAborted();
|
|
1567
|
+
ctx.logger.info(`\n📦 Bundling ${app.name}…`);
|
|
1568
|
+
await deps.bundle((0, node_path.join)(ctx.cwd, app.path), ctx);
|
|
1569
|
+
}
|
|
1570
|
+
ctx.logger.info("\n🐳 Building images…");
|
|
1571
|
+
await deps.docker.build(ref, apps);
|
|
1572
|
+
} else {
|
|
1573
|
+
ctx.logger.info(`\n🐳 Pulling ${ctx.tag}…`);
|
|
1574
|
+
await deps.docker.pull(ref, apps);
|
|
1575
|
+
}
|
|
1576
|
+
for (const app of stack.apps) {
|
|
1577
|
+
const digest = await deps.docker.digest(app.ref);
|
|
1578
|
+
run.images[app.name] = {
|
|
1579
|
+
ref: app.ref,
|
|
1580
|
+
tag: app.tag,
|
|
1581
|
+
...digest ? { digest } : {}
|
|
1582
|
+
};
|
|
1583
|
+
ctx.emit({
|
|
1584
|
+
type: "artifact.built",
|
|
1585
|
+
app: app.name,
|
|
1586
|
+
imageRef: app.ref,
|
|
1587
|
+
...digest ? { digest } : {}
|
|
1588
|
+
});
|
|
1589
|
+
run.changes.push({
|
|
1590
|
+
key: `image:${app.name}`,
|
|
1591
|
+
resourceType: "image",
|
|
1592
|
+
action: run.mode === "build" ? "build" : "reuse",
|
|
1593
|
+
id: digest ?? app.ref
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* The whole stack up — anything it no longer defines removed — and each
|
|
1599
|
+
* app's image recorded in the stage's state.
|
|
1600
|
+
*/
|
|
1601
|
+
async function releaseCompose(ctx, run, deps) {
|
|
1602
|
+
const { stack, ref } = run;
|
|
1603
|
+
ctx.logger.info("\n🚀 Starting the stack…");
|
|
1604
|
+
await deps.docker.up(ref);
|
|
1605
|
+
if (stack.local) await deps.docker.copyOut(ref, "caddy", "/data/caddy/pki/authorities/local/root.crt", caFile(run)).catch(() => {});
|
|
1606
|
+
await recordImages(ctx, run);
|
|
1607
|
+
for (const app of stack.apps) {
|
|
1608
|
+
const image = run.images[app.name];
|
|
1609
|
+
applied(ctx, run, {
|
|
1610
|
+
key: `service:${app.name}`,
|
|
1611
|
+
resourceType: "service",
|
|
1612
|
+
action: "ensure",
|
|
1613
|
+
id: `${stack.project}/${app.name}`,
|
|
1614
|
+
via: run.previous[app.name] ? "recorded" : "created"
|
|
1615
|
+
});
|
|
1616
|
+
ctx.emit({
|
|
1617
|
+
type: "app.deployed",
|
|
1618
|
+
app: app.name,
|
|
1619
|
+
applicationId: `${stack.project}/${app.name}`,
|
|
1620
|
+
imageRef: image?.ref ?? app.ref,
|
|
1621
|
+
url: app.url
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
ctx.logger.info(`\n✅ ${stack.project} is running:`);
|
|
1625
|
+
for (const app of stack.apps) ctx.logger.info(` ${app.name.padEnd(12)} ${app.url}`);
|
|
1626
|
+
if (stack.local) ctx.logger.info(`\n🔐 Certificates are from Caddy's local CA: NODE_EXTRA_CA_CERTS=${(0, node_path.join)(stackDir(ctx.stage), "caddy-root.crt")}`);
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Each app's image in the stage's state — the same `images` a Dokploy deploy
|
|
1630
|
+
* records: the ref it runs, the tag it was released under, and the digest
|
|
1631
|
+
* the tag resolved to.
|
|
1632
|
+
*/
|
|
1633
|
+
async function recordImages(ctx, run) {
|
|
1634
|
+
const journal = await require_state.DeployJournal.open(ctx.state, ctx.stage, () => require_state.createEmptyState(ctx.stage, "", ""));
|
|
1635
|
+
for (const app of run.stack.apps) {
|
|
1636
|
+
const image = run.images[app.name] ?? {
|
|
1637
|
+
ref: app.ref,
|
|
1638
|
+
tag: app.tag
|
|
1639
|
+
};
|
|
1640
|
+
require_state.setDeployedImage(journal.state, app.name, image);
|
|
1641
|
+
}
|
|
1642
|
+
journal.state.identity = ctx.identity.key;
|
|
1643
|
+
await journal.save();
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Each app asked through Caddy, over HTTPS, the certificate verified: an API
|
|
1647
|
+
* at `/health`, a site at `/`. On the local stage the edge is this machine and
|
|
1648
|
+
* its certificates are Caddy's internal CA's, so the CA `release` copied out
|
|
1649
|
+
* is the one trusted and the request goes to 127.0.0.1.
|
|
1650
|
+
*/
|
|
1651
|
+
async function verifyCompose(ctx, run, deps) {
|
|
1652
|
+
const { stack } = run;
|
|
1653
|
+
const ca = stack.local ? await (0, node_fs_promises.readFile)(caFile(run), "utf-8").catch(() => void 0) : void 0;
|
|
1654
|
+
ctx.logger.info("\n🩺 Checking each app through Caddy…");
|
|
1655
|
+
const down = (await Promise.all(stack.apps.map((app) => checkApp(ctx, app, deps, {
|
|
1656
|
+
ca,
|
|
1657
|
+
local: stack.local
|
|
1658
|
+
})))).filter((result) => !result.healthy).map(({ app, url, last }) => ({
|
|
1659
|
+
app,
|
|
1660
|
+
url,
|
|
1661
|
+
last
|
|
1662
|
+
}));
|
|
1663
|
+
if (down.length > 0) throw new ComposeAppsUnhealthy(stack.project, down);
|
|
1664
|
+
}
|
|
1665
|
+
async function checkApp(ctx, app, deps, options) {
|
|
1666
|
+
const url = `${app.url}${app.kind === "site" ? "/" : "/health"}`;
|
|
1667
|
+
let last = "not asked";
|
|
1668
|
+
for (let attempt = 1; attempt <= deps.healthAttempts; attempt++) {
|
|
1669
|
+
ctx.signal.throwIfAborted();
|
|
1670
|
+
let status;
|
|
1671
|
+
try {
|
|
1672
|
+
status = await deps.probe({
|
|
1673
|
+
url,
|
|
1674
|
+
...options.ca ? { ca: options.ca } : {},
|
|
1675
|
+
...options.local ? { connectTo: "127.0.0.1" } : {},
|
|
1676
|
+
timeoutMs: 1e4,
|
|
1677
|
+
signal: ctx.signal
|
|
1678
|
+
});
|
|
1679
|
+
last = `HTTP ${status}`;
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
last = error instanceof Error ? error.message : String(error);
|
|
1682
|
+
}
|
|
1683
|
+
const healthy = status !== void 0 && isHealthy(status);
|
|
1684
|
+
ctx.emit({
|
|
1685
|
+
type: "health.checked",
|
|
1686
|
+
app: app.name,
|
|
1687
|
+
url,
|
|
1688
|
+
healthy,
|
|
1689
|
+
...status !== void 0 ? { status } : {},
|
|
1690
|
+
attempt
|
|
1691
|
+
});
|
|
1692
|
+
if (healthy) {
|
|
1693
|
+
ctx.logger.info(` ✓ ${app.name.padEnd(12)} ${url} (${last})`);
|
|
1694
|
+
return {
|
|
1695
|
+
app: app.name,
|
|
1696
|
+
url,
|
|
1697
|
+
last,
|
|
1698
|
+
healthy
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
if (attempt < deps.healthAttempts) await new Promise((resolve) => setTimeout(resolve, deps.healthIntervalMs));
|
|
1702
|
+
}
|
|
1703
|
+
ctx.logger.warn(` ✗ ${app.name.padEnd(12)} ${url} (${last})`);
|
|
1704
|
+
return {
|
|
1705
|
+
app: app.name,
|
|
1706
|
+
url,
|
|
1707
|
+
last,
|
|
1708
|
+
healthy: false
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
function composeResult(ctx, run) {
|
|
1712
|
+
const apps = run.stack.apps.map((app) => {
|
|
1713
|
+
const image = run.images[app.name];
|
|
1714
|
+
return {
|
|
1715
|
+
appName: app.name,
|
|
1716
|
+
type: app.kind === "site" ? "web" : "backend",
|
|
1717
|
+
success: !ctx.dryRun,
|
|
1718
|
+
applicationId: `${run.stack.project}/${app.name}`,
|
|
1719
|
+
imageRef: image?.ref ?? app.ref,
|
|
1720
|
+
...image?.digest ? { digest: image.digest } : {},
|
|
1721
|
+
url: app.url
|
|
1722
|
+
};
|
|
1723
|
+
});
|
|
1724
|
+
return {
|
|
1725
|
+
apps,
|
|
1726
|
+
projectId: run.stack.project,
|
|
1727
|
+
successCount: ctx.dryRun ? 0 : apps.length,
|
|
1728
|
+
failedCount: 0,
|
|
1729
|
+
stage: ctx.stage,
|
|
1730
|
+
identity: ctx.identity.key,
|
|
1731
|
+
tag: ctx.tag,
|
|
1732
|
+
dryRun: ctx.dryRun,
|
|
1733
|
+
environmentId: "",
|
|
1734
|
+
skipped: [...ctx.skipped],
|
|
1735
|
+
urls: Object.fromEntries(run.stack.apps.map((app) => [app.name, app.url])),
|
|
1736
|
+
changes: run.changes
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
function caFile(run) {
|
|
1740
|
+
return (0, node_path.join)(run.dir, "caddy-root.crt");
|
|
1741
|
+
}
|
|
1742
|
+
function applied(ctx, run, change) {
|
|
1743
|
+
const { via, ...rest } = change;
|
|
1744
|
+
run.changes.push(rest);
|
|
1745
|
+
ctx.emit({
|
|
1746
|
+
type: "resource.applied",
|
|
1747
|
+
...rest,
|
|
1748
|
+
via
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
/** Write the stack's files: owner-only, since the env files hold secrets. */
|
|
1752
|
+
async function writeStack(root, dir, stack) {
|
|
1753
|
+
await (0, node_fs_promises.mkdir)(dir, {
|
|
1754
|
+
recursive: true,
|
|
1755
|
+
mode: 448
|
|
1756
|
+
});
|
|
1757
|
+
await (0, node_fs_promises.chmod)(dir, 448);
|
|
1758
|
+
const files = [];
|
|
1759
|
+
const write = async (path, content, mode = 420) => {
|
|
1760
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
|
|
1761
|
+
await (0, node_fs_promises.writeFile)(path, content, { mode });
|
|
1762
|
+
await (0, node_fs_promises.chmod)(path, mode);
|
|
1763
|
+
files.push(path);
|
|
1764
|
+
};
|
|
1765
|
+
await write((0, node_path.join)(dir, "docker-compose.yml"), composeYaml(stack));
|
|
1766
|
+
await write((0, node_path.join)(dir, "Caddyfile"), stack.caddyfile);
|
|
1767
|
+
for (const app of stack.apps) if (app.env) await write((0, node_path.join)(dir, `${app.name}.env`), envFile(app.env), 384);
|
|
1768
|
+
for (const [path, content] of Object.entries(stack.dockerfiles)) await write((0, node_path.join)(root, path), content);
|
|
1769
|
+
await ignoreStacks(root);
|
|
1770
|
+
return files;
|
|
1771
|
+
}
|
|
1772
|
+
/** Make sure the build context leaves `.gkm/compose` out. */
|
|
1773
|
+
async function ignoreStacks(root) {
|
|
1774
|
+
const path = (0, node_path.join)(root, ".dockerignore");
|
|
1775
|
+
if (!(0, node_fs.existsSync)(path)) {
|
|
1776
|
+
await (0, node_fs_promises.writeFile)(path, require_state.generateDockerignore());
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
const current = await (0, node_fs_promises.readFile)(path, "utf-8");
|
|
1780
|
+
if (/^\/?\.gkm\/compose\/?$|^\/?\.gkm\/?$/m.test(current)) return;
|
|
1781
|
+
await (0, node_fs_promises.appendFile)(path, `${current.endsWith("\n") ? "" : "\n"}\n# gkm compose's stacks: each app's env file holds its stage's secrets\n.gkm/compose\n`);
|
|
1782
|
+
}
|
|
1783
|
+
function composeYaml(stack) {
|
|
1784
|
+
const stage = stack.local ? "" : ` --stage ${stack.stage}`;
|
|
1785
|
+
return `# Generated by gkm compose from the construct manifest — do not edit.
|
|
1786
|
+
# The ${stack.stage} stage's APIs and sites behind one Caddy. Each backend
|
|
1787
|
+
# reads exactly the keys in its own env file beside this one.
|
|
1788
|
+
#
|
|
1789
|
+
# gkm compose${stage} start or update it
|
|
1790
|
+
# gkm compose${stage} --down stop it
|
|
1791
|
+
${(0, yaml.stringify)(stack.compose, {
|
|
1792
|
+
lineWidth: 0,
|
|
1793
|
+
aliasDuplicateObjects: false
|
|
1794
|
+
})}`;
|
|
1795
|
+
}
|
|
1796
|
+
function printPlan(ctx, run) {
|
|
1797
|
+
const { stack, mode, files } = run;
|
|
1798
|
+
ctx.logger.info(`\n🧱 ${stack.project} — stage ${stack.stage}`);
|
|
1799
|
+
for (const app of stack.apps) ctx.logger.info(` ${app.name.padEnd(12)} ${app.url.padEnd(40)} ${mode} ${app.ref}`);
|
|
1800
|
+
if (stack.infra.length > 0) ctx.logger.info(` infrastructure: ${stack.infra.join(", ")}`);
|
|
1801
|
+
ctx.logger.info(`\n📝 Wrote ${files.length} file(s) under ${stackDir(stack.stage)}/`);
|
|
1802
|
+
}
|
|
1803
|
+
//#endregion
|
|
1804
|
+
//#region src/target/compose/index.ts
|
|
1805
|
+
/**
|
|
1806
|
+
* The built-in `compose` target: a stage as one Docker Compose stack behind
|
|
1807
|
+
* Caddy, on the machine that runs the deploy.
|
|
1808
|
+
*
|
|
1809
|
+
* ```bash
|
|
1810
|
+
* gkm deploy --target compose --stage production --tag v1.4.0 # what CI pushed
|
|
1811
|
+
* gkm deploy --target compose --stage production # built from here
|
|
1812
|
+
* gkm deploy --target compose --stage development # the local stage
|
|
1813
|
+
* ```
|
|
1814
|
+
*
|
|
1815
|
+
* - `validate`: the stack (`composeStack`, pure) and, for a tag, every image
|
|
1816
|
+
* looked up in the registry — `ImageTagNotFound` before anything changes
|
|
1817
|
+
* - `plan`: the stack's files and what a run would create or reuse
|
|
1818
|
+
* - `provision`: generated secrets kept, files written, infrastructure up,
|
|
1819
|
+
* databases, roles, grants and migrations
|
|
1820
|
+
* - `build`: each backend bundled on the host and every image built — or, for
|
|
1821
|
+
* a tag, pulled
|
|
1822
|
+
* - `release`: `up --wait --remove-orphans`, and each app's image, tag and
|
|
1823
|
+
* digest in the stage's state
|
|
1824
|
+
* - `verify`: each app asked through Caddy over HTTPS
|
|
1825
|
+
*
|
|
1826
|
+
* `gkm compose` is this target with a few more switches (`--build`, `--pull`,
|
|
1827
|
+
* `--down`), run through the same `deploy()`.
|
|
1828
|
+
*/
|
|
1829
|
+
var compose_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({ composeTarget: () => composeTarget });
|
|
1830
|
+
/** The compose target, with its Docker, bundler and probe as given. */
|
|
1831
|
+
function composeTarget(options = {}) {
|
|
1832
|
+
const { mode, report, ...given } = options;
|
|
1833
|
+
const deps = {
|
|
1834
|
+
...defaultDeps,
|
|
1835
|
+
...given
|
|
1836
|
+
};
|
|
1837
|
+
return require_define.defineTarget({
|
|
1838
|
+
name: "compose",
|
|
1839
|
+
runtime: "server",
|
|
1840
|
+
capabilities: {
|
|
1841
|
+
rollback: false,
|
|
1842
|
+
migrations: "target",
|
|
1843
|
+
images: true,
|
|
1844
|
+
localStage: true
|
|
1845
|
+
},
|
|
1846
|
+
credentials: [],
|
|
1847
|
+
tag: ({ cwd }) => deps.revision(cwd),
|
|
1848
|
+
validate: (ctx) => validateCompose(ctx, deps, mode),
|
|
1849
|
+
plan: (ctx, run) => planCompose(ctx, run),
|
|
1850
|
+
provision: (ctx, run) => provisionCompose(ctx, run, deps),
|
|
1851
|
+
build: (ctx, run) => buildCompose(ctx, run, deps),
|
|
1852
|
+
release: (ctx, run) => releaseCompose(ctx, run, deps),
|
|
1853
|
+
verify: (ctx, run) => verifyCompose(ctx, run, deps),
|
|
1854
|
+
result: (ctx, run) => {
|
|
1855
|
+
report?.(run);
|
|
1856
|
+
return composeResult(ctx, run);
|
|
1857
|
+
}
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
//#endregion
|
|
1861
|
+
Object.defineProperty(exports, "DuplicateMigration", {
|
|
1862
|
+
enumerable: true,
|
|
1863
|
+
get: function() {
|
|
1864
|
+
return DuplicateMigration;
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
Object.defineProperty(exports, "KyselyNotFound", {
|
|
1868
|
+
enumerable: true,
|
|
1869
|
+
get: function() {
|
|
1870
|
+
return KyselyNotFound;
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
Object.defineProperty(exports, "MigrationFailed", {
|
|
1874
|
+
enumerable: true,
|
|
1875
|
+
get: function() {
|
|
1876
|
+
return MigrationFailed;
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
Object.defineProperty(exports, "MigrationHasNoUp", {
|
|
1880
|
+
enumerable: true,
|
|
1881
|
+
get: function() {
|
|
1882
|
+
return MigrationHasNoUp;
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
Object.defineProperty(exports, "MigrationsOutsideFolder", {
|
|
1886
|
+
enumerable: true,
|
|
1887
|
+
get: function() {
|
|
1888
|
+
return MigrationsOutsideFolder;
|
|
1889
|
+
}
|
|
1890
|
+
});
|
|
1891
|
+
Object.defineProperty(exports, "NoOwnerCredential", {
|
|
1892
|
+
enumerable: true,
|
|
1893
|
+
get: function() {
|
|
1894
|
+
return NoOwnerCredential;
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
Object.defineProperty(exports, "NoSuchMigrationTarget", {
|
|
1898
|
+
enumerable: true,
|
|
1899
|
+
get: function() {
|
|
1900
|
+
return NoSuchMigrationTarget;
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
Object.defineProperty(exports, "SeedFailed", {
|
|
1904
|
+
enumerable: true,
|
|
1905
|
+
get: function() {
|
|
1906
|
+
return SeedFailed;
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
Object.defineProperty(exports, "SeedHasNoSeed", {
|
|
1910
|
+
enumerable: true,
|
|
1911
|
+
get: function() {
|
|
1912
|
+
return SeedHasNoSeed;
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
Object.defineProperty(exports, "UnknownDatabaseFolder", {
|
|
1916
|
+
enumerable: true,
|
|
1917
|
+
get: function() {
|
|
1918
|
+
return UnknownDatabaseFolder;
|
|
1919
|
+
}
|
|
1920
|
+
});
|
|
1921
|
+
Object.defineProperty(exports, "UnknownMigrationFolder", {
|
|
1922
|
+
enumerable: true,
|
|
1923
|
+
get: function() {
|
|
1924
|
+
return UnknownMigrationFolder;
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
Object.defineProperty(exports, "composeProject", {
|
|
1928
|
+
enumerable: true,
|
|
1929
|
+
get: function() {
|
|
1930
|
+
return composeProject;
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
Object.defineProperty(exports, "composeTarget", {
|
|
1934
|
+
enumerable: true,
|
|
1935
|
+
get: function() {
|
|
1936
|
+
return composeTarget;
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
Object.defineProperty(exports, "compose_exports", {
|
|
1940
|
+
enumerable: true,
|
|
1941
|
+
get: function() {
|
|
1942
|
+
return compose_exports;
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
Object.defineProperty(exports, "dockerCompose", {
|
|
1946
|
+
enumerable: true,
|
|
1947
|
+
get: function() {
|
|
1948
|
+
return dockerCompose;
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
Object.defineProperty(exports, "migrateAndSeed", {
|
|
1952
|
+
enumerable: true,
|
|
1953
|
+
get: function() {
|
|
1954
|
+
return migrateAndSeed;
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
Object.defineProperty(exports, "migrateDatabases", {
|
|
1958
|
+
enumerable: true,
|
|
1959
|
+
get: function() {
|
|
1960
|
+
return migrateDatabases;
|
|
1961
|
+
}
|
|
1962
|
+
});
|
|
1963
|
+
Object.defineProperty(exports, "pendingMigrations", {
|
|
1964
|
+
enumerable: true,
|
|
1965
|
+
get: function() {
|
|
1966
|
+
return pendingMigrations;
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
Object.defineProperty(exports, "seedDatabases", {
|
|
1970
|
+
enumerable: true,
|
|
1971
|
+
get: function() {
|
|
1972
|
+
return seedDatabases;
|
|
1973
|
+
}
|
|
1974
|
+
});
|
|
1975
|
+
Object.defineProperty(exports, "stackDir", {
|
|
1976
|
+
enumerable: true,
|
|
1977
|
+
get: function() {
|
|
1978
|
+
return stackDir;
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
//# sourceMappingURL=compose-391oEKj1.cjs.map
|