@geekmidas/cli 10.0.0-alpha.4 → 10.0.0-alpha.41
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 +52 -74
- package/bin/adjacent-tsconfig.mjs +97 -0
- package/bin/gkm.mjs +7 -1
- package/dist/{HostingerProvider-h3EbEp-Z.cjs → HostingerProvider-BZFGYYVA.cjs} +21 -22
- package/dist/HostingerProvider-BZFGYYVA.cjs.map +1 -0
- package/dist/{HostingerProvider-Cft7lEYr.mjs → HostingerProvider-Bh6K7QYz.mjs} +20 -21
- package/dist/HostingerProvider-Bh6K7QYz.mjs.map +1 -0
- package/dist/{LocalStateProvider-Db9SEjyH.cjs → LocalStateProvider-7HR6SS2t.cjs} +11 -6
- package/dist/LocalStateProvider-7HR6SS2t.cjs.map +1 -0
- package/dist/{LocalStateProvider-Bn6tS-oL.mjs → LocalStateProvider-D7QyJoLM.mjs} +10 -4
- package/dist/LocalStateProvider-D7QyJoLM.mjs.map +1 -0
- package/dist/{openapi-CA0yJUTN.cjs → OpenApiTsGenerator-B4Kj_qNU.cjs} +142 -303
- package/dist/OpenApiTsGenerator-B4Kj_qNU.cjs.map +1 -0
- package/dist/{openapi-kkQbsRVj.mjs → OpenApiTsGenerator-CEh3EKN3.mjs} +113 -255
- package/dist/OpenApiTsGenerator-CEh3EKN3.mjs.map +1 -0
- package/dist/{Route53Provider-RdpUDJQk.mjs → Route53Provider-Cvj40A8I.mjs} +12 -12
- package/dist/Route53Provider-Cvj40A8I.mjs.map +1 -0
- package/dist/{Route53Provider-di2fMM5N.cjs → Route53Provider-DVILBsvV.cjs} +20 -21
- package/dist/Route53Provider-DVILBsvV.cjs.map +1 -0
- package/dist/{SSMStateProvider-BRt2py-A.mjs → SSMStateProvider-B0Hu1MKZ.mjs} +13 -4
- package/dist/SSMStateProvider-B0Hu1MKZ.mjs.map +1 -0
- package/dist/{SSMStateProvider-DyX8s9HI.cjs → SSMStateProvider-VrQ-yeqd.cjs} +17 -9
- package/dist/SSMStateProvider-VrQ-yeqd.cjs.map +1 -0
- package/dist/aws-DkHdTV-p.cjs +61 -0
- package/dist/aws-DkHdTV-p.cjs.map +1 -0
- package/dist/aws-DojzQnLd.mjs +61 -0
- package/dist/aws-DojzQnLd.mjs.map +1 -0
- package/dist/{backup-provisioner-BrsHwYG0.cjs → backup-provisioner-BmhIO_Yr.cjs} +21 -19
- package/dist/backup-provisioner-BmhIO_Yr.cjs.map +1 -0
- package/dist/{backup-provisioner-Bgj5Vi_L.mjs → backup-provisioner-DIVCbfee.mjs} +10 -7
- package/dist/backup-provisioner-DIVCbfee.mjs.map +1 -0
- package/dist/{bundler-Be8Zg4Wd.mjs → bundler--fyybf1N.mjs} +19 -30
- package/dist/bundler--fyybf1N.mjs.map +1 -0
- package/dist/{bundler-BY1OmV1e.cjs → bundler-By6_sXiL.cjs} +20 -32
- package/dist/bundler-By6_sXiL.cjs.map +1 -0
- package/dist/{config-WRdsQYZK.mjs → config-5watB1D0.mjs} +142 -154
- package/dist/config-5watB1D0.mjs.map +1 -0
- package/dist/{config-C21ocYNd.cjs → config-CJ-PAb7M.cjs} +209 -210
- package/dist/config-CJ-PAb7M.cjs.map +1 -0
- package/dist/config.cjs +9 -6
- package/dist/config.d.cts +62 -33
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +62 -33
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +3 -5
- package/dist/deploy/sniffer-envkit-patch.cjs +18 -10
- package/dist/deploy/sniffer-envkit-patch.cjs.map +1 -1
- package/dist/deploy/sniffer-envkit-patch.d.cts +0 -2
- package/dist/deploy/sniffer-envkit-patch.d.cts.map +1 -1
- package/dist/deploy/sniffer-envkit-patch.d.mts +0 -2
- package/dist/deploy/sniffer-envkit-patch.d.mts.map +1 -1
- package/dist/deploy/sniffer-envkit-patch.mjs +10 -2
- package/dist/deploy/sniffer-envkit-patch.mjs.map +1 -1
- package/dist/deploy/sniffer-hooks.cjs +13 -7
- package/dist/deploy/sniffer-hooks.cjs.map +1 -1
- package/dist/deploy/sniffer-hooks.d.cts +1 -2
- package/dist/deploy/sniffer-hooks.d.cts.map +1 -1
- package/dist/deploy/sniffer-hooks.d.mts +1 -2
- package/dist/deploy/sniffer-hooks.d.mts.map +1 -1
- package/dist/deploy/sniffer-hooks.mjs +9 -2
- package/dist/deploy/sniffer-hooks.mjs.map +1 -1
- package/dist/deploy/sniffer-loader.cjs +16 -7
- package/dist/deploy/sniffer-loader.cjs.map +1 -1
- package/dist/deploy/sniffer-loader.d.cts +1 -1
- package/dist/deploy/sniffer-loader.d.mts +1 -1
- package/dist/deploy/sniffer-loader.mjs +13 -2
- package/dist/deploy/sniffer-loader.mjs.map +1 -1
- package/dist/deploy/sniffer-routes-worker.cjs +11 -11
- package/dist/deploy/sniffer-routes-worker.cjs.map +1 -1
- package/dist/deploy/sniffer-routes-worker.d.cts +1 -1
- package/dist/deploy/sniffer-routes-worker.d.mts +1 -1
- package/dist/deploy/sniffer-routes-worker.mjs +3 -3
- package/dist/deploy/sniffer-routes-worker.mjs.map +1 -1
- package/dist/deploy/sniffer-worker.cjs +15 -6
- package/dist/deploy/sniffer-worker.cjs.map +1 -1
- package/dist/deploy/sniffer-worker.d.cts +1 -3
- package/dist/deploy/sniffer-worker.d.cts.map +1 -1
- package/dist/deploy/sniffer-worker.d.mts +1 -3
- package/dist/deploy/sniffer-worker.d.mts.map +1 -1
- package/dist/deploy/sniffer-worker.mjs +15 -4
- package/dist/deploy/sniffer-worker.mjs.map +1 -1
- package/dist/{discover-DTmFeloL.cjs → discover-B0f_kuNN.cjs} +118 -66
- package/dist/discover-B0f_kuNN.cjs.map +1 -0
- package/dist/{discover-CAlFgYec.mjs → discover-BLhf7V_J.mjs} +67 -22
- package/dist/discover-BLhf7V_J.mjs.map +1 -0
- package/dist/{emit-CbcRolsa.cjs → emit-BdoPFjZf.cjs} +53 -36
- package/dist/emit-BdoPFjZf.cjs.map +1 -0
- package/dist/{emit-CiZS_5Ub.mjs → emit-Bzq7JsMP.mjs} +24 -6
- package/dist/emit-Bzq7JsMP.mjs.map +1 -0
- package/dist/github-BAr8WGkR.cjs +213 -0
- package/dist/github-BAr8WGkR.cjs.map +1 -0
- package/dist/github-D5MthUlR.mjs +213 -0
- package/dist/github-D5MthUlR.mjs.map +1 -0
- package/dist/{index-DCtipn2h.d.cts → index-B3PeGOQp.d.cts} +4 -23
- package/dist/index-B3PeGOQp.d.cts.map +1 -0
- package/dist/{index-BThUzEIp.d.mts → index-BOyaGcCm.d.mts} +4 -23
- package/dist/index-BOyaGcCm.d.mts.map +1 -0
- package/dist/index.cjs +7071 -4519
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +7117 -4586
- package/dist/index.mjs.map +1 -1
- package/dist/migrate-BVI3yqcE.mjs +736 -0
- package/dist/migrate-BVI3yqcE.mjs.map +1 -0
- package/dist/migrate-CFOUApUt.cjs +743 -0
- package/dist/migrate-CFOUApUt.cjs.map +1 -0
- package/dist/openapi.cjs +197 -8
- package/dist/openapi.cjs.map +1 -0
- package/dist/openapi.d.cts +47 -14
- package/dist/openapi.d.cts.map +1 -1
- package/dist/openapi.d.mts +47 -14
- package/dist/openapi.d.mts.map +1 -1
- package/dist/openapi.mjs +191 -5
- package/dist/openapi.mjs.map +1 -0
- package/dist/ready-D8UXsP6q.mjs +516 -0
- package/dist/ready-D8UXsP6q.mjs.map +1 -0
- package/dist/ready-mcxamf73.cjs +563 -0
- package/dist/ready-mcxamf73.cjs.map +1 -0
- package/dist/reconcile/public.cjs +4 -4
- package/dist/reconcile/public.d.cts +27 -26
- package/dist/reconcile/public.d.cts.map +1 -1
- package/dist/reconcile/public.d.mts +27 -26
- package/dist/reconcile/public.d.mts.map +1 -1
- package/dist/reconcile/public.mjs +3 -4
- package/dist/{reconcile-CFs8C_1a.mjs → reconcile--I7V4Klj.mjs} +4 -7
- package/dist/reconcile--I7V4Klj.mjs.map +1 -0
- package/dist/{reconcile-D0pimNiy.cjs → reconcile-CuCQBa5-.cjs} +5 -8
- package/dist/reconcile-CuCQBa5-.cjs.map +1 -0
- package/dist/rolldown-runtime-BhDjJH2R.mjs +15 -0
- package/dist/{chunk-CUT6urMc.cjs → rolldown-runtime-C0BPl7ul.cjs} +23 -10
- package/dist/storage-BzaUxmqe.mjs +124 -0
- package/dist/storage-BzaUxmqe.mjs.map +1 -0
- package/dist/storage-CSGoC3bK.cjs +152 -0
- package/dist/storage-CSGoC3bK.cjs.map +1 -0
- package/dist/store-DQgxT_WR.cjs +211 -0
- package/dist/store-DQgxT_WR.cjs.map +1 -0
- package/dist/store-DeGATCS-.mjs +182 -0
- package/dist/store-DeGATCS-.mjs.map +1 -0
- package/dist/types-0R124hrY.d.mts +6324 -0
- package/dist/types-0R124hrY.d.mts.map +1 -0
- package/dist/types-DfdYe31u.d.cts +6325 -0
- package/dist/types-DfdYe31u.d.cts.map +1 -0
- package/dist/vitest.cjs +65 -0
- package/dist/vitest.cjs.map +1 -0
- package/dist/vitest.d.cts +29 -0
- package/dist/vitest.d.cts.map +1 -0
- package/dist/vitest.d.mts +29 -0
- package/dist/vitest.d.mts.map +1 -0
- package/dist/vitest.mjs +65 -0
- package/dist/vitest.mjs.map +1 -0
- package/dist/workspace/index.cjs +3 -4
- package/dist/workspace/index.d.cts +3 -3
- package/dist/workspace/index.d.mts +3 -3
- package/dist/workspace/index.mjs +2 -3
- package/dist/{credentials-G1JwBoXH.cjs → workspace-BIqeeqcR.cjs} +2248 -1809
- package/dist/workspace-BIqeeqcR.cjs.map +1 -0
- package/dist/{workspace-BFMrAKqI.mjs → workspace-DOmIhiau.mjs} +863 -952
- package/dist/workspace-DOmIhiau.mjs.map +1 -0
- package/dist/{credentials-BBIRJVp4.mjs → workspace-DwS1JK5u.mjs} +2114 -1706
- package/dist/workspace-DwS1JK5u.mjs.map +1 -0
- package/dist/{workspace-BcucjwvA.cjs → workspace-RYSYtHP7.cjs} +1032 -1098
- package/dist/workspace-RYSYtHP7.cjs.map +1 -0
- package/package.json +29 -20
- package/dist/CachedStateProvider-BLo7zv7S.cjs +0 -3
- package/dist/CachedStateProvider-BxoAm-B2.mjs +0 -3
- package/dist/CachedStateProvider-CqPdW7CB.cjs +0 -60
- package/dist/CachedStateProvider-CqPdW7CB.cjs.map +0 -1
- package/dist/CachedStateProvider-DTniehMP.mjs +0 -54
- package/dist/CachedStateProvider-DTniehMP.mjs.map +0 -1
- package/dist/HostingerProvider-Cft7lEYr.mjs.map +0 -1
- package/dist/HostingerProvider-h3EbEp-Z.cjs.map +0 -1
- package/dist/LocalStateProvider-Bn6tS-oL.mjs.map +0 -1
- package/dist/LocalStateProvider-Db9SEjyH.cjs.map +0 -1
- package/dist/Route53Provider-RdpUDJQk.mjs.map +0 -1
- package/dist/Route53Provider-di2fMM5N.cjs.map +0 -1
- package/dist/SSMStateProvider-BRt2py-A.mjs.map +0 -1
- package/dist/SSMStateProvider-DyX8s9HI.cjs.map +0 -1
- package/dist/backup-provisioner-Bgj5Vi_L.mjs.map +0 -1
- package/dist/backup-provisioner-BrsHwYG0.cjs.map +0 -1
- package/dist/bundler-BY1OmV1e.cjs.map +0 -1
- package/dist/bundler-Be8Zg4Wd.mjs.map +0 -1
- package/dist/chunk-Duj1WY3L.mjs +0 -7
- package/dist/config-C21ocYNd.cjs.map +0 -1
- package/dist/config-WRdsQYZK.mjs.map +0 -1
- package/dist/credentials-B5zRHdAl.mjs +0 -141
- package/dist/credentials-B5zRHdAl.mjs.map +0 -1
- package/dist/credentials-BBIRJVp4.mjs.map +0 -1
- package/dist/credentials-BapzjyV5.cjs +0 -195
- package/dist/credentials-BapzjyV5.cjs.map +0 -1
- package/dist/credentials-G1JwBoXH.cjs.map +0 -1
- package/dist/discover-CAlFgYec.mjs.map +0 -1
- package/dist/discover-DTmFeloL.cjs.map +0 -1
- package/dist/dokploy-api-BS2xnJLZ.mjs +0 -3
- package/dist/dokploy-api-BsVH0y_s.cjs +0 -718
- package/dist/dokploy-api-BsVH0y_s.cjs.map +0 -1
- package/dist/dokploy-api-BuxDRo3l.cjs +0 -3
- package/dist/dokploy-api-aTFPz7_4.mjs +0 -706
- package/dist/dokploy-api-aTFPz7_4.mjs.map +0 -1
- package/dist/emit-CbcRolsa.cjs.map +0 -1
- package/dist/emit-CiZS_5Ub.mjs.map +0 -1
- package/dist/encryption-C6jaU0hE.cjs +0 -54
- package/dist/encryption-C6jaU0hE.cjs.map +0 -1
- package/dist/encryption-CHzzeR8a.cjs +0 -4
- package/dist/encryption-CQUncu7v.mjs +0 -3
- package/dist/encryption-D-abyhZx.mjs +0 -42
- package/dist/encryption-D-abyhZx.mjs.map +0 -1
- package/dist/fullstack-secrets-C-GGohke.cjs +0 -111
- package/dist/fullstack-secrets-C-GGohke.cjs.map +0 -1
- package/dist/fullstack-secrets-D1o9-G5l.mjs +0 -87
- package/dist/fullstack-secrets-D1o9-G5l.mjs.map +0 -1
- package/dist/generator-CNzBw7g5.cjs +0 -289
- package/dist/generator-CNzBw7g5.cjs.map +0 -1
- package/dist/generator-VE2JrFlY.mjs +0 -253
- package/dist/generator-VE2JrFlY.mjs.map +0 -1
- package/dist/index-BThUzEIp.d.mts.map +0 -1
- package/dist/index-DCtipn2h.d.cts.map +0 -1
- package/dist/openapi-CA0yJUTN.cjs.map +0 -1
- package/dist/openapi-kkQbsRVj.mjs.map +0 -1
- package/dist/openapi-react-query-Byltvm_7.mjs +0 -167
- package/dist/openapi-react-query-Byltvm_7.mjs.map +0 -1
- package/dist/openapi-react-query-CmJ1xDbn.cjs +0 -173
- package/dist/openapi-react-query-CmJ1xDbn.cjs.map +0 -1
- package/dist/openapi-react-query.cjs +0 -4
- package/dist/openapi-react-query.d.cts +0 -11
- package/dist/openapi-react-query.d.cts.map +0 -1
- package/dist/openapi-react-query.d.mts +0 -11
- package/dist/openapi-react-query.d.mts.map +0 -1
- package/dist/openapi-react-query.mjs +0 -4
- package/dist/reconcile-CFs8C_1a.mjs.map +0 -1
- package/dist/reconcile-D0pimNiy.cjs.map +0 -1
- package/dist/setup-B5_oHHUj.cjs +0 -11
- package/dist/setup-Cm2UnAdh.cjs +0 -473
- package/dist/setup-Cm2UnAdh.cjs.map +0 -1
- package/dist/setup-DXvRLTZN.mjs +0 -443
- package/dist/setup-DXvRLTZN.mjs.map +0 -1
- package/dist/setup-ZijS4qeC.mjs +0 -11
- package/dist/storage-BJmzYxt8.cjs +0 -363
- package/dist/storage-BJmzYxt8.cjs.map +0 -1
- package/dist/storage-BM9hGpgv.cjs +0 -7
- package/dist/storage-Bi24WfoW.mjs +0 -297
- package/dist/storage-Bi24WfoW.mjs.map +0 -1
- package/dist/storage-CYlGwWTj.mjs +0 -3
- package/dist/sync-CmEuNZsB.mjs +0 -76
- package/dist/sync-CmEuNZsB.mjs.map +0 -1
- package/dist/sync-D306yOWu.cjs +0 -4
- package/dist/sync-SizaChmC.cjs +0 -93
- package/dist/sync-SizaChmC.cjs.map +0 -1
- package/dist/sync-hOqAWHR6.mjs +0 -3
- package/dist/types-BhIl-COG.d.cts +0 -2184
- package/dist/types-BhIl-COG.d.cts.map +0 -1
- package/dist/types-C-arnQ1r.d.mts +0 -2184
- package/dist/types-C-arnQ1r.d.mts.map +0 -1
- package/dist/workspace-BFMrAKqI.mjs.map +0 -1
- package/dist/workspace-BcucjwvA.cjs.map +0 -1
- package/dist/workspace-DdJss8I6.cjs +0 -9
- package/dist/workspace-k4l3rsFh.mjs +0 -8
|
@@ -1,63 +1,266 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
|
|
2
|
+
import { a as DEFAULT_CACHE, o as DEFAULT_EVENTS$1, s as DEFAULT_STORAGE, t as discover } from "./discover-BLhf7V_J.mjs";
|
|
3
|
+
import { c as loadWorkspaceAppInfo, f as appKey, l as loadWorkspaceConfig, r as NotInAnApp, t as ConfigNotFound } from "./config-5watB1D0.mjs";
|
|
4
|
+
import { E as TEST_STAGE, t as allConstructGlobs } from "./workspace-DOmIhiau.mjs";
|
|
5
|
+
import { i as toEmbeddableSecrets } from "./storage-BzaUxmqe.mjs";
|
|
6
|
+
import { i as FileSecretsStore, n as secretsStoreFor } from "./store-DeGATCS-.mjs";
|
|
7
|
+
import { c as readFakes } from "./ready-D8UXsP6q.mjs";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { networkInterfaces } from "node:os";
|
|
7
11
|
import { dirname, join, resolve } from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { execFile, execSync } from "node:child_process";
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase } from "@geekmidas/manifest";
|
|
11
14
|
import { createServer } from "node:net";
|
|
12
15
|
import { config } from "dotenv";
|
|
13
|
-
import {
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
|
|
18
|
+
import * as snsUrl from "@geekmidas/events/sns/url";
|
|
19
|
+
import * as sqsUrl from "@geekmidas/events/sqs/url";
|
|
14
20
|
import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
|
|
21
|
+
import { CreateTopicCommand, GetTopicAttributesCommand, SNSClient } from "@aws-sdk/client-sns";
|
|
22
|
+
import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
15
23
|
import { Client } from "pg";
|
|
24
|
+
import { stringify } from "yaml";
|
|
16
25
|
import { promisify } from "node:util";
|
|
17
|
-
import { createHash } from "node:crypto";
|
|
18
|
-
import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
|
|
19
26
|
import { cacheTableStatements } from "@geekmidas/cache/postgres";
|
|
27
|
+
//#region src/credentials/index.ts
|
|
28
|
+
const logger = console;
|
|
29
|
+
/**
|
|
30
|
+
* Load environment files
|
|
31
|
+
* @internal Exported for testing
|
|
32
|
+
*/
|
|
33
|
+
function loadEnvFiles(envConfig, cwd = process.cwd()) {
|
|
34
|
+
const loaded = [];
|
|
35
|
+
const missing = [];
|
|
36
|
+
const envFiles = envConfig ? Array.isArray(envConfig) ? envConfig : [envConfig] : [".env"];
|
|
37
|
+
for (const envFile of envFiles) {
|
|
38
|
+
const envPath = resolve(cwd, envFile);
|
|
39
|
+
if (existsSync(envPath)) {
|
|
40
|
+
config({
|
|
41
|
+
path: envPath,
|
|
42
|
+
override: true,
|
|
43
|
+
quiet: true
|
|
44
|
+
});
|
|
45
|
+
loaded.push(envFile);
|
|
46
|
+
} else if (envConfig) missing.push(envFile);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
loaded,
|
|
50
|
+
missing
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check if a port is available
|
|
55
|
+
* @internal Exported for testing
|
|
56
|
+
*/
|
|
57
|
+
async function isPortAvailable(port) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const server = createServer();
|
|
60
|
+
server.once("error", (err) => {
|
|
61
|
+
if (err.code === "EADDRINUSE") resolve(false);
|
|
62
|
+
else resolve(false);
|
|
63
|
+
});
|
|
64
|
+
server.once("listening", () => {
|
|
65
|
+
server.close();
|
|
66
|
+
resolve(true);
|
|
67
|
+
});
|
|
68
|
+
server.listen(port);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Find an available port starting from the preferred port
|
|
73
|
+
* @internal Exported for testing
|
|
74
|
+
*/
|
|
75
|
+
async function findAvailablePort(preferredPort, maxAttempts = 10) {
|
|
76
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
77
|
+
const port = preferredPort + i;
|
|
78
|
+
if (await isPortAvailable(port)) return port;
|
|
79
|
+
logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
|
|
82
|
+
}
|
|
83
|
+
const PORT_STATE_PATH = ".gkm/ports.json";
|
|
84
|
+
/**
|
|
85
|
+
* Load saved port state from .gkm/ports.json.
|
|
86
|
+
* @internal Exported for testing
|
|
87
|
+
*/
|
|
88
|
+
async function loadPortState(workspaceRoot) {
|
|
89
|
+
try {
|
|
90
|
+
const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), "utf-8");
|
|
91
|
+
return JSON.parse(raw);
|
|
92
|
+
} catch {
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Save port state to .gkm/ports.json.
|
|
98
|
+
* @internal Exported for testing
|
|
99
|
+
*/
|
|
100
|
+
async function savePortState(workspaceRoot, ports) {
|
|
101
|
+
const dir = join(workspaceRoot, ".gkm");
|
|
102
|
+
await mkdir(dir, { recursive: true });
|
|
103
|
+
await writeFile(join(workspaceRoot, PORT_STATE_PATH), `${JSON.stringify(ports, null, 2)}\n`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Load and flatten secrets for an app from encrypted storage.
|
|
107
|
+
* For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
|
|
108
|
+
* @internal Exported for testing
|
|
109
|
+
*/
|
|
110
|
+
async function loadSecretsForApp(store, stage, appName) {
|
|
111
|
+
let secrets = {};
|
|
112
|
+
const stageSecrets = await store.read(stage);
|
|
113
|
+
if (stageSecrets) {
|
|
114
|
+
logger.log(`🔐 Loading secrets from stage: ${stage}`);
|
|
115
|
+
secrets = toEmbeddableSecrets(stageSecrets);
|
|
116
|
+
}
|
|
117
|
+
if (Object.keys(secrets).length === 0) return {};
|
|
118
|
+
if (!appName) return secrets;
|
|
119
|
+
const prefix = appName.toUpperCase();
|
|
120
|
+
const mapped = { ...secrets };
|
|
121
|
+
const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
|
|
122
|
+
if (appDbUrl) mapped.DATABASE_URL = appDbUrl;
|
|
123
|
+
return mapped;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Walk up the directory tree to find the root containing .gkm/secrets/.
|
|
127
|
+
* @internal Exported for testing
|
|
128
|
+
*/
|
|
129
|
+
function findSecretsRoot(startDir) {
|
|
130
|
+
let dir = startDir;
|
|
131
|
+
while (dir !== "/") {
|
|
132
|
+
if (existsSync(join(dir, ".gkm", "secrets"))) return dir;
|
|
133
|
+
const parent = dirname(dir);
|
|
134
|
+
if (parent === dir) break;
|
|
135
|
+
dir = parent;
|
|
136
|
+
}
|
|
137
|
+
return startDir;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Generate the credentials injection code snippet.
|
|
141
|
+
* This is the common logic used by both entry wrapper and exec preload.
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
144
|
+
function generateCredentialsInjection(secretsJsonPath) {
|
|
145
|
+
return `import { existsSync, readFileSync } from 'node:fs';
|
|
20
146
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
147
|
+
// Inject dev secrets via globalThis and process.env
|
|
148
|
+
// Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
|
|
149
|
+
// Object.assign on the Credentials export only mutates one module copy.
|
|
150
|
+
const secretsPath = '${secretsJsonPath}';
|
|
151
|
+
if (existsSync(secretsPath)) {
|
|
152
|
+
const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
|
|
153
|
+
globalThis.__gkm_credentials__ = secrets;
|
|
154
|
+
Object.assign(process.env, secrets);
|
|
155
|
+
}
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Create a preload script that injects secrets into Credentials.
|
|
160
|
+
* Used by `gkm exec` to inject secrets before running any command.
|
|
161
|
+
* @internal Exported for testing
|
|
162
|
+
*/
|
|
163
|
+
async function createCredentialsPreload(preloadPath, secretsJsonPath) {
|
|
164
|
+
const content = `/**
|
|
165
|
+
* Credentials preload generated by 'gkm exec'
|
|
166
|
+
* This file is loaded via NODE_OPTIONS="--import <path>"
|
|
167
|
+
*/
|
|
168
|
+
${generateCredentialsInjection(secretsJsonPath)}`;
|
|
169
|
+
await writeFile(preloadPath, content);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Create a wrapper script that injects secrets before importing the entry file.
|
|
173
|
+
* @internal Exported for testing
|
|
174
|
+
*/
|
|
175
|
+
async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
|
|
176
|
+
const content = `#!/usr/bin/env node
|
|
177
|
+
/**
|
|
178
|
+
* Entry wrapper generated by 'gkm dev --entry'
|
|
179
|
+
*/
|
|
180
|
+
${secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
|
|
181
|
+
` : ""}// Import and run the user's entry file (dynamic import ensures secrets load first)
|
|
182
|
+
await import('${entryPath}');
|
|
183
|
+
`;
|
|
184
|
+
await writeFile(wrapperPath, content);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Prepare credentials for dev/exec/test modes.
|
|
188
|
+
* Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
|
|
189
|
+
* injects PORT, dependency URLs, and writes credentials JSON.
|
|
190
|
+
*
|
|
191
|
+
* @param options.stage - The stage whose secrets to load. Default: the project's `stages.local`.
|
|
192
|
+
* @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
|
|
193
|
+
* @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
|
|
194
|
+
* @internal Exported for testing
|
|
195
|
+
*/
|
|
196
|
+
async function prepareEntryCredentials(options) {
|
|
197
|
+
const cwd = options.cwd ?? process.cwd();
|
|
198
|
+
let workspaceAppPort;
|
|
199
|
+
let secretsRoot = cwd;
|
|
200
|
+
let appName;
|
|
201
|
+
let appInfo;
|
|
202
|
+
let workspace;
|
|
203
|
+
try {
|
|
204
|
+
appInfo = await loadWorkspaceAppInfo(cwd);
|
|
205
|
+
workspace = appInfo.workspace;
|
|
206
|
+
workspaceAppPort = appInfo.app.port;
|
|
207
|
+
secretsRoot = appInfo.workspaceRoot;
|
|
208
|
+
appName = appInfo.appName;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!(error instanceof NotInAnApp || error instanceof ConfigNotFound)) throw error;
|
|
211
|
+
workspace = await loadWorkspaceConfig(cwd).then((loaded) => loaded.workspace).catch(() => void 0);
|
|
212
|
+
secretsRoot = workspace?.root ?? findSecretsRoot(cwd);
|
|
213
|
+
}
|
|
214
|
+
const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
|
|
215
|
+
const stage = options.stage ?? workspace?.stages.local;
|
|
216
|
+
const store = stage ? workspace ? await secretsStoreFor(workspace, stage) : new FileSecretsStore(secretsRoot) : void 0;
|
|
217
|
+
const credentials = store && stage ? await loadSecretsForApp(store, stage, appName) : {};
|
|
218
|
+
credentials.PORT = String(resolvedPort);
|
|
219
|
+
if (appInfo?.app.type === "mobile") credentials.RCT_METRO_PORT = String(resolvedPort);
|
|
220
|
+
const declaredKeys = [];
|
|
221
|
+
let reconciled;
|
|
222
|
+
if (workspace) {
|
|
223
|
+
const { reconcileWorkspace } = await Promise.resolve().then(() => workspace_exports);
|
|
224
|
+
reconciled = await reconcileWorkspace(workspace, {
|
|
225
|
+
stage: options.reconcileStage ?? workspace.stages.local,
|
|
226
|
+
start: options.startDocker ?? false
|
|
227
|
+
});
|
|
228
|
+
Object.assign(credentials, reconciled.env);
|
|
229
|
+
declaredKeys.push(...Object.keys(reconciled.env));
|
|
230
|
+
if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
|
|
231
|
+
}
|
|
232
|
+
const secretsDir = join(secretsRoot, ".gkm");
|
|
233
|
+
await mkdir(secretsDir, { recursive: true });
|
|
234
|
+
const secretsFileName = options.secretsFileName ?? (appName ? `dev-secrets-${appName}.json` : "dev-secrets.json");
|
|
235
|
+
const secretsJsonPath = join(secretsDir, secretsFileName);
|
|
236
|
+
await writeFile(secretsJsonPath, JSON.stringify(credentials, null, 2));
|
|
237
|
+
return {
|
|
238
|
+
credentials,
|
|
239
|
+
resolvedPort,
|
|
240
|
+
secretsJsonPath,
|
|
241
|
+
appName,
|
|
242
|
+
secretsRoot,
|
|
243
|
+
appInfo,
|
|
244
|
+
declaredKeys,
|
|
245
|
+
...reconciled ? { reconciled } : {}
|
|
246
|
+
};
|
|
60
247
|
}
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/workspace/backends.ts
|
|
250
|
+
/**
|
|
251
|
+
* Which backend each declared kind resolves to — decided by where the project
|
|
252
|
+
* deploys, and by nothing else.
|
|
253
|
+
*
|
|
254
|
+
* These used to be read out of a `services:` block, where one key answered two
|
|
255
|
+
* questions: *does this exist* and *what backs it*. The first is the manifest's
|
|
256
|
+
* — a declared `Cache` is why there is a cache — and the second is the deploy
|
|
257
|
+
* target's, because the right cache for a Lambda is not the right cache for a
|
|
258
|
+
* box already running Postgres. With both answered elsewhere there was nothing
|
|
259
|
+
* left for the block to say, so it is gone and these read the target.
|
|
260
|
+
*
|
|
261
|
+
* Mail has no answer here at all: every provider speaks SMTP, so the provider
|
|
262
|
+
* is whichever one the stage's mail URL points at.
|
|
263
|
+
*/
|
|
61
264
|
/**
|
|
62
265
|
* Which family of defaults a workspace's deploy target belongs to.
|
|
63
266
|
*
|
|
@@ -76,61 +279,222 @@ function providerOf(workspace) {
|
|
|
76
279
|
return target === "dokploy" || target === "server" ? "server" : "aws";
|
|
77
280
|
}
|
|
78
281
|
/**
|
|
79
|
-
*
|
|
80
|
-
* a `services:` value can be.
|
|
282
|
+
* The cache backend a target implies.
|
|
81
283
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
284
|
+
* The same answer locally and deployed: a local `gkm dev` for an AWS app still
|
|
285
|
+
* uses the Upstash protocol, because a backend that differed between the two
|
|
286
|
+
* would be worse than a slower one.
|
|
85
287
|
*/
|
|
86
|
-
function
|
|
87
|
-
return
|
|
288
|
+
function cacheBackendFor(on) {
|
|
289
|
+
return DEFAULT_CACHE[on];
|
|
290
|
+
}
|
|
291
|
+
/** Where a declared bucket lives on a target. */
|
|
292
|
+
function storageBackendFor(on) {
|
|
293
|
+
return DEFAULT_STORAGE[on];
|
|
88
294
|
}
|
|
89
|
-
|
|
90
|
-
//#endregion
|
|
91
|
-
//#region src/reconcile/caddyfile.ts
|
|
92
295
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* Everything that owns a *public address*. Deployed, each of these has a real
|
|
96
|
-
* hostname and a certificate, so this is what makes the local shape the same
|
|
97
|
-
* one rather than a near-enough one — and the application is indifferent
|
|
98
|
-
* either way, because it reads whichever address was injected and never
|
|
99
|
-
* composes one.
|
|
296
|
+
* Which broker carries declared queues and topics on a target.
|
|
100
297
|
*
|
|
101
|
-
*
|
|
298
|
+
* Only consulted when something declares one; a project with no `Topic` or
|
|
299
|
+
* `Queue` has no events at all, whatever the target.
|
|
102
300
|
*/
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
301
|
+
function eventsBackendFor(on) {
|
|
302
|
+
return DEFAULT_EVENTS$1[on];
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/reconcile/containers.ts
|
|
108
306
|
/**
|
|
109
|
-
*
|
|
307
|
+
* What gkm knows about each container it can derive.
|
|
110
308
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
309
|
+
* Everything provider-specific about the local target lives here: the image, the
|
|
310
|
+
* ports the image listens on, and whether its data is worth keeping. The plan
|
|
311
|
+
* says *which* containers exist and never says any of this, which is the split
|
|
312
|
+
* the design asks for — neutral facts from the declaration, provider detail
|
|
313
|
+
* from here and from config.
|
|
314
|
+
*
|
|
315
|
+
* Shared by allocation and by the compose writer so the two cannot disagree
|
|
316
|
+
* about how many ports a container needs.
|
|
115
317
|
*/
|
|
116
|
-
const HOST_GATEWAY = "host.docker.internal";
|
|
117
318
|
/**
|
|
118
|
-
* The
|
|
319
|
+
* The Postgres image for a major version.
|
|
119
320
|
*
|
|
120
|
-
*
|
|
121
|
-
* without
|
|
122
|
-
* subdomains of it, and Caddy issues internal certificates for it by default.
|
|
321
|
+
* One place that knows the tag shape, so a declared version reaches the
|
|
322
|
+
* container without anyone restating `-alpine` beside it.
|
|
123
323
|
*/
|
|
124
|
-
|
|
324
|
+
function postgresImage(version) {
|
|
325
|
+
return `postgres:${version}-alpine`;
|
|
326
|
+
}
|
|
125
327
|
/**
|
|
126
|
-
* The
|
|
328
|
+
* The default image for each container.
|
|
127
329
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
330
|
+
* Pinned to a major rather than `latest` where the image publishes one, so a
|
|
331
|
+
* rebuild does not silently move a developer's Postgres a version. Overridable
|
|
332
|
+
* per project: a project needing `postgis/postgis` says so in config.
|
|
333
|
+
*/
|
|
334
|
+
const DEFAULT_IMAGES = {
|
|
335
|
+
postgres: postgresImage(DEFAULT_POSTGRES_VERSION),
|
|
336
|
+
minio: "pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
|
|
337
|
+
mailpit: "axllent/mailpit:latest",
|
|
338
|
+
redis: "redis:8-alpine",
|
|
339
|
+
"redis-http": "hiett/serverless-redis-http:latest",
|
|
340
|
+
rabbitmq: "rabbitmq:4-management-alpine",
|
|
341
|
+
localstack: "floci/floci:latest",
|
|
342
|
+
caddy: "caddy:2-alpine"
|
|
343
|
+
};
|
|
344
|
+
/**
|
|
345
|
+
* The ports each container listens on.
|
|
346
|
+
*
|
|
347
|
+
* The first is always the one an app connects to; the rest are consoles and
|
|
348
|
+
* management UIs, published because a bucket or an inbox you can look at is most
|
|
349
|
+
* of why these are real containers rather than stubs.
|
|
350
|
+
*/
|
|
351
|
+
const PORTS = {
|
|
352
|
+
postgres: [{
|
|
353
|
+
key: "postgres",
|
|
354
|
+
inside: 5432,
|
|
355
|
+
label: "postgres"
|
|
356
|
+
}],
|
|
357
|
+
minio: [{
|
|
358
|
+
key: "minio",
|
|
359
|
+
inside: 9e3,
|
|
360
|
+
label: "minio api"
|
|
361
|
+
}, {
|
|
362
|
+
key: "minio-console",
|
|
363
|
+
inside: 9001,
|
|
364
|
+
label: "minio console"
|
|
365
|
+
}],
|
|
366
|
+
mailpit: [{
|
|
367
|
+
key: "mailpit",
|
|
368
|
+
inside: 1025,
|
|
369
|
+
label: "smtp"
|
|
370
|
+
}, {
|
|
371
|
+
key: "mailpit-web",
|
|
372
|
+
inside: 8025,
|
|
373
|
+
label: "mailpit inbox"
|
|
374
|
+
}],
|
|
375
|
+
redis: [{
|
|
376
|
+
key: "redis",
|
|
377
|
+
inside: 6379,
|
|
378
|
+
label: "redis"
|
|
379
|
+
}],
|
|
380
|
+
"redis-http": [{
|
|
381
|
+
key: "redis-http",
|
|
382
|
+
inside: 80,
|
|
383
|
+
label: "cache"
|
|
384
|
+
}],
|
|
385
|
+
rabbitmq: [{
|
|
386
|
+
key: "rabbitmq",
|
|
387
|
+
inside: 5672,
|
|
388
|
+
label: "amqp"
|
|
389
|
+
}, {
|
|
390
|
+
key: "rabbitmq-management",
|
|
391
|
+
inside: 15672,
|
|
392
|
+
label: "rabbitmq console"
|
|
393
|
+
}],
|
|
394
|
+
localstack: [{
|
|
395
|
+
key: "localstack",
|
|
396
|
+
inside: 4566,
|
|
397
|
+
label: "localstack"
|
|
398
|
+
}],
|
|
399
|
+
caddy: [{
|
|
400
|
+
key: "caddy",
|
|
401
|
+
inside: 443,
|
|
402
|
+
label: "https edge"
|
|
403
|
+
}]
|
|
404
|
+
};
|
|
405
|
+
/** The port key — and container name, for an image — of an API's fake. */
|
|
406
|
+
function fakeKey(id) {
|
|
407
|
+
return `${kebabCase(id)}-fake`;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* The ports a container needs published. Empty for one gkm does not know.
|
|
411
|
+
*
|
|
412
|
+
* `fakes` answers for the containers no table can: an external API's fake
|
|
413
|
+
* listens wherever its declaration says.
|
|
414
|
+
*/
|
|
415
|
+
function portsOf(container, fakes = {}) {
|
|
416
|
+
const fake = fakes[container];
|
|
417
|
+
if (fake?.port !== void 0) return [{
|
|
418
|
+
key: container,
|
|
419
|
+
inside: fake.port,
|
|
420
|
+
label: `${fake.id} fake`
|
|
421
|
+
}];
|
|
422
|
+
return PORTS[container] ?? [];
|
|
423
|
+
}
|
|
424
|
+
/** The key a container's primary port is allocated under. */
|
|
425
|
+
function primaryPortKey(container) {
|
|
426
|
+
return portsOf(container)[0]?.key ?? container;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Every port key a set of containers needs, in a stable order.
|
|
430
|
+
*
|
|
431
|
+
* What allocation is handed: it assigns ports to names and knows nothing about
|
|
432
|
+
* what listens on them.
|
|
433
|
+
*/
|
|
434
|
+
function portKeys(containers, fakes = {}) {
|
|
435
|
+
const keys = [...containers].sort().flatMap((container) => portsOf(container, fakes).map((port) => port.key));
|
|
436
|
+
return [.../* @__PURE__ */ new Set([...keys, ...Object.keys(fakes).sort()])];
|
|
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
|
+
return {
|
|
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
|
+
}[container];
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/reconcile/caddyfile.ts
|
|
456
|
+
/**
|
|
457
|
+
* The kinds the edge serves.
|
|
458
|
+
*
|
|
459
|
+
* Everything that owns a *public address*. Deployed, each of these has a real
|
|
460
|
+
* hostname and a certificate, so this is what makes the local shape the same
|
|
461
|
+
* one rather than a near-enough one — and the application is indifferent
|
|
462
|
+
* either way, because it reads whichever address was injected and never
|
|
463
|
+
* composes one.
|
|
464
|
+
*
|
|
465
|
+
* Adding a kind here is the whole of adding it to the edge.
|
|
466
|
+
*/
|
|
467
|
+
const EDGE_KINDS = {
|
|
468
|
+
"file-server": true,
|
|
469
|
+
"rest-api": true,
|
|
470
|
+
site: true
|
|
471
|
+
};
|
|
472
|
+
/**
|
|
473
|
+
* How the edge reaches the host, from inside its container.
|
|
474
|
+
*
|
|
475
|
+
* A surface and a site are served by processes `gkm dev` starts on the host,
|
|
476
|
+
* not by containers — so the edge has to leave Docker's network to reach them.
|
|
477
|
+
* Docker Desktop resolves this name already; on Linux the compose definition
|
|
478
|
+
* maps it to the host gateway.
|
|
479
|
+
*/
|
|
480
|
+
const HOST_GATEWAY = "host.docker.internal";
|
|
481
|
+
/**
|
|
482
|
+
* The suffix every local host shares.
|
|
483
|
+
*
|
|
484
|
+
* `.localhost` rather than a made-up TLD because it resolves to loopback
|
|
485
|
+
* without touching `/etc/hosts` — macOS and systemd-resolved both answer for
|
|
486
|
+
* subdomains of it, and Caddy issues internal certificates for it by default.
|
|
487
|
+
*/
|
|
488
|
+
const LOCAL_TLD = "localhost";
|
|
489
|
+
/**
|
|
490
|
+
* The sites a plan implies, one per file server.
|
|
491
|
+
*
|
|
492
|
+
* The leading label is the *server's* stage-scoped name rather than the
|
|
493
|
+
* bucket's, which is the difference that matters: two servers over one bucket
|
|
494
|
+
* are a legitimate arrangement — two cache behaviours, one origin — and naming
|
|
495
|
+
* the host after the bucket would make them collide.
|
|
496
|
+
*/
|
|
497
|
+
function sitesFor(plan, project, addresses = {}) {
|
|
134
498
|
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
135
499
|
const sites = [];
|
|
136
500
|
for (const resource of plan.resources) {
|
|
@@ -163,7 +527,7 @@ function portOf(address) {
|
|
|
163
527
|
const { port, protocol } = new URL(address);
|
|
164
528
|
return port || (protocol === "https:" ? "443" : "80");
|
|
165
529
|
} catch {
|
|
166
|
-
return
|
|
530
|
+
return;
|
|
167
531
|
}
|
|
168
532
|
}
|
|
169
533
|
/**
|
|
@@ -176,11 +540,16 @@ function portOf(address) {
|
|
|
176
540
|
*/
|
|
177
541
|
function hostFor(resource, project) {
|
|
178
542
|
return [
|
|
179
|
-
resource.name,
|
|
543
|
+
resource.root && project ? stageOf(resource) : resource.name,
|
|
180
544
|
project,
|
|
181
545
|
LOCAL_TLD
|
|
182
546
|
].filter(Boolean).join(".");
|
|
183
547
|
}
|
|
548
|
+
/** The stage a resource's name carries, or nothing for the local stage's. */
|
|
549
|
+
function stageOf(resource) {
|
|
550
|
+
const base = resource.id.toLowerCase();
|
|
551
|
+
return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
|
|
552
|
+
}
|
|
184
553
|
/**
|
|
185
554
|
* The static root config, which only ever imports.
|
|
186
555
|
*
|
|
@@ -246,1139 +615,1486 @@ const GENERATED = `# Generated by gkm from the construct manifest — do not edi
|
|
|
246
615
|
# root and point Node at it, so nothing has to be installed to use these URLs
|
|
247
616
|
# from code; a browser needs the root trusted once.
|
|
248
617
|
`;
|
|
249
|
-
|
|
250
618
|
//#endregion
|
|
251
|
-
//#region src/reconcile/
|
|
252
|
-
/** The credential local containers are brought up with. */
|
|
253
|
-
const LOCAL_USER$2 = "geekmidas";
|
|
619
|
+
//#region src/reconcile/emulator.ts
|
|
254
620
|
/**
|
|
255
|
-
*
|
|
621
|
+
* The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
|
|
622
|
+
* locally — the facts the compose file, the env writer and the applier must
|
|
623
|
+
* agree on.
|
|
256
624
|
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
625
|
+
* The emulator has one account and answers with deterministic ARNs and queue
|
|
626
|
+
* URLs, which is what lets a connection string be composed before the topic
|
|
627
|
+
* or queue it names has been created.
|
|
260
628
|
*/
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
} };
|
|
629
|
+
/** The emulator's single account. */
|
|
630
|
+
const EMULATOR_ACCOUNT = "000000000000";
|
|
631
|
+
const EMULATOR_REGION = "us-east-1";
|
|
632
|
+
/**
|
|
633
|
+
* The emulator's credential. The `LSIA` prefix is what LocalStack required on
|
|
634
|
+
* issued keys; floci does not care, and keeping it means a project that pins
|
|
635
|
+
* the old image still works.
|
|
636
|
+
*/
|
|
637
|
+
const EMULATOR_CREDENTIALS = {
|
|
638
|
+
accessKeyId: "LSIAQAAAAAAVNCBMPNSG",
|
|
639
|
+
secretAccessKey: "geekmidas"
|
|
640
|
+
};
|
|
641
|
+
/** The emulator's address from the host, on the port it was published on. */
|
|
642
|
+
function emulatorEndpoint(port) {
|
|
643
|
+
return `http://localhost:${port}`;
|
|
278
644
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
forcePathStyle: true,
|
|
285
|
-
credentials: {
|
|
286
|
-
accessKeyId: LOCAL_USER$2,
|
|
287
|
-
secretAccessKey: LOCAL_USER$2
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
return {
|
|
291
|
-
async exists(bucket) {
|
|
292
|
-
try {
|
|
293
|
-
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
294
|
-
return true;
|
|
295
|
-
} catch {
|
|
296
|
-
return false;
|
|
297
|
-
}
|
|
298
|
-
},
|
|
299
|
-
async create(bucket) {
|
|
300
|
-
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
301
|
-
},
|
|
302
|
-
async policy(bucket) {
|
|
303
|
-
try {
|
|
304
|
-
const result = await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }));
|
|
305
|
-
return result.Policy;
|
|
306
|
-
} catch {
|
|
307
|
-
return void 0;
|
|
308
|
-
}
|
|
309
|
-
},
|
|
310
|
-
async setPolicy(bucket, policy) {
|
|
311
|
-
await s3.send(new PutBucketPolicyCommand({
|
|
312
|
-
Bucket: bucket,
|
|
313
|
-
Policy: policy
|
|
314
|
-
}));
|
|
315
|
-
}
|
|
316
|
-
};
|
|
645
|
+
function emulatorTopicArn(name) {
|
|
646
|
+
return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
|
|
647
|
+
}
|
|
648
|
+
function emulatorQueueUrl(name, port) {
|
|
649
|
+
return `${emulatorEndpoint(port)}/${EMULATOR_ACCOUNT}/${name}`;
|
|
317
650
|
}
|
|
318
|
-
|
|
319
651
|
//#endregion
|
|
320
|
-
//#region src/
|
|
652
|
+
//#region src/workspace/rootSite.ts
|
|
653
|
+
/**
|
|
654
|
+
* Which site the base domain points at — deployed and locally alike.
|
|
655
|
+
*
|
|
656
|
+
* Three rules, in order, and the last one is the point:
|
|
657
|
+
*
|
|
658
|
+
* 1. **One site** — it is the root. Unambiguous, so nothing has to be said.
|
|
659
|
+
* 2. **Named `web`** — the convention wins, because people already rely on it.
|
|
660
|
+
* 3. **Declared `root: true`** — the site says so itself.
|
|
661
|
+
*
|
|
662
|
+
* Anything else throws. It used to take the *first* site it iterated, and that
|
|
663
|
+
* order came from the manifest, which is built in glob traversal order — so two
|
|
664
|
+
* sites with neither named `web` meant renaming a file could move the root
|
|
665
|
+
* domain, silently.
|
|
666
|
+
*
|
|
667
|
+
* One rule for both targets: deployed the root site gets the base domain, and
|
|
668
|
+
* locally it gets the project's bare host behind the edge. Before this was
|
|
669
|
+
* shared, the local edge put every site on a subdomain, so what a developer
|
|
670
|
+
* browsed was not the shape production served.
|
|
671
|
+
*
|
|
672
|
+
* @returns the root site's name, or `undefined` when there are no sites
|
|
673
|
+
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
674
|
+
*/
|
|
675
|
+
function rootSite(sites) {
|
|
676
|
+
if (sites.length === 0) return void 0;
|
|
677
|
+
if (sites.length === 1) return sites[0].name;
|
|
678
|
+
if (sites.some(({ name }) => name === "web")) return "web";
|
|
679
|
+
const declared = sites.filter(({ root }) => root === true);
|
|
680
|
+
if (declared.length === 1) return declared[0].name;
|
|
681
|
+
throw new AmbiguousRootSite(sites.map(({ name }) => name), declared.length > 1);
|
|
682
|
+
}
|
|
683
|
+
/** Several sites could hold the base domain, and none of them says it does. */
|
|
684
|
+
var AmbiguousRootSite = class extends Error {
|
|
685
|
+
sites;
|
|
686
|
+
tooMany;
|
|
687
|
+
constructor(sites, tooMany = false) {
|
|
688
|
+
super(tooMany ? `More than one site declares \`root: true\` — ${sites.join(", ")}. The base domain points at one of them.` : `${sites.length} sites and nothing says which holds the base domain: ${sites.join(", ")}. Name one of them \`web\`, or declare \`root: true\` on the one the base domain points at. It used to be whichever the glob reached first, which is not a thing to decide a hostname.`);
|
|
689
|
+
this.sites = sites;
|
|
690
|
+
this.tooMany = tooMany;
|
|
691
|
+
this.name = "AmbiguousRootSite";
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
//#endregion
|
|
695
|
+
//#region src/reconcile/plan.ts
|
|
321
696
|
/**
|
|
322
|
-
*
|
|
697
|
+
* The reconcile plan — what a manifest wants to exist, for one stage.
|
|
323
698
|
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* the design asks for — neutral facts from the declaration, provider detail
|
|
328
|
-
* from here and from config.
|
|
699
|
+
* Pure. Turning the plan into running containers and provisioned resources is
|
|
700
|
+
* the applier's job; deciding *what* should exist is this, so the decisions can
|
|
701
|
+
* be asserted as data rather than by starting Docker.
|
|
329
702
|
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
703
|
+
* One plan serves every stage. `gkm dev` reconciles `development` and `gkm test`
|
|
704
|
+
* reconciles `test`; they differ only in what the resources are called, never in
|
|
705
|
+
* what infrastructure exists. Roles are cluster-scoped in Postgres while grants
|
|
706
|
+
* are per-object, so one container and one role pair serve both.
|
|
332
707
|
*/
|
|
333
708
|
/**
|
|
334
|
-
*
|
|
709
|
+
* Which container serves a kind.
|
|
335
710
|
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
711
|
+
* Derived kinds map to the same container as their parent — a schema tenant
|
|
712
|
+
* lives in its parent's Postgres — so the set is deduplicated rather than
|
|
713
|
+
* counted.
|
|
338
714
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
715
|
+
const CONTAINERS = {
|
|
716
|
+
database: "postgres",
|
|
717
|
+
"database-reader": "postgres",
|
|
718
|
+
"database-schema": "postgres",
|
|
719
|
+
objects: "minio",
|
|
720
|
+
"file-server": "caddy",
|
|
721
|
+
email: "mailpit"
|
|
722
|
+
};
|
|
342
723
|
/**
|
|
343
|
-
* The
|
|
724
|
+
* The container a cache backend needs locally.
|
|
344
725
|
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
726
|
+
* Each one matches the protocol its deployed form speaks, which is the property
|
|
727
|
+
* worth having: a cache that behaves differently in the two places is worse than
|
|
728
|
+
* a slower one.
|
|
729
|
+
*
|
|
730
|
+
* `db` is deliberately absent, the same way `pgboss` is absent from
|
|
731
|
+
* `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
|
|
732
|
+
* declares, so mapping it here would start a container for a project that needs
|
|
733
|
+
* none.
|
|
348
734
|
*/
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
mailpit: "axllent/mailpit:latest",
|
|
353
|
-
redis: "redis:8-alpine",
|
|
354
|
-
"redis-http": "hiett/serverless-redis-http:latest",
|
|
355
|
-
rabbitmq: "rabbitmq:4-management-alpine",
|
|
356
|
-
localstack: "floci/floci:latest",
|
|
357
|
-
caddy: "caddy:2-alpine"
|
|
735
|
+
const CACHE_CONTAINERS = {
|
|
736
|
+
upstash: "redis-http",
|
|
737
|
+
elasticache: "redis"
|
|
358
738
|
};
|
|
359
739
|
/**
|
|
360
|
-
*
|
|
740
|
+
* Kinds that resolve a value without anything running.
|
|
361
741
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
742
|
+
* A secret is the case: it has no address, so there is nothing to start and
|
|
743
|
+
* nothing to connect to — but it still appears in the plan, because the target
|
|
744
|
+
* still has to resolve it.
|
|
365
745
|
*/
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
key: "minio",
|
|
374
|
-
inside: 9e3,
|
|
375
|
-
label: "minio api"
|
|
376
|
-
}, {
|
|
377
|
-
key: "minio-console",
|
|
378
|
-
inside: 9001,
|
|
379
|
-
label: "minio console"
|
|
380
|
-
}],
|
|
381
|
-
mailpit: [{
|
|
382
|
-
key: "mailpit",
|
|
383
|
-
inside: 1025,
|
|
384
|
-
label: "smtp"
|
|
385
|
-
}, {
|
|
386
|
-
key: "mailpit-web",
|
|
387
|
-
inside: 8025,
|
|
388
|
-
label: "mailpit inbox"
|
|
389
|
-
}],
|
|
390
|
-
redis: [{
|
|
391
|
-
key: "redis",
|
|
392
|
-
inside: 6379,
|
|
393
|
-
label: "redis"
|
|
394
|
-
}],
|
|
395
|
-
"redis-http": [{
|
|
396
|
-
key: "redis-http",
|
|
397
|
-
inside: 80,
|
|
398
|
-
label: "cache"
|
|
399
|
-
}],
|
|
400
|
-
rabbitmq: [{
|
|
401
|
-
key: "rabbitmq",
|
|
402
|
-
inside: 5672,
|
|
403
|
-
label: "amqp"
|
|
404
|
-
}, {
|
|
405
|
-
key: "rabbitmq-management",
|
|
406
|
-
inside: 15672,
|
|
407
|
-
label: "rabbitmq console"
|
|
408
|
-
}],
|
|
409
|
-
localstack: [{
|
|
410
|
-
key: "localstack",
|
|
411
|
-
inside: 4566,
|
|
412
|
-
label: "localstack"
|
|
413
|
-
}],
|
|
414
|
-
caddy: [{
|
|
415
|
-
key: "caddy",
|
|
416
|
-
inside: 443,
|
|
417
|
-
label: "https edge"
|
|
418
|
-
}]
|
|
746
|
+
const CONTAINERLESS = {
|
|
747
|
+
secret: true,
|
|
748
|
+
credential: true,
|
|
749
|
+
"rest-api": true,
|
|
750
|
+
site: true,
|
|
751
|
+
"mobile-app": true,
|
|
752
|
+
"external-api": true
|
|
419
753
|
};
|
|
420
|
-
/** The ports a container needs published. Empty for one gkm does not know. */
|
|
421
|
-
function portsOf(container) {
|
|
422
|
-
return PORTS[container] ?? [];
|
|
423
|
-
}
|
|
424
|
-
/** The key a container's primary port is allocated under. */
|
|
425
|
-
function primaryPortKey(container) {
|
|
426
|
-
return portsOf(container)[0]?.key ?? container;
|
|
427
|
-
}
|
|
428
754
|
/**
|
|
429
|
-
*
|
|
755
|
+
* Containers that cannot run alone.
|
|
430
756
|
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
757
|
+
* The one case is the cache: `serverless-redis-http` is a proxy and needs the
|
|
758
|
+
* Redis it proxies. Kept here rather than in the compose definition so the plan
|
|
759
|
+
* -- which is what decides what must be running -- still names everything.
|
|
433
760
|
*/
|
|
434
|
-
|
|
435
|
-
return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
|
|
436
|
-
}
|
|
761
|
+
const REQUIRES = { "redis-http": ["redis"] };
|
|
437
762
|
/**
|
|
438
|
-
* The
|
|
763
|
+
* The role each kind's one key plays, where it is not a URL.
|
|
439
764
|
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
765
|
+
* The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
|
|
766
|
+
* construct declares, this is what the local target resolves, and both derive
|
|
767
|
+
* the key from the same `provideKey` so they cannot disagree.
|
|
442
768
|
*/
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
769
|
+
const ROLES = {
|
|
770
|
+
queue: "publisherConnectionString",
|
|
771
|
+
topic: "publisherConnectionString",
|
|
772
|
+
credential: "credentials",
|
|
773
|
+
"mobile-app": "scheme"
|
|
774
|
+
};
|
|
775
|
+
/** The kinds whose container is the events backend's rather than their own. */
|
|
776
|
+
const EVENT_KINDS = {
|
|
777
|
+
queue: true,
|
|
778
|
+
topic: true
|
|
779
|
+
};
|
|
780
|
+
/**
|
|
781
|
+
* Which kinds provision something *inside* their container.
|
|
782
|
+
*
|
|
783
|
+
* A database creates a database, a bucket creates a bucket; mail creates
|
|
784
|
+
* nothing — Mailpit accepts whatever is sent to it and has no per-stage object
|
|
785
|
+
* to name. Both still appear in the plan, because both still resolve a URL; the
|
|
786
|
+
* flag is the difference between "must be running" and "must be created".
|
|
787
|
+
*/
|
|
788
|
+
const PROVISIONS = {
|
|
789
|
+
database: true,
|
|
790
|
+
"database-reader": true,
|
|
791
|
+
"database-schema": true,
|
|
792
|
+
objects: true,
|
|
793
|
+
cache: true,
|
|
794
|
+
"file-server": true
|
|
795
|
+
};
|
|
796
|
+
/**
|
|
797
|
+
* The container each events backend needs, where it needs one of its own.
|
|
798
|
+
*
|
|
799
|
+
* `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
|
|
800
|
+
* schema tenant in a database the manifest already declares, so mapping it here
|
|
801
|
+
* would start a Postgres for a project that declared no database at all.
|
|
802
|
+
*
|
|
803
|
+
* This one comes from config rather than from a declaration because there are
|
|
804
|
+
* no `topic` or `queue` kinds yet. When they land the mapping moves to
|
|
805
|
+
* {@link CONTAINERS} keyed by kind, and this constant goes with them.
|
|
806
|
+
*/
|
|
807
|
+
const EVENT_CONTAINERS = {
|
|
808
|
+
sns: "localstack",
|
|
809
|
+
rabbitmq: "rabbitmq"
|
|
810
|
+
};
|
|
811
|
+
/** The backend a project gets when it declares queues or topics and says nothing. */
|
|
812
|
+
const DEFAULT_EVENTS = "pgboss";
|
|
813
|
+
/**
|
|
814
|
+
* How a resource of each kind separates its stage suffix.
|
|
815
|
+
*
|
|
816
|
+
* Postgres identifiers are snake_case and a hyphen would need quoting
|
|
817
|
+
* everywhere; buckets and queues are kebab and disallow underscores in some
|
|
818
|
+
* providers. The suffix is the same word either way.
|
|
819
|
+
*/
|
|
820
|
+
const SEPARATORS = {
|
|
821
|
+
database: "_",
|
|
822
|
+
"database-reader": "_",
|
|
823
|
+
"database-schema": "_",
|
|
824
|
+
objects: "-"
|
|
825
|
+
};
|
|
826
|
+
/**
|
|
827
|
+
* The stage-scoped name for a resource.
|
|
828
|
+
*
|
|
829
|
+
* The local stage carries no suffix, so `gkm dev` keeps using `orders` and a
|
|
830
|
+
* developer's psql history, saved connections, and muscle memory survive a
|
|
831
|
+
* change of stage name. Every other stage is suffixed.
|
|
832
|
+
*/
|
|
833
|
+
function resourceName(id, kind, stage, localStage) {
|
|
834
|
+
const base = id.toLowerCase();
|
|
835
|
+
if (stage === localStage) return base;
|
|
836
|
+
return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
|
|
453
837
|
}
|
|
454
|
-
|
|
455
|
-
//#endregion
|
|
456
|
-
//#region src/reconcile/compose.ts
|
|
457
|
-
/** The credential every derived container is brought up with locally. */
|
|
458
|
-
const LOCAL_USER$1 = "geekmidas";
|
|
459
838
|
/**
|
|
460
|
-
* The
|
|
839
|
+
* The container a kind needs, or `undefined` if it needs none.
|
|
461
840
|
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
841
|
+
* Queues and topics have no container of their own — they live in whichever
|
|
842
|
+
* broker the project selected, which is why the backend is an argument here
|
|
843
|
+
* rather than a second pass that adds containers after the fact.
|
|
465
844
|
*/
|
|
466
|
-
|
|
845
|
+
function containerFor(kind, events = DEFAULT_EVENTS, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
|
|
846
|
+
if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
|
|
847
|
+
if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
|
|
848
|
+
if (kind === "cache") {
|
|
849
|
+
if (derived) return CONTAINERS.database;
|
|
850
|
+
return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
|
|
851
|
+
}
|
|
852
|
+
return CONTAINERS[kind];
|
|
853
|
+
}
|
|
467
854
|
/**
|
|
468
|
-
* Build the
|
|
855
|
+
* Build the plan for a manifest and stage.
|
|
469
856
|
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
857
|
+
* `order` is the caller's provisioning order — parents before children — which
|
|
858
|
+
* `provisionOrder` produces from the manifest. Passing it in keeps this
|
|
859
|
+
* function free of the traversal and makes the ordering assertable on its own.
|
|
473
860
|
*/
|
|
474
|
-
function
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
861
|
+
function planFor(manifest, stage, order, options = {}) {
|
|
862
|
+
const containers = /* @__PURE__ */ new Set();
|
|
863
|
+
const resources = [];
|
|
864
|
+
const fakes = {};
|
|
865
|
+
const events = options.events ?? "pgboss";
|
|
866
|
+
const cache = options.cache ?? DEFAULT_CACHE.aws;
|
|
867
|
+
for (const id of order) {
|
|
868
|
+
const declaration = manifest[id];
|
|
869
|
+
if (!declaration) continue;
|
|
870
|
+
const local = declaration.kind === "external-api" ? options.fakes?.[id] : void 0;
|
|
871
|
+
const fake = local && {
|
|
872
|
+
key: fakeKey(id),
|
|
873
|
+
...local
|
|
874
|
+
};
|
|
875
|
+
if (fake) fakes[fake.key] = {
|
|
876
|
+
id,
|
|
877
|
+
...fake.image ? {
|
|
878
|
+
image: fake.image,
|
|
879
|
+
port: fake.port
|
|
880
|
+
} : {}
|
|
881
|
+
};
|
|
882
|
+
const container = fake?.image ? fake.key : containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
|
|
883
|
+
if (!container && !CONTAINERLESS[declaration.kind]) continue;
|
|
884
|
+
if (container) {
|
|
885
|
+
containers.add(container);
|
|
886
|
+
for (const required of REQUIRES[container] ?? []) containers.add(required);
|
|
887
|
+
}
|
|
888
|
+
resources.push({
|
|
889
|
+
id,
|
|
890
|
+
kind: declaration.kind,
|
|
891
|
+
...container ? { container } : {},
|
|
892
|
+
name: resourceName(id, declaration.kind, stage, options.localStage),
|
|
893
|
+
envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
|
|
894
|
+
provisions: PROVISIONS[declaration.kind] === true,
|
|
895
|
+
...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
|
|
896
|
+
...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
|
|
897
|
+
...declaration.kind === "mobile-app" ? {
|
|
898
|
+
publicEnv: publicEnvFor(declaration, manifest),
|
|
899
|
+
...declaration.scheme ? { scheme: declaration.scheme } : {}
|
|
900
|
+
} : {},
|
|
901
|
+
...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
|
|
902
|
+
...fake ? { fake: {
|
|
903
|
+
key: fake.key,
|
|
904
|
+
credentials: fake.credentials
|
|
905
|
+
} } : {},
|
|
906
|
+
...declaration.kind === "external-api" && !fake ? { url: declaration.url } : {},
|
|
907
|
+
..."of" in declaration ? { of: declaration.of } : {},
|
|
908
|
+
..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
|
|
909
|
+
..."version" in declaration && declaration.version ? { version: declaration.version } : {},
|
|
910
|
+
..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
|
|
911
|
+
..."table" in declaration && declaration.table ? { table: declaration.table } : {}
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
|
|
915
|
+
for (const extra of options.extraContainers ?? []) containers.add(extra);
|
|
916
|
+
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));
|
|
917
|
+
if (cache === "db") {
|
|
918
|
+
const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
|
|
919
|
+
if (placeless.length > 0) {
|
|
920
|
+
const databases = resources.filter((r) => r.kind === "database");
|
|
921
|
+
const [only] = databases;
|
|
922
|
+
if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
|
|
923
|
+
if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
|
|
924
|
+
for (const resource of placeless) resource.of = only.id;
|
|
925
|
+
}
|
|
484
926
|
}
|
|
927
|
+
const sites = resources.filter((r) => r.kind === "site");
|
|
928
|
+
const root = rootSite(sites.map((r) => {
|
|
929
|
+
const declared = manifest[r.id];
|
|
930
|
+
return {
|
|
931
|
+
name: appKey(r.id),
|
|
932
|
+
...declared?.kind === "site" && declared.root ? { root: true } : {}
|
|
933
|
+
};
|
|
934
|
+
}));
|
|
935
|
+
for (const site of sites) if (appKey(site.id) === root) site.root = true;
|
|
936
|
+
const workerBroker = events === "pgboss" && Object.values(manifest).some((d) => d?.kind === "worker") && resources.some((r) => r.kind === "database");
|
|
485
937
|
return {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
938
|
+
stage,
|
|
939
|
+
events,
|
|
940
|
+
cache,
|
|
941
|
+
containers: [...containers],
|
|
942
|
+
resources,
|
|
943
|
+
...Object.keys(fakes).length ? { fakes } : {},
|
|
944
|
+
...workerBroker ? { workerBroker: true } : {}
|
|
489
945
|
};
|
|
490
946
|
}
|
|
491
|
-
/**
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
container;
|
|
505
|
-
/** The containers that have a default, so the caller can see the set. */
|
|
506
|
-
known = Object.keys(DEFAULT_IMAGES).sort();
|
|
507
|
-
constructor(container) {
|
|
508
|
-
super("No image is known for this container; pin one in config");
|
|
509
|
-
this.name = "UnknownContainer";
|
|
510
|
-
this.container = container;
|
|
947
|
+
/**
|
|
948
|
+
* Queues or topics were declared with pg-boss and no database to hold them.
|
|
949
|
+
*
|
|
950
|
+
* Not a reconcile failure but a manifest one: the fix is a line of application
|
|
951
|
+
* code either way — declare a database, or select a backend that brings its own
|
|
952
|
+
* broker.
|
|
953
|
+
*/
|
|
954
|
+
var CacheNeedsDatabase = class extends Error {
|
|
955
|
+
ids;
|
|
956
|
+
constructor(ids) {
|
|
957
|
+
super(`A cache backed by the database needs a declared database, and none was declared, and on this deploy target that is where a cache lives. Declare one. Caches affected: ${ids.join(", ")}.`);
|
|
958
|
+
this.ids = ids;
|
|
959
|
+
this.name = "CacheNeedsDatabase";
|
|
511
960
|
}
|
|
512
961
|
};
|
|
513
|
-
/** One container's definition, published on the port it was assigned. */
|
|
514
|
-
function define(container, image, published) {
|
|
515
|
-
switch (container) {
|
|
516
|
-
case "postgres": return {
|
|
517
|
-
image,
|
|
518
|
-
restart: "unless-stopped",
|
|
519
|
-
ports: published,
|
|
520
|
-
environment: {
|
|
521
|
-
POSTGRES_USER: LOCAL_USER$1,
|
|
522
|
-
POSTGRES_PASSWORD: LOCAL_USER$1,
|
|
523
|
-
POSTGRES_DB: LOCAL_USER$1
|
|
524
|
-
},
|
|
525
|
-
volumes: ["postgres-data:/var/lib/postgresql"],
|
|
526
|
-
healthcheck: {
|
|
527
|
-
test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER$1}`],
|
|
528
|
-
interval: "5s",
|
|
529
|
-
timeout: "3s",
|
|
530
|
-
retries: 10,
|
|
531
|
-
start_period: "10s"
|
|
532
|
-
}
|
|
533
|
-
};
|
|
534
|
-
case "minio": return {
|
|
535
|
-
image,
|
|
536
|
-
restart: "unless-stopped",
|
|
537
|
-
ports: published,
|
|
538
|
-
environment: {
|
|
539
|
-
MINIO_ROOT_USER: LOCAL_USER$1,
|
|
540
|
-
MINIO_ROOT_PASSWORD: LOCAL_USER$1
|
|
541
|
-
},
|
|
542
|
-
command: "server --console-address \":9001\" /data",
|
|
543
|
-
volumes: ["minio-data:/data"],
|
|
544
|
-
healthcheck: {
|
|
545
|
-
test: [
|
|
546
|
-
"CMD",
|
|
547
|
-
"mc",
|
|
548
|
-
"ready",
|
|
549
|
-
"local"
|
|
550
|
-
],
|
|
551
|
-
interval: "10s",
|
|
552
|
-
timeout: "5s",
|
|
553
|
-
retries: 5,
|
|
554
|
-
start_period: "10s"
|
|
555
|
-
}
|
|
556
|
-
};
|
|
557
|
-
case "caddy": return {
|
|
558
|
-
image,
|
|
559
|
-
restart: "unless-stopped",
|
|
560
|
-
ports: published,
|
|
561
|
-
volumes: [
|
|
562
|
-
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
|
563
|
-
"./caddy-sites:/etc/caddy/sites:ro",
|
|
564
|
-
"caddy-data:/data"
|
|
565
|
-
],
|
|
566
|
-
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
567
|
-
healthcheck: {
|
|
568
|
-
test: [
|
|
569
|
-
"CMD",
|
|
570
|
-
"caddy",
|
|
571
|
-
"version"
|
|
572
|
-
],
|
|
573
|
-
interval: "10s",
|
|
574
|
-
timeout: "5s",
|
|
575
|
-
retries: 5
|
|
576
|
-
}
|
|
577
|
-
};
|
|
578
|
-
case "mailpit": return {
|
|
579
|
-
image,
|
|
580
|
-
restart: "unless-stopped",
|
|
581
|
-
ports: published,
|
|
582
|
-
environment: {
|
|
583
|
-
MP_SMTP_AUTH_ACCEPT_ANY: "1",
|
|
584
|
-
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
|
|
585
|
-
},
|
|
586
|
-
healthcheck: {
|
|
587
|
-
test: [
|
|
588
|
-
"CMD",
|
|
589
|
-
"wget",
|
|
590
|
-
"-q",
|
|
591
|
-
"--spider",
|
|
592
|
-
"http://localhost:8025"
|
|
593
|
-
],
|
|
594
|
-
interval: "10s",
|
|
595
|
-
timeout: "5s",
|
|
596
|
-
retries: 5
|
|
597
|
-
}
|
|
598
|
-
};
|
|
599
|
-
case "redis": return {
|
|
600
|
-
image,
|
|
601
|
-
restart: "unless-stopped",
|
|
602
|
-
ports: published,
|
|
603
|
-
volumes: ["redis-data:/data"],
|
|
604
|
-
healthcheck: {
|
|
605
|
-
test: [
|
|
606
|
-
"CMD",
|
|
607
|
-
"redis-cli",
|
|
608
|
-
"ping"
|
|
609
|
-
],
|
|
610
|
-
interval: "5s",
|
|
611
|
-
timeout: "3s",
|
|
612
|
-
retries: 5
|
|
613
|
-
}
|
|
614
|
-
};
|
|
615
|
-
case "redis-http": return {
|
|
616
|
-
image,
|
|
617
|
-
restart: "unless-stopped",
|
|
618
|
-
ports: published,
|
|
619
|
-
environment: {
|
|
620
|
-
SRH_MODE: "env",
|
|
621
|
-
SRH_TOKEN: LOCAL_TOKEN$1,
|
|
622
|
-
SRH_CONNECTION_STRING: "redis://redis:6379"
|
|
623
|
-
},
|
|
624
|
-
depends_on: ["redis"],
|
|
625
|
-
healthcheck: {
|
|
626
|
-
test: [
|
|
627
|
-
"CMD",
|
|
628
|
-
"wget",
|
|
629
|
-
"-q",
|
|
630
|
-
"-O",
|
|
631
|
-
"/dev/null",
|
|
632
|
-
"http://127.0.0.1:80/"
|
|
633
|
-
],
|
|
634
|
-
interval: "5s",
|
|
635
|
-
timeout: "3s",
|
|
636
|
-
retries: 10
|
|
637
|
-
}
|
|
638
|
-
};
|
|
639
|
-
case "rabbitmq": return {
|
|
640
|
-
image,
|
|
641
|
-
restart: "unless-stopped",
|
|
642
|
-
ports: published,
|
|
643
|
-
environment: {
|
|
644
|
-
RABBITMQ_DEFAULT_USER: LOCAL_USER$1,
|
|
645
|
-
RABBITMQ_DEFAULT_PASS: LOCAL_USER$1
|
|
646
|
-
},
|
|
647
|
-
volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
|
|
648
|
-
healthcheck: {
|
|
649
|
-
test: [
|
|
650
|
-
"CMD",
|
|
651
|
-
"rabbitmq-diagnostics",
|
|
652
|
-
"-q",
|
|
653
|
-
"ping"
|
|
654
|
-
],
|
|
655
|
-
interval: "10s",
|
|
656
|
-
timeout: "5s",
|
|
657
|
-
retries: 5
|
|
658
|
-
}
|
|
659
|
-
};
|
|
660
|
-
case "localstack": return {
|
|
661
|
-
image,
|
|
662
|
-
restart: "unless-stopped",
|
|
663
|
-
ports: published,
|
|
664
|
-
environment: {
|
|
665
|
-
AWS_DEFAULT_REGION: "us-east-1",
|
|
666
|
-
AWS_ACCESS_KEY_ID: "LSIAQAAAAAAVNCBMPNSG",
|
|
667
|
-
AWS_SECRET_ACCESS_KEY: LOCAL_USER$1
|
|
668
|
-
},
|
|
669
|
-
volumes: ["localstack-data:/var/lib/localstack"],
|
|
670
|
-
healthcheck: {
|
|
671
|
-
test: [
|
|
672
|
-
"CMD",
|
|
673
|
-
"curl",
|
|
674
|
-
"-f",
|
|
675
|
-
"http://localhost:4566/_localstack/health"
|
|
676
|
-
],
|
|
677
|
-
interval: "10s",
|
|
678
|
-
timeout: "5s",
|
|
679
|
-
retries: 5,
|
|
680
|
-
start_period: "10s"
|
|
681
|
-
}
|
|
682
|
-
};
|
|
683
|
-
default: throw new UnknownContainer(container);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
962
|
/**
|
|
687
|
-
*
|
|
963
|
+
* A `'db'` cache — a server target's default — in an app that declares more
|
|
964
|
+
* than one database.
|
|
688
965
|
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
966
|
+
* The target says *a* database and the graph offers several, so there is no
|
|
967
|
+
* answer to give — and the wrong kind of failure would be to pick one, since a
|
|
968
|
+
* cache silently landing in the wrong database is a bug that surfaces as
|
|
969
|
+
* missing rows much later. The fix is a stronger statement in application code:
|
|
970
|
+
* `orders.cache('Sessions')` names the database, which is a fact about the
|
|
971
|
+
* application rather than a deployment choice.
|
|
692
972
|
*/
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
var UnassignedPort = class extends Error {
|
|
702
|
-
/** The port key that went unassigned, e.g. `minio-console`. */
|
|
703
|
-
key;
|
|
704
|
-
constructor(key) {
|
|
705
|
-
super("No port was assigned for this container port");
|
|
706
|
-
this.name = "UnassignedPort";
|
|
707
|
-
this.key = key;
|
|
973
|
+
var CacheIsAmbiguous = class extends Error {
|
|
974
|
+
ids;
|
|
975
|
+
databases;
|
|
976
|
+
constructor(ids, databases) {
|
|
977
|
+
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 as a bare new Cache(). Caches affected: ${ids.join(", ")}.`);
|
|
978
|
+
this.ids = ids;
|
|
979
|
+
this.databases = databases;
|
|
980
|
+
this.name = "CacheIsAmbiguous";
|
|
708
981
|
}
|
|
709
982
|
};
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const dockerCli = {
|
|
717
|
-
async publishedPort(composePath, service, inside) {
|
|
718
|
-
try {
|
|
719
|
-
const { stdout } = await run("docker", [
|
|
720
|
-
"compose",
|
|
721
|
-
"-f",
|
|
722
|
-
composePath,
|
|
723
|
-
"port",
|
|
724
|
-
service,
|
|
725
|
-
String(inside)
|
|
726
|
-
]);
|
|
727
|
-
const port = Number(stdout.trim().split(":").pop());
|
|
728
|
-
return Number.isFinite(port) && port > 0 ? port : void 0;
|
|
729
|
-
} catch {
|
|
730
|
-
return void 0;
|
|
731
|
-
}
|
|
732
|
-
},
|
|
733
|
-
async up(composePath, services) {
|
|
734
|
-
await run("docker", [
|
|
735
|
-
"compose",
|
|
736
|
-
"-f",
|
|
737
|
-
composePath,
|
|
738
|
-
"up",
|
|
739
|
-
"-d",
|
|
740
|
-
"--remove-orphans",
|
|
741
|
-
"--wait",
|
|
742
|
-
`--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
|
|
743
|
-
...services
|
|
744
|
-
]);
|
|
745
|
-
},
|
|
746
|
-
async copyOut(composePath, service, from, to) {
|
|
747
|
-
await mkdir(dirname(to), { recursive: true });
|
|
748
|
-
await run("docker", [
|
|
749
|
-
"compose",
|
|
750
|
-
"-f",
|
|
751
|
-
composePath,
|
|
752
|
-
"cp",
|
|
753
|
-
`${service}:${from}`,
|
|
754
|
-
to
|
|
755
|
-
]);
|
|
756
|
-
},
|
|
757
|
-
async reload(composePath, service) {
|
|
758
|
-
await run("docker", [
|
|
759
|
-
"compose",
|
|
760
|
-
"-f",
|
|
761
|
-
composePath,
|
|
762
|
-
"exec",
|
|
763
|
-
"-T",
|
|
764
|
-
service,
|
|
765
|
-
"caddy",
|
|
766
|
-
"reload",
|
|
767
|
-
"--config",
|
|
768
|
-
"/etc/caddy/Caddyfile"
|
|
769
|
-
]);
|
|
770
|
-
},
|
|
771
|
-
async healthy(composePath, services) {
|
|
772
|
-
if (services.length === 0) return true;
|
|
773
|
-
try {
|
|
774
|
-
const { stdout } = await run("docker", [
|
|
775
|
-
"compose",
|
|
776
|
-
"-f",
|
|
777
|
-
composePath,
|
|
778
|
-
"ps",
|
|
779
|
-
"--format",
|
|
780
|
-
"json",
|
|
781
|
-
...services
|
|
782
|
-
]);
|
|
783
|
-
const running = /* @__PURE__ */ new Set();
|
|
784
|
-
for (const line of stdout.trim().split("\n").filter(Boolean)) {
|
|
785
|
-
const entry = JSON.parse(line);
|
|
786
|
-
const ok = entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy");
|
|
787
|
-
if (ok && entry.Service) running.add(entry.Service);
|
|
788
|
-
}
|
|
789
|
-
return services.every((service) => running.has(service));
|
|
790
|
-
} catch {
|
|
791
|
-
return false;
|
|
792
|
-
}
|
|
983
|
+
var PgBossNeedsDatabase = class extends Error {
|
|
984
|
+
ids;
|
|
985
|
+
constructor(ids) {
|
|
986
|
+
super(`pg-boss keeps its queues in a declared database, and none was declared. Declare one: on this deploy target, events are carried by pg-boss. Queues and topics affected: ${ids.join(", ")}.`);
|
|
987
|
+
this.ids = ids;
|
|
988
|
+
this.name = "PgBossNeedsDatabase";
|
|
793
989
|
}
|
|
794
990
|
};
|
|
795
|
-
|
|
796
991
|
//#endregion
|
|
797
|
-
//#region src/reconcile/
|
|
798
|
-
/** The stage whose resources carry no suffix. */
|
|
799
|
-
const DEFAULT_STAGE = "development";
|
|
992
|
+
//#region src/reconcile/env.ts
|
|
800
993
|
/**
|
|
801
|
-
*
|
|
994
|
+
* The URLs a reconciled stage injects.
|
|
802
995
|
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
996
|
+
* One `<NAME>_URL` per construct, and the protocol picks the driver. This is the
|
|
997
|
+
* seam between the target that writes a URL and the client that parses it, so
|
|
998
|
+
* every component of it is read off the resource — never inherited from ambient
|
|
999
|
+
* environment. A bucket can live in a different region than the function reading
|
|
1000
|
+
* it, and `AWS_REGION` in a Lambda is the *function's* region, so a URL that
|
|
1001
|
+
* omits `?region=` breaks cross-region silently at runtime.
|
|
1002
|
+
*
|
|
1003
|
+
* Locally that self-containment is what lets the port be whatever was free: the
|
|
1004
|
+
* app reads `ORDERS_URL` and never sees a host or a port.
|
|
806
1005
|
*/
|
|
807
|
-
const CONTAINERS = {
|
|
808
|
-
database: "postgres",
|
|
809
|
-
"database-reader": "postgres",
|
|
810
|
-
"database-schema": "postgres",
|
|
811
|
-
objects: "minio",
|
|
812
|
-
"file-server": "caddy",
|
|
813
|
-
email: "mailpit"
|
|
814
|
-
};
|
|
815
1006
|
/**
|
|
816
|
-
* The
|
|
1007
|
+
* The credential local containers are brought up with.
|
|
817
1008
|
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
1009
|
+
* The design's `roles: false` case — both URLs fall back to the cluster's master
|
|
1010
|
+
* credential. A deliberate downgrade, not a default: it holds until the role DDL
|
|
1011
|
+
* lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
|
|
1012
|
+
* and the owner URL stops being reachable from any manifest edge.
|
|
1013
|
+
*/
|
|
1014
|
+
const LOCAL_USER$2 = "geekmidas";
|
|
1015
|
+
/** The host containers are published on. */
|
|
1016
|
+
const LOCAL_HOST = "localhost";
|
|
1017
|
+
/** The region local buckets claim, so the client has one to sign with. */
|
|
1018
|
+
const LOCAL_REGION = "us-east-1";
|
|
1019
|
+
/**
|
|
1020
|
+
* The credentials the S3 client resolves for MinIO.
|
|
821
1021
|
*
|
|
822
|
-
* `
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
1022
|
+
* An `s3://` URL deliberately carries none: deployed, the SDK reads them from
|
|
1023
|
+
* the execution role, so a URL that embedded a key would be one more thing to
|
|
1024
|
+
* rotate and one more thing to leak into a log. Locally there is no role, and
|
|
1025
|
+
* the same chain reads these — which is why they are injected beside the URL
|
|
1026
|
+
* rather than written into it.
|
|
826
1027
|
*/
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
1028
|
+
const STORAGE_CREDENTIALS = {
|
|
1029
|
+
AWS_ACCESS_KEY_ID: LOCAL_USER$2,
|
|
1030
|
+
AWS_SECRET_ACCESS_KEY: LOCAL_USER$2,
|
|
1031
|
+
AWS_REGION: LOCAL_REGION
|
|
830
1032
|
};
|
|
1033
|
+
/** The token the local cache proxy accepts. Matches the compose definition. */
|
|
1034
|
+
const LOCAL_TOKEN$1 = "geekmidas";
|
|
1035
|
+
/** The port Mailpit's inbox — its web UI and HTTP API — is published under. */
|
|
1036
|
+
const MAILPIT_INBOX_PORT = "mailpit-web";
|
|
1037
|
+
/** The schema pg-boss keeps its tables in, inside the declared database. */
|
|
1038
|
+
const PGBOSS_SCHEMA = "pgboss";
|
|
1039
|
+
/** The exchange every local topic and queue shares on RabbitMQ. */
|
|
1040
|
+
const RABBITMQ_EXCHANGE = "geekmidas.events";
|
|
831
1041
|
/**
|
|
832
|
-
*
|
|
1042
|
+
* Every env key a reconciled plan resolves, and its value.
|
|
1043
|
+
*
|
|
1044
|
+
* Keys come from the declaration rather than being re-derived, so what the build
|
|
1045
|
+
* publishes and what the app reads cannot drift.
|
|
1046
|
+
*/
|
|
1047
|
+
function envFor(plan, options) {
|
|
1048
|
+
const env = {};
|
|
1049
|
+
const resolved = {};
|
|
1050
|
+
for (const resource of plan.resources) {
|
|
1051
|
+
const url = urlFor(resource, plan, options.ports, options.project ?? "", options.addresses);
|
|
1052
|
+
if (url) resolved[resource.id] = url;
|
|
1053
|
+
}
|
|
1054
|
+
for (const resource of plan.resources) {
|
|
1055
|
+
const url = resolved[resource.id];
|
|
1056
|
+
if (resource.kind === "rest-api") Object.assign(env, surfaceEnv(resource, url, resolved, {
|
|
1057
|
+
plan,
|
|
1058
|
+
...options.addresses ? { addresses: options.addresses } : {},
|
|
1059
|
+
...options.lanAddress ? { lanAddress: options.lanAddress } : {},
|
|
1060
|
+
...options.metroPorts ? { metroPorts: options.metroPorts } : {}
|
|
1061
|
+
}));
|
|
1062
|
+
if (url) env[resource.envKey] = url;
|
|
1063
|
+
if (isDatabase(resource.kind) && resource.roles !== false) {
|
|
1064
|
+
const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
|
|
1065
|
+
if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
|
|
1066
|
+
}
|
|
1067
|
+
if (resource.kind === "external-api" && url && resource.fake) env[provideKey(resource.id, "credentials")] = resource.fake.credentials;
|
|
1068
|
+
if (resource.kind === "email" && url) {
|
|
1069
|
+
env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
|
|
1070
|
+
const inbox = options.ports[MAILPIT_INBOX_PORT];
|
|
1071
|
+
if (inbox !== void 0) env[provideKey(resource.id, "inboxUrl")] = `http://${LOCAL_HOST}:${inbox}`;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
Object.assign(env, publicEnv(plan, env, options.addresses));
|
|
1075
|
+
Object.assign(env, brokerEnv(plan, options.ports));
|
|
1076
|
+
if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
|
|
1077
|
+
return env;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* The values a site's bundler inlines, under the names it inlines them by.
|
|
1081
|
+
*
|
|
1082
|
+
* A rename and nothing more. `API_URL` was resolved once, by the same code that
|
|
1083
|
+
* resolved it for the server; a site reads the same value under `VITE_API_URL`
|
|
1084
|
+
* because that prefix is how its bundler is told to ship it. Nothing is derived
|
|
1085
|
+
* twice, so a site and its API cannot come to disagree about where the API is.
|
|
1086
|
+
*
|
|
1087
|
+
* A source that resolved to nothing is skipped rather than written empty: an
|
|
1088
|
+
* inlined empty string is a frontend that builds and then fails at runtime
|
|
1089
|
+
* against `http:///`, where a missing variable fails at build with the name of
|
|
1090
|
+
* the thing that is missing.
|
|
1091
|
+
*/
|
|
1092
|
+
function publicEnv(plan, resolved, addresses = {}) {
|
|
1093
|
+
const env = {};
|
|
1094
|
+
const owners = new Map(plan.resources.map((r) => [r.envKey, r.id]));
|
|
1095
|
+
for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
|
|
1096
|
+
const value = (resource.kind === "mobile-app" ? addresses[owners.get(source) ?? ""] : void 0) ?? resolved[source];
|
|
1097
|
+
if (value) env[key] = value;
|
|
1098
|
+
}
|
|
1099
|
+
return env;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* What a surface publishes beyond its own address: who may call it, and where
|
|
1103
|
+
* a cookie set by it is readable.
|
|
833
1104
|
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
1105
|
+
* Both are read off the same list — the constructs that declared an edge to
|
|
1106
|
+
* this surface — which is why neither appears in application code. A surface
|
|
1107
|
+
* that listed its own callers would be edited every time something new called
|
|
1108
|
+
* it, and the thing being edited is already recorded in the graph.
|
|
1109
|
+
*
|
|
1110
|
+
* An empty origin list is written, and a surface with no address writes nothing
|
|
1111
|
+
* at all. The two cases look alike and are not: "nothing depends on this yet" is
|
|
1112
|
+
* a real state a target should publish, while "this surface has no address here"
|
|
1113
|
+
* means it is not running in this stage, and keys belong with the address they
|
|
1114
|
+
* describe.
|
|
837
1115
|
*/
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1116
|
+
function surfaceEnv(resource, url, resolved = {}, local = {}) {
|
|
1117
|
+
if (!url) return {};
|
|
1118
|
+
const origins = [...new Set((resource.callers ?? []).map((caller) => resolved[caller]).filter((address) => Boolean(address)).map(originOf).filter((origin) => Boolean(origin)))].sort();
|
|
1119
|
+
const domain = cookieDomain([url, ...origins]);
|
|
1120
|
+
const mobile = (resource.callers ?? []).map((caller) => local.plan?.resources.find((r) => r.id === caller && r.kind === "mobile-app")).filter((caller) => Boolean(caller));
|
|
1121
|
+
const hosts = [...local.lanAddress ? [local.lanAddress] : [], LOCAL_HOST];
|
|
1122
|
+
const schemes = mobile.flatMap((caller) => {
|
|
1123
|
+
const scheme = resolved[caller.id];
|
|
1124
|
+
const port = local.metroPorts?.[caller.id];
|
|
1125
|
+
return scheme ? mobileOrigins(scheme, {
|
|
1126
|
+
hosts,
|
|
1127
|
+
...port ? { port } : {}
|
|
1128
|
+
}) : [];
|
|
1129
|
+
});
|
|
1130
|
+
const own = local.addresses?.[resource.id];
|
|
1131
|
+
const device = mobile.length > 0 && local.lanAddress && own ? own.replace(/\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `//${local.lanAddress}`) : void 0;
|
|
1132
|
+
return {
|
|
1133
|
+
[provideKey(resource.id, "trustedOrigins")]: [...origins, ...new Set(schemes)].join(","),
|
|
1134
|
+
...domain ? { [provideKey(resource.id, "cookieDomain")]: domain } : {},
|
|
1135
|
+
...device ? { [provideKey(resource.id, "deviceUrl")]: device } : {}
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
/** An address reduced to the origin a browser compares against. */
|
|
1139
|
+
function originOf(address) {
|
|
1140
|
+
try {
|
|
1141
|
+
return new URL(address).origin;
|
|
1142
|
+
} catch {
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
844
1146
|
/**
|
|
845
|
-
*
|
|
1147
|
+
* The runtime role of whatever a derived node hangs off.
|
|
846
1148
|
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
1149
|
+
* A reader's parent may itself be a schema tenant, so this follows `of` by one
|
|
1150
|
+
* hop rather than assuming the cluster — the reader of a tenant reads that
|
|
1151
|
+
* tenant's schema, not the database's.
|
|
850
1152
|
*/
|
|
851
|
-
|
|
1153
|
+
function parentRole(resource, plan) {
|
|
1154
|
+
const parent = plan.resources.find((r) => r.id === resource.of);
|
|
1155
|
+
return parent ? localRole(parent) : resource.name;
|
|
1156
|
+
}
|
|
1157
|
+
/** Whether a kind connects to Postgres and therefore has an owner role. */
|
|
1158
|
+
function isDatabase(kind) {
|
|
1159
|
+
return kind === "database" || kind === "database-schema";
|
|
1160
|
+
}
|
|
852
1161
|
/**
|
|
853
|
-
* The
|
|
1162
|
+
* The URL a migrator connects with — the owner role, which can create, alter
|
|
1163
|
+
* and drop.
|
|
854
1164
|
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
1165
|
+
* Never in `provides`, which is the whole point. A handler that could reach this
|
|
1166
|
+
* could `DROP TABLE`, and the security property the role split exists for is
|
|
1167
|
+
* that it cannot: the value is injected by `gkm exec` for the migrate step and
|
|
1168
|
+
* by nothing else.
|
|
858
1169
|
*/
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
}
|
|
1170
|
+
function ownerUrl(resource, plan, ports, project) {
|
|
1171
|
+
if (!resource.container) return void 0;
|
|
1172
|
+
const port = ports[primaryPortKey(resource.container)];
|
|
1173
|
+
if (port === void 0) return void 0;
|
|
1174
|
+
const owner = ownerRole(localRole(resource));
|
|
1175
|
+
return postgres(port, rootDatabase(resource, plan), {
|
|
1176
|
+
user: owner,
|
|
1177
|
+
password: localRolePassword(project, plan, owner)
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
869
1180
|
/**
|
|
870
|
-
*
|
|
1181
|
+
* The shared broker keys, when anything is published or consumed.
|
|
871
1182
|
*
|
|
872
|
-
* A
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
1183
|
+
* A queue's own key is the producer's. These two are the *connection* itself,
|
|
1184
|
+
* which locally is one broker for every queue and topic in the project: the
|
|
1185
|
+
* generated pollers open a single connection and subscribe each worker by name
|
|
1186
|
+
* on it. Deployed there is no such thing — a Lambda is handed its own event
|
|
1187
|
+
* source — so this pair exists for the local target and says so.
|
|
876
1188
|
*/
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
"
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
};
|
|
1189
|
+
function brokerEnv(plan, ports) {
|
|
1190
|
+
const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
|
|
1191
|
+
const publisher = carrier ? urlFor(carrier, plan, ports, "") : plan.workerBroker ? workerBroker(plan, ports) : void 0;
|
|
1192
|
+
if (!publisher) return {};
|
|
1193
|
+
if (plan.events === "sns") return {};
|
|
1194
|
+
return { EVENT_PUBLISHER_CONNECTION_STRING: publisher };
|
|
1195
|
+
}
|
|
885
1196
|
/**
|
|
886
|
-
* The
|
|
887
|
-
*
|
|
888
|
-
* `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
|
|
889
|
-
* schema tenant in a database the manifest already declares, so mapping it here
|
|
890
|
-
* would start a Postgres for a project that declared no database at all.
|
|
891
|
-
*
|
|
892
|
-
* This one comes from config rather than from a declaration because there are
|
|
893
|
-
* no `topic` or `queue` kinds yet. When they land the mapping moves to
|
|
894
|
-
* {@link CONTAINERS} keyed by kind, and this constant goes with them.
|
|
1197
|
+
* The broker a worker schedules its crons through, when nothing else declared
|
|
1198
|
+
* one — pg-boss in the declared database, on that database's port.
|
|
895
1199
|
*/
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1200
|
+
function workerBroker(plan, ports) {
|
|
1201
|
+
const database = plan.resources.find((r) => r.kind === "database");
|
|
1202
|
+
if (!database?.container) return void 0;
|
|
1203
|
+
const port = ports[primaryPortKey(database.container)];
|
|
1204
|
+
return port === void 0 ? void 0 : broker(plan, port);
|
|
1205
|
+
}
|
|
902
1206
|
/**
|
|
903
|
-
*
|
|
1207
|
+
* The URL for one planned resource.
|
|
904
1208
|
*
|
|
905
|
-
*
|
|
906
|
-
* everywhere; buckets and queues are kebab and disallow underscores in some
|
|
907
|
-
* providers. The suffix is the same word either way.
|
|
1209
|
+
* Every part of it comes from the resource and the port it was published on.
|
|
908
1210
|
*/
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
"
|
|
912
|
-
"
|
|
913
|
-
|
|
914
|
-
|
|
1211
|
+
function urlFor(resource, plan, ports, project, addresses = {}) {
|
|
1212
|
+
if (resource.kind === "secret") return localSecret(project, plan, resource);
|
|
1213
|
+
if (resource.kind === "mobile-app") return schemeBase(project, resource.scheme);
|
|
1214
|
+
if (resource.kind === "credential") return void 0;
|
|
1215
|
+
if (resource.kind === "external-api") {
|
|
1216
|
+
if (!resource.fake) return resource.url === void 0 ? void 0 : externalApiUrl({
|
|
1217
|
+
id: resource.id,
|
|
1218
|
+
url: resource.url
|
|
1219
|
+
}, plan.stage);
|
|
1220
|
+
const port = ports[resource.fake.key];
|
|
1221
|
+
return port === void 0 ? void 0 : `http://${LOCAL_HOST}:${port}`;
|
|
1222
|
+
}
|
|
1223
|
+
if (resource.kind === "rest-api" || resource.kind === "site") {
|
|
1224
|
+
const address = addresses[resource.id];
|
|
1225
|
+
const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
|
|
1226
|
+
return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
|
|
1227
|
+
}
|
|
1228
|
+
if (!resource.container) return void 0;
|
|
1229
|
+
const port = ports[primaryPortKey(resource.container)];
|
|
1230
|
+
if (port === void 0) return void 0;
|
|
1231
|
+
switch (resource.kind) {
|
|
1232
|
+
case "database":
|
|
1233
|
+
case "database-schema":
|
|
1234
|
+
case "database-reader": {
|
|
1235
|
+
const database = rootDatabase(resource, plan);
|
|
1236
|
+
if (resource.roles === false) {
|
|
1237
|
+
const schema = schemaOf(resource, plan);
|
|
1238
|
+
return schema ? `${postgres(port, database)}?search_path=${schema}` : postgres(port, database);
|
|
1239
|
+
}
|
|
1240
|
+
const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
|
|
1241
|
+
return postgres(port, database, {
|
|
1242
|
+
user: role,
|
|
1243
|
+
password: localRolePassword(project, plan, role)
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
case "email": return `smtp://${LOCAL_HOST}:${port}`;
|
|
1247
|
+
case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
|
|
1248
|
+
case "file-server": {
|
|
1249
|
+
const origin = plan.resources.find((r) => r.id === resource.of);
|
|
1250
|
+
if (!origin) return void 0;
|
|
1251
|
+
return plan.containers.includes("caddy") ? `https://${hostFor(resource, project)}:${port}` : `http://${LOCAL_HOST}:${port}/${origin.name}`;
|
|
1252
|
+
}
|
|
1253
|
+
case "cache":
|
|
1254
|
+
if (resource.of) {
|
|
1255
|
+
const parent = plan.resources.find((r) => r.id === resource.of);
|
|
1256
|
+
const url = parent ? urlFor(parent, plan, ports, project, addresses) : void 0;
|
|
1257
|
+
return url ? withTable(url, resource.table ?? cacheTable(resource.id)) : void 0;
|
|
1258
|
+
}
|
|
1259
|
+
switch (plan.cache) {
|
|
1260
|
+
case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
|
|
1261
|
+
default: return `http://:${LOCAL_TOKEN$1}@${LOCAL_HOST}:${port}`;
|
|
1262
|
+
}
|
|
1263
|
+
case "queue":
|
|
1264
|
+
case "topic": return broker(plan, port, resource);
|
|
1265
|
+
default: return;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
915
1268
|
/**
|
|
916
|
-
* The
|
|
1269
|
+
* The producer's connection string, composed from the backend the plan chose.
|
|
917
1270
|
*
|
|
918
|
-
* The
|
|
919
|
-
*
|
|
920
|
-
*
|
|
1271
|
+
* The protocol is what picks the transport, so a producer never branches: the
|
|
1272
|
+
* same `.publish()` reaches pg-boss here and SQS deployed because the string it
|
|
1273
|
+
* was handed said so.
|
|
921
1274
|
*/
|
|
922
|
-
function
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1275
|
+
function broker(plan, port, resource) {
|
|
1276
|
+
switch (plan.events) {
|
|
1277
|
+
case "pgboss": {
|
|
1278
|
+
const database = plan.resources.find((r) => r.kind === "database");
|
|
1279
|
+
if (!database) throw new PgBossNeedsDatabase([]);
|
|
1280
|
+
return `pgboss://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
|
|
1281
|
+
}
|
|
1282
|
+
case "rabbitmq": return `rabbitmq://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
|
|
1283
|
+
case "sns": {
|
|
1284
|
+
if (!resource) throw new UnprovisionedEventsBackend("sns");
|
|
1285
|
+
const endpoint = emulatorEndpoint(port);
|
|
1286
|
+
const address = resource.kind === "topic" ? snsUrl.build({
|
|
1287
|
+
topicArn: emulatorTopicArn(resource.name),
|
|
1288
|
+
region: EMULATOR_REGION,
|
|
1289
|
+
endpoint
|
|
1290
|
+
}) : sqsUrl.build({
|
|
1291
|
+
queueUrl: emulatorQueueUrl(resource.name, port),
|
|
1292
|
+
region: EMULATOR_REGION,
|
|
1293
|
+
endpoint
|
|
1294
|
+
});
|
|
1295
|
+
const url = new URL(address);
|
|
1296
|
+
url.searchParams.set("accessKeyId", EMULATOR_CREDENTIALS.accessKeyId);
|
|
1297
|
+
url.searchParams.set("secretAccessKey", EMULATOR_CREDENTIALS.secretAccessKey);
|
|
1298
|
+
return url.toString();
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
/** A local Postgres URL, master credential and all. */
|
|
1303
|
+
function postgres(port, database, credential = {
|
|
1304
|
+
user: LOCAL_USER$2,
|
|
1305
|
+
password: LOCAL_USER$2
|
|
1306
|
+
}) {
|
|
1307
|
+
return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
|
|
926
1308
|
}
|
|
927
1309
|
/**
|
|
928
|
-
* The
|
|
1310
|
+
* The role a handler connects as, for one database or tenant.
|
|
929
1311
|
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
1312
|
+
* The construct's id, lowercased and stage-scoped exactly as its database or
|
|
1313
|
+
* schema is — so `Orders` in the `test` stage connects as `orders_test`, and two
|
|
1314
|
+
* stages sharing one cluster cannot share a credential. Roles are cluster-scoped
|
|
1315
|
+
* in Postgres, which is why the suffix is not optional here the way it is for a
|
|
1316
|
+
* schema.
|
|
933
1317
|
*/
|
|
934
|
-
function
|
|
935
|
-
|
|
936
|
-
if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
|
|
937
|
-
if (kind === "cache") {
|
|
938
|
-
if (derived) return CONTAINERS.database;
|
|
939
|
-
return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
|
|
940
|
-
}
|
|
941
|
-
return CONTAINERS[kind];
|
|
1318
|
+
function localRole(resource) {
|
|
1319
|
+
return resource.name;
|
|
942
1320
|
}
|
|
943
1321
|
/**
|
|
944
|
-
*
|
|
1322
|
+
* The password for a derived local role.
|
|
945
1323
|
*
|
|
946
|
-
*
|
|
947
|
-
* `
|
|
948
|
-
*
|
|
1324
|
+
* Derived rather than random, for the same reason the local secret is: a
|
|
1325
|
+
* password that changed on every `gkm dev` would lock a developer out of the
|
|
1326
|
+
* data they had a moment ago. Seeded by the project so two checkouts do not
|
|
1327
|
+
* share one, and by the stage so `test` and `development` do not.
|
|
1328
|
+
*
|
|
1329
|
+
* It is a *local* credential by construction — the cluster is on loopback and
|
|
1330
|
+
* the seed is a checkout path — and no deployed target uses this function.
|
|
949
1331
|
*/
|
|
950
|
-
function
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
});
|
|
1332
|
+
function localRolePassword(project, plan, role) {
|
|
1333
|
+
return createHash("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* A cache's address: its database's URL, carrying the table it reads.
|
|
1337
|
+
*
|
|
1338
|
+
* Appended rather than kept beside the URL as a second key, for the reason the
|
|
1339
|
+
* design gives everywhere else — an address and what it opens are one fact, and
|
|
1340
|
+
* two keys are one more thing to keep in step.
|
|
1341
|
+
*/
|
|
1342
|
+
function withTable(url, table) {
|
|
1343
|
+
const parsed = new URL(url);
|
|
1344
|
+
parsed.searchParams.set("table", table);
|
|
1345
|
+
return parsed.toString();
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* The physical database a derived resource lives in.
|
|
1349
|
+
*
|
|
1350
|
+
* Walks `of` to the top rather than reading the immediate parent, because a
|
|
1351
|
+
* reader's parent may itself be a schema tenant — two hops from the database
|
|
1352
|
+
* that actually exists.
|
|
1353
|
+
*/
|
|
1354
|
+
function rootDatabase(resource, plan) {
|
|
1355
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1356
|
+
let current = resource;
|
|
1357
|
+
while (current.of) {
|
|
1358
|
+
const parent = byId.get(current.of);
|
|
1359
|
+
if (!parent) break;
|
|
1360
|
+
current = parent;
|
|
980
1361
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
|
|
991
|
-
for (const resource of placeless) resource.of = only.id;
|
|
992
|
-
}
|
|
1362
|
+
return current.name;
|
|
1363
|
+
}
|
|
1364
|
+
/** The schema a derived resource puts on its search path, if it has one. */
|
|
1365
|
+
function schemaOf(resource, plan) {
|
|
1366
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1367
|
+
let current = resource;
|
|
1368
|
+
while (current) {
|
|
1369
|
+
if (current.kind === "database-schema") return current.schema;
|
|
1370
|
+
current = current.of ? byId.get(current.of) : void 0;
|
|
993
1371
|
}
|
|
994
|
-
return {
|
|
995
|
-
stage,
|
|
996
|
-
events,
|
|
997
|
-
cache,
|
|
998
|
-
containers: [...containers],
|
|
999
|
-
resources
|
|
1000
|
-
};
|
|
1001
1372
|
}
|
|
1002
1373
|
/**
|
|
1003
|
-
*
|
|
1374
|
+
* A broker address asked for without the topic or queue it would name.
|
|
1004
1375
|
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
* broker.
|
|
1376
|
+
* On SNS each topic and queue is its own address, so there is no project-wide
|
|
1377
|
+
* one to hand out — a cron's schedule store, say, cannot live there.
|
|
1008
1378
|
*/
|
|
1009
|
-
var
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
this.
|
|
1379
|
+
var UnprovisionedEventsBackend = class extends Error {
|
|
1380
|
+
backend;
|
|
1381
|
+
constructor(backend) {
|
|
1382
|
+
super(`'${backend}' has no single broker address: each topic and queue is addressed by its own ARN. Something asked for one without naming the topic or queue.`);
|
|
1383
|
+
this.backend = backend;
|
|
1384
|
+
this.name = "UnprovisionedEventsBackend";
|
|
1014
1385
|
}
|
|
1015
1386
|
};
|
|
1016
1387
|
/**
|
|
1017
|
-
*
|
|
1388
|
+
* The value a secret resolves to locally.
|
|
1018
1389
|
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
* `orders.cache('Sessions')` names the database, which is a fact about the
|
|
1024
|
-
* application rather than a deployment choice.
|
|
1390
|
+
* A hash of what identifies it, so it is stable, distinct per project, stage
|
|
1391
|
+
* and construct, and never written to disk as a literal. Deployed this is the
|
|
1392
|
+
* one kind whose value does *not* come from here — a secret manager generates
|
|
1393
|
+
* it once and the target reads it back.
|
|
1025
1394
|
*/
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
this.ids = ids;
|
|
1030
|
-
this.databases = databases;
|
|
1031
|
-
this.name = "CacheIsAmbiguous";
|
|
1032
|
-
}
|
|
1033
|
-
};
|
|
1034
|
-
var PgBossNeedsDatabase = class extends Error {
|
|
1035
|
-
constructor(ids) {
|
|
1036
|
-
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(", ")}.`);
|
|
1037
|
-
this.ids = ids;
|
|
1038
|
-
this.name = "PgBossNeedsDatabase";
|
|
1039
|
-
}
|
|
1040
|
-
};
|
|
1041
|
-
|
|
1395
|
+
function localSecret(project, plan, resource) {
|
|
1396
|
+
return createHash("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
|
|
1397
|
+
}
|
|
1042
1398
|
//#endregion
|
|
1043
|
-
//#region src/reconcile/
|
|
1399
|
+
//#region src/reconcile/apps.ts
|
|
1044
1400
|
/**
|
|
1045
|
-
* The
|
|
1401
|
+
* The apps, as services beside the containers their constructs derive.
|
|
1046
1402
|
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1403
|
+
* `gkm docker` used to write these by hand: every backend got
|
|
1404
|
+
* `DATABASE_URL=postgresql://…@postgres:5432/app` and `REDIS_URL=redis://redis`,
|
|
1405
|
+
* which was right only for a project whose single database happened to be
|
|
1406
|
+
* called `Database` — an `Orders` database publishes `ORDERS_URL`, a schema
|
|
1407
|
+
* tenant has a URL of its own, and buckets and mail were not wired at all.
|
|
1408
|
+
*
|
|
1409
|
+
* Now an app's environment is exactly what reconcile hands `gkm dev`: every key
|
|
1410
|
+
* the declared constructs provide, from the same derivation. The only
|
|
1411
|
+
* difference is where the addresses point — at the containers by service name
|
|
1412
|
+
* on the compose network, rather than at `localhost` on the ports published for
|
|
1413
|
+
* the host.
|
|
1414
|
+
*
|
|
1415
|
+
* Always from the *local* stage's plan, whichever stage is being reconciled:
|
|
1416
|
+
* `gkm test` rewrites the same file, and running the tests must not quietly
|
|
1417
|
+
* point these apps at the test databases.
|
|
1051
1418
|
*/
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
const LOCAL_HOST = "localhost";
|
|
1055
|
-
/** The region local buckets claim, so the client has one to sign with. */
|
|
1056
|
-
const LOCAL_REGION = "us-east-1";
|
|
1419
|
+
/** The profile app services carry, so `gkm dev` starts only the containers. */
|
|
1420
|
+
const APPS_PROFILE = "apps";
|
|
1057
1421
|
/**
|
|
1058
|
-
*
|
|
1422
|
+
* Where an app's image is built from.
|
|
1059
1423
|
*
|
|
1060
|
-
* An
|
|
1061
|
-
*
|
|
1062
|
-
* rotate and one more thing to leak into a log. Locally there is no role, and
|
|
1063
|
-
* the same chain reads these — which is why they are injected beside the URL
|
|
1064
|
-
* rather than written into it.
|
|
1424
|
+
* An app at the project root is a single-app project, whose `gkm docker`
|
|
1425
|
+
* writes `.gkm/docker/Dockerfile`; a workspace writes one per app.
|
|
1065
1426
|
*/
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
AWS_REGION: LOCAL_REGION
|
|
1070
|
-
};
|
|
1071
|
-
/** The token the local cache proxy accepts. Matches the compose definition. */
|
|
1072
|
-
const LOCAL_TOKEN = "geekmidas";
|
|
1073
|
-
/** The schema pg-boss keeps its tables in, inside the declared database. */
|
|
1074
|
-
const PGBOSS_SCHEMA = "pgboss";
|
|
1075
|
-
/** The exchange every local topic and queue shares on RabbitMQ. */
|
|
1076
|
-
const RABBITMQ_EXCHANGE = "geekmidas.events";
|
|
1427
|
+
function dockerfileOf(appName, path) {
|
|
1428
|
+
return path === "." ? ".gkm/docker/Dockerfile" : `.gkm/docker/Dockerfile.${appName}`;
|
|
1429
|
+
}
|
|
1077
1430
|
/**
|
|
1078
|
-
* Every
|
|
1431
|
+
* Every construct key, addressed across the compose network.
|
|
1079
1432
|
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1433
|
+
* `envFor` is asked with a placeholder port for every port key, so each
|
|
1434
|
+
* `localhost:<placeholder>` in its answer names exactly one container port —
|
|
1435
|
+
* two containers listening on the same port inside cannot be confused — and is
|
|
1436
|
+
* rewritten to `<container>:<inside>`.
|
|
1082
1437
|
*/
|
|
1083
|
-
function
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1438
|
+
function inNetworkEnv(workspace, manifest, fakes = {}) {
|
|
1439
|
+
const plan = planFor(manifest, workspace.stages.local, provisionOrder(manifest), {
|
|
1440
|
+
localStage: workspace.stages.local,
|
|
1441
|
+
...backendsOf(workspace),
|
|
1442
|
+
fakes
|
|
1443
|
+
});
|
|
1444
|
+
const placeholders = {};
|
|
1445
|
+
const targets = /* @__PURE__ */ new Map();
|
|
1446
|
+
let next = 49152;
|
|
1447
|
+
for (const container of [...plan.containers].sort()) for (const port of portsOf(container, plan.fakes)) {
|
|
1448
|
+
placeholders[port.key] = next;
|
|
1449
|
+
targets.set(next, `${container}:${port.inside}`);
|
|
1450
|
+
next += 1;
|
|
1089
1451
|
}
|
|
1090
|
-
for (const
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1452
|
+
for (const key of portKeys(plan.containers, plan.fakes)) placeholders[key] ??= next++;
|
|
1453
|
+
const env = envFor(plan, {
|
|
1454
|
+
ports: placeholders,
|
|
1455
|
+
project: workspace.name,
|
|
1456
|
+
addresses: surfaceAddresses(workspace, manifest, (app, port) => `http://${app}:${port}`)
|
|
1457
|
+
});
|
|
1458
|
+
const rewrite = (value) => value.replace(/(?:localhost|127\.0\.0\.1):(\d+)/g, (match, port) => {
|
|
1459
|
+
return targets.get(Number(port)) ?? match;
|
|
1460
|
+
});
|
|
1461
|
+
return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, rewrite(value)]));
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* The keys one app's environment may hold: what its own declaration provides
|
|
1465
|
+
* and requires, and what each construct it has an edge to provides.
|
|
1466
|
+
*
|
|
1467
|
+
* The edges, not the manifest. Every app used to be handed every key the
|
|
1468
|
+
* workspace resolved — a web app got the database's owner URL and the auth
|
|
1469
|
+
* server's signing secret, and the API got the auth server's database — which
|
|
1470
|
+
* is exactly what a declared edge exists to rule out. A deploy composes an
|
|
1471
|
+
* app's environment from its edges; so does this.
|
|
1472
|
+
*
|
|
1473
|
+
* One hop: a surface an app calls is reached over HTTP, so its URL is the
|
|
1474
|
+
* app's business and its database is not. A site also gets the public
|
|
1475
|
+
* variant of each key its bundle inlines (`NEXT_PUBLIC_API_URL`), and a surface
|
|
1476
|
+
* the edges of the workers whose crons and subscribers its server runs.
|
|
1477
|
+
*
|
|
1478
|
+
* `undefined` for an app no declaration describes, which gets nothing but its
|
|
1479
|
+
* port.
|
|
1480
|
+
*/
|
|
1481
|
+
function appEnvKeys(manifest, appName, runnables = {}) {
|
|
1482
|
+
const entry = Object.entries(manifest).find(([id, d]) => (d.kind === "rest-api" || d.kind === "site" || d.kind === "mobile-app") && appKey(id) === appName);
|
|
1483
|
+
if (!entry) return void 0;
|
|
1484
|
+
const [id, declaration] = entry;
|
|
1485
|
+
const keys = /* @__PURE__ */ new Set([
|
|
1486
|
+
...declaration.provides ?? [],
|
|
1487
|
+
...declaration.requires ?? [],
|
|
1488
|
+
...declaration.kind === "rest-api" ? declaration.endpoints.flatMap((endpoint) => endpoint.requires ?? []) : []
|
|
1489
|
+
]);
|
|
1490
|
+
const edges = [...dependenciesOf(declaration).map((edge) => edge.target), ...runnables[id] ?? []];
|
|
1491
|
+
if (declaration.kind === "rest-api" && declaration.endpoints.length === 0) {
|
|
1492
|
+
const runsWorkers = Object.values(manifest).some((other) => other.kind === "worker");
|
|
1493
|
+
for (const [otherId, other] of Object.entries(manifest)) {
|
|
1494
|
+
if (other.kind !== "worker") continue;
|
|
1495
|
+
edges.push(...dependenciesOf(other).map((edge) => edge.target), ...runnables[otherId] ?? []);
|
|
1496
|
+
keys.add("EVENT_PUBLISHER_CONNECTION_STRING");
|
|
1497
|
+
}
|
|
1498
|
+
if (runsWorkers) for (const other of Object.values(manifest)) {
|
|
1499
|
+
if (other.kind !== "queue" && other.kind !== "topic") continue;
|
|
1500
|
+
for (const key of other.provides ?? []) keys.add(key);
|
|
1097
1501
|
}
|
|
1098
|
-
if (resource.kind === "email" && url) env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
|
|
1099
1502
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1503
|
+
for (const id of edges) {
|
|
1504
|
+
const target = manifest[id];
|
|
1505
|
+
if (!target) continue;
|
|
1506
|
+
for (const key of target.provides ?? []) keys.add(key);
|
|
1507
|
+
if (target.kind === "queue" || target.kind === "topic") keys.add("EVENT_PUBLISHER_CONNECTION_STRING");
|
|
1508
|
+
if (target.kind === "objects") {
|
|
1509
|
+
keys.add("AWS_ACCESS_KEY_ID");
|
|
1510
|
+
keys.add("AWS_SECRET_ACCESS_KEY");
|
|
1511
|
+
keys.add("AWS_REGION");
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
if (declaration.kind === "site" || declaration.kind === "mobile-app") for (const key of Object.keys(publicEnvFor(declaration, manifest))) keys.add(key);
|
|
1515
|
+
return keys;
|
|
1516
|
+
}
|
|
1517
|
+
/** One compose service per app the workspace runs, behind the apps profile. */
|
|
1518
|
+
function appServices(workspace, manifest, containers, runnables = {}, fakes = {}) {
|
|
1519
|
+
const env = inNetworkEnv(workspace, manifest, fakes);
|
|
1520
|
+
const services = {};
|
|
1521
|
+
for (const [name, app] of Object.entries(workspace.apps)) {
|
|
1522
|
+
if (app.type === "mobile" || !app.port) continue;
|
|
1523
|
+
const key = appKey(name);
|
|
1524
|
+
const health = app.type === "web" ? "/" : "/health";
|
|
1525
|
+
const allowed = appEnvKeys(manifest, name, runnables);
|
|
1526
|
+
const own = Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey)));
|
|
1527
|
+
services[key] = {
|
|
1528
|
+
image: `${key}:\${TAG:-latest}`,
|
|
1529
|
+
build: {
|
|
1530
|
+
context: ".",
|
|
1531
|
+
dockerfile: dockerfileOf(name, app.path)
|
|
1532
|
+
},
|
|
1533
|
+
profiles: [APPS_PROFILE],
|
|
1534
|
+
ports: [`${app.port}:${app.port}`],
|
|
1535
|
+
environment: {
|
|
1536
|
+
NODE_ENV: "production",
|
|
1537
|
+
PORT: String(app.port),
|
|
1538
|
+
...own
|
|
1539
|
+
},
|
|
1540
|
+
...containers.some((c) => c !== "caddy") ? { depends_on: containers.filter((c) => c !== "caddy") } : {},
|
|
1541
|
+
healthcheck: {
|
|
1542
|
+
test: [
|
|
1543
|
+
"CMD",
|
|
1544
|
+
"wget",
|
|
1545
|
+
"-q",
|
|
1546
|
+
"--spider",
|
|
1547
|
+
`http://localhost:${app.port}${health}`
|
|
1548
|
+
],
|
|
1549
|
+
interval: "10s",
|
|
1550
|
+
timeout: "5s",
|
|
1551
|
+
retries: 5
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
return services;
|
|
1104
1556
|
}
|
|
1557
|
+
//#endregion
|
|
1558
|
+
//#region src/reconcile/clients.ts
|
|
1105
1559
|
/**
|
|
1106
|
-
* The
|
|
1560
|
+
* The real Postgres and MinIO clients the applier runs against.
|
|
1107
1561
|
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
* twice, so a site and its API cannot come to disagree about where the API is.
|
|
1562
|
+
* Kept apart from `provision.ts` so the rules there — what gets created, in what
|
|
1563
|
+
* order, and never dropping anything — are asserted against fakes, while the
|
|
1564
|
+
* drivers live here where there is nothing to assert but wiring.
|
|
1112
1565
|
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
* against `http:///`, where a missing variable fails at build with the name of
|
|
1116
|
-
* the thing that is missing.
|
|
1566
|
+
* Both are constructed from the same local addresses the env writer composes,
|
|
1567
|
+
* so the applier cannot end up talking to a different container than the app.
|
|
1117
1568
|
*/
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
|
|
1121
|
-
const value = resolved[source];
|
|
1122
|
-
if (value) env[key] = value;
|
|
1123
|
-
}
|
|
1124
|
-
return env;
|
|
1125
|
-
}
|
|
1569
|
+
/** The credential local containers are brought up with. */
|
|
1570
|
+
const LOCAL_USER$1 = "geekmidas";
|
|
1126
1571
|
/**
|
|
1127
|
-
*
|
|
1128
|
-
* a cookie set by it is readable.
|
|
1129
|
-
*
|
|
1130
|
-
* Both are read off the same list — the constructs that declared an edge to
|
|
1131
|
-
* this surface — which is why neither appears in application code. A surface
|
|
1132
|
-
* that listed its own callers would be edited every time something new called
|
|
1133
|
-
* it, and the thing being edited is already recorded in the graph.
|
|
1572
|
+
* A Postgres client that opens one connection per database it is asked about.
|
|
1134
1573
|
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
* means it is not running in this stage, and keys belong with the address they
|
|
1139
|
-
* describe.
|
|
1574
|
+
* `CREATE DATABASE` cannot run from a connection to the database being created,
|
|
1575
|
+
* and a schema must be created from inside its own database — so the database is
|
|
1576
|
+
* part of each query rather than fixed when the client is built.
|
|
1140
1577
|
*/
|
|
1141
|
-
function
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1578
|
+
function pgClient(port) {
|
|
1579
|
+
return { async query(database, sql, values) {
|
|
1580
|
+
const client = new Client({
|
|
1581
|
+
host: "localhost",
|
|
1582
|
+
port,
|
|
1583
|
+
user: LOCAL_USER$1,
|
|
1584
|
+
password: LOCAL_USER$1,
|
|
1585
|
+
database: database ?? LOCAL_USER$1
|
|
1586
|
+
});
|
|
1587
|
+
await client.connect();
|
|
1588
|
+
try {
|
|
1589
|
+
return (await client.query(sql, values)).rows;
|
|
1590
|
+
} finally {
|
|
1591
|
+
await client.end();
|
|
1592
|
+
}
|
|
1593
|
+
} };
|
|
1594
|
+
}
|
|
1595
|
+
/** A bucket client pointed at the local MinIO. */
|
|
1596
|
+
function bucketClient(port) {
|
|
1597
|
+
const s3 = new S3Client({
|
|
1598
|
+
region: "us-east-1",
|
|
1599
|
+
endpoint: `http://localhost:${port}`,
|
|
1600
|
+
forcePathStyle: true,
|
|
1601
|
+
credentials: {
|
|
1602
|
+
accessKeyId: LOCAL_USER$1,
|
|
1603
|
+
secretAccessKey: LOCAL_USER$1
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
return {
|
|
1607
|
+
async exists(bucket) {
|
|
1608
|
+
try {
|
|
1609
|
+
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
1610
|
+
return true;
|
|
1611
|
+
} catch {
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
async create(bucket) {
|
|
1616
|
+
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
1617
|
+
},
|
|
1618
|
+
async policy(bucket) {
|
|
1619
|
+
try {
|
|
1620
|
+
return (await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }))).Policy;
|
|
1621
|
+
} catch {
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
},
|
|
1625
|
+
async setPolicy(bucket, policy) {
|
|
1626
|
+
await s3.send(new PutBucketPolicyCommand({
|
|
1627
|
+
Bucket: bucket,
|
|
1628
|
+
Policy: policy
|
|
1629
|
+
}));
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
/** A topic and queue client pointed at the local AWS emulator. */
|
|
1634
|
+
function carrierClient(port) {
|
|
1635
|
+
const config = {
|
|
1636
|
+
region: EMULATOR_REGION,
|
|
1637
|
+
endpoint: emulatorEndpoint(port),
|
|
1638
|
+
credentials: EMULATOR_CREDENTIALS
|
|
1639
|
+
};
|
|
1640
|
+
const sns = new SNSClient(config);
|
|
1641
|
+
const sqs = new SQSClient(config);
|
|
1145
1642
|
return {
|
|
1146
|
-
|
|
1147
|
-
|
|
1643
|
+
async topicExists(name) {
|
|
1644
|
+
try {
|
|
1645
|
+
await sns.send(new GetTopicAttributesCommand({ TopicArn: emulatorTopicArn(name) }));
|
|
1646
|
+
return true;
|
|
1647
|
+
} catch {
|
|
1648
|
+
return false;
|
|
1649
|
+
}
|
|
1650
|
+
},
|
|
1651
|
+
async createTopic(name) {
|
|
1652
|
+
await sns.send(new CreateTopicCommand({ Name: name }));
|
|
1653
|
+
},
|
|
1654
|
+
async queueExists(name) {
|
|
1655
|
+
try {
|
|
1656
|
+
await sqs.send(new GetQueueUrlCommand({ QueueName: name }));
|
|
1657
|
+
return true;
|
|
1658
|
+
} catch {
|
|
1659
|
+
return false;
|
|
1660
|
+
}
|
|
1661
|
+
},
|
|
1662
|
+
async createQueue(name) {
|
|
1663
|
+
await sqs.send(new CreateQueueCommand({ QueueName: name }));
|
|
1664
|
+
}
|
|
1148
1665
|
};
|
|
1149
1666
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
try {
|
|
1153
|
-
return new URL(address).origin;
|
|
1154
|
-
} catch {
|
|
1155
|
-
return void 0;
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1667
|
+
//#endregion
|
|
1668
|
+
//#region src/reconcile/compose.ts
|
|
1158
1669
|
/**
|
|
1159
|
-
* The
|
|
1670
|
+
* The compose document a plan implies.
|
|
1160
1671
|
*
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1672
|
+
* The third declaration of the same containers disappears here: today a
|
|
1673
|
+
* `services: ['postgres','minio']` block is kept in step with the code by hand,
|
|
1674
|
+
* which is the triplication this design exists to remove, in the one place you
|
|
1675
|
+
* touch every day. A `database` implies Postgres; the list is derived.
|
|
1676
|
+
*
|
|
1677
|
+
* Returns an object rather than a string for the same reason `plan` is pure —
|
|
1678
|
+
* what should exist is asserted as data, and turning it into YAML is one line at
|
|
1679
|
+
* the edge.
|
|
1680
|
+
*
|
|
1681
|
+
* What stays config is unchanged: images and versions are pinned explicitly, and
|
|
1682
|
+
* a container no construct implies is still declared by hand. Neutral facts come
|
|
1683
|
+
* from the declaration, provider detail from config.
|
|
1164
1684
|
*/
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
return parent ? localRole(parent) : resource.name;
|
|
1168
|
-
}
|
|
1169
|
-
/** Whether a kind connects to Postgres and therefore has an owner role. */
|
|
1170
|
-
function isDatabase(kind) {
|
|
1171
|
-
return kind === "database" || kind === "database-schema";
|
|
1172
|
-
}
|
|
1685
|
+
/** The credential every derived container is brought up with locally. */
|
|
1686
|
+
const LOCAL_USER = "geekmidas";
|
|
1173
1687
|
/**
|
|
1174
|
-
* The
|
|
1175
|
-
* and drop.
|
|
1688
|
+
* The token the local cache accepts.
|
|
1176
1689
|
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
* by nothing else.
|
|
1690
|
+
* Fixed rather than generated: it is only ever reachable on a port this project
|
|
1691
|
+
* published, and the URL that carries it is derived, so nothing is made safer
|
|
1692
|
+
* by making it unguessable and a stable one keeps `redis-cli` sessions working.
|
|
1181
1693
|
*/
|
|
1182
|
-
|
|
1183
|
-
if (!resource.container) return void 0;
|
|
1184
|
-
const port = ports[primaryPortKey(resource.container)];
|
|
1185
|
-
if (port === void 0) return void 0;
|
|
1186
|
-
const owner = ownerRole(localRole(resource));
|
|
1187
|
-
return postgres(port, rootDatabase(resource, plan), {
|
|
1188
|
-
user: owner,
|
|
1189
|
-
password: localRolePassword(project, plan, owner)
|
|
1190
|
-
});
|
|
1191
|
-
}
|
|
1694
|
+
const LOCAL_TOKEN = "geekmidas";
|
|
1192
1695
|
/**
|
|
1193
|
-
*
|
|
1696
|
+
* Build the compose document for a plan.
|
|
1194
1697
|
*
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
* on it. Deployed there is no such thing — a Lambda is handed its own event
|
|
1199
|
-
* source — so this pair exists for the local target and says so.
|
|
1698
|
+
* Every container is published on its assigned port, never on the image's
|
|
1699
|
+
* default: the point of allocation is that two projects can run at once, and
|
|
1700
|
+
* one container publishing 5432 puts that back.
|
|
1200
1701
|
*/
|
|
1201
|
-
function
|
|
1202
|
-
const
|
|
1203
|
-
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1702
|
+
function composeFor(plan, options) {
|
|
1703
|
+
const services = {};
|
|
1704
|
+
const volumes = {};
|
|
1705
|
+
for (const container of [...plan.containers].sort()) {
|
|
1706
|
+
const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
|
|
1707
|
+
const fake = plan.fakes?.[container];
|
|
1708
|
+
const image = declared ? postgresImage(declared) : fake?.image ?? DEFAULT_IMAGES[container];
|
|
1709
|
+
if (!image) throw new UnknownContainer(container);
|
|
1710
|
+
const published = publish(container, options.ports, plan.fakes);
|
|
1711
|
+
services[container] = fake ? {
|
|
1712
|
+
image,
|
|
1713
|
+
restart: "unless-stopped",
|
|
1714
|
+
ports: published
|
|
1715
|
+
} : define(container, image, published);
|
|
1716
|
+
const volume = volumeOf(container);
|
|
1717
|
+
if (volume) volumes[volume] = {};
|
|
1718
|
+
}
|
|
1206
1719
|
return {
|
|
1207
|
-
|
|
1208
|
-
|
|
1720
|
+
name: options.project,
|
|
1721
|
+
services: {
|
|
1722
|
+
...services,
|
|
1723
|
+
...options.apps
|
|
1724
|
+
},
|
|
1725
|
+
...Object.keys(volumes).length ? { volumes } : {}
|
|
1209
1726
|
};
|
|
1210
1727
|
}
|
|
1211
|
-
/**
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1728
|
+
/** Render a compose document as the file it becomes. */
|
|
1729
|
+
function toYaml(file) {
|
|
1730
|
+
return `# Generated by gkm from the construct manifest — do not edit.
|
|
1731
|
+
# Containers are derived from what the app declares. Image pins and anything
|
|
1732
|
+
# no construct implies go in your own docker-compose.yml, which is merged over
|
|
1733
|
+
# this file and wins:
|
|
1734
|
+
#
|
|
1735
|
+
# docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
|
|
1736
|
+
#
|
|
1737
|
+
# "gkm dev" and "gkm test" start the containers. The apps sit behind the
|
|
1738
|
+
# "apps" profile: run "gkm setup", then
|
|
1739
|
+
# docker compose -f docker-compose.constructs.yml --profile apps up --build
|
|
1740
|
+
${stringify(file, { lineWidth: 0 })}`;
|
|
1741
|
+
}
|
|
1742
|
+
/** A container with no image, from config or from the defaults. */
|
|
1743
|
+
var UnknownContainer = class extends Error {
|
|
1744
|
+
/** The container that could not be resolved. */
|
|
1745
|
+
container;
|
|
1746
|
+
/** The containers that have a default, so the caller can see the set. */
|
|
1747
|
+
known = Object.keys(DEFAULT_IMAGES).sort();
|
|
1748
|
+
constructor(container) {
|
|
1749
|
+
super("No image is known for this container; pin one in config");
|
|
1750
|
+
this.name = "UnknownContainer";
|
|
1751
|
+
this.container = container;
|
|
1223
1752
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
switch (
|
|
1228
|
-
case "
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1753
|
+
};
|
|
1754
|
+
/** One container's definition, published on the port it was assigned. */
|
|
1755
|
+
function define(container, image, published) {
|
|
1756
|
+
switch (container) {
|
|
1757
|
+
case "postgres": return {
|
|
1758
|
+
image,
|
|
1759
|
+
restart: "unless-stopped",
|
|
1760
|
+
ports: published,
|
|
1761
|
+
environment: {
|
|
1762
|
+
POSTGRES_USER: LOCAL_USER,
|
|
1763
|
+
POSTGRES_PASSWORD: LOCAL_USER,
|
|
1764
|
+
POSTGRES_DB: LOCAL_USER
|
|
1765
|
+
},
|
|
1766
|
+
volumes: ["postgres-data:/var/lib/postgresql"],
|
|
1767
|
+
healthcheck: {
|
|
1768
|
+
test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER}`],
|
|
1769
|
+
interval: "5s",
|
|
1770
|
+
timeout: "3s",
|
|
1771
|
+
retries: 10,
|
|
1772
|
+
start_period: "10s"
|
|
1235
1773
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1774
|
+
};
|
|
1775
|
+
case "minio": return {
|
|
1776
|
+
image,
|
|
1777
|
+
restart: "unless-stopped",
|
|
1778
|
+
ports: published,
|
|
1779
|
+
environment: {
|
|
1780
|
+
MINIO_ROOT_USER: LOCAL_USER,
|
|
1781
|
+
MINIO_ROOT_PASSWORD: LOCAL_USER
|
|
1782
|
+
},
|
|
1783
|
+
command: "server --console-address \":9001\" /data",
|
|
1784
|
+
volumes: ["minio-data:/data"],
|
|
1785
|
+
healthcheck: {
|
|
1786
|
+
test: [
|
|
1787
|
+
"CMD",
|
|
1788
|
+
"mc",
|
|
1789
|
+
"ready",
|
|
1790
|
+
"local"
|
|
1791
|
+
],
|
|
1792
|
+
interval: "10s",
|
|
1793
|
+
timeout: "5s",
|
|
1794
|
+
retries: 5,
|
|
1795
|
+
start_period: "10s"
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
case "caddy": return {
|
|
1799
|
+
image,
|
|
1800
|
+
restart: "unless-stopped",
|
|
1801
|
+
ports: published,
|
|
1802
|
+
volumes: [
|
|
1803
|
+
"./.gkm/Caddyfile:/etc/caddy/Caddyfile:ro",
|
|
1804
|
+
"./.gkm/caddy-sites:/etc/caddy/sites:ro",
|
|
1805
|
+
"caddy-data:/data"
|
|
1806
|
+
],
|
|
1807
|
+
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
1808
|
+
healthcheck: {
|
|
1809
|
+
test: [
|
|
1810
|
+
"CMD",
|
|
1811
|
+
"caddy",
|
|
1812
|
+
"version"
|
|
1813
|
+
],
|
|
1814
|
+
interval: "10s",
|
|
1815
|
+
timeout: "5s",
|
|
1816
|
+
retries: 5
|
|
1817
|
+
}
|
|
1818
|
+
};
|
|
1819
|
+
case "mailpit": return {
|
|
1820
|
+
image,
|
|
1821
|
+
restart: "unless-stopped",
|
|
1822
|
+
ports: published,
|
|
1823
|
+
environment: {
|
|
1824
|
+
MP_SMTP_AUTH_ACCEPT_ANY: "1",
|
|
1825
|
+
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
|
|
1826
|
+
},
|
|
1827
|
+
healthcheck: {
|
|
1828
|
+
test: [
|
|
1829
|
+
"CMD",
|
|
1830
|
+
"wget",
|
|
1831
|
+
"-q",
|
|
1832
|
+
"--spider",
|
|
1833
|
+
"http://localhost:8025"
|
|
1834
|
+
],
|
|
1835
|
+
interval: "10s",
|
|
1836
|
+
timeout: "5s",
|
|
1837
|
+
retries: 5
|
|
1838
|
+
}
|
|
1839
|
+
};
|
|
1840
|
+
case "redis": return {
|
|
1841
|
+
image,
|
|
1842
|
+
restart: "unless-stopped",
|
|
1843
|
+
ports: published,
|
|
1844
|
+
volumes: ["redis-data:/data"],
|
|
1845
|
+
healthcheck: {
|
|
1846
|
+
test: [
|
|
1847
|
+
"CMD",
|
|
1848
|
+
"redis-cli",
|
|
1849
|
+
"ping"
|
|
1850
|
+
],
|
|
1851
|
+
interval: "5s",
|
|
1852
|
+
timeout: "3s",
|
|
1853
|
+
retries: 5
|
|
1854
|
+
}
|
|
1855
|
+
};
|
|
1856
|
+
case "redis-http": return {
|
|
1857
|
+
image,
|
|
1858
|
+
restart: "unless-stopped",
|
|
1859
|
+
ports: published,
|
|
1860
|
+
environment: {
|
|
1861
|
+
SRH_MODE: "env",
|
|
1862
|
+
SRH_TOKEN: LOCAL_TOKEN,
|
|
1863
|
+
SRH_CONNECTION_STRING: "redis://redis:6379"
|
|
1864
|
+
},
|
|
1865
|
+
depends_on: ["redis"],
|
|
1866
|
+
healthcheck: {
|
|
1867
|
+
test: [
|
|
1868
|
+
"CMD",
|
|
1869
|
+
"wget",
|
|
1870
|
+
"-q",
|
|
1871
|
+
"-O",
|
|
1872
|
+
"/dev/null",
|
|
1873
|
+
"http://127.0.0.1:80/"
|
|
1874
|
+
],
|
|
1875
|
+
interval: "5s",
|
|
1876
|
+
timeout: "3s",
|
|
1877
|
+
retries: 10
|
|
1254
1878
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1879
|
+
};
|
|
1880
|
+
case "rabbitmq": return {
|
|
1881
|
+
image,
|
|
1882
|
+
restart: "unless-stopped",
|
|
1883
|
+
ports: published,
|
|
1884
|
+
environment: {
|
|
1885
|
+
RABBITMQ_DEFAULT_USER: LOCAL_USER,
|
|
1886
|
+
RABBITMQ_DEFAULT_PASS: LOCAL_USER
|
|
1887
|
+
},
|
|
1888
|
+
volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
|
|
1889
|
+
healthcheck: {
|
|
1890
|
+
test: [
|
|
1891
|
+
"CMD",
|
|
1892
|
+
"rabbitmq-diagnostics",
|
|
1893
|
+
"-q",
|
|
1894
|
+
"ping"
|
|
1895
|
+
],
|
|
1896
|
+
interval: "10s",
|
|
1897
|
+
timeout: "5s",
|
|
1898
|
+
retries: 5
|
|
1258
1899
|
}
|
|
1259
|
-
|
|
1260
|
-
case "
|
|
1261
|
-
|
|
1900
|
+
};
|
|
1901
|
+
case "localstack": return {
|
|
1902
|
+
image,
|
|
1903
|
+
restart: "unless-stopped",
|
|
1904
|
+
ports: published,
|
|
1905
|
+
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
1906
|
+
environment: {
|
|
1907
|
+
AWS_DEFAULT_REGION: EMULATOR_REGION,
|
|
1908
|
+
AWS_ACCESS_KEY_ID: EMULATOR_CREDENTIALS.accessKeyId,
|
|
1909
|
+
AWS_SECRET_ACCESS_KEY: EMULATOR_CREDENTIALS.secretAccessKey
|
|
1910
|
+
},
|
|
1911
|
+
volumes: ["localstack-data:/var/lib/localstack"],
|
|
1912
|
+
healthcheck: {
|
|
1913
|
+
test: [
|
|
1914
|
+
"CMD",
|
|
1915
|
+
"bash",
|
|
1916
|
+
"-c",
|
|
1917
|
+
"exec 3<>/dev/tcp/localhost/4566"
|
|
1918
|
+
],
|
|
1919
|
+
interval: "10s",
|
|
1920
|
+
timeout: "5s",
|
|
1921
|
+
retries: 5,
|
|
1922
|
+
start_period: "10s"
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1925
|
+
default: throw new UnknownContainer(container);
|
|
1262
1926
|
}
|
|
1263
1927
|
}
|
|
1264
1928
|
/**
|
|
1265
|
-
* The
|
|
1929
|
+
* The published port mappings for a container.
|
|
1266
1930
|
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1931
|
+
* Every port is looked up by its own key rather than being derived as an offset
|
|
1932
|
+
* from the container's first, so MinIO's console cannot land on whatever was
|
|
1933
|
+
* allocated to the next container.
|
|
1270
1934
|
*/
|
|
1271
|
-
function
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
}
|
|
1278
|
-
case "rabbitmq": return `rabbitmq://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
|
|
1279
|
-
case "sns": throw new UnprovisionedEventsBackend("sns");
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
/** A local Postgres URL, master credential and all. */
|
|
1283
|
-
function postgres(port, database, credential = {
|
|
1284
|
-
user: LOCAL_USER,
|
|
1285
|
-
password: LOCAL_USER
|
|
1286
|
-
}) {
|
|
1287
|
-
return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
|
|
1935
|
+
function publish(container, ports, fakes) {
|
|
1936
|
+
return portsOf(container, fakes).map((port) => {
|
|
1937
|
+
const assigned = ports[port.key];
|
|
1938
|
+
if (assigned === void 0) throw new UnassignedPort(port.key);
|
|
1939
|
+
return `${assigned}:${port.inside}`;
|
|
1940
|
+
});
|
|
1288
1941
|
}
|
|
1942
|
+
/** A planned port that allocation never assigned. */
|
|
1943
|
+
var UnassignedPort = class extends Error {
|
|
1944
|
+
/** The port key that went unassigned, e.g. `minio-console`. */
|
|
1945
|
+
key;
|
|
1946
|
+
constructor(key) {
|
|
1947
|
+
super("No port was assigned for this container port");
|
|
1948
|
+
this.name = "UnassignedPort";
|
|
1949
|
+
this.key = key;
|
|
1950
|
+
}
|
|
1951
|
+
};
|
|
1952
|
+
//#endregion
|
|
1953
|
+
//#region src/reconcile/docker.ts
|
|
1289
1954
|
/**
|
|
1290
|
-
* The
|
|
1955
|
+
* The real Docker, behind the small interface reconcile needs.
|
|
1291
1956
|
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1296
|
-
*
|
|
1957
|
+
* Shells out to `docker compose` rather than talking to the daemon socket: it is
|
|
1958
|
+
* what a developer runs by hand when something is wrong, so the two cannot
|
|
1959
|
+
* disagree, and it keeps Docker out of this package's dependencies.
|
|
1960
|
+
*
|
|
1961
|
+
* Every call is scoped to the generated compose file. Nothing here can touch a
|
|
1962
|
+
* container this project did not generate.
|
|
1297
1963
|
*/
|
|
1298
|
-
|
|
1299
|
-
return resource.name;
|
|
1300
|
-
}
|
|
1964
|
+
const run = promisify(execFile);
|
|
1301
1965
|
/**
|
|
1302
|
-
* The
|
|
1303
|
-
*
|
|
1304
|
-
* Derived rather than random, for the same reason the local secret is: a
|
|
1305
|
-
* password that changed on every `gkm dev` would lock a developer out of the
|
|
1306
|
-
* data they had a moment ago. Seeded by the project so two checkouts do not
|
|
1307
|
-
* share one, and by the stage so `test` and `development` do not.
|
|
1966
|
+
* The generated file, then the project's own if it has one.
|
|
1308
1967
|
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1968
|
+
* Merged in that order so the project's wins — an `image:` there pins a
|
|
1969
|
+
* container this file derived, and a service there is added beside them.
|
|
1311
1970
|
*/
|
|
1312
|
-
function
|
|
1313
|
-
|
|
1971
|
+
function composeFiles(composePath) {
|
|
1972
|
+
const project = join(dirname(composePath), "docker-compose.yml");
|
|
1973
|
+
return [
|
|
1974
|
+
"-f",
|
|
1975
|
+
composePath,
|
|
1976
|
+
...existsSync(project) ? ["-f", project] : []
|
|
1977
|
+
];
|
|
1314
1978
|
}
|
|
1979
|
+
/** How long to wait for containers to pass their health checks. */
|
|
1980
|
+
const HEALTH_TIMEOUT_MS = 12e4;
|
|
1981
|
+
const dockerCli = {
|
|
1982
|
+
async publishedPort(composePath, service, inside) {
|
|
1983
|
+
try {
|
|
1984
|
+
const { stdout } = await run("docker", [
|
|
1985
|
+
"compose",
|
|
1986
|
+
...composeFiles(composePath),
|
|
1987
|
+
"port",
|
|
1988
|
+
service,
|
|
1989
|
+
String(inside)
|
|
1990
|
+
]);
|
|
1991
|
+
const port = Number(stdout.trim().split(":").pop());
|
|
1992
|
+
return Number.isFinite(port) && port > 0 ? port : void 0;
|
|
1993
|
+
} catch {
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
},
|
|
1997
|
+
async up(composePath, services) {
|
|
1998
|
+
await run("docker", [
|
|
1999
|
+
"compose",
|
|
2000
|
+
...composeFiles(composePath),
|
|
2001
|
+
"up",
|
|
2002
|
+
"-d",
|
|
2003
|
+
"--remove-orphans",
|
|
2004
|
+
"--wait",
|
|
2005
|
+
`--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
|
|
2006
|
+
...services
|
|
2007
|
+
]);
|
|
2008
|
+
},
|
|
2009
|
+
async copyOut(composePath, service, from, to) {
|
|
2010
|
+
await mkdir(dirname(to), { recursive: true });
|
|
2011
|
+
await run("docker", [
|
|
2012
|
+
"compose",
|
|
2013
|
+
...composeFiles(composePath),
|
|
2014
|
+
"cp",
|
|
2015
|
+
`${service}:${from}`,
|
|
2016
|
+
to
|
|
2017
|
+
]);
|
|
2018
|
+
},
|
|
2019
|
+
async reload(composePath, service) {
|
|
2020
|
+
await run("docker", [
|
|
2021
|
+
"compose",
|
|
2022
|
+
...composeFiles(composePath),
|
|
2023
|
+
"exec",
|
|
2024
|
+
"-T",
|
|
2025
|
+
service,
|
|
2026
|
+
"caddy",
|
|
2027
|
+
"reload",
|
|
2028
|
+
"--config",
|
|
2029
|
+
"/etc/caddy/Caddyfile"
|
|
2030
|
+
]);
|
|
2031
|
+
},
|
|
2032
|
+
async healthy(composePath, services) {
|
|
2033
|
+
if (services.length === 0) return true;
|
|
2034
|
+
try {
|
|
2035
|
+
const { stdout } = await run("docker", [
|
|
2036
|
+
"compose",
|
|
2037
|
+
...composeFiles(composePath),
|
|
2038
|
+
"ps",
|
|
2039
|
+
"--format",
|
|
2040
|
+
"json",
|
|
2041
|
+
...services
|
|
2042
|
+
]);
|
|
2043
|
+
const running = /* @__PURE__ */ new Set();
|
|
2044
|
+
for (const line of stdout.trim().split("\n").filter(Boolean)) {
|
|
2045
|
+
const entry = JSON.parse(line);
|
|
2046
|
+
if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
|
|
2047
|
+
}
|
|
2048
|
+
return services.every((service) => running.has(service));
|
|
2049
|
+
} catch {
|
|
2050
|
+
return false;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
//#endregion
|
|
2055
|
+
//#region src/reconcile/lan.ts
|
|
1315
2056
|
/**
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1318
|
-
* Appended rather than kept beside the URL as a second key, for the reason the
|
|
1319
|
-
* design gives everywhere else — an address and what it opens are one fact, and
|
|
1320
|
-
* two keys are one more thing to keep in step.
|
|
2057
|
+
* This machine's address on the local network — what a phone running the app
|
|
2058
|
+
* reaches it on.
|
|
1321
2059
|
*/
|
|
1322
|
-
function withTable(url, table) {
|
|
1323
|
-
const parsed = new URL(url);
|
|
1324
|
-
parsed.searchParams.set("table", table);
|
|
1325
|
-
return parsed.toString();
|
|
1326
|
-
}
|
|
1327
2060
|
/**
|
|
1328
|
-
* The
|
|
2061
|
+
* The first private IPv4 address of an interface that is up and not loopback.
|
|
1329
2062
|
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
2063
|
+
* Private ranges only (`10/8`, `172.16/12`, `192.168/16`): a public address
|
|
2064
|
+
* would mean listening for a phone on the open internet, and a link-local one
|
|
2065
|
+
* (`169.254/16`) is what an interface has when it found no network at all.
|
|
2066
|
+
* Undefined when there is none — a laptop on no network still runs, its mobile
|
|
2067
|
+
* app reaching only a simulator.
|
|
1333
2068
|
*/
|
|
1334
|
-
function
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
const parent = byId.get(current.of);
|
|
1339
|
-
if (!parent) break;
|
|
1340
|
-
current = parent;
|
|
2069
|
+
function lanAddress(interfaces = networkInterfaces()) {
|
|
2070
|
+
for (const entries of Object.values(interfaces)) for (const entry of entries ?? []) {
|
|
2071
|
+
if (entry.family !== "IPv4" || entry.internal) continue;
|
|
2072
|
+
if (isPrivate(entry.address)) return entry.address;
|
|
1341
2073
|
}
|
|
1342
|
-
return current.name;
|
|
1343
2074
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1347
|
-
let current = resource;
|
|
1348
|
-
while (current) {
|
|
1349
|
-
if (current.kind === "database-schema") return current.schema;
|
|
1350
|
-
current = current.of ? byId.get(current.of) : void 0;
|
|
1351
|
-
}
|
|
1352
|
-
return void 0;
|
|
2075
|
+
function isPrivate(address) {
|
|
2076
|
+
return /^10\./.test(address) || /^192\.168\./.test(address) || /^172\.(1[6-9]|2\d|3[01])\./.test(address);
|
|
1353
2077
|
}
|
|
2078
|
+
//#endregion
|
|
2079
|
+
//#region src/reconcile/ports.ts
|
|
1354
2080
|
/**
|
|
1355
|
-
*
|
|
2081
|
+
* Port allocation — why two projects can run at once.
|
|
1356
2082
|
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
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.`);
|
|
1364
|
-
this.backend = backend;
|
|
1365
|
-
this.name = "UnprovisionedEventsBackend";
|
|
1366
|
-
}
|
|
1367
|
-
};
|
|
1368
|
-
/**
|
|
1369
|
-
* The value a secret resolves to locally.
|
|
2083
|
+
* Publishing `5432:5432` means the second project on your machine cannot start.
|
|
2084
|
+
* Nothing solves that except giving each project its own ports, and what makes
|
|
2085
|
+
* it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
|
|
2086
|
+
* never sees a host or a port, so reconcile is free to bind whatever is
|
|
2087
|
+
* available and compose the URL from what it bound. An app reading `DB_HOST` and
|
|
2088
|
+
* `DB_PORT` separately could not be moved this way.
|
|
1370
2089
|
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
2090
|
+
* Assignments are **persisted, not recomputed**. Recomputing means adding a
|
|
2091
|
+
* construct renumbers its neighbours, and every running container, saved
|
|
2092
|
+
* connection string, and `psql` history goes stale at once.
|
|
2093
|
+
*
|
|
2094
|
+
* Pure, with the liveness probe injected — a collision with another project's
|
|
2095
|
+
* stack is a fact about the machine, and keeping it a parameter is what lets the
|
|
2096
|
+
* rules be asserted without binding a socket.
|
|
1375
2097
|
*/
|
|
1376
|
-
function localSecret(project, plan, resource) {
|
|
1377
|
-
return createHash("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
//#endregion
|
|
1381
|
-
//#region src/reconcile/ports.ts
|
|
1382
2098
|
/**
|
|
1383
2099
|
* The window ports are drawn from.
|
|
1384
2100
|
*
|
|
@@ -1477,17 +2193,33 @@ async function nextFree(from, taken, probe) {
|
|
|
1477
2193
|
* rather than as a connection failure ten minutes later.
|
|
1478
2194
|
*/
|
|
1479
2195
|
function isPortFree(port) {
|
|
1480
|
-
return new Promise((resolve
|
|
2196
|
+
return new Promise((resolve) => {
|
|
1481
2197
|
const server = createServer();
|
|
1482
|
-
server.once("error", () => resolve
|
|
1483
|
-
server.once("listening", () => server.close(() => resolve
|
|
2198
|
+
server.once("error", () => resolve(false));
|
|
2199
|
+
server.once("listening", () => server.close(() => resolve(true)));
|
|
1484
2200
|
server.listen(port, "0.0.0.0");
|
|
1485
2201
|
});
|
|
1486
2202
|
}
|
|
1487
|
-
|
|
1488
2203
|
//#endregion
|
|
1489
2204
|
//#region src/reconcile/provision.ts
|
|
1490
2205
|
/**
|
|
2206
|
+
* Creating what the URLs name.
|
|
2207
|
+
*
|
|
2208
|
+
* Convergent and idempotent: every statement asks whether the thing exists
|
|
2209
|
+
* first, so reconciling repeatedly is free and a half-applied run recovers by
|
|
2210
|
+
* being run again. That property is what lets `gkm dev` do this on every start.
|
|
2211
|
+
*
|
|
2212
|
+
* The line the design draws is at data, not at side effects — this creates
|
|
2213
|
+
* databases, schemas, and buckets, and it never seeds, resets, or drops. A
|
|
2214
|
+
* developer who wants their data gone asks for it explicitly.
|
|
2215
|
+
*
|
|
2216
|
+
* Statement generation is separated from application for the reason the design
|
|
2217
|
+
* gives: the same DDL has to run in-process locally and from a provisioner in a
|
|
2218
|
+
* VPC, and one implementation is what stops "works locally, fails deployed".
|
|
2219
|
+
* When the role DDL lands in `@geekmidas/db` this delegates to it rather than
|
|
2220
|
+
* growing its own.
|
|
2221
|
+
*/
|
|
2222
|
+
/**
|
|
1491
2223
|
* The Postgres work a plan implies.
|
|
1492
2224
|
*
|
|
1493
2225
|
* Databases before the schemas inside them, which `provisionOrder` already
|
|
@@ -1584,6 +2316,7 @@ function rolesFor(resource, schema, plan, project) {
|
|
|
1584
2316
|
owner,
|
|
1585
2317
|
...reader ? { reader } : {},
|
|
1586
2318
|
schema,
|
|
2319
|
+
...resource.kind === "database" ? { database: rootDatabase(resource, plan) } : {},
|
|
1587
2320
|
passwords: {
|
|
1588
2321
|
runtime: localRolePassword(project, plan, runtime),
|
|
1589
2322
|
owner: localRolePassword(project, plan, owner),
|
|
@@ -1629,8 +2362,7 @@ async function applyPostgres(client, statements) {
|
|
|
1629
2362
|
});
|
|
1630
2363
|
continue;
|
|
1631
2364
|
}
|
|
1632
|
-
|
|
1633
|
-
if (rows.length > 0) {
|
|
2365
|
+
if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
|
|
1634
2366
|
applied.push({
|
|
1635
2367
|
id: statement.id,
|
|
1636
2368
|
describe: statement.describe,
|
|
@@ -1697,6 +2429,47 @@ function bucketPolicy(bucket, open) {
|
|
|
1697
2429
|
});
|
|
1698
2430
|
}
|
|
1699
2431
|
/**
|
|
2432
|
+
* The topics and queues to create on the AWS emulator.
|
|
2433
|
+
*
|
|
2434
|
+
* Only on the `sns` backend: pg-boss makes its own tables on first connect and
|
|
2435
|
+
* a RabbitMQ exchange is declared by the client, but an SNS topic and an SQS
|
|
2436
|
+
* queue must exist before anything can publish to them.
|
|
2437
|
+
*/
|
|
2438
|
+
function carrierNames(plan) {
|
|
2439
|
+
if (plan.events !== "sns") return {
|
|
2440
|
+
topics: [],
|
|
2441
|
+
queues: []
|
|
2442
|
+
};
|
|
2443
|
+
const named = (kind) => plan.resources.filter((r) => r.kind === kind).map((r) => r.name);
|
|
2444
|
+
return {
|
|
2445
|
+
topics: named("topic"),
|
|
2446
|
+
queues: named("queue")
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
/** Create each topic and queue the plan names, skipping those that exist. */
|
|
2450
|
+
async function applyCarriers(client, carriers) {
|
|
2451
|
+
const applied = [];
|
|
2452
|
+
for (const name of carriers.topics) {
|
|
2453
|
+
const created = !await client.topicExists(name);
|
|
2454
|
+
if (created) await client.createTopic(name);
|
|
2455
|
+
applied.push({
|
|
2456
|
+
id: name,
|
|
2457
|
+
describe: `topic ${name}`,
|
|
2458
|
+
created
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
for (const name of carriers.queues) {
|
|
2462
|
+
const created = !await client.queueExists(name);
|
|
2463
|
+
if (created) await client.createQueue(name);
|
|
2464
|
+
applied.push({
|
|
2465
|
+
id: name,
|
|
2466
|
+
describe: `queue ${name}`,
|
|
2467
|
+
created
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
return applied;
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
1700
2473
|
* Apply each served bucket's open-path policy, skipping those already correct.
|
|
1701
2474
|
*
|
|
1702
2475
|
* Compared as parsed JSON rather than as text: the same policy re-serialised
|
|
@@ -1755,9 +2528,20 @@ async function applyBuckets(client, buckets) {
|
|
|
1755
2528
|
}
|
|
1756
2529
|
return applied;
|
|
1757
2530
|
}
|
|
1758
|
-
|
|
1759
2531
|
//#endregion
|
|
1760
2532
|
//#region src/reconcile/state.ts
|
|
2533
|
+
/**
|
|
2534
|
+
* What reconcile records, and the hash that lets it do nothing.
|
|
2535
|
+
*
|
|
2536
|
+
* Reconciling on every `gkm dev` is only acceptable if the converged case is
|
|
2537
|
+
* free, so the desired plan is hashed: matching hash plus healthy containers
|
|
2538
|
+
* means there is nothing to do. Without that, the automatic reconcile the design
|
|
2539
|
+
* asks for would tax every start.
|
|
2540
|
+
*
|
|
2541
|
+
* Almost nothing needs recording — roles are queried, migrations have their own
|
|
2542
|
+
* table, containers are remembered by Docker. Only ports and this hash have no
|
|
2543
|
+
* other owner.
|
|
2544
|
+
*/
|
|
1761
2545
|
const STATE_DIR = ".gkm/reconcile";
|
|
1762
2546
|
/**
|
|
1763
2547
|
* The hash of everything a reconcile would act on.
|
|
@@ -1786,7 +2570,7 @@ async function loadState(root, stage) {
|
|
|
1786
2570
|
const state = JSON.parse(raw);
|
|
1787
2571
|
return state.stage === stage ? state : void 0;
|
|
1788
2572
|
} catch {
|
|
1789
|
-
return
|
|
2573
|
+
return;
|
|
1790
2574
|
}
|
|
1791
2575
|
}
|
|
1792
2576
|
/** Record what was applied, so the next run can skip it. */
|
|
@@ -1795,14 +2579,46 @@ async function saveState(root, state) {
|
|
|
1795
2579
|
await mkdir(dirname(path), { recursive: true });
|
|
1796
2580
|
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
1797
2581
|
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Forget what was applied, so the next reconcile of this stage converges from
|
|
2584
|
+
* nothing — creating again whatever was dropped underneath the record.
|
|
2585
|
+
*/
|
|
2586
|
+
async function forgetState(root, stage) {
|
|
2587
|
+
await rm(statePath(root, stage), { force: true });
|
|
2588
|
+
}
|
|
1798
2589
|
function statePath(root, stage) {
|
|
1799
2590
|
return join(root, STATE_DIR, `${stage}.json`);
|
|
1800
2591
|
}
|
|
1801
|
-
|
|
1802
2592
|
//#endregion
|
|
1803
2593
|
//#region src/reconcile/index.ts
|
|
1804
|
-
/**
|
|
1805
|
-
|
|
2594
|
+
/**
|
|
2595
|
+
* Reconcile — the one convergent function `gkm dev`, `gkm test`, and `gkm setup`
|
|
2596
|
+
* all call.
|
|
2597
|
+
*
|
|
2598
|
+
* Three phases, and only this one has side effects: declaring is pure (`gkm
|
|
2599
|
+
* build` and the dev watcher), running reads `<NAME>_URL` and provisions
|
|
2600
|
+
* nothing. Reconcile computes the desired state, compares, and applies the
|
|
2601
|
+
* difference — which is what makes "when does it run" answerable with
|
|
2602
|
+
* *whenever, repeatedly*.
|
|
2603
|
+
*
|
|
2604
|
+
* It is safe to run automatically because its blast radius is entirely local:
|
|
2605
|
+
* this project's containers, this project's `.gkm/`. Nothing it does can reach a
|
|
2606
|
+
* cloud. The line is drawn at data, not at side effects — it allocates ports,
|
|
2607
|
+
* writes compose, starts containers, and creates roles; it never seeds, resets,
|
|
2608
|
+
* or drops.
|
|
2609
|
+
*
|
|
2610
|
+
* Docker is injected rather than imported so the rules above can be asserted
|
|
2611
|
+
* without a daemon. The default implementation is the real one.
|
|
2612
|
+
*/
|
|
2613
|
+
/**
|
|
2614
|
+
* Where the generated compose file is written, relative to the project root.
|
|
2615
|
+
*
|
|
2616
|
+
* At the root, beside the project's own `docker-compose.yml`, because the two
|
|
2617
|
+
* are one stack: this file is everything the constructs imply and is never
|
|
2618
|
+
* edited, that one is anything they cannot express — an image pin, an extra
|
|
2619
|
+
* service — and is merged over it, so it wins.
|
|
2620
|
+
*/
|
|
2621
|
+
const COMPOSE_PATH = "docker-compose.constructs.yml";
|
|
1806
2622
|
/**
|
|
1807
2623
|
* The local edge's config, beside the compose file that mounts it.
|
|
1808
2624
|
*
|
|
@@ -1838,32 +2654,40 @@ const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
|
|
|
1838
2654
|
* hash and one health check.
|
|
1839
2655
|
*/
|
|
1840
2656
|
async function reconcile(options) {
|
|
1841
|
-
const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient } = options;
|
|
2657
|
+
const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient, carriers = carrierClient } = options;
|
|
1842
2658
|
const plan = planFor(manifest, stage, provisionOrder(manifest), {
|
|
2659
|
+
localStage: options.localStage,
|
|
1843
2660
|
events: options.events,
|
|
1844
2661
|
cache: options.cache,
|
|
1845
2662
|
extraContainers: options.extraContainers,
|
|
2663
|
+
...options.fakes ? { fakes: options.fakes } : {},
|
|
1846
2664
|
...options.edge === void 0 ? {} : { edge: options.edge }
|
|
1847
2665
|
});
|
|
1848
2666
|
const composePath = join(root, COMPOSE_PATH);
|
|
1849
|
-
const observed = await observedPorts(docker, composePath, plan.containers);
|
|
1850
|
-
const ports = await allocate(project, portKeys(plan.containers), {
|
|
2667
|
+
const observed = await observedPorts(docker, composePath, plan.containers, plan.fakes);
|
|
2668
|
+
const ports = await allocate(project, portKeys(plan.containers, plan.fakes), {
|
|
1851
2669
|
...options.saved,
|
|
1852
2670
|
...observed
|
|
1853
2671
|
}, probe);
|
|
1854
2672
|
const compose = composeFor(plan, {
|
|
1855
2673
|
project,
|
|
1856
2674
|
ports,
|
|
1857
|
-
...options.
|
|
2675
|
+
...options.apps ? { apps: options.apps(plan.containers) } : {}
|
|
1858
2676
|
});
|
|
1859
2677
|
const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
|
|
1860
|
-
const hash = planHash(plan, compose, {
|
|
2678
|
+
const hash = planHash(plan, compose, {
|
|
2679
|
+
caddyfile,
|
|
2680
|
+
postgres: postgresStatements(plan, project).map((statement) => statement.create).join(";\n")
|
|
2681
|
+
});
|
|
1861
2682
|
const addresses = addressesFor(plan.containers, ports);
|
|
2683
|
+
const lan = plan.resources.some((r) => r.kind === "mobile-app") ? options.lanAddress === void 0 ? lanAddress() : options.lanAddress ?? void 0 : void 0;
|
|
1862
2684
|
const env = envFor(plan, {
|
|
1863
2685
|
ports,
|
|
1864
2686
|
project,
|
|
1865
2687
|
...options.mailFrom ? { mailFrom: options.mailFrom } : {},
|
|
1866
|
-
...options.addresses ? { addresses: options.addresses } : {}
|
|
2688
|
+
...options.addresses ? { addresses: options.addresses } : {},
|
|
2689
|
+
...lan ? { lanAddress: lan } : {},
|
|
2690
|
+
...options.metroPorts ? { metroPorts: options.metroPorts } : {}
|
|
1867
2691
|
});
|
|
1868
2692
|
if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
|
|
1869
2693
|
const result = {
|
|
@@ -1874,675 +2698,259 @@ async function reconcile(options) {
|
|
|
1874
2698
|
addresses,
|
|
1875
2699
|
env,
|
|
1876
2700
|
provisioned: [],
|
|
2701
|
+
fakes: options.fakes ?? {},
|
|
1877
2702
|
hash,
|
|
1878
2703
|
changed: false
|
|
1879
2704
|
};
|
|
1880
|
-
|
|
1881
|
-
const converged = recorded?.hash === hash && (!start || await docker.healthy(composePath, plan.containers));
|
|
1882
|
-
if (converged) return result;
|
|
2705
|
+
if ((await loadState(root, stage))?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers))) return result;
|
|
1883
2706
|
await write(composePath, toYaml(compose));
|
|
1884
2707
|
if (plan.containers.includes("caddy")) {
|
|
1885
2708
|
await write(join(root, CADDYFILE_PATH), caddyfileRoot());
|
|
1886
2709
|
await write(join(root, caddySitesPath(stage)), caddyfile);
|
|
2710
|
+
await pruneCaddySites(root, [
|
|
2711
|
+
stage,
|
|
2712
|
+
options.localStage,
|
|
2713
|
+
TEST_STAGE
|
|
2714
|
+
]);
|
|
1887
2715
|
}
|
|
1888
|
-
if (start && plan.containers.length > 0) await docker.up(composePath, plan.containers);
|
|
1889
|
-
if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
|
|
1890
|
-
if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
|
|
1891
|
-
const provisioned = start && provision ? await create(plan, ports, sql, buckets, project) : [];
|
|
1892
|
-
await saveState(root, {
|
|
1893
|
-
hash,
|
|
1894
|
-
stage
|
|
1895
|
-
});
|
|
1896
|
-
return {
|
|
1897
|
-
...result,
|
|
1898
|
-
provisioned,
|
|
1899
|
-
changed: true
|
|
1900
|
-
};
|
|
1901
|
-
}
|
|
1902
|
-
/**
|
|
1903
|
-
* Create the databases, schemas, and buckets the plan names.
|
|
1904
|
-
*
|
|
1905
|
-
* Idempotent throughout, so this runs on every non-converged reconcile rather
|
|
1906
|
-
* than being something to remember.
|
|
1907
|
-
*/
|
|
1908
|
-
async function create(plan, ports, sql, buckets, project) {
|
|
1909
|
-
const applied = [];
|
|
1910
|
-
const postgresPort = ports[primaryPortKey("postgres")];
|
|
1911
|
-
const statements = postgresStatements(plan, project);
|
|
1912
|
-
if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort), statements));
|
|
1913
|
-
const minioPort = ports[primaryPortKey("minio")];
|
|
1914
|
-
const names = bucketNames(plan);
|
|
1915
|
-
const policies = bucketPolicies(plan);
|
|
1916
|
-
if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
|
|
1917
|
-
const client = buckets(minioPort);
|
|
1918
|
-
if (names.length > 0) applied.push(...await applyBuckets(client, names));
|
|
1919
|
-
if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
|
|
1920
|
-
}
|
|
1921
|
-
return applied;
|
|
1922
|
-
}
|
|
1923
|
-
/**
|
|
1924
|
-
* The host ports of containers already running.
|
|
1925
|
-
*
|
|
1926
|
-
* Keyed the same way allocation keys them, so the two merge without either
|
|
1927
|
-
* knowing about the other.
|
|
1928
|
-
*/
|
|
1929
|
-
async function observedPorts(docker, composePath, containers) {
|
|
1930
|
-
const observed = {};
|
|
1931
|
-
for (const container of containers) for (const port of portsOf(container)) {
|
|
1932
|
-
const running = await docker.publishedPort(composePath, container, port.inside);
|
|
1933
|
-
if (running !== void 0) observed[port.key] = running;
|
|
1934
|
-
}
|
|
1935
|
-
return observed;
|
|
1936
|
-
}
|
|
1937
|
-
/**
|
|
1938
|
-
* Where each container can be reached on this machine.
|
|
1939
|
-
*
|
|
1940
|
-
* The primary port only — a container's console is for a human to open, not
|
|
1941
|
-
* something an app connects to.
|
|
1942
|
-
*/
|
|
1943
|
-
function addressesFor(containers, ports) {
|
|
1944
|
-
const addresses = {};
|
|
1945
|
-
for (const container of containers) {
|
|
1946
|
-
const port = ports[primaryPortKey(container)];
|
|
1947
|
-
if (port !== void 0) addresses[container] = `localhost:${port}`;
|
|
1948
|
-
}
|
|
1949
|
-
return addresses;
|
|
1950
|
-
}
|
|
1951
|
-
/** Write a file only when its content would change, so mtimes stay meaningful. */
|
|
1952
|
-
async function write(path, content) {
|
|
1953
|
-
try {
|
|
1954
|
-
if (await readFile(path, "utf-8") === content) return;
|
|
1955
|
-
} catch {}
|
|
1956
|
-
await mkdir(dirname(path), { recursive: true });
|
|
1957
|
-
await writeFile(path, content);
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
//#endregion
|
|
1961
|
-
//#region src/reconcile/workspace.ts
|
|
1962
|
-
/**
|
|
1963
|
-
* Every constructs glob in the workspace, resolved against its app.
|
|
1964
|
-
*
|
|
1965
|
-
* Absolute because discovery globs from a single cwd while apps live in
|
|
1966
|
-
* different directories.
|
|
1967
|
-
*/
|
|
1968
|
-
function constructGlobs(workspace) {
|
|
1969
|
-
return allConstructGlobs(workspace);
|
|
1970
|
-
}
|
|
1971
|
-
/** Whether any app has adopted the constructs glob. */
|
|
1972
|
-
function usesConstructs(workspace) {
|
|
1973
|
-
return constructGlobs(workspace).length > 0;
|
|
1974
|
-
}
|
|
1975
|
-
/**
|
|
1976
|
-
* Image pins, by container. The config half of the derived/config split.
|
|
1977
|
-
*
|
|
1978
|
-
* Read from `services.images` rather than from the backend keys beside it.
|
|
1979
|
-
* They used to share one key, which is how `cache: true` came to mean "start a
|
|
1980
|
-
* Redis" — the last way a container could exist because config asked for one
|
|
1981
|
-
* instead of because something declared it.
|
|
1982
|
-
*/
|
|
1983
|
-
function imagePins(workspace) {
|
|
1984
|
-
return { ...workspace.services.images };
|
|
1985
|
-
}
|
|
1986
|
-
/**
|
|
1987
|
-
* The containers a workspace's declarations imply, without reconciling.
|
|
1988
|
-
*
|
|
1989
|
-
* The same derivation `reconcileWorkspace` makes, available to callers that
|
|
1990
|
-
* need to know *what would run* without starting anything or writing ports —
|
|
1991
|
-
* `gkm setup` deciding which credentials to generate, `gkm docker` writing a
|
|
1992
|
-
* compose file. Those used to each read a boolean per service out of config,
|
|
1993
|
-
* which is how a container could exist because config asked for one rather than
|
|
1994
|
-
* because something declared it.
|
|
1995
|
-
*/
|
|
1996
|
-
async function derivedContainers(workspace, stage, manifest) {
|
|
1997
|
-
const found = manifest ?? await discover({
|
|
1998
|
-
patterns: constructGlobs(workspace),
|
|
1999
|
-
cwd: workspace.root
|
|
2000
|
-
});
|
|
2001
|
-
return planFor(found, stage, provisionOrder(found), {
|
|
2002
|
-
events: workspace.services.events,
|
|
2003
|
-
cache: cacheBackendOf(workspace.services.cache, providerOf(workspace))
|
|
2004
|
-
}).containers;
|
|
2005
|
-
}
|
|
2006
|
-
/**
|
|
2007
|
-
* Reconcile a workspace for one stage.
|
|
2008
|
-
*
|
|
2009
|
-
* Ports are loaded and saved around the call rather than inside it, so the loop
|
|
2010
|
-
* stays a pure-ish function of its inputs and the store keeps its single owner.
|
|
2011
|
-
*/
|
|
2012
|
-
async function reconcileWorkspace(workspace, options) {
|
|
2013
|
-
const manifest = options.manifest ?? await discover({
|
|
2014
|
-
patterns: constructGlobs(workspace),
|
|
2015
|
-
cwd: workspace.root
|
|
2016
|
-
});
|
|
2017
|
-
const result = await reconcile({
|
|
2018
|
-
root: workspace.root,
|
|
2019
|
-
project: workspace.name,
|
|
2020
|
-
manifest,
|
|
2021
|
-
stage: options.stage,
|
|
2022
|
-
events: workspace.services.events,
|
|
2023
|
-
cache: cacheBackendOf(workspace.services.cache, providerOf(workspace)),
|
|
2024
|
-
images: imagePins(workspace),
|
|
2025
|
-
saved: await loadPortState(workspace.root),
|
|
2026
|
-
addresses: surfaceAddresses(workspace, manifest),
|
|
2027
|
-
...options.start === void 0 ? {} : { start: options.start }
|
|
2028
|
-
});
|
|
2029
|
-
await savePortState(workspace.root, { ...result.ports });
|
|
2030
|
-
return result;
|
|
2031
|
-
}
|
|
2032
|
-
/**
|
|
2033
|
-
* Where each declared surface and site answers locally.
|
|
2034
|
-
*
|
|
2035
|
-
* The addresses come from the ports the workspace already assigns; which
|
|
2036
|
-
* construct sits at which one comes from the manifest. That split is the point:
|
|
2037
|
-
* this used to answer "who may call whom" by listing every app the workspace
|
|
2038
|
-
* runs, which is a different question that happened to give the same answer in
|
|
2039
|
-
* a single-repo workspace and no answer at all deployed. Now it answers only
|
|
2040
|
-
* "where does this construct answer", and the graph answers the rest.
|
|
2041
|
-
*
|
|
2042
|
-
* A site is matched to its app by `path`, because that is the one thing a site
|
|
2043
|
-
* declaration and a workspace app both name. Surfaces are matched to the
|
|
2044
|
-
* backend app: every `rest-api` in a process answers on that process's port, so
|
|
2045
|
-
* an app serving both its own API and an auth server publishes one address
|
|
2046
|
-
* twice — which is exactly what it does at runtime.
|
|
2047
|
-
*/
|
|
2048
|
-
function surfaceAddresses(workspace, manifest) {
|
|
2049
|
-
const addresses = {};
|
|
2050
|
-
for (const [id, declaration] of Object.entries(manifest)) {
|
|
2051
|
-
if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
|
|
2052
|
-
const host = declaration.kind === "site" ? appKey(id) : appKeyOfHost(manifest, id);
|
|
2053
|
-
const app = host ? workspace.apps[host] : void 0;
|
|
2054
|
-
if (app?.port) addresses[id] = localAddress(app.port);
|
|
2055
|
-
}
|
|
2056
|
-
return addresses;
|
|
2057
|
-
}
|
|
2058
|
-
/** The app key serving a surface, following a mount to its host. */
|
|
2059
|
-
function appKeyOfHost(manifest, id) {
|
|
2060
|
-
const host = hostOf(manifest, id);
|
|
2061
|
-
return host ? appKey(host) : void 0;
|
|
2062
|
-
}
|
|
2063
|
-
/** Where a local process answers, given the port the workspace gave it. */
|
|
2064
|
-
function localAddress(port) {
|
|
2065
|
-
return `http://localhost:${port}`;
|
|
2066
|
-
}
|
|
2067
|
-
|
|
2068
|
-
//#endregion
|
|
2069
|
-
//#region src/credentials/index.ts
|
|
2070
|
-
const logger = console;
|
|
2071
|
-
/**
|
|
2072
|
-
* Load environment files
|
|
2073
|
-
* @internal Exported for testing
|
|
2074
|
-
*/
|
|
2075
|
-
function loadEnvFiles(envConfig, cwd = process.cwd()) {
|
|
2076
|
-
const loaded = [];
|
|
2077
|
-
const missing = [];
|
|
2078
|
-
const envFiles = envConfig ? Array.isArray(envConfig) ? envConfig : [envConfig] : [".env"];
|
|
2079
|
-
for (const envFile of envFiles) {
|
|
2080
|
-
const envPath = resolve(cwd, envFile);
|
|
2081
|
-
if (existsSync(envPath)) {
|
|
2082
|
-
config({
|
|
2083
|
-
path: envPath,
|
|
2084
|
-
override: true,
|
|
2085
|
-
quiet: true
|
|
2086
|
-
});
|
|
2087
|
-
loaded.push(envFile);
|
|
2088
|
-
} else if (envConfig) missing.push(envFile);
|
|
2089
|
-
}
|
|
2090
|
-
return {
|
|
2091
|
-
loaded,
|
|
2092
|
-
missing
|
|
2093
|
-
};
|
|
2094
|
-
}
|
|
2095
|
-
/**
|
|
2096
|
-
* Check if a port is available
|
|
2097
|
-
* @internal Exported for testing
|
|
2098
|
-
*/
|
|
2099
|
-
async function isPortAvailable(port) {
|
|
2100
|
-
return new Promise((resolve$1) => {
|
|
2101
|
-
const server = createServer();
|
|
2102
|
-
server.once("error", (err) => {
|
|
2103
|
-
if (err.code === "EADDRINUSE") resolve$1(false);
|
|
2104
|
-
else resolve$1(false);
|
|
2105
|
-
});
|
|
2106
|
-
server.once("listening", () => {
|
|
2107
|
-
server.close();
|
|
2108
|
-
resolve$1(true);
|
|
2109
|
-
});
|
|
2110
|
-
server.listen(port);
|
|
2111
|
-
});
|
|
2112
|
-
}
|
|
2113
|
-
/**
|
|
2114
|
-
* Find an available port starting from the preferred port
|
|
2115
|
-
* @internal Exported for testing
|
|
2116
|
-
*/
|
|
2117
|
-
async function findAvailablePort(preferredPort, maxAttempts = 10) {
|
|
2118
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
2119
|
-
const port = preferredPort + i;
|
|
2120
|
-
if (await isPortAvailable(port)) return port;
|
|
2121
|
-
logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
|
|
2122
|
-
}
|
|
2123
|
-
throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
|
|
2124
|
-
}
|
|
2125
|
-
const PORT_STATE_PATH = ".gkm/ports.json";
|
|
2126
|
-
/**
|
|
2127
|
-
* Parse docker-compose.yml and extract all port mappings that use env var interpolation.
|
|
2128
|
-
* Entries like `'${POSTGRES_HOST_PORT:-5432}:5432'` are captured.
|
|
2129
|
-
* Fixed port mappings like `'5050:80'` are skipped.
|
|
2130
|
-
* @internal Exported for testing
|
|
2131
|
-
*/
|
|
2132
|
-
function parseComposePortMappings(composePath) {
|
|
2133
|
-
if (!existsSync(composePath)) return [];
|
|
2134
|
-
const content = readFileSync(composePath, "utf-8");
|
|
2135
|
-
const compose = parse$1(content);
|
|
2136
|
-
if (!compose?.services) return [];
|
|
2137
|
-
const results = [];
|
|
2138
|
-
for (const [serviceName, serviceConfig] of Object.entries(compose.services)) for (const portMapping of serviceConfig?.ports ?? []) {
|
|
2139
|
-
const match = String(portMapping).match(/\$\{(\w+):-(\d+)\}:(\d+)/);
|
|
2140
|
-
if (match?.[1] && match[2] && match[3]) results.push({
|
|
2141
|
-
service: serviceName,
|
|
2142
|
-
envVar: match[1],
|
|
2143
|
-
defaultPort: Number(match[2]),
|
|
2144
|
-
containerPort: Number(match[3])
|
|
2145
|
-
});
|
|
2146
|
-
}
|
|
2147
|
-
return results;
|
|
2148
|
-
}
|
|
2149
|
-
/**
|
|
2150
|
-
* Load saved port state from .gkm/ports.json.
|
|
2151
|
-
* @internal Exported for testing
|
|
2152
|
-
*/
|
|
2153
|
-
async function loadPortState(workspaceRoot) {
|
|
2154
|
-
try {
|
|
2155
|
-
const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), "utf-8");
|
|
2156
|
-
return JSON.parse(raw);
|
|
2157
|
-
} catch {
|
|
2158
|
-
return {};
|
|
2159
|
-
}
|
|
2160
|
-
}
|
|
2161
|
-
/**
|
|
2162
|
-
* Save port state to .gkm/ports.json.
|
|
2163
|
-
* @internal Exported for testing
|
|
2164
|
-
*/
|
|
2165
|
-
async function savePortState(workspaceRoot, ports) {
|
|
2166
|
-
const dir = join(workspaceRoot, ".gkm");
|
|
2167
|
-
await mkdir(dir, { recursive: true });
|
|
2168
|
-
await writeFile(join(workspaceRoot, PORT_STATE_PATH), `${JSON.stringify(ports, null, 2)}\n`);
|
|
2169
|
-
}
|
|
2170
|
-
/**
|
|
2171
|
-
* Check if a project's own Docker container is running and return its host port.
|
|
2172
|
-
* Uses `docker compose port` scoped to the project's compose file.
|
|
2173
|
-
* @internal Exported for testing
|
|
2174
|
-
*/
|
|
2175
|
-
function getContainerHostPort(workspaceRoot, service, containerPort) {
|
|
2176
|
-
try {
|
|
2177
|
-
const result = execSync(`docker compose port ${service} ${containerPort}`, {
|
|
2178
|
-
cwd: workspaceRoot,
|
|
2179
|
-
stdio: "pipe"
|
|
2180
|
-
}).toString().trim();
|
|
2181
|
-
const match = result.match(/:(\d+)$/);
|
|
2182
|
-
return match ? Number(match[1]) : null;
|
|
2183
|
-
} catch {
|
|
2184
|
-
return null;
|
|
2185
|
-
}
|
|
2186
|
-
}
|
|
2187
|
-
/**
|
|
2188
|
-
* Resolve host ports for Docker services by parsing docker-compose.yml.
|
|
2189
|
-
* Priority: running container → saved state → find available port.
|
|
2190
|
-
* Persists resolved ports to .gkm/ports.json.
|
|
2191
|
-
* @internal Exported for testing
|
|
2192
|
-
*/
|
|
2193
|
-
async function resolveServicePorts(workspaceRoot) {
|
|
2194
|
-
const composePath = join(workspaceRoot, "docker-compose.yml");
|
|
2195
|
-
const mappings = parseComposePortMappings(composePath);
|
|
2196
|
-
if (mappings.length === 0) return {
|
|
2197
|
-
dockerEnv: {},
|
|
2198
|
-
ports: {},
|
|
2199
|
-
mappings: []
|
|
2200
|
-
};
|
|
2201
|
-
const savedState = await loadPortState(workspaceRoot);
|
|
2202
|
-
const dockerEnv = {};
|
|
2203
|
-
const ports = {};
|
|
2204
|
-
const assignedPorts = /* @__PURE__ */ new Set();
|
|
2205
|
-
logger.log("\n🔌 Resolving service ports...");
|
|
2206
|
-
for (const mapping of mappings) {
|
|
2207
|
-
const containerPort = getContainerHostPort(workspaceRoot, mapping.service, mapping.containerPort);
|
|
2208
|
-
if (containerPort !== null) {
|
|
2209
|
-
ports[mapping.envVar] = containerPort;
|
|
2210
|
-
dockerEnv[mapping.envVar] = String(containerPort);
|
|
2211
|
-
assignedPorts.add(containerPort);
|
|
2212
|
-
logger.log(` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`);
|
|
2213
|
-
continue;
|
|
2214
|
-
}
|
|
2215
|
-
const savedPort = savedState[mapping.envVar];
|
|
2216
|
-
if (savedPort && !assignedPorts.has(savedPort) && await isPortAvailable(savedPort)) {
|
|
2217
|
-
ports[mapping.envVar] = savedPort;
|
|
2218
|
-
dockerEnv[mapping.envVar] = String(savedPort);
|
|
2219
|
-
assignedPorts.add(savedPort);
|
|
2220
|
-
logger.log(` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`);
|
|
2221
|
-
continue;
|
|
2222
|
-
}
|
|
2223
|
-
let resolvedPort = await findAvailablePort(mapping.defaultPort);
|
|
2224
|
-
while (assignedPorts.has(resolvedPort)) resolvedPort = await findAvailablePort(resolvedPort + 1);
|
|
2225
|
-
ports[mapping.envVar] = resolvedPort;
|
|
2226
|
-
dockerEnv[mapping.envVar] = String(resolvedPort);
|
|
2227
|
-
assignedPorts.add(resolvedPort);
|
|
2228
|
-
if (resolvedPort !== mapping.defaultPort) logger.log(` ⚡ ${mapping.service}:${mapping.containerPort}: port ${mapping.defaultPort} occupied, using port ${resolvedPort}`);
|
|
2229
|
-
else logger.log(` ✅ ${mapping.service}:${mapping.containerPort}: using default port ${resolvedPort}`);
|
|
2230
|
-
}
|
|
2231
|
-
await savePortState(workspaceRoot, ports);
|
|
2716
|
+
if (start && plan.containers.length > 0) await docker.up(composePath, plan.containers);
|
|
2717
|
+
if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
|
|
2718
|
+
if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
|
|
2719
|
+
const provisioned = start && provision ? await create(plan, ports, sql, buckets, carriers, project) : [];
|
|
2720
|
+
await saveState(root, {
|
|
2721
|
+
hash,
|
|
2722
|
+
stage
|
|
2723
|
+
});
|
|
2232
2724
|
return {
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2725
|
+
...result,
|
|
2726
|
+
provisioned,
|
|
2727
|
+
changed: true
|
|
2236
2728
|
};
|
|
2237
2729
|
}
|
|
2238
2730
|
/**
|
|
2239
|
-
*
|
|
2240
|
-
*
|
|
2241
|
-
*
|
|
2731
|
+
* Create the databases, schemas, buckets, topics and queues the plan names.
|
|
2732
|
+
*
|
|
2733
|
+
* Idempotent throughout, so this runs on every non-converged reconcile rather
|
|
2734
|
+
* than being something to remember.
|
|
2242
2735
|
*/
|
|
2243
|
-
function
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2736
|
+
async function create(plan, ports, sql, buckets, emulatorCarriers, project) {
|
|
2737
|
+
const applied = [];
|
|
2738
|
+
const postgresPort = ports[primaryPortKey("postgres")];
|
|
2739
|
+
const statements = postgresStatements(plan, project);
|
|
2740
|
+
if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort), statements));
|
|
2741
|
+
const minioPort = ports[primaryPortKey("minio")];
|
|
2742
|
+
const names = bucketNames(plan);
|
|
2743
|
+
const policies = bucketPolicies(plan);
|
|
2744
|
+
if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
|
|
2745
|
+
const client = buckets(minioPort);
|
|
2746
|
+
if (names.length > 0) applied.push(...await applyBuckets(client, names));
|
|
2747
|
+
if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
|
|
2748
|
+
}
|
|
2749
|
+
const emulatorPort = ports[primaryPortKey("localstack")];
|
|
2750
|
+
const carriers = carrierNames(plan);
|
|
2751
|
+
if (emulatorPort !== void 0 && carriers.topics.length + carriers.queues.length > 0) applied.push(...await applyCarriers(emulatorCarriers(emulatorPort), carriers));
|
|
2752
|
+
return applied;
|
|
2248
2753
|
}
|
|
2249
2754
|
/**
|
|
2250
|
-
*
|
|
2251
|
-
*
|
|
2252
|
-
*
|
|
2253
|
-
*
|
|
2755
|
+
* The host ports of containers already running.
|
|
2756
|
+
*
|
|
2757
|
+
* Keyed the same way allocation keys them, so the two merge without either
|
|
2758
|
+
* knowing about the other.
|
|
2254
2759
|
*/
|
|
2255
|
-
function
|
|
2256
|
-
const
|
|
2257
|
-
const
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
for (const mapping of mappings) {
|
|
2261
|
-
serviceNames.add(mapping.service);
|
|
2262
|
-
const resolved = ports[mapping.envVar];
|
|
2263
|
-
if (resolved !== void 0) portReplacements.push({
|
|
2264
|
-
defaultPort: mapping.defaultPort,
|
|
2265
|
-
resolvedPort: resolved
|
|
2266
|
-
});
|
|
2267
|
-
}
|
|
2268
|
-
for (const [key, value] of Object.entries(result)) {
|
|
2269
|
-
if (!key.endsWith("_HOST")) continue;
|
|
2270
|
-
if (serviceNames.has(value)) result[key] = "localhost";
|
|
2271
|
-
}
|
|
2272
|
-
for (const [key, value] of Object.entries(result)) {
|
|
2273
|
-
if (!key.endsWith("_PORT")) continue;
|
|
2274
|
-
for (const { defaultPort, resolvedPort } of portReplacements) if (value === String(defaultPort)) result[key] = String(resolvedPort);
|
|
2275
|
-
}
|
|
2276
|
-
for (const [key, value] of Object.entries(result)) {
|
|
2277
|
-
if (!key.endsWith("_URL") && !key.endsWith("_ENDPOINT") && !key.endsWith("_CONNECTION_STRING") && key !== "DATABASE_URL") continue;
|
|
2278
|
-
let rewritten = value;
|
|
2279
|
-
for (const name of serviceNames) rewritten = rewritten.replace(new RegExp(`@${name}:`, "g"), "@localhost:");
|
|
2280
|
-
for (const { defaultPort, resolvedPort } of portReplacements) rewritten = replacePortInUrl(rewritten, defaultPort, resolvedPort);
|
|
2281
|
-
result[key] = rewritten;
|
|
2760
|
+
async function observedPorts(docker, composePath, containers, fakes) {
|
|
2761
|
+
const observed = {};
|
|
2762
|
+
for (const container of containers) for (const port of portsOf(container, fakes)) {
|
|
2763
|
+
const running = await docker.publishedPort(composePath, container, port.inside);
|
|
2764
|
+
if (running !== void 0) observed[port.key] = running;
|
|
2282
2765
|
}
|
|
2283
|
-
return
|
|
2766
|
+
return observed;
|
|
2284
2767
|
}
|
|
2285
2768
|
/**
|
|
2286
|
-
*
|
|
2287
|
-
*
|
|
2288
|
-
*
|
|
2289
|
-
*
|
|
2769
|
+
* Where each container can be reached on this machine.
|
|
2770
|
+
*
|
|
2771
|
+
* The primary port only — a container's console is for a human to open, not
|
|
2772
|
+
* something an app connects to.
|
|
2290
2773
|
*/
|
|
2291
|
-
function
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
}
|
|
2774
|
+
function addressesFor(containers, ports) {
|
|
2775
|
+
const addresses = {};
|
|
2776
|
+
for (const container of containers) {
|
|
2777
|
+
const port = ports[primaryPortKey(container)];
|
|
2778
|
+
if (port !== void 0) addresses[container] = `localhost:${port}`;
|
|
2779
|
+
}
|
|
2780
|
+
return addresses;
|
|
2297
2781
|
}
|
|
2782
|
+
/** Write a file only when its content would change, so mtimes stay meaningful. */
|
|
2298
2783
|
/**
|
|
2299
|
-
*
|
|
2300
|
-
*
|
|
2784
|
+
* Remove the routes of stages this project no longer reconciles.
|
|
2785
|
+
*
|
|
2786
|
+
* Only the local stage and `test` are ever written, so any other file is left
|
|
2787
|
+
* over from a stage that was renamed — `development` becoming `dev`. Caddy
|
|
2788
|
+
* imports every file, and the leftover declares the same hosts as its
|
|
2789
|
+
* replacement: it refuses the whole config as ambiguous, and every URL behind
|
|
2790
|
+
* the edge goes with it.
|
|
2301
2791
|
*/
|
|
2302
|
-
function
|
|
2303
|
-
|
|
2304
|
-
const
|
|
2305
|
-
const
|
|
2306
|
-
return Object.keys(compose?.services ?? {});
|
|
2792
|
+
async function pruneCaddySites(root, stages) {
|
|
2793
|
+
const dir = dirname(join(root, caddySitesPath(TEST_STAGE)));
|
|
2794
|
+
const keep = new Set(stages.map((stage) => stage && `${stage}.caddy`));
|
|
2795
|
+
for (const file of await readdir(dir)) if (file.endsWith(".caddy") && !keep.has(file)) await rm(join(dir, file));
|
|
2307
2796
|
}
|
|
2308
|
-
|
|
2309
|
-
* Start docker-compose services for a single-app project (no workspace config).
|
|
2310
|
-
* Starts all services defined in docker-compose.yml.
|
|
2311
|
-
*/
|
|
2312
|
-
async function startComposeServices(cwd, portEnv, secretsEnv) {
|
|
2313
|
-
const composeFile = join(cwd, "docker-compose.yml");
|
|
2314
|
-
if (!existsSync(composeFile)) return;
|
|
2315
|
-
const servicesToStart = parseComposeServiceNames(composeFile);
|
|
2316
|
-
if (servicesToStart.length === 0) return;
|
|
2317
|
-
logger.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
|
|
2797
|
+
async function write(path, content) {
|
|
2318
2798
|
try {
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
});
|
|
2324
|
-
logger.log("✅ Services started");
|
|
2325
|
-
} catch (error) {
|
|
2326
|
-
logger.error("❌ Failed to start services:", error.message);
|
|
2327
|
-
throw error;
|
|
2328
|
-
}
|
|
2799
|
+
if (await readFile(path, "utf-8") === content) return;
|
|
2800
|
+
} catch {}
|
|
2801
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2802
|
+
await writeFile(path, content);
|
|
2329
2803
|
}
|
|
2804
|
+
//#endregion
|
|
2805
|
+
//#region src/reconcile/workspace.ts
|
|
2330
2806
|
/**
|
|
2331
|
-
*
|
|
2332
|
-
*
|
|
2333
|
-
*
|
|
2334
|
-
*
|
|
2807
|
+
* Workspace config → reconcile.
|
|
2808
|
+
*
|
|
2809
|
+
* Nothing here is read from a `services:` block — there is none. Which
|
|
2810
|
+
* containers exist comes from the manifest; which backend a cache or a broker
|
|
2811
|
+
* resolves to comes from the deploy target; and an image pin is the project's
|
|
2812
|
+
* own `docker-compose.yml`, merged over the generated file.
|
|
2813
|
+
*
|
|
2814
|
+
* Shared by `gkm setup`, `gkm dev`, and `gkm test` so all three converge on the
|
|
2815
|
+
* same state, differing only in the stage they pass.
|
|
2335
2816
|
*/
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
cwd: workspace.root,
|
|
2347
|
-
stdio: "inherit",
|
|
2348
|
-
env: buildDockerComposeEnv(secretsEnv, portEnv)
|
|
2349
|
-
});
|
|
2350
|
-
logger.log("✅ Services started");
|
|
2351
|
-
} catch (error) {
|
|
2352
|
-
logger.error("❌ Failed to start services:", error.message);
|
|
2353
|
-
throw error;
|
|
2354
|
-
}
|
|
2355
|
-
}
|
|
2817
|
+
var workspace_exports = /* @__PURE__ */ __exportAll({
|
|
2818
|
+
FAKE_ENV: () => FAKE_ENV,
|
|
2819
|
+
backendsOf: () => backendsOf,
|
|
2820
|
+
constructGlobs: () => constructGlobs,
|
|
2821
|
+
derivedContainers: () => derivedContainers,
|
|
2822
|
+
fakesApply: () => fakesApply,
|
|
2823
|
+
metroPorts: () => metroPorts,
|
|
2824
|
+
reconcileWorkspace: () => reconcileWorkspace,
|
|
2825
|
+
surfaceAddresses: () => surfaceAddresses
|
|
2826
|
+
});
|
|
2356
2827
|
/**
|
|
2357
|
-
*
|
|
2358
|
-
*
|
|
2359
|
-
*
|
|
2828
|
+
* Every constructs glob in the workspace, resolved against its app.
|
|
2829
|
+
*
|
|
2830
|
+
* Absolute because discovery globs from a single cwd while apps live in
|
|
2831
|
+
* different directories.
|
|
2360
2832
|
*/
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
}
|
|
2371
|
-
if (Object.keys(secrets).length === 0) return {};
|
|
2372
|
-
if (!appName) return secrets;
|
|
2373
|
-
const prefix = appName.toUpperCase();
|
|
2374
|
-
const mapped = { ...secrets };
|
|
2375
|
-
const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
|
|
2376
|
-
if (appDbUrl) mapped.DATABASE_URL = appDbUrl;
|
|
2377
|
-
return mapped;
|
|
2833
|
+
function constructGlobs(workspace) {
|
|
2834
|
+
return allConstructGlobs(workspace);
|
|
2835
|
+
}
|
|
2836
|
+
/** The backends a workspace's target implies, as the plan takes them. */
|
|
2837
|
+
function backendsOf(workspace) {
|
|
2838
|
+
const on = providerOf(workspace);
|
|
2839
|
+
return {
|
|
2840
|
+
events: eventsBackendFor(on),
|
|
2841
|
+
cache: cacheBackendFor(on)
|
|
2842
|
+
};
|
|
2378
2843
|
}
|
|
2379
2844
|
/**
|
|
2380
|
-
*
|
|
2381
|
-
*
|
|
2845
|
+
* The containers a workspace's declarations imply, without reconciling.
|
|
2846
|
+
*
|
|
2847
|
+
* The same derivation `reconcileWorkspace` makes, available to callers that
|
|
2848
|
+
* need to know *what would run* without starting anything or writing ports —
|
|
2849
|
+
* `gkm setup` deciding which credentials to generate, `gkm docker` writing a
|
|
2850
|
+
* compose file. Those used to each read a boolean per service out of config,
|
|
2851
|
+
* which is how a container could exist because config asked for one rather than
|
|
2852
|
+
* because something declared it.
|
|
2382
2853
|
*/
|
|
2383
|
-
function
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2854
|
+
async function derivedContainers(workspace, stage, manifest) {
|
|
2855
|
+
const found = manifest ?? await discover({
|
|
2856
|
+
patterns: constructGlobs(workspace),
|
|
2857
|
+
cwd: workspace.root
|
|
2858
|
+
});
|
|
2859
|
+
return planFor(found, stage, provisionOrder(found), {
|
|
2860
|
+
localStage: workspace.stages.local,
|
|
2861
|
+
...backendsOf(workspace)
|
|
2862
|
+
}).containers;
|
|
2392
2863
|
}
|
|
2393
2864
|
/**
|
|
2394
|
-
*
|
|
2395
|
-
*
|
|
2396
|
-
* @internal
|
|
2865
|
+
* Set by `gkm dev --fake` for the apps it starts. Each app's own `gkm dev`
|
|
2866
|
+
* reconciles too, and must answer the way the workspace did.
|
|
2397
2867
|
*/
|
|
2398
|
-
|
|
2399
|
-
return `import { existsSync, readFileSync } from 'node:fs';
|
|
2400
|
-
|
|
2401
|
-
// Inject dev secrets via globalThis and process.env
|
|
2402
|
-
// Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
|
|
2403
|
-
// Object.assign on the Credentials export only mutates one module copy.
|
|
2404
|
-
const secretsPath = '${secretsJsonPath}';
|
|
2405
|
-
if (existsSync(secretsPath)) {
|
|
2406
|
-
const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
|
|
2407
|
-
globalThis.__gkm_credentials__ = secrets;
|
|
2408
|
-
Object.assign(process.env, secrets);
|
|
2409
|
-
}
|
|
2410
|
-
`;
|
|
2411
|
-
}
|
|
2868
|
+
const FAKE_ENV = "GKM_FAKE";
|
|
2412
2869
|
/**
|
|
2413
|
-
*
|
|
2414
|
-
*
|
|
2415
|
-
*
|
|
2870
|
+
* Whether external APIs are faked: always for `gkm test`, and for `gkm dev`
|
|
2871
|
+
* only when asked — by `--fake`, or by the workspace that started this app
|
|
2872
|
+
* with it. Never for anything acting on a deployed stage.
|
|
2416
2873
|
*/
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
* Credentials preload generated by 'gkm exec'
|
|
2420
|
-
* This file is loaded via NODE_OPTIONS="--import <path>"
|
|
2421
|
-
*/
|
|
2422
|
-
${generateCredentialsInjection(secretsJsonPath)}`;
|
|
2423
|
-
await writeFile(preloadPath, content);
|
|
2874
|
+
function fakesApply(stage, fake, env = process.env) {
|
|
2875
|
+
return fake === true || stage === "test" || env["GKM_FAKE"] === "1";
|
|
2424
2876
|
}
|
|
2425
2877
|
/**
|
|
2426
|
-
*
|
|
2427
|
-
*
|
|
2878
|
+
* Reconcile a workspace for one stage.
|
|
2879
|
+
*
|
|
2880
|
+
* Ports are loaded and saved around the call rather than inside it, so the loop
|
|
2881
|
+
* stays a pure-ish function of its inputs and the store keeps its single owner.
|
|
2428
2882
|
*/
|
|
2429
|
-
async function
|
|
2430
|
-
const
|
|
2431
|
-
|
|
2432
|
-
|
|
2883
|
+
async function reconcileWorkspace(workspace, options) {
|
|
2884
|
+
const runnables = {};
|
|
2885
|
+
const discovered = await discover({
|
|
2886
|
+
patterns: constructGlobs(workspace),
|
|
2887
|
+
cwd: workspace.root,
|
|
2888
|
+
runnables
|
|
2889
|
+
});
|
|
2890
|
+
const manifest = options.manifest ?? discovered;
|
|
2891
|
+
const fakes = fakesApply(options.stage, options.fake) ? await readFakes(workspace.root, manifest) : {};
|
|
2892
|
+
const result = await reconcile({
|
|
2893
|
+
root: workspace.root,
|
|
2894
|
+
project: workspace.name,
|
|
2895
|
+
manifest,
|
|
2896
|
+
stage: options.stage,
|
|
2897
|
+
localStage: workspace.stages.local,
|
|
2898
|
+
...backendsOf(workspace),
|
|
2899
|
+
saved: await loadPortState(workspace.root),
|
|
2900
|
+
addresses: surfaceAddresses(workspace, manifest),
|
|
2901
|
+
metroPorts: metroPorts(workspace, manifest),
|
|
2902
|
+
fakes,
|
|
2903
|
+
apps: (containers) => appServices(workspace, manifest, containers, runnables, fakes),
|
|
2904
|
+
...options.start === void 0 ? {} : { start: options.start }
|
|
2905
|
+
});
|
|
2906
|
+
await savePortState(workspace.root, { ...result.ports });
|
|
2907
|
+
return result;
|
|
2908
|
+
}
|
|
2433
2909
|
/**
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2910
|
+
* Where each mobile app's Metro runs: its app's port, which `gkm exec` hands
|
|
2911
|
+
* Expo as `RCT_METRO_PORT`. Kept apart from the surfaces' addresses — a phone
|
|
2912
|
+
* reaches Metro by `exp://`, not through the edge.
|
|
2913
|
+
*/
|
|
2914
|
+
function metroPorts(workspace, manifest) {
|
|
2915
|
+
const ports = {};
|
|
2916
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
2917
|
+
if (declaration.kind !== "mobile-app") continue;
|
|
2918
|
+
const port = workspace.apps[appKey(id)]?.port;
|
|
2919
|
+
if (port) ports[id] = port;
|
|
2920
|
+
}
|
|
2921
|
+
return ports;
|
|
2440
2922
|
}
|
|
2441
2923
|
/**
|
|
2442
|
-
*
|
|
2443
|
-
* Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
|
|
2444
|
-
* injects PORT, dependency URLs, and writes credentials JSON.
|
|
2924
|
+
* Where each declared surface and site answers locally.
|
|
2445
2925
|
*
|
|
2446
|
-
*
|
|
2447
|
-
*
|
|
2448
|
-
*
|
|
2449
|
-
*
|
|
2450
|
-
*
|
|
2451
|
-
*
|
|
2452
|
-
*
|
|
2926
|
+
* The addresses come from the ports the workspace already assigns; which
|
|
2927
|
+
* construct sits at which one comes from the manifest. That split is the point:
|
|
2928
|
+
* this used to answer "who may call whom" by listing every app the workspace
|
|
2929
|
+
* runs, which is a different question that happened to give the same answer in
|
|
2930
|
+
* a single-repo workspace and no answer at all deployed. Now it answers only
|
|
2931
|
+
* "where does this construct answer", and the graph answers the rest.
|
|
2932
|
+
*
|
|
2933
|
+
* A site is matched to its app by `path`, because that is the one thing a site
|
|
2934
|
+
* declaration and a workspace app both name. Surfaces are matched to the
|
|
2935
|
+
* backend app: every `rest-api` in a process answers on that process's port, so
|
|
2936
|
+
* an app serving both its own API and an auth server publishes one address
|
|
2937
|
+
* twice — which is exactly what it does at runtime.
|
|
2453
2938
|
*/
|
|
2454
|
-
|
|
2455
|
-
const
|
|
2456
|
-
const
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
try {
|
|
2462
|
-
appInfo = await loadWorkspaceAppInfo(cwd);
|
|
2463
|
-
workspaceAppPort = appInfo.app.port;
|
|
2464
|
-
secretsRoot = appInfo.workspaceRoot;
|
|
2465
|
-
appName = appInfo.appName;
|
|
2466
|
-
} catch {
|
|
2467
|
-
secretsRoot = findSecretsRoot(cwd);
|
|
2468
|
-
appName = getAppNameFromCwd(cwd) ?? void 0;
|
|
2469
|
-
}
|
|
2470
|
-
const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
|
|
2471
|
-
const credentials = await loadSecretsForApp(secretsRoot, appName, options.stages);
|
|
2472
|
-
credentials.PORT = String(resolvedPort);
|
|
2473
|
-
const declaredKeys = [];
|
|
2474
|
-
if (appInfo && usesConstructs(appInfo.workspace)) {
|
|
2475
|
-
const { reconcileWorkspace: reconcileWorkspace$1 } = await import("./workspace-k4l3rsFh.mjs");
|
|
2476
|
-
const reconciled = await reconcileWorkspace$1(appInfo.workspace, {
|
|
2477
|
-
stage: options.reconcileStage ?? "development",
|
|
2478
|
-
start: options.startDocker ?? false
|
|
2479
|
-
});
|
|
2480
|
-
Object.assign(credentials, reconciled.env);
|
|
2481
|
-
declaredKeys.push(...Object.keys(reconciled.env));
|
|
2482
|
-
if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
|
|
2483
|
-
} else {
|
|
2484
|
-
const composePath = join(secretsRoot, "docker-compose.yml");
|
|
2485
|
-
const mappings = parseComposePortMappings(composePath);
|
|
2486
|
-
if (mappings.length > 0) {
|
|
2487
|
-
let resolvedPorts;
|
|
2488
|
-
if (portMode === "full") resolvedPorts = await resolveServicePorts(secretsRoot);
|
|
2489
|
-
else {
|
|
2490
|
-
const savedPorts = await loadPortState(secretsRoot);
|
|
2491
|
-
const ports = {};
|
|
2492
|
-
for (const mapping of mappings) {
|
|
2493
|
-
const containerPort = getContainerHostPort(secretsRoot, mapping.service, mapping.containerPort);
|
|
2494
|
-
if (containerPort !== null) ports[mapping.envVar] = containerPort;
|
|
2495
|
-
else {
|
|
2496
|
-
const saved = savedPorts[mapping.envVar];
|
|
2497
|
-
if (saved !== void 0) ports[mapping.envVar] = saved;
|
|
2498
|
-
}
|
|
2499
|
-
}
|
|
2500
|
-
resolvedPorts = {
|
|
2501
|
-
dockerEnv: {},
|
|
2502
|
-
ports,
|
|
2503
|
-
mappings
|
|
2504
|
-
};
|
|
2505
|
-
}
|
|
2506
|
-
if (options.startDocker) if (appInfo) await startWorkspaceServices(appInfo.workspace, resolvedPorts.dockerEnv, credentials);
|
|
2507
|
-
else await startComposeServices(secretsRoot, resolvedPorts.dockerEnv, credentials);
|
|
2508
|
-
if (Object.keys(resolvedPorts.ports).length > 0) {
|
|
2509
|
-
const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
|
|
2510
|
-
Object.assign(credentials, rewritten);
|
|
2511
|
-
logger.log(`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`);
|
|
2512
|
-
}
|
|
2513
|
-
}
|
|
2514
|
-
}
|
|
2515
|
-
if (appInfo?.appName) {
|
|
2516
|
-
const depEnv = getDependencyEnvVars(appInfo.workspace, appInfo.appName);
|
|
2517
|
-
Object.assign(credentials, depEnv);
|
|
2518
|
-
}
|
|
2519
|
-
if (!credentials.EVENT_PUBLISHER_CONNECTION_STRING && !credentials.EVENT_SUBSCRIBER_CONNECTION_STRING && credentials.PGBOSS_DB_USER) {
|
|
2520
|
-
const pgbossUrl = generatePgBossUrl({
|
|
2521
|
-
username: credentials.PGBOSS_DB_USER,
|
|
2522
|
-
password: credentials.PGBOSS_DB_PASSWORD ?? "",
|
|
2523
|
-
host: credentials.POSTGRES_HOST ?? "localhost",
|
|
2524
|
-
port: Number(credentials.POSTGRES_PORT ?? "5432"),
|
|
2525
|
-
database: credentials.POSTGRES_DB ?? "app"
|
|
2526
|
-
});
|
|
2527
|
-
credentials.EVENT_PUBLISHER_CONNECTION_STRING = pgbossUrl;
|
|
2528
|
-
credentials.EVENT_SUBSCRIBER_CONNECTION_STRING = pgbossUrl;
|
|
2939
|
+
function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
|
|
2940
|
+
const addresses = {};
|
|
2941
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
2942
|
+
if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
|
|
2943
|
+
const host = appKey(id);
|
|
2944
|
+
const port = workspace.apps[host]?.port;
|
|
2945
|
+
if (port) addresses[id] = at(host, port);
|
|
2529
2946
|
}
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
return {
|
|
2536
|
-
credentials,
|
|
2537
|
-
resolvedPort,
|
|
2538
|
-
secretsJsonPath,
|
|
2539
|
-
appName,
|
|
2540
|
-
secretsRoot,
|
|
2541
|
-
appInfo,
|
|
2542
|
-
declaredKeys
|
|
2543
|
-
};
|
|
2947
|
+
return addresses;
|
|
2948
|
+
}
|
|
2949
|
+
/** Where a local process answers, given the port the workspace gave it. */
|
|
2950
|
+
function localAddress(port) {
|
|
2951
|
+
return `http://localhost:${port}`;
|
|
2544
2952
|
}
|
|
2545
|
-
|
|
2546
2953
|
//#endregion
|
|
2547
|
-
export {
|
|
2548
|
-
|
|
2954
|
+
export { findAvailablePort as C, prepareEntryCredentials as D, loadSecretsForApp as E, createEntryWrapper as S, loadEnvFiles as T, cacheBackendFor as _, reconcileWorkspace as a, storageBackendFor as b, LOCAL_CA_PATH as c, quoteIdentifier as d, pgClient as f, primaryPortKey as g, fakeKey as h, derivedContainers as i, forgetState as l, rootSite as m, backendsOf as n, workspace_exports as o, resourceName as p, constructGlobs as r, COMPOSE_PATH as s, FAKE_ENV as t, applyPostgres as u, eventsBackendFor as v, isPortAvailable as w, createCredentialsPreload as x, providerOf as y };
|
|
2955
|
+
|
|
2956
|
+
//# sourceMappingURL=workspace-DwS1JK5u.mjs.map
|