@geekmidas/cli 9.0.2 → 10.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -120
- package/dist/{CachedStateProvider-BdX7klRc.cjs → CachedStateProvider-BLo7zv7S.cjs} +1 -1
- package/dist/CachedStateProvider-BxoAm-B2.mjs +3 -0
- package/dist/{CachedStateProvider-klLWZXWF.cjs → CachedStateProvider-CqPdW7CB.cjs} +1 -1
- package/dist/{CachedStateProvider-klLWZXWF.cjs.map → CachedStateProvider-CqPdW7CB.cjs.map} +1 -1
- package/dist/{CachedStateProvider-CzyFct42.mjs → CachedStateProvider-DTniehMP.mjs} +1 -1
- package/dist/{CachedStateProvider-CzyFct42.mjs.map → CachedStateProvider-DTniehMP.mjs.map} +1 -1
- package/dist/{HostingerProvider-B2ZYz7Yc.mjs → HostingerProvider-Cft7lEYr.mjs} +4 -3
- package/dist/HostingerProvider-Cft7lEYr.mjs.map +1 -0
- package/dist/{HostingerProvider-CrHfsgz9.cjs → HostingerProvider-h3EbEp-Z.cjs} +4 -3
- package/dist/HostingerProvider-h3EbEp-Z.cjs.map +1 -0
- package/dist/{LocalStateProvider-CP49rjzt.mjs → LocalStateProvider-Bn6tS-oL.mjs} +1 -1
- package/dist/{LocalStateProvider-CP49rjzt.mjs.map → LocalStateProvider-Bn6tS-oL.mjs.map} +1 -1
- package/dist/{LocalStateProvider-CalzNfNA.cjs → LocalStateProvider-Db9SEjyH.cjs} +1 -1
- package/dist/{LocalStateProvider-CalzNfNA.cjs.map → LocalStateProvider-Db9SEjyH.cjs.map} +1 -1
- package/dist/{Route53Provider-tlgBzrRQ.mjs → Route53Provider-RdpUDJQk.mjs} +1 -1
- package/dist/{Route53Provider-tlgBzrRQ.mjs.map → Route53Provider-RdpUDJQk.mjs.map} +1 -1
- package/dist/{Route53Provider-DwgXiLxt.cjs → Route53Provider-di2fMM5N.cjs} +1 -1
- package/dist/{Route53Provider-DwgXiLxt.cjs.map → Route53Provider-di2fMM5N.cjs.map} +1 -1
- package/dist/{SSMStateProvider-BlrTiV5m.mjs → SSMStateProvider-BRt2py-A.mjs} +1 -1
- package/dist/{SSMStateProvider-BlrTiV5m.mjs.map → SSMStateProvider-BRt2py-A.mjs.map} +1 -1
- package/dist/{SSMStateProvider-Bt6r8CTL.cjs → SSMStateProvider-DyX8s9HI.cjs} +1 -1
- package/dist/{SSMStateProvider-Bt6r8CTL.cjs.map → SSMStateProvider-DyX8s9HI.cjs.map} +1 -1
- package/dist/{backup-provisioner-Xunq-p_8.mjs → backup-provisioner-Bgj5Vi_L.mjs} +2 -2
- package/dist/{backup-provisioner-Xunq-p_8.mjs.map → backup-provisioner-Bgj5Vi_L.mjs.map} +1 -1
- package/dist/{backup-provisioner-B3HBN-2k.cjs → backup-provisioner-BrsHwYG0.cjs} +2 -2
- package/dist/{backup-provisioner-B3HBN-2k.cjs.map → backup-provisioner-BrsHwYG0.cjs.map} +1 -1
- package/dist/{bundler-Dufq3uvI.cjs → bundler-BY1OmV1e.cjs} +44 -14
- package/dist/bundler-BY1OmV1e.cjs.map +1 -0
- package/dist/{bundler-DkA4qzUD.mjs → bundler-Be8Zg4Wd.mjs} +45 -15
- package/dist/bundler-Be8Zg4Wd.mjs.map +1 -0
- package/dist/config-D8o6I_7Z.mjs +469 -0
- package/dist/config-D8o6I_7Z.mjs.map +1 -0
- package/dist/config-DbPE1KXF.cjs +535 -0
- package/dist/config-DbPE1KXF.cjs.map +1 -0
- package/dist/config.cjs +3 -2
- package/dist/config.d.cts +2 -2
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +2 -2
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +3 -2
- package/dist/{credentials-s1kLcIzK.mjs → credentials-B5zRHdAl.mjs} +21 -6
- package/dist/credentials-B5zRHdAl.mjs.map +1 -0
- package/dist/{credentials-C8DWtnMY.cjs → credentials-BapzjyV5.cjs} +26 -5
- package/dist/credentials-BapzjyV5.cjs.map +1 -0
- package/dist/credentials-Dh0Q6HzY.mjs +2548 -0
- package/dist/credentials-Dh0Q6HzY.mjs.map +1 -0
- package/dist/credentials-K961ThG3.cjs +2680 -0
- package/dist/credentials-K961ThG3.cjs.map +1 -0
- package/dist/deploy/sniffer-routes-worker.cjs +6 -2
- package/dist/deploy/sniffer-routes-worker.cjs.map +1 -1
- package/dist/deploy/sniffer-routes-worker.mjs +6 -2
- package/dist/deploy/sniffer-routes-worker.mjs.map +1 -1
- package/dist/discover-CAlFgYec.mjs +229 -0
- package/dist/discover-CAlFgYec.mjs.map +1 -0
- package/dist/discover-DTmFeloL.cjs +271 -0
- package/dist/discover-DTmFeloL.cjs.map +1 -0
- package/dist/dokploy-api-BS2xnJLZ.mjs +3 -0
- package/dist/{dokploy-api-CqOIKHme.cjs → dokploy-api-BsVH0y_s.cjs} +141 -32
- package/dist/dokploy-api-BsVH0y_s.cjs.map +1 -0
- package/dist/dokploy-api-BuxDRo3l.cjs +3 -0
- package/dist/{dokploy-api-sfZFlWuE.mjs → dokploy-api-aTFPz7_4.mjs} +141 -32
- package/dist/dokploy-api-aTFPz7_4.mjs.map +1 -0
- package/dist/emit-CbcRolsa.cjs +236 -0
- package/dist/emit-CbcRolsa.cjs.map +1 -0
- package/dist/emit-CiZS_5Ub.mjs +206 -0
- package/dist/emit-CiZS_5Ub.mjs.map +1 -0
- package/dist/{encryption-MfXAiFTm.cjs → encryption-C6jaU0hE.cjs} +1 -1
- package/dist/{encryption-MfXAiFTm.cjs.map → encryption-C6jaU0hE.cjs.map} +1 -1
- package/dist/{encryption-BVS7Vmzs.cjs → encryption-CHzzeR8a.cjs} +1 -1
- package/dist/encryption-CQUncu7v.mjs +3 -0
- package/dist/{encryption-CO5Q4Bhf.mjs → encryption-D-abyhZx.mjs} +1 -1
- package/dist/{encryption-CO5Q4Bhf.mjs.map → encryption-D-abyhZx.mjs.map} +1 -1
- package/dist/fullstack-secrets-C-GGohke.cjs +111 -0
- package/dist/fullstack-secrets-C-GGohke.cjs.map +1 -0
- package/dist/fullstack-secrets-D1o9-G5l.mjs +87 -0
- package/dist/fullstack-secrets-D1o9-G5l.mjs.map +1 -0
- package/dist/{fullstack-secrets-BYzi6Hwl.cjs → generator-CNzBw7g5.cjs} +1 -114
- package/dist/generator-CNzBw7g5.cjs.map +1 -0
- package/dist/{fullstack-secrets-l8s3II_H.mjs → generator-VE2JrFlY.mjs} +2 -85
- package/dist/generator-VE2JrFlY.mjs.map +1 -0
- package/dist/index-BThUzEIp.d.mts +175 -0
- package/dist/index-BThUzEIp.d.mts.map +1 -0
- package/dist/index-CHB4tSzX.d.cts +175 -0
- package/dist/index-CHB4tSzX.d.cts.map +1 -0
- package/dist/index.cjs +2329 -1584
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +2306 -1561
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-Cfwi0fqF.mjs → openapi-CgpMWMmM.mjs} +174 -95
- package/dist/openapi-CgpMWMmM.mjs.map +1 -0
- package/dist/{openapi-BzNOC7_i.cjs → openapi-CljONd_M.cjs} +175 -108
- package/dist/openapi-CljONd_M.cjs.map +1 -0
- package/dist/{openapi-react-query-C4iIvpPf.mjs → openapi-react-query-Byltvm_7.mjs} +2 -2
- package/dist/openapi-react-query-Byltvm_7.mjs.map +1 -0
- package/dist/{openapi-react-query-CYWik72n.cjs → openapi-react-query-CmJ1xDbn.cjs} +2 -2
- package/dist/openapi-react-query-CmJ1xDbn.cjs.map +1 -0
- package/dist/openapi-react-query.cjs +1 -1
- package/dist/openapi-react-query.mjs +1 -1
- package/dist/openapi.cjs +4 -3
- package/dist/openapi.d.cts +1 -1
- package/dist/openapi.d.mts +1 -1
- package/dist/openapi.mjs +4 -3
- package/dist/reconcile/public.cjs +9 -0
- package/dist/reconcile/public.d.cts +125 -0
- package/dist/reconcile/public.d.cts.map +1 -0
- package/dist/reconcile/public.d.mts +125 -0
- package/dist/reconcile/public.d.mts.map +1 -0
- package/dist/reconcile/public.mjs +4 -0
- package/dist/{reconcile-B2FoJE0a.mjs → reconcile-CFs8C_1a.mjs} +5 -4
- package/dist/reconcile-CFs8C_1a.mjs.map +1 -0
- package/dist/{reconcile-BzqKJ2Rt.cjs → reconcile-D0pimNiy.cjs} +5 -4
- package/dist/reconcile-D0pimNiy.cjs.map +1 -0
- package/dist/setup-CJiQM2pX.cjs +473 -0
- package/dist/setup-CJiQM2pX.cjs.map +1 -0
- package/dist/setup-DMtU3p41.cjs +11 -0
- package/dist/setup-DOsYueeQ.mjs +443 -0
- package/dist/setup-DOsYueeQ.mjs.map +1 -0
- package/dist/setup-DSutDAlt.mjs +11 -0
- package/dist/{storage-B1wvztiJ.cjs → storage-BJmzYxt8.cjs} +1 -1
- package/dist/{storage-B1wvztiJ.cjs.map → storage-BJmzYxt8.cjs.map} +1 -1
- package/dist/{storage-DOEtT2Hr.cjs → storage-BM9hGpgv.cjs} +1 -1
- package/dist/{storage-dbb9RyBl.mjs → storage-Bi24WfoW.mjs} +1 -1
- package/dist/{storage-dbb9RyBl.mjs.map → storage-Bi24WfoW.mjs.map} +1 -1
- package/dist/{storage-Cs4WBsc4.mjs → storage-CYlGwWTj.mjs} +1 -1
- package/dist/{sync-CSooqRQ8.mjs → sync-CmEuNZsB.mjs} +2 -2
- package/dist/{sync-CSooqRQ8.mjs.map → sync-CmEuNZsB.mjs.map} +1 -1
- package/dist/sync-D306yOWu.cjs +4 -0
- package/dist/{sync-o_CtEbvR.cjs → sync-SizaChmC.cjs} +2 -2
- package/dist/{sync-o_CtEbvR.cjs.map → sync-SizaChmC.cjs.map} +1 -1
- package/dist/{sync-B5UHXwRM.mjs → sync-hOqAWHR6.mjs} +1 -1
- package/dist/{index-Bw548Bta.d.mts → types-C-arnQ1r.d.mts} +590 -143
- package/dist/types-C-arnQ1r.d.mts.map +1 -0
- package/dist/{index-DTpkeFb8.d.cts → types-abJQuk7m.d.cts} +590 -143
- package/dist/types-abJQuk7m.d.cts.map +1 -0
- package/dist/workspace/index.cjs +5 -1
- package/dist/workspace/index.d.cts +3 -3
- package/dist/workspace/index.d.mts +3 -3
- package/dist/workspace/index.mjs +2 -2
- package/dist/{workspace-D-37Ylqo.mjs → workspace-7FqprxuJ.mjs} +117 -30
- package/dist/workspace-7FqprxuJ.mjs.map +1 -0
- package/dist/workspace-8acupeaI.cjs +9 -0
- package/dist/{workspace-CQTT7HJQ.cjs → workspace-XvqO9jIv.cjs} +139 -28
- package/dist/workspace-XvqO9jIv.cjs.map +1 -0
- package/dist/workspace-yP5AHnrn.mjs +8 -0
- package/package.json +26 -9
- package/CHANGELOG.md +0 -650
- package/FUNCTION_CRON_SUPPORT.md +0 -266
- package/dist/CachedStateProvider-GcGziXh1.mjs +0 -3
- package/dist/HostingerProvider-B2ZYz7Yc.mjs.map +0 -1
- package/dist/HostingerProvider-CrHfsgz9.cjs.map +0 -1
- package/dist/bundler-DkA4qzUD.mjs.map +0 -1
- package/dist/bundler-Dufq3uvI.cjs.map +0 -1
- package/dist/config-Bbu1JKvf.mjs +0 -211
- package/dist/config-Bbu1JKvf.mjs.map +0 -1
- package/dist/config-DujV8DbR.cjs +0 -253
- package/dist/config-DujV8DbR.cjs.map +0 -1
- package/dist/credentials-C8DWtnMY.cjs.map +0 -1
- package/dist/credentials-s1kLcIzK.mjs.map +0 -1
- package/dist/dokploy-api-BPYkG-ZZ.cjs +0 -3
- package/dist/dokploy-api-BiSzWM4v.mjs +0 -3
- package/dist/dokploy-api-CqOIKHme.cjs.map +0 -1
- package/dist/dokploy-api-sfZFlWuE.mjs.map +0 -1
- package/dist/encryption-Bd_m4q3g.mjs +0 -3
- package/dist/fullstack-secrets-BYzi6Hwl.cjs.map +0 -1
- package/dist/fullstack-secrets-l8s3II_H.mjs.map +0 -1
- package/dist/index-Bw548Bta.d.mts.map +0 -1
- package/dist/index-DTpkeFb8.d.cts.map +0 -1
- package/dist/openapi-BzNOC7_i.cjs.map +0 -1
- package/dist/openapi-Cfwi0fqF.mjs.map +0 -1
- package/dist/openapi-react-query-C4iIvpPf.mjs.map +0 -1
- package/dist/openapi-react-query-CYWik72n.cjs.map +0 -1
- package/dist/reconcile-B2FoJE0a.mjs.map +0 -1
- package/dist/reconcile-BzqKJ2Rt.cjs.map +0 -1
- package/dist/setup-DDXUXzzl.mjs +0 -765
- package/dist/setup-DDXUXzzl.mjs.map +0 -1
- package/dist/setup-DLwhfyt-.cjs +0 -8
- package/dist/setup-Dh-B7YTX.mjs +0 -8
- package/dist/setup-WZ-xqAQO.cjs +0 -849
- package/dist/setup-WZ-xqAQO.cjs.map +0 -1
- package/dist/sync-BLA94rgC.cjs +0 -4
- package/dist/types-B3GQUg3k.d.mts +0 -280
- package/dist/types-B3GQUg3k.d.mts.map +0 -1
- package/dist/types-BWQDiooe.d.cts +0 -280
- package/dist/types-BWQDiooe.d.cts.map +0 -1
- package/dist/workspace-CQTT7HJQ.cjs.map +0 -1
- package/dist/workspace-D-37Ylqo.mjs.map +0 -1
- package/docs/OPENAPI_TYPESCRIPT_DESIGN.md +0 -408
- package/docs/dev-server-resilience-design.md +0 -189
- package/docs/manifest-refactor-design.md +0 -287
- package/examples/cron-example.ts +0 -45
- package/examples/env.ts +0 -33
- package/examples/function-example.ts +0 -40
- package/examples/gkm.config.json +0 -22
- package/examples/gkm.config.ts +0 -14
- package/examples/gkm.minimal.config.json +0 -7
- package/examples/gkm.production.config.json +0 -27
- package/examples/logger.ts +0 -10
- package/scripts/sync-versions.ts +0 -108
- package/src/__tests__/EndpointGenerator.hooks.spec.ts +0 -204
- package/src/__tests__/config.spec.ts +0 -422
- package/src/__tests__/loadEnvFiles.spec.ts +0 -131
- package/src/__tests__/normalizeHooksConfig.spec.ts +0 -63
- package/src/__tests__/openapi-react-query.spec.ts +0 -506
- package/src/__tests__/openapi.spec.ts +0 -625
- package/src/__tests__/test-helpers.ts +0 -181
- package/src/auth/__tests__/credentials.spec.ts +0 -331
- package/src/auth/__tests__/index.spec.ts +0 -237
- package/src/auth/credentials.ts +0 -286
- package/src/auth/index.ts +0 -233
- package/src/build/__tests__/bundler.spec.ts +0 -446
- package/src/build/__tests__/endpoint-analyzer.spec.ts +0 -623
- package/src/build/__tests__/handler-templates.spec.ts +0 -1287
- package/src/build/__tests__/index-new.spec.ts +0 -550
- package/src/build/__tests__/manifests.spec.ts +0 -583
- package/src/build/__tests__/partitions.spec.ts +0 -110
- package/src/build/__tests__/workspace-build.spec.ts +0 -215
- package/src/build/bundler.ts +0 -256
- package/src/build/endpoint-analyzer.ts +0 -236
- package/src/build/handler-templates.ts +0 -1253
- package/src/build/index.ts +0 -616
- package/src/build/manifests.ts +0 -285
- package/src/build/partitions.ts +0 -58
- package/src/build/providerResolver.ts +0 -184
- package/src/build/types.ts +0 -104
- package/src/config.ts +0 -330
- package/src/credentials/__tests__/fullDockerPorts.spec.ts +0 -144
- package/src/credentials/__tests__/helpers.ts +0 -112
- package/src/credentials/__tests__/prepareEntryCredentials.spec.ts +0 -125
- package/src/credentials/__tests__/readonlyDockerPorts.spec.ts +0 -190
- package/src/credentials/__tests__/workspaceCredentials.spec.ts +0 -209
- package/src/credentials/index.ts +0 -843
- package/src/debug.ts +0 -68
- package/src/deploy/CachedStateProvider.ts +0 -86
- package/src/deploy/LocalStateProvider.ts +0 -57
- package/src/deploy/SSMStateProvider.ts +0 -117
- package/src/deploy/StateProvider.ts +0 -175
- package/src/deploy/__tests__/CachedStateProvider.spec.ts +0 -235
- package/src/deploy/__tests__/HostingerProvider.spec.ts +0 -347
- package/src/deploy/__tests__/LocalStateProvider.spec.ts +0 -130
- package/src/deploy/__tests__/Route53Provider.spec.ts +0 -425
- package/src/deploy/__tests__/SSMStateProvider.spec.ts +0 -201
- package/src/deploy/__tests__/__fixtures__/entry-apps/async-entry.ts +0 -24
- package/src/deploy/__tests__/__fixtures__/entry-apps/nested-config-entry.ts +0 -24
- package/src/deploy/__tests__/__fixtures__/entry-apps/no-env-entry.ts +0 -12
- package/src/deploy/__tests__/__fixtures__/entry-apps/simple-entry.ts +0 -14
- package/src/deploy/__tests__/__fixtures__/entry-apps/throwing-entry.ts +0 -16
- package/src/deploy/__tests__/__fixtures__/env-parsers/non-function-export.ts +0 -10
- package/src/deploy/__tests__/__fixtures__/env-parsers/optional-env-parser.ts +0 -21
- package/src/deploy/__tests__/__fixtures__/env-parsers/parseable-env-parser.ts +0 -18
- package/src/deploy/__tests__/__fixtures__/env-parsers/throwing-env-parser.ts +0 -16
- package/src/deploy/__tests__/__fixtures__/env-parsers/valid-env-parser.ts +0 -16
- package/src/deploy/__tests__/__fixtures__/route-apps/endpoints/auth.ts +0 -16
- package/src/deploy/__tests__/__fixtures__/route-apps/endpoints/health.ts +0 -13
- package/src/deploy/__tests__/__fixtures__/route-apps/endpoints/users.ts +0 -15
- package/src/deploy/__tests__/__fixtures__/route-apps/services.ts +0 -55
- package/src/deploy/__tests__/backup-provisioner.spec.ts +0 -428
- package/src/deploy/__tests__/createDnsProvider.spec.ts +0 -195
- package/src/deploy/__tests__/createStateProvider.spec.ts +0 -126
- package/src/deploy/__tests__/dns-orchestration.spec.ts +0 -192
- package/src/deploy/__tests__/dns-verification.spec.ts +0 -229
- package/src/deploy/__tests__/docker.spec.ts +0 -149
- package/src/deploy/__tests__/dokploy-api.spec.ts +0 -697
- package/src/deploy/__tests__/dokploy.spec.ts +0 -435
- package/src/deploy/__tests__/domain.spec.ts +0 -235
- package/src/deploy/__tests__/env-resolver.spec.ts +0 -903
- package/src/deploy/__tests__/index.spec.ts +0 -1091
- package/src/deploy/__tests__/init.spec.ts +0 -793
- package/src/deploy/__tests__/secrets.spec.ts +0 -303
- package/src/deploy/__tests__/sniffer.spec.ts +0 -838
- package/src/deploy/__tests__/state-e2e.spec.ts +0 -387
- package/src/deploy/__tests__/state.spec.ts +0 -876
- package/src/deploy/__tests__/undeploy.spec.ts +0 -758
- package/src/deploy/backup-provisioner.ts +0 -316
- package/src/deploy/dns/DnsProvider.ts +0 -201
- package/src/deploy/dns/HostingerProvider.ts +0 -174
- package/src/deploy/dns/Route53Provider.ts +0 -340
- package/src/deploy/dns/hostinger-api.ts +0 -261
- package/src/deploy/dns/index.ts +0 -627
- package/src/deploy/docker.ts +0 -260
- package/src/deploy/dokploy-api.ts +0 -1101
- package/src/deploy/dokploy.ts +0 -177
- package/src/deploy/domain.ts +0 -134
- package/src/deploy/env-resolver.ts +0 -305
- package/src/deploy/index.ts +0 -2094
- package/src/deploy/init.ts +0 -427
- package/src/deploy/secrets.ts +0 -187
- package/src/deploy/sniffer-envkit-patch.ts +0 -61
- package/src/deploy/sniffer-hooks.ts +0 -57
- package/src/deploy/sniffer-loader.ts +0 -28
- package/src/deploy/sniffer-routes-worker.ts +0 -104
- package/src/deploy/sniffer-worker.ts +0 -75
- package/src/deploy/sniffer.ts +0 -578
- package/src/deploy/state-commands.ts +0 -274
- package/src/deploy/state.ts +0 -482
- package/src/deploy/types.ts +0 -102
- package/src/deploy/undeploy.ts +0 -407
- package/src/dev/__tests__/entry-integration.spec.ts +0 -347
- package/src/dev/__tests__/entry.spec.ts +0 -144
- package/src/dev/__tests__/index.spec.ts +0 -2084
- package/src/dev/index.ts +0 -1649
- package/src/docker/__tests__/compose.spec.ts +0 -1142
- package/src/docker/__tests__/templates.spec.ts +0 -694
- package/src/docker/compose.ts +0 -808
- package/src/docker/index.ts +0 -523
- package/src/docker/templates.ts +0 -1067
- package/src/exec/index.ts +0 -118
- package/src/generators/CronGenerator.ts +0 -105
- package/src/generators/EndpointGenerator.ts +0 -954
- package/src/generators/FunctionGenerator.ts +0 -117
- package/src/generators/Generator.ts +0 -147
- package/src/generators/OpenApiTsGenerator.ts +0 -821
- package/src/generators/QueueGenerator.ts +0 -245
- package/src/generators/SubscriberGenerator.ts +0 -282
- package/src/generators/TopicGenerator.ts +0 -45
- package/src/generators/__tests__/CronGenerator.spec.ts +0 -445
- package/src/generators/__tests__/EndpointGenerator.spec.ts +0 -544
- package/src/generators/__tests__/FunctionGenerator.spec.ts +0 -256
- package/src/generators/__tests__/Generator.bustCache.spec.ts +0 -72
- package/src/generators/__tests__/OpenApiTsGenerator.registry.spec.ts +0 -98
- package/src/generators/__tests__/OpenApiTsGenerator.responseType.spec.ts +0 -56
- package/src/generators/__tests__/QueueGenerator.spec.ts +0 -141
- package/src/generators/__tests__/SubscriberGenerator.spec.ts +0 -410
- package/src/generators/index.ts +0 -11
- package/src/index.ts +0 -972
- package/src/init/__tests__/generators.spec.ts +0 -1070
- package/src/init/__tests__/init.spec.ts +0 -577
- package/src/init/__tests__/utils.spec.ts +0 -104
- package/src/init/generators/auth.ts +0 -225
- package/src/init/generators/config.ts +0 -316
- package/src/init/generators/docker.ts +0 -388
- package/src/init/generators/env.ts +0 -62
- package/src/init/generators/index.ts +0 -6
- package/src/init/generators/mobile-expo.ts +0 -576
- package/src/init/generators/models.ts +0 -153
- package/src/init/generators/monorepo.ts +0 -475
- package/src/init/generators/package.ts +0 -95
- package/src/init/generators/source.ts +0 -15
- package/src/init/generators/test.ts +0 -178
- package/src/init/generators/ui.ts +0 -1473
- package/src/init/generators/web-tanstack.ts +0 -348
- package/src/init/generators/web.ts +0 -363
- package/src/init/index.ts +0 -542
- package/src/init/templates/api.ts +0 -404
- package/src/init/templates/index.ts +0 -342
- package/src/init/templates/minimal.ts +0 -207
- package/src/init/templates/serverless.ts +0 -148
- package/src/init/templates/worker.ts +0 -216
- package/src/init/utils.ts +0 -197
- package/src/init/versions.ts +0 -52
- package/src/openapi-react-query.ts +0 -288
- package/src/openapi.ts +0 -259
- package/src/secrets/__tests__/encryption.spec.ts +0 -226
- package/src/secrets/__tests__/generator.spec.ts +0 -575
- package/src/secrets/__tests__/index.spec.ts +0 -91
- package/src/secrets/__tests__/reconcile.spec.ts +0 -123
- package/src/secrets/__tests__/storage.spec.ts +0 -645
- package/src/secrets/encryption.ts +0 -91
- package/src/secrets/generator.ts +0 -329
- package/src/secrets/index.ts +0 -417
- package/src/secrets/keystore.ts +0 -144
- package/src/secrets/reconcile.ts +0 -53
- package/src/secrets/storage.ts +0 -337
- package/src/secrets/sync.ts +0 -136
- package/src/secrets/types.ts +0 -70
- package/src/setup/__tests__/auto-setup.spec.ts +0 -147
- package/src/setup/__tests__/reconcile-secrets.spec.ts +0 -439
- package/src/setup/fullstack-secrets.ts +0 -140
- package/src/setup/index.ts +0 -411
- package/src/test/__tests__/__fixtures__/workspace.ts +0 -104
- package/src/test/__tests__/api.spec.ts +0 -199
- package/src/test/__tests__/auth.spec.ts +0 -162
- package/src/test/__tests__/index.spec.ts +0 -502
- package/src/test/__tests__/web.spec.ts +0 -210
- package/src/test/index.ts +0 -198
- package/src/types.ts +0 -363
- package/src/upgrade/__tests__/index.spec.ts +0 -354
- package/src/upgrade/index.ts +0 -253
- package/src/workspace/__tests__/client-generator.spec.ts +0 -326
- package/src/workspace/__tests__/index.spec.ts +0 -766
- package/src/workspace/__tests__/publicEnv.spec.ts +0 -64
- package/src/workspace/__tests__/schema.spec.ts +0 -737
- package/src/workspace/__tests__/type-inference.spec.ts +0 -251
- package/src/workspace/client-generator.ts +0 -109
- package/src/workspace/index.ts +0 -433
- package/src/workspace/publicEnv.ts +0 -57
- package/src/workspace/schema.ts +0 -861
- package/src/workspace/types.ts +0 -943
- package/tsconfig.json +0 -9
- package/tsdown.config.ts +0 -22
|
@@ -0,0 +1,2680 @@
|
|
|
1
|
+
const require_chunk = require('./chunk-CUT6urMc.cjs');
|
|
2
|
+
const require_discover = require('./discover-DTmFeloL.cjs');
|
|
3
|
+
const require_config = require('./config-DbPE1KXF.cjs');
|
|
4
|
+
const require_workspace = require('./workspace-XvqO9jIv.cjs');
|
|
5
|
+
const require_generator = require('./generator-CNzBw7g5.cjs');
|
|
6
|
+
const require_storage = require('./storage-BJmzYxt8.cjs');
|
|
7
|
+
const node_fs = require_chunk.__toESM(require("node:fs"));
|
|
8
|
+
const node_path = require_chunk.__toESM(require("node:path"));
|
|
9
|
+
const __geekmidas_manifest = require_chunk.__toESM(require("@geekmidas/manifest"));
|
|
10
|
+
const node_fs_promises = require_chunk.__toESM(require("node:fs/promises"));
|
|
11
|
+
const node_child_process = require_chunk.__toESM(require("node:child_process"));
|
|
12
|
+
const node_net = require_chunk.__toESM(require("node:net"));
|
|
13
|
+
const dotenv = require_chunk.__toESM(require("dotenv"));
|
|
14
|
+
const yaml = require_chunk.__toESM(require("yaml"));
|
|
15
|
+
const __aws_sdk_client_s3 = require_chunk.__toESM(require("@aws-sdk/client-s3"));
|
|
16
|
+
const pg = require_chunk.__toESM(require("pg"));
|
|
17
|
+
const node_util = require_chunk.__toESM(require("node:util"));
|
|
18
|
+
const node_crypto = require_chunk.__toESM(require("node:crypto"));
|
|
19
|
+
const __geekmidas_db_pg_roles = require_chunk.__toESM(require("@geekmidas/db/pg/roles"));
|
|
20
|
+
const __geekmidas_cache_postgres = require_chunk.__toESM(require("@geekmidas/cache/postgres"));
|
|
21
|
+
|
|
22
|
+
//#region src/workspace/backends.ts
|
|
23
|
+
const CACHE_BACKENDS = [
|
|
24
|
+
"upstash",
|
|
25
|
+
"elasticache",
|
|
26
|
+
"db"
|
|
27
|
+
];
|
|
28
|
+
const EMAIL_BACKENDS = [
|
|
29
|
+
"resend",
|
|
30
|
+
"ses",
|
|
31
|
+
"smtp"
|
|
32
|
+
];
|
|
33
|
+
const STORAGE_BACKENDS = [
|
|
34
|
+
"minio",
|
|
35
|
+
"s3",
|
|
36
|
+
"r2"
|
|
37
|
+
];
|
|
38
|
+
/**
|
|
39
|
+
* The cache backend a workspace selected, or the one its target implies.
|
|
40
|
+
*
|
|
41
|
+
* Defaulted here rather than by every caller, so "what happens when nobody
|
|
42
|
+
* said" is answered once and the answer is greppable — and now answered by
|
|
43
|
+
* *where it deploys*, because there is no single right answer for both a
|
|
44
|
+
* Lambda and a box running its own Postgres.
|
|
45
|
+
*
|
|
46
|
+
* `on` is the deploy target, not where this code happens to be running. A
|
|
47
|
+
* local `gkm dev` for an AWS app still defaults to Upstash, because a backend
|
|
48
|
+
* that differed between local and deployed would be worse than a slower one —
|
|
49
|
+
* which is the property the type's own documentation asks for.
|
|
50
|
+
*/
|
|
51
|
+
function cacheBackendOf(value, on = "aws") {
|
|
52
|
+
return isBackend(value, CACHE_BACKENDS) ? value : require_discover.DEFAULT_CACHE[on];
|
|
53
|
+
}
|
|
54
|
+
/** The email backend a workspace selected, defaulting to SES on every target. */
|
|
55
|
+
function emailBackendOf(value) {
|
|
56
|
+
return isBackend(value, EMAIL_BACKENDS) ? value : require_discover.DEFAULT_EMAIL;
|
|
57
|
+
}
|
|
58
|
+
/** The storage backend a workspace selected, or the one its target implies. */
|
|
59
|
+
function storageBackendOf(value, on = "aws") {
|
|
60
|
+
return isBackend(value, STORAGE_BACKENDS) ? value : require_discover.DEFAULT_STORAGE[on];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Which family of defaults a workspace's deploy target belongs to.
|
|
64
|
+
*
|
|
65
|
+
* One question, asked once: **does this target run containers the project
|
|
66
|
+
* controls?** Dokploy and a bare server do, so a bucket or a cache can live
|
|
67
|
+
* beside the app; AWS, Vercel and Cloudflare do not, so the default has to be
|
|
68
|
+
* something managed. That is the whole of the distinction, and it is why
|
|
69
|
+
* `MainProvider` is the right axis rather than the deploy target's own name —
|
|
70
|
+
* three targets share one answer.
|
|
71
|
+
*
|
|
72
|
+
* An AWS deploy names no `deploy.default` at all, because it goes through SST,
|
|
73
|
+
* which is why the absence is what selects `aws`.
|
|
74
|
+
*/
|
|
75
|
+
function providerOf(workspace) {
|
|
76
|
+
const target = workspace.deploy?.default;
|
|
77
|
+
return target === "dokploy" || target === "server" ? "server" : "aws";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether a string names a backend, as opposed to being one of the other things
|
|
81
|
+
* a `services:` value can be.
|
|
82
|
+
*
|
|
83
|
+
* A misspelling falls through to the default rather than throwing, which is the
|
|
84
|
+
* wrong trade for something with a cost attached — so callers that can report it
|
|
85
|
+
* should use {@link unknownBackend} to say so.
|
|
86
|
+
*/
|
|
87
|
+
function isBackend(value, backends) {
|
|
88
|
+
return typeof value === "string" && backends.includes(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/reconcile/caddyfile.ts
|
|
93
|
+
/**
|
|
94
|
+
* The kinds the edge serves.
|
|
95
|
+
*
|
|
96
|
+
* Everything that owns a *public address*. Deployed, each of these has a real
|
|
97
|
+
* hostname and a certificate, so this is what makes the local shape the same
|
|
98
|
+
* one rather than a near-enough one — and the application is indifferent
|
|
99
|
+
* either way, because it reads whichever address was injected and never
|
|
100
|
+
* composes one.
|
|
101
|
+
*
|
|
102
|
+
* Adding a kind here is the whole of adding it to the edge.
|
|
103
|
+
*/
|
|
104
|
+
const EDGE_KINDS = {
|
|
105
|
+
"file-server": true,
|
|
106
|
+
"rest-api": true,
|
|
107
|
+
site: true
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* How the edge reaches the host, from inside its container.
|
|
111
|
+
*
|
|
112
|
+
* A surface and a site are served by processes `gkm dev` starts on the host,
|
|
113
|
+
* not by containers — so the edge has to leave Docker's network to reach them.
|
|
114
|
+
* Docker Desktop resolves this name already; on Linux the compose definition
|
|
115
|
+
* maps it to the host gateway.
|
|
116
|
+
*/
|
|
117
|
+
const HOST_GATEWAY = "host.docker.internal";
|
|
118
|
+
/**
|
|
119
|
+
* The suffix every local host shares.
|
|
120
|
+
*
|
|
121
|
+
* `.localhost` rather than a made-up TLD because it resolves to loopback
|
|
122
|
+
* without touching `/etc/hosts` — macOS and systemd-resolved both answer for
|
|
123
|
+
* subdomains of it, and Caddy issues internal certificates for it by default.
|
|
124
|
+
*/
|
|
125
|
+
const LOCAL_TLD = "localhost";
|
|
126
|
+
/**
|
|
127
|
+
* The sites a plan implies, one per file server.
|
|
128
|
+
*
|
|
129
|
+
* The leading label is the *server's* stage-scoped name rather than the
|
|
130
|
+
* bucket's, which is the difference that matters: two servers over one bucket
|
|
131
|
+
* are a legitimate arrangement — two cache behaviours, one origin — and naming
|
|
132
|
+
* the host after the bucket would make them collide.
|
|
133
|
+
*/
|
|
134
|
+
function sitesFor(plan, project, addresses = {}) {
|
|
135
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
136
|
+
const sites = [];
|
|
137
|
+
for (const resource of plan.resources) {
|
|
138
|
+
if (!EDGE_KINDS[resource.kind]) continue;
|
|
139
|
+
const host = hostFor(resource, project);
|
|
140
|
+
if (resource.kind === "file-server") {
|
|
141
|
+
const origin = resource.of ? byId.get(resource.of) : void 0;
|
|
142
|
+
if (!origin) continue;
|
|
143
|
+
sites.push({
|
|
144
|
+
host,
|
|
145
|
+
upstream: "http://minio:9000",
|
|
146
|
+
rewrite: `/${origin.name}{uri}`
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const address = addresses[resource.id];
|
|
151
|
+
if (!address) continue;
|
|
152
|
+
const port = portOf(address);
|
|
153
|
+
if (!port) continue;
|
|
154
|
+
sites.push({
|
|
155
|
+
host,
|
|
156
|
+
upstream: `http://${HOST_GATEWAY}:${port}`
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return sites;
|
|
160
|
+
}
|
|
161
|
+
/** The port a local address answers on, defaulting by scheme. */
|
|
162
|
+
function portOf(address) {
|
|
163
|
+
try {
|
|
164
|
+
const { port, protocol } = new URL(address);
|
|
165
|
+
return port || (protocol === "https:" ? "443" : "80");
|
|
166
|
+
} catch {
|
|
167
|
+
return void 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Where one file server answers, including the stage its name carries.
|
|
172
|
+
*
|
|
173
|
+
* The project is a label rather than a decoration: it is what keeps two
|
|
174
|
+
* checkouts from answering on one address. Omitted when there is no project to
|
|
175
|
+
* name, because an empty label makes `uploads..localhost` — a host nothing
|
|
176
|
+
* resolves and no certificate can be issued for.
|
|
177
|
+
*/
|
|
178
|
+
function hostFor(resource, project) {
|
|
179
|
+
return [
|
|
180
|
+
resource.name,
|
|
181
|
+
project,
|
|
182
|
+
LOCAL_TLD
|
|
183
|
+
].filter(Boolean).join(".");
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The static root config, which only ever imports.
|
|
187
|
+
*
|
|
188
|
+
* Written once and identical for every stage, because the *sites* are what
|
|
189
|
+
* differ and they live in their own files. That split is not tidiness: one
|
|
190
|
+
* container serves every stage — the same way one Postgres holds `orders` and
|
|
191
|
+
* `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
|
|
192
|
+
* routes `gkm dev` is serving, and each command silently breaking the other.
|
|
193
|
+
*/
|
|
194
|
+
function caddyfileRoot() {
|
|
195
|
+
return `${GENERATED}
|
|
196
|
+
{
|
|
197
|
+
# No ACME, no public DNS: the CA is local and so are the names.
|
|
198
|
+
local_certs
|
|
199
|
+
auto_https disable_redirects
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# One file per stage, so two stages share this edge the way they already share
|
|
203
|
+
# one Postgres and one MinIO.
|
|
204
|
+
import /etc/caddy/sites/*.caddy
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* One stage's sites, as the file imported above.
|
|
209
|
+
*
|
|
210
|
+
* `rewrite` rather than a path prefix on the address: the bucket is the
|
|
211
|
+
* origin's business, and the client asks for the key it stored.
|
|
212
|
+
*
|
|
213
|
+
* `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
|
|
214
|
+
* and signs on the Host header, so forwarding the requested hostname would make
|
|
215
|
+
* it look for a bucket named after the domain.
|
|
216
|
+
*/
|
|
217
|
+
function toCaddyfile(sites) {
|
|
218
|
+
if (sites.length === 0) return `${GENERATED}
|
|
219
|
+
# No file server is declared in this stage, so it routes nothing.
|
|
220
|
+
`;
|
|
221
|
+
const blocks = sites.map(({ host, upstream, rewrite }) => `
|
|
222
|
+
https://${host} {
|
|
223
|
+
tls internal
|
|
224
|
+
|
|
225
|
+
reverse_proxy ${upstream} {${rewrite ? `
|
|
226
|
+
rewrite ${rewrite}
|
|
227
|
+
# MinIO routes and signs on the Host header, so forwarding the requested
|
|
228
|
+
# hostname would make it look for a bucket named after the domain.
|
|
229
|
+
header_up Host {upstream_hostport}` : `
|
|
230
|
+
# The application reads whatever address was injected, so it has to see
|
|
231
|
+
# the one a caller used — that is what makes a redirect, a cookie domain
|
|
232
|
+
# and a generated link point back here rather than at the upstream.
|
|
233
|
+
header_up Host {host}
|
|
234
|
+
header_up X-Forwarded-Proto {scheme}`}
|
|
235
|
+
}
|
|
236
|
+
}`);
|
|
237
|
+
return `${GENERATED}${blocks.join("\n")}\n`;
|
|
238
|
+
}
|
|
239
|
+
const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
|
|
240
|
+
#
|
|
241
|
+
# One host per declared file server, in front of the object store that holds its
|
|
242
|
+
# objects. This is the shape a file server has deployed — a domain serving a
|
|
243
|
+
# bucket — which MinIO alone cannot produce: its virtual-host mode reads the
|
|
244
|
+
# leading label as the bucket name.
|
|
245
|
+
#
|
|
246
|
+
# Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
|
|
247
|
+
# root and point Node at it, so nothing has to be installed to use these URLs
|
|
248
|
+
# from code; a browser needs the root trusted once.
|
|
249
|
+
`;
|
|
250
|
+
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/reconcile/clients.ts
|
|
253
|
+
/** The credential local containers are brought up with. */
|
|
254
|
+
const LOCAL_USER$2 = "geekmidas";
|
|
255
|
+
/**
|
|
256
|
+
* A Postgres client that opens one connection per database it is asked about.
|
|
257
|
+
*
|
|
258
|
+
* `CREATE DATABASE` cannot run from a connection to the database being created,
|
|
259
|
+
* and a schema must be created from inside its own database — so the database is
|
|
260
|
+
* part of each query rather than fixed when the client is built.
|
|
261
|
+
*/
|
|
262
|
+
function pgClient(port) {
|
|
263
|
+
return { async query(database, sql, values) {
|
|
264
|
+
const client = new pg.Client({
|
|
265
|
+
host: "localhost",
|
|
266
|
+
port,
|
|
267
|
+
user: LOCAL_USER$2,
|
|
268
|
+
password: LOCAL_USER$2,
|
|
269
|
+
database: database ?? LOCAL_USER$2
|
|
270
|
+
});
|
|
271
|
+
await client.connect();
|
|
272
|
+
try {
|
|
273
|
+
const result = await client.query(sql, values);
|
|
274
|
+
return result.rows;
|
|
275
|
+
} finally {
|
|
276
|
+
await client.end();
|
|
277
|
+
}
|
|
278
|
+
} };
|
|
279
|
+
}
|
|
280
|
+
/** A bucket client pointed at the local MinIO. */
|
|
281
|
+
function bucketClient(port) {
|
|
282
|
+
const s3 = new __aws_sdk_client_s3.S3Client({
|
|
283
|
+
region: "us-east-1",
|
|
284
|
+
endpoint: `http://localhost:${port}`,
|
|
285
|
+
forcePathStyle: true,
|
|
286
|
+
credentials: {
|
|
287
|
+
accessKeyId: LOCAL_USER$2,
|
|
288
|
+
secretAccessKey: LOCAL_USER$2
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
async exists(bucket) {
|
|
293
|
+
try {
|
|
294
|
+
await s3.send(new __aws_sdk_client_s3.HeadBucketCommand({ Bucket: bucket }));
|
|
295
|
+
return true;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
async create(bucket) {
|
|
301
|
+
await s3.send(new __aws_sdk_client_s3.CreateBucketCommand({ Bucket: bucket }));
|
|
302
|
+
},
|
|
303
|
+
async policy(bucket) {
|
|
304
|
+
try {
|
|
305
|
+
const result = await s3.send(new __aws_sdk_client_s3.GetBucketPolicyCommand({ Bucket: bucket }));
|
|
306
|
+
return result.Policy;
|
|
307
|
+
} catch {
|
|
308
|
+
return void 0;
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
async setPolicy(bucket, policy) {
|
|
312
|
+
await s3.send(new __aws_sdk_client_s3.PutBucketPolicyCommand({
|
|
313
|
+
Bucket: bucket,
|
|
314
|
+
Policy: policy
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/reconcile/containers.ts
|
|
322
|
+
/**
|
|
323
|
+
* What gkm knows about each container it can derive.
|
|
324
|
+
*
|
|
325
|
+
* Everything provider-specific about the local target lives here: the image, the
|
|
326
|
+
* ports the image listens on, and whether its data is worth keeping. The plan
|
|
327
|
+
* says *which* containers exist and never says any of this, which is the split
|
|
328
|
+
* the design asks for — neutral facts from the declaration, provider detail
|
|
329
|
+
* from here and from config.
|
|
330
|
+
*
|
|
331
|
+
* Shared by allocation and by the compose writer so the two cannot disagree
|
|
332
|
+
* about how many ports a container needs.
|
|
333
|
+
*/
|
|
334
|
+
/**
|
|
335
|
+
* The Postgres image for a major version.
|
|
336
|
+
*
|
|
337
|
+
* One place that knows the tag shape, so a declared version reaches the
|
|
338
|
+
* container without anyone restating `-alpine` beside it.
|
|
339
|
+
*/
|
|
340
|
+
function postgresImage(version) {
|
|
341
|
+
return `postgres:${version}-alpine`;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The default image for each container.
|
|
345
|
+
*
|
|
346
|
+
* Pinned to a major rather than `latest` where the image publishes one, so a
|
|
347
|
+
* rebuild does not silently move a developer's Postgres a version. Overridable
|
|
348
|
+
* per project: a project needing `postgis/postgis` says so in config.
|
|
349
|
+
*/
|
|
350
|
+
const DEFAULT_IMAGES = {
|
|
351
|
+
postgres: postgresImage(__geekmidas_manifest.DEFAULT_POSTGRES_VERSION),
|
|
352
|
+
minio: "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
|
|
353
|
+
mailpit: "axllent/mailpit:latest",
|
|
354
|
+
redis: "redis:8-alpine",
|
|
355
|
+
"redis-http": "hiett/serverless-redis-http:latest",
|
|
356
|
+
rabbitmq: "rabbitmq:4-management-alpine",
|
|
357
|
+
localstack: "floci/floci:latest",
|
|
358
|
+
caddy: "caddy:2-alpine"
|
|
359
|
+
};
|
|
360
|
+
/**
|
|
361
|
+
* The ports each container listens on.
|
|
362
|
+
*
|
|
363
|
+
* The first is always the one an app connects to; the rest are consoles and
|
|
364
|
+
* management UIs, published because a bucket or an inbox you can look at is most
|
|
365
|
+
* of why these are real containers rather than stubs.
|
|
366
|
+
*/
|
|
367
|
+
const PORTS = {
|
|
368
|
+
postgres: [{
|
|
369
|
+
key: "postgres",
|
|
370
|
+
inside: 5432,
|
|
371
|
+
label: "postgres"
|
|
372
|
+
}],
|
|
373
|
+
minio: [{
|
|
374
|
+
key: "minio",
|
|
375
|
+
inside: 9e3,
|
|
376
|
+
label: "minio api"
|
|
377
|
+
}, {
|
|
378
|
+
key: "minio-console",
|
|
379
|
+
inside: 9001,
|
|
380
|
+
label: "minio console"
|
|
381
|
+
}],
|
|
382
|
+
mailpit: [{
|
|
383
|
+
key: "mailpit",
|
|
384
|
+
inside: 1025,
|
|
385
|
+
label: "smtp"
|
|
386
|
+
}, {
|
|
387
|
+
key: "mailpit-web",
|
|
388
|
+
inside: 8025,
|
|
389
|
+
label: "mailpit inbox"
|
|
390
|
+
}],
|
|
391
|
+
redis: [{
|
|
392
|
+
key: "redis",
|
|
393
|
+
inside: 6379,
|
|
394
|
+
label: "redis"
|
|
395
|
+
}],
|
|
396
|
+
"redis-http": [{
|
|
397
|
+
key: "redis-http",
|
|
398
|
+
inside: 80,
|
|
399
|
+
label: "cache"
|
|
400
|
+
}],
|
|
401
|
+
rabbitmq: [{
|
|
402
|
+
key: "rabbitmq",
|
|
403
|
+
inside: 5672,
|
|
404
|
+
label: "amqp"
|
|
405
|
+
}, {
|
|
406
|
+
key: "rabbitmq-management",
|
|
407
|
+
inside: 15672,
|
|
408
|
+
label: "rabbitmq console"
|
|
409
|
+
}],
|
|
410
|
+
localstack: [{
|
|
411
|
+
key: "localstack",
|
|
412
|
+
inside: 4566,
|
|
413
|
+
label: "localstack"
|
|
414
|
+
}],
|
|
415
|
+
caddy: [{
|
|
416
|
+
key: "caddy",
|
|
417
|
+
inside: 443,
|
|
418
|
+
label: "https edge"
|
|
419
|
+
}]
|
|
420
|
+
};
|
|
421
|
+
/** The ports a container needs published. Empty for one gkm does not know. */
|
|
422
|
+
function portsOf(container) {
|
|
423
|
+
return PORTS[container] ?? [];
|
|
424
|
+
}
|
|
425
|
+
/** The key a container's primary port is allocated under. */
|
|
426
|
+
function primaryPortKey(container) {
|
|
427
|
+
return portsOf(container)[0]?.key ?? container;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Every port key a set of containers needs, in a stable order.
|
|
431
|
+
*
|
|
432
|
+
* What allocation is handed: it assigns ports to names and knows nothing about
|
|
433
|
+
* what listens on them.
|
|
434
|
+
*/
|
|
435
|
+
function portKeys(containers) {
|
|
436
|
+
return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The named volume a container's data lives in.
|
|
440
|
+
*
|
|
441
|
+
* Mailpit deliberately has none: local mail is disposable, and an inbox that
|
|
442
|
+
* survives days of dev is noise rather than state.
|
|
443
|
+
*/
|
|
444
|
+
function volumeOf(container) {
|
|
445
|
+
const volumes = {
|
|
446
|
+
postgres: "postgres-data",
|
|
447
|
+
minio: "minio-data",
|
|
448
|
+
redis: "redis-data",
|
|
449
|
+
rabbitmq: "rabbitmq-data",
|
|
450
|
+
localstack: "localstack-data",
|
|
451
|
+
caddy: "caddy-data"
|
|
452
|
+
};
|
|
453
|
+
return volumes[container];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/reconcile/compose.ts
|
|
458
|
+
/** The credential every derived container is brought up with locally. */
|
|
459
|
+
const LOCAL_USER$1 = "geekmidas";
|
|
460
|
+
/**
|
|
461
|
+
* The token the local cache accepts.
|
|
462
|
+
*
|
|
463
|
+
* Fixed rather than generated: it is only ever reachable on a port this project
|
|
464
|
+
* published, and the URL that carries it is derived, so nothing is made safer
|
|
465
|
+
* by making it unguessable and a stable one keeps `redis-cli` sessions working.
|
|
466
|
+
*/
|
|
467
|
+
const LOCAL_TOKEN$1 = "geekmidas";
|
|
468
|
+
/**
|
|
469
|
+
* Build the compose document for a plan.
|
|
470
|
+
*
|
|
471
|
+
* Every container is published on its assigned port, never on the image's
|
|
472
|
+
* default: the point of allocation is that two projects can run at once, and
|
|
473
|
+
* one container publishing 5432 puts that back.
|
|
474
|
+
*/
|
|
475
|
+
function composeFor(plan, options) {
|
|
476
|
+
const services = {};
|
|
477
|
+
const volumes = {};
|
|
478
|
+
for (const container of [...plan.containers].sort()) {
|
|
479
|
+
const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
|
|
480
|
+
const image = options.images?.[container] ?? (declared ? postgresImage(declared) : DEFAULT_IMAGES[container]);
|
|
481
|
+
if (!image) throw new UnknownContainer(container);
|
|
482
|
+
services[container] = define(container, image, publish(container, options.ports));
|
|
483
|
+
const volume = volumeOf(container);
|
|
484
|
+
if (volume) volumes[volume] = {};
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
name: options.project,
|
|
488
|
+
services,
|
|
489
|
+
...Object.keys(volumes).length ? { volumes } : {}
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/** Render a compose document as the file it becomes. */
|
|
493
|
+
function toYaml(file) {
|
|
494
|
+
return `# Generated by gkm from the construct manifest — do not edit.
|
|
495
|
+
# Containers are derived from what the app declares; images and anything no
|
|
496
|
+
# construct implies are yours, in gkm.config.ts.
|
|
497
|
+
#
|
|
498
|
+
# Use "gkm dev" or "gkm test" to start these. Running "docker compose up"
|
|
499
|
+
# directly will not inject secrets.
|
|
500
|
+
${(0, yaml.stringify)(file, { lineWidth: 0 })}`;
|
|
501
|
+
}
|
|
502
|
+
/** A container with no image, from config or from the defaults. */
|
|
503
|
+
var UnknownContainer = class extends Error {
|
|
504
|
+
/** The container that could not be resolved. */
|
|
505
|
+
container;
|
|
506
|
+
/** The containers that have a default, so the caller can see the set. */
|
|
507
|
+
known = Object.keys(DEFAULT_IMAGES).sort();
|
|
508
|
+
constructor(container) {
|
|
509
|
+
super("No image is known for this container; pin one in config");
|
|
510
|
+
this.name = "UnknownContainer";
|
|
511
|
+
this.container = container;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
/** One container's definition, published on the port it was assigned. */
|
|
515
|
+
function define(container, image, published) {
|
|
516
|
+
switch (container) {
|
|
517
|
+
case "postgres": return {
|
|
518
|
+
image,
|
|
519
|
+
restart: "unless-stopped",
|
|
520
|
+
ports: published,
|
|
521
|
+
environment: {
|
|
522
|
+
POSTGRES_USER: LOCAL_USER$1,
|
|
523
|
+
POSTGRES_PASSWORD: LOCAL_USER$1,
|
|
524
|
+
POSTGRES_DB: LOCAL_USER$1
|
|
525
|
+
},
|
|
526
|
+
volumes: ["postgres-data:/var/lib/postgresql"],
|
|
527
|
+
healthcheck: {
|
|
528
|
+
test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER$1}`],
|
|
529
|
+
interval: "5s",
|
|
530
|
+
timeout: "3s",
|
|
531
|
+
retries: 10,
|
|
532
|
+
start_period: "10s"
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
case "minio": return {
|
|
536
|
+
image,
|
|
537
|
+
restart: "unless-stopped",
|
|
538
|
+
ports: published,
|
|
539
|
+
environment: {
|
|
540
|
+
MINIO_ROOT_USER: LOCAL_USER$1,
|
|
541
|
+
MINIO_ROOT_PASSWORD: LOCAL_USER$1
|
|
542
|
+
},
|
|
543
|
+
command: "server --console-address \":9001\" /data",
|
|
544
|
+
volumes: ["minio-data:/data"],
|
|
545
|
+
healthcheck: {
|
|
546
|
+
test: [
|
|
547
|
+
"CMD",
|
|
548
|
+
"mc",
|
|
549
|
+
"ready",
|
|
550
|
+
"local"
|
|
551
|
+
],
|
|
552
|
+
interval: "10s",
|
|
553
|
+
timeout: "5s",
|
|
554
|
+
retries: 5,
|
|
555
|
+
start_period: "10s"
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
case "caddy": return {
|
|
559
|
+
image,
|
|
560
|
+
restart: "unless-stopped",
|
|
561
|
+
ports: published,
|
|
562
|
+
volumes: [
|
|
563
|
+
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
|
564
|
+
"./caddy-sites:/etc/caddy/sites:ro",
|
|
565
|
+
"caddy-data:/data"
|
|
566
|
+
],
|
|
567
|
+
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
568
|
+
healthcheck: {
|
|
569
|
+
test: [
|
|
570
|
+
"CMD",
|
|
571
|
+
"caddy",
|
|
572
|
+
"version"
|
|
573
|
+
],
|
|
574
|
+
interval: "10s",
|
|
575
|
+
timeout: "5s",
|
|
576
|
+
retries: 5
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
case "mailpit": return {
|
|
580
|
+
image,
|
|
581
|
+
restart: "unless-stopped",
|
|
582
|
+
ports: published,
|
|
583
|
+
environment: {
|
|
584
|
+
MP_SMTP_AUTH_ACCEPT_ANY: "1",
|
|
585
|
+
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
|
|
586
|
+
},
|
|
587
|
+
healthcheck: {
|
|
588
|
+
test: [
|
|
589
|
+
"CMD",
|
|
590
|
+
"wget",
|
|
591
|
+
"-q",
|
|
592
|
+
"--spider",
|
|
593
|
+
"http://localhost:8025"
|
|
594
|
+
],
|
|
595
|
+
interval: "10s",
|
|
596
|
+
timeout: "5s",
|
|
597
|
+
retries: 5
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
case "redis": return {
|
|
601
|
+
image,
|
|
602
|
+
restart: "unless-stopped",
|
|
603
|
+
ports: published,
|
|
604
|
+
volumes: ["redis-data:/data"],
|
|
605
|
+
healthcheck: {
|
|
606
|
+
test: [
|
|
607
|
+
"CMD",
|
|
608
|
+
"redis-cli",
|
|
609
|
+
"ping"
|
|
610
|
+
],
|
|
611
|
+
interval: "5s",
|
|
612
|
+
timeout: "3s",
|
|
613
|
+
retries: 5
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
case "redis-http": return {
|
|
617
|
+
image,
|
|
618
|
+
restart: "unless-stopped",
|
|
619
|
+
ports: published,
|
|
620
|
+
environment: {
|
|
621
|
+
SRH_MODE: "env",
|
|
622
|
+
SRH_TOKEN: LOCAL_TOKEN$1,
|
|
623
|
+
SRH_CONNECTION_STRING: "redis://redis:6379"
|
|
624
|
+
},
|
|
625
|
+
depends_on: ["redis"],
|
|
626
|
+
healthcheck: {
|
|
627
|
+
test: [
|
|
628
|
+
"CMD",
|
|
629
|
+
"wget",
|
|
630
|
+
"-q",
|
|
631
|
+
"-O",
|
|
632
|
+
"/dev/null",
|
|
633
|
+
"http://127.0.0.1:80/"
|
|
634
|
+
],
|
|
635
|
+
interval: "5s",
|
|
636
|
+
timeout: "3s",
|
|
637
|
+
retries: 10
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
case "rabbitmq": return {
|
|
641
|
+
image,
|
|
642
|
+
restart: "unless-stopped",
|
|
643
|
+
ports: published,
|
|
644
|
+
environment: {
|
|
645
|
+
RABBITMQ_DEFAULT_USER: LOCAL_USER$1,
|
|
646
|
+
RABBITMQ_DEFAULT_PASS: LOCAL_USER$1
|
|
647
|
+
},
|
|
648
|
+
volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
|
|
649
|
+
healthcheck: {
|
|
650
|
+
test: [
|
|
651
|
+
"CMD",
|
|
652
|
+
"rabbitmq-diagnostics",
|
|
653
|
+
"-q",
|
|
654
|
+
"ping"
|
|
655
|
+
],
|
|
656
|
+
interval: "10s",
|
|
657
|
+
timeout: "5s",
|
|
658
|
+
retries: 5
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
case "localstack": return {
|
|
662
|
+
image,
|
|
663
|
+
restart: "unless-stopped",
|
|
664
|
+
ports: published,
|
|
665
|
+
environment: {
|
|
666
|
+
AWS_DEFAULT_REGION: "us-east-1",
|
|
667
|
+
AWS_ACCESS_KEY_ID: "LSIAQAAAAAAVNCBMPNSG",
|
|
668
|
+
AWS_SECRET_ACCESS_KEY: LOCAL_USER$1
|
|
669
|
+
},
|
|
670
|
+
volumes: ["localstack-data:/var/lib/localstack"],
|
|
671
|
+
healthcheck: {
|
|
672
|
+
test: [
|
|
673
|
+
"CMD",
|
|
674
|
+
"curl",
|
|
675
|
+
"-f",
|
|
676
|
+
"http://localhost:4566/_localstack/health"
|
|
677
|
+
],
|
|
678
|
+
interval: "10s",
|
|
679
|
+
timeout: "5s",
|
|
680
|
+
retries: 5,
|
|
681
|
+
start_period: "10s"
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
default: throw new UnknownContainer(container);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* The published port mappings for a container.
|
|
689
|
+
*
|
|
690
|
+
* Every port is looked up by its own key rather than being derived as an offset
|
|
691
|
+
* from the container's first, so MinIO's console cannot land on whatever was
|
|
692
|
+
* allocated to the next container.
|
|
693
|
+
*/
|
|
694
|
+
function publish(container, ports) {
|
|
695
|
+
return portsOf(container).map((port) => {
|
|
696
|
+
const assigned = ports[port.key];
|
|
697
|
+
if (assigned === void 0) throw new UnassignedPort(port.key);
|
|
698
|
+
return `${assigned}:${port.inside}`;
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
/** A planned port that allocation never assigned. */
|
|
702
|
+
var UnassignedPort = class extends Error {
|
|
703
|
+
/** The port key that went unassigned, e.g. `minio-console`. */
|
|
704
|
+
key;
|
|
705
|
+
constructor(key) {
|
|
706
|
+
super("No port was assigned for this container port");
|
|
707
|
+
this.name = "UnassignedPort";
|
|
708
|
+
this.key = key;
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
//#endregion
|
|
713
|
+
//#region src/reconcile/docker.ts
|
|
714
|
+
const run = (0, node_util.promisify)(node_child_process.execFile);
|
|
715
|
+
/** How long to wait for containers to pass their health checks. */
|
|
716
|
+
const HEALTH_TIMEOUT_MS = 12e4;
|
|
717
|
+
const dockerCli = {
|
|
718
|
+
async publishedPort(composePath, service, inside) {
|
|
719
|
+
try {
|
|
720
|
+
const { stdout } = await run("docker", [
|
|
721
|
+
"compose",
|
|
722
|
+
"-f",
|
|
723
|
+
composePath,
|
|
724
|
+
"port",
|
|
725
|
+
service,
|
|
726
|
+
String(inside)
|
|
727
|
+
]);
|
|
728
|
+
const port = Number(stdout.trim().split(":").pop());
|
|
729
|
+
return Number.isFinite(port) && port > 0 ? port : void 0;
|
|
730
|
+
} catch {
|
|
731
|
+
return void 0;
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
async up(composePath, services) {
|
|
735
|
+
await run("docker", [
|
|
736
|
+
"compose",
|
|
737
|
+
"-f",
|
|
738
|
+
composePath,
|
|
739
|
+
"up",
|
|
740
|
+
"-d",
|
|
741
|
+
"--remove-orphans",
|
|
742
|
+
"--wait",
|
|
743
|
+
`--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
|
|
744
|
+
...services
|
|
745
|
+
]);
|
|
746
|
+
},
|
|
747
|
+
async copyOut(composePath, service, from, to) {
|
|
748
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(to), { recursive: true });
|
|
749
|
+
await run("docker", [
|
|
750
|
+
"compose",
|
|
751
|
+
"-f",
|
|
752
|
+
composePath,
|
|
753
|
+
"cp",
|
|
754
|
+
`${service}:${from}`,
|
|
755
|
+
to
|
|
756
|
+
]);
|
|
757
|
+
},
|
|
758
|
+
async reload(composePath, service) {
|
|
759
|
+
await run("docker", [
|
|
760
|
+
"compose",
|
|
761
|
+
"-f",
|
|
762
|
+
composePath,
|
|
763
|
+
"exec",
|
|
764
|
+
"-T",
|
|
765
|
+
service,
|
|
766
|
+
"caddy",
|
|
767
|
+
"reload",
|
|
768
|
+
"--config",
|
|
769
|
+
"/etc/caddy/Caddyfile"
|
|
770
|
+
]);
|
|
771
|
+
},
|
|
772
|
+
async healthy(composePath, services) {
|
|
773
|
+
if (services.length === 0) return true;
|
|
774
|
+
try {
|
|
775
|
+
const { stdout } = await run("docker", [
|
|
776
|
+
"compose",
|
|
777
|
+
"-f",
|
|
778
|
+
composePath,
|
|
779
|
+
"ps",
|
|
780
|
+
"--format",
|
|
781
|
+
"json",
|
|
782
|
+
...services
|
|
783
|
+
]);
|
|
784
|
+
const running = /* @__PURE__ */ new Set();
|
|
785
|
+
for (const line of stdout.trim().split("\n").filter(Boolean)) {
|
|
786
|
+
const entry = JSON.parse(line);
|
|
787
|
+
const ok = entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy");
|
|
788
|
+
if (ok && entry.Service) running.add(entry.Service);
|
|
789
|
+
}
|
|
790
|
+
return services.every((service) => running.has(service));
|
|
791
|
+
} catch {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region src/reconcile/plan.ts
|
|
799
|
+
/** The stage whose resources carry no suffix. */
|
|
800
|
+
const DEFAULT_STAGE = "development";
|
|
801
|
+
/**
|
|
802
|
+
* Which container serves a kind.
|
|
803
|
+
*
|
|
804
|
+
* Derived kinds map to the same container as their parent — a schema tenant
|
|
805
|
+
* lives in its parent's Postgres — so the set is deduplicated rather than
|
|
806
|
+
* counted.
|
|
807
|
+
*/
|
|
808
|
+
const CONTAINERS = {
|
|
809
|
+
database: "postgres",
|
|
810
|
+
"database-reader": "postgres",
|
|
811
|
+
"database-schema": "postgres",
|
|
812
|
+
objects: "minio",
|
|
813
|
+
"file-server": "caddy",
|
|
814
|
+
email: "mailpit"
|
|
815
|
+
};
|
|
816
|
+
/**
|
|
817
|
+
* The container a cache backend needs locally.
|
|
818
|
+
*
|
|
819
|
+
* Each one matches the protocol its deployed form speaks, which is the property
|
|
820
|
+
* worth having: a cache that behaves differently in the two places is worse than
|
|
821
|
+
* a slower one.
|
|
822
|
+
*
|
|
823
|
+
* `db` is deliberately absent, the same way `pgboss` is absent from
|
|
824
|
+
* `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
|
|
825
|
+
* declares, so mapping it here would start a container for a project that needs
|
|
826
|
+
* none.
|
|
827
|
+
*/
|
|
828
|
+
const CACHE_CONTAINERS = {
|
|
829
|
+
upstash: "redis-http",
|
|
830
|
+
elasticache: "redis"
|
|
831
|
+
};
|
|
832
|
+
/**
|
|
833
|
+
* Kinds that resolve a value without anything running.
|
|
834
|
+
*
|
|
835
|
+
* A secret is the case: it has no address, so there is nothing to start and
|
|
836
|
+
* nothing to connect to — but it still appears in the plan, because the target
|
|
837
|
+
* still has to resolve it.
|
|
838
|
+
*/
|
|
839
|
+
const CONTAINERLESS = {
|
|
840
|
+
secret: true,
|
|
841
|
+
credential: true,
|
|
842
|
+
"rest-api": true,
|
|
843
|
+
site: true
|
|
844
|
+
};
|
|
845
|
+
/**
|
|
846
|
+
* Containers that cannot run alone.
|
|
847
|
+
*
|
|
848
|
+
* The one case is the cache: `serverless-redis-http` is a proxy and needs the
|
|
849
|
+
* Redis it proxies. Kept here rather than in the compose definition so the plan
|
|
850
|
+
* -- which is what decides what must be running -- still names everything.
|
|
851
|
+
*/
|
|
852
|
+
const REQUIRES = { "redis-http": ["redis"] };
|
|
853
|
+
/**
|
|
854
|
+
* The role each kind's one key plays, where it is not a URL.
|
|
855
|
+
*
|
|
856
|
+
* The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
|
|
857
|
+
* construct declares, this is what the local target resolves, and both derive
|
|
858
|
+
* the key from the same `provideKey` so they cannot disagree.
|
|
859
|
+
*/
|
|
860
|
+
const ROLES = {
|
|
861
|
+
queue: "publisherConnectionString",
|
|
862
|
+
topic: "publisherConnectionString",
|
|
863
|
+
credential: "credential"
|
|
864
|
+
};
|
|
865
|
+
/** The kinds whose container is the events backend's rather than their own. */
|
|
866
|
+
const EVENT_KINDS = {
|
|
867
|
+
queue: true,
|
|
868
|
+
topic: true
|
|
869
|
+
};
|
|
870
|
+
/**
|
|
871
|
+
* Which kinds provision something *inside* their container.
|
|
872
|
+
*
|
|
873
|
+
* A database creates a database, a bucket creates a bucket; mail creates
|
|
874
|
+
* nothing — Mailpit accepts whatever is sent to it and has no per-stage object
|
|
875
|
+
* to name. Both still appear in the plan, because both still resolve a URL; the
|
|
876
|
+
* flag is the difference between "must be running" and "must be created".
|
|
877
|
+
*/
|
|
878
|
+
const PROVISIONS = {
|
|
879
|
+
database: true,
|
|
880
|
+
"database-reader": true,
|
|
881
|
+
"database-schema": true,
|
|
882
|
+
objects: true,
|
|
883
|
+
cache: true,
|
|
884
|
+
"file-server": true
|
|
885
|
+
};
|
|
886
|
+
/**
|
|
887
|
+
* The container each events backend needs, where it needs one of its own.
|
|
888
|
+
*
|
|
889
|
+
* `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
|
|
890
|
+
* schema tenant in a database the manifest already declares, so mapping it here
|
|
891
|
+
* would start a Postgres for a project that declared no database at all.
|
|
892
|
+
*
|
|
893
|
+
* This one comes from config rather than from a declaration because there are
|
|
894
|
+
* no `topic` or `queue` kinds yet. When they land the mapping moves to
|
|
895
|
+
* {@link CONTAINERS} keyed by kind, and this constant goes with them.
|
|
896
|
+
*/
|
|
897
|
+
const EVENT_CONTAINERS = {
|
|
898
|
+
sns: "localstack",
|
|
899
|
+
rabbitmq: "rabbitmq"
|
|
900
|
+
};
|
|
901
|
+
/** The backend a project gets when it declares queues or topics and says nothing. */
|
|
902
|
+
const DEFAULT_EVENTS = "pgboss";
|
|
903
|
+
/**
|
|
904
|
+
* How a resource of each kind separates its stage suffix.
|
|
905
|
+
*
|
|
906
|
+
* Postgres identifiers are snake_case and a hyphen would need quoting
|
|
907
|
+
* everywhere; buckets and queues are kebab and disallow underscores in some
|
|
908
|
+
* providers. The suffix is the same word either way.
|
|
909
|
+
*/
|
|
910
|
+
const SEPARATORS = {
|
|
911
|
+
database: "_",
|
|
912
|
+
"database-reader": "_",
|
|
913
|
+
"database-schema": "_",
|
|
914
|
+
objects: "-"
|
|
915
|
+
};
|
|
916
|
+
/**
|
|
917
|
+
* The stage-scoped name for a resource.
|
|
918
|
+
*
|
|
919
|
+
* The default stage carries no suffix, so `gkm dev` keeps using `orders` and a
|
|
920
|
+
* developer's psql history, saved connections, and muscle memory survive this
|
|
921
|
+
* change. Every other stage is suffixed.
|
|
922
|
+
*/
|
|
923
|
+
function resourceName(id, kind, stage) {
|
|
924
|
+
const base = id.toLowerCase();
|
|
925
|
+
if (stage === DEFAULT_STAGE) return base;
|
|
926
|
+
return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* The container a kind needs, or `undefined` if it needs none.
|
|
930
|
+
*
|
|
931
|
+
* Queues and topics have no container of their own — they live in whichever
|
|
932
|
+
* broker the project selected, which is why the backend is an argument here
|
|
933
|
+
* rather than a second pass that adds containers after the fact.
|
|
934
|
+
*/
|
|
935
|
+
function containerFor(kind, events = DEFAULT_EVENTS, cache = require_discover.DEFAULT_CACHE.aws, derived = false, edge = true) {
|
|
936
|
+
if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
|
|
937
|
+
if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
|
|
938
|
+
if (kind === "cache") {
|
|
939
|
+
if (derived) return CONTAINERS.database;
|
|
940
|
+
return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
|
|
941
|
+
}
|
|
942
|
+
return CONTAINERS[kind];
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Build the plan for a manifest and stage.
|
|
946
|
+
*
|
|
947
|
+
* `order` is the caller's provisioning order — parents before children — which
|
|
948
|
+
* `provisionOrder` produces from the manifest. Passing it in keeps this
|
|
949
|
+
* function free of the traversal and makes the ordering assertable on its own.
|
|
950
|
+
*/
|
|
951
|
+
function planFor(manifest, stage, order, options = {}) {
|
|
952
|
+
const containers = /* @__PURE__ */ new Set();
|
|
953
|
+
const resources = [];
|
|
954
|
+
const events = options.events ?? DEFAULT_EVENTS;
|
|
955
|
+
const cache = options.cache ?? require_discover.DEFAULT_CACHE.aws;
|
|
956
|
+
for (const id of order) {
|
|
957
|
+
const declaration = manifest[id];
|
|
958
|
+
if (!declaration) continue;
|
|
959
|
+
const container = containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
|
|
960
|
+
if (!container && !CONTAINERLESS[declaration.kind]) continue;
|
|
961
|
+
if (container) {
|
|
962
|
+
containers.add(container);
|
|
963
|
+
for (const required of REQUIRES[container] ?? []) containers.add(required);
|
|
964
|
+
}
|
|
965
|
+
resources.push({
|
|
966
|
+
id,
|
|
967
|
+
kind: declaration.kind,
|
|
968
|
+
...container ? { container } : {},
|
|
969
|
+
name: resourceName(id, declaration.kind, stage),
|
|
970
|
+
envKey: (0, __geekmidas_manifest.providedKeyFor)(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
|
|
971
|
+
provisions: PROVISIONS[declaration.kind] === true,
|
|
972
|
+
...declaration.kind === "rest-api" ? { callers: (0, __geekmidas_manifest.dependentsOf)(manifest, id) } : {},
|
|
973
|
+
...declaration.kind === "site" ? { publicEnv: (0, __geekmidas_manifest.publicEnvFor)(declaration, manifest) } : {},
|
|
974
|
+
...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
|
|
975
|
+
..."of" in declaration ? { of: declaration.of } : {},
|
|
976
|
+
..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
|
|
977
|
+
..."version" in declaration && declaration.version ? { version: declaration.version } : {},
|
|
978
|
+
..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
|
|
979
|
+
..."table" in declaration && declaration.table ? { table: declaration.table } : {}
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
|
|
983
|
+
for (const extra of options.extraContainers ?? []) containers.add(extra);
|
|
984
|
+
if (events === "pgboss" && resources.some((r) => EVENT_KINDS[r.kind]) && !resources.some((r) => r.kind === "database")) throw new PgBossNeedsDatabase(resources.filter((r) => EVENT_KINDS[r.kind]).map((r) => r.id));
|
|
985
|
+
if (cache === "db") {
|
|
986
|
+
const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
|
|
987
|
+
if (placeless.length > 0) {
|
|
988
|
+
const databases = resources.filter((r) => r.kind === "database");
|
|
989
|
+
const [only] = databases;
|
|
990
|
+
if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
|
|
991
|
+
if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
|
|
992
|
+
for (const resource of placeless) resource.of = only.id;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
stage,
|
|
997
|
+
events,
|
|
998
|
+
cache,
|
|
999
|
+
containers: [...containers],
|
|
1000
|
+
resources
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Queues or topics were declared with pg-boss and no database to hold them.
|
|
1005
|
+
*
|
|
1006
|
+
* Not a reconcile failure but a manifest one: the fix is a line of application
|
|
1007
|
+
* code either way — declare a database, or select a backend that brings its own
|
|
1008
|
+
* broker.
|
|
1009
|
+
*/
|
|
1010
|
+
var CacheNeedsDatabase = class extends Error {
|
|
1011
|
+
constructor(ids) {
|
|
1012
|
+
super(`A cache backed by the database needs a declared database, and none was declared. Declare one, or set services.cache to 'upstash' or 'elasticache'. Caches affected: ${ids.join(", ")}.`);
|
|
1013
|
+
this.ids = ids;
|
|
1014
|
+
this.name = "CacheNeedsDatabase";
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
/**
|
|
1018
|
+
* `services.cache: 'db'` in an app that declares more than one database.
|
|
1019
|
+
*
|
|
1020
|
+
* The config says *a* database and the graph offers several, so there is no
|
|
1021
|
+
* answer to give — and the wrong kind of failure would be to pick one, since a
|
|
1022
|
+
* cache silently landing in the wrong database is a bug that surfaces as
|
|
1023
|
+
* missing rows much later. The fix is a stronger statement in application code:
|
|
1024
|
+
* `orders.cache('Sessions')` names the database, which is a fact about the
|
|
1025
|
+
* application rather than a deployment choice.
|
|
1026
|
+
*/
|
|
1027
|
+
var CacheIsAmbiguous = class extends Error {
|
|
1028
|
+
constructor(ids, databases) {
|
|
1029
|
+
super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database — e.g. ${databases[0]}.cache('Sessions') — rather than with services.cache. Caches affected: ${ids.join(", ")}.`);
|
|
1030
|
+
this.ids = ids;
|
|
1031
|
+
this.databases = databases;
|
|
1032
|
+
this.name = "CacheIsAmbiguous";
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
var PgBossNeedsDatabase = class extends Error {
|
|
1036
|
+
constructor(ids) {
|
|
1037
|
+
super(`pg-boss keeps its queues in a declared database, and none was declared. Declare one, or set services.events to 'rabbitmq' or 'sns'. Queues and topics affected: ${ids.join(", ")}.`);
|
|
1038
|
+
this.ids = ids;
|
|
1039
|
+
this.name = "PgBossNeedsDatabase";
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
//#endregion
|
|
1044
|
+
//#region src/reconcile/env.ts
|
|
1045
|
+
/**
|
|
1046
|
+
* The credential local containers are brought up with.
|
|
1047
|
+
*
|
|
1048
|
+
* The design's `roles: false` case — both URLs fall back to the cluster's master
|
|
1049
|
+
* credential. A deliberate downgrade, not a default: it holds until the role DDL
|
|
1050
|
+
* lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
|
|
1051
|
+
* and the owner URL stops being reachable from any manifest edge.
|
|
1052
|
+
*/
|
|
1053
|
+
const LOCAL_USER = "geekmidas";
|
|
1054
|
+
/** The host containers are published on. */
|
|
1055
|
+
const LOCAL_HOST = "localhost";
|
|
1056
|
+
/** The region local buckets claim, so the client has one to sign with. */
|
|
1057
|
+
const LOCAL_REGION = "us-east-1";
|
|
1058
|
+
/**
|
|
1059
|
+
* The credentials the S3 client resolves for MinIO.
|
|
1060
|
+
*
|
|
1061
|
+
* An `s3://` URL deliberately carries none: deployed, the SDK reads them from
|
|
1062
|
+
* the execution role, so a URL that embedded a key would be one more thing to
|
|
1063
|
+
* rotate and one more thing to leak into a log. Locally there is no role, and
|
|
1064
|
+
* the same chain reads these — which is why they are injected beside the URL
|
|
1065
|
+
* rather than written into it.
|
|
1066
|
+
*/
|
|
1067
|
+
const STORAGE_CREDENTIALS = {
|
|
1068
|
+
AWS_ACCESS_KEY_ID: LOCAL_USER,
|
|
1069
|
+
AWS_SECRET_ACCESS_KEY: LOCAL_USER,
|
|
1070
|
+
AWS_REGION: LOCAL_REGION
|
|
1071
|
+
};
|
|
1072
|
+
/** The token the local cache proxy accepts. Matches the compose definition. */
|
|
1073
|
+
const LOCAL_TOKEN = "geekmidas";
|
|
1074
|
+
/** The schema pg-boss keeps its tables in, inside the declared database. */
|
|
1075
|
+
const PGBOSS_SCHEMA = "pgboss";
|
|
1076
|
+
/** The exchange every local topic and queue shares on RabbitMQ. */
|
|
1077
|
+
const RABBITMQ_EXCHANGE = "geekmidas.events";
|
|
1078
|
+
/**
|
|
1079
|
+
* Every env key a reconciled plan resolves, and its value.
|
|
1080
|
+
*
|
|
1081
|
+
* Keys come from the declaration rather than being re-derived, so what the build
|
|
1082
|
+
* publishes and what the app reads cannot drift.
|
|
1083
|
+
*/
|
|
1084
|
+
function envFor(plan, options) {
|
|
1085
|
+
const env = {};
|
|
1086
|
+
const resolved = {};
|
|
1087
|
+
for (const resource of plan.resources) {
|
|
1088
|
+
const url = urlFor(resource, plan, options.ports, options.project ?? "", options.addresses);
|
|
1089
|
+
if (url) resolved[resource.id] = url;
|
|
1090
|
+
}
|
|
1091
|
+
for (const resource of plan.resources) {
|
|
1092
|
+
const url = resolved[resource.id];
|
|
1093
|
+
if (resource.kind === "rest-api") Object.assign(env, surfaceEnv(resource, url, resolved));
|
|
1094
|
+
if (url) env[resource.envKey] = url;
|
|
1095
|
+
if (isDatabase(resource.kind) && resource.roles !== false) {
|
|
1096
|
+
const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
|
|
1097
|
+
if (owner) env[(0, __geekmidas_manifest.provideKey)(resource.id, "ownerUrl")] = owner;
|
|
1098
|
+
}
|
|
1099
|
+
if (resource.kind === "email" && url) env[(0, __geekmidas_manifest.provideKey)(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
|
|
1100
|
+
}
|
|
1101
|
+
Object.assign(env, publicEnv(plan, env));
|
|
1102
|
+
Object.assign(env, brokerEnv(plan, options.ports));
|
|
1103
|
+
if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
|
|
1104
|
+
return env;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* The values a site's bundler inlines, under the names it inlines them by.
|
|
1108
|
+
*
|
|
1109
|
+
* A rename and nothing more. `API_URL` was resolved once, by the same code that
|
|
1110
|
+
* resolved it for the server; a site reads the same value under `VITE_API_URL`
|
|
1111
|
+
* because that prefix is how its bundler is told to ship it. Nothing is derived
|
|
1112
|
+
* twice, so a site and its API cannot come to disagree about where the API is.
|
|
1113
|
+
*
|
|
1114
|
+
* A source that resolved to nothing is skipped rather than written empty: an
|
|
1115
|
+
* inlined empty string is a frontend that builds and then fails at runtime
|
|
1116
|
+
* against `http:///`, where a missing variable fails at build with the name of
|
|
1117
|
+
* the thing that is missing.
|
|
1118
|
+
*/
|
|
1119
|
+
function publicEnv(plan, resolved) {
|
|
1120
|
+
const env = {};
|
|
1121
|
+
for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
|
|
1122
|
+
const value = resolved[source];
|
|
1123
|
+
if (value) env[key] = value;
|
|
1124
|
+
}
|
|
1125
|
+
return env;
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* What a surface publishes beyond its own address: who may call it, and where
|
|
1129
|
+
* a cookie set by it is readable.
|
|
1130
|
+
*
|
|
1131
|
+
* Both are read off the same list — the constructs that declared an edge to
|
|
1132
|
+
* this surface — which is why neither appears in application code. A surface
|
|
1133
|
+
* that listed its own callers would be edited every time something new called
|
|
1134
|
+
* it, and the thing being edited is already recorded in the graph.
|
|
1135
|
+
*
|
|
1136
|
+
* An empty origin list is written, and a surface with no address writes nothing
|
|
1137
|
+
* at all. The two cases look alike and are not: "nothing depends on this yet" is
|
|
1138
|
+
* a real state a target should publish, while "this surface has no address here"
|
|
1139
|
+
* means it is not running in this stage, and keys belong with the address they
|
|
1140
|
+
* describe.
|
|
1141
|
+
*/
|
|
1142
|
+
function surfaceEnv(resource, url, resolved = {}) {
|
|
1143
|
+
if (!url) return {};
|
|
1144
|
+
const origins = [...new Set((resource.callers ?? []).map((caller) => resolved[caller]).filter((address) => Boolean(address)).map(originOf).filter((origin) => Boolean(origin)))].sort();
|
|
1145
|
+
const domain = (0, __geekmidas_manifest.cookieDomain)([url, ...origins]);
|
|
1146
|
+
return {
|
|
1147
|
+
[(0, __geekmidas_manifest.provideKey)(resource.id, "trustedOrigins")]: origins.join(","),
|
|
1148
|
+
...domain ? { [(0, __geekmidas_manifest.provideKey)(resource.id, "cookieDomain")]: domain } : {}
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
/** An address reduced to the origin a browser compares against. */
|
|
1152
|
+
function originOf(address) {
|
|
1153
|
+
try {
|
|
1154
|
+
return new URL(address).origin;
|
|
1155
|
+
} catch {
|
|
1156
|
+
return void 0;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* The runtime role of whatever a derived node hangs off.
|
|
1161
|
+
*
|
|
1162
|
+
* A reader's parent may itself be a schema tenant, so this follows `of` by one
|
|
1163
|
+
* hop rather than assuming the cluster — the reader of a tenant reads that
|
|
1164
|
+
* tenant's schema, not the database's.
|
|
1165
|
+
*/
|
|
1166
|
+
function parentRole(resource, plan) {
|
|
1167
|
+
const parent = plan.resources.find((r) => r.id === resource.of);
|
|
1168
|
+
return parent ? localRole(parent) : resource.name;
|
|
1169
|
+
}
|
|
1170
|
+
/** Whether a kind connects to Postgres and therefore has an owner role. */
|
|
1171
|
+
function isDatabase(kind) {
|
|
1172
|
+
return kind === "database" || kind === "database-schema";
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* The URL a migrator connects with — the owner role, which can create, alter
|
|
1176
|
+
* and drop.
|
|
1177
|
+
*
|
|
1178
|
+
* Never in `provides`, which is the whole point. A handler that could reach this
|
|
1179
|
+
* could `DROP TABLE`, and the security property the role split exists for is
|
|
1180
|
+
* that it cannot: the value is injected by `gkm exec` for the migrate step and
|
|
1181
|
+
* by nothing else.
|
|
1182
|
+
*/
|
|
1183
|
+
function ownerUrl(resource, plan, ports, project) {
|
|
1184
|
+
if (!resource.container) return void 0;
|
|
1185
|
+
const port = ports[primaryPortKey(resource.container)];
|
|
1186
|
+
if (port === void 0) return void 0;
|
|
1187
|
+
const owner = (0, __geekmidas_db_pg_roles.ownerRole)(localRole(resource));
|
|
1188
|
+
return postgres(port, rootDatabase(resource, plan), {
|
|
1189
|
+
user: owner,
|
|
1190
|
+
password: localRolePassword(project, plan, owner)
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* The shared broker keys, when anything is published or consumed.
|
|
1195
|
+
*
|
|
1196
|
+
* A queue's own key is the producer's. These two are the *connection* itself,
|
|
1197
|
+
* which locally is one broker for every queue and topic in the project: the
|
|
1198
|
+
* generated pollers open a single connection and subscribe each worker by name
|
|
1199
|
+
* on it. Deployed there is no such thing — a Lambda is handed its own event
|
|
1200
|
+
* source — so this pair exists for the local target and says so.
|
|
1201
|
+
*/
|
|
1202
|
+
function brokerEnv(plan, ports) {
|
|
1203
|
+
const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
|
|
1204
|
+
if (!carrier) return {};
|
|
1205
|
+
const publisher = urlFor(carrier, plan, ports, "");
|
|
1206
|
+
if (!publisher) return {};
|
|
1207
|
+
return {
|
|
1208
|
+
EVENT_PUBLISHER_CONNECTION_STRING: publisher,
|
|
1209
|
+
EVENT_SUBSCRIBER_CONNECTION_STRING: plan.events === "sns" ? publisher.replace(/^sns:/, "sqs:") : publisher
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* The URL for one planned resource.
|
|
1214
|
+
*
|
|
1215
|
+
* Every part of it comes from the resource and the port it was published on.
|
|
1216
|
+
*/
|
|
1217
|
+
function urlFor(resource, plan, ports, project, addresses = {}) {
|
|
1218
|
+
if (resource.kind === "secret") return localSecret(project, plan, resource);
|
|
1219
|
+
if (resource.kind === "credential") return void 0;
|
|
1220
|
+
if (resource.kind === "rest-api" || resource.kind === "site") {
|
|
1221
|
+
const address = addresses[resource.id];
|
|
1222
|
+
const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
|
|
1223
|
+
return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
|
|
1224
|
+
}
|
|
1225
|
+
if (!resource.container) return void 0;
|
|
1226
|
+
const port = ports[primaryPortKey(resource.container)];
|
|
1227
|
+
if (port === void 0) return void 0;
|
|
1228
|
+
switch (resource.kind) {
|
|
1229
|
+
case "database":
|
|
1230
|
+
case "database-schema":
|
|
1231
|
+
case "database-reader": {
|
|
1232
|
+
const database = rootDatabase(resource, plan);
|
|
1233
|
+
if (resource.roles === false) {
|
|
1234
|
+
const schema = schemaOf(resource, plan);
|
|
1235
|
+
return schema ? `${postgres(port, database)}?search_path=${schema}` : postgres(port, database);
|
|
1236
|
+
}
|
|
1237
|
+
const role = resource.kind === "database-reader" ? (0, __geekmidas_db_pg_roles.readerRole)(parentRole(resource, plan)) : localRole(resource);
|
|
1238
|
+
return postgres(port, database, {
|
|
1239
|
+
user: role,
|
|
1240
|
+
password: localRolePassword(project, plan, role)
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
case "email": return `smtp://${LOCAL_HOST}:${port}`;
|
|
1244
|
+
case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
|
|
1245
|
+
case "file-server": {
|
|
1246
|
+
const origin = plan.resources.find((r) => r.id === resource.of);
|
|
1247
|
+
if (!origin) return void 0;
|
|
1248
|
+
return plan.containers.includes("caddy") ? `https://${hostFor(resource, project)}:${port}` : `http://${LOCAL_HOST}:${port}/${origin.name}`;
|
|
1249
|
+
}
|
|
1250
|
+
case "cache":
|
|
1251
|
+
if (resource.of) {
|
|
1252
|
+
const parent = plan.resources.find((r) => r.id === resource.of);
|
|
1253
|
+
const url = parent ? urlFor(parent, plan, ports, project, addresses) : void 0;
|
|
1254
|
+
return url ? withTable(url, resource.table ?? (0, __geekmidas_manifest.cacheTable)(resource.id)) : void 0;
|
|
1255
|
+
}
|
|
1256
|
+
switch (plan.cache) {
|
|
1257
|
+
case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
|
|
1258
|
+
default: return `http://:${LOCAL_TOKEN}@${LOCAL_HOST}:${port}`;
|
|
1259
|
+
}
|
|
1260
|
+
case "queue":
|
|
1261
|
+
case "topic": return broker(plan, port);
|
|
1262
|
+
default: return void 0;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* The producer's connection string, composed from the backend the plan chose.
|
|
1267
|
+
*
|
|
1268
|
+
* The protocol is what picks the transport, so a producer never branches: the
|
|
1269
|
+
* same `.publish()` reaches pg-boss here and SQS deployed because the string it
|
|
1270
|
+
* was handed said so.
|
|
1271
|
+
*/
|
|
1272
|
+
function broker(plan, port) {
|
|
1273
|
+
switch (plan.events) {
|
|
1274
|
+
case "pgboss": {
|
|
1275
|
+
const database = plan.resources.find((r) => r.kind === "database");
|
|
1276
|
+
if (!database) throw new PgBossNeedsDatabase([]);
|
|
1277
|
+
return `pgboss://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
|
|
1278
|
+
}
|
|
1279
|
+
case "rabbitmq": return `rabbitmq://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
|
|
1280
|
+
case "sns": throw new UnprovisionedEventsBackend("sns");
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/** A local Postgres URL, master credential and all. */
|
|
1284
|
+
function postgres(port, database, credential = {
|
|
1285
|
+
user: LOCAL_USER,
|
|
1286
|
+
password: LOCAL_USER
|
|
1287
|
+
}) {
|
|
1288
|
+
return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* The role a handler connects as, for one database or tenant.
|
|
1292
|
+
*
|
|
1293
|
+
* The construct's id, lowercased and stage-scoped exactly as its database or
|
|
1294
|
+
* schema is — so `Orders` in the `test` stage connects as `orders_test`, and two
|
|
1295
|
+
* stages sharing one cluster cannot share a credential. Roles are cluster-scoped
|
|
1296
|
+
* in Postgres, which is why the suffix is not optional here the way it is for a
|
|
1297
|
+
* schema.
|
|
1298
|
+
*/
|
|
1299
|
+
function localRole(resource) {
|
|
1300
|
+
return resource.name;
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* The password for a derived local role.
|
|
1304
|
+
*
|
|
1305
|
+
* Derived rather than random, for the same reason the local secret is: a
|
|
1306
|
+
* password that changed on every `gkm dev` would lock a developer out of the
|
|
1307
|
+
* data they had a moment ago. Seeded by the project so two checkouts do not
|
|
1308
|
+
* share one, and by the stage so `test` and `development` do not.
|
|
1309
|
+
*
|
|
1310
|
+
* It is a *local* credential by construction — the cluster is on loopback and
|
|
1311
|
+
* the seed is a checkout path — and no deployed target uses this function.
|
|
1312
|
+
*/
|
|
1313
|
+
function localRolePassword(project, plan, role) {
|
|
1314
|
+
return (0, node_crypto.createHash)("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* A cache's address: its database's URL, carrying the table it reads.
|
|
1318
|
+
*
|
|
1319
|
+
* Appended rather than kept beside the URL as a second key, for the reason the
|
|
1320
|
+
* design gives everywhere else — an address and what it opens are one fact, and
|
|
1321
|
+
* two keys are one more thing to keep in step.
|
|
1322
|
+
*/
|
|
1323
|
+
function withTable(url, table) {
|
|
1324
|
+
const parsed = new URL(url);
|
|
1325
|
+
parsed.searchParams.set("table", table);
|
|
1326
|
+
return parsed.toString();
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* The physical database a derived resource lives in.
|
|
1330
|
+
*
|
|
1331
|
+
* Walks `of` to the top rather than reading the immediate parent, because a
|
|
1332
|
+
* reader's parent may itself be a schema tenant — two hops from the database
|
|
1333
|
+
* that actually exists.
|
|
1334
|
+
*/
|
|
1335
|
+
function rootDatabase(resource, plan) {
|
|
1336
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1337
|
+
let current = resource;
|
|
1338
|
+
while (current.of) {
|
|
1339
|
+
const parent = byId.get(current.of);
|
|
1340
|
+
if (!parent) break;
|
|
1341
|
+
current = parent;
|
|
1342
|
+
}
|
|
1343
|
+
return current.name;
|
|
1344
|
+
}
|
|
1345
|
+
/** The schema a derived resource puts on its search path, if it has one. */
|
|
1346
|
+
function schemaOf(resource, plan) {
|
|
1347
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1348
|
+
let current = resource;
|
|
1349
|
+
while (current) {
|
|
1350
|
+
if (current.kind === "database-schema") return current.schema;
|
|
1351
|
+
current = current.of ? byId.get(current.of) : void 0;
|
|
1352
|
+
}
|
|
1353
|
+
return void 0;
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* A backend whose local addresses cannot be derived yet.
|
|
1357
|
+
*
|
|
1358
|
+
* SNS and SQS are addressed by ARN, which means the topic and queue have to be
|
|
1359
|
+
* created in LocalStack before a URL can name them. Until that provisioning
|
|
1360
|
+
* lands, saying so is better than handing out a string that fails on first use.
|
|
1361
|
+
*/
|
|
1362
|
+
var UnprovisionedEventsBackend = class extends Error {
|
|
1363
|
+
constructor(backend) {
|
|
1364
|
+
super(`The local target cannot derive addresses for '${backend}' yet — its topics and queues are named by ARN, which nothing creates locally. Use 'pgboss' (the default) or 'rabbitmq' for local development.`);
|
|
1365
|
+
this.backend = backend;
|
|
1366
|
+
this.name = "UnprovisionedEventsBackend";
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
/**
|
|
1370
|
+
* The value a secret resolves to locally.
|
|
1371
|
+
*
|
|
1372
|
+
* A hash of what identifies it, so it is stable, distinct per project, stage
|
|
1373
|
+
* and construct, and never written to disk as a literal. Deployed this is the
|
|
1374
|
+
* one kind whose value does *not* come from here — a secret manager generates
|
|
1375
|
+
* it once and the target reads it back.
|
|
1376
|
+
*/
|
|
1377
|
+
function localSecret(project, plan, resource) {
|
|
1378
|
+
return (0, node_crypto.createHash)("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/reconcile/ports.ts
|
|
1383
|
+
/**
|
|
1384
|
+
* The window ports are drawn from.
|
|
1385
|
+
*
|
|
1386
|
+
* Above the registered range so nothing here needs privileges, and below the
|
|
1387
|
+
* ephemeral range both Linux (32768+) and macOS (49152+) allocate from — a port
|
|
1388
|
+
* the kernel might hand to an outgoing connection is one that is free when
|
|
1389
|
+
* checked and taken by the time it is bound.
|
|
1390
|
+
*/
|
|
1391
|
+
const WINDOW = {
|
|
1392
|
+
min: 2e4,
|
|
1393
|
+
max: 32e3
|
|
1394
|
+
};
|
|
1395
|
+
/**
|
|
1396
|
+
* How far apart two projects start.
|
|
1397
|
+
*
|
|
1398
|
+
* Wide enough that a project can add containers without walking into its
|
|
1399
|
+
* neighbour's block, narrow enough that the window holds many projects.
|
|
1400
|
+
*/
|
|
1401
|
+
const BLOCK = 50;
|
|
1402
|
+
/**
|
|
1403
|
+
* Where a project's ports start.
|
|
1404
|
+
*
|
|
1405
|
+
* Derived from the name rather than assigned sequentially so that a fresh clone
|
|
1406
|
+
* of the same project on the same machine lands on the same block — the first
|
|
1407
|
+
* run has nothing persisted to read, and hashing is what keeps that first run
|
|
1408
|
+
* from colliding with whatever else is already allocated.
|
|
1409
|
+
*
|
|
1410
|
+
* @example startingPort('toolbox') // stable for a given name
|
|
1411
|
+
*/
|
|
1412
|
+
function startingPort(project) {
|
|
1413
|
+
const digest = (0, node_crypto.createHash)("sha256").update(project).digest();
|
|
1414
|
+
const blocks = Math.floor((WINDOW.max - WINDOW.min) / BLOCK);
|
|
1415
|
+
return WINDOW.min + digest.readUInt32BE(0) % blocks * BLOCK;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Assign a port to every container that has none, keeping the ones that do.
|
|
1419
|
+
*
|
|
1420
|
+
* Existing assignments are returned untouched even when the container is no
|
|
1421
|
+
* longer in the plan: removing a construct must not renumber what is left, and
|
|
1422
|
+
* keeping the entry means re-adding it later restores the same port rather than
|
|
1423
|
+
* inventing a new one.
|
|
1424
|
+
*
|
|
1425
|
+
* @param probe - reports what is actually listening. A port assigned to this
|
|
1426
|
+
* project is skipped without probing, because on a converged reconcile our own
|
|
1427
|
+
* container is the thing listening on it.
|
|
1428
|
+
* @throws {NoPortAvailable} when the window is exhausted.
|
|
1429
|
+
*/
|
|
1430
|
+
async function allocate(project, containers, existing = {}, probe = isPortFree) {
|
|
1431
|
+
const assignments = { ...existing };
|
|
1432
|
+
const taken = new Set(Object.values(assignments));
|
|
1433
|
+
for (const container of [...containers].sort()) {
|
|
1434
|
+
if (assignments[container] !== void 0) continue;
|
|
1435
|
+
const port = await nextFree(startingPort(project), taken, probe);
|
|
1436
|
+
assignments[container] = port;
|
|
1437
|
+
taken.add(port);
|
|
1438
|
+
}
|
|
1439
|
+
return assignments;
|
|
1440
|
+
}
|
|
1441
|
+
/** The window was exhausted. */
|
|
1442
|
+
var NoPortAvailable = class extends Error {
|
|
1443
|
+
/** Where the search started. */
|
|
1444
|
+
from;
|
|
1445
|
+
/** The window searched, so the caller can report what was full. */
|
|
1446
|
+
window = WINDOW;
|
|
1447
|
+
constructor(from) {
|
|
1448
|
+
super("No free port is available in the local port range");
|
|
1449
|
+
this.name = "NoPortAvailable";
|
|
1450
|
+
this.from = from;
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
/**
|
|
1454
|
+
* The first port at or after `from` that is neither ours nor in use.
|
|
1455
|
+
*
|
|
1456
|
+
* Wraps at the top of the window rather than stopping, so a project whose block
|
|
1457
|
+
* sits near the end is not the one that fails.
|
|
1458
|
+
*/
|
|
1459
|
+
async function nextFree(from, taken, probe) {
|
|
1460
|
+
const span = WINDOW.max - WINDOW.min;
|
|
1461
|
+
for (let offset = 0; offset < span; offset++) {
|
|
1462
|
+
const port = WINDOW.min + (from - WINDOW.min + offset) % span;
|
|
1463
|
+
if (taken.has(port)) continue;
|
|
1464
|
+
if (await probe(port)) return port;
|
|
1465
|
+
}
|
|
1466
|
+
throw new NoPortAvailable(from);
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Whether nothing is listening on a port.
|
|
1470
|
+
*
|
|
1471
|
+
* Binds rather than connects: a refused connection says nothing about whether
|
|
1472
|
+
* the port can be *published*, which is the question Docker will ask. Binds on
|
|
1473
|
+
* `0.0.0.0` for the same reason — a port free on loopback can still be taken on
|
|
1474
|
+
* the address Docker publishes to.
|
|
1475
|
+
*
|
|
1476
|
+
* Inherently racy, and that is acceptable: Docker failing to bind stays the real
|
|
1477
|
+
* check. This exists so the common case surfaces as a message at reconcile
|
|
1478
|
+
* rather than as a connection failure ten minutes later.
|
|
1479
|
+
*/
|
|
1480
|
+
function isPortFree(port) {
|
|
1481
|
+
return new Promise((resolve$1) => {
|
|
1482
|
+
const server = (0, node_net.createServer)();
|
|
1483
|
+
server.once("error", () => resolve$1(false));
|
|
1484
|
+
server.once("listening", () => server.close(() => resolve$1(true)));
|
|
1485
|
+
server.listen(port, "0.0.0.0");
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
//#endregion
|
|
1490
|
+
//#region src/reconcile/provision.ts
|
|
1491
|
+
/**
|
|
1492
|
+
* The Postgres work a plan implies.
|
|
1493
|
+
*
|
|
1494
|
+
* Databases before the schemas inside them, which `provisionOrder` already
|
|
1495
|
+
* guarantees by putting parents first.
|
|
1496
|
+
*/
|
|
1497
|
+
function postgresStatements(plan, project = "") {
|
|
1498
|
+
const statements = [];
|
|
1499
|
+
for (const resource of plan.resources) {
|
|
1500
|
+
if (!resource.provisions || resource.container !== "postgres") continue;
|
|
1501
|
+
if (resource.kind === "database") {
|
|
1502
|
+
statements.push({
|
|
1503
|
+
id: resource.id,
|
|
1504
|
+
describe: `database ${resource.name}`,
|
|
1505
|
+
exists: {
|
|
1506
|
+
sql: "SELECT 1 FROM pg_database WHERE datname = $1",
|
|
1507
|
+
values: [resource.name]
|
|
1508
|
+
},
|
|
1509
|
+
create: `CREATE DATABASE ${quoteIdentifier(resource.name)}`
|
|
1510
|
+
});
|
|
1511
|
+
if (resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project));
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
if (resource.kind === "database-schema" && resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project));
|
|
1515
|
+
const cacheHome = resource.kind === "cache" && resource.of ? plan.resources.find((r) => r.id === resource.of) : void 0;
|
|
1516
|
+
if (cacheHome) statements.push(...cacheTableDdl(resource, cacheHome, plan));
|
|
1517
|
+
}
|
|
1518
|
+
return statements;
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* A cache's table, in the schema the role that reads it resolves names in.
|
|
1522
|
+
*
|
|
1523
|
+
* Two things have to line up, and neither is the table's contents. The driver
|
|
1524
|
+
* names the table unqualified and lets the connection's `search_path` place it
|
|
1525
|
+
* — which the role carries — so creating it from the master connection, whose
|
|
1526
|
+
* path is `public`, leaves a table the application cannot see and a
|
|
1527
|
+
* `relation "cache" does not exist` on the first request. And the default
|
|
1528
|
+
* privileges that make a tenant's tables reachable are granted *for the owner
|
|
1529
|
+
* role*, so nothing covers a table the master created.
|
|
1530
|
+
*
|
|
1531
|
+
* Hence: qualified with the tenant's schema, handed to its owner, and granted
|
|
1532
|
+
* explicitly — `roleStatements` grants what exists at the time it runs, and
|
|
1533
|
+
* this table does not exist yet.
|
|
1534
|
+
*
|
|
1535
|
+
* A database that opted out of roles needs none of it: everything connects as
|
|
1536
|
+
* the master, whose `search_path` finds `public`, and the table it creates
|
|
1537
|
+
* there is the table it reads.
|
|
1538
|
+
*/
|
|
1539
|
+
function cacheTableDdl(cache, home, plan) {
|
|
1540
|
+
const database = rootDatabase(home, plan);
|
|
1541
|
+
const schema = home.roles === false ? void 0 : home.schema;
|
|
1542
|
+
const name = cache.table ?? (0, __geekmidas_manifest.cacheTable)(cache.id);
|
|
1543
|
+
const table = schema ? `${schema}.${name}` : name;
|
|
1544
|
+
const created = (0, __geekmidas_cache_postgres.cacheTableStatements)({ table }).map((statement) => ({
|
|
1545
|
+
id: cache.id,
|
|
1546
|
+
describe: statement.describe,
|
|
1547
|
+
database,
|
|
1548
|
+
...statement.exists ? { exists: statement.exists } : {},
|
|
1549
|
+
create: statement.sql
|
|
1550
|
+
}));
|
|
1551
|
+
if (!schema) return created;
|
|
1552
|
+
const runtime = localRole(home);
|
|
1553
|
+
const owner = (0, __geekmidas_db_pg_roles.ownerRole)(runtime);
|
|
1554
|
+
const qualified = table.split(".").map((part) => quoteIdentifier(part)).join(".");
|
|
1555
|
+
return [
|
|
1556
|
+
...created,
|
|
1557
|
+
{
|
|
1558
|
+
id: cache.id,
|
|
1559
|
+
describe: `cache table ${table} is owned by ${owner}`,
|
|
1560
|
+
database,
|
|
1561
|
+
create: `ALTER TABLE ${qualified} OWNER TO ${quoteIdentifier(owner)}`
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
id: cache.id,
|
|
1565
|
+
describe: `${runtime} may read and write ${table}`,
|
|
1566
|
+
database,
|
|
1567
|
+
create: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${qualified} TO ${quoteIdentifier(runtime)}`
|
|
1568
|
+
}
|
|
1569
|
+
];
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* The roles and schema for one database or tenant.
|
|
1573
|
+
*
|
|
1574
|
+
* Skipped entirely when the construct declared `roles: false` — the documented
|
|
1575
|
+
* downgrade, where both URLs fall back to the cluster's master credential. It
|
|
1576
|
+
* is a choice someone made rather than a default they got.
|
|
1577
|
+
*/
|
|
1578
|
+
function rolesFor(resource, schema, plan, project) {
|
|
1579
|
+
if (resource.roles === false) return [];
|
|
1580
|
+
const runtime = localRole(resource);
|
|
1581
|
+
const owner = (0, __geekmidas_db_pg_roles.ownerRole)(runtime);
|
|
1582
|
+
const reader = plan.resources.some((r) => r.kind === "database-reader" && r.of === resource.id) ? (0, __geekmidas_db_pg_roles.readerRole)(runtime) : void 0;
|
|
1583
|
+
return (0, __geekmidas_db_pg_roles.roleStatements)({
|
|
1584
|
+
runtime,
|
|
1585
|
+
owner,
|
|
1586
|
+
...reader ? { reader } : {},
|
|
1587
|
+
schema,
|
|
1588
|
+
passwords: {
|
|
1589
|
+
runtime: localRolePassword(project, plan, runtime),
|
|
1590
|
+
owner: localRolePassword(project, plan, owner),
|
|
1591
|
+
...reader ? { reader: localRolePassword(project, plan, reader) } : {}
|
|
1592
|
+
}
|
|
1593
|
+
}).map((statement) => ({
|
|
1594
|
+
id: resource.id,
|
|
1595
|
+
describe: statement.describe,
|
|
1596
|
+
database: rootDatabase(resource, plan),
|
|
1597
|
+
...statement.exists ? { exists: statement.exists } : {},
|
|
1598
|
+
create: statement.sql
|
|
1599
|
+
}));
|
|
1600
|
+
}
|
|
1601
|
+
/** The buckets a plan implies. */
|
|
1602
|
+
function bucketNames(plan) {
|
|
1603
|
+
return plan.resources.filter((r) => r.provisions && r.kind === "objects").map((r) => r.name);
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Quote an identifier for use in DDL.
|
|
1607
|
+
*
|
|
1608
|
+
* Names reaching here are already canonical and stage-suffixed, so this is
|
|
1609
|
+
* defence rather than sanitisation — but DDL cannot be parameterised, and an
|
|
1610
|
+
* unquoted identifier is the one place a name could ever be more than a name.
|
|
1611
|
+
*/
|
|
1612
|
+
function quoteIdentifier(name) {
|
|
1613
|
+
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Apply the plan's Postgres statements.
|
|
1617
|
+
*
|
|
1618
|
+
* Each is checked before it is run, so this converges rather than erroring on a
|
|
1619
|
+
* second pass.
|
|
1620
|
+
*/
|
|
1621
|
+
async function applyPostgres(client, statements) {
|
|
1622
|
+
const applied = [];
|
|
1623
|
+
for (const statement of statements) {
|
|
1624
|
+
if (!statement.exists) {
|
|
1625
|
+
await client.query(statement.database, statement.create);
|
|
1626
|
+
applied.push({
|
|
1627
|
+
id: statement.id,
|
|
1628
|
+
describe: statement.describe,
|
|
1629
|
+
created: false
|
|
1630
|
+
});
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
const rows = await client.query(statement.database, statement.exists.sql, statement.exists.values);
|
|
1634
|
+
if (rows.length > 0) {
|
|
1635
|
+
applied.push({
|
|
1636
|
+
id: statement.id,
|
|
1637
|
+
describe: statement.describe,
|
|
1638
|
+
created: false
|
|
1639
|
+
});
|
|
1640
|
+
continue;
|
|
1641
|
+
}
|
|
1642
|
+
await client.query(statement.database, statement.create);
|
|
1643
|
+
applied.push({
|
|
1644
|
+
id: statement.id,
|
|
1645
|
+
describe: statement.describe,
|
|
1646
|
+
created: true
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
return applied;
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* What each bucket's open paths are, if anything serves it.
|
|
1653
|
+
*
|
|
1654
|
+
* Keyed by the *origin*, because that is where the policy lives: two servers
|
|
1655
|
+
* over one bucket contribute to one policy, so the patterns are unioned rather
|
|
1656
|
+
* than the last one winning.
|
|
1657
|
+
*/
|
|
1658
|
+
function bucketPolicies(plan) {
|
|
1659
|
+
const byBucket = /* @__PURE__ */ new Map();
|
|
1660
|
+
for (const resource of plan.resources) {
|
|
1661
|
+
if (resource.kind !== "file-server" || !resource.open?.length) continue;
|
|
1662
|
+
const origin = plan.resources.find((r) => r.id === resource.of);
|
|
1663
|
+
if (!origin) continue;
|
|
1664
|
+
const patterns = byBucket.get(origin.name) ?? /* @__PURE__ */ new Set();
|
|
1665
|
+
for (const pattern of resource.open) patterns.add(pattern);
|
|
1666
|
+
byBucket.set(origin.name, patterns);
|
|
1667
|
+
}
|
|
1668
|
+
return [...byBucket].map(([bucket, open]) => ({
|
|
1669
|
+
bucket,
|
|
1670
|
+
open: [...open].sort()
|
|
1671
|
+
})).sort((a, b) => a.bucket.localeCompare(b.bucket));
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* One bucket's open paths as an S3 bucket policy.
|
|
1675
|
+
*
|
|
1676
|
+
* Anonymous `s3:GetObject` on the named prefixes and nothing else — which is
|
|
1677
|
+
* what "open" means everywhere: the server serves that path without a
|
|
1678
|
+
* signature, and the bucket stays private for everything else.
|
|
1679
|
+
*
|
|
1680
|
+
* One fidelity note, stated rather than discovered: an S3 policy resource's `*`
|
|
1681
|
+
* crosses `/`, so a single-star pattern is *wider* here than the construct's own
|
|
1682
|
+
* runtime check, which stops at a segment boundary. The construct is the
|
|
1683
|
+
* stricter of the two, so a key it refuses to serve unsigned is never one this
|
|
1684
|
+
* policy was relied on to refuse — but a key fetched directly, bypassing the
|
|
1685
|
+
* client, can be admitted by the policy where the client would have said no.
|
|
1686
|
+
* Prefer `**` where crossing segments is what you meant.
|
|
1687
|
+
*/
|
|
1688
|
+
function bucketPolicy(bucket, open) {
|
|
1689
|
+
return JSON.stringify({
|
|
1690
|
+
Version: "2012-10-17",
|
|
1691
|
+
Statement: [{
|
|
1692
|
+
Sid: "GkmOpenPaths",
|
|
1693
|
+
Effect: "Allow",
|
|
1694
|
+
Principal: { AWS: ["*"] },
|
|
1695
|
+
Action: ["s3:GetObject"],
|
|
1696
|
+
Resource: open.map((pattern) => `arn:aws:s3:::${bucket}/${pattern.replace(/\*\*/g, "*")}`)
|
|
1697
|
+
}]
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Apply each served bucket's open-path policy, skipping those already correct.
|
|
1702
|
+
*
|
|
1703
|
+
* Compared as parsed JSON rather than as text: the same policy re-serialised
|
|
1704
|
+
* with different key order is the same policy, and rewriting it every reconcile
|
|
1705
|
+
* would report a change on every start.
|
|
1706
|
+
*/
|
|
1707
|
+
async function applyPolicies(client, policies) {
|
|
1708
|
+
const applied = [];
|
|
1709
|
+
for (const { bucket, open } of policies) {
|
|
1710
|
+
const wanted = bucketPolicy(bucket, open);
|
|
1711
|
+
const describe = `open paths on ${bucket}`;
|
|
1712
|
+
if (equivalent(await client.policy(bucket), wanted)) {
|
|
1713
|
+
applied.push({
|
|
1714
|
+
id: `${bucket}:policy`,
|
|
1715
|
+
describe,
|
|
1716
|
+
created: false
|
|
1717
|
+
});
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
await client.setPolicy(bucket, wanted);
|
|
1721
|
+
applied.push({
|
|
1722
|
+
id: `${bucket}:policy`,
|
|
1723
|
+
describe,
|
|
1724
|
+
created: true
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
return applied;
|
|
1728
|
+
}
|
|
1729
|
+
/** Whether two policy documents say the same thing. */
|
|
1730
|
+
function equivalent(current, wanted) {
|
|
1731
|
+
if (!current) return false;
|
|
1732
|
+
try {
|
|
1733
|
+
return JSON.stringify(JSON.parse(current)) === JSON.stringify(JSON.parse(wanted));
|
|
1734
|
+
} catch {
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
/** Create every bucket the plan names, skipping those that exist. */
|
|
1739
|
+
async function applyBuckets(client, buckets) {
|
|
1740
|
+
const applied = [];
|
|
1741
|
+
for (const bucket of buckets) {
|
|
1742
|
+
if (await client.exists(bucket)) {
|
|
1743
|
+
applied.push({
|
|
1744
|
+
id: bucket,
|
|
1745
|
+
describe: `bucket ${bucket}`,
|
|
1746
|
+
created: false
|
|
1747
|
+
});
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
await client.create(bucket);
|
|
1751
|
+
applied.push({
|
|
1752
|
+
id: bucket,
|
|
1753
|
+
describe: `bucket ${bucket}`,
|
|
1754
|
+
created: true
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
return applied;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
//#endregion
|
|
1761
|
+
//#region src/reconcile/state.ts
|
|
1762
|
+
const STATE_DIR = ".gkm/reconcile";
|
|
1763
|
+
/**
|
|
1764
|
+
* The hash of everything a reconcile would act on.
|
|
1765
|
+
*
|
|
1766
|
+
* Covers the compose document rather than the plan alone, because the plan is
|
|
1767
|
+
* unchanged when only an image pin moves — and a changed image is exactly a
|
|
1768
|
+
* container that must be recreated. `stringify` is stable here because
|
|
1769
|
+
* `composeFor` sorts its containers, which is what makes the hash meaningful.
|
|
1770
|
+
*
|
|
1771
|
+
* Deliberately a plain function over serialisable input: the deployed target
|
|
1772
|
+
* keys its provisioner invocation on the same idea, and the two must not be able
|
|
1773
|
+
* to disagree about what counts as a change.
|
|
1774
|
+
*/
|
|
1775
|
+
function planHash(plan, compose, generated = {}) {
|
|
1776
|
+
return (0, node_crypto.createHash)("sha256").update(JSON.stringify({
|
|
1777
|
+
stage: plan.stage,
|
|
1778
|
+
plan,
|
|
1779
|
+
compose,
|
|
1780
|
+
generated
|
|
1781
|
+
})).digest("hex");
|
|
1782
|
+
}
|
|
1783
|
+
/** Read the recorded state, or nothing if this stage has never reconciled. */
|
|
1784
|
+
async function loadState(root, stage) {
|
|
1785
|
+
try {
|
|
1786
|
+
const raw = await (0, node_fs_promises.readFile)(statePath(root, stage), "utf-8");
|
|
1787
|
+
const state = JSON.parse(raw);
|
|
1788
|
+
return state.stage === stage ? state : void 0;
|
|
1789
|
+
} catch {
|
|
1790
|
+
return void 0;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
/** Record what was applied, so the next run can skip it. */
|
|
1794
|
+
async function saveState(root, state) {
|
|
1795
|
+
const path = statePath(root, state.stage);
|
|
1796
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
|
|
1797
|
+
await (0, node_fs_promises.writeFile)(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
1798
|
+
}
|
|
1799
|
+
function statePath(root, stage) {
|
|
1800
|
+
return (0, node_path.join)(root, STATE_DIR, `${stage}.json`);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
//#endregion
|
|
1804
|
+
//#region src/reconcile/index.ts
|
|
1805
|
+
/** Where the generated compose file is written, relative to the project root. */
|
|
1806
|
+
const COMPOSE_PATH = ".gkm/docker-compose.yml";
|
|
1807
|
+
/**
|
|
1808
|
+
* The local edge's config, beside the compose file that mounts it.
|
|
1809
|
+
*
|
|
1810
|
+
* Relative in the compose volume, so it resolves against `.gkm/` wherever the
|
|
1811
|
+
* project lives.
|
|
1812
|
+
*/
|
|
1813
|
+
const CADDYFILE_PATH = ".gkm/Caddyfile";
|
|
1814
|
+
/**
|
|
1815
|
+
* Where one stage's routes live, imported by the file above.
|
|
1816
|
+
*
|
|
1817
|
+
* Per stage because one edge serves every stage, the same way one Postgres
|
|
1818
|
+
* holds `orders` and `orders_test`. A single file would mean `gkm test`
|
|
1819
|
+
* deleting the routes `gkm dev` is serving.
|
|
1820
|
+
*/
|
|
1821
|
+
const caddySitesPath = (stage) => `.gkm/caddy-sites/${stage}.caddy`;
|
|
1822
|
+
/**
|
|
1823
|
+
* Where the local edge's root certificate is copied to.
|
|
1824
|
+
*
|
|
1825
|
+
* Exported so a process can be pointed at it without installing anything:
|
|
1826
|
+
* `NODE_EXTRA_CA_CERTS` is the whole of the trust story for Node, and it needs
|
|
1827
|
+
* no sudo. A browser is the case that still wants the root in the system store,
|
|
1828
|
+
* which is a one-time `caddy trust` rather than something a reconcile should do
|
|
1829
|
+
* on a developer's behalf.
|
|
1830
|
+
*/
|
|
1831
|
+
const LOCAL_CA_PATH = ".gkm/caddy-root.crt";
|
|
1832
|
+
/** Where Caddy keeps the root of the CA it generated. */
|
|
1833
|
+
const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
|
|
1834
|
+
/**
|
|
1835
|
+
* Converge the local target on what the manifest declares.
|
|
1836
|
+
*
|
|
1837
|
+
* Cheap when there is nothing to do: the hash is compared before the compose
|
|
1838
|
+
* file is written or a container is touched, and a converged project pays one
|
|
1839
|
+
* hash and one health check.
|
|
1840
|
+
*/
|
|
1841
|
+
async function reconcile(options) {
|
|
1842
|
+
const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient } = options;
|
|
1843
|
+
const plan = planFor(manifest, stage, (0, __geekmidas_manifest.provisionOrder)(manifest), {
|
|
1844
|
+
events: options.events,
|
|
1845
|
+
cache: options.cache,
|
|
1846
|
+
extraContainers: options.extraContainers,
|
|
1847
|
+
...options.edge === void 0 ? {} : { edge: options.edge }
|
|
1848
|
+
});
|
|
1849
|
+
const composePath = (0, node_path.join)(root, COMPOSE_PATH);
|
|
1850
|
+
const observed = await observedPorts(docker, composePath, plan.containers);
|
|
1851
|
+
const ports = await allocate(project, portKeys(plan.containers), {
|
|
1852
|
+
...options.saved,
|
|
1853
|
+
...observed
|
|
1854
|
+
}, probe);
|
|
1855
|
+
const compose = composeFor(plan, {
|
|
1856
|
+
project,
|
|
1857
|
+
ports,
|
|
1858
|
+
...options.images ? { images: options.images } : {}
|
|
1859
|
+
});
|
|
1860
|
+
const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
|
|
1861
|
+
const hash = planHash(plan, compose, { caddyfile });
|
|
1862
|
+
const addresses = addressesFor(plan.containers, ports);
|
|
1863
|
+
const env = envFor(plan, {
|
|
1864
|
+
ports,
|
|
1865
|
+
project,
|
|
1866
|
+
...options.mailFrom ? { mailFrom: options.mailFrom } : {},
|
|
1867
|
+
...options.addresses ? { addresses: options.addresses } : {}
|
|
1868
|
+
});
|
|
1869
|
+
if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = (0, node_path.join)(root, LOCAL_CA_PATH);
|
|
1870
|
+
const result = {
|
|
1871
|
+
stage,
|
|
1872
|
+
plan,
|
|
1873
|
+
compose,
|
|
1874
|
+
ports,
|
|
1875
|
+
addresses,
|
|
1876
|
+
env,
|
|
1877
|
+
provisioned: [],
|
|
1878
|
+
hash,
|
|
1879
|
+
changed: false
|
|
1880
|
+
};
|
|
1881
|
+
const recorded = await loadState(root, stage);
|
|
1882
|
+
const converged = recorded?.hash === hash && (!start || await docker.healthy(composePath, plan.containers));
|
|
1883
|
+
if (converged) return result;
|
|
1884
|
+
await write(composePath, toYaml(compose));
|
|
1885
|
+
if (plan.containers.includes("caddy")) {
|
|
1886
|
+
await write((0, node_path.join)(root, CADDYFILE_PATH), caddyfileRoot());
|
|
1887
|
+
await write((0, node_path.join)(root, caddySitesPath(stage)), caddyfile);
|
|
1888
|
+
}
|
|
1889
|
+
if (start && plan.containers.length > 0) await docker.up(composePath, plan.containers);
|
|
1890
|
+
if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
|
|
1891
|
+
if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, (0, node_path.join)(root, LOCAL_CA_PATH)).catch(() => {});
|
|
1892
|
+
const provisioned = start && provision ? await create(plan, ports, sql, buckets, project) : [];
|
|
1893
|
+
await saveState(root, {
|
|
1894
|
+
hash,
|
|
1895
|
+
stage
|
|
1896
|
+
});
|
|
1897
|
+
return {
|
|
1898
|
+
...result,
|
|
1899
|
+
provisioned,
|
|
1900
|
+
changed: true
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Create the databases, schemas, and buckets the plan names.
|
|
1905
|
+
*
|
|
1906
|
+
* Idempotent throughout, so this runs on every non-converged reconcile rather
|
|
1907
|
+
* than being something to remember.
|
|
1908
|
+
*/
|
|
1909
|
+
async function create(plan, ports, sql, buckets, project) {
|
|
1910
|
+
const applied = [];
|
|
1911
|
+
const postgresPort = ports[primaryPortKey("postgres")];
|
|
1912
|
+
const statements = postgresStatements(plan, project);
|
|
1913
|
+
if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort), statements));
|
|
1914
|
+
const minioPort = ports[primaryPortKey("minio")];
|
|
1915
|
+
const names = bucketNames(plan);
|
|
1916
|
+
const policies = bucketPolicies(plan);
|
|
1917
|
+
if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
|
|
1918
|
+
const client = buckets(minioPort);
|
|
1919
|
+
if (names.length > 0) applied.push(...await applyBuckets(client, names));
|
|
1920
|
+
if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
|
|
1921
|
+
}
|
|
1922
|
+
return applied;
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* The host ports of containers already running.
|
|
1926
|
+
*
|
|
1927
|
+
* Keyed the same way allocation keys them, so the two merge without either
|
|
1928
|
+
* knowing about the other.
|
|
1929
|
+
*/
|
|
1930
|
+
async function observedPorts(docker, composePath, containers) {
|
|
1931
|
+
const observed = {};
|
|
1932
|
+
for (const container of containers) for (const port of portsOf(container)) {
|
|
1933
|
+
const running = await docker.publishedPort(composePath, container, port.inside);
|
|
1934
|
+
if (running !== void 0) observed[port.key] = running;
|
|
1935
|
+
}
|
|
1936
|
+
return observed;
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Where each container can be reached on this machine.
|
|
1940
|
+
*
|
|
1941
|
+
* The primary port only — a container's console is for a human to open, not
|
|
1942
|
+
* something an app connects to.
|
|
1943
|
+
*/
|
|
1944
|
+
function addressesFor(containers, ports) {
|
|
1945
|
+
const addresses = {};
|
|
1946
|
+
for (const container of containers) {
|
|
1947
|
+
const port = ports[primaryPortKey(container)];
|
|
1948
|
+
if (port !== void 0) addresses[container] = `localhost:${port}`;
|
|
1949
|
+
}
|
|
1950
|
+
return addresses;
|
|
1951
|
+
}
|
|
1952
|
+
/** Write a file only when its content would change, so mtimes stay meaningful. */
|
|
1953
|
+
async function write(path, content) {
|
|
1954
|
+
try {
|
|
1955
|
+
if (await (0, node_fs_promises.readFile)(path, "utf-8") === content) return;
|
|
1956
|
+
} catch {}
|
|
1957
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
|
|
1958
|
+
await (0, node_fs_promises.writeFile)(path, content);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
//#endregion
|
|
1962
|
+
//#region src/reconcile/workspace.ts
|
|
1963
|
+
/**
|
|
1964
|
+
* Every constructs glob in the workspace, resolved against its app.
|
|
1965
|
+
*
|
|
1966
|
+
* Absolute because discovery globs from a single cwd while apps live in
|
|
1967
|
+
* different directories.
|
|
1968
|
+
*/
|
|
1969
|
+
function constructGlobs(workspace) {
|
|
1970
|
+
return require_workspace.allConstructGlobs(workspace);
|
|
1971
|
+
}
|
|
1972
|
+
/** Whether any app has adopted the constructs glob. */
|
|
1973
|
+
function usesConstructs(workspace) {
|
|
1974
|
+
return constructGlobs(workspace).length > 0;
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Image pins, by container. The config half of the derived/config split.
|
|
1978
|
+
*
|
|
1979
|
+
* Read from `services.images` rather than from the backend keys beside it.
|
|
1980
|
+
* They used to share one key, which is how `cache: true` came to mean "start a
|
|
1981
|
+
* Redis" — the last way a container could exist because config asked for one
|
|
1982
|
+
* instead of because something declared it.
|
|
1983
|
+
*/
|
|
1984
|
+
function imagePins(workspace) {
|
|
1985
|
+
return { ...workspace.services.images };
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1988
|
+
* The containers a workspace's declarations imply, without reconciling.
|
|
1989
|
+
*
|
|
1990
|
+
* The same derivation `reconcileWorkspace` makes, available to callers that
|
|
1991
|
+
* need to know *what would run* without starting anything or writing ports —
|
|
1992
|
+
* `gkm setup` deciding which credentials to generate, `gkm docker` writing a
|
|
1993
|
+
* compose file. Those used to each read a boolean per service out of config,
|
|
1994
|
+
* which is how a container could exist because config asked for one rather than
|
|
1995
|
+
* because something declared it.
|
|
1996
|
+
*/
|
|
1997
|
+
async function derivedContainers(workspace, stage, manifest) {
|
|
1998
|
+
const found = manifest ?? await require_discover.discover({
|
|
1999
|
+
patterns: constructGlobs(workspace),
|
|
2000
|
+
cwd: workspace.root
|
|
2001
|
+
});
|
|
2002
|
+
return planFor(found, stage, (0, __geekmidas_manifest.provisionOrder)(found), {
|
|
2003
|
+
events: workspace.services.events,
|
|
2004
|
+
cache: cacheBackendOf(workspace.services.cache, providerOf(workspace))
|
|
2005
|
+
}).containers;
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Reconcile a workspace for one stage.
|
|
2009
|
+
*
|
|
2010
|
+
* Ports are loaded and saved around the call rather than inside it, so the loop
|
|
2011
|
+
* stays a pure-ish function of its inputs and the store keeps its single owner.
|
|
2012
|
+
*/
|
|
2013
|
+
async function reconcileWorkspace(workspace, options) {
|
|
2014
|
+
const manifest = options.manifest ?? await require_discover.discover({
|
|
2015
|
+
patterns: constructGlobs(workspace),
|
|
2016
|
+
cwd: workspace.root
|
|
2017
|
+
});
|
|
2018
|
+
const result = await reconcile({
|
|
2019
|
+
root: workspace.root,
|
|
2020
|
+
project: workspace.name,
|
|
2021
|
+
manifest,
|
|
2022
|
+
stage: options.stage,
|
|
2023
|
+
events: workspace.services.events,
|
|
2024
|
+
cache: cacheBackendOf(workspace.services.cache, providerOf(workspace)),
|
|
2025
|
+
images: imagePins(workspace),
|
|
2026
|
+
saved: await loadPortState(workspace.root),
|
|
2027
|
+
addresses: surfaceAddresses(workspace, manifest),
|
|
2028
|
+
...options.start === void 0 ? {} : { start: options.start }
|
|
2029
|
+
});
|
|
2030
|
+
await savePortState(workspace.root, { ...result.ports });
|
|
2031
|
+
return result;
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Where each declared surface and site answers locally.
|
|
2035
|
+
*
|
|
2036
|
+
* The addresses come from the ports the workspace already assigns; which
|
|
2037
|
+
* construct sits at which one comes from the manifest. That split is the point:
|
|
2038
|
+
* this used to answer "who may call whom" by listing every app the workspace
|
|
2039
|
+
* runs, which is a different question that happened to give the same answer in
|
|
2040
|
+
* a single-repo workspace and no answer at all deployed. Now it answers only
|
|
2041
|
+
* "where does this construct answer", and the graph answers the rest.
|
|
2042
|
+
*
|
|
2043
|
+
* A site is matched to its app by `path`, because that is the one thing a site
|
|
2044
|
+
* declaration and a workspace app both name. Surfaces are matched to the
|
|
2045
|
+
* backend app: every `rest-api` in a process answers on that process's port, so
|
|
2046
|
+
* an app serving both its own API and an auth server publishes one address
|
|
2047
|
+
* twice — which is exactly what it does at runtime.
|
|
2048
|
+
*/
|
|
2049
|
+
function surfaceAddresses(workspace, manifest) {
|
|
2050
|
+
const addresses = {};
|
|
2051
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
2052
|
+
if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
|
|
2053
|
+
const host = declaration.kind === "site" ? require_config.appKey(id) : appKeyOfHost(manifest, id);
|
|
2054
|
+
const app = host ? workspace.apps[host] : void 0;
|
|
2055
|
+
if (app?.port) addresses[id] = localAddress(app.port);
|
|
2056
|
+
}
|
|
2057
|
+
return addresses;
|
|
2058
|
+
}
|
|
2059
|
+
/** The app key serving a surface, following a mount to its host. */
|
|
2060
|
+
function appKeyOfHost(manifest, id) {
|
|
2061
|
+
const host = require_config.hostOf(manifest, id);
|
|
2062
|
+
return host ? require_config.appKey(host) : void 0;
|
|
2063
|
+
}
|
|
2064
|
+
/** Where a local process answers, given the port the workspace gave it. */
|
|
2065
|
+
function localAddress(port) {
|
|
2066
|
+
return `http://localhost:${port}`;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
//#endregion
|
|
2070
|
+
//#region src/credentials/index.ts
|
|
2071
|
+
const logger = console;
|
|
2072
|
+
/**
|
|
2073
|
+
* Load environment files
|
|
2074
|
+
* @internal Exported for testing
|
|
2075
|
+
*/
|
|
2076
|
+
function loadEnvFiles(envConfig, cwd = process.cwd()) {
|
|
2077
|
+
const loaded = [];
|
|
2078
|
+
const missing = [];
|
|
2079
|
+
const envFiles = envConfig ? Array.isArray(envConfig) ? envConfig : [envConfig] : [".env"];
|
|
2080
|
+
for (const envFile of envFiles) {
|
|
2081
|
+
const envPath = (0, node_path.resolve)(cwd, envFile);
|
|
2082
|
+
if ((0, node_fs.existsSync)(envPath)) {
|
|
2083
|
+
(0, dotenv.config)({
|
|
2084
|
+
path: envPath,
|
|
2085
|
+
override: true,
|
|
2086
|
+
quiet: true
|
|
2087
|
+
});
|
|
2088
|
+
loaded.push(envFile);
|
|
2089
|
+
} else if (envConfig) missing.push(envFile);
|
|
2090
|
+
}
|
|
2091
|
+
return {
|
|
2092
|
+
loaded,
|
|
2093
|
+
missing
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Check if a port is available
|
|
2098
|
+
* @internal Exported for testing
|
|
2099
|
+
*/
|
|
2100
|
+
async function isPortAvailable(port) {
|
|
2101
|
+
return new Promise((resolve$1) => {
|
|
2102
|
+
const server = (0, node_net.createServer)();
|
|
2103
|
+
server.once("error", (err) => {
|
|
2104
|
+
if (err.code === "EADDRINUSE") resolve$1(false);
|
|
2105
|
+
else resolve$1(false);
|
|
2106
|
+
});
|
|
2107
|
+
server.once("listening", () => {
|
|
2108
|
+
server.close();
|
|
2109
|
+
resolve$1(true);
|
|
2110
|
+
});
|
|
2111
|
+
server.listen(port);
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Find an available port starting from the preferred port
|
|
2116
|
+
* @internal Exported for testing
|
|
2117
|
+
*/
|
|
2118
|
+
async function findAvailablePort(preferredPort, maxAttempts = 10) {
|
|
2119
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
2120
|
+
const port = preferredPort + i;
|
|
2121
|
+
if (await isPortAvailable(port)) return port;
|
|
2122
|
+
logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
|
|
2123
|
+
}
|
|
2124
|
+
throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
|
|
2125
|
+
}
|
|
2126
|
+
const PORT_STATE_PATH = ".gkm/ports.json";
|
|
2127
|
+
/**
|
|
2128
|
+
* Parse docker-compose.yml and extract all port mappings that use env var interpolation.
|
|
2129
|
+
* Entries like `'${POSTGRES_HOST_PORT:-5432}:5432'` are captured.
|
|
2130
|
+
* Fixed port mappings like `'5050:80'` are skipped.
|
|
2131
|
+
* @internal Exported for testing
|
|
2132
|
+
*/
|
|
2133
|
+
function parseComposePortMappings(composePath) {
|
|
2134
|
+
if (!(0, node_fs.existsSync)(composePath)) return [];
|
|
2135
|
+
const content = (0, node_fs.readFileSync)(composePath, "utf-8");
|
|
2136
|
+
const compose = (0, yaml.parse)(content);
|
|
2137
|
+
if (!compose?.services) return [];
|
|
2138
|
+
const results = [];
|
|
2139
|
+
for (const [serviceName, serviceConfig] of Object.entries(compose.services)) for (const portMapping of serviceConfig?.ports ?? []) {
|
|
2140
|
+
const match = String(portMapping).match(/\$\{(\w+):-(\d+)\}:(\d+)/);
|
|
2141
|
+
if (match?.[1] && match[2] && match[3]) results.push({
|
|
2142
|
+
service: serviceName,
|
|
2143
|
+
envVar: match[1],
|
|
2144
|
+
defaultPort: Number(match[2]),
|
|
2145
|
+
containerPort: Number(match[3])
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
return results;
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Load saved port state from .gkm/ports.json.
|
|
2152
|
+
* @internal Exported for testing
|
|
2153
|
+
*/
|
|
2154
|
+
async function loadPortState(workspaceRoot) {
|
|
2155
|
+
try {
|
|
2156
|
+
const raw = await (0, node_fs_promises.readFile)((0, node_path.join)(workspaceRoot, PORT_STATE_PATH), "utf-8");
|
|
2157
|
+
return JSON.parse(raw);
|
|
2158
|
+
} catch {
|
|
2159
|
+
return {};
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Save port state to .gkm/ports.json.
|
|
2164
|
+
* @internal Exported for testing
|
|
2165
|
+
*/
|
|
2166
|
+
async function savePortState(workspaceRoot, ports) {
|
|
2167
|
+
const dir = (0, node_path.join)(workspaceRoot, ".gkm");
|
|
2168
|
+
await (0, node_fs_promises.mkdir)(dir, { recursive: true });
|
|
2169
|
+
await (0, node_fs_promises.writeFile)((0, node_path.join)(workspaceRoot, PORT_STATE_PATH), `${JSON.stringify(ports, null, 2)}\n`);
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* Check if a project's own Docker container is running and return its host port.
|
|
2173
|
+
* Uses `docker compose port` scoped to the project's compose file.
|
|
2174
|
+
* @internal Exported for testing
|
|
2175
|
+
*/
|
|
2176
|
+
function getContainerHostPort(workspaceRoot, service, containerPort) {
|
|
2177
|
+
try {
|
|
2178
|
+
const result = (0, node_child_process.execSync)(`docker compose port ${service} ${containerPort}`, {
|
|
2179
|
+
cwd: workspaceRoot,
|
|
2180
|
+
stdio: "pipe"
|
|
2181
|
+
}).toString().trim();
|
|
2182
|
+
const match = result.match(/:(\d+)$/);
|
|
2183
|
+
return match ? Number(match[1]) : null;
|
|
2184
|
+
} catch {
|
|
2185
|
+
return null;
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Resolve host ports for Docker services by parsing docker-compose.yml.
|
|
2190
|
+
* Priority: running container → saved state → find available port.
|
|
2191
|
+
* Persists resolved ports to .gkm/ports.json.
|
|
2192
|
+
* @internal Exported for testing
|
|
2193
|
+
*/
|
|
2194
|
+
async function resolveServicePorts(workspaceRoot) {
|
|
2195
|
+
const composePath = (0, node_path.join)(workspaceRoot, "docker-compose.yml");
|
|
2196
|
+
const mappings = parseComposePortMappings(composePath);
|
|
2197
|
+
if (mappings.length === 0) return {
|
|
2198
|
+
dockerEnv: {},
|
|
2199
|
+
ports: {},
|
|
2200
|
+
mappings: []
|
|
2201
|
+
};
|
|
2202
|
+
const savedState = await loadPortState(workspaceRoot);
|
|
2203
|
+
const dockerEnv = {};
|
|
2204
|
+
const ports = {};
|
|
2205
|
+
const assignedPorts = /* @__PURE__ */ new Set();
|
|
2206
|
+
logger.log("\n🔌 Resolving service ports...");
|
|
2207
|
+
for (const mapping of mappings) {
|
|
2208
|
+
const containerPort = getContainerHostPort(workspaceRoot, mapping.service, mapping.containerPort);
|
|
2209
|
+
if (containerPort !== null) {
|
|
2210
|
+
ports[mapping.envVar] = containerPort;
|
|
2211
|
+
dockerEnv[mapping.envVar] = String(containerPort);
|
|
2212
|
+
assignedPorts.add(containerPort);
|
|
2213
|
+
logger.log(` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`);
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2216
|
+
const savedPort = savedState[mapping.envVar];
|
|
2217
|
+
if (savedPort && !assignedPorts.has(savedPort) && await isPortAvailable(savedPort)) {
|
|
2218
|
+
ports[mapping.envVar] = savedPort;
|
|
2219
|
+
dockerEnv[mapping.envVar] = String(savedPort);
|
|
2220
|
+
assignedPorts.add(savedPort);
|
|
2221
|
+
logger.log(` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`);
|
|
2222
|
+
continue;
|
|
2223
|
+
}
|
|
2224
|
+
let resolvedPort = await findAvailablePort(mapping.defaultPort);
|
|
2225
|
+
while (assignedPorts.has(resolvedPort)) resolvedPort = await findAvailablePort(resolvedPort + 1);
|
|
2226
|
+
ports[mapping.envVar] = resolvedPort;
|
|
2227
|
+
dockerEnv[mapping.envVar] = String(resolvedPort);
|
|
2228
|
+
assignedPorts.add(resolvedPort);
|
|
2229
|
+
if (resolvedPort !== mapping.defaultPort) logger.log(` ⚡ ${mapping.service}:${mapping.containerPort}: port ${mapping.defaultPort} occupied, using port ${resolvedPort}`);
|
|
2230
|
+
else logger.log(` ✅ ${mapping.service}:${mapping.containerPort}: using default port ${resolvedPort}`);
|
|
2231
|
+
}
|
|
2232
|
+
await savePortState(workspaceRoot, ports);
|
|
2233
|
+
return {
|
|
2234
|
+
dockerEnv,
|
|
2235
|
+
ports,
|
|
2236
|
+
mappings
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Replace a port in a URL string.
|
|
2241
|
+
* Handles both `hostname:port` and `localhost:port` patterns.
|
|
2242
|
+
* @internal Exported for testing
|
|
2243
|
+
*/
|
|
2244
|
+
function replacePortInUrl(url, oldPort, newPort) {
|
|
2245
|
+
if (oldPort === newPort) return url;
|
|
2246
|
+
let result = url.replace(new RegExp(`:${oldPort}(?=[/?#]|$)`, "g"), `:${newPort}`);
|
|
2247
|
+
result = result.replace(new RegExp(`%3A${oldPort}(?=[%/?#&]|$)`, "gi"), `%3A${newPort}`);
|
|
2248
|
+
return result;
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* Rewrite connection URLs and port vars in secrets with resolved ports.
|
|
2252
|
+
* Uses the parsed compose mappings to determine which default ports to replace.
|
|
2253
|
+
* Pure transform — does not modify secrets on disk.
|
|
2254
|
+
* @internal Exported for testing
|
|
2255
|
+
*/
|
|
2256
|
+
function rewriteUrlsWithPorts(secrets, resolvedPorts) {
|
|
2257
|
+
const { ports, mappings } = resolvedPorts;
|
|
2258
|
+
const result = { ...secrets };
|
|
2259
|
+
const portReplacements = [];
|
|
2260
|
+
const serviceNames = /* @__PURE__ */ new Set();
|
|
2261
|
+
for (const mapping of mappings) {
|
|
2262
|
+
serviceNames.add(mapping.service);
|
|
2263
|
+
const resolved = ports[mapping.envVar];
|
|
2264
|
+
if (resolved !== void 0) portReplacements.push({
|
|
2265
|
+
defaultPort: mapping.defaultPort,
|
|
2266
|
+
resolvedPort: resolved
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
for (const [key, value] of Object.entries(result)) {
|
|
2270
|
+
if (!key.endsWith("_HOST")) continue;
|
|
2271
|
+
if (serviceNames.has(value)) result[key] = "localhost";
|
|
2272
|
+
}
|
|
2273
|
+
for (const [key, value] of Object.entries(result)) {
|
|
2274
|
+
if (!key.endsWith("_PORT")) continue;
|
|
2275
|
+
for (const { defaultPort, resolvedPort } of portReplacements) if (value === String(defaultPort)) result[key] = String(resolvedPort);
|
|
2276
|
+
}
|
|
2277
|
+
for (const [key, value] of Object.entries(result)) {
|
|
2278
|
+
if (!key.endsWith("_URL") && !key.endsWith("_ENDPOINT") && !key.endsWith("_CONNECTION_STRING") && key !== "DATABASE_URL") continue;
|
|
2279
|
+
let rewritten = value;
|
|
2280
|
+
for (const name of serviceNames) rewritten = rewritten.replace(new RegExp(`@${name}:`, "g"), "@localhost:");
|
|
2281
|
+
for (const { defaultPort, resolvedPort } of portReplacements) rewritten = replacePortInUrl(rewritten, defaultPort, resolvedPort);
|
|
2282
|
+
result[key] = rewritten;
|
|
2283
|
+
}
|
|
2284
|
+
return result;
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* Build the environment variables to pass to `docker compose up`.
|
|
2288
|
+
* Merges process.env, secrets, and port mappings so that Docker Compose
|
|
2289
|
+
* can interpolate variables like ${POSTGRES_USER} correctly.
|
|
2290
|
+
* @internal Exported for testing
|
|
2291
|
+
*/
|
|
2292
|
+
function buildDockerComposeEnv(secretsEnv, portEnv) {
|
|
2293
|
+
return {
|
|
2294
|
+
...process.env,
|
|
2295
|
+
...secretsEnv,
|
|
2296
|
+
...portEnv
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Parse all service names from a docker-compose.yml file.
|
|
2301
|
+
* @internal Exported for testing
|
|
2302
|
+
*/
|
|
2303
|
+
function parseComposeServiceNames(composePath) {
|
|
2304
|
+
if (!(0, node_fs.existsSync)(composePath)) return [];
|
|
2305
|
+
const content = (0, node_fs.readFileSync)(composePath, "utf-8");
|
|
2306
|
+
const compose = (0, yaml.parse)(content);
|
|
2307
|
+
return Object.keys(compose?.services ?? {});
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Start docker-compose services for a single-app project (no workspace config).
|
|
2311
|
+
* Starts all services defined in docker-compose.yml.
|
|
2312
|
+
*/
|
|
2313
|
+
async function startComposeServices(cwd, portEnv, secretsEnv) {
|
|
2314
|
+
const composeFile = (0, node_path.join)(cwd, "docker-compose.yml");
|
|
2315
|
+
if (!(0, node_fs.existsSync)(composeFile)) return;
|
|
2316
|
+
const servicesToStart = parseComposeServiceNames(composeFile);
|
|
2317
|
+
if (servicesToStart.length === 0) return;
|
|
2318
|
+
logger.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
|
|
2319
|
+
try {
|
|
2320
|
+
(0, node_child_process.execSync)(`docker compose up -d ${servicesToStart.join(" ")}`, {
|
|
2321
|
+
cwd,
|
|
2322
|
+
stdio: "inherit",
|
|
2323
|
+
env: buildDockerComposeEnv(secretsEnv, portEnv)
|
|
2324
|
+
});
|
|
2325
|
+
logger.log("✅ Services started");
|
|
2326
|
+
} catch (error) {
|
|
2327
|
+
logger.error("❌ Failed to start services:", error.message);
|
|
2328
|
+
throw error;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Start docker-compose services for a workspace.
|
|
2333
|
+
* Discovers all services from docker-compose.yml and starts everything
|
|
2334
|
+
* except app services (which are managed by turbo).
|
|
2335
|
+
* @internal Exported for testing
|
|
2336
|
+
*/
|
|
2337
|
+
async function startWorkspaceServices(workspace, portEnv, secretsEnv) {
|
|
2338
|
+
const composeFile = (0, node_path.join)(workspace.root, "docker-compose.yml");
|
|
2339
|
+
if (!(0, node_fs.existsSync)(composeFile)) return;
|
|
2340
|
+
const allServices = parseComposeServiceNames(composeFile);
|
|
2341
|
+
const appNames = new Set(Object.keys(workspace.apps));
|
|
2342
|
+
const servicesToStart = allServices.filter((name) => !appNames.has(name));
|
|
2343
|
+
if (servicesToStart.length === 0) return;
|
|
2344
|
+
logger.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
|
|
2345
|
+
try {
|
|
2346
|
+
(0, node_child_process.execSync)(`docker compose up -d ${servicesToStart.join(" ")}`, {
|
|
2347
|
+
cwd: workspace.root,
|
|
2348
|
+
stdio: "inherit",
|
|
2349
|
+
env: buildDockerComposeEnv(secretsEnv, portEnv)
|
|
2350
|
+
});
|
|
2351
|
+
logger.log("✅ Services started");
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
logger.error("❌ Failed to start services:", error.message);
|
|
2354
|
+
throw error;
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Load and flatten secrets for an app from encrypted storage.
|
|
2359
|
+
* For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
|
|
2360
|
+
* @internal Exported for testing
|
|
2361
|
+
*/
|
|
2362
|
+
async function loadSecretsForApp(secretsRoot, appName, stages = ["dev", "development"]) {
|
|
2363
|
+
let secrets = {};
|
|
2364
|
+
for (const stage of stages) if (require_storage.secretsExist(stage, secretsRoot)) {
|
|
2365
|
+
const stageSecrets = await require_storage.readStageSecrets(stage, secretsRoot);
|
|
2366
|
+
if (stageSecrets) {
|
|
2367
|
+
logger.log(`🔐 Loading secrets from stage: ${stage}`);
|
|
2368
|
+
secrets = require_storage.toEmbeddableSecrets(stageSecrets);
|
|
2369
|
+
break;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (Object.keys(secrets).length === 0) return {};
|
|
2373
|
+
if (!appName) return secrets;
|
|
2374
|
+
const prefix = appName.toUpperCase();
|
|
2375
|
+
const mapped = { ...secrets };
|
|
2376
|
+
const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
|
|
2377
|
+
if (appDbUrl) mapped.DATABASE_URL = appDbUrl;
|
|
2378
|
+
return mapped;
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* Walk up the directory tree to find the root containing .gkm/secrets/.
|
|
2382
|
+
* @internal Exported for testing
|
|
2383
|
+
*/
|
|
2384
|
+
function findSecretsRoot(startDir) {
|
|
2385
|
+
let dir = startDir;
|
|
2386
|
+
while (dir !== "/") {
|
|
2387
|
+
if ((0, node_fs.existsSync)((0, node_path.join)(dir, ".gkm", "secrets"))) return dir;
|
|
2388
|
+
const parent = (0, node_path.dirname)(dir);
|
|
2389
|
+
if (parent === dir) break;
|
|
2390
|
+
dir = parent;
|
|
2391
|
+
}
|
|
2392
|
+
return startDir;
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Generate the credentials injection code snippet.
|
|
2396
|
+
* This is the common logic used by both entry wrapper and exec preload.
|
|
2397
|
+
* @internal
|
|
2398
|
+
*/
|
|
2399
|
+
function generateCredentialsInjection(secretsJsonPath) {
|
|
2400
|
+
return `import { existsSync, readFileSync } from 'node:fs';
|
|
2401
|
+
|
|
2402
|
+
// Inject dev secrets via globalThis and process.env
|
|
2403
|
+
// Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
|
|
2404
|
+
// Object.assign on the Credentials export only mutates one module copy.
|
|
2405
|
+
const secretsPath = '${secretsJsonPath}';
|
|
2406
|
+
if (existsSync(secretsPath)) {
|
|
2407
|
+
const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
|
|
2408
|
+
globalThis.__gkm_credentials__ = secrets;
|
|
2409
|
+
Object.assign(process.env, secrets);
|
|
2410
|
+
}
|
|
2411
|
+
`;
|
|
2412
|
+
}
|
|
2413
|
+
/**
|
|
2414
|
+
* Create a preload script that injects secrets into Credentials.
|
|
2415
|
+
* Used by `gkm exec` to inject secrets before running any command.
|
|
2416
|
+
* @internal Exported for testing
|
|
2417
|
+
*/
|
|
2418
|
+
async function createCredentialsPreload(preloadPath, secretsJsonPath) {
|
|
2419
|
+
const content = `/**
|
|
2420
|
+
* Credentials preload generated by 'gkm exec'
|
|
2421
|
+
* This file is loaded via NODE_OPTIONS="--import <path>"
|
|
2422
|
+
*/
|
|
2423
|
+
${generateCredentialsInjection(secretsJsonPath)}`;
|
|
2424
|
+
await (0, node_fs_promises.writeFile)(preloadPath, content);
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Create a wrapper script that injects secrets before importing the entry file.
|
|
2428
|
+
* @internal Exported for testing
|
|
2429
|
+
*/
|
|
2430
|
+
async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
|
|
2431
|
+
const credentialsInjection = secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
|
|
2432
|
+
` : "";
|
|
2433
|
+
const content = `#!/usr/bin/env node
|
|
2434
|
+
/**
|
|
2435
|
+
* Entry wrapper generated by 'gkm dev --entry'
|
|
2436
|
+
*/
|
|
2437
|
+
${credentialsInjection}// Import and run the user's entry file (dynamic import ensures secrets load first)
|
|
2438
|
+
await import('${entryPath}');
|
|
2439
|
+
`;
|
|
2440
|
+
await (0, node_fs_promises.writeFile)(wrapperPath, content);
|
|
2441
|
+
}
|
|
2442
|
+
/**
|
|
2443
|
+
* Prepare credentials for dev/exec/test modes.
|
|
2444
|
+
* Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
|
|
2445
|
+
* injects PORT, dependency URLs, and writes credentials JSON.
|
|
2446
|
+
*
|
|
2447
|
+
* @param options.resolveDockerPorts - How to resolve Docker ports:
|
|
2448
|
+
* - `'full'` (default): probe running containers, saved state, then find available ports. Used by dev/test.
|
|
2449
|
+
* - `'readonly'`: check running containers and saved state only, never probe for new ports. Used by exec.
|
|
2450
|
+
* @param options.stages - Secret stages to try, in order. Default: ['dev', 'development'].
|
|
2451
|
+
* @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
|
|
2452
|
+
* @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
|
|
2453
|
+
* @internal Exported for testing
|
|
2454
|
+
*/
|
|
2455
|
+
async function prepareEntryCredentials(options) {
|
|
2456
|
+
const cwd = options.cwd ?? process.cwd();
|
|
2457
|
+
const portMode = options.resolveDockerPorts ?? "full";
|
|
2458
|
+
let workspaceAppPort;
|
|
2459
|
+
let secretsRoot = cwd;
|
|
2460
|
+
let appName;
|
|
2461
|
+
let appInfo;
|
|
2462
|
+
try {
|
|
2463
|
+
appInfo = await require_config.loadWorkspaceAppInfo(cwd);
|
|
2464
|
+
workspaceAppPort = appInfo.app.port;
|
|
2465
|
+
secretsRoot = appInfo.workspaceRoot;
|
|
2466
|
+
appName = appInfo.appName;
|
|
2467
|
+
} catch {
|
|
2468
|
+
secretsRoot = findSecretsRoot(cwd);
|
|
2469
|
+
appName = require_config.getAppNameFromCwd(cwd) ?? void 0;
|
|
2470
|
+
}
|
|
2471
|
+
const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
|
|
2472
|
+
const credentials = await loadSecretsForApp(secretsRoot, appName, options.stages);
|
|
2473
|
+
credentials.PORT = String(resolvedPort);
|
|
2474
|
+
const declaredKeys = [];
|
|
2475
|
+
if (appInfo && usesConstructs(appInfo.workspace)) {
|
|
2476
|
+
const { reconcileWorkspace: reconcileWorkspace$1 } = await Promise.resolve().then(() => require("./workspace-8acupeaI.cjs"));
|
|
2477
|
+
const reconciled = await reconcileWorkspace$1(appInfo.workspace, {
|
|
2478
|
+
stage: options.reconcileStage ?? "development",
|
|
2479
|
+
start: options.startDocker ?? false
|
|
2480
|
+
});
|
|
2481
|
+
Object.assign(credentials, reconciled.env);
|
|
2482
|
+
declaredKeys.push(...Object.keys(reconciled.env));
|
|
2483
|
+
if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
|
|
2484
|
+
} else {
|
|
2485
|
+
const composePath = (0, node_path.join)(secretsRoot, "docker-compose.yml");
|
|
2486
|
+
const mappings = parseComposePortMappings(composePath);
|
|
2487
|
+
if (mappings.length > 0) {
|
|
2488
|
+
let resolvedPorts;
|
|
2489
|
+
if (portMode === "full") resolvedPorts = await resolveServicePorts(secretsRoot);
|
|
2490
|
+
else {
|
|
2491
|
+
const savedPorts = await loadPortState(secretsRoot);
|
|
2492
|
+
const ports = {};
|
|
2493
|
+
for (const mapping of mappings) {
|
|
2494
|
+
const containerPort = getContainerHostPort(secretsRoot, mapping.service, mapping.containerPort);
|
|
2495
|
+
if (containerPort !== null) ports[mapping.envVar] = containerPort;
|
|
2496
|
+
else {
|
|
2497
|
+
const saved = savedPorts[mapping.envVar];
|
|
2498
|
+
if (saved !== void 0) ports[mapping.envVar] = saved;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
resolvedPorts = {
|
|
2502
|
+
dockerEnv: {},
|
|
2503
|
+
ports,
|
|
2504
|
+
mappings
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
if (options.startDocker) if (appInfo) await startWorkspaceServices(appInfo.workspace, resolvedPorts.dockerEnv, credentials);
|
|
2508
|
+
else await startComposeServices(secretsRoot, resolvedPorts.dockerEnv, credentials);
|
|
2509
|
+
if (Object.keys(resolvedPorts.ports).length > 0) {
|
|
2510
|
+
const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
|
|
2511
|
+
Object.assign(credentials, rewritten);
|
|
2512
|
+
logger.log(`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
if (appInfo?.appName) {
|
|
2517
|
+
const depEnv = require_workspace.getDependencyEnvVars(appInfo.workspace, appInfo.appName);
|
|
2518
|
+
Object.assign(credentials, depEnv);
|
|
2519
|
+
}
|
|
2520
|
+
if (!credentials.EVENT_PUBLISHER_CONNECTION_STRING && !credentials.EVENT_SUBSCRIBER_CONNECTION_STRING && credentials.PGBOSS_DB_USER) {
|
|
2521
|
+
const pgbossUrl = require_generator.generatePgBossUrl({
|
|
2522
|
+
username: credentials.PGBOSS_DB_USER,
|
|
2523
|
+
password: credentials.PGBOSS_DB_PASSWORD ?? "",
|
|
2524
|
+
host: credentials.POSTGRES_HOST ?? "localhost",
|
|
2525
|
+
port: Number(credentials.POSTGRES_PORT ?? "5432"),
|
|
2526
|
+
database: credentials.POSTGRES_DB ?? "app"
|
|
2527
|
+
});
|
|
2528
|
+
credentials.EVENT_PUBLISHER_CONNECTION_STRING = pgbossUrl;
|
|
2529
|
+
credentials.EVENT_SUBSCRIBER_CONNECTION_STRING = pgbossUrl;
|
|
2530
|
+
}
|
|
2531
|
+
const secretsDir = (0, node_path.join)(secretsRoot, ".gkm");
|
|
2532
|
+
await (0, node_fs_promises.mkdir)(secretsDir, { recursive: true });
|
|
2533
|
+
const secretsFileName = options.secretsFileName ?? (appName ? `dev-secrets-${appName}.json` : "dev-secrets.json");
|
|
2534
|
+
const secretsJsonPath = (0, node_path.join)(secretsDir, secretsFileName);
|
|
2535
|
+
await (0, node_fs_promises.writeFile)(secretsJsonPath, JSON.stringify(credentials, null, 2));
|
|
2536
|
+
return {
|
|
2537
|
+
credentials,
|
|
2538
|
+
resolvedPort,
|
|
2539
|
+
secretsJsonPath,
|
|
2540
|
+
appName,
|
|
2541
|
+
secretsRoot,
|
|
2542
|
+
appInfo,
|
|
2543
|
+
declaredKeys
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
//#endregion
|
|
2548
|
+
Object.defineProperty(exports, 'LOCAL_CA_PATH', {
|
|
2549
|
+
enumerable: true,
|
|
2550
|
+
get: function () {
|
|
2551
|
+
return LOCAL_CA_PATH;
|
|
2552
|
+
}
|
|
2553
|
+
});
|
|
2554
|
+
Object.defineProperty(exports, 'applyPostgres', {
|
|
2555
|
+
enumerable: true,
|
|
2556
|
+
get: function () {
|
|
2557
|
+
return applyPostgres;
|
|
2558
|
+
}
|
|
2559
|
+
});
|
|
2560
|
+
Object.defineProperty(exports, 'cacheBackendOf', {
|
|
2561
|
+
enumerable: true,
|
|
2562
|
+
get: function () {
|
|
2563
|
+
return cacheBackendOf;
|
|
2564
|
+
}
|
|
2565
|
+
});
|
|
2566
|
+
Object.defineProperty(exports, 'constructGlobs', {
|
|
2567
|
+
enumerable: true,
|
|
2568
|
+
get: function () {
|
|
2569
|
+
return constructGlobs;
|
|
2570
|
+
}
|
|
2571
|
+
});
|
|
2572
|
+
Object.defineProperty(exports, 'createCredentialsPreload', {
|
|
2573
|
+
enumerable: true,
|
|
2574
|
+
get: function () {
|
|
2575
|
+
return createCredentialsPreload;
|
|
2576
|
+
}
|
|
2577
|
+
});
|
|
2578
|
+
Object.defineProperty(exports, 'createEntryWrapper', {
|
|
2579
|
+
enumerable: true,
|
|
2580
|
+
get: function () {
|
|
2581
|
+
return createEntryWrapper;
|
|
2582
|
+
}
|
|
2583
|
+
});
|
|
2584
|
+
Object.defineProperty(exports, 'derivedContainers', {
|
|
2585
|
+
enumerable: true,
|
|
2586
|
+
get: function () {
|
|
2587
|
+
return derivedContainers;
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
2590
|
+
Object.defineProperty(exports, 'emailBackendOf', {
|
|
2591
|
+
enumerable: true,
|
|
2592
|
+
get: function () {
|
|
2593
|
+
return emailBackendOf;
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2596
|
+
Object.defineProperty(exports, 'findAvailablePort', {
|
|
2597
|
+
enumerable: true,
|
|
2598
|
+
get: function () {
|
|
2599
|
+
return findAvailablePort;
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2602
|
+
Object.defineProperty(exports, 'imagePins', {
|
|
2603
|
+
enumerable: true,
|
|
2604
|
+
get: function () {
|
|
2605
|
+
return imagePins;
|
|
2606
|
+
}
|
|
2607
|
+
});
|
|
2608
|
+
Object.defineProperty(exports, 'isPortAvailable', {
|
|
2609
|
+
enumerable: true,
|
|
2610
|
+
get: function () {
|
|
2611
|
+
return isPortAvailable;
|
|
2612
|
+
}
|
|
2613
|
+
});
|
|
2614
|
+
Object.defineProperty(exports, 'loadEnvFiles', {
|
|
2615
|
+
enumerable: true,
|
|
2616
|
+
get: function () {
|
|
2617
|
+
return loadEnvFiles;
|
|
2618
|
+
}
|
|
2619
|
+
});
|
|
2620
|
+
Object.defineProperty(exports, 'loadSecretsForApp', {
|
|
2621
|
+
enumerable: true,
|
|
2622
|
+
get: function () {
|
|
2623
|
+
return loadSecretsForApp;
|
|
2624
|
+
}
|
|
2625
|
+
});
|
|
2626
|
+
Object.defineProperty(exports, 'prepareEntryCredentials', {
|
|
2627
|
+
enumerable: true,
|
|
2628
|
+
get: function () {
|
|
2629
|
+
return prepareEntryCredentials;
|
|
2630
|
+
}
|
|
2631
|
+
});
|
|
2632
|
+
Object.defineProperty(exports, 'providerOf', {
|
|
2633
|
+
enumerable: true,
|
|
2634
|
+
get: function () {
|
|
2635
|
+
return providerOf;
|
|
2636
|
+
}
|
|
2637
|
+
});
|
|
2638
|
+
Object.defineProperty(exports, 'reconcileWorkspace', {
|
|
2639
|
+
enumerable: true,
|
|
2640
|
+
get: function () {
|
|
2641
|
+
return reconcileWorkspace;
|
|
2642
|
+
}
|
|
2643
|
+
});
|
|
2644
|
+
Object.defineProperty(exports, 'resolveServicePorts', {
|
|
2645
|
+
enumerable: true,
|
|
2646
|
+
get: function () {
|
|
2647
|
+
return resolveServicePorts;
|
|
2648
|
+
}
|
|
2649
|
+
});
|
|
2650
|
+
Object.defineProperty(exports, 'resourceName', {
|
|
2651
|
+
enumerable: true,
|
|
2652
|
+
get: function () {
|
|
2653
|
+
return resourceName;
|
|
2654
|
+
}
|
|
2655
|
+
});
|
|
2656
|
+
Object.defineProperty(exports, 'rewriteUrlsWithPorts', {
|
|
2657
|
+
enumerable: true,
|
|
2658
|
+
get: function () {
|
|
2659
|
+
return rewriteUrlsWithPorts;
|
|
2660
|
+
}
|
|
2661
|
+
});
|
|
2662
|
+
Object.defineProperty(exports, 'startWorkspaceServices', {
|
|
2663
|
+
enumerable: true,
|
|
2664
|
+
get: function () {
|
|
2665
|
+
return startWorkspaceServices;
|
|
2666
|
+
}
|
|
2667
|
+
});
|
|
2668
|
+
Object.defineProperty(exports, 'storageBackendOf', {
|
|
2669
|
+
enumerable: true,
|
|
2670
|
+
get: function () {
|
|
2671
|
+
return storageBackendOf;
|
|
2672
|
+
}
|
|
2673
|
+
});
|
|
2674
|
+
Object.defineProperty(exports, 'usesConstructs', {
|
|
2675
|
+
enumerable: true,
|
|
2676
|
+
get: function () {
|
|
2677
|
+
return usesConstructs;
|
|
2678
|
+
}
|
|
2679
|
+
});
|
|
2680
|
+
//# sourceMappingURL=credentials-K961ThG3.cjs.map
|