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