@geekmidas/cli 10.0.0-alpha.9 → 10.0.0-alpha.91
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 +315 -414
- package/bin/adjacent-tsconfig.mjs +46 -8
- package/bin/gkm.mjs +11 -3
- package/bin/owning-tsconfig-jsx-register.mjs +18 -0
- package/bin/owning-tsconfig-jsx.mjs +108 -0
- package/dist/EndpointGenerator-CVc6Oi1Y.mjs +1021 -0
- package/dist/EndpointGenerator-CVc6Oi1Y.mjs.map +1 -0
- package/dist/EndpointGenerator-CwLjBCFE.cjs +1080 -0
- package/dist/EndpointGenerator-CwLjBCFE.cjs.map +1 -0
- package/dist/GoDaddyProvider-DSStJVAv.mjs +387 -0
- package/dist/GoDaddyProvider-DSStJVAv.mjs.map +1 -0
- package/dist/GoDaddyProvider-DqZ1qD_F.cjs +387 -0
- package/dist/GoDaddyProvider-DqZ1qD_F.cjs.map +1 -0
- package/dist/{HostingerProvider-Cft7lEYr.mjs → HostingerProvider-BI6J7oJr.mjs} +22 -23
- package/dist/HostingerProvider-BI6J7oJr.mjs.map +1 -0
- package/dist/{HostingerProvider-h3EbEp-Z.cjs → HostingerProvider-CyTpG_jy.cjs} +22 -23
- package/dist/HostingerProvider-CyTpG_jy.cjs.map +1 -0
- package/dist/LegacyStateStore-DXmNA7hy.cjs +89 -0
- package/dist/LegacyStateStore-DXmNA7hy.cjs.map +1 -0
- package/dist/LegacyStateStore-ZkdmiUFZ.mjs +89 -0
- package/dist/LegacyStateStore-ZkdmiUFZ.mjs.map +1 -0
- package/dist/LocalStateStore-BBeTlRTH.cjs +178 -0
- package/dist/LocalStateStore-BBeTlRTH.cjs.map +1 -0
- package/dist/LocalStateStore-CU-O0SBp.mjs +167 -0
- package/dist/LocalStateStore-CU-O0SBp.mjs.map +1 -0
- package/dist/OpenApiTsGenerator-B--HV-sW.cjs +541 -0
- package/dist/OpenApiTsGenerator-B--HV-sW.cjs.map +1 -0
- package/dist/OpenApiTsGenerator-CkvsPdgg.mjs +536 -0
- package/dist/OpenApiTsGenerator-CkvsPdgg.mjs.map +1 -0
- package/dist/{Route53Provider-di2fMM5N.cjs → Route53Provider-Bllus2NR.cjs} +21 -22
- package/dist/Route53Provider-Bllus2NR.cjs.map +1 -0
- package/dist/{Route53Provider-RdpUDJQk.mjs → Route53Provider-QXxjkZO1.mjs} +13 -13
- package/dist/Route53Provider-QXxjkZO1.mjs.map +1 -0
- package/dist/S3StateStore-Br8VMY22.cjs +160 -0
- package/dist/S3StateStore-Br8VMY22.cjs.map +1 -0
- package/dist/S3StateStore-bwxcI_wQ.mjs +161 -0
- package/dist/S3StateStore-bwxcI_wQ.mjs.map +1 -0
- package/dist/SSMStateStore-CsOS0W1y.mjs +154 -0
- package/dist/SSMStateStore-CsOS0W1y.mjs.map +1 -0
- package/dist/SSMStateStore-DoZE1Wi1.cjs +153 -0
- package/dist/SSMStateStore-DoZE1Wi1.cjs.map +1 -0
- package/dist/SubscriberGenerator-BPqQf8fB.mjs +448 -0
- package/dist/SubscriberGenerator-BPqQf8fB.mjs.map +1 -0
- package/dist/SubscriberGenerator-BVn7cQ-2.cjs +489 -0
- package/dist/SubscriberGenerator-BVn7cQ-2.cjs.map +1 -0
- package/dist/add-BSHk5Ngn.mjs +747 -0
- package/dist/add-BSHk5Ngn.mjs.map +1 -0
- package/dist/add-DmmBDV2e.cjs +749 -0
- package/dist/add-DmmBDV2e.cjs.map +1 -0
- package/dist/appPorts-C9z9Pzn9.mjs +223 -0
- package/dist/appPorts-C9z9Pzn9.mjs.map +1 -0
- package/dist/appPorts-Cu79hJF6.cjs +264 -0
- package/dist/appPorts-Cu79hJF6.cjs.map +1 -0
- package/dist/aws-B-6uEhgP.mjs +71 -0
- package/dist/aws-B-6uEhgP.mjs.map +1 -0
- package/dist/aws-BTawF0F-.cjs +82 -0
- package/dist/aws-BTawF0F-.cjs.map +1 -0
- package/dist/awsStore-7VmS_cZR.cjs +54 -0
- package/dist/awsStore-7VmS_cZR.cjs.map +1 -0
- package/dist/awsStore-B529csle.mjs +43 -0
- package/dist/awsStore-B529csle.mjs.map +1 -0
- package/dist/{backup-provisioner-Bgj5Vi_L.mjs → backup-provisioner-C8DnCmxK.mjs} +11 -8
- package/dist/backup-provisioner-C8DnCmxK.mjs.map +1 -0
- package/dist/{backup-provisioner-BrsHwYG0.cjs → backup-provisioner-oDEsPj02.cjs} +22 -20
- package/dist/backup-provisioner-oDEsPj02.cjs.map +1 -0
- package/dist/{bundler-BY1OmV1e.cjs → bundler-BQJiB7Ub.cjs} +38 -35
- package/dist/bundler-BQJiB7Ub.cjs.map +1 -0
- package/dist/{bundler-Be8Zg4Wd.mjs → bundler-D6eBfVkl.mjs} +36 -32
- package/dist/bundler-D6eBfVkl.mjs.map +1 -0
- package/dist/cli-BE9mqeX8.cjs +69 -0
- package/dist/cli-BE9mqeX8.cjs.map +1 -0
- package/dist/cli-DD-Yk-I5.mjs +69 -0
- package/dist/cli-DD-Yk-I5.mjs.map +1 -0
- package/dist/compose-Bhmf0n4o.mjs +3140 -0
- package/dist/compose-Bhmf0n4o.mjs.map +1 -0
- package/dist/compose-CvpU6ooi.cjs +3265 -0
- package/dist/compose-CvpU6ooi.cjs.map +1 -0
- package/dist/config-CdEcTrjG.d.mts +269 -0
- package/dist/config-CdEcTrjG.d.mts.map +1 -0
- package/dist/config-pST7E0CS.d.cts +269 -0
- package/dist/config-pST7E0CS.d.cts.map +1 -0
- package/dist/config.cjs +420 -11
- package/dist/config.cjs.map +1 -0
- package/dist/config.d.cts +4 -120
- package/dist/config.d.mts +4 -120
- package/dist/config.mjs +404 -4
- package/dist/config.mjs.map +1 -0
- package/dist/credentialSchemas-BJqRJNK4.mjs +236 -0
- package/dist/credentialSchemas-BJqRJNK4.mjs.map +1 -0
- package/dist/credentialSchemas-DCR4Wd1W.cjs +271 -0
- package/dist/credentialSchemas-DCR4Wd1W.cjs.map +1 -0
- package/dist/credentials-BKfIugu2.cjs +513 -0
- package/dist/credentials-BKfIugu2.cjs.map +1 -0
- package/dist/credentials-CnVqoXMb.mjs +388 -0
- package/dist/credentials-CnVqoXMb.mjs.map +1 -0
- package/dist/databases-BY-pEImp.cjs +597 -0
- package/dist/databases-BY-pEImp.cjs.map +1 -0
- package/dist/databases-CzszQV0N.mjs +494 -0
- package/dist/databases-CzszQV0N.mjs.map +1 -0
- package/dist/define-DR8xGYfL.mjs +97 -0
- package/dist/define-DR8xGYfL.mjs.map +1 -0
- package/dist/define-DzRz8FgH.cjs +144 -0
- package/dist/define-DzRz8FgH.cjs.map +1 -0
- package/dist/deploy/public.cjs +208 -0
- package/dist/deploy/public.cjs.map +1 -0
- package/dist/deploy/public.d.cts +649 -0
- package/dist/deploy/public.d.cts.map +1 -0
- package/dist/deploy/public.d.mts +649 -0
- package/dist/deploy/public.d.mts.map +1 -0
- package/dist/deploy/public.mjs +142 -0
- package/dist/deploy/public.mjs.map +1 -0
- package/dist/deploy/sniffer-envkit-patch.cjs +18 -10
- package/dist/deploy/sniffer-envkit-patch.cjs.map +1 -1
- package/dist/deploy/sniffer-envkit-patch.d.cts +0 -2
- package/dist/deploy/sniffer-envkit-patch.d.cts.map +1 -1
- package/dist/deploy/sniffer-envkit-patch.d.mts +0 -2
- package/dist/deploy/sniffer-envkit-patch.d.mts.map +1 -1
- package/dist/deploy/sniffer-envkit-patch.mjs +10 -2
- package/dist/deploy/sniffer-envkit-patch.mjs.map +1 -1
- package/dist/deploy/sniffer-envparser-worker.cjs +54 -0
- package/dist/deploy/sniffer-envparser-worker.cjs.map +1 -0
- package/dist/deploy/sniffer-envparser-worker.d.cts +1 -0
- package/dist/deploy/sniffer-envparser-worker.d.mts +1 -0
- package/dist/deploy/sniffer-envparser-worker.mjs +55 -0
- package/dist/deploy/sniffer-envparser-worker.mjs.map +1 -0
- package/dist/deploy/sniffer-hooks.cjs +13 -7
- package/dist/deploy/sniffer-hooks.cjs.map +1 -1
- package/dist/deploy/sniffer-hooks.d.cts +1 -2
- package/dist/deploy/sniffer-hooks.d.cts.map +1 -1
- package/dist/deploy/sniffer-hooks.d.mts +1 -2
- package/dist/deploy/sniffer-hooks.d.mts.map +1 -1
- package/dist/deploy/sniffer-hooks.mjs +10 -3
- package/dist/deploy/sniffer-hooks.mjs.map +1 -1
- package/dist/deploy/sniffer-loader.cjs +16 -7
- package/dist/deploy/sniffer-loader.cjs.map +1 -1
- package/dist/deploy/sniffer-loader.d.cts +1 -1
- package/dist/deploy/sniffer-loader.d.mts +1 -1
- package/dist/deploy/sniffer-loader.mjs +14 -3
- package/dist/deploy/sniffer-loader.mjs.map +1 -1
- package/dist/deploy/sniffer-routes-worker.cjs +11 -11
- package/dist/deploy/sniffer-routes-worker.cjs.map +1 -1
- package/dist/deploy/sniffer-routes-worker.d.cts +1 -1
- package/dist/deploy/sniffer-routes-worker.d.mts +1 -1
- package/dist/deploy/sniffer-routes-worker.mjs +4 -4
- package/dist/deploy/sniffer-routes-worker.mjs.map +1 -1
- package/dist/deploy/sniffer-worker.cjs +15 -6
- package/dist/deploy/sniffer-worker.cjs.map +1 -1
- package/dist/deploy/sniffer-worker.d.cts +1 -3
- package/dist/deploy/sniffer-worker.d.cts.map +1 -1
- package/dist/deploy/sniffer-worker.d.mts +1 -3
- package/dist/deploy/sniffer-worker.d.mts.map +1 -1
- package/dist/deploy/sniffer-worker.mjs +15 -4
- package/dist/deploy/sniffer-worker.mjs.map +1 -1
- package/dist/deploy-CuHasEA4.cjs +554 -0
- package/dist/deploy-CuHasEA4.cjs.map +1 -0
- package/dist/deploy-QNtx_eWR.mjs +525 -0
- package/dist/deploy-QNtx_eWR.mjs.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-BPelzeGW.mjs +526 -0
- package/dist/discover-BPelzeGW.mjs.map +1 -0
- package/dist/discover-CU0paUyH.d.cts +108 -0
- package/dist/discover-CU0paUyH.d.cts.map +1 -0
- package/dist/discover-D1It5r_h.cjs +599 -0
- package/dist/discover-D1It5r_h.cjs.map +1 -0
- package/dist/discover-DFiJxwfv.d.mts +108 -0
- package/dist/discover-DFiJxwfv.d.mts.map +1 -0
- package/dist/dokploy-CYcMLh9-.mjs +38 -0
- package/dist/dokploy-CYcMLh9-.mjs.map +1 -0
- package/dist/dokploy-DIowwyIB.cjs +38 -0
- package/dist/dokploy-DIowwyIB.cjs.map +1 -0
- package/dist/drivers-DTZzxeIl.mjs +177 -0
- package/dist/drivers-DTZzxeIl.mjs.map +1 -0
- package/dist/drivers-LYSdQDmd.cjs +200 -0
- package/dist/drivers-LYSdQDmd.cjs.map +1 -0
- package/dist/{emit-CiZS_5Ub.mjs → emit-C1e8qD08.mjs} +13 -26
- package/dist/emit-C1e8qD08.mjs.map +1 -0
- package/dist/{emit-CbcRolsa.cjs → emit-Dmc1nvdS.cjs} +31 -57
- package/dist/emit-Dmc1nvdS.cjs.map +1 -0
- package/dist/encryption-BrEef7Jk.mjs +88 -0
- package/dist/encryption-BrEef7Jk.mjs.map +1 -0
- package/dist/encryption-CjYdUb2s.cjs +89 -0
- package/dist/encryption-CjYdUb2s.cjs.map +1 -0
- package/dist/engine-2h4k5ICq.mjs +3085 -0
- package/dist/engine-2h4k5ICq.mjs.map +1 -0
- package/dist/engine-BhJ7ZP9K.cjs +3198 -0
- package/dist/engine-BhJ7ZP9K.cjs.map +1 -0
- package/dist/github-B5Bw4HIu.cjs +669 -0
- package/dist/github-B5Bw4HIu.cjs.map +1 -0
- package/dist/github-BCDF6xVH.mjs +669 -0
- package/dist/github-BCDF6xVH.mjs.map +1 -0
- package/dist/{index-C6e-ULLx.d.mts → index-D-F_hTDz.d.cts} +39 -24
- package/dist/index-D-F_hTDz.d.cts.map +1 -0
- package/dist/{index-CR5urhWX.d.cts → index-YFNxR5Ns.d.mts} +39 -24
- package/dist/index-YFNxR5Ns.d.mts.map +1 -0
- package/dist/index.cjs +9487 -12080
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +9420 -12026
- package/dist/index.mjs.map +1 -1
- package/dist/local-BjxDfPvy.cjs +241 -0
- package/dist/local-BjxDfPvy.cjs.map +1 -0
- package/dist/local-IuoIF3CT.mjs +224 -0
- package/dist/local-IuoIF3CT.mjs.map +1 -0
- package/dist/migrate-CGaancHY.cjs +93 -0
- package/dist/migrate-CGaancHY.cjs.map +1 -0
- package/dist/migrate-DWZz9kah.mjs +93 -0
- package/dist/migrate-DWZz9kah.mjs.map +1 -0
- package/dist/migrate-DbtcuOr_.mjs +265 -0
- package/dist/migrate-DbtcuOr_.mjs.map +1 -0
- package/dist/migrate-q2YYLJLE.cjs +270 -0
- package/dist/migrate-q2YYLJLE.cjs.map +1 -0
- package/dist/openapi.cjs +214 -10
- package/dist/openapi.cjs.map +1 -0
- package/dist/openapi.d.cts +47 -17
- package/dist/openapi.d.cts.map +1 -1
- package/dist/openapi.d.mts +47 -17
- package/dist/openapi.d.mts.map +1 -1
- package/dist/openapi.mjs +208 -5
- package/dist/openapi.mjs.map +1 -0
- 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-BpNbORRB.cjs +402 -0
- package/dist/ready-BpNbORRB.cjs.map +1 -0
- package/dist/ready-b6vm0-W-.mjs +373 -0
- package/dist/ready-b6vm0-W-.mjs.map +1 -0
- package/dist/reconcile/public.cjs +3 -5
- package/dist/reconcile/public.d.cts +11 -87
- package/dist/reconcile/public.d.cts.map +1 -1
- package/dist/reconcile/public.d.mts +11 -87
- package/dist/reconcile/public.d.mts.map +1 -1
- package/dist/reconcile/public.mjs +3 -4
- package/dist/{reconcile-D0pimNiy.cjs → reconcile-CuCQBa5-.cjs} +5 -8
- package/dist/reconcile-CuCQBa5-.cjs.map +1 -0
- package/dist/{reconcile-CFs8C_1a.mjs → reconcile-Dh1Jk7WE.mjs} +4 -7
- package/dist/reconcile-Dh1Jk7WE.mjs.map +1 -0
- package/dist/resolve-Cns0v6dN.cjs +315 -0
- package/dist/resolve-Cns0v6dN.cjs.map +1 -0
- package/dist/resolve-DNg0icnh.mjs +262 -0
- package/dist/resolve-DNg0icnh.mjs.map +1 -0
- package/dist/rollback-CFrNLmxs.mjs +132 -0
- package/dist/rollback-CFrNLmxs.mjs.map +1 -0
- package/dist/rollback-UIjS25F1.cjs +161 -0
- package/dist/rollback-UIjS25F1.cjs.map +1 -0
- package/dist/rolldown-runtime-BhDjJH2R.mjs +15 -0
- package/dist/{chunk-CUT6urMc.cjs → rolldown-runtime-C0BPl7ul.cjs} +23 -10
- package/dist/runtime-CN_kmqxo.d.cts +90 -0
- package/dist/runtime-CN_kmqxo.d.cts.map +1 -0
- package/dist/runtime-Cjvon3oj.d.mts +90 -0
- package/dist/runtime-Cjvon3oj.d.mts.map +1 -0
- package/dist/sandbox/config-worker.cjs +43 -0
- package/dist/sandbox/config-worker.cjs.map +1 -0
- package/dist/sandbox/config-worker.d.cts +1 -0
- package/dist/sandbox/config-worker.d.mts +1 -0
- package/dist/sandbox/config-worker.mjs +44 -0
- package/dist/sandbox/config-worker.mjs.map +1 -0
- package/dist/sandbox/credentials-worker.cjs +54 -0
- package/dist/sandbox/credentials-worker.cjs.map +1 -0
- package/dist/sandbox/credentials-worker.d.cts +1 -0
- package/dist/sandbox/credentials-worker.d.mts +1 -0
- package/dist/sandbox/credentials-worker.mjs +55 -0
- package/dist/sandbox/credentials-worker.mjs.map +1 -0
- package/dist/sandbox/discover-worker.cjs +47 -0
- package/dist/sandbox/discover-worker.cjs.map +1 -0
- package/dist/sandbox/discover-worker.d.cts +1 -0
- package/dist/sandbox/discover-worker.d.mts +1 -0
- package/dist/sandbox/discover-worker.mjs +48 -0
- package/dist/sandbox/discover-worker.mjs.map +1 -0
- package/dist/sandbox/migrate-worker.cjs +75 -0
- package/dist/sandbox/migrate-worker.cjs.map +1 -0
- package/dist/sandbox/migrate-worker.d.cts +1 -0
- package/dist/sandbox/migrate-worker.d.mts +1 -0
- package/dist/sandbox/migrate-worker.mjs +76 -0
- package/dist/sandbox/migrate-worker.mjs.map +1 -0
- 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/sandbox-M8UnLhXE.d.cts +126 -0
- package/dist/sandbox-M8UnLhXE.d.cts.map +1 -0
- package/dist/sandbox-M8UnLhXE.d.mts +126 -0
- package/dist/sandbox-M8UnLhXE.d.mts.map +1 -0
- package/dist/{workspace-zoqRbihZ.mjs → schemas-BObwxB_t.cjs} +998 -1659
- package/dist/schemas-BObwxB_t.cjs.map +1 -0
- package/dist/{workspace-C6VJQVtG.cjs → schemas-D-xeKJDD.mjs} +913 -1842
- package/dist/schemas-D-xeKJDD.mjs.map +1 -0
- package/dist/secretsManager-Chs9VV2r.mjs +76 -0
- package/dist/secretsManager-Chs9VV2r.mjs.map +1 -0
- package/dist/secretsManager-DcMlHnRC.cjs +87 -0
- package/dist/secretsManager-DcMlHnRC.cjs.map +1 -0
- package/dist/seeds-CkIPFGNO.cjs +10560 -0
- package/dist/seeds-CkIPFGNO.cjs.map +1 -0
- package/dist/seeds-odfgSCmu.mjs +9538 -0
- package/dist/seeds-odfgSCmu.mjs.map +1 -0
- package/dist/sniffer-BTtX3NUM.mjs +1262 -0
- package/dist/sniffer-BTtX3NUM.mjs.map +1 -0
- package/dist/sniffer-IAQEpsQb.cjs +1333 -0
- package/dist/sniffer-IAQEpsQb.cjs.map +1 -0
- package/dist/sst-BgcOsaBi.cjs +453 -0
- package/dist/sst-BgcOsaBi.cjs.map +1 -0
- package/dist/sst-CJzvyrEz.mjs +430 -0
- package/dist/sst-CJzvyrEz.mjs.map +1 -0
- package/dist/stages-CUn6gvXL.cjs +174 -0
- package/dist/stages-CUn6gvXL.cjs.map +1 -0
- package/dist/stages-CnZRYmkI.mjs +174 -0
- package/dist/stages-CnZRYmkI.mjs.map +1 -0
- package/dist/target/public.cjs +15 -0
- package/dist/target/public.d.cts +28 -0
- package/dist/target/public.d.cts.map +1 -0
- package/dist/target/public.d.mts +28 -0
- package/dist/target/public.d.mts.map +1 -0
- package/dist/target/public.mjs +4 -0
- package/dist/terminal-D8TPK7o9.mjs +84 -0
- package/dist/terminal-D8TPK7o9.mjs.map +1 -0
- package/dist/terminal-DuapRxh6.cjs +84 -0
- package/dist/terminal-DuapRxh6.cjs.map +1 -0
- package/dist/types-NqX1Hz4P.d.cts +7454 -0
- package/dist/types-NqX1Hz4P.d.cts.map +1 -0
- package/dist/types-ZHuZ89hv.d.mts +7453 -0
- package/dist/types-ZHuZ89hv.d.mts.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 +65 -0
- package/dist/vitest.cjs.map +1 -0
- package/dist/vitest.d.cts +29 -0
- package/dist/vitest.d.cts.map +1 -0
- package/dist/vitest.d.mts +29 -0
- package/dist/vitest.d.mts.map +1 -0
- package/dist/vitest.mjs +65 -0
- package/dist/vitest.mjs.map +1 -0
- package/dist/workerRuntime-BXfhbe13.cjs +108 -0
- package/dist/workerRuntime-BXfhbe13.cjs.map +1 -0
- package/dist/workerRuntime-CsOyBRx2.mjs +79 -0
- package/dist/workerRuntime-CsOyBRx2.mjs.map +1 -0
- package/dist/workspace/index.cjs +8 -4
- package/dist/workspace/index.d.cts +3 -3
- package/dist/workspace/index.d.mts +3 -3
- package/dist/workspace/index.mjs +2 -3
- package/dist/workspace-CJ0lKFq-.mjs +1979 -0
- package/dist/workspace-CJ0lKFq-.mjs.map +1 -0
- package/dist/workspace-CwW3Gkru.cjs +2355 -0
- package/dist/workspace-CwW3Gkru.cjs.map +1 -0
- package/package.json +53 -22
- package/dist/CachedStateProvider-BLo7zv7S.cjs +0 -3
- package/dist/CachedStateProvider-BxoAm-B2.mjs +0 -3
- package/dist/CachedStateProvider-CqPdW7CB.cjs +0 -60
- package/dist/CachedStateProvider-CqPdW7CB.cjs.map +0 -1
- package/dist/CachedStateProvider-DTniehMP.mjs +0 -54
- package/dist/CachedStateProvider-DTniehMP.mjs.map +0 -1
- package/dist/HostingerProvider-Cft7lEYr.mjs.map +0 -1
- package/dist/HostingerProvider-h3EbEp-Z.cjs.map +0 -1
- package/dist/LocalStateProvider-Bn6tS-oL.mjs +0 -42
- package/dist/LocalStateProvider-Bn6tS-oL.mjs.map +0 -1
- package/dist/LocalStateProvider-Db9SEjyH.cjs +0 -43
- package/dist/LocalStateProvider-Db9SEjyH.cjs.map +0 -1
- package/dist/Route53Provider-RdpUDJQk.mjs.map +0 -1
- package/dist/Route53Provider-di2fMM5N.cjs.map +0 -1
- package/dist/SSMStateProvider-BRt2py-A.mjs +0 -66
- package/dist/SSMStateProvider-BRt2py-A.mjs.map +0 -1
- package/dist/SSMStateProvider-DyX8s9HI.cjs +0 -66
- package/dist/SSMStateProvider-DyX8s9HI.cjs.map +0 -1
- package/dist/backup-provisioner-Bgj5Vi_L.mjs.map +0 -1
- package/dist/backup-provisioner-BrsHwYG0.cjs.map +0 -1
- package/dist/bundler-BY1OmV1e.cjs.map +0 -1
- package/dist/bundler-Be8Zg4Wd.mjs.map +0 -1
- package/dist/chunk-Duj1WY3L.mjs +0 -7
- package/dist/config-BZ1F0WuA.cjs +0 -462
- package/dist/config-BZ1F0WuA.cjs.map +0 -1
- package/dist/config-DVVph9yz.mjs +0 -402
- package/dist/config-DVVph9yz.mjs.map +0 -1
- package/dist/config.d.cts.map +0 -1
- package/dist/config.d.mts.map +0 -1
- package/dist/credentials-B5zRHdAl.mjs +0 -141
- package/dist/credentials-B5zRHdAl.mjs.map +0 -1
- package/dist/credentials-BapzjyV5.cjs +0 -195
- package/dist/credentials-BapzjyV5.cjs.map +0 -1
- package/dist/credentials-Xamqe_NJ.cjs +0 -2681
- package/dist/credentials-Xamqe_NJ.cjs.map +0 -1
- package/dist/credentials-dP8vRVRv.mjs +0 -2549
- package/dist/credentials-dP8vRVRv.mjs.map +0 -1
- package/dist/discover-CAlFgYec.mjs +0 -229
- package/dist/discover-CAlFgYec.mjs.map +0 -1
- package/dist/discover-DTmFeloL.cjs +0 -271
- package/dist/discover-DTmFeloL.cjs.map +0 -1
- package/dist/dokploy-api-BS2xnJLZ.mjs +0 -3
- package/dist/dokploy-api-BsVH0y_s.cjs +0 -718
- package/dist/dokploy-api-BsVH0y_s.cjs.map +0 -1
- package/dist/dokploy-api-BuxDRo3l.cjs +0 -3
- package/dist/dokploy-api-aTFPz7_4.mjs +0 -706
- package/dist/dokploy-api-aTFPz7_4.mjs.map +0 -1
- package/dist/emit-CbcRolsa.cjs.map +0 -1
- package/dist/emit-CiZS_5Ub.mjs.map +0 -1
- package/dist/encryption-C6jaU0hE.cjs +0 -54
- package/dist/encryption-C6jaU0hE.cjs.map +0 -1
- package/dist/encryption-CHzzeR8a.cjs +0 -4
- package/dist/encryption-CQUncu7v.mjs +0 -3
- package/dist/encryption-D-abyhZx.mjs +0 -42
- package/dist/encryption-D-abyhZx.mjs.map +0 -1
- package/dist/fullstack-secrets-C-GGohke.cjs +0 -111
- package/dist/fullstack-secrets-C-GGohke.cjs.map +0 -1
- package/dist/fullstack-secrets-D1o9-G5l.mjs +0 -87
- package/dist/fullstack-secrets-D1o9-G5l.mjs.map +0 -1
- package/dist/generator-CNzBw7g5.cjs +0 -289
- package/dist/generator-CNzBw7g5.cjs.map +0 -1
- package/dist/generator-VE2JrFlY.mjs +0 -253
- package/dist/generator-VE2JrFlY.mjs.map +0 -1
- package/dist/index-C6e-ULLx.d.mts.map +0 -1
- package/dist/index-CR5urhWX.d.cts.map +0 -1
- package/dist/openapi-SjsgARie.cjs +0 -2075
- package/dist/openapi-SjsgARie.cjs.map +0 -1
- package/dist/openapi-Xx0A7Wg0.mjs +0 -2021
- package/dist/openapi-Xx0A7Wg0.mjs.map +0 -1
- package/dist/openapi-react-query-Byltvm_7.mjs +0 -167
- package/dist/openapi-react-query-Byltvm_7.mjs.map +0 -1
- package/dist/openapi-react-query-CmJ1xDbn.cjs +0 -173
- package/dist/openapi-react-query-CmJ1xDbn.cjs.map +0 -1
- package/dist/openapi-react-query.cjs +0 -4
- package/dist/openapi-react-query.d.cts +0 -11
- package/dist/openapi-react-query.d.cts.map +0 -1
- package/dist/openapi-react-query.d.mts +0 -11
- package/dist/openapi-react-query.d.mts.map +0 -1
- package/dist/openapi-react-query.mjs +0 -4
- package/dist/reconcile-CFs8C_1a.mjs.map +0 -1
- package/dist/reconcile-D0pimNiy.cjs.map +0 -1
- package/dist/setup-BA8ciTPU.mjs +0 -11
- package/dist/setup-BsZvZP6f.cjs +0 -11
- package/dist/setup-C8KOx8_8.mjs +0 -443
- package/dist/setup-C8KOx8_8.mjs.map +0 -1
- package/dist/setup-hC6hI86f.cjs +0 -473
- package/dist/setup-hC6hI86f.cjs.map +0 -1
- package/dist/storage-BJmzYxt8.cjs +0 -363
- package/dist/storage-BJmzYxt8.cjs.map +0 -1
- package/dist/storage-BM9hGpgv.cjs +0 -7
- package/dist/storage-Bi24WfoW.mjs +0 -297
- package/dist/storage-Bi24WfoW.mjs.map +0 -1
- package/dist/storage-CYlGwWTj.mjs +0 -3
- package/dist/sync-CmEuNZsB.mjs +0 -76
- package/dist/sync-CmEuNZsB.mjs.map +0 -1
- package/dist/sync-D306yOWu.cjs +0 -4
- package/dist/sync-SizaChmC.cjs +0 -93
- package/dist/sync-SizaChmC.cjs.map +0 -1
- package/dist/sync-hOqAWHR6.mjs +0 -3
- package/dist/types-6pkp2RRj.d.mts +0 -2220
- package/dist/types-6pkp2RRj.d.mts.map +0 -1
- package/dist/types-Y3fKQSAK.d.cts +0 -2220
- package/dist/types-Y3fKQSAK.d.cts.map +0 -1
- package/dist/workspace-BzrG_2SI.mjs +0 -8
- package/dist/workspace-C6VJQVtG.cjs.map +0 -1
- package/dist/workspace-XXtNhqJl.cjs +0 -9
- package/dist/workspace-zoqRbihZ.mjs.map +0 -1
|
@@ -0,0 +1,3140 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
|
|
2
|
+
import { c as runOutput, s as run, x as gkmHome } from "./credentials-CnVqoXMb.mjs";
|
|
3
|
+
import { r as discover } from "./discover-BPelzeGW.mjs";
|
|
4
|
+
import { t as appKey } from "./derive-B4vDpzgP.mjs";
|
|
5
|
+
import { B as LOGS_PORT, I as otlpTelemetryEnv, L as resolveStageTelemetry, at as SERVER_IPV6_KEY, it as SERVER_IPV4_KEY, nt as certificateFor, ot as cnameTarget, rt as proxyFor, st as isReservedStageKey, tt as ComposeTlsFileMissing, z as telemetryEnv } from "./workspace-CJ0lKFq-.mjs";
|
|
6
|
+
import { t as defineTarget } from "./define-DR8xGYfL.mjs";
|
|
7
|
+
import { At as applyPolicies, Bt as appEnvKeys, Ct as redisService, Et as withRedisPassword, F as createDnsProvider, Gt as localRolePassword, Ht as networkEnv, It as LEGACY_LOCAL_LOGIN, Jn as initStageSecrets, Jt as TURBO_VERSION, Kt as composeBuildPaths, Lt as ensurePostgresLogin, Mt as bucketPolicies, O as recordRelease, P as DnsRecordsUnreadable, Pt as postgresStatements, Q as devServicesUsed, Rt as bucketClient, S as normalizeDnsConfig, Sn as primaryPortKey, St as STACK_CACHE, T as createEmptyState, Tt as stackRedis, Un as imageRef, Ut as workerEnvKeys, Wt as envFor, Yt as WORKER_PORT, Z as assertExternalServices, Zt as findBuildRoot, _ as workerDockerfile, _n as DEFAULT_IMAGES, _t as loadLocalCredentials, an as composeFor, ar as NoDomainForStage, b as extractSubdomain, bn as portKeys, bt as withGeneratedSecrets, cn as planFor, d as stageProviderNotes, er as LOGS_SERVICE, fn as isTelemetryKey, ft as constructGlobs, g as ensureDockerignore, gn as usesTelemetry, gt as LOCAL_RABBITMQ_USER, hn as telemetryOf, i as assertNoStaleSecrets, ir as withLogsPassword, it as workerUnits, jt as applyPostgres, kt as applyBuckets, l as assertStageProvidersEnabled, m as appDockerfile, n as reportDatabaseRuns, nr as logsService, o as verifyStageProviders, on as EMULATOR_ACCESS_KEY_ID, or as isMainFrontendApp, pn as scopeTelemetryEnv, qt as imageLayout, r as reportPlannedSeeds, rr as stackLogs, rt as suppliedOnly, s as DeployJournal, sr as resolveHost, t as DeploySeedsFailed, tr as logsAccess, tt as reportDevServices, un as hostFor, vt as postgresSuperuser, wt as runsRedis, x as findRootDomain, xt as REDIS_SERVICE, y as validateImageRef, zt as pgClient } from "./seeds-odfgSCmu.mjs";
|
|
8
|
+
import { i as DEFAULT_EVENTS } from "./SubscriberGenerator-BPqQf8fB.mjs";
|
|
9
|
+
import { i as installedFrom } from "./drivers-DTZzxeIl.mjs";
|
|
10
|
+
import { f as plannedSeeds, o as SeedFailed, p as seedDatabases, u as migrateDatabases } from "./databases-CzszQV0N.mjs";
|
|
11
|
+
import { n as assertStageCredentials } from "./credentialSchemas-BJqRJNK4.mjs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { hostname, userInfo } from "node:os";
|
|
16
|
+
import { CLIENT_IP_HEADER, provideKey, provisionOrder, publicEnvFor } from "@geekmidas/manifest";
|
|
17
|
+
import { isIPv4, isIPv6 } from "node:net";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { stringify } from "yaml";
|
|
21
|
+
import { lookup } from "node:dns/promises";
|
|
22
|
+
import { request } from "node:https";
|
|
23
|
+
//#region src/compose/dns.ts
|
|
24
|
+
/**
|
|
25
|
+
* A compose stage's DNS: the records its public hosts need, and the check that
|
|
26
|
+
* they resolve to its server before a certificate is asked for.
|
|
27
|
+
*
|
|
28
|
+
* - **The server** (`serverAddress`): the stage's own secrets hold its public
|
|
29
|
+
* addresses — `GKM_SERVER_IPV4`, and `GKM_SERVER_IPV6` where it has one.
|
|
30
|
+
* Reserved for gkm: never handed to an app.
|
|
31
|
+
* - **The plan** (`planStackDns`, pure): every public host the stack serves —
|
|
32
|
+
* its apex, each app, each file server, the public log UI — pointed at the
|
|
33
|
+
* server. An A record per host (and an AAAA with an IPv6 address), or, where
|
|
34
|
+
* the domain's `dns` entry says `records: { mode: 'cname', target }`, one A
|
|
35
|
+
* record for the target and a CNAME to it for every other host but the
|
|
36
|
+
* apex, which DNS forbids a CNAME at.
|
|
37
|
+
* - **Applying it** (`applyStackDns`): through the domain's provider in `dns`,
|
|
38
|
+
* a record set at a time. A record with the right value is left alone; one
|
|
39
|
+
* with another value is replaced and the change printed. A `manual` domain
|
|
40
|
+
* is printed and nothing else. A dry run reads, prints and writes nothing.
|
|
41
|
+
* - **The check** (`checkStackDns`): each host resolved with the system
|
|
42
|
+
* resolver. One that is not the server stops the deploy with
|
|
43
|
+
* {@link HostNotPointingAtServer}, before Caddy or Traefik ask Let's Encrypt
|
|
44
|
+
* for a certificate it cannot get — and use up the rate limit trying.
|
|
45
|
+
*/
|
|
46
|
+
/** The line that says how to set the server's address. */
|
|
47
|
+
function serverAddressHint(stage) {
|
|
48
|
+
return `gkm secrets:set ${SERVER_IPV4_KEY} '<ip>' --stage ${stage}`;
|
|
49
|
+
}
|
|
50
|
+
/** A server address in the stage's secrets that is not an address. */
|
|
51
|
+
var ServerAddressInvalid = class extends Error {
|
|
52
|
+
stage;
|
|
53
|
+
key;
|
|
54
|
+
value;
|
|
55
|
+
constructor(stage, key, value) {
|
|
56
|
+
const ipv4 = key === SERVER_IPV4_KEY;
|
|
57
|
+
super(`${key} in the '${stage}' stage's secrets is ${JSON.stringify(value)}, which is not an ${ipv4 ? "IPv4 address (like 203.0.113.10)" : "IPv6 address (like 2001:db8::10)"}. Set the server's public address: gkm secrets:set ${key} '<ip>' --stage ${stage}.`);
|
|
58
|
+
this.stage = stage;
|
|
59
|
+
this.key = key;
|
|
60
|
+
this.value = value;
|
|
61
|
+
this.name = "ServerAddressInvalid";
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* A compose stage that serves real domains with no server address: its hosts
|
|
66
|
+
* cannot be pointed or checked, and a certificate would be asked for blind.
|
|
67
|
+
*/
|
|
68
|
+
var ServerAddressMissing = class extends Error {
|
|
69
|
+
stage;
|
|
70
|
+
domain;
|
|
71
|
+
constructor(stage, domain) {
|
|
72
|
+
super(`The '${stage}' stage serves ${domain} and its secrets hold no ${SERVER_IPV4_KEY} — the public address of the server its stack runs on, which its hosts' DNS records point at and every deploy checks before asking for certificates. Set it: ${serverAddressHint(stage)} (and ${SERVER_IPV6_KEY} for an IPv6 address).`);
|
|
73
|
+
this.stage = stage;
|
|
74
|
+
this.domain = domain;
|
|
75
|
+
this.name = "ServerAddressMissing";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* The server's address from the stage's secrets, checked — `undefined` when
|
|
80
|
+
* `GKM_SERVER_IPV4` is not set.
|
|
81
|
+
*
|
|
82
|
+
* @throws {ServerAddressInvalid} for a value that is not an address
|
|
83
|
+
*/
|
|
84
|
+
function serverAddress(stage, custom) {
|
|
85
|
+
const ipv4 = custom?.[SERVER_IPV4_KEY]?.trim();
|
|
86
|
+
const ipv6 = custom?.[SERVER_IPV6_KEY]?.trim();
|
|
87
|
+
if (ipv6 && !isIPv6(ipv6)) throw new ServerAddressInvalid(stage, SERVER_IPV6_KEY, ipv6);
|
|
88
|
+
if (!ipv4) return void 0;
|
|
89
|
+
if (!isIPv4(ipv4)) throw new ServerAddressInvalid(stage, SERVER_IPV4_KEY, ipv4);
|
|
90
|
+
return {
|
|
91
|
+
ipv4,
|
|
92
|
+
...ipv6 ? { ipv6 } : {}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A name that resolves to this machine by definition — `localhost` and every
|
|
97
|
+
* `*.localhost` — and is in nobody's DNS.
|
|
98
|
+
*/
|
|
99
|
+
function isLocalHost(host) {
|
|
100
|
+
const name = host.toLowerCase().replace(/\.$/, "");
|
|
101
|
+
return name === "localhost" || name.endsWith(".localhost");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The server's address, required where the stage serves real domains — not
|
|
105
|
+
* `*.localhost` ones, which point at this machine already.
|
|
106
|
+
*
|
|
107
|
+
* @throws {ServerAddressMissing} when the stage has a domain and no address
|
|
108
|
+
* @throws {ServerAddressInvalid} for a value that is not an address
|
|
109
|
+
*/
|
|
110
|
+
function requiredServerAddress(stage, domain, custom) {
|
|
111
|
+
const server = serverAddress(stage, custom);
|
|
112
|
+
if (!server && domain && !isLocalHost(domain)) throw new ServerAddressMissing(stage, domain);
|
|
113
|
+
return server;
|
|
114
|
+
}
|
|
115
|
+
const MANAGED = [
|
|
116
|
+
"A",
|
|
117
|
+
"AAAA",
|
|
118
|
+
"CNAME"
|
|
119
|
+
];
|
|
120
|
+
/** Every public host the stack serves, once each, sorted. */
|
|
121
|
+
function stackHosts(stack) {
|
|
122
|
+
return [...new Set(stack.routes.map((route) => route.host))].sort();
|
|
123
|
+
}
|
|
124
|
+
/** The TTL a domain's records are written with. */
|
|
125
|
+
function recordTtl(config) {
|
|
126
|
+
const ttl = "ttl" in config ? config.ttl : void 0;
|
|
127
|
+
if (ttl) return ttl;
|
|
128
|
+
return config.provider === "godaddy" ? 600 : 300;
|
|
129
|
+
}
|
|
130
|
+
function providerName(config) {
|
|
131
|
+
return typeof config.provider === "string" ? config.provider : "custom";
|
|
132
|
+
}
|
|
133
|
+
/** The records a stage's hosts need — pure. */
|
|
134
|
+
function planStackDns(input) {
|
|
135
|
+
const { stage, server } = input;
|
|
136
|
+
const dns = input.dns ? normalizeDnsConfig(input.dns) : {};
|
|
137
|
+
const records = /* @__PURE__ */ new Map();
|
|
138
|
+
const uncovered = [];
|
|
139
|
+
const add = (host, domain, type, value) => {
|
|
140
|
+
const name = extractSubdomain(host, domain);
|
|
141
|
+
records.set(`${domain} ${name} ${type}`, {
|
|
142
|
+
host,
|
|
143
|
+
domain,
|
|
144
|
+
name,
|
|
145
|
+
type,
|
|
146
|
+
value,
|
|
147
|
+
ttl: recordTtl(dns[domain])
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
const pointAt = (host, domain) => {
|
|
151
|
+
add(host, domain, "A", server.ipv4);
|
|
152
|
+
if (server.ipv6) add(host, domain, "AAAA", server.ipv6);
|
|
153
|
+
};
|
|
154
|
+
for (const raw of input.hosts) {
|
|
155
|
+
const host = raw.toLowerCase();
|
|
156
|
+
const domain = findRootDomain(host, dns);
|
|
157
|
+
if (!domain) {
|
|
158
|
+
uncovered.push(host);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const target = cnameTarget(dns[domain].records, stage);
|
|
162
|
+
if (target) pointAt(target, domain);
|
|
163
|
+
if (target && host !== domain && host !== target) add(host, domain, "CNAME", target);
|
|
164
|
+
else pointAt(host, domain);
|
|
165
|
+
}
|
|
166
|
+
const domains = /* @__PURE__ */ new Map();
|
|
167
|
+
for (const record of [...records.values()].sort((a, b) => a.domain.localeCompare(b.domain) || a.name.localeCompare(b.name) || a.type.localeCompare(b.type))) {
|
|
168
|
+
const config = dns[record.domain];
|
|
169
|
+
const plan = domains.get(record.domain) ?? {
|
|
170
|
+
domain: record.domain,
|
|
171
|
+
provider: providerName(config),
|
|
172
|
+
config,
|
|
173
|
+
records: []
|
|
174
|
+
};
|
|
175
|
+
plan.records.push(record);
|
|
176
|
+
domains.set(record.domain, plan);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
stage,
|
|
180
|
+
server,
|
|
181
|
+
domains: [...domains.values()],
|
|
182
|
+
uncovered: uncovered.sort()
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** `api.example.com A 203.0.113.10 (TTL 600)` */
|
|
186
|
+
function describe(record) {
|
|
187
|
+
return `${record.host.padEnd(32)} ${record.type.padEnd(5)} ${record.value} (TTL ${record.ttl})`;
|
|
188
|
+
}
|
|
189
|
+
function printManual(plan, log, why) {
|
|
190
|
+
log(` ${plan.domain}: ${why} — create these records at its DNS host:`);
|
|
191
|
+
for (const record of plan.records) log(` ${describe(record)}`);
|
|
192
|
+
return plan.records.map((r) => ({
|
|
193
|
+
domain: r.domain,
|
|
194
|
+
name: r.name,
|
|
195
|
+
type: r.type,
|
|
196
|
+
action: "manual",
|
|
197
|
+
value: r.value,
|
|
198
|
+
ttl: r.ttl
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
/** The existing records of every planned name, for the types gkm manages. */
|
|
202
|
+
async function existingRecords(provider, plan) {
|
|
203
|
+
const names = [...new Set(plan.records.map((r) => r.name))];
|
|
204
|
+
const wanted = names.flatMap((name) => MANAGED.map((type) => ({
|
|
205
|
+
name,
|
|
206
|
+
type
|
|
207
|
+
})));
|
|
208
|
+
if (provider.readRecords) return provider.readRecords(plan.domain, wanted);
|
|
209
|
+
return (await provider.getRecords(plan.domain)).filter((r) => names.includes(r.name) && MANAGED.includes(r.type));
|
|
210
|
+
}
|
|
211
|
+
const same = (a, b) => a.trim().replace(/\.$/, "").toLowerCase() === b.trim().replace(/\.$/, "").toLowerCase();
|
|
212
|
+
/**
|
|
213
|
+
* What each domain's records need, compared with what is there: creates,
|
|
214
|
+
* updates, and the deletes a change of mode needs — a name moving from an A
|
|
215
|
+
* record to a CNAME, or back, cannot hold both. Nothing else is deleted.
|
|
216
|
+
*/
|
|
217
|
+
function diffDomainDns(plan, existing) {
|
|
218
|
+
const changes = [];
|
|
219
|
+
const find = (name, type) => existing.find((r) => r.name === name && r.type === type);
|
|
220
|
+
const planned = /* @__PURE__ */ new Map();
|
|
221
|
+
for (const record of plan.records) {
|
|
222
|
+
const types = planned.get(record.name) ?? /* @__PURE__ */ new Set();
|
|
223
|
+
types.add(record.type);
|
|
224
|
+
planned.set(record.name, types);
|
|
225
|
+
}
|
|
226
|
+
for (const [name, types] of planned) {
|
|
227
|
+
const conflicting = types.has("CNAME") ? ["A", "AAAA"] : ["CNAME"];
|
|
228
|
+
for (const type of conflicting) {
|
|
229
|
+
const found = find(name, type);
|
|
230
|
+
if (!found || found.values.length === 0) continue;
|
|
231
|
+
changes.push({
|
|
232
|
+
domain: plan.domain,
|
|
233
|
+
name,
|
|
234
|
+
type,
|
|
235
|
+
action: "delete",
|
|
236
|
+
previous: found.values.join(", ")
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const record of plan.records) {
|
|
241
|
+
const found = find(record.name, record.type);
|
|
242
|
+
const base = {
|
|
243
|
+
domain: plan.domain,
|
|
244
|
+
name: record.name,
|
|
245
|
+
type: record.type,
|
|
246
|
+
value: record.value,
|
|
247
|
+
ttl: record.ttl
|
|
248
|
+
};
|
|
249
|
+
if (!found || found.values.length === 0) changes.push({
|
|
250
|
+
...base,
|
|
251
|
+
action: "create"
|
|
252
|
+
});
|
|
253
|
+
else if (found.values.length === 1 && same(found.values[0], record.value)) changes.push({
|
|
254
|
+
...base,
|
|
255
|
+
action: "unchanged"
|
|
256
|
+
});
|
|
257
|
+
else changes.push({
|
|
258
|
+
...base,
|
|
259
|
+
action: "update",
|
|
260
|
+
previous: found.values.join(", ")
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return changes;
|
|
264
|
+
}
|
|
265
|
+
/** A host's full name from a domain and a relative name. */
|
|
266
|
+
function fullName(domain, name) {
|
|
267
|
+
return name === "@" ? domain : `${name}.${domain}`;
|
|
268
|
+
}
|
|
269
|
+
function printChange(change, dryRun, log) {
|
|
270
|
+
const host = fullName(change.domain, change.name).padEnd(32);
|
|
271
|
+
const type = change.type.padEnd(5);
|
|
272
|
+
const would = dryRun ? "would " : "";
|
|
273
|
+
switch (change.action) {
|
|
274
|
+
case "unchanged":
|
|
275
|
+
log(` ✓ ${host} ${type} ${change.value} (TTL ${change.ttl}) — up to date`);
|
|
276
|
+
break;
|
|
277
|
+
case "create":
|
|
278
|
+
log(` + ${host} ${type} ${change.value} (TTL ${change.ttl}) — ${would}create`);
|
|
279
|
+
break;
|
|
280
|
+
case "update":
|
|
281
|
+
log(` ~ ${host} ${type} ${change.previous} → ${change.value} (TTL ${change.ttl}) — ${would}update`);
|
|
282
|
+
break;
|
|
283
|
+
case "write":
|
|
284
|
+
log(dryRun ? ` ? ${host} ${type} ${change.value} (TTL ${change.ttl}) — would write; current value unknown (key cannot read DNS)` : ` ! ${host} ${type} ${change.value} (TTL ${change.ttl}) — written (not diffed)`);
|
|
285
|
+
break;
|
|
286
|
+
case "delete": log(` - ${host} ${type} ${change.previous} — ${would}delete (a name cannot hold a CNAME and an A record)`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Each domain's records, through its provider. Returns every change, the
|
|
291
|
+
* unchanged included; a dry run makes none of them.
|
|
292
|
+
*/
|
|
293
|
+
async function applyStackDns(plan, options = {}) {
|
|
294
|
+
const dryRun = options.dryRun === true;
|
|
295
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
296
|
+
const providerFor = options.providerFor ?? ((config) => createDnsProvider({
|
|
297
|
+
config,
|
|
298
|
+
...options.credentials ? { godaddy: options.credentials } : {}
|
|
299
|
+
}));
|
|
300
|
+
const changes = [];
|
|
301
|
+
for (const host of plan.uncovered) log(` ⚠ ${host} is under no domain in dns — point it at ${plan.server.ipv4} yourself`);
|
|
302
|
+
for (const domain of plan.domains) {
|
|
303
|
+
if (domain.provider === "manual") {
|
|
304
|
+
changes.push(...printManual(domain, log, "manual"));
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const provider = await providerFor(domain.config);
|
|
308
|
+
if (!provider) {
|
|
309
|
+
changes.push(...printManual(domain, log, "manual"));
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
log(` ${domain.domain} (${provider.name})${dryRun ? " — dry run" : ""}`);
|
|
313
|
+
let existing;
|
|
314
|
+
let diff;
|
|
315
|
+
try {
|
|
316
|
+
existing = await existingRecords(provider, domain);
|
|
317
|
+
diff = diffDomainDns(domain, existing);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (!(error instanceof DnsRecordsUnreadable)) throw error;
|
|
320
|
+
log(` ℹ The ${provider.name} key cannot read ${domain.domain}'s DNS records, so changes cannot be diffed: every record is written (idempotent).`);
|
|
321
|
+
existing = [];
|
|
322
|
+
diff = domain.records.map((r) => ({
|
|
323
|
+
domain: r.domain,
|
|
324
|
+
name: r.name,
|
|
325
|
+
type: r.type,
|
|
326
|
+
action: "write",
|
|
327
|
+
value: r.value,
|
|
328
|
+
ttl: r.ttl
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
for (const change of diff) printChange(change, dryRun, log);
|
|
332
|
+
if (!plan.server.ipv6) for (const name of new Set(domain.records.map((r) => r.name))) {
|
|
333
|
+
const aaaa = existing.find((r) => r.name === name && r.type === "AAAA");
|
|
334
|
+
const planned = domain.records.find((r) => r.name === name && r.type === "A");
|
|
335
|
+
if (aaaa && planned && aaaa.values.length > 0) log(` ⚠ ${fullName(domain.domain, name)} also has AAAA ${aaaa.values.join(", ")}: set ${SERVER_IPV6_KEY} in the '${plan.stage}' stage's secrets to the server's IPv6 address, or remove that record, or IPv6 clients go elsewhere`);
|
|
336
|
+
}
|
|
337
|
+
changes.push(...diff);
|
|
338
|
+
if (dryRun) continue;
|
|
339
|
+
const deletes = diff.filter((c) => c.action === "delete");
|
|
340
|
+
if (deletes.length > 0) await provider.deleteRecords(domain.domain, deletes.map((c) => ({
|
|
341
|
+
name: c.name,
|
|
342
|
+
type: c.type
|
|
343
|
+
})));
|
|
344
|
+
const writes = diff.filter((c) => c.action === "create" || c.action === "update" || c.action === "write");
|
|
345
|
+
if (writes.length > 0) await provider.upsertRecords(domain.domain, writes.map((c) => ({
|
|
346
|
+
name: c.name,
|
|
347
|
+
type: c.type,
|
|
348
|
+
value: c.value,
|
|
349
|
+
ttl: c.ttl
|
|
350
|
+
})));
|
|
351
|
+
}
|
|
352
|
+
return changes;
|
|
353
|
+
}
|
|
354
|
+
/** The system resolver: what the server's own clients would be told. */
|
|
355
|
+
const systemLookup = async (host) => {
|
|
356
|
+
return (await lookup(host, { all: true })).map((a) => a.address);
|
|
357
|
+
};
|
|
358
|
+
/** Hosts that do not resolve to the stage's server. */
|
|
359
|
+
var HostNotPointingAtServer = class extends Error {
|
|
360
|
+
stage;
|
|
361
|
+
hosts;
|
|
362
|
+
expected;
|
|
363
|
+
fix;
|
|
364
|
+
constructor(stage, hosts, expected, fix) {
|
|
365
|
+
const want = expected.ipv6 ? `${expected.ipv4} (and ${expected.ipv6})` : expected.ipv4;
|
|
366
|
+
const lines = hosts.map((h) => ` - ${h.host} → ${h.resolved.length > 0 ? h.resolved.join(", ") : `nothing${h.error ? ` (${h.error})` : ""}`}, expected ${want}`);
|
|
367
|
+
super(`${hosts.length === 1 ? "A host" : `${hosts.length} hosts`} of '${stage}' do${hosts.length === 1 ? "es" : ""} not resolve to its server, so a certificate for ${hosts.length === 1 ? "it" : "them"} cannot be issued:\n${lines.join("\n")}\n${fix}\nRecords can take a few minutes to propagate. With a CDN or proxy in front of the server, pass --skip-dns-check.`);
|
|
368
|
+
this.stage = stage;
|
|
369
|
+
this.hosts = hosts;
|
|
370
|
+
this.expected = expected;
|
|
371
|
+
this.fix = fix;
|
|
372
|
+
this.name = "HostNotPointingAtServer";
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
/**
|
|
376
|
+
* Every host resolved: each must resolve to the server's IPv4 address and to
|
|
377
|
+
* nothing but the server's addresses.
|
|
378
|
+
*
|
|
379
|
+
* @throws {HostNotPointingAtServer} naming every host that does not
|
|
380
|
+
*/
|
|
381
|
+
async function checkStackDns(input) {
|
|
382
|
+
const lookup = input.lookup ?? systemLookup;
|
|
383
|
+
const { server } = input;
|
|
384
|
+
const ours = new Set([server.ipv4, server.ipv6].filter(Boolean).map((a) => a.toLowerCase()));
|
|
385
|
+
const bad = [];
|
|
386
|
+
const ok = [];
|
|
387
|
+
for (const host of input.hosts) {
|
|
388
|
+
let resolved;
|
|
389
|
+
try {
|
|
390
|
+
resolved = [...new Set(await lookup(host))];
|
|
391
|
+
} catch (error) {
|
|
392
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : error instanceof Error ? error.message : String(error);
|
|
393
|
+
bad.push({
|
|
394
|
+
host,
|
|
395
|
+
resolved: [],
|
|
396
|
+
error: code
|
|
397
|
+
});
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (resolved.some((a) => a.toLowerCase() === server.ipv4.toLowerCase()) && resolved.every((a) => ours.has(a.toLowerCase()))) ok.push(host);
|
|
401
|
+
else bad.push({
|
|
402
|
+
host,
|
|
403
|
+
resolved
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (bad.length > 0) throw new HostNotPointingAtServer(input.stage, bad, {
|
|
407
|
+
ipv4: server.ipv4,
|
|
408
|
+
...server.ipv6 ? { ipv6: server.ipv6 } : {}
|
|
409
|
+
}, dnsFix(input, bad));
|
|
410
|
+
return ok;
|
|
411
|
+
}
|
|
412
|
+
/** How to fix it: `gkm setup`, or the records to create by hand. */
|
|
413
|
+
function dnsFix(input, bad) {
|
|
414
|
+
const hosts = bad.map((h) => h.host);
|
|
415
|
+
let plan;
|
|
416
|
+
try {
|
|
417
|
+
plan = planStackDns({
|
|
418
|
+
stage: input.stage,
|
|
419
|
+
hosts,
|
|
420
|
+
server: input.server,
|
|
421
|
+
dns: input.dns
|
|
422
|
+
});
|
|
423
|
+
} catch {
|
|
424
|
+
plan = {
|
|
425
|
+
stage: input.stage,
|
|
426
|
+
server: input.server,
|
|
427
|
+
domains: [],
|
|
428
|
+
uncovered: hosts
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const automatic = plan.domains.filter((d) => d.provider !== "manual");
|
|
432
|
+
const byHand = [...plan.domains.filter((d) => d.provider === "manual").flatMap((d) => d.records.map(describe)), ...plan.uncovered.flatMap((host) => [`${host.padEnd(32)} A ${input.server.ipv4}`, ...input.server.ipv6 ? [`${host.padEnd(32)} AAAA ${input.server.ipv6}`] : []])];
|
|
433
|
+
const lines = [];
|
|
434
|
+
if (automatic.length > 0) lines.push(`Fix: gkm setup --stage ${input.stage} writes the records for ${automatic.map((d) => d.domain).join(", ")} (gkm setup --stage ${input.stage} --dry-run shows them first).`);
|
|
435
|
+
if (byHand.length > 0) lines.push(`${automatic.length > 0 ? "And create" : "Fix: create"} these records at the DNS host:`, ...byHand.map((line) => ` ${line}`));
|
|
436
|
+
return lines.join("\n");
|
|
437
|
+
}
|
|
438
|
+
//#endregion
|
|
439
|
+
//#region src/compose/docker.ts
|
|
440
|
+
/**
|
|
441
|
+
* The Docker operations `gkm compose` needs, behind an interface.
|
|
442
|
+
*
|
|
443
|
+
* Small on purpose, like reconcile's: the rules about *when* each runs — the
|
|
444
|
+
* registry is asked before anything is pulled, nothing is started for a tag
|
|
445
|
+
* that is missing — are asserted against a fake, and this file is only the
|
|
446
|
+
* wiring to the real CLI.
|
|
447
|
+
*
|
|
448
|
+
* Every call is an argument array. Image refs, tags and paths come from the
|
|
449
|
+
* workspace, and none of them reaches a shell.
|
|
450
|
+
*/
|
|
451
|
+
/** `output` as `spawn` reads it. */
|
|
452
|
+
const STDIO = {
|
|
453
|
+
inherit: "inherit",
|
|
454
|
+
stderr: [
|
|
455
|
+
"ignore",
|
|
456
|
+
2,
|
|
457
|
+
2
|
|
458
|
+
],
|
|
459
|
+
ignore: "ignore"
|
|
460
|
+
};
|
|
461
|
+
/** How a stack's docker commands run: where it is, where output goes. */
|
|
462
|
+
function runOptions(stack, env) {
|
|
463
|
+
return {
|
|
464
|
+
cwd: stack.cwd,
|
|
465
|
+
stdio: STDIO[stack.output ?? "inherit"],
|
|
466
|
+
...env ? { env } : {},
|
|
467
|
+
...stack.signal ? { signal: stack.signal } : {}
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The host ports a `docker ps` Ports column publishes: `0.0.0.0:443->443/tcp,
|
|
472
|
+
* [::]:443->443/tcp, 0.0.0.0:9000-9001->9000-9001/tcp`.
|
|
473
|
+
*/
|
|
474
|
+
function publishedPorts(column) {
|
|
475
|
+
const ports = /* @__PURE__ */ new Set();
|
|
476
|
+
for (const match of column.matchAll(/(?:[\d.]+|\[[^\]]*\]):(\d+)(?:-(\d+))?->/g)) {
|
|
477
|
+
const first = Number(match[1]);
|
|
478
|
+
const last = match[2] ? Number(match[2]) : first;
|
|
479
|
+
for (let port = first; port <= last; port++) ports.add(port);
|
|
480
|
+
}
|
|
481
|
+
return [...ports];
|
|
482
|
+
}
|
|
483
|
+
/** Each line `docker ps` printed, as the containers holding `port`. */
|
|
484
|
+
function portHolders(output, port) {
|
|
485
|
+
return output.split("\n").filter(Boolean).map((line) => line.split(" ")).filter(([, , , column = ""]) => publishedPorts(column).includes(port)).map(([container = "", project, service]) => ({
|
|
486
|
+
container,
|
|
487
|
+
...project ? { project } : {},
|
|
488
|
+
...service ? { service } : {}
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
/** How long a stack has to pass its health checks. */
|
|
492
|
+
const WAIT_TIMEOUT_S = 180;
|
|
493
|
+
/** `docker compose port` printed nothing a port can be read from. */
|
|
494
|
+
var ServicePortUnknown = class extends Error {
|
|
495
|
+
service;
|
|
496
|
+
inside;
|
|
497
|
+
output;
|
|
498
|
+
constructor(service, inside, output) {
|
|
499
|
+
super(`'${service}' publishes no host port for ${inside} (docker said: ${output.trim() || "nothing"}). Is it running? \`docker compose ps\` shows the stack.`);
|
|
500
|
+
this.service = service;
|
|
501
|
+
this.inside = inside;
|
|
502
|
+
this.output = output;
|
|
503
|
+
this.name = "ServicePortUnknown";
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
/**
|
|
507
|
+
* Whether `ref` names a registry on this machine's loopback — which the
|
|
508
|
+
* daemon pulls from and pushes to over plain HTTP by default, and `docker
|
|
509
|
+
* manifest inspect` reaches only when told `--insecure` (without it, a local
|
|
510
|
+
* registry's image is reported as no such manifest).
|
|
511
|
+
*/
|
|
512
|
+
function isLoopbackRegistry(ref) {
|
|
513
|
+
const first = ref.split("/")[0];
|
|
514
|
+
if (!ref.includes("/") || !/[.:]|^localhost$/.test(first)) return false;
|
|
515
|
+
const host = first.replace(/:\d+$/, "");
|
|
516
|
+
return host === "localhost" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
517
|
+
}
|
|
518
|
+
/** `docker push` finished without saying what digest the registry stored. */
|
|
519
|
+
var PushDigestUnknown = class extends Error {
|
|
520
|
+
ref;
|
|
521
|
+
output;
|
|
522
|
+
constructor(ref, output) {
|
|
523
|
+
super(`Pushed ${ref}, but docker did not say which digest the registry stored (it said: ${output.trim().split("\n").slice(-3).join(" / ") || "nothing"}). Check the push with \`docker buildx imagetools inspect ${ref}\`.`);
|
|
524
|
+
this.ref = ref;
|
|
525
|
+
this.output = output;
|
|
526
|
+
this.name = "PushDigestUnknown";
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
/** The digest `docker push` reports last: `<tag>: digest: sha256:… size: …`. */
|
|
530
|
+
function pushedDigest(output) {
|
|
531
|
+
return [...output.matchAll(/digest: (sha256:[0-9a-f]{64})/g)].at(-1)?.[1];
|
|
532
|
+
}
|
|
533
|
+
function compose(stack, args) {
|
|
534
|
+
return [
|
|
535
|
+
"compose",
|
|
536
|
+
"-p",
|
|
537
|
+
stack.project,
|
|
538
|
+
"-f",
|
|
539
|
+
stack.file,
|
|
540
|
+
...(stack.overrides ?? []).flatMap((file) => ["-f", file]),
|
|
541
|
+
...args
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
/** Run a program and keep everything it said, whatever it exits with. */
|
|
545
|
+
function capture(command, args, timeoutMs = 6e4) {
|
|
546
|
+
return new Promise((resolve, reject) => {
|
|
547
|
+
const child = spawn(command, [...args], {
|
|
548
|
+
stdio: [
|
|
549
|
+
"ignore",
|
|
550
|
+
"pipe",
|
|
551
|
+
"pipe"
|
|
552
|
+
],
|
|
553
|
+
shell: false
|
|
554
|
+
});
|
|
555
|
+
let stdout = "";
|
|
556
|
+
let stderr = "";
|
|
557
|
+
child.stdout.on("data", (chunk) => {
|
|
558
|
+
stdout += chunk.toString();
|
|
559
|
+
});
|
|
560
|
+
child.stderr.on("data", (chunk) => {
|
|
561
|
+
stderr += chunk.toString();
|
|
562
|
+
});
|
|
563
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
564
|
+
timer.unref?.();
|
|
565
|
+
child.on("error", (error) => {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
reject(error);
|
|
568
|
+
});
|
|
569
|
+
child.on("close", (code) => {
|
|
570
|
+
clearTimeout(timer);
|
|
571
|
+
resolve({
|
|
572
|
+
code,
|
|
573
|
+
stdout,
|
|
574
|
+
stderr
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Whether a failed `docker manifest inspect` means the tag is not there, as
|
|
581
|
+
* opposed to the registry not answering.
|
|
582
|
+
*
|
|
583
|
+
* Read off the message because that is all the CLI gives. Anything not
|
|
584
|
+
* recognisably "no such tag" is treated as the registry being unreachable:
|
|
585
|
+
* a registry that refused credentials must not be reported as a tag that was
|
|
586
|
+
* never pushed, or the fix offered would be the wrong one.
|
|
587
|
+
*/
|
|
588
|
+
function isMissingManifest(output) {
|
|
589
|
+
return /no such manifest|manifest unknown|manifest for .* not found|not found: manifest/i.test(output);
|
|
590
|
+
}
|
|
591
|
+
const dockerCompose = {
|
|
592
|
+
async lookup(ref) {
|
|
593
|
+
const { code, stdout, stderr } = await capture("docker", [
|
|
594
|
+
"manifest",
|
|
595
|
+
"inspect",
|
|
596
|
+
...isLoopbackRegistry(ref) ? ["--insecure"] : [],
|
|
597
|
+
ref
|
|
598
|
+
]);
|
|
599
|
+
if (code === 0) return {
|
|
600
|
+
ref,
|
|
601
|
+
status: "found"
|
|
602
|
+
};
|
|
603
|
+
const output = `${stderr}\n${stdout}`.trim();
|
|
604
|
+
return isMissingManifest(output) ? {
|
|
605
|
+
ref,
|
|
606
|
+
status: "missing"
|
|
607
|
+
} : {
|
|
608
|
+
ref,
|
|
609
|
+
status: "unreachable",
|
|
610
|
+
detail: output
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
async build(stack, services) {
|
|
614
|
+
await run("docker", compose(stack, ["build", ...services]), runOptions(stack, {
|
|
615
|
+
...process.env,
|
|
616
|
+
DOCKER_BUILDKIT: "1"
|
|
617
|
+
}));
|
|
618
|
+
},
|
|
619
|
+
async pull(stack, services) {
|
|
620
|
+
await run("docker", compose(stack, ["pull", ...services]), runOptions(stack));
|
|
621
|
+
},
|
|
622
|
+
async up(stack, services) {
|
|
623
|
+
await run("docker", compose(stack, [
|
|
624
|
+
"up",
|
|
625
|
+
"-d",
|
|
626
|
+
"--wait",
|
|
627
|
+
`--wait-timeout=${WAIT_TIMEOUT_S}`,
|
|
628
|
+
...services ? [] : ["--remove-orphans"],
|
|
629
|
+
...services ?? []
|
|
630
|
+
]), runOptions(stack));
|
|
631
|
+
},
|
|
632
|
+
async down(stack) {
|
|
633
|
+
await run("docker", compose(stack, ["down"]), runOptions(stack));
|
|
634
|
+
},
|
|
635
|
+
async port(stack, service, inside) {
|
|
636
|
+
const output = await runOutput("docker", compose(stack, [
|
|
637
|
+
"port",
|
|
638
|
+
service,
|
|
639
|
+
String(inside)
|
|
640
|
+
]), {
|
|
641
|
+
cwd: stack.cwd,
|
|
642
|
+
...stack.signal ? { signal: stack.signal } : {}
|
|
643
|
+
});
|
|
644
|
+
const port = Number(output.trim().split(":").pop());
|
|
645
|
+
if (!Number.isInteger(port) || port <= 0) throw new ServicePortUnknown(service, inside, output);
|
|
646
|
+
return port;
|
|
647
|
+
},
|
|
648
|
+
async health(stack, service) {
|
|
649
|
+
const { code, stdout } = await capture("docker", [...compose(stack, [
|
|
650
|
+
"ps",
|
|
651
|
+
"--format",
|
|
652
|
+
"{{.Health}}",
|
|
653
|
+
service
|
|
654
|
+
])]);
|
|
655
|
+
if (code !== 0) return void 0;
|
|
656
|
+
return stdout.trim().split("\n")[0] || void 0;
|
|
657
|
+
},
|
|
658
|
+
async copyOut(stack, service, from, to) {
|
|
659
|
+
await mkdir(dirname(to), { recursive: true });
|
|
660
|
+
await run("docker", compose(stack, [
|
|
661
|
+
"cp",
|
|
662
|
+
`${service}:${from}`,
|
|
663
|
+
to
|
|
664
|
+
]), runOptions(stack));
|
|
665
|
+
},
|
|
666
|
+
async push(stack, ref) {
|
|
667
|
+
const output = await runOutput("docker", ["push", ref], {
|
|
668
|
+
cwd: stack.cwd,
|
|
669
|
+
...stack.signal ? { signal: stack.signal } : {}
|
|
670
|
+
});
|
|
671
|
+
const digest = pushedDigest(output);
|
|
672
|
+
if (!digest) throw new PushDigestUnknown(ref, output);
|
|
673
|
+
return digest;
|
|
674
|
+
},
|
|
675
|
+
async digest(ref) {
|
|
676
|
+
const { code, stdout } = await capture("docker", [
|
|
677
|
+
"image",
|
|
678
|
+
"inspect",
|
|
679
|
+
"--format={{json .RepoDigests}} {{.Id}}",
|
|
680
|
+
ref
|
|
681
|
+
]);
|
|
682
|
+
if (code !== 0) return void 0;
|
|
683
|
+
const [json = "[]", id] = stdout.trim().split(" ");
|
|
684
|
+
const repository = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
685
|
+
try {
|
|
686
|
+
const digests = JSON.parse(json);
|
|
687
|
+
const match = Array.isArray(digests) ? digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`)) : void 0;
|
|
688
|
+
if (match) return match.slice(repository.length + 1);
|
|
689
|
+
} catch {}
|
|
690
|
+
return id || void 0;
|
|
691
|
+
},
|
|
692
|
+
async ensureNetwork(name) {
|
|
693
|
+
const { code } = await capture("docker", [
|
|
694
|
+
"network",
|
|
695
|
+
"inspect",
|
|
696
|
+
name
|
|
697
|
+
]);
|
|
698
|
+
if (code === 0) return;
|
|
699
|
+
const created = await capture("docker", [
|
|
700
|
+
"network",
|
|
701
|
+
"create",
|
|
702
|
+
name
|
|
703
|
+
]);
|
|
704
|
+
if (created.code !== 0 && !/already exists/.test(created.stderr)) throw new NetworkCreateFailed(name, created.stderr);
|
|
705
|
+
},
|
|
706
|
+
async publishers(port) {
|
|
707
|
+
const { stdout } = await capture("docker", [
|
|
708
|
+
"ps",
|
|
709
|
+
"--format",
|
|
710
|
+
"{{.Names}} {{.Label \"com.docker.compose.project\"}} {{.Label \"com.docker.compose.service\"}} {{.Ports}}"
|
|
711
|
+
]);
|
|
712
|
+
return portHolders(stdout, port);
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
/** `docker network create` failed. */
|
|
716
|
+
var NetworkCreateFailed = class extends Error {
|
|
717
|
+
network;
|
|
718
|
+
output;
|
|
719
|
+
constructor(network, output) {
|
|
720
|
+
super(`Could not create the Docker network '${network}' (docker said: ${output.trim() || "nothing"}). Create it by hand with \`docker network create ${network}\` and run again.`);
|
|
721
|
+
this.network = network;
|
|
722
|
+
this.output = output;
|
|
723
|
+
this.name = "NetworkCreateFailed";
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
//#endregion
|
|
727
|
+
//#region src/compose/traefik.ts
|
|
728
|
+
/**
|
|
729
|
+
* The server's shared edge, rendered for Traefik: what the edge itself runs
|
|
730
|
+
* with, and what one stack registers with it.
|
|
731
|
+
*
|
|
732
|
+
* One Traefik per server owns 80 and 443, the ACME state and the redirect to
|
|
733
|
+
* HTTPS. It is configured through its file provider alone: each stack writes
|
|
734
|
+
* one file — its routers, services and middlewares, every name prefixed with
|
|
735
|
+
* its compose project — into a directory the edge watches, and removes it
|
|
736
|
+
* when it stops. There is no Docker socket: the edge never learns what runs
|
|
737
|
+
* on the server except from those files.
|
|
738
|
+
*
|
|
739
|
+
* The edge and the stacks meet on one external Docker network. Only a
|
|
740
|
+
* stack's public services join it, each under an alias prefixed with its
|
|
741
|
+
* project (`<project>-api`), because the network is shared: a bare service
|
|
742
|
+
* name there would be answered by every stack's `api`.
|
|
743
|
+
*
|
|
744
|
+
* Pure: the documents are data, and the edge's side effects are `edge.ts`'s.
|
|
745
|
+
*/
|
|
746
|
+
/**
|
|
747
|
+
* The image, pinned: the edge is every stack's way in, so a new version is a
|
|
748
|
+
* change to this line, not whatever `latest` is on the day a server starts.
|
|
749
|
+
*/
|
|
750
|
+
const TRAEFIK_IMAGE = "traefik:v3.7.13";
|
|
751
|
+
/** The edge's compose project, one per server. */
|
|
752
|
+
const EDGE_PROJECT = "gkm-edge";
|
|
753
|
+
/** The external network the edge and every stack's public services share. */
|
|
754
|
+
const EDGE_NETWORK = "gkm-edge";
|
|
755
|
+
/** The edge's compose service. */
|
|
756
|
+
const EDGE_SERVICE = "traefik";
|
|
757
|
+
/** Inside the edge's container: what it watches, and where certificates are. */
|
|
758
|
+
const EDGE_DYNAMIC_DIR = "/etc/gkm-edge/dynamic";
|
|
759
|
+
const EDGE_CERTS_DIR = "/etc/gkm-edge/certs";
|
|
760
|
+
/** The certificate resolver every deployed route without its own certificate uses. */
|
|
761
|
+
const ACME_RESOLVER = "letsencrypt";
|
|
762
|
+
/** The internal entrypoint the edge's health check asks; never published. */
|
|
763
|
+
const PING_PORT = 8082;
|
|
764
|
+
/** A stack's service, as the edge — and the stack itself — reach it. */
|
|
765
|
+
function edgeAlias(project, service) {
|
|
766
|
+
return `${project}-${service}`;
|
|
767
|
+
}
|
|
768
|
+
/** A stack's certificate, as the edge's container reads it. */
|
|
769
|
+
function edgeCertificatePaths(project) {
|
|
770
|
+
return {
|
|
771
|
+
certFile: `${EDGE_CERTS_DIR}/${project}.crt`,
|
|
772
|
+
keyFile: `${EDGE_CERTS_DIR}/${project}.key`
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* The edge's static configuration: the entrypoints, the redirect, ACME, and
|
|
777
|
+
* the directory it watches. No API and no dashboard — nothing about the edge
|
|
778
|
+
* is reachable but the routes stacks register.
|
|
779
|
+
*/
|
|
780
|
+
function traefikStatic(ports) {
|
|
781
|
+
return {
|
|
782
|
+
global: {
|
|
783
|
+
checkNewVersion: false,
|
|
784
|
+
sendAnonymousUsage: false
|
|
785
|
+
},
|
|
786
|
+
entryPoints: {
|
|
787
|
+
web: {
|
|
788
|
+
address: ":80",
|
|
789
|
+
http: { redirections: { entryPoint: {
|
|
790
|
+
to: ports.https === 443 ? "websecure" : `:${ports.https}`,
|
|
791
|
+
scheme: "https",
|
|
792
|
+
permanent: true
|
|
793
|
+
} } }
|
|
794
|
+
},
|
|
795
|
+
websecure: { address: ":443" },
|
|
796
|
+
ping: { address: `:${PING_PORT}` }
|
|
797
|
+
},
|
|
798
|
+
ping: { entryPoint: "ping" },
|
|
799
|
+
providers: { file: {
|
|
800
|
+
directory: EDGE_DYNAMIC_DIR,
|
|
801
|
+
watch: true
|
|
802
|
+
} },
|
|
803
|
+
certificatesResolvers: { [ACME_RESOLVER]: { acme: {
|
|
804
|
+
storage: "/acme/acme.json",
|
|
805
|
+
httpChallenge: { entryPoint: "web" }
|
|
806
|
+
} } },
|
|
807
|
+
log: { level: "INFO" }
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
/** The edge's compose document. */
|
|
811
|
+
function edgeCompose(options) {
|
|
812
|
+
const { dir, ports } = options;
|
|
813
|
+
return {
|
|
814
|
+
name: EDGE_PROJECT,
|
|
815
|
+
services: { [EDGE_SERVICE]: {
|
|
816
|
+
image: TRAEFIK_IMAGE,
|
|
817
|
+
restart: "unless-stopped",
|
|
818
|
+
ports: [`${ports.https}:443`, `${ports.http}:80`],
|
|
819
|
+
volumes: [
|
|
820
|
+
`${dir}/traefik.yml:/etc/traefik/traefik.yml:ro`,
|
|
821
|
+
`${dir}/dynamic:${EDGE_DYNAMIC_DIR}:ro`,
|
|
822
|
+
`${dir}/certs:${EDGE_CERTS_DIR}:ro`,
|
|
823
|
+
"acme:/acme"
|
|
824
|
+
],
|
|
825
|
+
labels: { "dev.geekmidas.edge.config": createHash("sha256").update(options.staticConfig).digest("hex").slice(0, 16) },
|
|
826
|
+
networks: ["edge"],
|
|
827
|
+
healthcheck: {
|
|
828
|
+
test: [
|
|
829
|
+
"CMD",
|
|
830
|
+
"traefik",
|
|
831
|
+
"healthcheck",
|
|
832
|
+
"--ping"
|
|
833
|
+
],
|
|
834
|
+
interval: "10s",
|
|
835
|
+
timeout: "5s",
|
|
836
|
+
retries: 5
|
|
837
|
+
},
|
|
838
|
+
logging: options.logging
|
|
839
|
+
} },
|
|
840
|
+
volumes: { acme: {} },
|
|
841
|
+
networks: { edge: {
|
|
842
|
+
name: EDGE_NETWORK,
|
|
843
|
+
external: true
|
|
844
|
+
} }
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
/** A stack's dynamic configuration, as an object. */
|
|
848
|
+
function traefikDynamic(routes, options) {
|
|
849
|
+
const { project, tls } = options;
|
|
850
|
+
const name = (route, suffix) => `${project}-${route.name}${suffix ? `-${suffix}` : ""}`;
|
|
851
|
+
const url = ({ service, port }) => `http://${edgeAlias(project, service)}:${port}`;
|
|
852
|
+
const routers = {};
|
|
853
|
+
const services = {};
|
|
854
|
+
const middlewares = {};
|
|
855
|
+
const strip = `${project}-strip-gkm-headers`;
|
|
856
|
+
if (routes.length > 0) middlewares[strip] = { headers: { customRequestHeaders: { [CLIENT_IP_HEADER]: "" } } };
|
|
857
|
+
for (const route of routes) {
|
|
858
|
+
const chain = [strip];
|
|
859
|
+
if (route.allow) {
|
|
860
|
+
middlewares[name(route, "allow")] = { ipAllowList: { sourceRange: [...route.allow] } };
|
|
861
|
+
chain.push(name(route, "allow"));
|
|
862
|
+
}
|
|
863
|
+
if (route.prefix) {
|
|
864
|
+
middlewares[name(route, "prefix")] = { addPrefix: { prefix: route.prefix } };
|
|
865
|
+
chain.push(name(route, "prefix"));
|
|
866
|
+
}
|
|
867
|
+
routers[name(route)] = {
|
|
868
|
+
rule: `Host(\`${route.host}\`)`,
|
|
869
|
+
entryPoints: ["websecure"],
|
|
870
|
+
service: name(route),
|
|
871
|
+
middlewares: chain,
|
|
872
|
+
tls: tls.kind === "acme" ? { certResolver: ACME_RESOLVER } : {}
|
|
873
|
+
};
|
|
874
|
+
services[name(route)] = { loadBalancer: {
|
|
875
|
+
servers: route.upstreams.map((upstream) => ({ url: url(upstream) })),
|
|
876
|
+
...route.prefix ? { passHostHeader: false } : {},
|
|
877
|
+
...route.streaming ? { responseForwarding: { flushInterval: "-1ms" } } : {},
|
|
878
|
+
...route.health ? { healthCheck: {
|
|
879
|
+
path: route.health,
|
|
880
|
+
interval: "5s",
|
|
881
|
+
timeout: "3s"
|
|
882
|
+
} } : {}
|
|
883
|
+
} };
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
http: {
|
|
887
|
+
routers,
|
|
888
|
+
services,
|
|
889
|
+
...Object.keys(middlewares).length > 0 ? { middlewares } : {}
|
|
890
|
+
},
|
|
891
|
+
...tls.kind === "files" ? { tls: { certificates: [{
|
|
892
|
+
certFile: tls.certFile,
|
|
893
|
+
keyFile: tls.keyFile
|
|
894
|
+
}] } } : {}
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
/** A stack's dynamic configuration file, as written to the edge's directory. */
|
|
898
|
+
function traefikDynamicFile(routes, options) {
|
|
899
|
+
return `# Generated by gkm compose for ${options.project} — do not edit.
|
|
900
|
+
# The shared edge (${EDGE_PROJECT}) reads every file in this directory; this
|
|
901
|
+
# one is removed by gkm compose --down.
|
|
902
|
+
${yaml(traefikDynamic(routes, options))}`;
|
|
903
|
+
}
|
|
904
|
+
/** The edge's static configuration file. */
|
|
905
|
+
function traefikStaticFile(ports) {
|
|
906
|
+
return `# Generated by gkm compose — the server's shared edge. Do not edit.
|
|
907
|
+
${yaml(traefikStatic(ports))}`;
|
|
908
|
+
}
|
|
909
|
+
/** The edge's compose file. */
|
|
910
|
+
function edgeComposeFile(options) {
|
|
911
|
+
return `# Generated by gkm compose — the server's shared edge. Do not edit.
|
|
912
|
+
#
|
|
913
|
+
# Started by the first stack that runs with proxy: 'traefik', and left
|
|
914
|
+
# running by gkm compose --down. Stop it, once nothing uses it, with:
|
|
915
|
+
#
|
|
916
|
+
# docker compose -p ${EDGE_PROJECT} -f ${options.dir}/docker-compose.yml down
|
|
917
|
+
${yaml(edgeCompose(options))}`;
|
|
918
|
+
}
|
|
919
|
+
function yaml(value) {
|
|
920
|
+
return stringify(value, {
|
|
921
|
+
lineWidth: 0,
|
|
922
|
+
aliasDuplicateObjects: false
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
//#endregion
|
|
926
|
+
//#region src/compose/edge.ts
|
|
927
|
+
/**
|
|
928
|
+
* The server's shared Traefik edge, on disk and in Docker: started when a
|
|
929
|
+
* stack needs it, and each stack's routes written to — and removed from —
|
|
930
|
+
* the directory it watches.
|
|
931
|
+
*
|
|
932
|
+
* The edge lives in the deploy user's gkm home (`$GKM_HOME/edge`, else
|
|
933
|
+
* `~/.gkm/edge`): the user who runs `gkm compose` owns it, so nothing needs
|
|
934
|
+
* root, and it sits beside the stage keys that same user already keeps.
|
|
935
|
+
*
|
|
936
|
+
* ~/.gkm/edge/
|
|
937
|
+
* docker-compose.yml the edge's compose project (gkm-edge)
|
|
938
|
+
* traefik.yml its static configuration
|
|
939
|
+
* dynamic/<project>.yml one per stack: its routers, services, middlewares
|
|
940
|
+
* certs/<project>.crt|.key a stack's own certificate, where it has one
|
|
941
|
+
*/
|
|
942
|
+
/** The edge's directory on this machine. */
|
|
943
|
+
function edgeDir(env = process.env) {
|
|
944
|
+
return join(gkmHome(env), "edge");
|
|
945
|
+
}
|
|
946
|
+
/** The edge's compose project, as docker is asked about it. */
|
|
947
|
+
function edgeRef(dir, options = {}) {
|
|
948
|
+
return {
|
|
949
|
+
project: EDGE_PROJECT,
|
|
950
|
+
file: join(dir, "docker-compose.yml"),
|
|
951
|
+
cwd: dir,
|
|
952
|
+
...options
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Something other than the proxy a stack is set to holds the edge's ports.
|
|
957
|
+
*
|
|
958
|
+
* With `proxy: 'traefik'`, the stack's own Caddy — still running from before
|
|
959
|
+
* the switch — or any other container; with `proxy: 'caddy'`, the shared
|
|
960
|
+
* edge, or another stack's Caddy. Refused before anything is started, so the
|
|
961
|
+
* stack is never left half on one proxy and half on the other.
|
|
962
|
+
*/
|
|
963
|
+
var ComposeProxyClash = class extends Error {
|
|
964
|
+
project;
|
|
965
|
+
proxy;
|
|
966
|
+
port;
|
|
967
|
+
holder;
|
|
968
|
+
constructor(project, proxy, port, holder) {
|
|
969
|
+
const by = holder.project ? `${holder.container} (compose project ${holder.project})` : holder.container;
|
|
970
|
+
const fix = proxy === "traefik" ? holder.project === project && holder.service === "caddy" ? `That is this stack's own Caddy, from before it was switched to proxy: 'traefik'. Move it once: stop it (docker compose -p ${project} stop caddy), then run gkm compose again — the shared edge takes 80/443 and the stack registers with it.` : `The shared edge needs ports 80 and 443. Stop ${holder.container}${holder.project ? ` (docker compose -p ${holder.project} down, or its own gkm compose --down)` : ""}, or move the edge with GKM_COMPOSE_HTTPS_PORT/GKM_COMPOSE_HTTP_PORT.` : holder.project === "gkm-edge" ? `That is the server's shared Traefik edge, which other stacks register with. Set deploy.compose.proxy to 'traefik' for this stage so the stack registers with it too, or stop the edge once nothing uses it (docker compose -p ${EDGE_PROJECT} down).` : `Stop ${holder.container}, or publish this stack's Caddy elsewhere with GKM_COMPOSE_HTTPS_PORT/GKM_COMPOSE_HTTP_PORT.`;
|
|
971
|
+
super(`${project} is set to proxy: '${proxy}', and port ${port} is held by ${by}. ${fix}`);
|
|
972
|
+
this.project = project;
|
|
973
|
+
this.proxy = proxy;
|
|
974
|
+
this.port = port;
|
|
975
|
+
this.holder = holder;
|
|
976
|
+
this.name = "ComposeProxyClash";
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
/**
|
|
980
|
+
* Refuse a stack whose edge ports are held by anything but its own proxy:
|
|
981
|
+
* the shared edge for `'traefik'`, the stack's own Caddy for `'caddy'`.
|
|
982
|
+
*/
|
|
983
|
+
async function assertEdgePorts(docker, options) {
|
|
984
|
+
const { project, proxy, ports } = options;
|
|
985
|
+
for (const port of [ports.https, ports.http]) for (const holder of await docker.publishers(port)) if (!(proxy === "traefik" ? holder.project === "gkm-edge" : holder.project === project && holder.service === "caddy")) throw new ComposeProxyClash(project, proxy, port, holder);
|
|
986
|
+
}
|
|
987
|
+
/** Write a file whole or not at all: a temporary file, then a rename. */
|
|
988
|
+
async function writeAtomic(path, content, mode = 420) {
|
|
989
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
990
|
+
await writeFile(temporary, content, { mode });
|
|
991
|
+
await chmod(temporary, mode);
|
|
992
|
+
await rename(temporary, path);
|
|
993
|
+
}
|
|
994
|
+
/** Write a file only when its content changed; whether it did. */
|
|
995
|
+
async function writeIfChanged(path, content) {
|
|
996
|
+
if (await readFile(path, "utf-8").catch(() => void 0) === content) return false;
|
|
997
|
+
await writeAtomic(path, content);
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* The edge, up: its network, its files, and its container — started when it
|
|
1002
|
+
* is not running, and recreated when its configuration changed. Idempotent:
|
|
1003
|
+
* on a server where it already runs as configured, `up` changes nothing.
|
|
1004
|
+
*/
|
|
1005
|
+
async function ensureEdge(docker, options) {
|
|
1006
|
+
const { dir, ports } = options;
|
|
1007
|
+
await mkdir(join(dir, "dynamic"), { recursive: true });
|
|
1008
|
+
await mkdir(join(dir, "certs"), {
|
|
1009
|
+
recursive: true,
|
|
1010
|
+
mode: 448
|
|
1011
|
+
});
|
|
1012
|
+
const staticConfig = traefikStaticFile(ports);
|
|
1013
|
+
await writeIfChanged(join(dir, "traefik.yml"), staticConfig);
|
|
1014
|
+
await writeIfChanged(join(dir, "docker-compose.yml"), edgeComposeFile({
|
|
1015
|
+
dir,
|
|
1016
|
+
ports,
|
|
1017
|
+
staticConfig,
|
|
1018
|
+
logging: options.logging
|
|
1019
|
+
}));
|
|
1020
|
+
await docker.ensureNetwork(EDGE_NETWORK);
|
|
1021
|
+
await docker.up(edgeRef(dir, {
|
|
1022
|
+
...options.output ? { output: options.output } : {},
|
|
1023
|
+
...options.signal ? { signal: options.signal } : {}
|
|
1024
|
+
}), [EDGE_SERVICE]);
|
|
1025
|
+
return [join(dir, "traefik.yml"), join(dir, "docker-compose.yml")];
|
|
1026
|
+
}
|
|
1027
|
+
/** A stack's file in the edge's directory. */
|
|
1028
|
+
function routesFile(dir, project) {
|
|
1029
|
+
return join(dir, "dynamic", `${project}.yml`);
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Register a stack with the edge: its certificate first, where it has one,
|
|
1033
|
+
* so the routes that need it never load without it — then its routes, in one
|
|
1034
|
+
* rename the edge picks up whole.
|
|
1035
|
+
*/
|
|
1036
|
+
async function writeEdgeRoutes(dir, project, routes, certificate) {
|
|
1037
|
+
const files = [];
|
|
1038
|
+
if (certificate) {
|
|
1039
|
+
const certs = join(dir, "certs");
|
|
1040
|
+
await mkdir(certs, {
|
|
1041
|
+
recursive: true,
|
|
1042
|
+
mode: 448
|
|
1043
|
+
});
|
|
1044
|
+
const crt = join(certs, `${project}.crt`);
|
|
1045
|
+
const key = join(certs, `${project}.key`);
|
|
1046
|
+
await copyFile(certificate.certFile, `${crt}.tmp`);
|
|
1047
|
+
await rename(`${crt}.tmp`, crt);
|
|
1048
|
+
await copyFile(certificate.keyFile, `${key}.tmp`);
|
|
1049
|
+
await chmod(`${key}.tmp`, 384);
|
|
1050
|
+
await rename(`${key}.tmp`, key);
|
|
1051
|
+
files.push(crt, key);
|
|
1052
|
+
} else await removeCertificate(dir, project);
|
|
1053
|
+
await mkdir(join(dir, "dynamic"), { recursive: true });
|
|
1054
|
+
const file = routesFile(dir, project);
|
|
1055
|
+
await writeAtomic(file, routes);
|
|
1056
|
+
files.push(file);
|
|
1057
|
+
return files;
|
|
1058
|
+
}
|
|
1059
|
+
/** What a stack's file holds for the moment between emptying and removal. */
|
|
1060
|
+
const UNREGISTERED = "# Unregistered by gkm compose --down.\n";
|
|
1061
|
+
async function removeCertificate(dir, project) {
|
|
1062
|
+
for (const ext of ["crt", "key"]) await rm(join(dir, "certs", `${project}.${ext}`), { force: true });
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Unregister a stack: its routes, then its certificate. Whether there was
|
|
1066
|
+
* anything to remove.
|
|
1067
|
+
*/
|
|
1068
|
+
async function removeEdgeRoutes(dir, project) {
|
|
1069
|
+
const file = routesFile(dir, project);
|
|
1070
|
+
const existed = existsSync(file);
|
|
1071
|
+
if (existed) await writeAtomic(file, UNREGISTERED);
|
|
1072
|
+
await rm(file, { force: true });
|
|
1073
|
+
await removeCertificate(dir, project);
|
|
1074
|
+
return existed;
|
|
1075
|
+
}
|
|
1076
|
+
//#endregion
|
|
1077
|
+
//#region src/compose/images.ts
|
|
1078
|
+
/**
|
|
1079
|
+
* The tag a site's image carries for a release.
|
|
1080
|
+
*
|
|
1081
|
+
* A site's public URLs are inlined when it is built, so one bundle serves one
|
|
1082
|
+
* stage: `v1.4.0-production` and `v1.4.0-staging` are two images of the same
|
|
1083
|
+
* commit. A backend reads its URLs at runtime, so one image serves every stage
|
|
1084
|
+
* and its tag is the release's alone.
|
|
1085
|
+
*/
|
|
1086
|
+
function siteTag(tag, stage) {
|
|
1087
|
+
return `${tag}-${stage}`;
|
|
1088
|
+
}
|
|
1089
|
+
/** Images a tag names that the registry does not have. */
|
|
1090
|
+
var ImageTagNotFound = class extends Error {
|
|
1091
|
+
tag;
|
|
1092
|
+
refs;
|
|
1093
|
+
constructor(tag, refs) {
|
|
1094
|
+
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.`);
|
|
1095
|
+
this.tag = tag;
|
|
1096
|
+
this.refs = refs;
|
|
1097
|
+
this.name = "ImageTagNotFound";
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
/** The registry did not answer, or did not let this machine in. */
|
|
1101
|
+
var RegistryUnreachable = class extends Error {
|
|
1102
|
+
ref;
|
|
1103
|
+
detail;
|
|
1104
|
+
constructor(ref, detail) {
|
|
1105
|
+
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>\`).`);
|
|
1106
|
+
this.ref = ref;
|
|
1107
|
+
this.detail = detail;
|
|
1108
|
+
this.name = "RegistryUnreachable";
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
/**
|
|
1112
|
+
* Assert every image exists, before anything else happens.
|
|
1113
|
+
*
|
|
1114
|
+
* Asked all at once and reported all at once: a release missing two images is
|
|
1115
|
+
* one problem with two names, and finding the second only after fixing the
|
|
1116
|
+
* first is a slower way to learn the same thing.
|
|
1117
|
+
*
|
|
1118
|
+
* @throws {RegistryUnreachable} when the registry cannot say
|
|
1119
|
+
* @throws {ImageTagNotFound} listing every missing image
|
|
1120
|
+
*/
|
|
1121
|
+
async function assertImagesExist(docker, tag, images) {
|
|
1122
|
+
const lookups = await Promise.all(images.map((image) => docker.lookup(image.ref)));
|
|
1123
|
+
const unreachable = lookups.find((lookup) => lookup.status === "unreachable");
|
|
1124
|
+
if (unreachable?.status === "unreachable") throw new RegistryUnreachable(unreachable.ref, unreachable.detail);
|
|
1125
|
+
const missing = lookups.filter((lookup) => lookup.status === "missing").map((lookup) => lookup.ref);
|
|
1126
|
+
if (missing.length > 0) throw new ImageTagNotFound(tag, missing);
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* A push or a pull with no `deploy.registry` to push to or pull from.
|
|
1130
|
+
*
|
|
1131
|
+
* Without one an image is named `<namespace>/<project>-<app>:<tag>`, which
|
|
1132
|
+
* Docker resolves to Docker Hub — so a release would be pushed to, or pulled
|
|
1133
|
+
* from, somebody else's account. It is never a fallback.
|
|
1134
|
+
*/
|
|
1135
|
+
var RegistryRequired = class extends Error {
|
|
1136
|
+
operation;
|
|
1137
|
+
ref;
|
|
1138
|
+
constructor(operation, ref) {
|
|
1139
|
+
super(`${operation === "push" ? "Pushing" : "Pulling"} images needs a registry, and gkm.config.ts sets no deploy.registry: '${ref}' would be ${operation === "push" ? "pushed to" : "pulled from"} Docker Hub. Set deploy.registry (e.g. registry: 'ghcr.io/acme') in gkm.config.ts.` + (operation === "pull" ? " To run images built here instead, pass --build." : ""));
|
|
1140
|
+
this.operation = operation;
|
|
1141
|
+
this.ref = ref;
|
|
1142
|
+
this.name = "RegistryRequired";
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
/** An image pinned to its digest: `<ref>@sha256:…`. */
|
|
1146
|
+
function pinnedRef(ref, digest) {
|
|
1147
|
+
return `${ref.split("@")[0]}@${digest}`;
|
|
1148
|
+
}
|
|
1149
|
+
/** A digests file with no entry for an app the stack runs. */
|
|
1150
|
+
var ImageDigestMissing = class extends Error {
|
|
1151
|
+
file;
|
|
1152
|
+
apps;
|
|
1153
|
+
constructor(file, apps) {
|
|
1154
|
+
super(`${file} pins no digest for ${apps.join(", ")}. Use the file the same release's \`gkm compose --build --push --digests-file\` wrote, or run without --digests-file to pull by tag.`);
|
|
1155
|
+
this.file = file;
|
|
1156
|
+
this.apps = apps;
|
|
1157
|
+
this.name = "ImageDigestMissing";
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
/** A digests file entry that is not this app's image. */
|
|
1161
|
+
var ImageDigestMismatch = class extends Error {
|
|
1162
|
+
app;
|
|
1163
|
+
expected;
|
|
1164
|
+
found;
|
|
1165
|
+
constructor(app, expected, found) {
|
|
1166
|
+
super(`The digests file pins '${app}' to ${found}, but this release runs ${expected}. It was written for another registry, project, stage or tag; use the file this stage's push of this tag wrote.`);
|
|
1167
|
+
this.app = app;
|
|
1168
|
+
this.expected = expected;
|
|
1169
|
+
this.found = found;
|
|
1170
|
+
this.name = "ImageDigestMismatch";
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
/** A digests file that is not a JSON object of app to pinned ref. */
|
|
1174
|
+
var ImageDigestsInvalid = class extends Error {
|
|
1175
|
+
file;
|
|
1176
|
+
detail;
|
|
1177
|
+
constructor(file, detail) {
|
|
1178
|
+
super(`${file} is not a digests file (${detail}). It should be the JSON object \`gkm compose --build --push --digests-file\` writes: { "api": "<registry>/<image>:<tag>@sha256:…" }.`);
|
|
1179
|
+
this.file = file;
|
|
1180
|
+
this.detail = detail;
|
|
1181
|
+
this.name = "ImageDigestsInvalid";
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
/** Parse a digests file's content, checking its shape. */
|
|
1185
|
+
function parseDigests(file, content) {
|
|
1186
|
+
let parsed;
|
|
1187
|
+
try {
|
|
1188
|
+
parsed = JSON.parse(content);
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
throw new ImageDigestsInvalid(file, error instanceof Error ? error.message : String(error));
|
|
1191
|
+
}
|
|
1192
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ImageDigestsInvalid(file, "not a JSON object");
|
|
1193
|
+
for (const [app, value] of Object.entries(parsed)) if (typeof value !== "string" || !/@sha256:[0-9a-f]{64}$/.test(value)) throw new ImageDigestsInvalid(file, `'${app}' is not an <image>:<tag>@sha256:… ref`);
|
|
1194
|
+
return parsed;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Each image pinned to the digest the file names for it.
|
|
1198
|
+
*
|
|
1199
|
+
* @throws {ImageDigestMissing} naming every app the file has no entry for
|
|
1200
|
+
* @throws {ImageDigestMismatch} when an entry is another image or tag
|
|
1201
|
+
*/
|
|
1202
|
+
function pinImages(file, digests, images) {
|
|
1203
|
+
const missing = images.filter((image) => !digests[image.app]).map((image) => image.app);
|
|
1204
|
+
if (missing.length > 0) throw new ImageDigestMissing(file, missing);
|
|
1205
|
+
return images.map((image) => {
|
|
1206
|
+
const pinned = digests[image.app];
|
|
1207
|
+
if (pinned.split("@")[0] !== image.ref) throw new ImageDigestMismatch(image.app, image.ref, pinned);
|
|
1208
|
+
return {
|
|
1209
|
+
...image,
|
|
1210
|
+
ref: pinned
|
|
1211
|
+
};
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
//#endregion
|
|
1215
|
+
//#region src/compose/secrets.ts
|
|
1216
|
+
var secrets_exports = /* @__PURE__ */ __exportAll({ deployedStackSecrets: () => deployedStackSecrets });
|
|
1217
|
+
function deployedStackSecrets(workspace, stage, stored, manifest) {
|
|
1218
|
+
const withSeed = withGeneratedSecrets(stored ?? initStageSecrets(stage), manifest);
|
|
1219
|
+
const withLogs = resolveStageTelemetry({
|
|
1220
|
+
...workspace.deploy?.telemetry ? { telemetry: workspace.deploy.telemetry } : {},
|
|
1221
|
+
stage,
|
|
1222
|
+
local: false,
|
|
1223
|
+
target: "compose",
|
|
1224
|
+
runtime: "server",
|
|
1225
|
+
selfHosted: true,
|
|
1226
|
+
used: usesTelemetry(manifest)
|
|
1227
|
+
})?.provider === "self-hosted" ? withLogsPassword(withSeed.secrets) : {
|
|
1228
|
+
secrets: withSeed.secrets,
|
|
1229
|
+
generated: []
|
|
1230
|
+
};
|
|
1231
|
+
const withRedis = runsRedis(manifest, withLogs.secrets.custom ?? {}) ? withRedisPassword(withLogs.secrets) : {
|
|
1232
|
+
secrets: withLogs.secrets,
|
|
1233
|
+
generated: []
|
|
1234
|
+
};
|
|
1235
|
+
return {
|
|
1236
|
+
secrets: withRedis.secrets,
|
|
1237
|
+
generated: [
|
|
1238
|
+
...withSeed.generated,
|
|
1239
|
+
...withLogs.generated,
|
|
1240
|
+
...withRedis.generated
|
|
1241
|
+
]
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
//#endregion
|
|
1245
|
+
//#region src/compose/caddyfile.ts
|
|
1246
|
+
/**
|
|
1247
|
+
* The stack's own edge, rendered for Caddy: one site block per route.
|
|
1248
|
+
*
|
|
1249
|
+
* Deployed, each host is the stage's real domain and Caddy gets it a
|
|
1250
|
+
* certificate from Let's Encrypt on first request — the same automatic HTTPS
|
|
1251
|
+
* it does anywhere, with nothing to configure but the name — or serves the
|
|
1252
|
+
* stage's own certificate (`deploy.compose.tls`). Locally the hosts are
|
|
1253
|
+
* `*.localhost` and the certificates come from Caddy's own CA, as the `gkm
|
|
1254
|
+
* dev` edge does, so a local stack is `https://` like a deployed one.
|
|
1255
|
+
*
|
|
1256
|
+
* Pure: the routes are data (`routes.ts`), and rendering them is one string.
|
|
1257
|
+
*/
|
|
1258
|
+
/** Where Caddy reads a stage's own certificate, inside its container. */
|
|
1259
|
+
const CADDY_TLS_DIR = "/etc/caddy/tls";
|
|
1260
|
+
/** An upstream on the stack's own network: its service name. */
|
|
1261
|
+
function address({ service, port }) {
|
|
1262
|
+
return `${service}:${port}`;
|
|
1263
|
+
}
|
|
1264
|
+
/** The Caddyfile for a stack's edge. */
|
|
1265
|
+
function edgeCaddyfile(routes, options) {
|
|
1266
|
+
const { tls } = options;
|
|
1267
|
+
const global = tls.kind === "internal" ? `{
|
|
1268
|
+
# No ACME and no public DNS: the CA is Caddy's own and the names resolve
|
|
1269
|
+
# to this machine.
|
|
1270
|
+
local_certs
|
|
1271
|
+
auto_https disable_redirects
|
|
1272
|
+
}` : options.email ? `{
|
|
1273
|
+
email ${options.email}
|
|
1274
|
+
}` : "";
|
|
1275
|
+
const certificate = tls.kind === "internal" ? `
|
|
1276
|
+
tls internal
|
|
1277
|
+
` : tls.kind === "files" ? `
|
|
1278
|
+
tls ${tls.certFile} ${tls.keyFile}
|
|
1279
|
+
` : "";
|
|
1280
|
+
const blocks = routes.map(({ host, upstreams, prefix, allow, streaming }) => `https://${host} {${certificate}
|
|
1281
|
+
# gkm's own header, which only a service of this stack may send: the auth
|
|
1282
|
+
# server rate-limits by the client address it carries.
|
|
1283
|
+
request_header -${CLIENT_IP_HEADER}
|
|
1284
|
+
${allow ? `
|
|
1285
|
+
# Only these addresses — the peer Caddy sees, never a header a client
|
|
1286
|
+
# could set. Everything else is refused before it reaches the upstream.
|
|
1287
|
+
@denied not remote_ip ${allow.join(" ")}
|
|
1288
|
+
respond @denied 403
|
|
1289
|
+
` : ""}${prefix ? `
|
|
1290
|
+
# A bucket, served at a host of its own: the bucket is a prefix on MinIO,
|
|
1291
|
+
# which routes and signs on the Host header — so it is sent the upstream's.
|
|
1292
|
+
reverse_proxy ${upstreams.map(address).join(" ")} {
|
|
1293
|
+
rewrite ${prefix}{uri}
|
|
1294
|
+
header_up Host {upstream_hostport}
|
|
1295
|
+
}` : `
|
|
1296
|
+
# Host and X-Forwarded-* are passed through as Caddy does by default, so
|
|
1297
|
+
# an app builds redirects, cookies and links on the address a caller used.
|
|
1298
|
+
reverse_proxy ${upstreams.map(address).join(" ")}${streaming ? ` {
|
|
1299
|
+
# Never buffer: a streamed response — server-sent events, a Next.js
|
|
1300
|
+
# RSC payload — reaches the client as each chunk is written.
|
|
1301
|
+
flush_interval -1
|
|
1302
|
+
}` : ""}`}
|
|
1303
|
+
}`);
|
|
1304
|
+
return `${HEADER}${global ? `${global}\n\n` : ""}${blocks.join("\n\n")}\n`;
|
|
1305
|
+
}
|
|
1306
|
+
const HEADER = `# Generated by gkm compose from the construct manifest — do not edit.
|
|
1307
|
+
#
|
|
1308
|
+
# One host per API and site, each in front of its own container, one per
|
|
1309
|
+
# file server in front of the stack's MinIO, and — when its logs are served
|
|
1310
|
+
# publicly — one for OpenObserve, to the addresses it allows.
|
|
1311
|
+
`;
|
|
1312
|
+
//#endregion
|
|
1313
|
+
//#region src/compose/stack.ts
|
|
1314
|
+
/**
|
|
1315
|
+
* The stack a workspace runs as for one stage — pure.
|
|
1316
|
+
*
|
|
1317
|
+
* Everything `gkm compose` writes is decided here, as data: the compose file,
|
|
1318
|
+
* the Caddyfile, one env file per backend, and the image each app runs. The
|
|
1319
|
+
* command around it reads the workspace, asks the registry, writes the files
|
|
1320
|
+
* and starts Docker; none of those decide anything, so all of it can be
|
|
1321
|
+
* asserted without a daemon.
|
|
1322
|
+
*
|
|
1323
|
+
* The values come from the same derivations every other target uses. The
|
|
1324
|
+
* plan is reconcile's `planFor`, the keys an app reads are `appEnvKeys`, and
|
|
1325
|
+
* every URL is `envFor`'s — asked twice, once with each app at its public
|
|
1326
|
+
* address and once on the compose network, because a browser and a sibling
|
|
1327
|
+
* service reach the same API by different names.
|
|
1328
|
+
*/
|
|
1329
|
+
var stack_exports = /* @__PURE__ */ __exportAll({
|
|
1330
|
+
EDGE_PORT_ENV: () => EDGE_PORT_ENV,
|
|
1331
|
+
EnvValueMultiline: () => EnvValueMultiline,
|
|
1332
|
+
LOGS_PORT_ENV: () => LOGS_PORT_ENV,
|
|
1333
|
+
LOG_ROTATION: () => LOG_ROTATION,
|
|
1334
|
+
LocalCredentialsMissing: () => LocalCredentialsMissing,
|
|
1335
|
+
NothingToCompose: () => NothingToCompose,
|
|
1336
|
+
StageSecretMissing: () => StageSecretMissing,
|
|
1337
|
+
StageSeedMissing: () => StageSeedMissing,
|
|
1338
|
+
composeProject: () => composeProject,
|
|
1339
|
+
composeStack: () => composeStack,
|
|
1340
|
+
derivedCredentials: () => derivedCredentials,
|
|
1341
|
+
envFile: () => envFile,
|
|
1342
|
+
stackDir: () => stackDir
|
|
1343
|
+
});
|
|
1344
|
+
/** Where a stack's files are written, relative to the workspace root. */
|
|
1345
|
+
function stackDir(stage) {
|
|
1346
|
+
return `.gkm/compose/${stage}`;
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* The compose project a stage's stack runs as.
|
|
1350
|
+
*
|
|
1351
|
+
* The deploy identity's scope, so two workspaces with one name in different
|
|
1352
|
+
* namespaces never share containers or volumes — and the stage, so a
|
|
1353
|
+
* machine can run two stages side by side.
|
|
1354
|
+
*/
|
|
1355
|
+
function composeProject(identity) {
|
|
1356
|
+
return `${identity.scope}-${identity.stage}`;
|
|
1357
|
+
}
|
|
1358
|
+
/** The ports the edge is published on, and the variables that move them. */
|
|
1359
|
+
const EDGE_PORT_ENV = {
|
|
1360
|
+
https: "GKM_COMPOSE_HTTPS_PORT",
|
|
1361
|
+
http: "GKM_COMPOSE_HTTP_PORT"
|
|
1362
|
+
};
|
|
1363
|
+
/**
|
|
1364
|
+
* The variable that moves the local stage's OpenObserve off its loopback
|
|
1365
|
+
* port, 5080 — which the local stage takes from nowhere else, since it
|
|
1366
|
+
* ignores `deploy.telemetry`. Another stack, or another tool, may hold 5080.
|
|
1367
|
+
*/
|
|
1368
|
+
const LOGS_PORT_ENV = "GKM_COMPOSE_LOGS_PORT";
|
|
1369
|
+
/**
|
|
1370
|
+
* Containers this target does not run.
|
|
1371
|
+
*
|
|
1372
|
+
* The AWS emulator never applies: a stack is a server target, whose events
|
|
1373
|
+
* are pg-boss. Reconcile's Caddy fronts `gkm dev`'s host processes; the stack
|
|
1374
|
+
* brings its own edge.
|
|
1375
|
+
*
|
|
1376
|
+
* MinIO and Mailpit run on the local stage always, and on a deployed stage
|
|
1377
|
+
* only where `--allow-dev-services` allowed them and its secrets configure no
|
|
1378
|
+
* real bucket or mail server — see `devServices.ts`.
|
|
1379
|
+
*/
|
|
1380
|
+
const NOT_RUN = {
|
|
1381
|
+
localstack: true,
|
|
1382
|
+
caddy: true
|
|
1383
|
+
};
|
|
1384
|
+
/** The kinds a deployed stage takes from its secrets, or a dev service. */
|
|
1385
|
+
const SERVICE_KINDS = {
|
|
1386
|
+
email: "email",
|
|
1387
|
+
objects: "objects",
|
|
1388
|
+
"file-server": "file-server"
|
|
1389
|
+
};
|
|
1390
|
+
/** The region MinIO is addressed with. It has no regions; the SDK wants one. */
|
|
1391
|
+
const MINIO_REGION = "us-east-1";
|
|
1392
|
+
/** The S3 client's credentials, read beside a bucket's URL when it has them. */
|
|
1393
|
+
const STORAGE_KEYS = [
|
|
1394
|
+
"AWS_ACCESS_KEY_ID",
|
|
1395
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
1396
|
+
"AWS_REGION"
|
|
1397
|
+
];
|
|
1398
|
+
/**
|
|
1399
|
+
* Every service's Docker logs, rotated. The json-file driver keeps a
|
|
1400
|
+
* container's output in one file that grows until the disk is full — on a
|
|
1401
|
+
* small server, in weeks. Three files of 10 MB each per container is enough
|
|
1402
|
+
* to see what happened and never fills anything. A project's own override
|
|
1403
|
+
* file that sets `logging` wins over it.
|
|
1404
|
+
*/
|
|
1405
|
+
const LOG_ROTATION = {
|
|
1406
|
+
driver: "json-file",
|
|
1407
|
+
options: {
|
|
1408
|
+
"max-size": "10m",
|
|
1409
|
+
"max-file": "3"
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
/** The local stage's stack, without the logins `gkm dev` generates. */
|
|
1413
|
+
var LocalCredentialsMissing = class extends Error {
|
|
1414
|
+
stage;
|
|
1415
|
+
constructor(stage) {
|
|
1416
|
+
super(`The local stage '${stage}' runs with this machine's generated logins, and none were passed. Load them with loadLocalCredentials(workspace) and pass them as localCredentials.`);
|
|
1417
|
+
this.stage = stage;
|
|
1418
|
+
this.name = "LocalCredentialsMissing";
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
/**
|
|
1422
|
+
* A deployed stage's container logins, derived from its seed like its role
|
|
1423
|
+
* passwords — so every deploy of the stage computes the same ones, and no one
|
|
1424
|
+
* reading the repo can.
|
|
1425
|
+
*/
|
|
1426
|
+
function derivedCredentials(project, plan, seed) {
|
|
1427
|
+
const derive = (purpose) => localRolePassword(project, plan, purpose, seed);
|
|
1428
|
+
return {
|
|
1429
|
+
postgres: {
|
|
1430
|
+
user: postgresSuperuser(project),
|
|
1431
|
+
password: derive("master")
|
|
1432
|
+
},
|
|
1433
|
+
minio: {
|
|
1434
|
+
user: `${project}-minio`,
|
|
1435
|
+
password: derive("minio")
|
|
1436
|
+
},
|
|
1437
|
+
rabbitmq: {
|
|
1438
|
+
user: LOCAL_RABBITMQ_USER,
|
|
1439
|
+
password: derive("rabbitmq")
|
|
1440
|
+
},
|
|
1441
|
+
redis: { password: derive("redis") },
|
|
1442
|
+
cacheToken: derive("cache-token"),
|
|
1443
|
+
emulator: {
|
|
1444
|
+
accessKeyId: EMULATOR_ACCESS_KEY_ID,
|
|
1445
|
+
secretAccessKey: derive("emulator")
|
|
1446
|
+
},
|
|
1447
|
+
logs: {
|
|
1448
|
+
email: `admin@${project}`,
|
|
1449
|
+
password: derive("logs")
|
|
1450
|
+
}
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
/** A value only the stage's secrets can hold, and they do not. */
|
|
1454
|
+
var StageSecretMissing = class extends Error {
|
|
1455
|
+
app;
|
|
1456
|
+
key;
|
|
1457
|
+
stage;
|
|
1458
|
+
constructor(app, key, stage) {
|
|
1459
|
+
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}`);
|
|
1460
|
+
this.app = app;
|
|
1461
|
+
this.key = key;
|
|
1462
|
+
this.stage = stage;
|
|
1463
|
+
this.name = "StageSecretMissing";
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
/** A deployed stage's secrets with no seed to derive its passwords from. */
|
|
1467
|
+
var StageSeedMissing = class extends Error {
|
|
1468
|
+
stage;
|
|
1469
|
+
constructor(stage) {
|
|
1470
|
+
super(`The stage '${stage}' has no seed in its secrets, and a deployed stage's database passwords are derived from it. Run gkm compose --stage ${stage} without --dry-run once, or gkm deploy, to generate it.`);
|
|
1471
|
+
this.stage = stage;
|
|
1472
|
+
this.name = "StageSeedMissing";
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
/** A workspace with no API and no site to run. */
|
|
1476
|
+
var NothingToCompose = class extends Error {
|
|
1477
|
+
root;
|
|
1478
|
+
constructor(root) {
|
|
1479
|
+
super(`${root} declares no RestApi, no site and no Worker with work to do, so a stack would run nothing. Mobile apps ship through their own toolchain.`);
|
|
1480
|
+
this.root = root;
|
|
1481
|
+
this.name = "NothingToCompose";
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
/** A value that cannot be written to an env file a line at a time. */
|
|
1485
|
+
var EnvValueMultiline = class extends Error {
|
|
1486
|
+
app;
|
|
1487
|
+
key;
|
|
1488
|
+
constructor(app, key) {
|
|
1489
|
+
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.`);
|
|
1490
|
+
this.app = app;
|
|
1491
|
+
this.key = key;
|
|
1492
|
+
this.name = "EnvValueMultiline";
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
/** The services a stack's apps wait on and the edge waits on in turn. */
|
|
1496
|
+
const HEALTHY = { condition: "service_healthy" };
|
|
1497
|
+
/**
|
|
1498
|
+
* An app's health check: its own server, asked on 127.0.0.1 — not
|
|
1499
|
+
* `localhost`, which resolves to `::1` first in an Alpine container while a
|
|
1500
|
+
* server may listen on IPv4 alone.
|
|
1501
|
+
*/
|
|
1502
|
+
function probe(port, path) {
|
|
1503
|
+
return {
|
|
1504
|
+
test: [
|
|
1505
|
+
"CMD",
|
|
1506
|
+
"wget",
|
|
1507
|
+
"-q",
|
|
1508
|
+
"-O",
|
|
1509
|
+
"/dev/null",
|
|
1510
|
+
`http://127.0.0.1:${port}${path}`
|
|
1511
|
+
],
|
|
1512
|
+
interval: "5s",
|
|
1513
|
+
timeout: "3s",
|
|
1514
|
+
retries: 20,
|
|
1515
|
+
start_period: "10s"
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
/** The stack for one stage. */
|
|
1519
|
+
function composeStack(input) {
|
|
1520
|
+
const { workspace, manifest, stage, identity } = input;
|
|
1521
|
+
const local = stage === workspace.stages.local;
|
|
1522
|
+
const project = composeProject(identity);
|
|
1523
|
+
const custom = input.secrets?.custom ?? {};
|
|
1524
|
+
const https = input.ports?.https ?? 443;
|
|
1525
|
+
const http = input.ports?.http ?? 80;
|
|
1526
|
+
const proxy = proxyFor(workspace.deploy?.compose, stage, local);
|
|
1527
|
+
const certificate = local ? void 0 : certificateFor(workspace.deploy?.compose, stage, workspace.root);
|
|
1528
|
+
const plan = planFor(manifest, stage, provisionOrder(manifest), {
|
|
1529
|
+
localStage: workspace.stages.local,
|
|
1530
|
+
events: DEFAULT_EVENTS.server,
|
|
1531
|
+
cache: STACK_CACHE,
|
|
1532
|
+
edge: false
|
|
1533
|
+
});
|
|
1534
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1535
|
+
if (local && !input.localCredentials) throw new LocalCredentialsMissing(stage);
|
|
1536
|
+
const seed = local ? input.localCredentials.seed : input.secrets?.seed;
|
|
1537
|
+
if (!seed) throw new StageSeedMissing(stage);
|
|
1538
|
+
const containers = local ? input.localCredentials : derivedCredentials(workspace.name, plan, seed);
|
|
1539
|
+
const credential = {
|
|
1540
|
+
containers: {
|
|
1541
|
+
...containers,
|
|
1542
|
+
minio: {
|
|
1543
|
+
user: custom.AWS_ACCESS_KEY_ID ?? containers.minio.user,
|
|
1544
|
+
password: custom.AWS_SECRET_ACCESS_KEY ?? containers.minio.password
|
|
1545
|
+
}
|
|
1546
|
+
},
|
|
1547
|
+
seed
|
|
1548
|
+
};
|
|
1549
|
+
const apps = stackApps(workspace, manifest, plan, {
|
|
1550
|
+
local,
|
|
1551
|
+
stage,
|
|
1552
|
+
https,
|
|
1553
|
+
identity,
|
|
1554
|
+
images: input.images
|
|
1555
|
+
});
|
|
1556
|
+
const workers = stackWorkers(workerUnits(workspace, manifest, input.background ?? {}), {
|
|
1557
|
+
stage,
|
|
1558
|
+
identity,
|
|
1559
|
+
images: input.images
|
|
1560
|
+
});
|
|
1561
|
+
if (apps.length === 0 && workers.length === 0) throw new NothingToCompose(workspace.root);
|
|
1562
|
+
const owners = /* @__PURE__ */ new Map();
|
|
1563
|
+
for (const resource of plan.resources) {
|
|
1564
|
+
owners.set(resource.envKey, resource);
|
|
1565
|
+
if (resource.kind === "email") {
|
|
1566
|
+
owners.set(provideKey(resource.id, "from"), resource);
|
|
1567
|
+
owners.set(provideKey(resource.id, "inboxUrl"), resource);
|
|
1568
|
+
}
|
|
1569
|
+
if (resource.kind === "external-api") owners.set(provideKey(resource.id, "credentials"), resource);
|
|
1570
|
+
}
|
|
1571
|
+
const reads = /* @__PURE__ */ new Map();
|
|
1572
|
+
for (const app of apps) {
|
|
1573
|
+
const site = manifest[app.id];
|
|
1574
|
+
reads.set(app.name, site?.kind === "site" ? Object.values(publicEnvFor(site, manifest)) : [...appEnvKeys(manifest, app.name, input.runnables) ?? []]);
|
|
1575
|
+
}
|
|
1576
|
+
for (const worker of workers) reads.set(worker.name, [...workerEnvKeys(manifest, worker.id, input.runnables) ?? []]);
|
|
1577
|
+
const domain = workspace.domains?.[stage];
|
|
1578
|
+
const telemetry = resolveStageTelemetry({
|
|
1579
|
+
...workspace.deploy?.telemetry ? { telemetry: workspace.deploy.telemetry } : {},
|
|
1580
|
+
stage,
|
|
1581
|
+
local,
|
|
1582
|
+
target: "compose",
|
|
1583
|
+
runtime: "server",
|
|
1584
|
+
selfHosted: true,
|
|
1585
|
+
used: usesTelemetry(manifest)
|
|
1586
|
+
});
|
|
1587
|
+
const logsBase = telemetry?.provider === "self-hosted" ? stackLogs({
|
|
1588
|
+
config: local && input.ports?.logs ? {
|
|
1589
|
+
...telemetry.selfHosted,
|
|
1590
|
+
port: input.ports.logs
|
|
1591
|
+
} : telemetry.selfHosted,
|
|
1592
|
+
stage,
|
|
1593
|
+
local,
|
|
1594
|
+
project: workspace.name,
|
|
1595
|
+
...domain ? { domain } : {},
|
|
1596
|
+
custom,
|
|
1597
|
+
https,
|
|
1598
|
+
...local ? { localLogin: input.localCredentials.logs } : {}
|
|
1599
|
+
}) : void 0;
|
|
1600
|
+
const services = local ? {
|
|
1601
|
+
missing: [],
|
|
1602
|
+
minio: plan.resources.filter((r) => r.kind === "objects").map((r) => r.id),
|
|
1603
|
+
mailpit: plan.resources.filter((r) => r.kind === "email").map((r) => r.id)
|
|
1604
|
+
} : (() => {
|
|
1605
|
+
assertStageProvidersEnabled(workspace, manifest, stage);
|
|
1606
|
+
return assertExternalServices({
|
|
1607
|
+
stage,
|
|
1608
|
+
declarations: serviceDeclarations(plan, reads, owners),
|
|
1609
|
+
supplied: custom,
|
|
1610
|
+
allow: input.allowDevServices === true,
|
|
1611
|
+
...domain ? { domain } : {},
|
|
1612
|
+
providers: stageProviderNotes(workspace, stage)
|
|
1613
|
+
});
|
|
1614
|
+
})();
|
|
1615
|
+
const redis = stackRedis({
|
|
1616
|
+
plan,
|
|
1617
|
+
stage,
|
|
1618
|
+
local,
|
|
1619
|
+
custom,
|
|
1620
|
+
...local ? { localPassword: input.localCredentials.redis.password } : {}
|
|
1621
|
+
});
|
|
1622
|
+
const infra = [...plan.containers.filter((c) => !NOT_RUN[c]).filter((c) => c !== LOGS_SERVICE).filter((c) => c !== "minio" || services.minio.length > 0).filter((c) => c !== "mailpit" || services.mailpit.length > 0).filter((c) => c !== "redis" || redis !== void 0), ...logsBase ? [LOGS_SERVICE] : []].sort();
|
|
1623
|
+
const storage = infra.includes("minio") ? (() => {
|
|
1624
|
+
const buckets = services.minio.map((id) => byId.get(id)?.name).filter((name) => Boolean(name)).sort();
|
|
1625
|
+
return {
|
|
1626
|
+
...credential.containers.minio,
|
|
1627
|
+
buckets,
|
|
1628
|
+
policies: bucketPolicies(plan).filter((p) => buckets.includes(p.bucket))
|
|
1629
|
+
};
|
|
1630
|
+
})() : void 0;
|
|
1631
|
+
const fileServers = /* @__PURE__ */ new Map();
|
|
1632
|
+
if (storage) for (const resource of plan.resources) {
|
|
1633
|
+
if (resource.kind !== "file-server" || !resource.of) continue;
|
|
1634
|
+
if (!services.minio.includes(resource.of)) continue;
|
|
1635
|
+
if (custom[resource.envKey] !== void 0) continue;
|
|
1636
|
+
const bucket = byId.get(resource.of);
|
|
1637
|
+
if (!bucket) continue;
|
|
1638
|
+
let host;
|
|
1639
|
+
if (local) host = hostFor(resource, workspace.name);
|
|
1640
|
+
else {
|
|
1641
|
+
if (!domain) throw new NoDomainForStage(stage);
|
|
1642
|
+
host = `${resource.subdomain ?? appKey(resource.id)}.${domain}`;
|
|
1643
|
+
}
|
|
1644
|
+
fileServers.set(resource.id, {
|
|
1645
|
+
url: `https://${host}${local && https !== 443 ? `:${https}` : ""}`,
|
|
1646
|
+
route: {
|
|
1647
|
+
name: `files-${appKey(resource.id)}`,
|
|
1648
|
+
host,
|
|
1649
|
+
upstreams: [{
|
|
1650
|
+
service: "minio",
|
|
1651
|
+
port: 9e3
|
|
1652
|
+
}],
|
|
1653
|
+
streaming: false,
|
|
1654
|
+
prefix: `/${bucket.name}`
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
const edgeServices = new Set(proxy === "traefik" ? [
|
|
1659
|
+
...apps.map((app) => app.name),
|
|
1660
|
+
...fileServers.size > 0 ? ["minio"] : [],
|
|
1661
|
+
...logsBase?.host && logsBase.public ? [LOGS_SERVICE] : []
|
|
1662
|
+
] : []);
|
|
1663
|
+
const hostOf = (service) => edgeServices.has(service) ? edgeAlias(project, service) : service;
|
|
1664
|
+
const logs = logsBase && {
|
|
1665
|
+
...logsBase,
|
|
1666
|
+
appEnv: {
|
|
1667
|
+
...logsBase.appEnv,
|
|
1668
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: logsBase.appEnv.OTEL_EXPORTER_OTLP_ENDPOINT.replace(`//openobserve:`, `//${hostOf("openobserve")}:`)
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
const derivation = {
|
|
1672
|
+
project: workspace.name,
|
|
1673
|
+
credentials: credential.containers,
|
|
1674
|
+
seed: credential.seed,
|
|
1675
|
+
...!local && domain ? { mailFrom: `noreply@${domain}` } : {}
|
|
1676
|
+
};
|
|
1677
|
+
const outside = networkEnv(plan, {
|
|
1678
|
+
...derivation,
|
|
1679
|
+
addresses: Object.fromEntries(apps.map((app) => [app.id, app.url]))
|
|
1680
|
+
}, hostOf);
|
|
1681
|
+
const inside = networkEnv(plan, {
|
|
1682
|
+
...derivation,
|
|
1683
|
+
addresses: Object.fromEntries(apps.map((app) => [app.id, `http://${hostOf(app.name)}:${app.port}`]))
|
|
1684
|
+
}, hostOf);
|
|
1685
|
+
const surfaceUrls = new Map(plan.resources.filter((r) => r.kind === "rest-api" || r.kind === "site").map((r) => [r.envKey, r.id]));
|
|
1686
|
+
const appById = new Map(apps.map((app) => [app.id, app]));
|
|
1687
|
+
/**
|
|
1688
|
+
* What a key resolves to for one app, from the stage's secrets where only
|
|
1689
|
+
* they can hold it, and the derivations otherwise.
|
|
1690
|
+
*/
|
|
1691
|
+
const valueFor = (app, key, owner) => {
|
|
1692
|
+
if (isReservedStageKey(key)) return void 0;
|
|
1693
|
+
if (owner && SERVICE_KINDS[owner.kind]) {
|
|
1694
|
+
if (custom[key] !== void 0) return custom[key];
|
|
1695
|
+
if (owner.kind === "file-server") return fileServers.get(owner.id)?.url;
|
|
1696
|
+
return outside[key];
|
|
1697
|
+
}
|
|
1698
|
+
if (owner && suppliedOnly(owner.kind, owner.id, key, local)) {
|
|
1699
|
+
const value = custom[key];
|
|
1700
|
+
if (value === void 0) throw new StageSecretMissing(app.name, key, stage);
|
|
1701
|
+
return value;
|
|
1702
|
+
}
|
|
1703
|
+
if (custom[key] !== void 0) return custom[key];
|
|
1704
|
+
if (owner?.kind === "cache") return redis?.urls[key];
|
|
1705
|
+
const surface = surfaceUrls.get(key);
|
|
1706
|
+
if (app.kind === "rest-api" && surface && surface !== app.id) return inside[key];
|
|
1707
|
+
return outside[key];
|
|
1708
|
+
};
|
|
1709
|
+
const telemetryValues = logs ? telemetryEnv({
|
|
1710
|
+
endpoint: logs.appEnv.OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
1711
|
+
headers: logs.appEnv.OTEL_EXPORTER_OTLP_HEADERS
|
|
1712
|
+
}, telemetry?.sampleRate ?? 1) : telemetry?.provider === "otlp" ? otlpTelemetryEnv(telemetry) : void 0;
|
|
1713
|
+
/** Each key one backend process reads, resolved for this stage. */
|
|
1714
|
+
const valuesFor = (app, keys) => {
|
|
1715
|
+
const values = {};
|
|
1716
|
+
for (const key of [...keys].sort()) {
|
|
1717
|
+
if (isTelemetryKey(key)) continue;
|
|
1718
|
+
const owner = owners.get(key) ?? storageOwner(key, plan);
|
|
1719
|
+
if (!local && owner?.kind === "email" && key.endsWith("_INBOX_URL")) continue;
|
|
1720
|
+
if (STORAGE_KEYS.includes(key)) {
|
|
1721
|
+
const value = custom[key] ?? storageValue(key, storage);
|
|
1722
|
+
if (value !== void 0) values[key] = value;
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
const value = valueFor(app, key, owner);
|
|
1726
|
+
if (value !== void 0) values[key] = value;
|
|
1727
|
+
}
|
|
1728
|
+
return values;
|
|
1729
|
+
};
|
|
1730
|
+
for (const app of apps) {
|
|
1731
|
+
if (app.kind === "site") {
|
|
1732
|
+
const site = manifest[app.id];
|
|
1733
|
+
if (site?.kind !== "site") continue;
|
|
1734
|
+
const args = {};
|
|
1735
|
+
for (const [key, source] of Object.entries(publicEnvFor(site, manifest))) {
|
|
1736
|
+
const value = valueFor(app, source, owners.get(source));
|
|
1737
|
+
if (value !== void 0) args[key] = value;
|
|
1738
|
+
}
|
|
1739
|
+
if (app.build && Object.keys(args).length > 0) app.build.args = args;
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
if (input.buildOnly) continue;
|
|
1743
|
+
const keys = appEnvKeys(manifest, app.name, input.runnables) ?? /* @__PURE__ */ new Set();
|
|
1744
|
+
const values = valuesFor(app, keys);
|
|
1745
|
+
const trusted = provideKey(app.id, "trustedOrigins");
|
|
1746
|
+
if (keys.has(trusted)) {
|
|
1747
|
+
const internal = (byId.get(app.id)?.callers ?? []).map((caller) => appById.get(caller)).filter((caller) => caller?.kind === "rest-api").map((caller) => `http://${hostOf(caller.name)}:${caller.port}`);
|
|
1748
|
+
const origins = [...(values[trusted] ?? "").split(",").filter(Boolean), ...internal];
|
|
1749
|
+
values[trusted] = [...new Set(origins)].join(",");
|
|
1750
|
+
}
|
|
1751
|
+
app.env = {
|
|
1752
|
+
...scopeTelemetryEnv(values, {
|
|
1753
|
+
uses: telemetryOf(manifest, app.id) !== void 0,
|
|
1754
|
+
serviceName: app.name,
|
|
1755
|
+
...telemetryValues ? { telemetry: telemetryValues } : {}
|
|
1756
|
+
}),
|
|
1757
|
+
NODE_ENV: "production",
|
|
1758
|
+
PORT: String(app.port),
|
|
1759
|
+
STAGE: stage
|
|
1760
|
+
};
|
|
1761
|
+
for (const [key, value] of Object.entries(app.env)) if (/[\r\n]/.test(value)) throw new EnvValueMultiline(app.name, key);
|
|
1762
|
+
}
|
|
1763
|
+
for (const worker of input.buildOnly ? [] : workers) {
|
|
1764
|
+
const keys = workerEnvKeys(manifest, worker.id, input.runnables) ?? /* @__PURE__ */ new Set();
|
|
1765
|
+
worker.env = {
|
|
1766
|
+
...scopeTelemetryEnv(valuesFor({
|
|
1767
|
+
name: worker.name,
|
|
1768
|
+
id: worker.id,
|
|
1769
|
+
kind: "rest-api"
|
|
1770
|
+
}, keys), {
|
|
1771
|
+
uses: telemetryOf(manifest, worker.id) !== void 0,
|
|
1772
|
+
serviceName: worker.name,
|
|
1773
|
+
...telemetryValues ? { telemetry: telemetryValues } : {}
|
|
1774
|
+
}),
|
|
1775
|
+
NODE_ENV: "production",
|
|
1776
|
+
PORT: String(worker.port),
|
|
1777
|
+
STAGE: stage
|
|
1778
|
+
};
|
|
1779
|
+
for (const [key, value] of Object.entries(worker.env)) if (/[\r\n]/.test(value)) throw new EnvValueMultiline(worker.name, key);
|
|
1780
|
+
}
|
|
1781
|
+
const deployedTls = certificate ? {
|
|
1782
|
+
kind: "files",
|
|
1783
|
+
...proxy === "caddy" ? {
|
|
1784
|
+
certFile: `${CADDY_TLS_DIR}/cert.pem`,
|
|
1785
|
+
keyFile: `${CADDY_TLS_DIR}/key.pem`
|
|
1786
|
+
} : edgeCertificatePaths(project)
|
|
1787
|
+
} : { kind: "acme" };
|
|
1788
|
+
const tls = local ? { kind: "internal" } : deployedTls;
|
|
1789
|
+
const layout = input.layout ?? defaultLayout(workspace.root);
|
|
1790
|
+
const compose = stackFile({
|
|
1791
|
+
project,
|
|
1792
|
+
plan,
|
|
1793
|
+
infra,
|
|
1794
|
+
apps,
|
|
1795
|
+
workers,
|
|
1796
|
+
credentials: credential.containers,
|
|
1797
|
+
...storage ? { storage } : {},
|
|
1798
|
+
...logs ? { logs } : {},
|
|
1799
|
+
...redis ? { redis } : {},
|
|
1800
|
+
https,
|
|
1801
|
+
http,
|
|
1802
|
+
composeDir: join(workspace.root, stackDir(stage)),
|
|
1803
|
+
buildRoot: layout.buildRoot,
|
|
1804
|
+
workspaceRoot: workspace.root,
|
|
1805
|
+
envFiles: !input.buildOnly,
|
|
1806
|
+
proxy,
|
|
1807
|
+
edge: new Map([...edgeServices].map((service) => [service, hostOf(service)])),
|
|
1808
|
+
tls: certificate !== void 0
|
|
1809
|
+
});
|
|
1810
|
+
const routes = [
|
|
1811
|
+
...apps.map((app) => ({
|
|
1812
|
+
name: app.name,
|
|
1813
|
+
host: app.host,
|
|
1814
|
+
upstreams: [{
|
|
1815
|
+
service: app.name,
|
|
1816
|
+
port: app.port
|
|
1817
|
+
}],
|
|
1818
|
+
streaming: true,
|
|
1819
|
+
health: app.kind === "site" ? "/" : "/health"
|
|
1820
|
+
})),
|
|
1821
|
+
...[...fileServers.values()].map(({ route }) => route),
|
|
1822
|
+
...logs?.host && logs.public ? [{
|
|
1823
|
+
name: LOGS_SERVICE,
|
|
1824
|
+
host: logs.host,
|
|
1825
|
+
upstreams: [{
|
|
1826
|
+
service: LOGS_SERVICE,
|
|
1827
|
+
port: LOGS_PORT
|
|
1828
|
+
}],
|
|
1829
|
+
streaming: true,
|
|
1830
|
+
allow: logs.public.allow
|
|
1831
|
+
}] : []
|
|
1832
|
+
];
|
|
1833
|
+
const dockerfiles = {};
|
|
1834
|
+
for (const app of apps) {
|
|
1835
|
+
if (!app.build) continue;
|
|
1836
|
+
dockerfiles[app.build.dockerfile] = appDockerfile(app.name, workspace.apps[app.name], {
|
|
1837
|
+
layout,
|
|
1838
|
+
workspaceRoot: workspace.root,
|
|
1839
|
+
apps: workspace.apps,
|
|
1840
|
+
manifest,
|
|
1841
|
+
cache: STACK_CACHE,
|
|
1842
|
+
workspace,
|
|
1843
|
+
stage
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
for (const worker of workers) {
|
|
1847
|
+
if (!worker.build) continue;
|
|
1848
|
+
dockerfiles[worker.build.dockerfile] = workerDockerfile({
|
|
1849
|
+
id: worker.id,
|
|
1850
|
+
name: worker.name,
|
|
1851
|
+
app: worker.host
|
|
1852
|
+
}, workspace.apps[worker.host], {
|
|
1853
|
+
layout,
|
|
1854
|
+
workspaceRoot: workspace.root,
|
|
1855
|
+
cache: STACK_CACHE
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
return {
|
|
1859
|
+
project,
|
|
1860
|
+
stage,
|
|
1861
|
+
local,
|
|
1862
|
+
apps,
|
|
1863
|
+
workers,
|
|
1864
|
+
infra,
|
|
1865
|
+
compose,
|
|
1866
|
+
proxy,
|
|
1867
|
+
routes,
|
|
1868
|
+
...proxy === "caddy" ? { caddyfile: edgeCaddyfile(routes, { tls }) } : { traefik: traefikDynamicFile(routes, {
|
|
1869
|
+
project,
|
|
1870
|
+
tls: deployedTls
|
|
1871
|
+
}) },
|
|
1872
|
+
...certificate ? { tls: certificate } : {},
|
|
1873
|
+
dockerfiles,
|
|
1874
|
+
plan,
|
|
1875
|
+
credential,
|
|
1876
|
+
...storage ? { storage } : {},
|
|
1877
|
+
devServices: local ? [] : devServicesUsed(services),
|
|
1878
|
+
...logs ? { logs } : {},
|
|
1879
|
+
...telemetry ? { telemetry } : {},
|
|
1880
|
+
...redis ? { redis } : {}
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* The mail and storage constructs a deployed stage's apps read, with who
|
|
1885
|
+
* reads each — and every file server's bucket, which serves it whether or
|
|
1886
|
+
* not an app reads it directly.
|
|
1887
|
+
*/
|
|
1888
|
+
function serviceDeclarations(plan, reads, owners) {
|
|
1889
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1890
|
+
const readers = /* @__PURE__ */ new Map();
|
|
1891
|
+
const add = (id, app) => {
|
|
1892
|
+
const set = readers.get(id) ?? /* @__PURE__ */ new Set();
|
|
1893
|
+
if (app) set.add(app);
|
|
1894
|
+
readers.set(id, set);
|
|
1895
|
+
};
|
|
1896
|
+
for (const [app, keys] of reads) for (const key of keys) {
|
|
1897
|
+
const owner = owners.get(key);
|
|
1898
|
+
if (!owner || !SERVICE_KINDS[owner.kind]) continue;
|
|
1899
|
+
if (owner.kind === "email" && key.endsWith("_INBOX_URL")) continue;
|
|
1900
|
+
add(owner.id, app);
|
|
1901
|
+
if (owner.kind === "file-server" && owner.of) add(owner.of);
|
|
1902
|
+
}
|
|
1903
|
+
return [...readers].flatMap(([id, apps]) => {
|
|
1904
|
+
const resource = byId.get(id);
|
|
1905
|
+
const kind = resource && SERVICE_KINDS[resource.kind];
|
|
1906
|
+
if (!resource || !kind) return [];
|
|
1907
|
+
return [{
|
|
1908
|
+
id,
|
|
1909
|
+
kind,
|
|
1910
|
+
...resource.of ? { of: resource.of } : {},
|
|
1911
|
+
apps: [...apps].sort()
|
|
1912
|
+
}];
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
/** The stack's MinIO's value for one of the S3 client's keys. */
|
|
1916
|
+
function storageValue(key, storage) {
|
|
1917
|
+
if (!storage) return void 0;
|
|
1918
|
+
if (key === "AWS_ACCESS_KEY_ID") return storage.user;
|
|
1919
|
+
if (key === "AWS_SECRET_ACCESS_KEY") return storage.password;
|
|
1920
|
+
if (key === "AWS_REGION") return MINIO_REGION;
|
|
1921
|
+
}
|
|
1922
|
+
/** Which bucket an S3 credential key belongs to — the first one declared. */
|
|
1923
|
+
function storageOwner(key, plan) {
|
|
1924
|
+
if (!STORAGE_KEYS.includes(key)) return void 0;
|
|
1925
|
+
return plan.resources.find((r) => r.kind === "objects");
|
|
1926
|
+
}
|
|
1927
|
+
/** The apps a stack runs: every API and every site, at its stage address. */
|
|
1928
|
+
function stackApps(workspace, manifest, plan, options) {
|
|
1929
|
+
const apps = [];
|
|
1930
|
+
const resources = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1931
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
1932
|
+
if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
|
|
1933
|
+
const name = appKey(id);
|
|
1934
|
+
const app = workspace.apps[name];
|
|
1935
|
+
const resource = resources.get(id);
|
|
1936
|
+
if (!app || !resource || !app.port) continue;
|
|
1937
|
+
const host = hostOf(name, app, workspace, resource, options);
|
|
1938
|
+
const tag = declaration.kind === "site" ? siteTag(options.images.tag, options.stage) : options.images.tag;
|
|
1939
|
+
const ref = validateImageRef(imageRef(options.identity, name, options.images.registry, tag));
|
|
1940
|
+
apps.push({
|
|
1941
|
+
app: name,
|
|
1942
|
+
name,
|
|
1943
|
+
id,
|
|
1944
|
+
kind: declaration.kind,
|
|
1945
|
+
path: app.path,
|
|
1946
|
+
port: app.port,
|
|
1947
|
+
host,
|
|
1948
|
+
url: `https://${host}${options.local && options.https !== 443 ? `:${options.https}` : ""}`,
|
|
1949
|
+
ref,
|
|
1950
|
+
tag,
|
|
1951
|
+
...options.images.mode === "build" ? { build: { dockerfile: `${stackDir(options.stage)}/Dockerfile.${name}` } } : {}
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
return apps.sort((a, b) => a.name.localeCompare(b.name));
|
|
1955
|
+
}
|
|
1956
|
+
/** Each worker's image, and where it is built from. */
|
|
1957
|
+
function stackWorkers(units, options) {
|
|
1958
|
+
return units.map((unit) => ({
|
|
1959
|
+
app: unit.name,
|
|
1960
|
+
name: unit.name,
|
|
1961
|
+
id: unit.id,
|
|
1962
|
+
host: unit.app,
|
|
1963
|
+
port: WORKER_PORT,
|
|
1964
|
+
ref: validateImageRef(imageRef(options.identity, unit.name, options.images.registry, options.images.tag)),
|
|
1965
|
+
tag: options.images.tag,
|
|
1966
|
+
...options.images.mode === "build" ? { build: { dockerfile: `${stackDir(options.stage)}/Dockerfile.${unit.name}` } } : {}
|
|
1967
|
+
}));
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* The host an app answers on for a stage: the `gkm dev` edge's
|
|
1971
|
+
* `*.localhost` names for the local stage, and the stage's domain — by the
|
|
1972
|
+
* same rule a deploy follows — for any other.
|
|
1973
|
+
*/
|
|
1974
|
+
function hostOf(name, app, workspace, resource, options) {
|
|
1975
|
+
if (options.local) return hostFor(resource, workspace.name);
|
|
1976
|
+
return resolveHost(name, app, options.stage, workspace.domains, isMainFrontendApp(name, app, workspace.apps));
|
|
1977
|
+
}
|
|
1978
|
+
/** The compose document: the infrastructure, the apps, and the edge. */
|
|
1979
|
+
function stackFile(options) {
|
|
1980
|
+
const { project, plan, apps } = options;
|
|
1981
|
+
const infra = options.infra.filter((name) => name !== LOGS_SERVICE);
|
|
1982
|
+
const derived = composeFor({
|
|
1983
|
+
...plan,
|
|
1984
|
+
containers: [...infra]
|
|
1985
|
+
}, {
|
|
1986
|
+
project,
|
|
1987
|
+
ports: Object.fromEntries(portKeys(infra, plan.fakes).map((key) => [key, 0])),
|
|
1988
|
+
credentials: options.credentials
|
|
1989
|
+
});
|
|
1990
|
+
const services = {};
|
|
1991
|
+
const volumes = {
|
|
1992
|
+
...derived.volumes,
|
|
1993
|
+
...options.proxy === "caddy" ? {
|
|
1994
|
+
"caddy-data": {},
|
|
1995
|
+
"caddy-config": {}
|
|
1996
|
+
} : {},
|
|
1997
|
+
...options.logs ? { "openobserve-data": {} } : {}
|
|
1998
|
+
};
|
|
1999
|
+
for (const name of infra) {
|
|
2000
|
+
const { ports: _ports, depends_on, profiles: _profiles, ...service } = derived.services[name];
|
|
2001
|
+
services[name] = {
|
|
2002
|
+
...service,
|
|
2003
|
+
...depends_on?.length ? { depends_on: Object.fromEntries(depends_on.map((dep) => [dep, HEALTHY])) } : {}
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
const postgres = services.postgres;
|
|
2007
|
+
if (postgres) postgres.ports = ["127.0.0.1::5432"];
|
|
2008
|
+
const minio = services.minio;
|
|
2009
|
+
if (minio && options.storage) {
|
|
2010
|
+
minio.environment = {
|
|
2011
|
+
...minio.environment,
|
|
2012
|
+
MINIO_ROOT_USER: options.storage.user,
|
|
2013
|
+
MINIO_ROOT_PASSWORD: options.storage.password
|
|
2014
|
+
};
|
|
2015
|
+
minio.ports = ["127.0.0.1::9000"];
|
|
2016
|
+
}
|
|
2017
|
+
if (services["redis"] && options.redis) services[REDIS_SERVICE] = redisService(options.redis);
|
|
2018
|
+
if (options.logs) services[LOGS_SERVICE] = logsService(options.logs);
|
|
2019
|
+
for (const app of apps) services[app.name] = {
|
|
2020
|
+
image: app.ref,
|
|
2021
|
+
...app.build ? { build: {
|
|
2022
|
+
...composeBuildPaths({
|
|
2023
|
+
composeDir: options.composeDir,
|
|
2024
|
+
buildRoot: options.buildRoot,
|
|
2025
|
+
workspaceRoot: options.workspaceRoot,
|
|
2026
|
+
dockerfile: app.build.dockerfile
|
|
2027
|
+
}),
|
|
2028
|
+
...app.build.args ? { args: app.build.args } : {}
|
|
2029
|
+
} } : {},
|
|
2030
|
+
restart: "unless-stopped",
|
|
2031
|
+
...app.kind === "rest-api" ? {
|
|
2032
|
+
env_file: [{
|
|
2033
|
+
path: `./${app.name}.env`,
|
|
2034
|
+
format: "raw"
|
|
2035
|
+
}],
|
|
2036
|
+
...infra.length ? { depends_on: Object.fromEntries(infra.map((name) => [name, HEALTHY])) } : {}
|
|
2037
|
+
} : {},
|
|
2038
|
+
healthcheck: probe(app.port, app.kind === "site" ? "/" : "/health")
|
|
2039
|
+
};
|
|
2040
|
+
for (const worker of options.workers) services[worker.name] = {
|
|
2041
|
+
image: worker.ref,
|
|
2042
|
+
...worker.build ? { build: composeBuildPaths({
|
|
2043
|
+
composeDir: options.composeDir,
|
|
2044
|
+
buildRoot: options.buildRoot,
|
|
2045
|
+
workspaceRoot: options.workspaceRoot,
|
|
2046
|
+
dockerfile: worker.build.dockerfile
|
|
2047
|
+
}) } : {},
|
|
2048
|
+
restart: "unless-stopped",
|
|
2049
|
+
env_file: [{
|
|
2050
|
+
path: `./${worker.name}.env`,
|
|
2051
|
+
format: "raw"
|
|
2052
|
+
}],
|
|
2053
|
+
...infra.length ? { depends_on: Object.fromEntries(infra.map((name) => [name, HEALTHY])) } : {},
|
|
2054
|
+
healthcheck: probe(worker.port, "/health")
|
|
2055
|
+
};
|
|
2056
|
+
if (options.proxy === "caddy") services.caddy = {
|
|
2057
|
+
image: DEFAULT_IMAGES.caddy,
|
|
2058
|
+
restart: "unless-stopped",
|
|
2059
|
+
ports: [`${options.https}:443`, `${options.http}:80`],
|
|
2060
|
+
volumes: [
|
|
2061
|
+
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
|
2062
|
+
"caddy-data:/data",
|
|
2063
|
+
"caddy-config:/config",
|
|
2064
|
+
...options.tls ? [`./tls:${CADDY_TLS_DIR}:ro`] : []
|
|
2065
|
+
],
|
|
2066
|
+
depends_on: Object.fromEntries(apps.map((app) => [app.name, HEALTHY])),
|
|
2067
|
+
healthcheck: {
|
|
2068
|
+
test: [
|
|
2069
|
+
"CMD",
|
|
2070
|
+
"caddy",
|
|
2071
|
+
"version"
|
|
2072
|
+
],
|
|
2073
|
+
interval: "10s",
|
|
2074
|
+
timeout: "5s",
|
|
2075
|
+
retries: 5
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
for (const [service, alias] of options.edge) {
|
|
2079
|
+
const definition = services[service];
|
|
2080
|
+
if (!definition) continue;
|
|
2081
|
+
definition.networks = {
|
|
2082
|
+
default: { aliases: [alias] },
|
|
2083
|
+
edge: { aliases: [alias] }
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
for (const service of Object.values(services)) {
|
|
2087
|
+
if (!options.envFiles) delete service.env_file;
|
|
2088
|
+
service.logging = {
|
|
2089
|
+
driver: LOG_ROTATION.driver,
|
|
2090
|
+
options: { ...LOG_ROTATION.options }
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
return {
|
|
2094
|
+
name: project,
|
|
2095
|
+
services,
|
|
2096
|
+
volumes,
|
|
2097
|
+
...options.edge.size > 0 ? { networks: { edge: {
|
|
2098
|
+
name: EDGE_NETWORK,
|
|
2099
|
+
external: true
|
|
2100
|
+
} } } : {}
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* A build without a layout: the workspace is the build root, with pnpm.
|
|
2105
|
+
* What a caller that never looked at the filesystem gets.
|
|
2106
|
+
*/
|
|
2107
|
+
function defaultLayout(root) {
|
|
2108
|
+
return {
|
|
2109
|
+
buildRoot: root,
|
|
2110
|
+
gkmRoot: ".",
|
|
2111
|
+
tools: {
|
|
2112
|
+
packageManager: "pnpm",
|
|
2113
|
+
turboVersion: TURBO_VERSION,
|
|
2114
|
+
monorepo: true
|
|
2115
|
+
},
|
|
2116
|
+
gkmPaths: ["gkm.config.*"]
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
/** Render an app's env file: one `KEY=value` per line, read raw by compose. */
|
|
2120
|
+
function envFile(env) {
|
|
2121
|
+
return `${Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
|
|
2122
|
+
}
|
|
2123
|
+
//#endregion
|
|
2124
|
+
//#region src/target/compose/health.ts
|
|
2125
|
+
/**
|
|
2126
|
+
* Asking each app of a stack whether it answers — through Caddy, over HTTPS,
|
|
2127
|
+
* the way a browser reaches it.
|
|
2128
|
+
*
|
|
2129
|
+
* `docker compose up --wait` already knows each container passed its own
|
|
2130
|
+
* health check, asked on 127.0.0.1 inside it. What that cannot say is whether
|
|
2131
|
+
* the stack is reachable: whether the edge routes the host to the app, and
|
|
2132
|
+
* whether the certificate it presents is one a client accepts. So the check
|
|
2133
|
+
* goes through the edge, by hostname, and verifies the certificate.
|
|
2134
|
+
*/
|
|
2135
|
+
/** A request that got no answer in time. */
|
|
2136
|
+
var HealthRequestTimedOut = class extends Error {
|
|
2137
|
+
url;
|
|
2138
|
+
timeoutMs;
|
|
2139
|
+
constructor(url, timeoutMs) {
|
|
2140
|
+
super(`${url} did not answer within ${timeoutMs}ms. Check the stack is up with \`docker compose ps\`.`);
|
|
2141
|
+
this.url = url;
|
|
2142
|
+
this.timeoutMs = timeoutMs;
|
|
2143
|
+
this.name = "HealthRequestTimedOut";
|
|
2144
|
+
}
|
|
2145
|
+
};
|
|
2146
|
+
/** The real probe: one HTTPS GET, the certificate verified. */
|
|
2147
|
+
const httpsProbe = ({ url, ca, connectTo, connectPort, timeoutMs, signal }) => {
|
|
2148
|
+
const target = new URL(url);
|
|
2149
|
+
return new Promise((resolve, reject) => {
|
|
2150
|
+
const req = request({
|
|
2151
|
+
host: connectTo ?? target.hostname,
|
|
2152
|
+
port: connectPort ?? (target.port || 443),
|
|
2153
|
+
servername: target.hostname,
|
|
2154
|
+
path: `${target.pathname}${target.search}`,
|
|
2155
|
+
method: "GET",
|
|
2156
|
+
headers: { host: target.host },
|
|
2157
|
+
...ca ? { ca } : {},
|
|
2158
|
+
...signal ? { signal } : {},
|
|
2159
|
+
timeout: timeoutMs
|
|
2160
|
+
}, (res) => {
|
|
2161
|
+
res.resume();
|
|
2162
|
+
res.on("end", () => resolve(res.statusCode ?? 0));
|
|
2163
|
+
res.on("error", reject);
|
|
2164
|
+
});
|
|
2165
|
+
req.on("timeout", () => req.destroy(new HealthRequestTimedOut(url, timeoutMs)));
|
|
2166
|
+
req.on("error", reject);
|
|
2167
|
+
req.end();
|
|
2168
|
+
});
|
|
2169
|
+
};
|
|
2170
|
+
/** Whether a status means the app answered as it should. */
|
|
2171
|
+
function isHealthy(status) {
|
|
2172
|
+
return status >= 200 && status < 400;
|
|
2173
|
+
}
|
|
2174
|
+
/** Apps that did not answer through the edge. */
|
|
2175
|
+
var ComposeAppsUnhealthy = class extends Error {
|
|
2176
|
+
project;
|
|
2177
|
+
apps;
|
|
2178
|
+
proxy;
|
|
2179
|
+
constructor(project, apps, proxy = "caddy") {
|
|
2180
|
+
const names = apps.map((a) => a.app).join(" ");
|
|
2181
|
+
super(`${project} started, but ${apps.length === 1 ? "this app does" : "these apps do"} not answer through ${proxy === "caddy" ? "Caddy" : "the shared Traefik edge"}:\n` + apps.map(({ url, last }) => ` - ${url}: ${last}`).join("\n") + (proxy === "caddy" ? `\nSee why with \`docker compose -p ${project} logs caddy ${names}\`.` : `\nSee why with \`docker compose -p gkm-edge logs traefik\` and \`docker compose -p ${project} logs ${names}\`.`));
|
|
2182
|
+
this.project = project;
|
|
2183
|
+
this.apps = apps;
|
|
2184
|
+
this.proxy = proxy;
|
|
2185
|
+
this.name = "ComposeAppsUnhealthy";
|
|
2186
|
+
}
|
|
2187
|
+
};
|
|
2188
|
+
//#endregion
|
|
2189
|
+
//#region src/target/compose/phases.ts
|
|
2190
|
+
/**
|
|
2191
|
+
* The compose target's phases: a stage as one Docker Compose stack behind
|
|
2192
|
+
* Caddy, on the machine the deploy runs on.
|
|
2193
|
+
*
|
|
2194
|
+
* The order is the point of this file, and the rules it keeps are these:
|
|
2195
|
+
*
|
|
2196
|
+
* - A tag is checked before anything happens (`validate`). Every app's image
|
|
2197
|
+
* is asked of the registry first; one missing image stops the run naming
|
|
2198
|
+
* them all, and nothing is written, pulled or started.
|
|
2199
|
+
* - The databases exist before the apps do (`provision`). Postgres comes up
|
|
2200
|
+
* alone, its databases, roles and grants are created, its migrations
|
|
2201
|
+
* applied and its seeds run from this machine, and only then do the apps
|
|
2202
|
+
* start — so an app
|
|
2203
|
+
* never boots against a schema that is not there yet.
|
|
2204
|
+
* - What a stage runs is recorded (`release`). Each app's image, the tag it
|
|
2205
|
+
* was released under and the digest it resolved to are written to the
|
|
2206
|
+
* stage's state, so "what is this stage running" has an exact answer.
|
|
2207
|
+
* - What was started answers (`verify`): each app is asked through Caddy,
|
|
2208
|
+
* over HTTPS, with the certificate verified.
|
|
2209
|
+
*
|
|
2210
|
+
* Every image is built inside Docker from a pruned slice of the build root —
|
|
2211
|
+
* the same Dockerfiles `gkm docker` writes — so nothing is built on this
|
|
2212
|
+
* machine before `docker compose build`.
|
|
2213
|
+
*
|
|
2214
|
+
* Docker, the registry, Postgres and the health probe are
|
|
2215
|
+
* injected, so the rules are asserted without a daemon; the defaults are the
|
|
2216
|
+
* real ones.
|
|
2217
|
+
*/
|
|
2218
|
+
/** `--push` without `--build`: there is nothing built here to push. */
|
|
2219
|
+
var ComposePushNeedsBuild = class extends Error {
|
|
2220
|
+
constructor() {
|
|
2221
|
+
super("--push pushes the images this checkout builds, so it needs --build (and never --pull): gkm compose --stage <stage> --build --push --tag <tag>.");
|
|
2222
|
+
this.name = "ComposePushNeedsBuild";
|
|
2223
|
+
}
|
|
2224
|
+
};
|
|
2225
|
+
/** Digests to run by, on a run that pulls nothing. */
|
|
2226
|
+
var ComposePinNeedsPull = class extends Error {
|
|
2227
|
+
constructor() {
|
|
2228
|
+
super("--digests-file pins the images a release pulls, so it needs --tag (or --pull) — or, with --build --push, it is where the pushed digests are written.");
|
|
2229
|
+
this.name = "ComposePinNeedsPull";
|
|
2230
|
+
}
|
|
2231
|
+
};
|
|
2232
|
+
/** A port variable that does not hold a port. */
|
|
2233
|
+
var EdgePortInvalid = class extends Error {
|
|
2234
|
+
variable;
|
|
2235
|
+
value;
|
|
2236
|
+
constructor(variable, value) {
|
|
2237
|
+
super(`${variable}=${value} is not a port. Set it to a number between 1 and 65535, or unset it for the default.`);
|
|
2238
|
+
this.variable = variable;
|
|
2239
|
+
this.value = value;
|
|
2240
|
+
this.name = "EdgePortInvalid";
|
|
2241
|
+
}
|
|
2242
|
+
};
|
|
2243
|
+
/** A build with no commit to tag its images with. */
|
|
2244
|
+
var NoGitRevision = class extends Error {
|
|
2245
|
+
root;
|
|
2246
|
+
constructor(root) {
|
|
2247
|
+
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.`);
|
|
2248
|
+
this.root = root;
|
|
2249
|
+
this.name = "NoGitRevision";
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2252
|
+
/**
|
|
2253
|
+
* The project's own file merged over a stage's stack: `docker-compose.<stage>.yml`
|
|
2254
|
+
* at the workspace root. What the generated file cannot know — a port bound
|
|
2255
|
+
* to a tailnet address, another log driver — goes there, and is never
|
|
2256
|
+
* overwritten.
|
|
2257
|
+
*/
|
|
2258
|
+
function stackOverrideFile(root, stage) {
|
|
2259
|
+
return join(root, `docker-compose.${stage}.yml`);
|
|
2260
|
+
}
|
|
2261
|
+
/** The commit HEAD is at, short — and `-dirty` when the tree has changes. */
|
|
2262
|
+
async function gitRevision(root) {
|
|
2263
|
+
let sha;
|
|
2264
|
+
try {
|
|
2265
|
+
sha = (await runOutput("git", [
|
|
2266
|
+
"rev-parse",
|
|
2267
|
+
"--short",
|
|
2268
|
+
"HEAD"
|
|
2269
|
+
], { cwd: root })).trim();
|
|
2270
|
+
} catch {
|
|
2271
|
+
throw new NoGitRevision(root);
|
|
2272
|
+
}
|
|
2273
|
+
if (!sha) throw new NoGitRevision(root);
|
|
2274
|
+
return (await runOutput("git", ["status", "--porcelain"], { cwd: root }).catch(() => "")).trim() ? `${sha}-dirty` : sha;
|
|
2275
|
+
}
|
|
2276
|
+
/** The edge's ports, from the environment — 443 and 80 when unset. */
|
|
2277
|
+
function edgePorts(env = process.env) {
|
|
2278
|
+
const read = (variable, fallback) => {
|
|
2279
|
+
const value = env[variable];
|
|
2280
|
+
if (value === void 0 || value === "") return fallback;
|
|
2281
|
+
const port = Number(value);
|
|
2282
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new EdgePortInvalid(variable, value);
|
|
2283
|
+
return port;
|
|
2284
|
+
};
|
|
2285
|
+
const logs = env["GKM_COMPOSE_LOGS_PORT"] ? read(LOGS_PORT_ENV, 0) : void 0;
|
|
2286
|
+
return {
|
|
2287
|
+
https: read(EDGE_PORT_ENV.https, 443),
|
|
2288
|
+
http: read(EDGE_PORT_ENV.http, 80),
|
|
2289
|
+
...logs ? { logs } : {}
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* The real ones. Each app is asked for three minutes before it counts as
|
|
2294
|
+
* down: a deployed stage's first ACME certificate can take that long.
|
|
2295
|
+
*/
|
|
2296
|
+
const defaultDeps = {
|
|
2297
|
+
docker: dockerCompose,
|
|
2298
|
+
revision: gitRevision,
|
|
2299
|
+
sql: pgClient,
|
|
2300
|
+
buckets: bucketClient,
|
|
2301
|
+
logins: ensurePostgresLogin,
|
|
2302
|
+
migrate: migrateDatabases,
|
|
2303
|
+
seed: seedDatabases,
|
|
2304
|
+
probe: httpsProbe,
|
|
2305
|
+
healthAttempts: 90,
|
|
2306
|
+
healthIntervalMs: 2e3,
|
|
2307
|
+
env: process.env,
|
|
2308
|
+
lookup: systemLookup
|
|
2309
|
+
};
|
|
2310
|
+
/**
|
|
2311
|
+
* The stack the stage would run, and — for a tag — the registry's word that
|
|
2312
|
+
* every image it names exists. Nothing is written: a missing image stops the
|
|
2313
|
+
* run here, before a file, a secret or a container is touched.
|
|
2314
|
+
*/
|
|
2315
|
+
async function validateCompose(ctx, deps, options = {}) {
|
|
2316
|
+
const { workspace, stage, identity, tag } = ctx;
|
|
2317
|
+
const root = workspace.root;
|
|
2318
|
+
const mode = options.mode ?? (ctx.tagGiven ? "pull" : "build");
|
|
2319
|
+
const push = options.push === true;
|
|
2320
|
+
if (push && mode !== "build") throw new ComposePushNeedsBuild();
|
|
2321
|
+
if (options.pin && mode !== "pull") throw new ComposePinNeedsPull();
|
|
2322
|
+
const registry = workspace.deploy?.registry;
|
|
2323
|
+
if (!registry && (push || mode === "pull")) throw new RegistryRequired(push ? "push" : "pull", imageRef(identity, ctx.apps[0] ?? workspace.name, void 0, tag));
|
|
2324
|
+
const runnables = {};
|
|
2325
|
+
const background = {};
|
|
2326
|
+
const manifest = await discover({
|
|
2327
|
+
patterns: constructGlobs(workspace),
|
|
2328
|
+
cwd: root,
|
|
2329
|
+
runnables,
|
|
2330
|
+
background
|
|
2331
|
+
});
|
|
2332
|
+
const { secrets, generated } = await stageSecrets(ctx, manifest);
|
|
2333
|
+
const server = push || stage === workspace.stages.local ? void 0 : requiredServerAddress(stage, workspace.domains?.[stage], secrets?.custom);
|
|
2334
|
+
if (!push) {
|
|
2335
|
+
assertNoStaleSecrets({
|
|
2336
|
+
manifest,
|
|
2337
|
+
stage,
|
|
2338
|
+
supplied: secrets?.custom ?? {}
|
|
2339
|
+
});
|
|
2340
|
+
await assertStageCredentials({
|
|
2341
|
+
root,
|
|
2342
|
+
patterns: constructGlobs(workspace),
|
|
2343
|
+
manifest,
|
|
2344
|
+
stage,
|
|
2345
|
+
supplied: secrets?.custom ?? {}
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
const layout = mode === "build" ? imageLayout(workspace) : void 0;
|
|
2349
|
+
const composed = composeStack({
|
|
2350
|
+
workspace,
|
|
2351
|
+
manifest,
|
|
2352
|
+
runnables,
|
|
2353
|
+
background,
|
|
2354
|
+
stage,
|
|
2355
|
+
identity,
|
|
2356
|
+
images: {
|
|
2357
|
+
mode,
|
|
2358
|
+
tag,
|
|
2359
|
+
...registry ? { registry } : {}
|
|
2360
|
+
},
|
|
2361
|
+
secrets,
|
|
2362
|
+
...stage === workspace.stages.local ? { localCredentials: (await loadLocalCredentials(workspace)).credentials } : {},
|
|
2363
|
+
allowDevServices: ctx.allowDevServices,
|
|
2364
|
+
ports: edgePorts(deps.env),
|
|
2365
|
+
...layout ? { layout } : {},
|
|
2366
|
+
...push ? { buildOnly: true } : {}
|
|
2367
|
+
});
|
|
2368
|
+
reportDevServices(ctx, composed.devServices);
|
|
2369
|
+
if (!push && stage !== workspace.stages.local) {
|
|
2370
|
+
const verified = await verifyStageProviders({
|
|
2371
|
+
workspace,
|
|
2372
|
+
manifest,
|
|
2373
|
+
stage,
|
|
2374
|
+
secrets: secrets?.custom ?? {}
|
|
2375
|
+
});
|
|
2376
|
+
for (const line of verified) ctx.logger.info(`✓ ${line} verified`);
|
|
2377
|
+
}
|
|
2378
|
+
if (mode === "build") assertRedisClient(workspace, composed);
|
|
2379
|
+
const stack = options.pin ? withPinnedImages(composed, options.pin.file, options.pin.digests) : composed;
|
|
2380
|
+
if (mode === "pull" && !ctx.dryRun) await assertImagesExist(deps.docker, tag, [...stack.apps, ...stack.workers]);
|
|
2381
|
+
if (stack.tls && !push) {
|
|
2382
|
+
for (const file of [stack.tls.certFile, stack.tls.keyFile]) if (!existsSync(file)) throw new ComposeTlsFileMissing(stage, file);
|
|
2383
|
+
}
|
|
2384
|
+
if (!push && !stack.local) await checkDns(ctx, deps, stackHosts(stack).filter((host) => !isLocalHost(host)), server);
|
|
2385
|
+
if (stack.redis && !stack.local) ctx.secrets.mask(stack.redis.password);
|
|
2386
|
+
if (stack.logs && (!stack.local || stack.logs.passwordFromSecrets)) {
|
|
2387
|
+
ctx.secrets.mask(stack.logs.password);
|
|
2388
|
+
ctx.secrets.mask(stack.logs.appEnv.OTEL_EXPORTER_OTLP_HEADERS);
|
|
2389
|
+
}
|
|
2390
|
+
const dir = join(root, stackDir(stage));
|
|
2391
|
+
const override = stackOverrideFile(root, stage);
|
|
2392
|
+
const overrides = existsSync(override) ? [override] : [];
|
|
2393
|
+
if (overrides.length > 0) ctx.logger.info(`🧩 Merging docker-compose.${stage}.yml over the generated stack`);
|
|
2394
|
+
const recorded = push ? void 0 : await ctx.state.read(stage);
|
|
2395
|
+
const previous = Object.fromEntries(Object.entries(recorded?.state.releases ?? {}).map(([name, releases]) => {
|
|
2396
|
+
const { ref, tag, digest } = releases.current;
|
|
2397
|
+
return [name, {
|
|
2398
|
+
ref,
|
|
2399
|
+
...tag ? { tag } : {},
|
|
2400
|
+
...digest ? { digest } : {}
|
|
2401
|
+
}];
|
|
2402
|
+
}));
|
|
2403
|
+
return {
|
|
2404
|
+
mode,
|
|
2405
|
+
push,
|
|
2406
|
+
stack,
|
|
2407
|
+
dir,
|
|
2408
|
+
ref: {
|
|
2409
|
+
project: composeProject(identity),
|
|
2410
|
+
file: join(dir, "docker-compose.yml"),
|
|
2411
|
+
...overrides.length > 0 ? { overrides } : {},
|
|
2412
|
+
cwd: root,
|
|
2413
|
+
output: ctx.childOutput,
|
|
2414
|
+
signal: ctx.signal
|
|
2415
|
+
},
|
|
2416
|
+
secrets,
|
|
2417
|
+
generated,
|
|
2418
|
+
previous,
|
|
2419
|
+
files: [],
|
|
2420
|
+
images: {},
|
|
2421
|
+
changes: []
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
/** A stack whose caches are in Redis, built for an app that has no client. */
|
|
2425
|
+
var RedisClientMissing = class extends Error {
|
|
2426
|
+
apps;
|
|
2427
|
+
constructor(apps) {
|
|
2428
|
+
super(`The stack keeps every cache in Redis, and ${apps.join(", ")} ${apps.length === 1 ? "does" : "do"} not depend on ioredis, the client the generated server reaches it with. Add it to the workspace: pnpm add ioredis`);
|
|
2429
|
+
this.apps = apps;
|
|
2430
|
+
this.name = "RedisClientMissing";
|
|
2431
|
+
}
|
|
2432
|
+
};
|
|
2433
|
+
/**
|
|
2434
|
+
* Every backend and worker image built for a stack with a cache can resolve
|
|
2435
|
+
* `ioredis` — listed by its app or a directory above it, the way Node and
|
|
2436
|
+
* the image's install find it.
|
|
2437
|
+
*/
|
|
2438
|
+
function assertRedisClient(workspace, stack) {
|
|
2439
|
+
if (!stack.plan.resources.some((r) => r.kind === "cache")) return;
|
|
2440
|
+
const missing = [.../* @__PURE__ */ new Set([...stack.apps.filter((app) => app.kind === "rest-api").map((a) => a.name), ...stack.workers.map((worker) => worker.host)])].filter((name) => {
|
|
2441
|
+
const app = workspace.apps[name];
|
|
2442
|
+
return app && !installedFrom(join(workspace.root, app.path), "ioredis");
|
|
2443
|
+
}).sort();
|
|
2444
|
+
if (missing.length > 0) throw new RedisClientMissing(missing);
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* The stage's secrets — and, for a deployed stage, everything it generates
|
|
2448
|
+
* once: its seed, and each declared secret and keyring. Kept only once the
|
|
2449
|
+
* run goes ahead (`provision`), so a dry run, or a tag that is not there,
|
|
2450
|
+
* leaves the stage's secrets as it found them.
|
|
2451
|
+
*/
|
|
2452
|
+
/**
|
|
2453
|
+
* The deploy-time DNS check: each public host resolved with the system
|
|
2454
|
+
* resolver and compared with the server's address from the stage's secrets.
|
|
2455
|
+
* A dry run warns rather than stops.
|
|
2456
|
+
*/
|
|
2457
|
+
async function checkDns(ctx, deps, hosts, server) {
|
|
2458
|
+
const { workspace, stage } = ctx;
|
|
2459
|
+
if (hosts.length === 0) return;
|
|
2460
|
+
if (ctx.skipDnsCheck) {
|
|
2461
|
+
ctx.logger.info("🌐 DNS check skipped (--skip-dns-check)");
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
if (!server) {
|
|
2465
|
+
ctx.logger.info(`🌐 DNS check skipped: no ${SERVER_IPV4_KEY} in '${stage}''s secrets — ${serverAddressHint(stage)}`);
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
try {
|
|
2469
|
+
const ok = await checkStackDns({
|
|
2470
|
+
stage,
|
|
2471
|
+
hosts,
|
|
2472
|
+
server,
|
|
2473
|
+
dns: workspace.dns,
|
|
2474
|
+
lookup: deps.lookup ?? systemLookup
|
|
2475
|
+
});
|
|
2476
|
+
ctx.logger.info(`🌐 ${ok.length} host${ok.length === 1 ? "" : "s"} resolve${ok.length === 1 ? "s" : ""} to ${server.ipv4}`);
|
|
2477
|
+
} catch (error) {
|
|
2478
|
+
if (ctx.dryRun && error instanceof HostNotPointingAtServer) {
|
|
2479
|
+
ctx.logger.warn(`⚠ ${error.message}`);
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
throw error;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
async function stageSecrets(ctx, manifest) {
|
|
2486
|
+
const stored = await ctx.secrets.read();
|
|
2487
|
+
if (ctx.stage === ctx.workspace.stages.local) return {
|
|
2488
|
+
secrets: stored,
|
|
2489
|
+
generated: []
|
|
2490
|
+
};
|
|
2491
|
+
const { secrets, generated } = deployedStackSecrets(ctx.workspace, ctx.stage, stored, manifest);
|
|
2492
|
+
if (secrets.seed) ctx.secrets.mask(secrets.seed);
|
|
2493
|
+
for (const value of Object.values(secrets.custom ?? {})) ctx.secrets.mask(value);
|
|
2494
|
+
return {
|
|
2495
|
+
secrets,
|
|
2496
|
+
generated
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* The stack with each app's and worker's image pinned to the digest the file
|
|
2501
|
+
* names: the refs it is checked, pulled and recorded by, and the image each
|
|
2502
|
+
* compose service runs.
|
|
2503
|
+
*/
|
|
2504
|
+
function withPinnedImages(stack, file, digests) {
|
|
2505
|
+
const apps = pinImages(file, digests, stack.apps);
|
|
2506
|
+
const workers = pinImages(file, digests, stack.workers);
|
|
2507
|
+
const services = { ...stack.compose.services };
|
|
2508
|
+
for (const image of [...apps, ...workers]) services[image.name] = {
|
|
2509
|
+
...services[image.name],
|
|
2510
|
+
image: image.ref
|
|
2511
|
+
};
|
|
2512
|
+
return {
|
|
2513
|
+
...stack,
|
|
2514
|
+
apps,
|
|
2515
|
+
workers,
|
|
2516
|
+
compose: {
|
|
2517
|
+
...stack.compose,
|
|
2518
|
+
services
|
|
2519
|
+
}
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* A dry run: the stack's files, written so they can be read, and what a real
|
|
2524
|
+
* run would do — no image is built or pulled, no container touched, nothing
|
|
2525
|
+
* recorded and no secret kept.
|
|
2526
|
+
*/
|
|
2527
|
+
async function planCompose(ctx, run) {
|
|
2528
|
+
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.`);
|
|
2529
|
+
run.files = await writeStack(ctx.cwd, run.dir, run.stack);
|
|
2530
|
+
printPlan(ctx, run);
|
|
2531
|
+
if (!run.push && run.stack.infra.includes("postgres")) reportPlannedSeeds((await plannedSeeds({
|
|
2532
|
+
root: ctx.workspace.root,
|
|
2533
|
+
manifest: ctx.manifest
|
|
2534
|
+
})).map(({ target, seeds }) => ({
|
|
2535
|
+
folder: target.seeds,
|
|
2536
|
+
seeds
|
|
2537
|
+
})), (line) => ctx.logger.info(line));
|
|
2538
|
+
const planned = (change) => {
|
|
2539
|
+
run.changes.push(change);
|
|
2540
|
+
ctx.emit({
|
|
2541
|
+
type: "resource.planned",
|
|
2542
|
+
...change
|
|
2543
|
+
});
|
|
2544
|
+
};
|
|
2545
|
+
for (const service of run.push ? [] : run.stack.infra) planned({
|
|
2546
|
+
key: `service:${service}`,
|
|
2547
|
+
resourceType: "service",
|
|
2548
|
+
action: "ensure"
|
|
2549
|
+
});
|
|
2550
|
+
for (const app of run.stack.apps) {
|
|
2551
|
+
planned({
|
|
2552
|
+
key: `image:${app.name}`,
|
|
2553
|
+
resourceType: "image",
|
|
2554
|
+
action: run.mode === "build" ? "build" : "reuse",
|
|
2555
|
+
id: app.ref
|
|
2556
|
+
});
|
|
2557
|
+
if (run.push) continue;
|
|
2558
|
+
planned({
|
|
2559
|
+
key: `service:${app.name}`,
|
|
2560
|
+
resourceType: "service",
|
|
2561
|
+
action: run.previous[app.name] ? "reuse" : "create"
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
for (const worker of run.stack.workers) {
|
|
2565
|
+
planned({
|
|
2566
|
+
key: `image:${worker.name}`,
|
|
2567
|
+
resourceType: "image",
|
|
2568
|
+
action: run.mode === "build" ? "build" : "reuse",
|
|
2569
|
+
id: worker.ref
|
|
2570
|
+
});
|
|
2571
|
+
if (run.push) continue;
|
|
2572
|
+
planned({
|
|
2573
|
+
key: `service:${worker.name}`,
|
|
2574
|
+
resourceType: "service",
|
|
2575
|
+
action: run.previous[worker.name] ? "reuse" : "create"
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
if (!run.push) planned({
|
|
2579
|
+
key: run.stack.proxy === "caddy" ? "service:caddy" : `service:${EDGE_PROJECT}/${EDGE_SERVICE}`,
|
|
2580
|
+
resourceType: "service",
|
|
2581
|
+
action: "ensure"
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* What the apps run on: the stage's generated secrets kept, the stack's files
|
|
2586
|
+
* written, its infrastructure started, and its databases, roles, grants and
|
|
2587
|
+
* migrations applied.
|
|
2588
|
+
*/
|
|
2589
|
+
async function provisionCompose(ctx, run, deps) {
|
|
2590
|
+
const { stack, ref } = run;
|
|
2591
|
+
if (run.push) return;
|
|
2592
|
+
if (run.generated.length > 0 && run.secrets) {
|
|
2593
|
+
await ctx.secrets.write(run.secrets);
|
|
2594
|
+
ctx.logger.info(`🔑 Generated for "${ctx.stage}" (${ctx.secrets.store}): ${run.generated.join(", ")}`);
|
|
2595
|
+
}
|
|
2596
|
+
const ports = edgePorts(deps.env);
|
|
2597
|
+
await assertEdgePorts(deps.docker, {
|
|
2598
|
+
project: stack.project,
|
|
2599
|
+
proxy: stack.proxy,
|
|
2600
|
+
ports
|
|
2601
|
+
});
|
|
2602
|
+
run.files = await writeStack(ctx.cwd, run.dir, stack);
|
|
2603
|
+
printPlan(ctx, run);
|
|
2604
|
+
if (stack.proxy === "traefik") {
|
|
2605
|
+
const dir = edgeDir(deps.env);
|
|
2606
|
+
ctx.logger.info(`\n🌐 Starting the shared edge (${EDGE_PROJECT}) from ${dir}…`);
|
|
2607
|
+
run.files.push(...await ensureEdge(deps.docker, {
|
|
2608
|
+
dir,
|
|
2609
|
+
ports,
|
|
2610
|
+
logging: {
|
|
2611
|
+
driver: LOG_ROTATION.driver,
|
|
2612
|
+
options: { ...LOG_ROTATION.options }
|
|
2613
|
+
},
|
|
2614
|
+
...ref.output ? { output: ref.output } : {},
|
|
2615
|
+
...ref.signal ? { signal: ref.signal } : {}
|
|
2616
|
+
}));
|
|
2617
|
+
applied(ctx, run, {
|
|
2618
|
+
key: `service:${EDGE_PROJECT}/${EDGE_SERVICE}`,
|
|
2619
|
+
resourceType: "service",
|
|
2620
|
+
action: "ensure",
|
|
2621
|
+
id: `${EDGE_PROJECT}/${EDGE_SERVICE}`,
|
|
2622
|
+
via: "found"
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
if (stack.infra.length > 0) {
|
|
2626
|
+
ctx.logger.info(`\n🗄️ Starting ${stack.infra.join(", ")}…`);
|
|
2627
|
+
await deps.docker.up(ref, stack.infra);
|
|
2628
|
+
for (const service of stack.infra) applied(ctx, run, {
|
|
2629
|
+
key: `service:${service}`,
|
|
2630
|
+
resourceType: "service",
|
|
2631
|
+
action: "ensure",
|
|
2632
|
+
id: `${stack.project}/${service}`,
|
|
2633
|
+
via: Object.keys(run.previous).length > 0 ? "recorded" : "created"
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
if (stack.infra.includes("postgres")) await prepareDatabases(ctx, run, deps);
|
|
2637
|
+
if (stack.storage && stack.infra.includes("minio")) await prepareBuckets(ctx, run, deps);
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Create each bucket the stack's MinIO serves, and the open paths of each
|
|
2641
|
+
* one a file server fronts — from this machine, on MinIO's loopback port,
|
|
2642
|
+
* the way `gkm dev` creates them.
|
|
2643
|
+
*/
|
|
2644
|
+
async function prepareBuckets(ctx, run, deps) {
|
|
2645
|
+
const storage = run.stack.storage;
|
|
2646
|
+
if (storage.buckets.length === 0) return;
|
|
2647
|
+
const port = await deps.docker.port(run.ref, "minio", 9e3);
|
|
2648
|
+
const client = deps.buckets(port, storage);
|
|
2649
|
+
const created = [...await applyBuckets(client, storage.buckets), ...await applyPolicies(client, storage.policies)].filter((entry) => entry.created).length;
|
|
2650
|
+
ctx.logger.info(`🪣 Buckets: ${storage.buckets.join(", ")}${created ? ` (${created} change(s))` : ""}`);
|
|
2651
|
+
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Create what the plan names in the stack's Postgres, migrate it, then run
|
|
2654
|
+
* its seeds — every one, every time, as each construct's owner.
|
|
2655
|
+
*/
|
|
2656
|
+
async function prepareDatabases(ctx, run, deps) {
|
|
2657
|
+
const { stack } = run;
|
|
2658
|
+
const workspace = ctx.workspace;
|
|
2659
|
+
const port = await deps.docker.port(run.ref, "postgres", 5432);
|
|
2660
|
+
const superuser = stack.credential.containers.postgres;
|
|
2661
|
+
const login = await deps.logins({
|
|
2662
|
+
port,
|
|
2663
|
+
login: superuser,
|
|
2664
|
+
legacy: [{
|
|
2665
|
+
user: LEGACY_LOCAL_LOGIN.user,
|
|
2666
|
+
password: superuser.password
|
|
2667
|
+
}, ...stack.local ? [LEGACY_LOCAL_LOGIN] : []]
|
|
2668
|
+
});
|
|
2669
|
+
if (login.notice) ctx.logger.info(login.notice);
|
|
2670
|
+
const statements = postgresStatements(stack.plan, workspace.name, stack.credential.seed);
|
|
2671
|
+
if (statements.length > 0) {
|
|
2672
|
+
ctx.logger.info("🗄️ Creating databases, roles and grants…");
|
|
2673
|
+
await applyPostgres(deps.sql(port, login.login), statements);
|
|
2674
|
+
}
|
|
2675
|
+
const sources = {};
|
|
2676
|
+
const manifest = await discover({
|
|
2677
|
+
patterns: constructGlobs(workspace),
|
|
2678
|
+
cwd: workspace.root,
|
|
2679
|
+
sources
|
|
2680
|
+
});
|
|
2681
|
+
const env = envFor(stack.plan, {
|
|
2682
|
+
ports: { [primaryPortKey("postgres")]: port },
|
|
2683
|
+
project: workspace.name,
|
|
2684
|
+
credentials: {
|
|
2685
|
+
...stack.credential.containers,
|
|
2686
|
+
postgres: login.login
|
|
2687
|
+
},
|
|
2688
|
+
seed: stack.credential.seed
|
|
2689
|
+
});
|
|
2690
|
+
const options = {
|
|
2691
|
+
root: workspace.root,
|
|
2692
|
+
manifest,
|
|
2693
|
+
sources,
|
|
2694
|
+
env
|
|
2695
|
+
};
|
|
2696
|
+
const runs = await deps.migrate(options);
|
|
2697
|
+
const seeds = await deps.seed({
|
|
2698
|
+
...options,
|
|
2699
|
+
stage: ctx.stage
|
|
2700
|
+
}).catch((error) => {
|
|
2701
|
+
if (error instanceof SeedFailed) throw new DeploySeedsFailed(ctx.stage, error.construct, error.seed, error);
|
|
2702
|
+
throw error;
|
|
2703
|
+
});
|
|
2704
|
+
reportDatabaseRuns(runs.map(({ target, applied }) => ({
|
|
2705
|
+
construct: target.id,
|
|
2706
|
+
folder: target.migrations,
|
|
2707
|
+
applied
|
|
2708
|
+
})), seeds.map(({ target, seeded }) => ({
|
|
2709
|
+
construct: target.id,
|
|
2710
|
+
folder: target.seeds,
|
|
2711
|
+
seeded
|
|
2712
|
+
})), (line) => ctx.logger.info(line), (event) => ctx.emit(event));
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* Each app's image: built from this checkout — inside Docker, every one of
|
|
2716
|
+
* them, from a pruned slice of the build root — or pulled at the tag. Nothing
|
|
2717
|
+
* live changes.
|
|
2718
|
+
*/
|
|
2719
|
+
async function buildCompose(ctx, run, deps) {
|
|
2720
|
+
const { stack, ref } = run;
|
|
2721
|
+
const images = [...stack.apps, ...stack.workers];
|
|
2722
|
+
const apps = images.map((image) => image.app);
|
|
2723
|
+
if (run.push) {
|
|
2724
|
+
run.files = await writeStack(ctx.cwd, run.dir, stack);
|
|
2725
|
+
printPlan(ctx, run);
|
|
2726
|
+
}
|
|
2727
|
+
if (run.mode === "build") {
|
|
2728
|
+
ctx.logger.info("\n🐳 Building images…");
|
|
2729
|
+
await deps.docker.build(ref, apps);
|
|
2730
|
+
} else {
|
|
2731
|
+
ctx.logger.info(`\n🐳 Pulling ${ctx.tag}…`);
|
|
2732
|
+
await deps.docker.pull(ref, apps);
|
|
2733
|
+
}
|
|
2734
|
+
if (run.push) ctx.logger.info("\n📤 Pushing images…");
|
|
2735
|
+
for (const image of images) {
|
|
2736
|
+
const digest = run.push ? await deps.docker.push(ref, image.ref) : await deps.docker.digest(image.ref);
|
|
2737
|
+
if (run.push) ctx.logger.info(` ${image.app.padEnd(12)} ${image.ref}@${digest}`);
|
|
2738
|
+
run.images[image.app] = {
|
|
2739
|
+
ref: image.ref,
|
|
2740
|
+
tag: image.tag,
|
|
2741
|
+
...digest ? { digest } : {}
|
|
2742
|
+
};
|
|
2743
|
+
ctx.emit({
|
|
2744
|
+
type: "artifact.built",
|
|
2745
|
+
app: image.app,
|
|
2746
|
+
imageRef: image.ref,
|
|
2747
|
+
...digest ? { digest } : {}
|
|
2748
|
+
});
|
|
2749
|
+
run.changes.push({
|
|
2750
|
+
key: `image:${image.app}`,
|
|
2751
|
+
resourceType: "image",
|
|
2752
|
+
action: run.mode === "build" ? "build" : "reuse",
|
|
2753
|
+
id: digest ?? image.ref
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* The whole stack up — anything it no longer defines removed — and each
|
|
2759
|
+
* app's image recorded in the stage's state.
|
|
2760
|
+
*/
|
|
2761
|
+
async function releaseCompose(ctx, run, deps) {
|
|
2762
|
+
const { stack, ref } = run;
|
|
2763
|
+
if (run.push) return;
|
|
2764
|
+
ctx.logger.info("\n🚀 Starting the stack…");
|
|
2765
|
+
await deps.docker.up(ref);
|
|
2766
|
+
const edge = edgeDir(deps.env);
|
|
2767
|
+
if (stack.proxy === "traefik" && stack.traefik) {
|
|
2768
|
+
const written = await writeEdgeRoutes(edge, stack.project, stack.traefik, stack.tls);
|
|
2769
|
+
run.files.push(...written);
|
|
2770
|
+
ctx.logger.info(`🌐 Registered ${stack.routes.length} route(s) with ${EDGE_PROJECT}: ${written.at(-1)}`);
|
|
2771
|
+
} else await removeEdgeRoutes(edge, stack.project);
|
|
2772
|
+
if (stack.local) await deps.docker.copyOut(ref, "caddy", "/data/caddy/pki/authorities/local/root.crt", caFile(run)).catch(() => {});
|
|
2773
|
+
await recordImages(ctx, run);
|
|
2774
|
+
for (const app of stack.apps) {
|
|
2775
|
+
const image = run.images[app.name];
|
|
2776
|
+
applied(ctx, run, {
|
|
2777
|
+
key: `service:${app.name}`,
|
|
2778
|
+
resourceType: "service",
|
|
2779
|
+
action: "ensure",
|
|
2780
|
+
id: `${stack.project}/${app.name}`,
|
|
2781
|
+
via: run.previous[app.name] ? "recorded" : "created"
|
|
2782
|
+
});
|
|
2783
|
+
ctx.emit({
|
|
2784
|
+
type: "app.deployed",
|
|
2785
|
+
app: app.name,
|
|
2786
|
+
applicationId: `${stack.project}/${app.name}`,
|
|
2787
|
+
imageRef: image?.ref ?? app.ref,
|
|
2788
|
+
url: app.url
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
for (const worker of stack.workers) applied(ctx, run, {
|
|
2792
|
+
key: `service:${worker.name}`,
|
|
2793
|
+
resourceType: "service",
|
|
2794
|
+
action: "ensure",
|
|
2795
|
+
id: `${stack.project}/${worker.name}`,
|
|
2796
|
+
via: run.previous[worker.name] ? "recorded" : "created"
|
|
2797
|
+
});
|
|
2798
|
+
ctx.logger.info(`\n✅ ${stack.project} is running:`);
|
|
2799
|
+
for (const app of stack.apps) ctx.logger.info(` ${app.name.padEnd(12)} ${app.url}`);
|
|
2800
|
+
for (const worker of stack.workers) ctx.logger.info(` ${worker.name.padEnd(12)} worker ${worker.id}`);
|
|
2801
|
+
if (stack.local) ctx.logger.info(`\n🔐 Certificates are from Caddy's local CA: NODE_EXTRA_CA_CERTS=${join(stackDir(ctx.stage), "caddy-root.crt")}`);
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* Each app's image in the stage's state — the same `images` a Dokploy deploy
|
|
2805
|
+
* records: the ref it runs, the tag it was released under, and the digest
|
|
2806
|
+
* the tag resolved to.
|
|
2807
|
+
*/
|
|
2808
|
+
async function recordImages(ctx, run) {
|
|
2809
|
+
const journal = await DeployJournal.open(ctx.state, ctx.stage, () => createEmptyState(ctx.stage, "", ""));
|
|
2810
|
+
for (const app of [...run.stack.apps, ...run.stack.workers]) {
|
|
2811
|
+
const image = run.images[app.app] ?? {
|
|
2812
|
+
ref: app.ref,
|
|
2813
|
+
tag: app.tag
|
|
2814
|
+
};
|
|
2815
|
+
recordRelease(journal.state, app.app, image);
|
|
2816
|
+
}
|
|
2817
|
+
journal.state.identity = ctx.identity.key;
|
|
2818
|
+
await journal.save();
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* Each app asked through Caddy, over HTTPS, the certificate verified: an API
|
|
2822
|
+
* at `/health`, a site at `/`. On the local stage the edge is this machine and
|
|
2823
|
+
* its certificates are Caddy's internal CA's, so the CA `release` copied out
|
|
2824
|
+
* is the one trusted and the request goes to 127.0.0.1.
|
|
2825
|
+
*/
|
|
2826
|
+
async function verifyCompose(ctx, run, deps) {
|
|
2827
|
+
const { stack } = run;
|
|
2828
|
+
if (run.push) return;
|
|
2829
|
+
const ca = stack.local ? await readFile(caFile(run), "utf-8").catch(() => void 0) : void 0;
|
|
2830
|
+
ctx.logger.info(`\n🩺 Checking each app through ${stack.proxy === "caddy" ? "Caddy" : "the shared edge"}…`);
|
|
2831
|
+
const edge = stack.local || stack.proxy === "traefik" ? {
|
|
2832
|
+
connectTo: "127.0.0.1",
|
|
2833
|
+
...stack.local ? {} : { connectPort: edgePorts(deps.env).https }
|
|
2834
|
+
} : void 0;
|
|
2835
|
+
const results = await Promise.all(stack.apps.map((app) => checkApp(ctx, app, deps, {
|
|
2836
|
+
ca,
|
|
2837
|
+
edge
|
|
2838
|
+
})));
|
|
2839
|
+
results.push(...await Promise.all(stack.workers.map((worker) => checkContainer(ctx, run, deps, worker.name))));
|
|
2840
|
+
if (stack.logs) results.push(await checkContainer(ctx, run, deps, LOGS_SERVICE));
|
|
2841
|
+
const down = results.filter((result) => !result.healthy).map(({ app, url, last }) => ({
|
|
2842
|
+
app,
|
|
2843
|
+
url,
|
|
2844
|
+
last
|
|
2845
|
+
}));
|
|
2846
|
+
if (down.length > 0) throw new ComposeAppsUnhealthy(stack.project, down, stack.proxy);
|
|
2847
|
+
if (stack.logs) reportLogs(ctx, stack.logs, stack.local);
|
|
2848
|
+
}
|
|
2849
|
+
/**
|
|
2850
|
+
* A container's own health check, as Docker reports it: a worker's, which
|
|
2851
|
+
* has no route through the edge, and OpenObserve's — by default it has no
|
|
2852
|
+
* host there, and served publicly it answers only the addresses it allows,
|
|
2853
|
+
* which need not include this one.
|
|
2854
|
+
*/
|
|
2855
|
+
async function checkContainer(ctx, run, deps, service) {
|
|
2856
|
+
const url = `docker:${service}`;
|
|
2857
|
+
let last = "not asked";
|
|
2858
|
+
for (let attempt = 1; attempt <= deps.healthAttempts; attempt++) {
|
|
2859
|
+
ctx.signal.throwIfAborted();
|
|
2860
|
+
const health = await deps.docker.health(run.ref, service).catch((error) => error instanceof Error ? error.message : String(error));
|
|
2861
|
+
last = health ?? "not running";
|
|
2862
|
+
const healthy = health === "healthy";
|
|
2863
|
+
ctx.emit({
|
|
2864
|
+
type: "health.checked",
|
|
2865
|
+
app: service,
|
|
2866
|
+
url,
|
|
2867
|
+
healthy,
|
|
2868
|
+
attempt
|
|
2869
|
+
});
|
|
2870
|
+
if (healthy) {
|
|
2871
|
+
ctx.logger.info(` ✓ ${service.padEnd(12)} ${url} (${last})`);
|
|
2872
|
+
return {
|
|
2873
|
+
app: service,
|
|
2874
|
+
url,
|
|
2875
|
+
last,
|
|
2876
|
+
healthy
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
if (attempt < deps.healthAttempts) await new Promise((resolve) => setTimeout(resolve, deps.healthIntervalMs));
|
|
2880
|
+
}
|
|
2881
|
+
ctx.logger.warn(` ✗ ${service.padEnd(12)} ${url} (${last})`);
|
|
2882
|
+
return {
|
|
2883
|
+
app: service,
|
|
2884
|
+
url,
|
|
2885
|
+
last,
|
|
2886
|
+
healthy: false
|
|
2887
|
+
};
|
|
2888
|
+
}
|
|
2889
|
+
/** How to open the logs, printed — and as an event, for a headless caller. */
|
|
2890
|
+
function reportLogs(ctx, logs, local) {
|
|
2891
|
+
const lines = logsAccess(logs, {
|
|
2892
|
+
stage: ctx.stage,
|
|
2893
|
+
local,
|
|
2894
|
+
user: currentUser(),
|
|
2895
|
+
hostname: hostname()
|
|
2896
|
+
});
|
|
2897
|
+
ctx.logger.info(`\n${lines.join("\n")}`);
|
|
2898
|
+
ctx.emit({
|
|
2899
|
+
type: "logs.ready",
|
|
2900
|
+
service: LOGS_SERVICE,
|
|
2901
|
+
access: logs.public ? "public" : "tunnel",
|
|
2902
|
+
url: logs.url,
|
|
2903
|
+
...logs.public ? { allow: [...logs.public.allow] } : { port: logs.port },
|
|
2904
|
+
email: logs.email
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
/** This machine's user — a guess at who would SSH in. */
|
|
2908
|
+
function currentUser() {
|
|
2909
|
+
try {
|
|
2910
|
+
return userInfo().username;
|
|
2911
|
+
} catch {
|
|
2912
|
+
return process.env.USER ?? "user";
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
async function checkApp(ctx, app, deps, options) {
|
|
2916
|
+
const url = `${app.url}${app.kind === "site" ? "/" : "/health"}`;
|
|
2917
|
+
let last = "not asked";
|
|
2918
|
+
for (let attempt = 1; attempt <= deps.healthAttempts; attempt++) {
|
|
2919
|
+
ctx.signal.throwIfAborted();
|
|
2920
|
+
let status;
|
|
2921
|
+
try {
|
|
2922
|
+
status = await deps.probe({
|
|
2923
|
+
url,
|
|
2924
|
+
...options.ca ? { ca: options.ca } : {},
|
|
2925
|
+
...options.edge,
|
|
2926
|
+
timeoutMs: 1e4,
|
|
2927
|
+
signal: ctx.signal
|
|
2928
|
+
});
|
|
2929
|
+
last = `HTTP ${status}`;
|
|
2930
|
+
} catch (error) {
|
|
2931
|
+
last = error instanceof Error ? error.message : String(error);
|
|
2932
|
+
}
|
|
2933
|
+
const healthy = status !== void 0 && isHealthy(status);
|
|
2934
|
+
ctx.emit({
|
|
2935
|
+
type: "health.checked",
|
|
2936
|
+
app: app.name,
|
|
2937
|
+
url,
|
|
2938
|
+
healthy,
|
|
2939
|
+
...status !== void 0 ? { status } : {},
|
|
2940
|
+
attempt
|
|
2941
|
+
});
|
|
2942
|
+
if (healthy) {
|
|
2943
|
+
ctx.logger.info(` ✓ ${app.name.padEnd(12)} ${url} (${last})`);
|
|
2944
|
+
return {
|
|
2945
|
+
app: app.name,
|
|
2946
|
+
url,
|
|
2947
|
+
last,
|
|
2948
|
+
healthy
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
if (attempt < deps.healthAttempts) await new Promise((resolve) => setTimeout(resolve, deps.healthIntervalMs));
|
|
2952
|
+
}
|
|
2953
|
+
ctx.logger.warn(` ✗ ${app.name.padEnd(12)} ${url} (${last})`);
|
|
2954
|
+
return {
|
|
2955
|
+
app: app.name,
|
|
2956
|
+
url,
|
|
2957
|
+
last,
|
|
2958
|
+
healthy: false
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
function composeResult(ctx, run) {
|
|
2962
|
+
const apps = [...run.stack.apps.map((app) => {
|
|
2963
|
+
const image = run.images[app.name];
|
|
2964
|
+
return {
|
|
2965
|
+
appName: app.name,
|
|
2966
|
+
type: app.kind === "site" ? "web" : "backend",
|
|
2967
|
+
success: !ctx.dryRun,
|
|
2968
|
+
applicationId: `${run.stack.project}/${app.name}`,
|
|
2969
|
+
imageRef: image?.ref ?? app.ref,
|
|
2970
|
+
...image?.digest ? { digest: image.digest } : {},
|
|
2971
|
+
url: app.url
|
|
2972
|
+
};
|
|
2973
|
+
}), ...run.stack.workers.map((worker) => {
|
|
2974
|
+
const image = run.images[worker.name];
|
|
2975
|
+
return {
|
|
2976
|
+
appName: worker.name,
|
|
2977
|
+
type: "backend",
|
|
2978
|
+
success: !ctx.dryRun,
|
|
2979
|
+
applicationId: `${run.stack.project}/${worker.name}`,
|
|
2980
|
+
imageRef: image?.ref ?? worker.ref,
|
|
2981
|
+
...image?.digest ? { digest: image.digest } : {}
|
|
2982
|
+
};
|
|
2983
|
+
})];
|
|
2984
|
+
return {
|
|
2985
|
+
apps,
|
|
2986
|
+
projectId: run.stack.project,
|
|
2987
|
+
successCount: ctx.dryRun ? 0 : apps.length,
|
|
2988
|
+
failedCount: 0,
|
|
2989
|
+
stage: ctx.stage,
|
|
2990
|
+
identity: ctx.identity.key,
|
|
2991
|
+
tag: ctx.tag,
|
|
2992
|
+
dryRun: ctx.dryRun,
|
|
2993
|
+
environmentId: "",
|
|
2994
|
+
skipped: [...ctx.skipped],
|
|
2995
|
+
urls: Object.fromEntries(run.stack.apps.map((app) => [app.name, app.url])),
|
|
2996
|
+
changes: run.changes
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
function caFile(run) {
|
|
3000
|
+
return join(run.dir, "caddy-root.crt");
|
|
3001
|
+
}
|
|
3002
|
+
function applied(ctx, run, change) {
|
|
3003
|
+
const { via, ...rest } = change;
|
|
3004
|
+
run.changes.push(rest);
|
|
3005
|
+
ctx.emit({
|
|
3006
|
+
type: "resource.applied",
|
|
3007
|
+
...rest,
|
|
3008
|
+
via
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
3011
|
+
/** Write the stack's files: owner-only, since the env files hold secrets. */
|
|
3012
|
+
async function writeStack(root, dir, stack) {
|
|
3013
|
+
await mkdir(dir, {
|
|
3014
|
+
recursive: true,
|
|
3015
|
+
mode: 448
|
|
3016
|
+
});
|
|
3017
|
+
await chmod(dir, 448);
|
|
3018
|
+
const files = [];
|
|
3019
|
+
const write = async (path, content, mode = 420) => {
|
|
3020
|
+
await mkdir(dirname(path), { recursive: true });
|
|
3021
|
+
await writeFile(path, content, { mode });
|
|
3022
|
+
await chmod(path, mode);
|
|
3023
|
+
files.push(path);
|
|
3024
|
+
};
|
|
3025
|
+
await write(join(dir, "docker-compose.yml"), composeYaml(stack));
|
|
3026
|
+
if (stack.caddyfile !== void 0) {
|
|
3027
|
+
await write(join(dir, "Caddyfile"), stack.caddyfile);
|
|
3028
|
+
await rm(join(dir, "traefik.yml"), { force: true });
|
|
3029
|
+
} else await rm(join(dir, "Caddyfile"), { force: true });
|
|
3030
|
+
if (stack.traefik !== void 0) await write(join(dir, "traefik.yml"), stack.traefik);
|
|
3031
|
+
if (stack.proxy === "caddy" && stack.tls) {
|
|
3032
|
+
await mkdir(join(dir, "tls"), {
|
|
3033
|
+
recursive: true,
|
|
3034
|
+
mode: 448
|
|
3035
|
+
});
|
|
3036
|
+
await write(join(dir, "tls", "cert.pem"), await readFile(stack.tls.certFile, "utf-8"));
|
|
3037
|
+
await write(join(dir, "tls", "key.pem"), await readFile(stack.tls.keyFile, "utf-8"), 384);
|
|
3038
|
+
} else await rm(join(dir, "tls"), {
|
|
3039
|
+
recursive: true,
|
|
3040
|
+
force: true
|
|
3041
|
+
});
|
|
3042
|
+
for (const app of [...stack.apps, ...stack.workers]) if (app.env) await write(join(dir, `${app.name}.env`), envFile(app.env), 384);
|
|
3043
|
+
if (stack.logs && stack.compose.services["openobserve"]?.env_file) await write(join(dir, `${LOGS_SERVICE}.env`), envFile(stack.logs.env), 384);
|
|
3044
|
+
if (stack.redis && stack.compose.services["redis"]?.env_file) await write(join(dir, `${REDIS_SERVICE}.env`), envFile(stack.redis.env), 384);
|
|
3045
|
+
for (const entry of await readdir(dir)) if (entry.endsWith(".credentials")) await rm(join(dir, entry));
|
|
3046
|
+
for (const [path, content] of Object.entries(stack.dockerfiles)) await write(join(root, path), content);
|
|
3047
|
+
await ensureDockerignore(findBuildRoot(root));
|
|
3048
|
+
return files;
|
|
3049
|
+
}
|
|
3050
|
+
function composeYaml(stack) {
|
|
3051
|
+
const stage = ` --stage ${stack.stage}`;
|
|
3052
|
+
return `# Generated by gkm compose from the construct manifest — do not edit.
|
|
3053
|
+
# The ${stack.stage} stage's APIs and sites behind ${stack.proxy === "caddy" ? "one Caddy" : `the shared Traefik edge (${EDGE_PROJECT})`}, and its workers.
|
|
3054
|
+
# Each backend and worker reads exactly the keys in its own env file beside
|
|
3055
|
+
# this one.
|
|
3056
|
+
#
|
|
3057
|
+
# gkm compose${stage} start or update it
|
|
3058
|
+
# gkm compose${stage} --down stop it
|
|
3059
|
+
${stringify(stack.compose, {
|
|
3060
|
+
lineWidth: 0,
|
|
3061
|
+
aliasDuplicateObjects: false
|
|
3062
|
+
})}`;
|
|
3063
|
+
}
|
|
3064
|
+
function printPlan(ctx, run) {
|
|
3065
|
+
const { stack, mode, files } = run;
|
|
3066
|
+
ctx.logger.info(`\n🧱 ${stack.project} — stage ${stack.stage}`);
|
|
3067
|
+
for (const app of stack.apps) ctx.logger.info(` ${app.name.padEnd(12)} ${app.url.padEnd(40)} ${mode} ${app.ref}`);
|
|
3068
|
+
for (const worker of stack.workers) ctx.logger.info(` ${worker.name.padEnd(12)} ${`worker ${worker.id} (no route)`.padEnd(40)} ${mode} ${worker.ref}`);
|
|
3069
|
+
if (stack.infra.length > 0) ctx.logger.info(` infrastructure: ${stack.infra.join(", ")}`);
|
|
3070
|
+
ctx.logger.info(`\n📝 Wrote ${files.length} file(s) under ${stackDir(stack.stage)}/`);
|
|
3071
|
+
}
|
|
3072
|
+
//#endregion
|
|
3073
|
+
//#region src/target/compose/index.ts
|
|
3074
|
+
/**
|
|
3075
|
+
* The built-in `compose` target: a stage as one Docker Compose stack behind
|
|
3076
|
+
* Caddy, on the machine that runs the deploy.
|
|
3077
|
+
*
|
|
3078
|
+
* ```bash
|
|
3079
|
+
* gkm deploy --target compose --stage production --tag v1.4.0 # what CI pushed
|
|
3080
|
+
* gkm deploy --target compose --stage production # built from here
|
|
3081
|
+
* gkm deploy --target compose --stage development # the local stage
|
|
3082
|
+
* ```
|
|
3083
|
+
*
|
|
3084
|
+
* - `validate`: the stack (`composeStack`, pure) and, for a tag, every image
|
|
3085
|
+
* looked up in the registry — `ImageTagNotFound` before anything changes
|
|
3086
|
+
* - `plan`: the stack's files and what a run would create or reuse
|
|
3087
|
+
* - `provision`: generated secrets kept, files written, infrastructure up,
|
|
3088
|
+
* databases, roles, grants and migrations
|
|
3089
|
+
* - `build`: each backend bundled on the host and every image built — or, for
|
|
3090
|
+
* a tag, pulled
|
|
3091
|
+
* - `release`: `up --wait --remove-orphans`, and each app's image, tag and
|
|
3092
|
+
* digest in the stage's state
|
|
3093
|
+
* - `verify`: each app asked through Caddy over HTTPS
|
|
3094
|
+
*
|
|
3095
|
+
* With `push` (`gkm compose --build --push`), run with `buildOnly`, it builds
|
|
3096
|
+
* every image and pushes it to `deploy.registry`, and does nothing else.
|
|
3097
|
+
*
|
|
3098
|
+
* `gkm compose` is this target with a few more switches (`--build`, `--pull`,
|
|
3099
|
+
* `--down`), run through the same `deploy()`.
|
|
3100
|
+
*/
|
|
3101
|
+
var compose_exports = /* @__PURE__ */ __exportAll({ composeTarget: () => composeTarget });
|
|
3102
|
+
/** The compose target, with its Docker, bundler and probe as given. */
|
|
3103
|
+
function composeTarget(options = {}) {
|
|
3104
|
+
const { mode, push, pin, report, ...given } = options;
|
|
3105
|
+
const deps = {
|
|
3106
|
+
...defaultDeps,
|
|
3107
|
+
...given
|
|
3108
|
+
};
|
|
3109
|
+
return defineTarget({
|
|
3110
|
+
name: "compose",
|
|
3111
|
+
runtime: "server",
|
|
3112
|
+
capabilities: {
|
|
3113
|
+
rollback: false,
|
|
3114
|
+
migrations: "target",
|
|
3115
|
+
images: true,
|
|
3116
|
+
localStage: true,
|
|
3117
|
+
selfHostedTelemetry: true
|
|
3118
|
+
},
|
|
3119
|
+
credentials: [],
|
|
3120
|
+
tag: ({ cwd }) => deps.revision(cwd),
|
|
3121
|
+
validate: (ctx) => validateCompose(ctx, deps, {
|
|
3122
|
+
...mode ? { mode } : {},
|
|
3123
|
+
...push ? { push } : {},
|
|
3124
|
+
...pin ? { pin } : {}
|
|
3125
|
+
}),
|
|
3126
|
+
plan: (ctx, run) => planCompose(ctx, run),
|
|
3127
|
+
provision: (ctx, run) => provisionCompose(ctx, run, deps),
|
|
3128
|
+
build: (ctx, run) => buildCompose(ctx, run, deps),
|
|
3129
|
+
release: (ctx, run) => releaseCompose(ctx, run, deps),
|
|
3130
|
+
verify: (ctx, run) => verifyCompose(ctx, run, deps),
|
|
3131
|
+
result: (ctx, run) => {
|
|
3132
|
+
report?.(run);
|
|
3133
|
+
return composeResult(ctx, run);
|
|
3134
|
+
}
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
//#endregion
|
|
3138
|
+
export { planStackDns as _, stackOverrideFile as a, serverAddressHint as b, stack_exports as c, pinnedRef as d, edgeDir as f, applyStackDns as g, dockerCompose as h, ComposePushNeedsBuild as i, secrets_exports as l, EDGE_PROJECT as m, compose_exports as n, composeProject as o, removeEdgeRoutes as p, ComposePinNeedsPull as r, stackDir as s, composeTarget as t, parseDigests as u, requiredServerAddress as v, stackHosts as x, serverAddress as y };
|
|
3139
|
+
|
|
3140
|
+
//# sourceMappingURL=compose-Bhmf0n4o.mjs.map
|