@geekmidas/cli 10.0.0-alpha.80 → 10.0.0-alpha.82
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/dist/{EndpointGenerator-DftalLYg.mjs → EndpointGenerator-DtnIGafh.mjs} +71 -8
- package/dist/EndpointGenerator-DtnIGafh.mjs.map +1 -0
- package/dist/{EndpointGenerator-CP6sBvQV.cjs → EndpointGenerator-cKlnllIu.cjs} +88 -7
- package/dist/EndpointGenerator-cKlnllIu.cjs.map +1 -0
- package/dist/{OpenApiTsGenerator-nu1r1NgB.mjs → OpenApiTsGenerator-ByRPMbBN.mjs} +34 -7
- package/dist/OpenApiTsGenerator-ByRPMbBN.mjs.map +1 -0
- package/dist/{OpenApiTsGenerator-CKBCckKz.cjs → OpenApiTsGenerator-CdUM6Hvv.cjs} +34 -7
- package/dist/OpenApiTsGenerator-CdUM6Hvv.cjs.map +1 -0
- package/dist/{SubscriberGenerator-CajwOa5W.cjs → SubscriberGenerator-CYriX5BV.cjs} +2 -2
- package/dist/{SubscriberGenerator-CajwOa5W.cjs.map → SubscriberGenerator-CYriX5BV.cjs.map} +1 -1
- package/dist/{SubscriberGenerator-5looQ3gB.mjs → SubscriberGenerator-DQZeGkDW.mjs} +2 -2
- package/dist/{SubscriberGenerator-5looQ3gB.mjs.map → SubscriberGenerator-DQZeGkDW.mjs.map} +1 -1
- package/dist/{add-DJ0VE-y-.cjs → add-1T3y6E4M.cjs} +2 -2
- package/dist/{add-DJ0VE-y-.cjs.map → add-1T3y6E4M.cjs.map} +1 -1
- package/dist/{add-CIxT-Iq6.mjs → add-DnYHJOap.mjs} +2 -2
- package/dist/{add-CIxT-Iq6.mjs.map → add-DnYHJOap.mjs.map} +1 -1
- package/dist/{bundler-D4pl87A5.mjs → bundler-DlKdxCGh.mjs} +3 -3
- package/dist/{bundler-D4pl87A5.mjs.map → bundler-DlKdxCGh.mjs.map} +1 -1
- package/dist/{bundler-BlbSLFLn.cjs → bundler-YNvLdvlh.cjs} +3 -3
- package/dist/{bundler-BlbSLFLn.cjs.map → bundler-YNvLdvlh.cjs.map} +1 -1
- package/dist/{cli-BkNZBe_-.cjs → cli-CWee1-wU.cjs} +3 -3
- package/dist/{cli-BkNZBe_-.cjs.map → cli-CWee1-wU.cjs.map} +1 -1
- package/dist/{cli-7aLfgIGt.mjs → cli-qMszGvDu.mjs} +3 -3
- package/dist/{cli-7aLfgIGt.mjs.map → cli-qMszGvDu.mjs.map} +1 -1
- package/dist/{compose-n7K8H5j8.mjs → compose-D4sD4Lgr.mjs} +83 -351
- package/dist/compose-D4sD4Lgr.mjs.map +1 -0
- package/dist/{compose-Bn4OJONR.cjs → compose-qnDyPJNp.cjs} +101 -369
- package/dist/compose-qnDyPJNp.cjs.map +1 -0
- package/dist/deploy/public.cjs +5 -5
- package/dist/deploy/public.d.cts.map +1 -1
- package/dist/deploy/public.d.mts.map +1 -1
- package/dist/deploy/public.mjs +5 -5
- package/dist/{deploy-DK4Y6ev4.mjs → deploy-C00DAKOt.mjs} +3 -3
- package/dist/{deploy-DK4Y6ev4.mjs.map → deploy-C00DAKOt.mjs.map} +1 -1
- package/dist/{deploy-Ppnh8MHS.cjs → deploy-CXP2fXvK.cjs} +3 -3
- package/dist/{deploy-Ppnh8MHS.cjs.map → deploy-CXP2fXvK.cjs.map} +1 -1
- package/dist/{dokploy-Bjq05wkw.mjs → dokploy-B3zoCmJb.mjs} +2 -2
- package/dist/{dokploy-Bjq05wkw.mjs.map → dokploy-B3zoCmJb.mjs.map} +1 -1
- package/dist/{dokploy-CJvdoNc3.cjs → dokploy-BHcSmghb.cjs} +2 -2
- package/dist/{dokploy-CJvdoNc3.cjs.map → dokploy-BHcSmghb.cjs.map} +1 -1
- package/dist/{drivers-Ch1rJitn.mjs → drivers-B4RP2B2O.mjs} +2 -2
- package/dist/{drivers-Ch1rJitn.mjs.map → drivers-B4RP2B2O.mjs.map} +1 -1
- package/dist/{drivers-BdvtCKc7.cjs → drivers-CJx76mcb.cjs} +2 -2
- package/dist/{drivers-BdvtCKc7.cjs.map → drivers-CJx76mcb.cjs.map} +1 -1
- package/dist/{encryption-CAyR6D7B.cjs → encryption-DLin89I_.cjs} +2 -2
- package/dist/{encryption-CAyR6D7B.cjs.map → encryption-DLin89I_.cjs.map} +1 -1
- package/dist/{encryption-BrsOJFFz.mjs → encryption-DPFljXtM.mjs} +2 -2
- package/dist/{encryption-BrsOJFFz.mjs.map → encryption-DPFljXtM.mjs.map} +1 -1
- package/dist/{engine-DzBZR5D0.cjs → engine-BS3iX9Mu.cjs} +2 -2
- package/dist/{engine-DzBZR5D0.cjs.map → engine-BS3iX9Mu.cjs.map} +1 -1
- package/dist/{engine-ba9CvjTo.mjs → engine-CgzgIg-_.mjs} +2 -2
- package/dist/{engine-ba9CvjTo.mjs.map → engine-CgzgIg-_.mjs.map} +1 -1
- package/dist/{github-BLjU59Ye.cjs → github-D8KVmNRg.cjs} +2 -2
- package/dist/{github-BLjU59Ye.cjs.map → github-D8KVmNRg.cjs.map} +1 -1
- package/dist/{github-CVdbVlT5.mjs → github-DL4zwJsR.mjs} +2 -2
- package/dist/{github-CVdbVlT5.mjs.map → github-DL4zwJsR.mjs.map} +1 -1
- package/dist/index.cjs +225 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +226 -50
- package/dist/index.mjs.map +1 -1
- package/dist/{migrate-iEdyEx4S.cjs → migrate-BdKhIJgT.cjs} +2 -2
- package/dist/{migrate-iEdyEx4S.cjs.map → migrate-BdKhIJgT.cjs.map} +1 -1
- package/dist/{migrate-DlBgATa-.mjs → migrate-CCejlgU0.mjs} +2 -2
- package/dist/{migrate-DlBgATa-.mjs.map → migrate-CCejlgU0.mjs.map} +1 -1
- package/dist/{migrate-pfu4aYzX.mjs → migrate-Cd3kcI7o.mjs} +2 -2
- package/dist/{migrate-pfu4aYzX.mjs.map → migrate-Cd3kcI7o.mjs.map} +1 -1
- package/dist/{migrate-DkB833Jj.cjs → migrate-DIjG8-QR.cjs} +2 -2
- package/dist/{migrate-DkB833Jj.cjs.map → migrate-DIjG8-QR.cjs.map} +1 -1
- package/dist/openapi.cjs +16 -11
- package/dist/openapi.cjs.map +1 -1
- package/dist/openapi.d.cts +28 -0
- package/dist/openapi.d.cts.map +1 -1
- package/dist/openapi.d.mts +28 -0
- package/dist/openapi.d.mts.map +1 -1
- package/dist/openapi.mjs +16 -12
- package/dist/openapi.mjs.map +1 -1
- package/dist/{ready-BB9xS4S9.cjs → ready-4kx2pAhm.cjs} +5 -5
- package/dist/{ready-BB9xS4S9.cjs.map → ready-4kx2pAhm.cjs.map} +1 -1
- package/dist/{ready-ZZoNskrr.mjs → ready-DhZbZtFw.mjs} +5 -5
- package/dist/{ready-ZZoNskrr.mjs.map → ready-DhZbZtFw.mjs.map} +1 -1
- package/dist/{resolve-4_5N67Fk.cjs → resolve-ApSUK_HB.cjs} +3 -3
- package/dist/{resolve-4_5N67Fk.cjs.map → resolve-ApSUK_HB.cjs.map} +1 -1
- package/dist/{resolve-CQPn66jr.mjs → resolve-DY9TaVeV.mjs} +3 -3
- package/dist/{resolve-CQPn66jr.mjs.map → resolve-DY9TaVeV.mjs.map} +1 -1
- package/dist/{rollback-CgYUPLbq.cjs → rollback-CdGhCk1d.cjs} +3 -3
- package/dist/{rollback-CgYUPLbq.cjs.map → rollback-CdGhCk1d.cjs.map} +1 -1
- package/dist/{rollback-C0cbdsM-.mjs → rollback-DXU6cr7q.mjs} +3 -3
- package/dist/{rollback-C0cbdsM-.mjs.map → rollback-DXU6cr7q.mjs.map} +1 -1
- package/dist/{seeds-ZljwYV_p.cjs → seeds-BLzrSwEr.cjs} +3889 -2953
- package/dist/seeds-BLzrSwEr.cjs.map +1 -0
- package/dist/{seeds-BdkFpW0P.mjs → seeds-DbyqaXW4.mjs} +2344 -1528
- package/dist/seeds-DbyqaXW4.mjs.map +1 -0
- package/dist/target/public.cjs +1 -1
- package/dist/target/public.mjs +1 -1
- package/dist/vitest.cjs +1 -1
- package/dist/vitest.mjs +1 -1
- package/package.json +15 -15
- package/dist/EndpointGenerator-CP6sBvQV.cjs.map +0 -1
- package/dist/EndpointGenerator-DftalLYg.mjs.map +0 -1
- package/dist/OpenApiTsGenerator-CKBCckKz.cjs.map +0 -1
- package/dist/OpenApiTsGenerator-nu1r1NgB.mjs.map +0 -1
- package/dist/compose-Bn4OJONR.cjs.map +0 -1
- package/dist/compose-n7K8H5j8.mjs.map +0 -1
- package/dist/seeds-BdkFpW0P.mjs.map +0 -1
- package/dist/seeds-ZljwYV_p.cjs.map +0 -1
|
@@ -2,14 +2,14 @@ import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
|
|
|
2
2
|
import { i as run$1, s as gkmHome } from "./run-BJLIerMM.mjs";
|
|
3
3
|
import { d as output, r as discover, u as ConstructGenerator } from "./discover-BPelzeGW.mjs";
|
|
4
4
|
import { t as appKey } from "./derive-B4vDpzgP.mjs";
|
|
5
|
-
import { O as TEST_STAGE, R as UnknownSecretsStoreProvider, h as getPublicEnvPrefix, m as PUBLIC_ENV_PREFIXES, n as allConstructGlobs } from "./workspace-BBdqmkch.mjs";
|
|
5
|
+
import { F as LOGS_PORT, O as TEST_STAGE, R as UnknownSecretsStoreProvider, h as getPublicEnvPrefix, m as PUBLIC_ENV_PREFIXES, n as allConstructGlobs } from "./workspace-BBdqmkch.mjs";
|
|
6
6
|
import { ConfigNotFound, NotInAnApp, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
|
|
7
|
-
import { n as runtimeFor, r as generateTelemetryModule } from "./EndpointGenerator-
|
|
8
|
-
import { c as runtimeOf } from "./resolve-
|
|
9
|
-
import { a as DEFAULT_STORAGE, i as DEFAULT_EVENTS$1, n as readFakes, o as holdsEveryCache, r as DEFAULT_CACHE, t as SubscriberGenerator } from "./SubscriberGenerator-
|
|
7
|
+
import { n as runtimeFor, r as generateTelemetryModule, s as telemetryArgs } from "./EndpointGenerator-DtnIGafh.mjs";
|
|
8
|
+
import { c as runtimeOf } from "./resolve-DY9TaVeV.mjs";
|
|
9
|
+
import { a as DEFAULT_STORAGE, i as DEFAULT_EVENTS$1, n as readFakes, o as holdsEveryCache, r as DEFAULT_CACHE, t as SubscriberGenerator } from "./SubscriberGenerator-DQZeGkDW.mjs";
|
|
10
10
|
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
11
11
|
import { constants, existsSync, readFileSync } from "node:fs";
|
|
12
|
-
import { appendFile, chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { appendFile, chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
13
13
|
import { homedir, networkInterfaces } from "node:os";
|
|
14
14
|
import { promisify } from "node:util";
|
|
15
15
|
import { Cron } from "@geekmidas/constructs/crons";
|
|
@@ -18,16 +18,16 @@ import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dep
|
|
|
18
18
|
import { createServer } from "node:net";
|
|
19
19
|
import { execFile, spawn } from "node:child_process";
|
|
20
20
|
import { config } from "dotenv";
|
|
21
|
-
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
21
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomInt } from "node:crypto";
|
|
22
|
+
import { stringify } from "yaml";
|
|
22
23
|
import { formatKeyring, generateKeyring } from "@geekmidas/constructs/encryption";
|
|
23
24
|
import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
|
|
24
25
|
import * as snsUrl from "@geekmidas/events/sns/url";
|
|
25
26
|
import * as sqsUrl from "@geekmidas/events/sqs/url";
|
|
26
|
-
import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
|
|
27
|
+
import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, ListBucketsCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
|
|
27
28
|
import { CreateTopicCommand, GetTopicAttributesCommand, SNSClient } from "@aws-sdk/client-sns";
|
|
28
29
|
import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
29
30
|
import { Client } from "pg";
|
|
30
|
-
import { stringify } from "yaml";
|
|
31
31
|
import { cacheTableStatements } from "@geekmidas/cache/postgres";
|
|
32
32
|
//#region src/secrets/storage.ts
|
|
33
33
|
var storage_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -499,6 +499,10 @@ function packageName$1(root) {
|
|
|
499
499
|
return;
|
|
500
500
|
}
|
|
501
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* Encrypt a JSON value with a stage key: AES-256-GCM, the file format every
|
|
504
|
+
* stage's secrets are kept in.
|
|
505
|
+
*/
|
|
502
506
|
function encrypt(secrets, keyHex) {
|
|
503
507
|
const key = Buffer.from(keyHex, "hex");
|
|
504
508
|
const iv = randomBytes(IV_LENGTH);
|
|
@@ -510,6 +514,7 @@ function encrypt(secrets, keyHex) {
|
|
|
510
514
|
iv: iv.toString("hex")
|
|
511
515
|
};
|
|
512
516
|
}
|
|
517
|
+
/** Decrypt what {@link encrypt} wrote. */
|
|
513
518
|
function decrypt(data, keyHex) {
|
|
514
519
|
const key = Buffer.from(keyHex, "hex");
|
|
515
520
|
const combined = Buffer.from(data.encrypted, "base64");
|
|
@@ -984,269 +989,579 @@ function volumeOf(container) {
|
|
|
984
989
|
}[container];
|
|
985
990
|
}
|
|
986
991
|
//#endregion
|
|
987
|
-
//#region src/
|
|
988
|
-
/**
|
|
989
|
-
* Which backend each declared kind resolves to — decided by where the project
|
|
990
|
-
* deploys, and by nothing else.
|
|
991
|
-
*
|
|
992
|
-
* These used to be read out of a `services:` block, where one key answered two
|
|
993
|
-
* questions: *does this exist* and *what backs it*. The first is the manifest's
|
|
994
|
-
* — a declared `Cache` is why there is a cache — and the second is the deploy
|
|
995
|
-
* target's, because the right cache for a Lambda is not the right cache for a
|
|
996
|
-
* box already running Postgres. With both answered elsewhere there was nothing
|
|
997
|
-
* left for the block to say, so it is gone and these read the target.
|
|
998
|
-
*
|
|
999
|
-
* Mail has no answer here at all: every provider speaks SMTP, so the provider
|
|
1000
|
-
* is whichever one the stage's mail URL points at.
|
|
1001
|
-
*/
|
|
992
|
+
//#region src/reconcile/emulator.ts
|
|
1002
993
|
/**
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
* controls?** Dokploy does, so a bucket or a cache can live beside the app;
|
|
1007
|
-
* AWS, Vercel and Cloudflare do not, so the default has to be something
|
|
1008
|
-
* managed. That is the whole of the distinction, and it is why `MainProvider`
|
|
1009
|
-
* is the right axis rather than the deploy target's own name — targets share
|
|
1010
|
-
* answers.
|
|
994
|
+
* The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
|
|
995
|
+
* locally — the facts the compose file, the env writer and the applier must
|
|
996
|
+
* agree on.
|
|
1011
997
|
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
998
|
+
* The emulator has one account and answers with deterministic ARNs and queue
|
|
999
|
+
* URLs, which is what lets a connection string be composed before the topic
|
|
1000
|
+
* or queue it names has been created.
|
|
1015
1001
|
*/
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
}
|
|
1002
|
+
/** The emulator's single account. */
|
|
1003
|
+
const EMULATOR_ACCOUNT = "000000000000";
|
|
1004
|
+
const EMULATOR_REGION = "us-east-1";
|
|
1020
1005
|
/**
|
|
1021
|
-
* The
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1006
|
+
* The emulator's access key id. The `LSIA` prefix is what LocalStack required
|
|
1007
|
+
* on issued keys, and LocalStack reads the account from the id — so it is
|
|
1008
|
+
* fixed, where the secret key beside it is generated per machine (see
|
|
1009
|
+
* `localCredentials.ts`). Floci does not care; keeping it means a project
|
|
1010
|
+
* that pins the old image still works.
|
|
1026
1011
|
*/
|
|
1027
|
-
|
|
1028
|
-
|
|
1012
|
+
const EMULATOR_ACCESS_KEY_ID = "LSIAQAAAAAAVNCBMPNSG";
|
|
1013
|
+
/** The emulator's address from the host, on the port it was published on. */
|
|
1014
|
+
function emulatorEndpoint(port) {
|
|
1015
|
+
return `http://localhost:${port}`;
|
|
1029
1016
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
return DEFAULT_STORAGE[on];
|
|
1017
|
+
function emulatorTopicArn(name) {
|
|
1018
|
+
return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
|
|
1033
1019
|
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
*
|
|
1037
|
-
* Only consulted when something declares one; a project with no `Topic` or
|
|
1038
|
-
* `Queue` has no events at all, whatever the target.
|
|
1039
|
-
*/
|
|
1040
|
-
function eventsBackendFor(on) {
|
|
1041
|
-
return DEFAULT_EVENTS$1[on];
|
|
1020
|
+
function emulatorQueueUrl(name, port) {
|
|
1021
|
+
return `${emulatorEndpoint(port)}/${EMULATOR_ACCOUNT}/${name}`;
|
|
1042
1022
|
}
|
|
1043
1023
|
//#endregion
|
|
1044
|
-
//#region src/
|
|
1024
|
+
//#region src/reconcile/compose.ts
|
|
1045
1025
|
/**
|
|
1046
|
-
*
|
|
1047
|
-
* an image.
|
|
1026
|
+
* The compose document a plan implies.
|
|
1048
1027
|
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
* What `gkm exec` injects inside an image build: the public values its build
|
|
1062
|
-
* args set — every `NEXT_PUBLIC_*`, `VITE_*` and `EXPO_PUBLIC_*` — and
|
|
1063
|
-
* nothing read from a secrets store, a file or a container.
|
|
1064
|
-
*/
|
|
1065
|
-
function imageBuildCredentials(env = process.env) {
|
|
1066
|
-
const credentials = {};
|
|
1067
|
-
for (const [key, value] of Object.entries(env)) {
|
|
1068
|
-
if (value === void 0) continue;
|
|
1069
|
-
if (PUBLIC_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) credentials[key] = value;
|
|
1070
|
-
}
|
|
1071
|
-
return credentials;
|
|
1072
|
-
}
|
|
1073
|
-
//#endregion
|
|
1074
|
-
//#region src/docker/templates.ts
|
|
1075
|
-
/**
|
|
1076
|
-
* The turbo every image prunes and builds with when the build root resolves
|
|
1077
|
-
* none of its own: a repository without turbo still needs `turbo prune` to
|
|
1078
|
-
* cut its slice of the workspace, and an unpinned `turbo` is whatever the
|
|
1079
|
-
* registry says on the day of the build.
|
|
1080
|
-
*/
|
|
1081
|
-
const TURBO_VERSION = "2.5.4";
|
|
1082
|
-
/**
|
|
1083
|
-
* Caddy, which serves a static site's files. A 2.x tag, so a rebuild picks
|
|
1084
|
-
* up patch releases and never a major.
|
|
1085
|
-
*/
|
|
1086
|
-
const STATIC_SITE_IMAGE = "caddy:2.10-alpine";
|
|
1087
|
-
const LOCKFILES = [
|
|
1088
|
-
["pnpm-lock.yaml", "pnpm"],
|
|
1089
|
-
["bun.lockb", "bun"],
|
|
1090
|
-
["yarn.lock", "yarn"],
|
|
1091
|
-
["package-lock.json", "npm"]
|
|
1092
|
-
];
|
|
1093
|
-
/**
|
|
1094
|
-
* Detect package manager from lockfiles
|
|
1095
|
-
* Walks up the directory tree to find lockfile (for monorepos)
|
|
1096
|
-
*/
|
|
1097
|
-
function detectPackageManager(cwd = process.cwd()) {
|
|
1098
|
-
const lockfile = findLockfilePath(cwd);
|
|
1099
|
-
if (!lockfile) return "pnpm";
|
|
1100
|
-
const name = lockfile.slice(dirname(lockfile).length + 1);
|
|
1101
|
-
return LOCKFILES.find(([file]) => file === name)?.[1] ?? "pnpm";
|
|
1102
|
-
}
|
|
1103
|
-
/**
|
|
1104
|
-
* Find the lockfile path by walking up the directory tree
|
|
1105
|
-
* Returns the full path to the lockfile, or null if not found
|
|
1028
|
+
* The third declaration of the same containers disappears here: today a
|
|
1029
|
+
* `services: ['postgres','minio']` block is kept in step with the code by hand,
|
|
1030
|
+
* which is the triplication this design exists to remove, in the one place you
|
|
1031
|
+
* touch every day. A `database` implies Postgres; the list is derived.
|
|
1032
|
+
*
|
|
1033
|
+
* Returns an object rather than a string for the same reason `plan` is pure —
|
|
1034
|
+
* what should exist is asserted as data, and turning it into YAML is one line at
|
|
1035
|
+
* the edge.
|
|
1036
|
+
*
|
|
1037
|
+
* What stays config is unchanged: images and versions are pinned explicitly, and
|
|
1038
|
+
* a container no construct implies is still declared by hand. Neutral facts come
|
|
1039
|
+
* from the declaration, provider detail from config.
|
|
1106
1040
|
*/
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
const lockfilePath = join(dir, lockfile);
|
|
1110
|
-
if (existsSync(lockfilePath)) return lockfilePath;
|
|
1111
|
-
}
|
|
1112
|
-
return null;
|
|
1113
|
-
}
|
|
1041
|
+
/** The database every connection opens first: every cluster has it. */
|
|
1042
|
+
const CLUSTER_DATABASE = "postgres";
|
|
1114
1043
|
/**
|
|
1115
|
-
*
|
|
1116
|
-
* holding a lockfile or a `pnpm-workspace.yaml` — the package manager's root,
|
|
1117
|
-
* which is what an install resolves the workspace's dependencies against.
|
|
1044
|
+
* Build the compose document for a plan.
|
|
1118
1045
|
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
* when neither is found.
|
|
1046
|
+
* Every container is published on its assigned port, never on the image's
|
|
1047
|
+
* default: the point of allocation is that two projects can run at once, and
|
|
1048
|
+
* one container publishing 5432 puts that back.
|
|
1123
1049
|
*/
|
|
1124
|
-
function
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1050
|
+
function composeFor(plan, options) {
|
|
1051
|
+
const services = {};
|
|
1052
|
+
const volumes = {};
|
|
1053
|
+
for (const container of [...plan.containers].sort()) {
|
|
1054
|
+
const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
|
|
1055
|
+
const fake = plan.fakes?.[container];
|
|
1056
|
+
const image = declared ? postgresImage(declared) : fake?.image ?? DEFAULT_IMAGES[container];
|
|
1057
|
+
if (!image) throw new UnknownContainer(container);
|
|
1058
|
+
const published = publish(container, options.ports, plan.fakes);
|
|
1059
|
+
services[container] = fake ? {
|
|
1060
|
+
image,
|
|
1061
|
+
restart: "unless-stopped",
|
|
1062
|
+
ports: published
|
|
1063
|
+
} : define(container, image, published, options.credentials);
|
|
1064
|
+
const volume = volumeOf(container);
|
|
1065
|
+
if (volume) volumes[volume] = {};
|
|
1139
1066
|
}
|
|
1140
|
-
dirs.push(root);
|
|
1141
|
-
return dirs;
|
|
1142
|
-
}
|
|
1143
|
-
/** The package manager, its version, turbo's and the shape of a build root. */
|
|
1144
|
-
function resolveBuildTools(buildRoot) {
|
|
1145
|
-
const pkg = readJson(join(buildRoot, "package.json"));
|
|
1146
|
-
const packageManager = detectPackageManager(buildRoot);
|
|
1147
|
-
const field = typeof pkg?.packageManager === "string" ? pkg.packageManager : "";
|
|
1148
|
-
const match = /^([a-z]+)@(\d[^+\s]*)/.exec(field);
|
|
1149
|
-
const packageManagerVersion = match && match[1] === packageManager ? match[2] : void 0;
|
|
1150
1067
|
return {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
}
|
|
1157
|
-
/**
|
|
1158
|
-
* The turbo version a build root resolves: what is installed, or what its
|
|
1159
|
-
* lockfile pins. Undefined when it has none.
|
|
1160
|
-
*/
|
|
1161
|
-
function resolveTurboVersion(buildRoot) {
|
|
1162
|
-
const installed = readJson(join(buildRoot, "node_modules", "turbo", "package.json"));
|
|
1163
|
-
if (typeof installed?.version === "string") return installed.version;
|
|
1164
|
-
const read = (file) => {
|
|
1165
|
-
try {
|
|
1166
|
-
return readFileSync(join(buildRoot, file), "utf-8");
|
|
1167
|
-
} catch {
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1068
|
+
name: options.project,
|
|
1069
|
+
services: {
|
|
1070
|
+
...services,
|
|
1071
|
+
...options.apps
|
|
1072
|
+
},
|
|
1073
|
+
...Object.keys(volumes).length ? { volumes } : {}
|
|
1170
1074
|
};
|
|
1171
|
-
const pnpm = read("pnpm-lock.yaml");
|
|
1172
|
-
const fromPnpm = pnpm && /^ {2}'?turbo@(\d[^:'(\s]*)'?:/m.exec(pnpm);
|
|
1173
|
-
if (fromPnpm) return fromPnpm[1];
|
|
1174
|
-
const npm = read("package-lock.json");
|
|
1175
|
-
if (npm) {
|
|
1176
|
-
const version = safeParse(npm)?.packages?.["node_modules/turbo"]?.version;
|
|
1177
|
-
if (version) return version;
|
|
1178
|
-
}
|
|
1179
|
-
const yarn = read("yarn.lock");
|
|
1180
|
-
const fromYarn = yarn && /^"?turbo@[^\n]*:\n\s+version:? "?(\d[^"\s]*)"?/m.exec(yarn);
|
|
1181
|
-
if (fromYarn) return fromYarn[1];
|
|
1182
1075
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1076
|
+
/** Render a compose document as the file it becomes. */
|
|
1077
|
+
function toYaml(file) {
|
|
1078
|
+
return `# Generated by gkm from the construct manifest — do not edit.
|
|
1079
|
+
# Containers are derived from what the app declares. Image pins and anything
|
|
1080
|
+
# no construct implies go in your own docker-compose.yml, which is merged over
|
|
1081
|
+
# this file and wins:
|
|
1082
|
+
#
|
|
1083
|
+
# docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
|
|
1084
|
+
#
|
|
1085
|
+
# "gkm dev" and "gkm test" start the containers. The apps sit behind the
|
|
1086
|
+
# "apps" profile: run "gkm setup", then
|
|
1087
|
+
# docker compose -f docker-compose.constructs.yml --profile apps up --build
|
|
1088
|
+
${stringify(file, { lineWidth: 0 })}`;
|
|
1189
1089
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1090
|
+
/** A container with no image, from config or from the defaults. */
|
|
1091
|
+
var UnknownContainer = class extends Error {
|
|
1092
|
+
/** The container that could not be resolved. */
|
|
1093
|
+
container;
|
|
1094
|
+
/** The containers that have a default, so the caller can see the set. */
|
|
1095
|
+
known = Object.keys(DEFAULT_IMAGES).sort();
|
|
1096
|
+
constructor(container) {
|
|
1097
|
+
super("No image is known for this container; pin one in config");
|
|
1098
|
+
this.name = "UnknownContainer";
|
|
1099
|
+
this.container = container;
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
/** One container's definition, published on the port it was assigned. */
|
|
1103
|
+
function define(container, image, published, credentials) {
|
|
1104
|
+
switch (container) {
|
|
1105
|
+
case "postgres": return {
|
|
1106
|
+
image,
|
|
1107
|
+
restart: "unless-stopped",
|
|
1108
|
+
ports: published,
|
|
1109
|
+
environment: {
|
|
1110
|
+
POSTGRES_USER: credentials.postgres.user,
|
|
1111
|
+
POSTGRES_PASSWORD: credentials.postgres.password,
|
|
1112
|
+
POSTGRES_DB: CLUSTER_DATABASE
|
|
1113
|
+
},
|
|
1114
|
+
volumes: ["postgres-data:/var/lib/postgresql"],
|
|
1115
|
+
healthcheck: {
|
|
1116
|
+
test: ["CMD-SHELL", `pg_isready -U ${credentials.postgres.user} -d ${CLUSTER_DATABASE}`],
|
|
1117
|
+
interval: "5s",
|
|
1118
|
+
timeout: "3s",
|
|
1119
|
+
retries: 10,
|
|
1120
|
+
start_period: "10s"
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
case "minio": return {
|
|
1124
|
+
image,
|
|
1125
|
+
restart: "unless-stopped",
|
|
1126
|
+
ports: published,
|
|
1127
|
+
environment: {
|
|
1128
|
+
MINIO_ROOT_USER: credentials.minio.user,
|
|
1129
|
+
MINIO_ROOT_PASSWORD: credentials.minio.password
|
|
1130
|
+
},
|
|
1131
|
+
command: "server --console-address \":9001\" /data",
|
|
1132
|
+
volumes: ["minio-data:/data"],
|
|
1133
|
+
healthcheck: {
|
|
1134
|
+
test: [
|
|
1135
|
+
"CMD",
|
|
1136
|
+
"mc",
|
|
1137
|
+
"ready",
|
|
1138
|
+
"local"
|
|
1139
|
+
],
|
|
1140
|
+
interval: "10s",
|
|
1141
|
+
timeout: "5s",
|
|
1142
|
+
retries: 5,
|
|
1143
|
+
start_period: "10s"
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
case "caddy": return {
|
|
1147
|
+
image,
|
|
1148
|
+
restart: "unless-stopped",
|
|
1149
|
+
ports: published,
|
|
1150
|
+
volumes: [
|
|
1151
|
+
"./.gkm/Caddyfile:/etc/caddy/Caddyfile:ro",
|
|
1152
|
+
"./.gkm/caddy-sites:/etc/caddy/sites:ro",
|
|
1153
|
+
"caddy-data:/data"
|
|
1154
|
+
],
|
|
1155
|
+
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
1156
|
+
healthcheck: {
|
|
1157
|
+
test: [
|
|
1158
|
+
"CMD",
|
|
1159
|
+
"caddy",
|
|
1160
|
+
"version"
|
|
1161
|
+
],
|
|
1162
|
+
interval: "10s",
|
|
1163
|
+
timeout: "5s",
|
|
1164
|
+
retries: 5
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
case "mailpit": return {
|
|
1168
|
+
image,
|
|
1169
|
+
restart: "unless-stopped",
|
|
1170
|
+
ports: published,
|
|
1171
|
+
environment: {
|
|
1172
|
+
MP_SMTP_AUTH_ACCEPT_ANY: "1",
|
|
1173
|
+
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
|
|
1174
|
+
},
|
|
1175
|
+
healthcheck: {
|
|
1176
|
+
test: [
|
|
1177
|
+
"CMD",
|
|
1178
|
+
"wget",
|
|
1179
|
+
"-q",
|
|
1180
|
+
"--spider",
|
|
1181
|
+
"http://localhost:8025"
|
|
1182
|
+
],
|
|
1183
|
+
interval: "10s",
|
|
1184
|
+
timeout: "5s",
|
|
1185
|
+
retries: 5
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
case "redis": return {
|
|
1189
|
+
image,
|
|
1190
|
+
restart: "unless-stopped",
|
|
1191
|
+
ports: published,
|
|
1192
|
+
command: "sh -c 'exec docker-entrypoint.sh redis-server --requirepass \"$$REDIS_PASSWORD\"'",
|
|
1193
|
+
environment: {
|
|
1194
|
+
REDIS_PASSWORD: credentials.redis.password,
|
|
1195
|
+
REDISCLI_AUTH: credentials.redis.password
|
|
1196
|
+
},
|
|
1197
|
+
volumes: ["redis-data:/data"],
|
|
1198
|
+
healthcheck: {
|
|
1199
|
+
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"],
|
|
1200
|
+
interval: "5s",
|
|
1201
|
+
timeout: "3s",
|
|
1202
|
+
retries: 5
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
case "redis-http": return {
|
|
1206
|
+
image,
|
|
1207
|
+
restart: "unless-stopped",
|
|
1208
|
+
ports: published,
|
|
1209
|
+
environment: {
|
|
1210
|
+
SRH_MODE: "env",
|
|
1211
|
+
SRH_TOKEN: credentials.cacheToken,
|
|
1212
|
+
SRH_CONNECTION_STRING: `redis://:${encodeURIComponent(credentials.redis.password)}@redis:6379`
|
|
1213
|
+
},
|
|
1214
|
+
depends_on: ["redis"],
|
|
1215
|
+
healthcheck: {
|
|
1216
|
+
test: [
|
|
1217
|
+
"CMD",
|
|
1218
|
+
"wget",
|
|
1219
|
+
"-q",
|
|
1220
|
+
"-O",
|
|
1221
|
+
"/dev/null",
|
|
1222
|
+
"http://127.0.0.1:80/"
|
|
1223
|
+
],
|
|
1224
|
+
interval: "5s",
|
|
1225
|
+
timeout: "3s",
|
|
1226
|
+
retries: 10
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
case "rabbitmq": return {
|
|
1230
|
+
image,
|
|
1231
|
+
restart: "unless-stopped",
|
|
1232
|
+
ports: published,
|
|
1233
|
+
environment: {
|
|
1234
|
+
RABBITMQ_DEFAULT_USER: credentials.rabbitmq.user,
|
|
1235
|
+
RABBITMQ_DEFAULT_PASS: credentials.rabbitmq.password
|
|
1236
|
+
},
|
|
1237
|
+
volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
|
|
1238
|
+
healthcheck: {
|
|
1239
|
+
test: [
|
|
1240
|
+
"CMD",
|
|
1241
|
+
"rabbitmq-diagnostics",
|
|
1242
|
+
"-q",
|
|
1243
|
+
"ping"
|
|
1244
|
+
],
|
|
1245
|
+
interval: "10s",
|
|
1246
|
+
timeout: "5s",
|
|
1247
|
+
retries: 5
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
case "localstack": return {
|
|
1251
|
+
image,
|
|
1252
|
+
restart: "unless-stopped",
|
|
1253
|
+
ports: published,
|
|
1254
|
+
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
1255
|
+
environment: {
|
|
1256
|
+
AWS_DEFAULT_REGION: EMULATOR_REGION,
|
|
1257
|
+
AWS_ACCESS_KEY_ID: credentials.emulator.accessKeyId,
|
|
1258
|
+
AWS_SECRET_ACCESS_KEY: credentials.emulator.secretAccessKey
|
|
1259
|
+
},
|
|
1260
|
+
volumes: ["localstack-data:/var/lib/localstack"],
|
|
1261
|
+
healthcheck: {
|
|
1262
|
+
test: [
|
|
1263
|
+
"CMD",
|
|
1264
|
+
"bash",
|
|
1265
|
+
"-c",
|
|
1266
|
+
"exec 3<>/dev/tcp/localhost/4566"
|
|
1267
|
+
],
|
|
1268
|
+
interval: "10s",
|
|
1269
|
+
timeout: "5s",
|
|
1270
|
+
retries: 5,
|
|
1271
|
+
start_period: "10s"
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
default: throw new UnknownContainer(container);
|
|
1196
1275
|
}
|
|
1197
1276
|
}
|
|
1198
1277
|
/**
|
|
1199
|
-
*
|
|
1278
|
+
* The published port mappings for a container.
|
|
1279
|
+
*
|
|
1280
|
+
* Every port is looked up by its own key rather than being derived as an offset
|
|
1281
|
+
* from the container's first, so MinIO's console cannot land on whatever was
|
|
1282
|
+
* allocated to the next container.
|
|
1200
1283
|
*/
|
|
1201
|
-
function
|
|
1202
|
-
return
|
|
1284
|
+
function publish(container, ports, fakes) {
|
|
1285
|
+
return portsOf(container, fakes).map((port) => {
|
|
1286
|
+
const assigned = ports[port.key];
|
|
1287
|
+
if (assigned === void 0) throw new UnassignedPort(port.key);
|
|
1288
|
+
return `${assigned}:${port.inside}`;
|
|
1289
|
+
});
|
|
1203
1290
|
}
|
|
1291
|
+
/** A planned port that allocation never assigned. */
|
|
1292
|
+
var UnassignedPort = class extends Error {
|
|
1293
|
+
/** The port key that went unassigned, e.g. `minio-console`. */
|
|
1294
|
+
key;
|
|
1295
|
+
constructor(key) {
|
|
1296
|
+
super("No port was assigned for this container port");
|
|
1297
|
+
this.name = "UnassignedPort";
|
|
1298
|
+
this.key = key;
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
//#endregion
|
|
1302
|
+
//#region src/workspace/backends.ts
|
|
1204
1303
|
/**
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1304
|
+
* Which backend each declared kind resolves to — decided by where the project
|
|
1305
|
+
* deploys, and by nothing else.
|
|
1306
|
+
*
|
|
1307
|
+
* These used to be read out of a `services:` block, where one key answered two
|
|
1308
|
+
* questions: *does this exist* and *what backs it*. The first is the manifest's
|
|
1309
|
+
* — a declared `Cache` is why there is a cache — and the second is the deploy
|
|
1310
|
+
* target's, because the right cache for a Lambda is not the right cache for a
|
|
1311
|
+
* box already running Postgres. With both answered elsewhere there was nothing
|
|
1312
|
+
* left for the block to say, so it is gone and these read the target.
|
|
1313
|
+
*
|
|
1314
|
+
* Mail has no answer here at all: every provider speaks SMTP, so the provider
|
|
1315
|
+
* is whichever one the stage's mail URL points at.
|
|
1207
1316
|
*/
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1317
|
+
/**
|
|
1318
|
+
* Which family of defaults a workspace's deploy target belongs to.
|
|
1319
|
+
*
|
|
1320
|
+
* One question, asked once: **does this target run containers the project
|
|
1321
|
+
* controls?** Dokploy does, so a bucket or a cache can live beside the app;
|
|
1322
|
+
* AWS, Vercel and Cloudflare do not, so the default has to be something
|
|
1323
|
+
* managed. That is the whole of the distinction, and it is why `MainProvider`
|
|
1324
|
+
* is the right axis rather than the deploy target's own name — targets share
|
|
1325
|
+
* answers.
|
|
1326
|
+
*
|
|
1327
|
+
* Each target declares its answer as its `runtime` — a target package in its
|
|
1328
|
+
* `package.json`, so this never loads one. A workspace that names no target
|
|
1329
|
+
* is on AWS: an SST project predates `deploy.default: 'sst'`.
|
|
1330
|
+
*/
|
|
1331
|
+
function providerOf(workspace) {
|
|
1332
|
+
const target = workspace.deploy?.default;
|
|
1333
|
+
return target ? runtimeOf(target, workspace) : "aws";
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* The cache backend a target implies.
|
|
1337
|
+
*
|
|
1338
|
+
* The same answer locally and deployed: a local `gkm dev` for an AWS app still
|
|
1339
|
+
* uses the Upstash protocol, because a backend that differed between the two
|
|
1340
|
+
* would be worse than a slower one.
|
|
1341
|
+
*/
|
|
1342
|
+
function cacheBackendFor(on) {
|
|
1343
|
+
return DEFAULT_CACHE[on];
|
|
1344
|
+
}
|
|
1345
|
+
/** Where a declared bucket lives on a target. */
|
|
1346
|
+
function storageBackendFor(on) {
|
|
1347
|
+
return DEFAULT_STORAGE[on];
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Which broker carries declared queues and topics on a target.
|
|
1351
|
+
*
|
|
1352
|
+
* Only consulted when something declares one; a project with no `Topic` or
|
|
1353
|
+
* `Queue` has no events at all, whatever the target.
|
|
1354
|
+
*/
|
|
1355
|
+
function eventsBackendFor(on) {
|
|
1356
|
+
return DEFAULT_EVENTS$1[on];
|
|
1357
|
+
}
|
|
1358
|
+
//#endregion
|
|
1359
|
+
//#region src/exec/imageBuild.ts
|
|
1360
|
+
/**
|
|
1361
|
+
* Set by a site's Dockerfile in its builder stage: this process is building
|
|
1362
|
+
* an image.
|
|
1363
|
+
*
|
|
1364
|
+
* Inside an image there is no stage to load secrets for and no local
|
|
1365
|
+
* container to resolve an address from — the public URLs the bundle inlines
|
|
1366
|
+
* arrived as the Dockerfile's build args, already in the environment. So
|
|
1367
|
+
* `gkm exec -- next build` there injects exactly those, and never the
|
|
1368
|
+
* `localhost` addresses a workspace resolves on a developer's machine.
|
|
1369
|
+
*/
|
|
1370
|
+
const IMAGE_BUILD_ENV = "GKM_IMAGE_BUILD";
|
|
1371
|
+
/** Whether this process is building an image. */
|
|
1372
|
+
function isImageBuild(env = process.env) {
|
|
1373
|
+
return env[IMAGE_BUILD_ENV] === "1";
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* What `gkm exec` injects inside an image build: the public values its build
|
|
1377
|
+
* args set — every `NEXT_PUBLIC_*`, `VITE_*` and `EXPO_PUBLIC_*` — and
|
|
1378
|
+
* nothing read from a secrets store, a file or a container.
|
|
1379
|
+
*/
|
|
1380
|
+
function imageBuildCredentials(env = process.env) {
|
|
1381
|
+
const credentials = {};
|
|
1382
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1383
|
+
if (value === void 0) continue;
|
|
1384
|
+
if (PUBLIC_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) credentials[key] = value;
|
|
1385
|
+
}
|
|
1386
|
+
return credentials;
|
|
1387
|
+
}
|
|
1388
|
+
//#endregion
|
|
1389
|
+
//#region src/docker/templates.ts
|
|
1390
|
+
/**
|
|
1391
|
+
* The turbo every image prunes and builds with when the build root resolves
|
|
1392
|
+
* none of its own: a repository without turbo still needs `turbo prune` to
|
|
1393
|
+
* cut its slice of the workspace, and an unpinned `turbo` is whatever the
|
|
1394
|
+
* registry says on the day of the build.
|
|
1395
|
+
*/
|
|
1396
|
+
const TURBO_VERSION = "2.5.4";
|
|
1397
|
+
/**
|
|
1398
|
+
* Caddy, which serves a static site's files. A 2.x tag, so a rebuild picks
|
|
1399
|
+
* up patch releases and never a major.
|
|
1400
|
+
*/
|
|
1401
|
+
const STATIC_SITE_IMAGE = "caddy:2.10-alpine";
|
|
1402
|
+
const LOCKFILES = [
|
|
1403
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
1404
|
+
["bun.lockb", "bun"],
|
|
1405
|
+
["yarn.lock", "yarn"],
|
|
1406
|
+
["package-lock.json", "npm"]
|
|
1407
|
+
];
|
|
1408
|
+
/**
|
|
1409
|
+
* Detect package manager from lockfiles
|
|
1410
|
+
* Walks up the directory tree to find lockfile (for monorepos)
|
|
1411
|
+
*/
|
|
1412
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
1413
|
+
const lockfile = findLockfilePath(cwd);
|
|
1414
|
+
if (!lockfile) return "pnpm";
|
|
1415
|
+
const name = lockfile.slice(dirname(lockfile).length + 1);
|
|
1416
|
+
return LOCKFILES.find(([file]) => file === name)?.[1] ?? "pnpm";
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Find the lockfile path by walking up the directory tree
|
|
1420
|
+
* Returns the full path to the lockfile, or null if not found
|
|
1421
|
+
*/
|
|
1422
|
+
function findLockfilePath(cwd = process.cwd()) {
|
|
1423
|
+
for (const dir of upFrom(cwd)) for (const [lockfile] of LOCKFILES) {
|
|
1424
|
+
const lockfilePath = join(dir, lockfile);
|
|
1425
|
+
if (existsSync(lockfilePath)) return lockfilePath;
|
|
1426
|
+
}
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* The directory every image is built from: the nearest at or above `cwd`
|
|
1431
|
+
* holding a lockfile or a `pnpm-workspace.yaml` — the package manager's root,
|
|
1432
|
+
* which is what an install resolves the workspace's dependencies against.
|
|
1433
|
+
*
|
|
1434
|
+
* In a project of its own that is the gkm workspace's root. A gkm workspace
|
|
1435
|
+
* nested inside a monorepo, whose dependencies are the monorepo's
|
|
1436
|
+
* `workspace:*` packages, is built from the monorepo's root. `cwd` itself
|
|
1437
|
+
* when neither is found.
|
|
1438
|
+
*/
|
|
1439
|
+
function findBuildRoot(cwd = process.cwd()) {
|
|
1440
|
+
for (const dir of upFrom(cwd)) {
|
|
1441
|
+
if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
|
|
1442
|
+
for (const [lockfile] of LOCKFILES) if (existsSync(join(dir, lockfile))) return dir;
|
|
1443
|
+
}
|
|
1444
|
+
return cwd;
|
|
1445
|
+
}
|
|
1446
|
+
/** `cwd` and every directory above it, the filesystem root last. */
|
|
1447
|
+
function upFrom(cwd) {
|
|
1448
|
+
const dirs = [];
|
|
1449
|
+
const root = parse(cwd).root;
|
|
1450
|
+
let dir = cwd;
|
|
1451
|
+
while (dir !== root) {
|
|
1452
|
+
dirs.push(dir);
|
|
1453
|
+
dir = dirname(dir);
|
|
1454
|
+
}
|
|
1455
|
+
dirs.push(root);
|
|
1456
|
+
return dirs;
|
|
1457
|
+
}
|
|
1458
|
+
/** The package manager, its version, turbo's and the shape of a build root. */
|
|
1459
|
+
function resolveBuildTools(buildRoot) {
|
|
1460
|
+
const pkg = readJson(join(buildRoot, "package.json"));
|
|
1461
|
+
const packageManager = detectPackageManager(buildRoot);
|
|
1462
|
+
const field = typeof pkg?.packageManager === "string" ? pkg.packageManager : "";
|
|
1463
|
+
const match = /^([a-z]+)@(\d[^+\s]*)/.exec(field);
|
|
1464
|
+
const packageManagerVersion = match && match[1] === packageManager ? match[2] : void 0;
|
|
1465
|
+
return {
|
|
1466
|
+
packageManager,
|
|
1467
|
+
...packageManagerVersion ? { packageManagerVersion } : {},
|
|
1468
|
+
turboVersion: resolveTurboVersion(buildRoot) ?? "2.5.4",
|
|
1469
|
+
monorepo: existsSync(join(buildRoot, "pnpm-workspace.yaml")) || pkg?.workspaces !== void 0
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* The turbo version a build root resolves: what is installed, or what its
|
|
1474
|
+
* lockfile pins. Undefined when it has none.
|
|
1475
|
+
*/
|
|
1476
|
+
function resolveTurboVersion(buildRoot) {
|
|
1477
|
+
const installed = readJson(join(buildRoot, "node_modules", "turbo", "package.json"));
|
|
1478
|
+
if (typeof installed?.version === "string") return installed.version;
|
|
1479
|
+
const read = (file) => {
|
|
1480
|
+
try {
|
|
1481
|
+
return readFileSync(join(buildRoot, file), "utf-8");
|
|
1482
|
+
} catch {
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
const pnpm = read("pnpm-lock.yaml");
|
|
1487
|
+
const fromPnpm = pnpm && /^ {2}'?turbo@(\d[^:'(\s]*)'?:/m.exec(pnpm);
|
|
1488
|
+
if (fromPnpm) return fromPnpm[1];
|
|
1489
|
+
const npm = read("package-lock.json");
|
|
1490
|
+
if (npm) {
|
|
1491
|
+
const version = safeParse(npm)?.packages?.["node_modules/turbo"]?.version;
|
|
1492
|
+
if (version) return version;
|
|
1493
|
+
}
|
|
1494
|
+
const yarn = read("yarn.lock");
|
|
1495
|
+
const fromYarn = yarn && /^"?turbo@[^\n]*:\n\s+version:? "?(\d[^"\s]*)"?/m.exec(yarn);
|
|
1496
|
+
if (fromYarn) return fromYarn[1];
|
|
1497
|
+
}
|
|
1498
|
+
function readJson(path) {
|
|
1499
|
+
try {
|
|
1500
|
+
return safeParse(readFileSync(path, "utf-8"));
|
|
1501
|
+
} catch {
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
function safeParse(text) {
|
|
1506
|
+
try {
|
|
1507
|
+
const value = JSON.parse(text);
|
|
1508
|
+
return value && typeof value === "object" ? value : void 0;
|
|
1509
|
+
} catch {
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Check if turbo.json exists (walks up directory tree)
|
|
1515
|
+
*/
|
|
1516
|
+
function hasTurboConfig(cwd = process.cwd()) {
|
|
1517
|
+
return upFrom(cwd).some((dir) => existsSync(join(dir, "turbo.json")));
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Package manager specific commands. `version` pins what corepack (or npm,
|
|
1521
|
+
* for bun) installs; without it, the latest.
|
|
1522
|
+
*/
|
|
1523
|
+
function getPmConfig(pm, version) {
|
|
1524
|
+
return {
|
|
1525
|
+
pnpm: {
|
|
1526
|
+
install: `corepack enable && corepack prepare pnpm@${version ?? "latest"} --activate`,
|
|
1527
|
+
lockfile: "pnpm-lock.yaml",
|
|
1528
|
+
installCmd: "pnpm install",
|
|
1529
|
+
cacheTarget: "/root/.local/share/pnpm/store",
|
|
1530
|
+
cacheId: "pnpm",
|
|
1531
|
+
run: "pnpm run",
|
|
1532
|
+
dlx: "pnpm dlx"
|
|
1533
|
+
},
|
|
1534
|
+
npm: {
|
|
1535
|
+
install: version ? `npm install -g npm@${version}` : "",
|
|
1536
|
+
lockfile: "package-lock.json",
|
|
1537
|
+
installCmd: "npm install",
|
|
1538
|
+
cacheTarget: "/root/.npm",
|
|
1539
|
+
cacheId: "npm",
|
|
1540
|
+
run: "npm run",
|
|
1541
|
+
dlx: "npx --yes"
|
|
1542
|
+
},
|
|
1543
|
+
yarn: {
|
|
1544
|
+
install: `corepack enable && corepack prepare yarn@${version ?? "stable"} --activate`,
|
|
1545
|
+
lockfile: "yarn.lock",
|
|
1546
|
+
installCmd: "yarn install",
|
|
1547
|
+
cacheTarget: "/root/.yarn/cache",
|
|
1548
|
+
cacheId: "yarn",
|
|
1549
|
+
run: "yarn run",
|
|
1550
|
+
dlx: "yarn dlx"
|
|
1551
|
+
},
|
|
1552
|
+
bun: {
|
|
1553
|
+
install: `npm install -g bun@${version ?? "latest"}`,
|
|
1554
|
+
lockfile: "bun.lockb",
|
|
1555
|
+
installCmd: "bun install",
|
|
1556
|
+
cacheTarget: "/root/.bun/install/cache",
|
|
1557
|
+
cacheId: "bun",
|
|
1558
|
+
run: "bun run",
|
|
1559
|
+
dlx: "bunx"
|
|
1560
|
+
}
|
|
1561
|
+
}[pm];
|
|
1562
|
+
}
|
|
1563
|
+
/** What every template's first three stages need, resolved from options. */
|
|
1564
|
+
function layout(options) {
|
|
1250
1565
|
const pm = getPmConfig(options.packageManager, options.packageManagerVersion);
|
|
1251
1566
|
const turbo = `${pm.dlx} turbo@${options.turboVersion ?? "2.5.4"}`;
|
|
1252
1567
|
const gkmRoot = options.gkmRoot ?? ".";
|
|
@@ -1393,7 +1708,16 @@ function siteWorkspaceSteps(options) {
|
|
|
1393
1708
|
|
|
1394
1709
|
# The typed client of each API this site calls, generated from its endpoints
|
|
1395
1710
|
# into ${gkmRoot === "." ? "" : `${gkmRoot}/`}.gkm/client/ — offline, with no secret and no container.
|
|
1396
|
-
RUN ${clients.map(({ app, path }) =>
|
|
1711
|
+
RUN ${clients.map(({ app, path, telemetry }) => [
|
|
1712
|
+
"cd",
|
|
1713
|
+
`/app/${path}`,
|
|
1714
|
+
"&&",
|
|
1715
|
+
"gkm",
|
|
1716
|
+
"openapi",
|
|
1717
|
+
"--app",
|
|
1718
|
+
app,
|
|
1719
|
+
...telemetryArgs(telemetry)
|
|
1720
|
+
].join(" ")).join(" && \\\n ")}` : "";
|
|
1397
1721
|
return `# The gkm CLI the workspace installs, as \`gkm\`
|
|
1398
1722
|
RUN cd ${root} && GKM_BIN="$(node -e '${FIND_GKM_BIN}')" && \\
|
|
1399
1723
|
{ echo '#!/bin/sh'; echo "exec node \\"$GKM_BIN\\" \\"\\$@\\""; } > /usr/local/bin/gkm && \\
|
|
@@ -2195,42 +2519,9 @@ const GENERATED = `# Generated by gkm from the construct manifest — do not edi
|
|
|
2195
2519
|
# from code; a browser needs the root trusted once.
|
|
2196
2520
|
`;
|
|
2197
2521
|
//#endregion
|
|
2198
|
-
//#region src/
|
|
2522
|
+
//#region src/workspace/rootSite.ts
|
|
2199
2523
|
/**
|
|
2200
|
-
*
|
|
2201
|
-
* locally — the facts the compose file, the env writer and the applier must
|
|
2202
|
-
* agree on.
|
|
2203
|
-
*
|
|
2204
|
-
* The emulator has one account and answers with deterministic ARNs and queue
|
|
2205
|
-
* URLs, which is what lets a connection string be composed before the topic
|
|
2206
|
-
* or queue it names has been created.
|
|
2207
|
-
*/
|
|
2208
|
-
/** The emulator's single account. */
|
|
2209
|
-
const EMULATOR_ACCOUNT = "000000000000";
|
|
2210
|
-
const EMULATOR_REGION = "us-east-1";
|
|
2211
|
-
/**
|
|
2212
|
-
* The emulator's credential. The `LSIA` prefix is what LocalStack required on
|
|
2213
|
-
* issued keys; floci does not care, and keeping it means a project that pins
|
|
2214
|
-
* the old image still works.
|
|
2215
|
-
*/
|
|
2216
|
-
const EMULATOR_CREDENTIALS = {
|
|
2217
|
-
accessKeyId: "LSIAQAAAAAAVNCBMPNSG",
|
|
2218
|
-
secretAccessKey: "geekmidas"
|
|
2219
|
-
};
|
|
2220
|
-
/** The emulator's address from the host, on the port it was published on. */
|
|
2221
|
-
function emulatorEndpoint(port) {
|
|
2222
|
-
return `http://localhost:${port}`;
|
|
2223
|
-
}
|
|
2224
|
-
function emulatorTopicArn(name) {
|
|
2225
|
-
return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
|
|
2226
|
-
}
|
|
2227
|
-
function emulatorQueueUrl(name, port) {
|
|
2228
|
-
return `${emulatorEndpoint(port)}/${EMULATOR_ACCOUNT}/${name}`;
|
|
2229
|
-
}
|
|
2230
|
-
//#endregion
|
|
2231
|
-
//#region src/workspace/rootSite.ts
|
|
2232
|
-
/**
|
|
2233
|
-
* Which site the base domain points at — deployed and locally alike.
|
|
2524
|
+
* Which site the base domain points at — deployed and locally alike.
|
|
2234
2525
|
*
|
|
2235
2526
|
* Three rules, in order, and the last one is the point:
|
|
2236
2527
|
*
|
|
@@ -2586,15 +2877,6 @@ var PgBossNeedsDatabase = class extends Error {
|
|
|
2586
2877
|
* Locally that self-containment is what lets the port be whatever was free: the
|
|
2587
2878
|
* app reads `ORDERS_URL` and never sees a host or a port.
|
|
2588
2879
|
*/
|
|
2589
|
-
/**
|
|
2590
|
-
* The credential local containers are brought up with.
|
|
2591
|
-
*
|
|
2592
|
-
* The design's `roles: false` case — both URLs fall back to the cluster's master
|
|
2593
|
-
* credential. A deliberate downgrade, not a default: it holds until the role DDL
|
|
2594
|
-
* lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
|
|
2595
|
-
* and the owner URL stops being reachable from any manifest edge.
|
|
2596
|
-
*/
|
|
2597
|
-
const LOCAL_USER$2 = "geekmidas";
|
|
2598
2880
|
/** The host containers are published on. */
|
|
2599
2881
|
const LOCAL_HOST = "localhost";
|
|
2600
2882
|
/** The region local buckets claim, so the client has one to sign with. */
|
|
@@ -2608,23 +2890,23 @@ const LOCAL_REGION = "us-east-1";
|
|
|
2608
2890
|
* the same chain reads these — which is why they are injected beside the URL
|
|
2609
2891
|
* rather than written into it.
|
|
2610
2892
|
*/
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2893
|
+
function storageCredentials(credentials) {
|
|
2894
|
+
return {
|
|
2895
|
+
AWS_ACCESS_KEY_ID: credentials.minio.user,
|
|
2896
|
+
AWS_SECRET_ACCESS_KEY: credentials.minio.password,
|
|
2897
|
+
AWS_REGION: LOCAL_REGION
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2618
2900
|
/** The port Mailpit's inbox — its web UI and HTTP API — is published under. */
|
|
2619
2901
|
const MAILPIT_INBOX_PORT = "mailpit-web";
|
|
2620
2902
|
/** The schema pg-boss keeps its tables in, inside the declared database. */
|
|
2621
2903
|
const PGBOSS_SCHEMA = "pgboss";
|
|
2622
2904
|
/** The exchange every local topic and queue shares on RabbitMQ. */
|
|
2623
|
-
const RABBITMQ_EXCHANGE = "
|
|
2624
|
-
/** The credential `options` describe
|
|
2905
|
+
const RABBITMQ_EXCHANGE = "gkm.events";
|
|
2906
|
+
/** The credential `options` describe. */
|
|
2625
2907
|
function credentialOf(options) {
|
|
2626
2908
|
return {
|
|
2627
|
-
|
|
2909
|
+
containers: options.credentials,
|
|
2628
2910
|
...options.seed ? { seed: options.seed } : {}
|
|
2629
2911
|
};
|
|
2630
2912
|
}
|
|
@@ -2664,7 +2946,7 @@ function envFor(plan, options) {
|
|
|
2664
2946
|
}
|
|
2665
2947
|
Object.assign(env, publicEnv(plan, env, options.addresses));
|
|
2666
2948
|
Object.assign(env, brokerEnv(plan, options.ports, credential));
|
|
2667
|
-
if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env,
|
|
2949
|
+
if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, storageCredentials(options.credentials));
|
|
2668
2950
|
return env;
|
|
2669
2951
|
}
|
|
2670
2952
|
/**
|
|
@@ -2799,7 +3081,7 @@ function workerBroker(plan, ports, credential) {
|
|
|
2799
3081
|
*
|
|
2800
3082
|
* Every part of it comes from the resource and the port it was published on.
|
|
2801
3083
|
*/
|
|
2802
|
-
function urlFor(resource, plan, ports, project, addresses
|
|
3084
|
+
function urlFor(resource, plan, ports, project, addresses, credential) {
|
|
2803
3085
|
if (resource.kind === "secret") return localSecret(project, plan, resource);
|
|
2804
3086
|
if (resource.kind === "encryption") return localKeyring(project, plan, resource);
|
|
2805
3087
|
if (resource.kind === "mobile-app") return schemeBase(project, resource.scheme);
|
|
@@ -2813,7 +3095,7 @@ function urlFor(resource, plan, ports, project, addresses = {}, credential = { m
|
|
|
2813
3095
|
return port === void 0 ? void 0 : `http://${LOCAL_HOST}:${port}`;
|
|
2814
3096
|
}
|
|
2815
3097
|
if (resource.kind === "rest-api" || resource.kind === "site") {
|
|
2816
|
-
const address = addresses[resource.id];
|
|
3098
|
+
const address = addresses?.[resource.id];
|
|
2817
3099
|
const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
|
|
2818
3100
|
return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
|
|
2819
3101
|
}
|
|
@@ -2827,10 +3109,7 @@ function urlFor(resource, plan, ports, project, addresses = {}, credential = { m
|
|
|
2827
3109
|
const database = rootDatabase(resource, plan);
|
|
2828
3110
|
if (resource.roles === false) {
|
|
2829
3111
|
const schema = schemaOf(resource, plan);
|
|
2830
|
-
const master =
|
|
2831
|
-
user: LOCAL_USER$2,
|
|
2832
|
-
password: credential.master
|
|
2833
|
-
};
|
|
3112
|
+
const master = credential.containers.postgres;
|
|
2834
3113
|
return schema ? `${postgres(port, database, master)}?search_path=${schema}` : postgres(port, database, master);
|
|
2835
3114
|
}
|
|
2836
3115
|
const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
|
|
@@ -2854,8 +3133,8 @@ function urlFor(resource, plan, ports, project, addresses = {}, credential = { m
|
|
|
2854
3133
|
}
|
|
2855
3134
|
switch (plan.cache) {
|
|
2856
3135
|
case "elasticache":
|
|
2857
|
-
case "redis": return `redis
|
|
2858
|
-
default: return `http://:${
|
|
3136
|
+
case "redis": return `redis://:${encodeURIComponent(credential.containers.redis.password)}@${LOCAL_HOST}:${port}`;
|
|
3137
|
+
default: return `http://:${encodeURIComponent(credential.containers.cacheToken)}@${LOCAL_HOST}:${port}`;
|
|
2859
3138
|
}
|
|
2860
3139
|
case "queue":
|
|
2861
3140
|
case "topic": return broker(plan, port, credential, resource);
|
|
@@ -2874,9 +3153,13 @@ function broker(plan, port, credential, resource) {
|
|
|
2874
3153
|
case "pgboss": {
|
|
2875
3154
|
const database = plan.resources.find((r) => r.kind === "database");
|
|
2876
3155
|
if (!database) throw new PgBossNeedsDatabase([]);
|
|
2877
|
-
|
|
3156
|
+
const { user, password } = credential.containers.postgres;
|
|
3157
|
+
return `pgboss://${user}:${encodeURIComponent(password)}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
|
|
3158
|
+
}
|
|
3159
|
+
case "rabbitmq": {
|
|
3160
|
+
const { user, password } = credential.containers.rabbitmq;
|
|
3161
|
+
return `rabbitmq://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
|
|
2878
3162
|
}
|
|
2879
|
-
case "rabbitmq": return `rabbitmq://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
|
|
2880
3163
|
case "sns": {
|
|
2881
3164
|
if (!resource) throw new UnprovisionedEventsBackend("sns");
|
|
2882
3165
|
const endpoint = emulatorEndpoint(port);
|
|
@@ -2890,17 +3173,15 @@ function broker(plan, port, credential, resource) {
|
|
|
2890
3173
|
endpoint
|
|
2891
3174
|
});
|
|
2892
3175
|
const url = new URL(address);
|
|
2893
|
-
|
|
2894
|
-
url.searchParams.set("
|
|
3176
|
+
const { accessKeyId, secretAccessKey } = credential.containers.emulator;
|
|
3177
|
+
url.searchParams.set("accessKeyId", accessKeyId);
|
|
3178
|
+
url.searchParams.set("secretAccessKey", secretAccessKey);
|
|
2895
3179
|
return url.toString();
|
|
2896
3180
|
}
|
|
2897
3181
|
}
|
|
2898
3182
|
}
|
|
2899
|
-
/** A local Postgres URL,
|
|
2900
|
-
function postgres(port, database, credential
|
|
2901
|
-
user: LOCAL_USER$2,
|
|
2902
|
-
password: LOCAL_USER$2
|
|
2903
|
-
}) {
|
|
3183
|
+
/** A local Postgres URL, with the login it connects as. */
|
|
3184
|
+
function postgres(port, database, credential) {
|
|
2904
3185
|
return `postgres://${credential.user}:${encodeURIComponent(credential.password)}@${LOCAL_HOST}:${port}/${database}`;
|
|
2905
3186
|
}
|
|
2906
3187
|
/**
|
|
@@ -3195,14 +3476,19 @@ function addEdgeKeys(manifest, edges, keys) {
|
|
|
3195
3476
|
}
|
|
3196
3477
|
}
|
|
3197
3478
|
/** One compose service per app the workspace runs, behind the apps profile. */
|
|
3198
|
-
function appServices(workspace, manifest, containers, runnables = {}, fakes = {}) {
|
|
3479
|
+
function appServices(workspace, manifest, containers, credentials, runnables = {}, fakes = {}) {
|
|
3199
3480
|
const plan = localPlan(workspace, manifest, fakes);
|
|
3200
|
-
const
|
|
3481
|
+
const derivation = {
|
|
3201
3482
|
project: workspace.name,
|
|
3483
|
+
credentials,
|
|
3484
|
+
seed: credentials.seed
|
|
3485
|
+
};
|
|
3486
|
+
const env = networkEnv(plan, {
|
|
3487
|
+
...derivation,
|
|
3202
3488
|
addresses: surfaceAddresses(workspace, manifest, (app, port) => `http://${app}:${port}`)
|
|
3203
3489
|
});
|
|
3204
3490
|
const browser = networkEnv(plan, {
|
|
3205
|
-
|
|
3491
|
+
...derivation,
|
|
3206
3492
|
addresses: surfaceAddresses(workspace, manifest)
|
|
3207
3493
|
});
|
|
3208
3494
|
const services = {};
|
|
@@ -3277,8 +3563,6 @@ function healthcheck(port, path) {
|
|
|
3277
3563
|
* Both are constructed from the same local addresses the env writer composes,
|
|
3278
3564
|
* so the applier cannot end up talking to a different container than the app.
|
|
3279
3565
|
*/
|
|
3280
|
-
/** The credential local containers are brought up with. */
|
|
3281
|
-
const LOCAL_USER$1 = "geekmidas";
|
|
3282
3566
|
/**
|
|
3283
3567
|
* A Postgres client that opens one connection per database it is asked about.
|
|
3284
3568
|
*
|
|
@@ -3286,14 +3570,14 @@ const LOCAL_USER$1 = "geekmidas";
|
|
|
3286
3570
|
* and a schema must be created from inside its own database — so the database is
|
|
3287
3571
|
* part of each query rather than fixed when the client is built.
|
|
3288
3572
|
*/
|
|
3289
|
-
function pgClient(port,
|
|
3573
|
+
function pgClient(port, login) {
|
|
3290
3574
|
return { async query(database, sql, values) {
|
|
3291
3575
|
const client = new Client({
|
|
3292
3576
|
host: "localhost",
|
|
3293
3577
|
port,
|
|
3294
|
-
user:
|
|
3295
|
-
password,
|
|
3296
|
-
database: database ??
|
|
3578
|
+
user: login.user,
|
|
3579
|
+
password: login.password,
|
|
3580
|
+
database: database ?? "postgres"
|
|
3297
3581
|
});
|
|
3298
3582
|
await client.connect();
|
|
3299
3583
|
try {
|
|
@@ -3304,10 +3588,7 @@ function pgClient(port, password = LOCAL_USER$1) {
|
|
|
3304
3588
|
} };
|
|
3305
3589
|
}
|
|
3306
3590
|
/** A bucket client pointed at a MinIO on this machine. */
|
|
3307
|
-
function bucketClient(port, credentials
|
|
3308
|
-
user: LOCAL_USER$1,
|
|
3309
|
-
password: LOCAL_USER$1
|
|
3310
|
-
}) {
|
|
3591
|
+
function bucketClient(port, credentials) {
|
|
3311
3592
|
const s3 = new S3Client({
|
|
3312
3593
|
region: "us-east-1",
|
|
3313
3594
|
endpoint: `http://localhost:${port}`,
|
|
@@ -3345,11 +3626,11 @@ function bucketClient(port, credentials = {
|
|
|
3345
3626
|
};
|
|
3346
3627
|
}
|
|
3347
3628
|
/** A topic and queue client pointed at the local AWS emulator. */
|
|
3348
|
-
function carrierClient(port) {
|
|
3629
|
+
function carrierClient(port, credentials) {
|
|
3349
3630
|
const config = {
|
|
3350
3631
|
region: EMULATOR_REGION,
|
|
3351
3632
|
endpoint: emulatorEndpoint(port),
|
|
3352
|
-
credentials
|
|
3633
|
+
credentials
|
|
3353
3634
|
};
|
|
3354
3635
|
const sns = new SNSClient(config);
|
|
3355
3636
|
const sqs = new SQSClient(config);
|
|
@@ -3379,1195 +3660,1883 @@ function carrierClient(port) {
|
|
|
3379
3660
|
};
|
|
3380
3661
|
}
|
|
3381
3662
|
//#endregion
|
|
3382
|
-
//#region src/reconcile/
|
|
3663
|
+
//#region src/reconcile/docker.ts
|
|
3383
3664
|
/**
|
|
3384
|
-
* The
|
|
3385
|
-
*
|
|
3386
|
-
* The third declaration of the same containers disappears here: today a
|
|
3387
|
-
* `services: ['postgres','minio']` block is kept in step with the code by hand,
|
|
3388
|
-
* which is the triplication this design exists to remove, in the one place you
|
|
3389
|
-
* touch every day. A `database` implies Postgres; the list is derived.
|
|
3665
|
+
* The real Docker, behind the small interface reconcile needs.
|
|
3390
3666
|
*
|
|
3391
|
-
*
|
|
3392
|
-
* what
|
|
3393
|
-
*
|
|
3667
|
+
* Shells out to `docker compose` rather than talking to the daemon socket: it is
|
|
3668
|
+
* what a developer runs by hand when something is wrong, so the two cannot
|
|
3669
|
+
* disagree, and it keeps Docker out of this package's dependencies.
|
|
3394
3670
|
*
|
|
3395
|
-
*
|
|
3396
|
-
*
|
|
3397
|
-
* from the declaration, provider detail from config.
|
|
3671
|
+
* Every call is scoped to the generated compose file. Nothing here can touch a
|
|
3672
|
+
* container this project did not generate.
|
|
3398
3673
|
*/
|
|
3399
|
-
|
|
3400
|
-
const LOCAL_USER = "geekmidas";
|
|
3674
|
+
const run = promisify(execFile);
|
|
3401
3675
|
/**
|
|
3402
|
-
* The
|
|
3676
|
+
* The generated file, then the project's own if it has one.
|
|
3403
3677
|
*
|
|
3404
|
-
*
|
|
3405
|
-
*
|
|
3406
|
-
|
|
3678
|
+
* Merged in that order so the project's wins — an `image:` there pins a
|
|
3679
|
+
* container this file derived, and a service there is added beside them.
|
|
3680
|
+
*/
|
|
3681
|
+
function composeFiles(composePath) {
|
|
3682
|
+
const project = join(dirname(composePath), "docker-compose.yml");
|
|
3683
|
+
return [
|
|
3684
|
+
"-f",
|
|
3685
|
+
composePath,
|
|
3686
|
+
...existsSync(project) ? ["-f", project] : []
|
|
3687
|
+
];
|
|
3688
|
+
}
|
|
3689
|
+
/** How long to wait for containers to pass their health checks. */
|
|
3690
|
+
const HEALTH_TIMEOUT_MS = 12e4;
|
|
3691
|
+
/** `docker compose up` exited unsuccessfully; its own output said why. */
|
|
3692
|
+
var ComposeUpFailed = class extends Error {
|
|
3693
|
+
services;
|
|
3694
|
+
code;
|
|
3695
|
+
constructor(services, code) {
|
|
3696
|
+
super(`docker compose could not start ${services.join(", ")} (exit ${code ?? "signal"}). Its output above names the container; \`docker compose logs <service>\` shows why it stopped.`);
|
|
3697
|
+
this.services = services;
|
|
3698
|
+
this.code = code;
|
|
3699
|
+
this.name = "ComposeUpFailed";
|
|
3700
|
+
}
|
|
3701
|
+
};
|
|
3702
|
+
/** Run compose with its output on this terminal rather than captured. */
|
|
3703
|
+
function shown(args, services) {
|
|
3704
|
+
return new Promise((resolve, reject) => {
|
|
3705
|
+
const child = spawn("docker", args, { stdio: [
|
|
3706
|
+
"ignore",
|
|
3707
|
+
"inherit",
|
|
3708
|
+
"inherit"
|
|
3709
|
+
] });
|
|
3710
|
+
child.on("error", reject);
|
|
3711
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new ComposeUpFailed(services, code)));
|
|
3712
|
+
});
|
|
3713
|
+
}
|
|
3714
|
+
const dockerCli = {
|
|
3715
|
+
async publishedPort(composePath, service, inside) {
|
|
3716
|
+
try {
|
|
3717
|
+
const { stdout } = await run("docker", [
|
|
3718
|
+
"compose",
|
|
3719
|
+
...composeFiles(composePath),
|
|
3720
|
+
"port",
|
|
3721
|
+
service,
|
|
3722
|
+
String(inside)
|
|
3723
|
+
]);
|
|
3724
|
+
const port = Number(stdout.trim().split(":").pop());
|
|
3725
|
+
return Number.isFinite(port) && port > 0 ? port : void 0;
|
|
3726
|
+
} catch {
|
|
3727
|
+
return;
|
|
3728
|
+
}
|
|
3729
|
+
},
|
|
3730
|
+
async up(composePath, services, options = {}) {
|
|
3731
|
+
const args = [
|
|
3732
|
+
"compose",
|
|
3733
|
+
...composeFiles(composePath),
|
|
3734
|
+
"up",
|
|
3735
|
+
"-d",
|
|
3736
|
+
"--remove-orphans",
|
|
3737
|
+
"--wait",
|
|
3738
|
+
`--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
|
|
3739
|
+
...services
|
|
3740
|
+
];
|
|
3741
|
+
if (options.show) return shown(args, services);
|
|
3742
|
+
await run("docker", args);
|
|
3743
|
+
},
|
|
3744
|
+
async copyOut(composePath, service, from, to) {
|
|
3745
|
+
await mkdir(dirname(to), { recursive: true });
|
|
3746
|
+
await run("docker", [
|
|
3747
|
+
"compose",
|
|
3748
|
+
...composeFiles(composePath),
|
|
3749
|
+
"cp",
|
|
3750
|
+
`${service}:${from}`,
|
|
3751
|
+
to
|
|
3752
|
+
]);
|
|
3753
|
+
},
|
|
3754
|
+
async reload(composePath, service) {
|
|
3755
|
+
await run("docker", [
|
|
3756
|
+
"compose",
|
|
3757
|
+
...composeFiles(composePath),
|
|
3758
|
+
"exec",
|
|
3759
|
+
"-T",
|
|
3760
|
+
service,
|
|
3761
|
+
"caddy",
|
|
3762
|
+
"reload",
|
|
3763
|
+
"--config",
|
|
3764
|
+
"/etc/caddy/Caddyfile"
|
|
3765
|
+
]);
|
|
3766
|
+
},
|
|
3767
|
+
async exec(composePath, service, argv) {
|
|
3768
|
+
try {
|
|
3769
|
+
const { stdout, stderr } = await run("docker", [
|
|
3770
|
+
"compose",
|
|
3771
|
+
...composeFiles(composePath),
|
|
3772
|
+
"exec",
|
|
3773
|
+
"-T",
|
|
3774
|
+
service,
|
|
3775
|
+
...argv
|
|
3776
|
+
]);
|
|
3777
|
+
return {
|
|
3778
|
+
ok: true,
|
|
3779
|
+
output: `${stdout}${stderr}`
|
|
3780
|
+
};
|
|
3781
|
+
} catch (error) {
|
|
3782
|
+
const { stdout = "", stderr = "" } = error;
|
|
3783
|
+
return {
|
|
3784
|
+
ok: false,
|
|
3785
|
+
output: `${stdout}${stderr}`
|
|
3786
|
+
};
|
|
3787
|
+
}
|
|
3788
|
+
},
|
|
3789
|
+
async healthy(composePath, services) {
|
|
3790
|
+
if (services.length === 0) return true;
|
|
3791
|
+
try {
|
|
3792
|
+
const { stdout } = await run("docker", [
|
|
3793
|
+
"compose",
|
|
3794
|
+
...composeFiles(composePath),
|
|
3795
|
+
"ps",
|
|
3796
|
+
"--format",
|
|
3797
|
+
"json",
|
|
3798
|
+
...services
|
|
3799
|
+
]);
|
|
3800
|
+
const running = /* @__PURE__ */ new Set();
|
|
3801
|
+
for (const line of stdout.trim().split("\n").filter(Boolean)) {
|
|
3802
|
+
const entry = JSON.parse(line);
|
|
3803
|
+
if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
|
|
3804
|
+
}
|
|
3805
|
+
return services.every((service) => running.has(service));
|
|
3806
|
+
} catch {
|
|
3807
|
+
return false;
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
};
|
|
3811
|
+
//#endregion
|
|
3812
|
+
//#region src/reconcile/lan.ts
|
|
3813
|
+
/**
|
|
3814
|
+
* This machine's address on the local network — what a phone running the app
|
|
3815
|
+
* reaches it on.
|
|
3407
3816
|
*/
|
|
3408
|
-
const LOCAL_TOKEN = "geekmidas";
|
|
3409
3817
|
/**
|
|
3410
|
-
*
|
|
3818
|
+
* The first private IPv4 address of an interface that is up and not loopback.
|
|
3411
3819
|
*
|
|
3412
|
-
*
|
|
3413
|
-
*
|
|
3414
|
-
*
|
|
3820
|
+
* Private ranges only (`10/8`, `172.16/12`, `192.168/16`): a public address
|
|
3821
|
+
* would mean listening for a phone on the open internet, and a link-local one
|
|
3822
|
+
* (`169.254/16`) is what an interface has when it found no network at all.
|
|
3823
|
+
* Undefined when there is none — a laptop on no network still runs, its mobile
|
|
3824
|
+
* app reaching only a simulator.
|
|
3415
3825
|
*/
|
|
3416
|
-
function
|
|
3417
|
-
const
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
|
|
3421
|
-
const fake = plan.fakes?.[container];
|
|
3422
|
-
const image = declared ? postgresImage(declared) : fake?.image ?? DEFAULT_IMAGES[container];
|
|
3423
|
-
if (!image) throw new UnknownContainer(container);
|
|
3424
|
-
const published = publish(container, options.ports, plan.fakes);
|
|
3425
|
-
services[container] = fake ? {
|
|
3426
|
-
image,
|
|
3427
|
-
restart: "unless-stopped",
|
|
3428
|
-
ports: published
|
|
3429
|
-
} : define(container, image, published);
|
|
3430
|
-
const volume = volumeOf(container);
|
|
3431
|
-
if (volume) volumes[volume] = {};
|
|
3826
|
+
function lanAddress(interfaces = networkInterfaces()) {
|
|
3827
|
+
for (const entries of Object.values(interfaces)) for (const entry of entries ?? []) {
|
|
3828
|
+
if (entry.family !== "IPv4" || entry.internal) continue;
|
|
3829
|
+
if (isPrivate(entry.address)) return entry.address;
|
|
3432
3830
|
}
|
|
3433
|
-
return {
|
|
3434
|
-
name: options.project,
|
|
3435
|
-
services: {
|
|
3436
|
-
...services,
|
|
3437
|
-
...options.apps
|
|
3438
|
-
},
|
|
3439
|
-
...Object.keys(volumes).length ? { volumes } : {}
|
|
3440
|
-
};
|
|
3441
3831
|
}
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
return `# Generated by gkm from the construct manifest — do not edit.
|
|
3445
|
-
# Containers are derived from what the app declares. Image pins and anything
|
|
3446
|
-
# no construct implies go in your own docker-compose.yml, which is merged over
|
|
3447
|
-
# this file and wins:
|
|
3448
|
-
#
|
|
3449
|
-
# docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
|
|
3450
|
-
#
|
|
3451
|
-
# "gkm dev" and "gkm test" start the containers. The apps sit behind the
|
|
3452
|
-
# "apps" profile: run "gkm setup", then
|
|
3453
|
-
# docker compose -f docker-compose.constructs.yml --profile apps up --build
|
|
3454
|
-
${stringify(file, { lineWidth: 0 })}`;
|
|
3832
|
+
function isPrivate(address) {
|
|
3833
|
+
return /^10\./.test(address) || /^192\.168\./.test(address) || /^172\.(1[6-9]|2\d|3[01])\./.test(address);
|
|
3455
3834
|
}
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3835
|
+
//#endregion
|
|
3836
|
+
//#region src/reconcile/logins.ts
|
|
3837
|
+
/**
|
|
3838
|
+
* Bringing a container that already has data in line with the workspace's
|
|
3839
|
+
* generated logins.
|
|
3840
|
+
*
|
|
3841
|
+
* Postgres and RabbitMQ read their login from the environment once, when the
|
|
3842
|
+
* volume is first initialised; a volume an older gkm made was initialised
|
|
3843
|
+
* with the old fixed one, and a changed environment alone would lock the
|
|
3844
|
+
* developer out of their own data. So after the containers are up, each one
|
|
3845
|
+
* is asked whether the generated login opens it, and if not:
|
|
3846
|
+
*
|
|
3847
|
+
* - **Postgres**: signed in to with the old login, the generated superuser is
|
|
3848
|
+
* created (or given its password), and the old one stops logging in. Where
|
|
3849
|
+
* no login is accepted — another checkout of the same project rotated it —
|
|
3850
|
+
* the password is set from inside the container, which trusts its own
|
|
3851
|
+
* socket.
|
|
3852
|
+
* - **MinIO** reads its root login on every start, so recreating the
|
|
3853
|
+
* container rotated it already. If the old login is still the one it takes —
|
|
3854
|
+
* a project's own `docker-compose.yml` pins it — that is kept, with a note.
|
|
3855
|
+
* - **RabbitMQ**: the user is added, or given its password, with `rabbitmqctl`
|
|
3856
|
+
* inside the container.
|
|
3857
|
+
*
|
|
3858
|
+
* Nothing is deleted: no volume, no database, no bucket.
|
|
3859
|
+
*/
|
|
3860
|
+
/**
|
|
3861
|
+
* The login every local container was brought up with before logins were
|
|
3862
|
+
* generated. Kept only to recognise — and rotate away from — a volume made
|
|
3863
|
+
* then.
|
|
3864
|
+
*/
|
|
3865
|
+
const LEGACY_LOCAL_LOGIN = Object.freeze({
|
|
3866
|
+
user: "geekmidas",
|
|
3867
|
+
password: "geekmidas"
|
|
3868
|
+
});
|
|
3869
|
+
/** No login this workspace knows opens its Postgres. */
|
|
3870
|
+
var PostgresLoginRefused = class extends Error {
|
|
3871
|
+
port;
|
|
3872
|
+
user;
|
|
3873
|
+
constructor(port, user) {
|
|
3874
|
+
super(`The Postgres on port ${port} accepts neither '${user}' with this workspace's generated password nor the login older gkm versions used, and could not be reset from inside its container. If it is not this project's container, stop whatever holds the port. If it is, removing its volume starts it over with the generated login — and deletes its data: docker compose -f docker-compose.constructs.yml down -v`);
|
|
3875
|
+
this.port = port;
|
|
3876
|
+
this.user = user;
|
|
3877
|
+
this.name = "PostgresLoginRefused";
|
|
3466
3878
|
}
|
|
3467
3879
|
};
|
|
3468
|
-
/**
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
healthcheck: {
|
|
3523
|
-
test: [
|
|
3524
|
-
"CMD",
|
|
3525
|
-
"caddy",
|
|
3526
|
-
"version"
|
|
3527
|
-
],
|
|
3528
|
-
interval: "10s",
|
|
3529
|
-
timeout: "5s",
|
|
3530
|
-
retries: 5
|
|
3531
|
-
}
|
|
3532
|
-
};
|
|
3533
|
-
case "mailpit": return {
|
|
3534
|
-
image,
|
|
3535
|
-
restart: "unless-stopped",
|
|
3536
|
-
ports: published,
|
|
3537
|
-
environment: {
|
|
3538
|
-
MP_SMTP_AUTH_ACCEPT_ANY: "1",
|
|
3539
|
-
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
|
|
3540
|
-
},
|
|
3541
|
-
healthcheck: {
|
|
3542
|
-
test: [
|
|
3543
|
-
"CMD",
|
|
3544
|
-
"wget",
|
|
3545
|
-
"-q",
|
|
3546
|
-
"--spider",
|
|
3547
|
-
"http://localhost:8025"
|
|
3548
|
-
],
|
|
3549
|
-
interval: "10s",
|
|
3550
|
-
timeout: "5s",
|
|
3551
|
-
retries: 5
|
|
3552
|
-
}
|
|
3880
|
+
/** No login this workspace knows opens its MinIO. */
|
|
3881
|
+
var MinioLoginRefused = class extends Error {
|
|
3882
|
+
port;
|
|
3883
|
+
user;
|
|
3884
|
+
constructor(port, user) {
|
|
3885
|
+
super(`The MinIO on port ${port} accepts neither '${user}' with this workspace's generated password nor the login older gkm versions used. If your docker-compose.yml sets MINIO_ROOT_USER or MINIO_ROOT_PASSWORD, remove them so the generated login applies.`);
|
|
3886
|
+
this.port = port;
|
|
3887
|
+
this.user = user;
|
|
3888
|
+
this.name = "MinioLoginRefused";
|
|
3889
|
+
}
|
|
3890
|
+
};
|
|
3891
|
+
/** Whether `login` opens the Postgres on `port`, as a connected client. */
|
|
3892
|
+
async function connect$1(port, login) {
|
|
3893
|
+
const client = new Client({
|
|
3894
|
+
host: "localhost",
|
|
3895
|
+
port,
|
|
3896
|
+
user: login.user,
|
|
3897
|
+
password: login.password,
|
|
3898
|
+
database: CLUSTER_DATABASE
|
|
3899
|
+
});
|
|
3900
|
+
try {
|
|
3901
|
+
await client.connect();
|
|
3902
|
+
return client;
|
|
3903
|
+
} catch (error) {
|
|
3904
|
+
await client.end().catch(() => {});
|
|
3905
|
+
const code = error.code;
|
|
3906
|
+
if (code === "28P01" || code === "28000" || code === "3D000") return null;
|
|
3907
|
+
throw error;
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
/** Quote a string literal for DDL that cannot take parameters. */
|
|
3911
|
+
function literal(value) {
|
|
3912
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
3913
|
+
}
|
|
3914
|
+
function ident(name) {
|
|
3915
|
+
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
3916
|
+
}
|
|
3917
|
+
/** The SQL that makes `login` a superuser with its password, new or not. */
|
|
3918
|
+
function superuserSql(login) {
|
|
3919
|
+
return `DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${literal(login.user)}) THEN ALTER ROLE ${ident(login.user)} WITH SUPERUSER LOGIN PASSWORD ${literal(login.password)}; ELSE CREATE ROLE ${ident(login.user)} WITH SUPERUSER LOGIN PASSWORD ${literal(login.password)}; END IF; END \$\$`;
|
|
3920
|
+
}
|
|
3921
|
+
/**
|
|
3922
|
+
* Make the Postgres on `port` take `login`, rotating it from an older one
|
|
3923
|
+
* where that is what it takes.
|
|
3924
|
+
*/
|
|
3925
|
+
async function ensurePostgresLogin(options) {
|
|
3926
|
+
const { port, login, legacy } = options;
|
|
3927
|
+
const current = await connect$1(port, login);
|
|
3928
|
+
if (current) {
|
|
3929
|
+
await current.end();
|
|
3930
|
+
return {
|
|
3931
|
+
service: "postgres",
|
|
3932
|
+
status: "current",
|
|
3933
|
+
login
|
|
3553
3934
|
};
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3935
|
+
}
|
|
3936
|
+
for (const old of legacy) {
|
|
3937
|
+
const client = await connect$1(port, old);
|
|
3938
|
+
if (!client) continue;
|
|
3939
|
+
try {
|
|
3940
|
+
await client.query(superuserSql(login));
|
|
3941
|
+
} catch {
|
|
3942
|
+
await client.end();
|
|
3943
|
+
return {
|
|
3944
|
+
service: "postgres",
|
|
3945
|
+
status: "kept",
|
|
3946
|
+
login: old,
|
|
3947
|
+
notice: `⚠️ Postgres still signs in as '${old.user}' with the old fixed password: it could not be rotated. To start over with a generated one, remove its volume (this deletes its data): docker compose -f docker-compose.constructs.yml down -v`
|
|
3948
|
+
};
|
|
3949
|
+
}
|
|
3950
|
+
await client.end();
|
|
3951
|
+
await retire(port, login, old);
|
|
3952
|
+
return {
|
|
3953
|
+
service: "postgres",
|
|
3954
|
+
status: "rotated",
|
|
3955
|
+
login,
|
|
3956
|
+
notice: `🔑 Postgres moved from the old fixed login to a generated one ('${login.user}'); its data is unchanged. See it with: gkm dev:credentials`
|
|
3569
3957
|
};
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
}
|
|
3958
|
+
}
|
|
3959
|
+
if (options.exec) for (const user of [login.user, ...legacy.map((l) => l.user)]) {
|
|
3960
|
+
if (!(await options.exec("postgres", [
|
|
3961
|
+
"psql",
|
|
3962
|
+
"-v",
|
|
3963
|
+
"ON_ERROR_STOP=1",
|
|
3964
|
+
"-U",
|
|
3965
|
+
user,
|
|
3966
|
+
"-d",
|
|
3967
|
+
"postgres",
|
|
3968
|
+
"-c",
|
|
3969
|
+
superuserSql(login)
|
|
3970
|
+
])).ok) continue;
|
|
3971
|
+
const client = await connect$1(port, login);
|
|
3972
|
+
if (!client) continue;
|
|
3973
|
+
await client.end();
|
|
3974
|
+
for (const old of legacy) await retire(port, login, old);
|
|
3975
|
+
return {
|
|
3976
|
+
service: "postgres",
|
|
3977
|
+
status: "rotated",
|
|
3978
|
+
login,
|
|
3979
|
+
notice: `🔑 Postgres's login was reset to this workspace's generated one ('${login.user}'); its data is unchanged.`
|
|
3593
3980
|
};
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3981
|
+
}
|
|
3982
|
+
throw new PostgresLoginRefused(port, login.user);
|
|
3983
|
+
}
|
|
3984
|
+
/** Stop an older login from opening the cluster, now another does. */
|
|
3985
|
+
async function retire(port, login, old) {
|
|
3986
|
+
if (old.user === login.user) return;
|
|
3987
|
+
const client = await connect$1(port, login);
|
|
3988
|
+
if (!client) return;
|
|
3989
|
+
try {
|
|
3990
|
+
if ((await client.query("SELECT 1 FROM pg_roles WHERE rolname = $1", [old.user])).rowCount) await client.query(`ALTER ROLE ${ident(old.user)} WITH NOLOGIN PASSWORD NULL`);
|
|
3991
|
+
} finally {
|
|
3992
|
+
await client.end();
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
/** Whether `login` signs in to the MinIO on `port`. */
|
|
3996
|
+
async function minioAccepts(port, login) {
|
|
3997
|
+
const s3 = new S3Client({
|
|
3998
|
+
region: "us-east-1",
|
|
3999
|
+
endpoint: `http://localhost:${port}`,
|
|
4000
|
+
forcePathStyle: true,
|
|
4001
|
+
credentials: {
|
|
4002
|
+
accessKeyId: login.user,
|
|
4003
|
+
secretAccessKey: login.password
|
|
4004
|
+
},
|
|
4005
|
+
maxAttempts: 1
|
|
4006
|
+
});
|
|
4007
|
+
try {
|
|
4008
|
+
await s3.send(new ListBucketsCommand({}));
|
|
4009
|
+
return true;
|
|
4010
|
+
} catch (error) {
|
|
4011
|
+
const name = error.name ?? "";
|
|
4012
|
+
if (name === "InvalidAccessKeyId" || name === "SignatureDoesNotMatch" || name === "AccessDenied") return false;
|
|
4013
|
+
throw error;
|
|
4014
|
+
} finally {
|
|
4015
|
+
s3.destroy();
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
/**
|
|
4019
|
+
* Check the MinIO on `port` takes `login`. It reads its root login on every
|
|
4020
|
+
* start, so a recreated container already does — unless something pins an
|
|
4021
|
+
* older one, which is then kept.
|
|
4022
|
+
*/
|
|
4023
|
+
async function ensureMinioLogin(options) {
|
|
4024
|
+
const { port, login, legacy } = options;
|
|
4025
|
+
if (await minioAccepts(port, login)) return {
|
|
4026
|
+
service: "minio",
|
|
4027
|
+
status: "current",
|
|
4028
|
+
login
|
|
4029
|
+
};
|
|
4030
|
+
for (const old of legacy) if (await minioAccepts(port, old)) return {
|
|
4031
|
+
service: "minio",
|
|
4032
|
+
status: "kept",
|
|
4033
|
+
login: old,
|
|
4034
|
+
notice: `⚠️ MinIO still signs in as '${old.user}' with the old fixed password: something — usually your docker-compose.yml's MINIO_ROOT_USER/MINIO_ROOT_PASSWORD — pins it. Remove that to use the generated login.`
|
|
4035
|
+
};
|
|
4036
|
+
throw new MinioLoginRefused(port, login.user);
|
|
4037
|
+
}
|
|
4038
|
+
/**
|
|
4039
|
+
* Make the RabbitMQ in the project's container take `login`, with
|
|
4040
|
+
* `rabbitmqctl` inside it — which needs no password.
|
|
4041
|
+
*/
|
|
4042
|
+
async function ensureRabbitmqLogin(options) {
|
|
4043
|
+
const { login, legacy, exec } = options;
|
|
4044
|
+
const ctl = (...args) => exec("rabbitmq", ["rabbitmqctl", ...args]);
|
|
4045
|
+
if ((await ctl("authenticate_user", login.user, login.password)).ok) return {
|
|
4046
|
+
service: "rabbitmq",
|
|
4047
|
+
status: "current",
|
|
4048
|
+
login
|
|
4049
|
+
};
|
|
4050
|
+
if (!(await ctl("add_user", login.user, login.password)).ok) await ctl("change_password", login.user, login.password);
|
|
4051
|
+
await ctl("set_user_tags", login.user, "administrator");
|
|
4052
|
+
await ctl("set_permissions", "-p", "/", login.user, ".*", ".*", ".*");
|
|
4053
|
+
for (const old of legacy) if (old.user !== login.user) await ctl("delete_user", old.user);
|
|
4054
|
+
return {
|
|
4055
|
+
service: "rabbitmq",
|
|
4056
|
+
status: "rotated",
|
|
4057
|
+
login,
|
|
4058
|
+
notice: `🔑 RabbitMQ moved from the old fixed login to a generated one ('${login.user}'); its queues are unchanged.`
|
|
4059
|
+
};
|
|
4060
|
+
}
|
|
4061
|
+
/**
|
|
4062
|
+
* Check the login of each running container that keeps one, and the
|
|
4063
|
+
* credentials to use from here: the generated ones, or an older login a
|
|
4064
|
+
* service could not be moved off.
|
|
4065
|
+
*/
|
|
4066
|
+
async function ensureLogins(options) {
|
|
4067
|
+
const { containers, ports, exec } = options;
|
|
4068
|
+
let credentials = options.credentials;
|
|
4069
|
+
const outcomes = [];
|
|
4070
|
+
const legacy = [LEGACY_LOCAL_LOGIN];
|
|
4071
|
+
if (containers.includes("postgres") && ports.postgres !== void 0) {
|
|
4072
|
+
const outcome = await ensurePostgresLogin({
|
|
4073
|
+
port: ports.postgres,
|
|
4074
|
+
login: credentials.postgres,
|
|
4075
|
+
legacy,
|
|
4076
|
+
...exec ? { exec } : {}
|
|
4077
|
+
});
|
|
4078
|
+
outcomes.push(outcome);
|
|
4079
|
+
credentials = {
|
|
4080
|
+
...credentials,
|
|
4081
|
+
postgres: outcome.login
|
|
3614
4082
|
};
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
healthcheck: {
|
|
3627
|
-
test: [
|
|
3628
|
-
"CMD",
|
|
3629
|
-
"bash",
|
|
3630
|
-
"-c",
|
|
3631
|
-
"exec 3<>/dev/tcp/localhost/4566"
|
|
3632
|
-
],
|
|
3633
|
-
interval: "10s",
|
|
3634
|
-
timeout: "5s",
|
|
3635
|
-
retries: 5,
|
|
3636
|
-
start_period: "10s"
|
|
3637
|
-
}
|
|
4083
|
+
}
|
|
4084
|
+
if (containers.includes("minio") && ports.minio !== void 0) {
|
|
4085
|
+
const outcome = await ensureMinioLogin({
|
|
4086
|
+
port: ports.minio,
|
|
4087
|
+
login: credentials.minio,
|
|
4088
|
+
legacy
|
|
4089
|
+
});
|
|
4090
|
+
outcomes.push(outcome);
|
|
4091
|
+
credentials = {
|
|
4092
|
+
...credentials,
|
|
4093
|
+
minio: outcome.login
|
|
3638
4094
|
};
|
|
3639
|
-
default: throw new UnknownContainer(container);
|
|
3640
4095
|
}
|
|
4096
|
+
if (containers.includes("rabbitmq") && exec) outcomes.push(await ensureRabbitmqLogin({
|
|
4097
|
+
login: credentials.rabbitmq,
|
|
4098
|
+
legacy,
|
|
4099
|
+
exec
|
|
4100
|
+
}));
|
|
4101
|
+
return {
|
|
4102
|
+
credentials,
|
|
4103
|
+
outcomes
|
|
4104
|
+
};
|
|
4105
|
+
}
|
|
4106
|
+
//#endregion
|
|
4107
|
+
//#region src/reconcile/ports.ts
|
|
4108
|
+
/**
|
|
4109
|
+
* Port allocation — why two projects can run at once.
|
|
4110
|
+
*
|
|
4111
|
+
* Publishing `5432:5432` means the second project on your machine cannot start.
|
|
4112
|
+
* Nothing solves that except giving each project its own ports, and what makes
|
|
4113
|
+
* it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
|
|
4114
|
+
* never sees a host or a port, so reconcile is free to bind whatever is
|
|
4115
|
+
* available and compose the URL from what it bound. An app reading `DB_HOST` and
|
|
4116
|
+
* `DB_PORT` separately could not be moved this way.
|
|
4117
|
+
*
|
|
4118
|
+
* Assignments are **persisted, not recomputed**. Recomputing means adding a
|
|
4119
|
+
* construct renumbers its neighbours, and every running container, saved
|
|
4120
|
+
* connection string, and `psql` history goes stale at once.
|
|
4121
|
+
*
|
|
4122
|
+
* Pure, with the liveness probe injected — a collision with another project's
|
|
4123
|
+
* stack is a fact about the machine, and keeping it a parameter is what lets the
|
|
4124
|
+
* rules be asserted without binding a socket.
|
|
4125
|
+
*/
|
|
4126
|
+
/**
|
|
4127
|
+
* The window ports are drawn from.
|
|
4128
|
+
*
|
|
4129
|
+
* Above the registered range so nothing here needs privileges, and below the
|
|
4130
|
+
* ephemeral range both Linux (32768+) and macOS (49152+) allocate from — a port
|
|
4131
|
+
* the kernel might hand to an outgoing connection is one that is free when
|
|
4132
|
+
* checked and taken by the time it is bound.
|
|
4133
|
+
*/
|
|
4134
|
+
const WINDOW = {
|
|
4135
|
+
min: 2e4,
|
|
4136
|
+
max: 32e3
|
|
4137
|
+
};
|
|
4138
|
+
/**
|
|
4139
|
+
* How far apart two projects start.
|
|
4140
|
+
*
|
|
4141
|
+
* Wide enough that a project can add containers without walking into its
|
|
4142
|
+
* neighbour's block, narrow enough that the window holds many projects.
|
|
4143
|
+
*/
|
|
4144
|
+
const BLOCK = 50;
|
|
4145
|
+
/**
|
|
4146
|
+
* Where a project's ports start.
|
|
4147
|
+
*
|
|
4148
|
+
* Derived from the name rather than assigned sequentially so that a fresh clone
|
|
4149
|
+
* of the same project on the same machine lands on the same block — the first
|
|
4150
|
+
* run has nothing persisted to read, and hashing is what keeps that first run
|
|
4151
|
+
* from colliding with whatever else is already allocated.
|
|
4152
|
+
*
|
|
4153
|
+
* @example startingPort('toolbox') // stable for a given name
|
|
4154
|
+
*/
|
|
4155
|
+
function startingPort(project) {
|
|
4156
|
+
const digest = createHash("sha256").update(project).digest();
|
|
4157
|
+
const blocks = Math.floor((WINDOW.max - WINDOW.min) / BLOCK);
|
|
4158
|
+
return WINDOW.min + digest.readUInt32BE(0) % blocks * BLOCK;
|
|
4159
|
+
}
|
|
4160
|
+
/**
|
|
4161
|
+
* Assign a port to every container that has none, keeping the ones that do.
|
|
4162
|
+
*
|
|
4163
|
+
* Existing assignments are returned untouched even when the container is no
|
|
4164
|
+
* longer in the plan: removing a construct must not renumber what is left, and
|
|
4165
|
+
* keeping the entry means re-adding it later restores the same port rather than
|
|
4166
|
+
* inventing a new one.
|
|
4167
|
+
*
|
|
4168
|
+
* @param probe - reports what is actually listening. A port assigned to this
|
|
4169
|
+
* project is skipped without probing, because on a converged reconcile our own
|
|
4170
|
+
* container is the thing listening on it.
|
|
4171
|
+
* @throws {NoPortAvailable} when the window is exhausted.
|
|
4172
|
+
*/
|
|
4173
|
+
async function allocate(project, containers, existing = {}, probe = isPortFree) {
|
|
4174
|
+
const assignments = { ...existing };
|
|
4175
|
+
const taken = new Set(Object.values(assignments));
|
|
4176
|
+
for (const container of [...containers].sort()) {
|
|
4177
|
+
if (assignments[container] !== void 0) continue;
|
|
4178
|
+
const port = await nextFree(startingPort(project), taken, probe);
|
|
4179
|
+
assignments[container] = port;
|
|
4180
|
+
taken.add(port);
|
|
4181
|
+
}
|
|
4182
|
+
return assignments;
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4185
|
+
* The ports to keep: what running containers are observed on, then what was
|
|
4186
|
+
* saved — minus any saved entry whose port something else already holds.
|
|
4187
|
+
*
|
|
4188
|
+
* Spreading one over the other let an observed port override its own key and
|
|
4189
|
+
* leave a *different* key still saved on the same number: two services on one
|
|
4190
|
+
* port, and whichever answered first won. Saved keys for containers the plan no
|
|
4191
|
+
* longer has — a backend switched and back — were the usual source. Observed
|
|
4192
|
+
* wins because it is what is actually bound; a dropped entry is simply
|
|
4193
|
+
* allocated again.
|
|
4194
|
+
*/
|
|
4195
|
+
function keptPorts(saved = {}, observed = {}) {
|
|
4196
|
+
const kept = { ...observed };
|
|
4197
|
+
const taken = new Set(Object.values(observed));
|
|
4198
|
+
for (const [key, port] of Object.entries(saved).sort(([a], [b]) => a < b ? -1 : 1)) {
|
|
4199
|
+
if (key in kept || taken.has(port)) continue;
|
|
4200
|
+
kept[key] = port;
|
|
4201
|
+
taken.add(port);
|
|
4202
|
+
}
|
|
4203
|
+
return kept;
|
|
4204
|
+
}
|
|
4205
|
+
/** The window was exhausted. */
|
|
4206
|
+
var NoPortAvailable = class extends Error {
|
|
4207
|
+
/** Where the search started. */
|
|
4208
|
+
from;
|
|
4209
|
+
/** The window searched, so the caller can report what was full. */
|
|
4210
|
+
window = WINDOW;
|
|
4211
|
+
constructor(from) {
|
|
4212
|
+
super("No free port is available in the local port range");
|
|
4213
|
+
this.name = "NoPortAvailable";
|
|
4214
|
+
this.from = from;
|
|
4215
|
+
}
|
|
4216
|
+
};
|
|
4217
|
+
/**
|
|
4218
|
+
* The first port at or after `from` that is neither ours nor in use.
|
|
4219
|
+
*
|
|
4220
|
+
* Wraps at the top of the window rather than stopping, so a project whose block
|
|
4221
|
+
* sits near the end is not the one that fails.
|
|
4222
|
+
*/
|
|
4223
|
+
async function nextFree(from, taken, probe) {
|
|
4224
|
+
const span = WINDOW.max - WINDOW.min;
|
|
4225
|
+
for (let offset = 0; offset < span; offset++) {
|
|
4226
|
+
const port = WINDOW.min + (from - WINDOW.min + offset) % span;
|
|
4227
|
+
if (taken.has(port)) continue;
|
|
4228
|
+
if (await probe(port)) return port;
|
|
4229
|
+
}
|
|
4230
|
+
throw new NoPortAvailable(from);
|
|
4231
|
+
}
|
|
4232
|
+
/**
|
|
4233
|
+
* Whether nothing is listening on a port.
|
|
4234
|
+
*
|
|
4235
|
+
* Binds rather than connects: a refused connection says nothing about whether
|
|
4236
|
+
* the port can be *published*, which is the question Docker will ask. Binds on
|
|
4237
|
+
* `0.0.0.0` for the same reason — a port free on loopback can still be taken on
|
|
4238
|
+
* the address Docker publishes to.
|
|
4239
|
+
*
|
|
4240
|
+
* Inherently racy, and that is acceptable: Docker failing to bind stays the real
|
|
4241
|
+
* check. This exists so the common case surfaces as a message at reconcile
|
|
4242
|
+
* rather than as a connection failure ten minutes later.
|
|
4243
|
+
*/
|
|
4244
|
+
function isPortFree(port) {
|
|
4245
|
+
return new Promise((resolve) => {
|
|
4246
|
+
const server = createServer();
|
|
4247
|
+
server.once("error", () => resolve(false));
|
|
4248
|
+
server.once("listening", () => server.close(() => resolve(true)));
|
|
4249
|
+
server.listen(port, "0.0.0.0");
|
|
4250
|
+
});
|
|
4251
|
+
}
|
|
4252
|
+
//#endregion
|
|
4253
|
+
//#region src/reconcile/provision.ts
|
|
4254
|
+
/**
|
|
4255
|
+
* Creating what the URLs name.
|
|
4256
|
+
*
|
|
4257
|
+
* Convergent and idempotent: every statement asks whether the thing exists
|
|
4258
|
+
* first, so reconciling repeatedly is free and a half-applied run recovers by
|
|
4259
|
+
* being run again. That property is what lets `gkm dev` do this on every start.
|
|
4260
|
+
*
|
|
4261
|
+
* The line the design draws is at data, not at side effects — this creates
|
|
4262
|
+
* databases, schemas, and buckets, and it never seeds, resets, or drops. A
|
|
4263
|
+
* developer who wants their data gone asks for it explicitly.
|
|
4264
|
+
*
|
|
4265
|
+
* Statement generation is separated from application for the reason the design
|
|
4266
|
+
* gives: the same DDL has to run in-process locally and from a provisioner in a
|
|
4267
|
+
* VPC, and one implementation is what stops "works locally, fails deployed".
|
|
4268
|
+
* When the role DDL lands in `@geekmidas/db` this delegates to it rather than
|
|
4269
|
+
* growing its own.
|
|
4270
|
+
*/
|
|
4271
|
+
/**
|
|
4272
|
+
* The Postgres work a plan implies.
|
|
4273
|
+
*
|
|
4274
|
+
* Databases before the schemas inside them, which `provisionOrder` already
|
|
4275
|
+
* guarantees by putting parents first.
|
|
4276
|
+
*/
|
|
4277
|
+
function postgresStatements(plan, project = "", seed) {
|
|
4278
|
+
const statements = [];
|
|
4279
|
+
for (const resource of plan.resources) {
|
|
4280
|
+
if (!resource.provisions || resource.container !== "postgres") continue;
|
|
4281
|
+
if (resource.kind === "database") {
|
|
4282
|
+
statements.push({
|
|
4283
|
+
id: resource.id,
|
|
4284
|
+
describe: `database ${resource.name}`,
|
|
4285
|
+
exists: {
|
|
4286
|
+
sql: "SELECT 1 FROM pg_database WHERE datname = $1",
|
|
4287
|
+
values: [resource.name]
|
|
4288
|
+
},
|
|
4289
|
+
create: `CREATE DATABASE ${quoteIdentifier(resource.name)}`
|
|
4290
|
+
});
|
|
4291
|
+
if (resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
|
|
4292
|
+
continue;
|
|
4293
|
+
}
|
|
4294
|
+
if (resource.kind === "database-schema" && resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
|
|
4295
|
+
const cacheHome = resource.kind === "cache" && resource.of ? plan.resources.find((r) => r.id === resource.of) : void 0;
|
|
4296
|
+
if (cacheHome) statements.push(...cacheTableDdl(resource, cacheHome, plan));
|
|
4297
|
+
}
|
|
4298
|
+
return statements;
|
|
4299
|
+
}
|
|
4300
|
+
/**
|
|
4301
|
+
* A cache's table, in the schema the role that reads it resolves names in.
|
|
4302
|
+
*
|
|
4303
|
+
* Two things have to line up, and neither is the table's contents. The driver
|
|
4304
|
+
* names the table unqualified and lets the connection's `search_path` place it
|
|
4305
|
+
* — which the role carries — so creating it from the master connection, whose
|
|
4306
|
+
* path is `public`, leaves a table the application cannot see and a
|
|
4307
|
+
* `relation "cache" does not exist` on the first request. And the default
|
|
4308
|
+
* privileges that make a tenant's tables reachable are granted *for the owner
|
|
4309
|
+
* role*, so nothing covers a table the master created.
|
|
4310
|
+
*
|
|
4311
|
+
* Hence: qualified with the tenant's schema, handed to its owner, and granted
|
|
4312
|
+
* explicitly — `roleStatements` grants what exists at the time it runs, and
|
|
4313
|
+
* this table does not exist yet.
|
|
4314
|
+
*
|
|
4315
|
+
* A database that opted out of roles needs none of it: everything connects as
|
|
4316
|
+
* the master, whose `search_path` finds `public`, and the table it creates
|
|
4317
|
+
* there is the table it reads.
|
|
4318
|
+
*/
|
|
4319
|
+
function cacheTableDdl(cache, home, plan) {
|
|
4320
|
+
const database = rootDatabase(home, plan);
|
|
4321
|
+
const schema = home.roles === false ? void 0 : home.schema;
|
|
4322
|
+
const name = cache.table ?? cacheTable(cache.id);
|
|
4323
|
+
const table = schema ? `${schema}.${name}` : name;
|
|
4324
|
+
const created = cacheTableStatements({ table }).map((statement) => ({
|
|
4325
|
+
id: cache.id,
|
|
4326
|
+
describe: statement.describe,
|
|
4327
|
+
database,
|
|
4328
|
+
...statement.exists ? { exists: statement.exists } : {},
|
|
4329
|
+
create: statement.sql
|
|
4330
|
+
}));
|
|
4331
|
+
if (!schema) return created;
|
|
4332
|
+
const runtime = localRole(home);
|
|
4333
|
+
const owner = ownerRole(runtime);
|
|
4334
|
+
const qualified = table.split(".").map((part) => quoteIdentifier(part)).join(".");
|
|
4335
|
+
return [
|
|
4336
|
+
...created,
|
|
4337
|
+
{
|
|
4338
|
+
id: cache.id,
|
|
4339
|
+
describe: `cache table ${table} is owned by ${owner}`,
|
|
4340
|
+
database,
|
|
4341
|
+
create: `ALTER TABLE ${qualified} OWNER TO ${quoteIdentifier(owner)}`
|
|
4342
|
+
},
|
|
4343
|
+
{
|
|
4344
|
+
id: cache.id,
|
|
4345
|
+
describe: `${runtime} may read and write ${table}`,
|
|
4346
|
+
database,
|
|
4347
|
+
create: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${qualified} TO ${quoteIdentifier(runtime)}`
|
|
4348
|
+
}
|
|
4349
|
+
];
|
|
4350
|
+
}
|
|
4351
|
+
/**
|
|
4352
|
+
* The roles and schema for one database or tenant.
|
|
4353
|
+
*
|
|
4354
|
+
* Skipped entirely when the construct declared `roles: false` — the documented
|
|
4355
|
+
* downgrade, where both URLs fall back to the cluster's master credential. It
|
|
4356
|
+
* is a choice someone made rather than a default they got.
|
|
4357
|
+
*/
|
|
4358
|
+
function rolesFor(resource, schema, plan, project, seed) {
|
|
4359
|
+
if (resource.roles === false) return [];
|
|
4360
|
+
const runtime = localRole(resource);
|
|
4361
|
+
const owner = ownerRole(runtime);
|
|
4362
|
+
const reader = plan.resources.some((r) => r.kind === "database-reader" && r.of === resource.id) ? readerRole(runtime) : void 0;
|
|
4363
|
+
return roleStatements({
|
|
4364
|
+
runtime,
|
|
4365
|
+
owner,
|
|
4366
|
+
...reader ? { reader } : {},
|
|
4367
|
+
schema,
|
|
4368
|
+
...resource.kind === "database" ? { database: rootDatabase(resource, plan) } : {},
|
|
4369
|
+
passwords: {
|
|
4370
|
+
runtime: localRolePassword(project, plan, runtime, seed),
|
|
4371
|
+
owner: localRolePassword(project, plan, owner, seed),
|
|
4372
|
+
...reader ? { reader: localRolePassword(project, plan, reader, seed) } : {}
|
|
4373
|
+
}
|
|
4374
|
+
}).map((statement) => ({
|
|
4375
|
+
id: resource.id,
|
|
4376
|
+
describe: statement.describe,
|
|
4377
|
+
database: rootDatabase(resource, plan),
|
|
4378
|
+
...statement.exists ? { exists: statement.exists } : {},
|
|
4379
|
+
create: statement.sql
|
|
4380
|
+
}));
|
|
4381
|
+
}
|
|
4382
|
+
/** The buckets a plan implies. */
|
|
4383
|
+
function bucketNames(plan) {
|
|
4384
|
+
return plan.resources.filter((r) => r.provisions && r.kind === "objects").map((r) => r.name);
|
|
4385
|
+
}
|
|
4386
|
+
/**
|
|
4387
|
+
* Quote an identifier for use in DDL.
|
|
4388
|
+
*
|
|
4389
|
+
* Names reaching here are already canonical and stage-suffixed, so this is
|
|
4390
|
+
* defence rather than sanitisation — but DDL cannot be parameterised, and an
|
|
4391
|
+
* unquoted identifier is the one place a name could ever be more than a name.
|
|
4392
|
+
*/
|
|
4393
|
+
function quoteIdentifier(name) {
|
|
4394
|
+
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
4395
|
+
}
|
|
4396
|
+
/**
|
|
4397
|
+
* Apply the plan's Postgres statements.
|
|
4398
|
+
*
|
|
4399
|
+
* Each is checked before it is run, so this converges rather than erroring on a
|
|
4400
|
+
* second pass.
|
|
4401
|
+
*/
|
|
4402
|
+
async function applyPostgres(client, statements) {
|
|
4403
|
+
const applied = [];
|
|
4404
|
+
for (const statement of statements) {
|
|
4405
|
+
if (!statement.exists) {
|
|
4406
|
+
await client.query(statement.database, statement.create);
|
|
4407
|
+
applied.push({
|
|
4408
|
+
id: statement.id,
|
|
4409
|
+
describe: statement.describe,
|
|
4410
|
+
created: false
|
|
4411
|
+
});
|
|
4412
|
+
continue;
|
|
4413
|
+
}
|
|
4414
|
+
if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
|
|
4415
|
+
applied.push({
|
|
4416
|
+
id: statement.id,
|
|
4417
|
+
describe: statement.describe,
|
|
4418
|
+
created: false
|
|
4419
|
+
});
|
|
4420
|
+
continue;
|
|
4421
|
+
}
|
|
4422
|
+
await client.query(statement.database, statement.create);
|
|
4423
|
+
applied.push({
|
|
4424
|
+
id: statement.id,
|
|
4425
|
+
describe: statement.describe,
|
|
4426
|
+
created: true
|
|
4427
|
+
});
|
|
4428
|
+
}
|
|
4429
|
+
return applied;
|
|
3641
4430
|
}
|
|
3642
4431
|
/**
|
|
3643
|
-
*
|
|
4432
|
+
* What each bucket's open paths are, if anything serves it.
|
|
3644
4433
|
*
|
|
3645
|
-
*
|
|
3646
|
-
*
|
|
3647
|
-
*
|
|
4434
|
+
* Keyed by the *origin*, because that is where the policy lives: two servers
|
|
4435
|
+
* over one bucket contribute to one policy, so the patterns are unioned rather
|
|
4436
|
+
* than the last one winning.
|
|
3648
4437
|
*/
|
|
3649
|
-
function
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
if (
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
/** The port key that went unassigned, e.g. `minio-console`. */
|
|
3659
|
-
key;
|
|
3660
|
-
constructor(key) {
|
|
3661
|
-
super("No port was assigned for this container port");
|
|
3662
|
-
this.name = "UnassignedPort";
|
|
3663
|
-
this.key = key;
|
|
4438
|
+
function bucketPolicies(plan) {
|
|
4439
|
+
const byBucket = /* @__PURE__ */ new Map();
|
|
4440
|
+
for (const resource of plan.resources) {
|
|
4441
|
+
if (resource.kind !== "file-server" || !resource.open?.length) continue;
|
|
4442
|
+
const origin = plan.resources.find((r) => r.id === resource.of);
|
|
4443
|
+
if (!origin) continue;
|
|
4444
|
+
const patterns = byBucket.get(origin.name) ?? /* @__PURE__ */ new Set();
|
|
4445
|
+
for (const pattern of resource.open) patterns.add(pattern);
|
|
4446
|
+
byBucket.set(origin.name, patterns);
|
|
3664
4447
|
}
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
4448
|
+
return [...byBucket].map(([bucket, open]) => ({
|
|
4449
|
+
bucket,
|
|
4450
|
+
open: [...open].sort()
|
|
4451
|
+
})).sort((a, b) => a.bucket.localeCompare(b.bucket));
|
|
4452
|
+
}
|
|
3668
4453
|
/**
|
|
3669
|
-
*
|
|
4454
|
+
* One bucket's open paths as an S3 bucket policy.
|
|
3670
4455
|
*
|
|
3671
|
-
*
|
|
3672
|
-
* what
|
|
3673
|
-
*
|
|
4456
|
+
* Anonymous `s3:GetObject` on the named prefixes and nothing else — which is
|
|
4457
|
+
* what "open" means everywhere: the server serves that path without a
|
|
4458
|
+
* signature, and the bucket stays private for everything else.
|
|
3674
4459
|
*
|
|
3675
|
-
*
|
|
3676
|
-
*
|
|
4460
|
+
* One fidelity note, stated rather than discovered: an S3 policy resource's `*`
|
|
4461
|
+
* crosses `/`, so a single-star pattern is *wider* here than the construct's own
|
|
4462
|
+
* runtime check, which stops at a segment boundary. The construct is the
|
|
4463
|
+
* stricter of the two, so a key it refuses to serve unsigned is never one this
|
|
4464
|
+
* policy was relied on to refuse — but a key fetched directly, bypassing the
|
|
4465
|
+
* client, can be admitted by the policy where the client would have said no.
|
|
4466
|
+
* Prefer `**` where crossing segments is what you meant.
|
|
3677
4467
|
*/
|
|
3678
|
-
|
|
4468
|
+
function bucketPolicy(bucket, open) {
|
|
4469
|
+
return JSON.stringify({
|
|
4470
|
+
Version: "2012-10-17",
|
|
4471
|
+
Statement: [{
|
|
4472
|
+
Sid: "GkmOpenPaths",
|
|
4473
|
+
Effect: "Allow",
|
|
4474
|
+
Principal: { AWS: ["*"] },
|
|
4475
|
+
Action: ["s3:GetObject"],
|
|
4476
|
+
Resource: open.map((pattern) => `arn:aws:s3:::${bucket}/${pattern.replace(/\*\*/g, "*")}`)
|
|
4477
|
+
}]
|
|
4478
|
+
});
|
|
4479
|
+
}
|
|
3679
4480
|
/**
|
|
3680
|
-
* The
|
|
4481
|
+
* The topics and queues to create on the AWS emulator.
|
|
3681
4482
|
*
|
|
3682
|
-
*
|
|
3683
|
-
*
|
|
4483
|
+
* Only on the `sns` backend: pg-boss makes its own tables on first connect and
|
|
4484
|
+
* a RabbitMQ exchange is declared by the client, but an SNS topic and an SQS
|
|
4485
|
+
* queue must exist before anything can publish to them.
|
|
3684
4486
|
*/
|
|
3685
|
-
function
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
4487
|
+
function carrierNames(plan) {
|
|
4488
|
+
if (plan.events !== "sns") return {
|
|
4489
|
+
topics: [],
|
|
4490
|
+
queues: []
|
|
4491
|
+
};
|
|
4492
|
+
const named = (kind) => plan.resources.filter((r) => r.kind === kind).map((r) => r.name);
|
|
4493
|
+
return {
|
|
4494
|
+
topics: named("topic"),
|
|
4495
|
+
queues: named("queue")
|
|
4496
|
+
};
|
|
3692
4497
|
}
|
|
3693
|
-
/**
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
4498
|
+
/** Create each topic and queue the plan names, skipping those that exist. */
|
|
4499
|
+
async function applyCarriers(client, carriers) {
|
|
4500
|
+
const applied = [];
|
|
4501
|
+
for (const name of carriers.topics) {
|
|
4502
|
+
const created = !await client.topicExists(name);
|
|
4503
|
+
if (created) await client.createTopic(name);
|
|
4504
|
+
applied.push({
|
|
4505
|
+
id: name,
|
|
4506
|
+
describe: `topic ${name}`,
|
|
4507
|
+
created
|
|
4508
|
+
});
|
|
3704
4509
|
}
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
child.on("close", (code) => code === 0 ? resolve() : reject(new ComposeUpFailed(services, code)));
|
|
3716
|
-
});
|
|
4510
|
+
for (const name of carriers.queues) {
|
|
4511
|
+
const created = !await client.queueExists(name);
|
|
4512
|
+
if (created) await client.createQueue(name);
|
|
4513
|
+
applied.push({
|
|
4514
|
+
id: name,
|
|
4515
|
+
describe: `queue ${name}`,
|
|
4516
|
+
created
|
|
4517
|
+
});
|
|
4518
|
+
}
|
|
4519
|
+
return applied;
|
|
3717
4520
|
}
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
}
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
await
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
]);
|
|
3757
|
-
},
|
|
3758
|
-
async reload(composePath, service) {
|
|
3759
|
-
await run("docker", [
|
|
3760
|
-
"compose",
|
|
3761
|
-
...composeFiles(composePath),
|
|
3762
|
-
"exec",
|
|
3763
|
-
"-T",
|
|
3764
|
-
service,
|
|
3765
|
-
"caddy",
|
|
3766
|
-
"reload",
|
|
3767
|
-
"--config",
|
|
3768
|
-
"/etc/caddy/Caddyfile"
|
|
3769
|
-
]);
|
|
3770
|
-
},
|
|
3771
|
-
async healthy(composePath, services) {
|
|
3772
|
-
if (services.length === 0) return true;
|
|
3773
|
-
try {
|
|
3774
|
-
const { stdout } = await run("docker", [
|
|
3775
|
-
"compose",
|
|
3776
|
-
...composeFiles(composePath),
|
|
3777
|
-
"ps",
|
|
3778
|
-
"--format",
|
|
3779
|
-
"json",
|
|
3780
|
-
...services
|
|
3781
|
-
]);
|
|
3782
|
-
const running = /* @__PURE__ */ new Set();
|
|
3783
|
-
for (const line of stdout.trim().split("\n").filter(Boolean)) {
|
|
3784
|
-
const entry = JSON.parse(line);
|
|
3785
|
-
if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
|
|
3786
|
-
}
|
|
3787
|
-
return services.every((service) => running.has(service));
|
|
3788
|
-
} catch {
|
|
3789
|
-
return false;
|
|
4521
|
+
/** The Postgres databases the plan creates — each is a `CREATE DATABASE`. */
|
|
4522
|
+
function postgresDatabaseNames(plan) {
|
|
4523
|
+
return plan.resources.filter((resource) => resource.kind === "database" && resource.provisions && resource.container === "postgres").map((resource) => resource.name);
|
|
4524
|
+
}
|
|
4525
|
+
/**
|
|
4526
|
+
* Whether every one of these databases exists. A database dropped behind the
|
|
4527
|
+
* recorded state's back — by another checkout's test teardown, against the
|
|
4528
|
+
* same container — makes the state a lie, and only asking Postgres finds out.
|
|
4529
|
+
* Unreachable counts as missing: applying again is what finds out why.
|
|
4530
|
+
*/
|
|
4531
|
+
async function databasesExist(client, names) {
|
|
4532
|
+
if (names.length === 0) return true;
|
|
4533
|
+
try {
|
|
4534
|
+
const rows = await client.query(void 0, "SELECT datname FROM pg_catalog.pg_database WHERE datname = ANY($1)", [names]);
|
|
4535
|
+
return new Set(rows.map((row) => row.datname)).size === names.length;
|
|
4536
|
+
} catch {
|
|
4537
|
+
return false;
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
/**
|
|
4541
|
+
* Apply each served bucket's open-path policy, skipping those already correct.
|
|
4542
|
+
*
|
|
4543
|
+
* Compared as parsed JSON rather than as text: the same policy re-serialised
|
|
4544
|
+
* with different key order is the same policy, and rewriting it every reconcile
|
|
4545
|
+
* would report a change on every start.
|
|
4546
|
+
*/
|
|
4547
|
+
async function applyPolicies(client, policies) {
|
|
4548
|
+
const applied = [];
|
|
4549
|
+
for (const { bucket, open } of policies) {
|
|
4550
|
+
const wanted = bucketPolicy(bucket, open);
|
|
4551
|
+
const describe = `open paths on ${bucket}`;
|
|
4552
|
+
if (equivalent(await client.policy(bucket), wanted)) {
|
|
4553
|
+
applied.push({
|
|
4554
|
+
id: `${bucket}:policy`,
|
|
4555
|
+
describe,
|
|
4556
|
+
created: false
|
|
4557
|
+
});
|
|
4558
|
+
continue;
|
|
3790
4559
|
}
|
|
4560
|
+
await client.setPolicy(bucket, wanted);
|
|
4561
|
+
applied.push({
|
|
4562
|
+
id: `${bucket}:policy`,
|
|
4563
|
+
describe,
|
|
4564
|
+
created: true
|
|
4565
|
+
});
|
|
3791
4566
|
}
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
*
|
|
3802
|
-
* Private ranges only (`10/8`, `172.16/12`, `192.168/16`): a public address
|
|
3803
|
-
* would mean listening for a phone on the open internet, and a link-local one
|
|
3804
|
-
* (`169.254/16`) is what an interface has when it found no network at all.
|
|
3805
|
-
* Undefined when there is none — a laptop on no network still runs, its mobile
|
|
3806
|
-
* app reaching only a simulator.
|
|
3807
|
-
*/
|
|
3808
|
-
function lanAddress(interfaces = networkInterfaces()) {
|
|
3809
|
-
for (const entries of Object.values(interfaces)) for (const entry of entries ?? []) {
|
|
3810
|
-
if (entry.family !== "IPv4" || entry.internal) continue;
|
|
3811
|
-
if (isPrivate(entry.address)) return entry.address;
|
|
4567
|
+
return applied;
|
|
4568
|
+
}
|
|
4569
|
+
/** Whether two policy documents say the same thing. */
|
|
4570
|
+
function equivalent(current, wanted) {
|
|
4571
|
+
if (!current) return false;
|
|
4572
|
+
try {
|
|
4573
|
+
return JSON.stringify(JSON.parse(current)) === JSON.stringify(JSON.parse(wanted));
|
|
4574
|
+
} catch {
|
|
4575
|
+
return false;
|
|
3812
4576
|
}
|
|
3813
4577
|
}
|
|
3814
|
-
|
|
3815
|
-
|
|
4578
|
+
/** Create every bucket the plan names, skipping those that exist. */
|
|
4579
|
+
async function applyBuckets(client, buckets) {
|
|
4580
|
+
const applied = [];
|
|
4581
|
+
for (const bucket of buckets) {
|
|
4582
|
+
if (await client.exists(bucket)) {
|
|
4583
|
+
applied.push({
|
|
4584
|
+
id: bucket,
|
|
4585
|
+
describe: `bucket ${bucket}`,
|
|
4586
|
+
created: false
|
|
4587
|
+
});
|
|
4588
|
+
continue;
|
|
4589
|
+
}
|
|
4590
|
+
await client.create(bucket);
|
|
4591
|
+
applied.push({
|
|
4592
|
+
id: bucket,
|
|
4593
|
+
describe: `bucket ${bucket}`,
|
|
4594
|
+
created: true
|
|
4595
|
+
});
|
|
4596
|
+
}
|
|
4597
|
+
return applied;
|
|
3816
4598
|
}
|
|
3817
4599
|
//#endregion
|
|
3818
|
-
//#region src/reconcile/
|
|
3819
|
-
/**
|
|
3820
|
-
* Port allocation — why two projects can run at once.
|
|
3821
|
-
*
|
|
3822
|
-
* Publishing `5432:5432` means the second project on your machine cannot start.
|
|
3823
|
-
* Nothing solves that except giving each project its own ports, and what makes
|
|
3824
|
-
* it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
|
|
3825
|
-
* never sees a host or a port, so reconcile is free to bind whatever is
|
|
3826
|
-
* available and compose the URL from what it bound. An app reading `DB_HOST` and
|
|
3827
|
-
* `DB_PORT` separately could not be moved this way.
|
|
3828
|
-
*
|
|
3829
|
-
* Assignments are **persisted, not recomputed**. Recomputing means adding a
|
|
3830
|
-
* construct renumbers its neighbours, and every running container, saved
|
|
3831
|
-
* connection string, and `psql` history goes stale at once.
|
|
3832
|
-
*
|
|
3833
|
-
* Pure, with the liveness probe injected — a collision with another project's
|
|
3834
|
-
* stack is a fact about the machine, and keeping it a parameter is what lets the
|
|
3835
|
-
* rules be asserted without binding a socket.
|
|
3836
|
-
*/
|
|
4600
|
+
//#region src/reconcile/state.ts
|
|
3837
4601
|
/**
|
|
3838
|
-
*
|
|
4602
|
+
* What reconcile records, and the hash that lets it do nothing.
|
|
3839
4603
|
*
|
|
3840
|
-
*
|
|
3841
|
-
*
|
|
3842
|
-
*
|
|
3843
|
-
*
|
|
3844
|
-
*/
|
|
3845
|
-
const WINDOW = {
|
|
3846
|
-
min: 2e4,
|
|
3847
|
-
max: 32e3
|
|
3848
|
-
};
|
|
3849
|
-
/**
|
|
3850
|
-
* How far apart two projects start.
|
|
4604
|
+
* Reconciling on every `gkm dev` is only acceptable if the converged case is
|
|
4605
|
+
* free, so the desired plan is hashed: matching hash plus healthy containers
|
|
4606
|
+
* means there is nothing to do. Without that, the automatic reconcile the design
|
|
4607
|
+
* asks for would tax every start.
|
|
3851
4608
|
*
|
|
3852
|
-
*
|
|
3853
|
-
*
|
|
4609
|
+
* Almost nothing needs recording — roles are queried, migrations have their own
|
|
4610
|
+
* table, containers are remembered by Docker. Only ports and this hash have no
|
|
4611
|
+
* other owner.
|
|
3854
4612
|
*/
|
|
3855
|
-
const
|
|
4613
|
+
const STATE_DIR = ".gkm/reconcile";
|
|
3856
4614
|
/**
|
|
3857
|
-
*
|
|
4615
|
+
* The hash of everything a reconcile would act on.
|
|
3858
4616
|
*
|
|
3859
|
-
*
|
|
3860
|
-
*
|
|
3861
|
-
*
|
|
3862
|
-
*
|
|
4617
|
+
* Covers the compose document rather than the plan alone, because the plan is
|
|
4618
|
+
* unchanged when only an image pin moves — and a changed image is exactly a
|
|
4619
|
+
* container that must be recreated. `stringify` is stable here because
|
|
4620
|
+
* `composeFor` sorts its containers, which is what makes the hash meaningful.
|
|
3863
4621
|
*
|
|
3864
|
-
*
|
|
4622
|
+
* Deliberately a plain function over serialisable input: the deployed target
|
|
4623
|
+
* keys its provisioner invocation on the same idea, and the two must not be able
|
|
4624
|
+
* to disagree about what counts as a change.
|
|
3865
4625
|
*/
|
|
3866
|
-
function
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
4626
|
+
function planHash(plan, compose, generated = {}) {
|
|
4627
|
+
return createHash("sha256").update(JSON.stringify({
|
|
4628
|
+
stage: plan.stage,
|
|
4629
|
+
plan,
|
|
4630
|
+
compose,
|
|
4631
|
+
generated
|
|
4632
|
+
})).digest("hex");
|
|
3870
4633
|
}
|
|
3871
|
-
/**
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
* @param probe - reports what is actually listening. A port assigned to this
|
|
3880
|
-
* project is skipped without probing, because on a converged reconcile our own
|
|
3881
|
-
* container is the thing listening on it.
|
|
3882
|
-
* @throws {NoPortAvailable} when the window is exhausted.
|
|
3883
|
-
*/
|
|
3884
|
-
async function allocate(project, containers, existing = {}, probe = isPortFree) {
|
|
3885
|
-
const assignments = { ...existing };
|
|
3886
|
-
const taken = new Set(Object.values(assignments));
|
|
3887
|
-
for (const container of [...containers].sort()) {
|
|
3888
|
-
if (assignments[container] !== void 0) continue;
|
|
3889
|
-
const port = await nextFree(startingPort(project), taken, probe);
|
|
3890
|
-
assignments[container] = port;
|
|
3891
|
-
taken.add(port);
|
|
4634
|
+
/** Read the recorded state, or nothing if this stage has never reconciled. */
|
|
4635
|
+
async function loadState(root, stage) {
|
|
4636
|
+
try {
|
|
4637
|
+
const raw = await readFile(statePath(root, stage), "utf-8");
|
|
4638
|
+
const state = JSON.parse(raw);
|
|
4639
|
+
return state.stage === stage ? state : void 0;
|
|
4640
|
+
} catch {
|
|
4641
|
+
return;
|
|
3892
4642
|
}
|
|
3893
|
-
return assignments;
|
|
3894
4643
|
}
|
|
3895
|
-
/**
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
* leave a *different* key still saved on the same number: two services on one
|
|
3901
|
-
* port, and whichever answered first won. Saved keys for containers the plan no
|
|
3902
|
-
* longer has — a backend switched and back — were the usual source. Observed
|
|
3903
|
-
* wins because it is what is actually bound; a dropped entry is simply
|
|
3904
|
-
* allocated again.
|
|
3905
|
-
*/
|
|
3906
|
-
function keptPorts(saved = {}, observed = {}) {
|
|
3907
|
-
const kept = { ...observed };
|
|
3908
|
-
const taken = new Set(Object.values(observed));
|
|
3909
|
-
for (const [key, port] of Object.entries(saved).sort(([a], [b]) => a < b ? -1 : 1)) {
|
|
3910
|
-
if (key in kept || taken.has(port)) continue;
|
|
3911
|
-
kept[key] = port;
|
|
3912
|
-
taken.add(port);
|
|
3913
|
-
}
|
|
3914
|
-
return kept;
|
|
4644
|
+
/** Record what was applied, so the next run can skip it. */
|
|
4645
|
+
async function saveState(root, state) {
|
|
4646
|
+
const path = statePath(root, state.stage);
|
|
4647
|
+
await mkdir(dirname(path), { recursive: true });
|
|
4648
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
3915
4649
|
}
|
|
3916
|
-
/** The window was exhausted. */
|
|
3917
|
-
var NoPortAvailable = class extends Error {
|
|
3918
|
-
/** Where the search started. */
|
|
3919
|
-
from;
|
|
3920
|
-
/** The window searched, so the caller can report what was full. */
|
|
3921
|
-
window = WINDOW;
|
|
3922
|
-
constructor(from) {
|
|
3923
|
-
super("No free port is available in the local port range");
|
|
3924
|
-
this.name = "NoPortAvailable";
|
|
3925
|
-
this.from = from;
|
|
3926
|
-
}
|
|
3927
|
-
};
|
|
3928
4650
|
/**
|
|
3929
|
-
*
|
|
3930
|
-
*
|
|
3931
|
-
* Wraps at the top of the window rather than stopping, so a project whose block
|
|
3932
|
-
* sits near the end is not the one that fails.
|
|
4651
|
+
* Forget what was applied, so the next reconcile of this stage converges from
|
|
4652
|
+
* nothing — creating again whatever was dropped underneath the record.
|
|
3933
4653
|
*/
|
|
3934
|
-
async function
|
|
3935
|
-
|
|
3936
|
-
for (let offset = 0; offset < span; offset++) {
|
|
3937
|
-
const port = WINDOW.min + (from - WINDOW.min + offset) % span;
|
|
3938
|
-
if (taken.has(port)) continue;
|
|
3939
|
-
if (await probe(port)) return port;
|
|
3940
|
-
}
|
|
3941
|
-
throw new NoPortAvailable(from);
|
|
4654
|
+
async function forgetState(root, stage) {
|
|
4655
|
+
await rm(statePath(root, stage), { force: true });
|
|
3942
4656
|
}
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
*
|
|
3946
|
-
* Binds rather than connects: a refused connection says nothing about whether
|
|
3947
|
-
* the port can be *published*, which is the question Docker will ask. Binds on
|
|
3948
|
-
* `0.0.0.0` for the same reason — a port free on loopback can still be taken on
|
|
3949
|
-
* the address Docker publishes to.
|
|
3950
|
-
*
|
|
3951
|
-
* Inherently racy, and that is acceptable: Docker failing to bind stays the real
|
|
3952
|
-
* check. This exists so the common case surfaces as a message at reconcile
|
|
3953
|
-
* rather than as a connection failure ten minutes later.
|
|
3954
|
-
*/
|
|
3955
|
-
function isPortFree(port) {
|
|
3956
|
-
return new Promise((resolve) => {
|
|
3957
|
-
const server = createServer();
|
|
3958
|
-
server.once("error", () => resolve(false));
|
|
3959
|
-
server.once("listening", () => server.close(() => resolve(true)));
|
|
3960
|
-
server.listen(port, "0.0.0.0");
|
|
3961
|
-
});
|
|
4657
|
+
function statePath(root, stage) {
|
|
4658
|
+
return join(root, STATE_DIR, `${stage}.json`);
|
|
3962
4659
|
}
|
|
3963
4660
|
//#endregion
|
|
3964
|
-
//#region src/reconcile/
|
|
4661
|
+
//#region src/reconcile/index.ts
|
|
3965
4662
|
/**
|
|
3966
|
-
*
|
|
3967
|
-
*
|
|
3968
|
-
* Convergent and idempotent: every statement asks whether the thing exists
|
|
3969
|
-
* first, so reconciling repeatedly is free and a half-applied run recovers by
|
|
3970
|
-
* being run again. That property is what lets `gkm dev` do this on every start.
|
|
4663
|
+
* Reconcile — the one convergent function `gkm dev`, `gkm test`, and `gkm setup`
|
|
4664
|
+
* all call.
|
|
3971
4665
|
*
|
|
3972
|
-
*
|
|
3973
|
-
*
|
|
3974
|
-
*
|
|
4666
|
+
* Three phases, and only this one has side effects: declaring is pure (`gkm
|
|
4667
|
+
* build` and the dev watcher), running reads `<NAME>_URL` and provisions
|
|
4668
|
+
* nothing. Reconcile computes the desired state, compares, and applies the
|
|
4669
|
+
* difference — which is what makes "when does it run" answerable with
|
|
4670
|
+
* *whenever, repeatedly*.
|
|
3975
4671
|
*
|
|
3976
|
-
*
|
|
3977
|
-
*
|
|
3978
|
-
*
|
|
3979
|
-
*
|
|
3980
|
-
*
|
|
4672
|
+
* It is safe to run automatically because its blast radius is entirely local:
|
|
4673
|
+
* this project's containers, this project's `.gkm/`. Nothing it does can reach a
|
|
4674
|
+
* cloud. The line is drawn at data, not at side effects — it allocates ports,
|
|
4675
|
+
* writes compose, starts containers, and creates roles; it never seeds, resets,
|
|
4676
|
+
* or drops.
|
|
4677
|
+
*
|
|
4678
|
+
* Docker is injected rather than imported so the rules above can be asserted
|
|
4679
|
+
* without a daemon. The default implementation is the real one.
|
|
3981
4680
|
*/
|
|
3982
4681
|
/**
|
|
3983
|
-
*
|
|
4682
|
+
* Where the generated compose file is written, relative to the project root.
|
|
3984
4683
|
*
|
|
3985
|
-
*
|
|
3986
|
-
*
|
|
4684
|
+
* At the root, beside the project's own `docker-compose.yml`, because the two
|
|
4685
|
+
* are one stack: this file is everything the constructs imply and is never
|
|
4686
|
+
* edited, that one is anything they cannot express — an image pin, an extra
|
|
4687
|
+
* service — and is merged over it, so it wins.
|
|
3987
4688
|
*/
|
|
3988
|
-
|
|
3989
|
-
const statements = [];
|
|
3990
|
-
for (const resource of plan.resources) {
|
|
3991
|
-
if (!resource.provisions || resource.container !== "postgres") continue;
|
|
3992
|
-
if (resource.kind === "database") {
|
|
3993
|
-
statements.push({
|
|
3994
|
-
id: resource.id,
|
|
3995
|
-
describe: `database ${resource.name}`,
|
|
3996
|
-
exists: {
|
|
3997
|
-
sql: "SELECT 1 FROM pg_database WHERE datname = $1",
|
|
3998
|
-
values: [resource.name]
|
|
3999
|
-
},
|
|
4000
|
-
create: `CREATE DATABASE ${quoteIdentifier(resource.name)}`
|
|
4001
|
-
});
|
|
4002
|
-
if (resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
|
|
4003
|
-
continue;
|
|
4004
|
-
}
|
|
4005
|
-
if (resource.kind === "database-schema" && resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
|
|
4006
|
-
const cacheHome = resource.kind === "cache" && resource.of ? plan.resources.find((r) => r.id === resource.of) : void 0;
|
|
4007
|
-
if (cacheHome) statements.push(...cacheTableDdl(resource, cacheHome, plan));
|
|
4008
|
-
}
|
|
4009
|
-
return statements;
|
|
4010
|
-
}
|
|
4689
|
+
const COMPOSE_PATH = "docker-compose.constructs.yml";
|
|
4011
4690
|
/**
|
|
4012
|
-
*
|
|
4013
|
-
*
|
|
4014
|
-
* Two things have to line up, and neither is the table's contents. The driver
|
|
4015
|
-
* names the table unqualified and lets the connection's `search_path` place it
|
|
4016
|
-
* — which the role carries — so creating it from the master connection, whose
|
|
4017
|
-
* path is `public`, leaves a table the application cannot see and a
|
|
4018
|
-
* `relation "cache" does not exist` on the first request. And the default
|
|
4019
|
-
* privileges that make a tenant's tables reachable are granted *for the owner
|
|
4020
|
-
* role*, so nothing covers a table the master created.
|
|
4691
|
+
* The local edge's config, beside the compose file that mounts it.
|
|
4021
4692
|
*
|
|
4022
|
-
*
|
|
4023
|
-
*
|
|
4024
|
-
|
|
4693
|
+
* Relative in the compose volume, so it resolves against `.gkm/` wherever the
|
|
4694
|
+
* project lives.
|
|
4695
|
+
*/
|
|
4696
|
+
const CADDYFILE_PATH = ".gkm/Caddyfile";
|
|
4697
|
+
/**
|
|
4698
|
+
* Where one stage's routes live, imported by the file above.
|
|
4025
4699
|
*
|
|
4026
|
-
*
|
|
4027
|
-
*
|
|
4028
|
-
*
|
|
4700
|
+
* Per stage because one edge serves every stage, the same way one Postgres
|
|
4701
|
+
* holds `orders` and `orders_test`. A single file would mean `gkm test`
|
|
4702
|
+
* deleting the routes `gkm dev` is serving.
|
|
4029
4703
|
*/
|
|
4030
|
-
|
|
4031
|
-
const database = rootDatabase(home, plan);
|
|
4032
|
-
const schema = home.roles === false ? void 0 : home.schema;
|
|
4033
|
-
const name = cache.table ?? cacheTable(cache.id);
|
|
4034
|
-
const table = schema ? `${schema}.${name}` : name;
|
|
4035
|
-
const created = cacheTableStatements({ table }).map((statement) => ({
|
|
4036
|
-
id: cache.id,
|
|
4037
|
-
describe: statement.describe,
|
|
4038
|
-
database,
|
|
4039
|
-
...statement.exists ? { exists: statement.exists } : {},
|
|
4040
|
-
create: statement.sql
|
|
4041
|
-
}));
|
|
4042
|
-
if (!schema) return created;
|
|
4043
|
-
const runtime = localRole(home);
|
|
4044
|
-
const owner = ownerRole(runtime);
|
|
4045
|
-
const qualified = table.split(".").map((part) => quoteIdentifier(part)).join(".");
|
|
4046
|
-
return [
|
|
4047
|
-
...created,
|
|
4048
|
-
{
|
|
4049
|
-
id: cache.id,
|
|
4050
|
-
describe: `cache table ${table} is owned by ${owner}`,
|
|
4051
|
-
database,
|
|
4052
|
-
create: `ALTER TABLE ${qualified} OWNER TO ${quoteIdentifier(owner)}`
|
|
4053
|
-
},
|
|
4054
|
-
{
|
|
4055
|
-
id: cache.id,
|
|
4056
|
-
describe: `${runtime} may read and write ${table}`,
|
|
4057
|
-
database,
|
|
4058
|
-
create: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${qualified} TO ${quoteIdentifier(runtime)}`
|
|
4059
|
-
}
|
|
4060
|
-
];
|
|
4061
|
-
}
|
|
4704
|
+
const caddySitesPath = (stage) => `.gkm/caddy-sites/${stage}.caddy`;
|
|
4062
4705
|
/**
|
|
4063
|
-
*
|
|
4706
|
+
* Where the local edge's root certificate is copied to.
|
|
4064
4707
|
*
|
|
4065
|
-
*
|
|
4066
|
-
*
|
|
4067
|
-
* is
|
|
4708
|
+
* Exported so a process can be pointed at it without installing anything:
|
|
4709
|
+
* `NODE_EXTRA_CA_CERTS` is the whole of the trust story for Node, and it needs
|
|
4710
|
+
* no sudo. A browser is the case that still wants the root in the system store,
|
|
4711
|
+
* which is a one-time `caddy trust` rather than something a reconcile should do
|
|
4712
|
+
* on a developer's behalf.
|
|
4068
4713
|
*/
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
const owner = ownerRole(runtime);
|
|
4073
|
-
const reader = plan.resources.some((r) => r.kind === "database-reader" && r.of === resource.id) ? readerRole(runtime) : void 0;
|
|
4074
|
-
return roleStatements({
|
|
4075
|
-
runtime,
|
|
4076
|
-
owner,
|
|
4077
|
-
...reader ? { reader } : {},
|
|
4078
|
-
schema,
|
|
4079
|
-
...resource.kind === "database" ? { database: rootDatabase(resource, plan) } : {},
|
|
4080
|
-
passwords: {
|
|
4081
|
-
runtime: localRolePassword(project, plan, runtime, seed),
|
|
4082
|
-
owner: localRolePassword(project, plan, owner, seed),
|
|
4083
|
-
...reader ? { reader: localRolePassword(project, plan, reader, seed) } : {}
|
|
4084
|
-
}
|
|
4085
|
-
}).map((statement) => ({
|
|
4086
|
-
id: resource.id,
|
|
4087
|
-
describe: statement.describe,
|
|
4088
|
-
database: rootDatabase(resource, plan),
|
|
4089
|
-
...statement.exists ? { exists: statement.exists } : {},
|
|
4090
|
-
create: statement.sql
|
|
4091
|
-
}));
|
|
4092
|
-
}
|
|
4093
|
-
/** The buckets a plan implies. */
|
|
4094
|
-
function bucketNames(plan) {
|
|
4095
|
-
return plan.resources.filter((r) => r.provisions && r.kind === "objects").map((r) => r.name);
|
|
4096
|
-
}
|
|
4714
|
+
const LOCAL_CA_PATH = ".gkm/caddy-root.crt";
|
|
4715
|
+
/** Where Caddy keeps the root of the CA it generated. */
|
|
4716
|
+
const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
|
|
4097
4717
|
/**
|
|
4098
|
-
*
|
|
4718
|
+
* Converge the local target on what the manifest declares.
|
|
4099
4719
|
*
|
|
4100
|
-
*
|
|
4101
|
-
*
|
|
4102
|
-
*
|
|
4720
|
+
* Cheap when there is nothing to do: the hash is compared before the compose
|
|
4721
|
+
* file is written or a container is touched, and a converged project pays one
|
|
4722
|
+
* hash and one health check.
|
|
4103
4723
|
*/
|
|
4104
|
-
function
|
|
4105
|
-
|
|
4724
|
+
async function reconcile(options) {
|
|
4725
|
+
const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient, carriers = carrierClient, logins = ensureLogins } = options;
|
|
4726
|
+
const { seed } = options.credentials;
|
|
4727
|
+
const plan = planFor(manifest, stage, provisionOrder(manifest), {
|
|
4728
|
+
localStage: options.localStage,
|
|
4729
|
+
events: options.events,
|
|
4730
|
+
cache: options.cache,
|
|
4731
|
+
extraContainers: options.extraContainers,
|
|
4732
|
+
...options.fakes ? { fakes: options.fakes } : {},
|
|
4733
|
+
...options.edge === void 0 ? {} : { edge: options.edge }
|
|
4734
|
+
});
|
|
4735
|
+
const composePath = join(root, COMPOSE_PATH);
|
|
4736
|
+
const observed = await observedPorts(docker, composePath, plan.containers, plan.fakes);
|
|
4737
|
+
const ports = await allocate(project, portKeys(plan.containers, plan.fakes), keptPorts(options.saved, observed), probe);
|
|
4738
|
+
const compose = composeFor(plan, {
|
|
4739
|
+
project,
|
|
4740
|
+
ports,
|
|
4741
|
+
credentials: options.credentials,
|
|
4742
|
+
...options.apps ? { apps: options.apps(plan.containers) } : {}
|
|
4743
|
+
});
|
|
4744
|
+
const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
|
|
4745
|
+
const hash = planHash(plan, compose, {
|
|
4746
|
+
caddyfile,
|
|
4747
|
+
postgres: postgresStatements(plan, project, seed).map((statement) => statement.create).join(";\n")
|
|
4748
|
+
});
|
|
4749
|
+
const services = serviceAddresses(plan.containers, ports, plan.fakes);
|
|
4750
|
+
const lan = plan.resources.some((r) => r.kind === "mobile-app") ? options.lanAddress === void 0 ? lanAddress() : options.lanAddress ?? void 0 : void 0;
|
|
4751
|
+
const envWith = (credentials) => {
|
|
4752
|
+
const env = envFor(plan, {
|
|
4753
|
+
ports,
|
|
4754
|
+
project,
|
|
4755
|
+
credentials,
|
|
4756
|
+
seed,
|
|
4757
|
+
...options.mailFrom ? { mailFrom: options.mailFrom } : {},
|
|
4758
|
+
...options.addresses ? { addresses: options.addresses } : {},
|
|
4759
|
+
...lan ? { lanAddress: lan } : {},
|
|
4760
|
+
...options.metroPorts ? { metroPorts: options.metroPorts } : {}
|
|
4761
|
+
});
|
|
4762
|
+
if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
|
|
4763
|
+
return env;
|
|
4764
|
+
};
|
|
4765
|
+
const result = {
|
|
4766
|
+
stage,
|
|
4767
|
+
plan,
|
|
4768
|
+
compose,
|
|
4769
|
+
ports,
|
|
4770
|
+
services,
|
|
4771
|
+
env: envWith(options.credentials),
|
|
4772
|
+
credentials: options.credentials,
|
|
4773
|
+
logins: [],
|
|
4774
|
+
provisioned: [],
|
|
4775
|
+
fakes: options.fakes ?? {},
|
|
4776
|
+
hash,
|
|
4777
|
+
changed: false
|
|
4778
|
+
};
|
|
4779
|
+
const recorded = await loadState(root, stage);
|
|
4780
|
+
const postgresPort = ports[primaryPortKey("postgres")];
|
|
4781
|
+
if (recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers)) && (!start || !provision || postgresPort === void 0 || await databasesExist(sql(postgresPort, options.credentials.postgres), postgresDatabaseNames(plan)))) return result;
|
|
4782
|
+
await write(composePath, toYaml(compose), 384);
|
|
4783
|
+
if (plan.containers.includes("caddy")) {
|
|
4784
|
+
await write(join(root, CADDYFILE_PATH), caddyfileRoot());
|
|
4785
|
+
await write(join(root, caddySitesPath(stage)), caddyfile);
|
|
4786
|
+
await pruneCaddySites(root, [
|
|
4787
|
+
stage,
|
|
4788
|
+
options.localStage,
|
|
4789
|
+
TEST_STAGE
|
|
4790
|
+
]);
|
|
4791
|
+
}
|
|
4792
|
+
if (start && plan.containers.length > 0) {
|
|
4793
|
+
options.progress?.(`🐳 Starting ${plan.containers.join(", ")} — the first start pulls each image, which can take a few minutes`);
|
|
4794
|
+
await docker.up(composePath, plan.containers, options.progress ? { show: true } : {});
|
|
4795
|
+
}
|
|
4796
|
+
if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
|
|
4797
|
+
if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
|
|
4798
|
+
const checked = start && plan.containers.length > 0 ? await logins({
|
|
4799
|
+
credentials: options.credentials,
|
|
4800
|
+
containers: plan.containers,
|
|
4801
|
+
ports: {
|
|
4802
|
+
...postgresPort !== void 0 ? { postgres: postgresPort } : {},
|
|
4803
|
+
...ports[primaryPortKey("minio")] !== void 0 ? { minio: ports[primaryPortKey("minio")] } : {}
|
|
4804
|
+
},
|
|
4805
|
+
...docker.exec ? { exec: (service, argv) => docker.exec(composePath, service, argv) } : {}
|
|
4806
|
+
}) : {
|
|
4807
|
+
credentials: options.credentials,
|
|
4808
|
+
outcomes: []
|
|
4809
|
+
};
|
|
4810
|
+
const credentials = checked.credentials;
|
|
4811
|
+
if (start && provision) options.progress?.("🗄️ Creating what the constructs declare — databases, roles, buckets, topics");
|
|
4812
|
+
const provisioned = start && provision ? await create(plan, ports, credentials, sql, buckets, carriers, project) : [];
|
|
4813
|
+
await saveState(root, {
|
|
4814
|
+
hash,
|
|
4815
|
+
stage
|
|
4816
|
+
});
|
|
4817
|
+
return {
|
|
4818
|
+
...result,
|
|
4819
|
+
...credentials === options.credentials ? {} : { env: envWith(credentials) },
|
|
4820
|
+
credentials,
|
|
4821
|
+
logins: checked.outcomes,
|
|
4822
|
+
provisioned,
|
|
4823
|
+
changed: true
|
|
4824
|
+
};
|
|
4106
4825
|
}
|
|
4107
4826
|
/**
|
|
4108
|
-
*
|
|
4827
|
+
* Create the databases, schemas, buckets, topics and queues the plan names.
|
|
4109
4828
|
*
|
|
4110
|
-
*
|
|
4111
|
-
*
|
|
4829
|
+
* Idempotent throughout, so this runs on every non-converged reconcile rather
|
|
4830
|
+
* than being something to remember.
|
|
4112
4831
|
*/
|
|
4113
|
-
async function
|
|
4832
|
+
async function create(plan, ports, credentials, sql, buckets, emulatorCarriers, project) {
|
|
4114
4833
|
const applied = [];
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
|
|
4126
|
-
applied.push({
|
|
4127
|
-
id: statement.id,
|
|
4128
|
-
describe: statement.describe,
|
|
4129
|
-
created: false
|
|
4130
|
-
});
|
|
4131
|
-
continue;
|
|
4132
|
-
}
|
|
4133
|
-
await client.query(statement.database, statement.create);
|
|
4134
|
-
applied.push({
|
|
4135
|
-
id: statement.id,
|
|
4136
|
-
describe: statement.describe,
|
|
4137
|
-
created: true
|
|
4138
|
-
});
|
|
4834
|
+
const postgresPort = ports[primaryPortKey("postgres")];
|
|
4835
|
+
const statements = postgresStatements(plan, project, credentials.seed);
|
|
4836
|
+
if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort, credentials.postgres), statements));
|
|
4837
|
+
const minioPort = ports[primaryPortKey("minio")];
|
|
4838
|
+
const names = bucketNames(plan);
|
|
4839
|
+
const policies = bucketPolicies(plan);
|
|
4840
|
+
if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
|
|
4841
|
+
const client = buckets(minioPort, credentials.minio);
|
|
4842
|
+
if (names.length > 0) applied.push(...await applyBuckets(client, names));
|
|
4843
|
+
if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
|
|
4139
4844
|
}
|
|
4845
|
+
const emulatorPort = ports[primaryPortKey("localstack")];
|
|
4846
|
+
const carriers = carrierNames(plan);
|
|
4847
|
+
if (emulatorPort !== void 0 && carriers.topics.length + carriers.queues.length > 0) applied.push(...await applyCarriers(emulatorCarriers(emulatorPort, credentials.emulator), carriers));
|
|
4140
4848
|
return applied;
|
|
4141
4849
|
}
|
|
4142
4850
|
/**
|
|
4143
|
-
*
|
|
4851
|
+
* The host ports of containers already running.
|
|
4144
4852
|
*
|
|
4145
|
-
* Keyed
|
|
4146
|
-
*
|
|
4147
|
-
* than the last one winning.
|
|
4853
|
+
* Keyed the same way allocation keys them, so the two merge without either
|
|
4854
|
+
* knowing about the other.
|
|
4148
4855
|
*/
|
|
4149
|
-
function
|
|
4150
|
-
const
|
|
4151
|
-
for (const
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
if (!origin) continue;
|
|
4155
|
-
const patterns = byBucket.get(origin.name) ?? /* @__PURE__ */ new Set();
|
|
4156
|
-
for (const pattern of resource.open) patterns.add(pattern);
|
|
4157
|
-
byBucket.set(origin.name, patterns);
|
|
4856
|
+
async function observedPorts(docker, composePath, containers, fakes) {
|
|
4857
|
+
const observed = {};
|
|
4858
|
+
for (const container of containers) for (const port of portsOf(container, fakes)) {
|
|
4859
|
+
const running = await docker.publishedPort(composePath, container, port.inside);
|
|
4860
|
+
if (running !== void 0) observed[port.key] = running;
|
|
4158
4861
|
}
|
|
4159
|
-
return
|
|
4160
|
-
bucket,
|
|
4161
|
-
open: [...open].sort()
|
|
4162
|
-
})).sort((a, b) => a.bucket.localeCompare(b.bucket));
|
|
4862
|
+
return observed;
|
|
4163
4863
|
}
|
|
4864
|
+
/** Write a file only when its content would change, so mtimes stay meaningful. */
|
|
4164
4865
|
/**
|
|
4165
|
-
*
|
|
4166
|
-
*
|
|
4167
|
-
* Anonymous `s3:GetObject` on the named prefixes and nothing else — which is
|
|
4168
|
-
* what "open" means everywhere: the server serves that path without a
|
|
4169
|
-
* signature, and the bucket stays private for everything else.
|
|
4866
|
+
* Remove the routes of stages this project no longer reconciles.
|
|
4170
4867
|
*
|
|
4171
|
-
*
|
|
4172
|
-
*
|
|
4173
|
-
*
|
|
4174
|
-
*
|
|
4175
|
-
*
|
|
4176
|
-
* client, can be admitted by the policy where the client would have said no.
|
|
4177
|
-
* Prefer `**` where crossing segments is what you meant.
|
|
4868
|
+
* Only the local stage and `test` are ever written, so any other file is left
|
|
4869
|
+
* over from a stage that was renamed — `development` becoming `dev`. Caddy
|
|
4870
|
+
* imports every file, and the leftover declares the same hosts as its
|
|
4871
|
+
* replacement: it refuses the whole config as ambiguous, and every URL behind
|
|
4872
|
+
* the edge goes with it.
|
|
4178
4873
|
*/
|
|
4179
|
-
function
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4874
|
+
async function pruneCaddySites(root, stages) {
|
|
4875
|
+
const dir = dirname(join(root, caddySitesPath(TEST_STAGE)));
|
|
4876
|
+
const keep = new Set(stages.map((stage) => stage && `${stage}.caddy`));
|
|
4877
|
+
for (const file of await readdir(dir)) if (file.endsWith(".caddy") && !keep.has(file)) await rm(join(dir, file));
|
|
4878
|
+
}
|
|
4879
|
+
async function write(path, content, mode) {
|
|
4880
|
+
try {
|
|
4881
|
+
if (await readFile(path, "utf-8") === content) {
|
|
4882
|
+
if (mode !== void 0) await chmod(path, mode);
|
|
4883
|
+
return;
|
|
4884
|
+
}
|
|
4885
|
+
} catch {}
|
|
4886
|
+
await mkdir(dirname(path), { recursive: true });
|
|
4887
|
+
await writeFile(path, content, mode === void 0 ? {} : { mode });
|
|
4888
|
+
if (mode !== void 0) await chmod(path, mode);
|
|
4190
4889
|
}
|
|
4890
|
+
//#endregion
|
|
4891
|
+
//#region src/deploy/otel.ts
|
|
4191
4892
|
/**
|
|
4192
|
-
* The
|
|
4893
|
+
* The OpenTelemetry variables a stage's secrets pass to every backend.
|
|
4193
4894
|
*
|
|
4194
|
-
*
|
|
4195
|
-
*
|
|
4196
|
-
*
|
|
4895
|
+
* A production server starts OpenTelemetry when `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
4896
|
+
* is set (`generators/telemetry.ts`), and the SDK reads the rest of its
|
|
4897
|
+
* configuration from the standard `OTEL_*` variables. No construct declares
|
|
4898
|
+
* them — they say where the telemetry goes, not what the app is — so a target
|
|
4899
|
+
* that writes each backend only the keys it reads would never hand them over.
|
|
4900
|
+
* Every target that runs backends asks this module instead.
|
|
4901
|
+
*
|
|
4902
|
+
* Which keys pass is a pattern over the `OTEL_` prefix rather than the prefix
|
|
4903
|
+
* alone or a list:
|
|
4904
|
+
*
|
|
4905
|
+
* - The bare prefix would forward anything a stage happened to name `OTEL_*`
|
|
4906
|
+
* — `OTEL_LOG_LEVEL=debug`, `OTEL_SDK_DISABLED` — to every backend, without
|
|
4907
|
+
* anyone choosing to.
|
|
4908
|
+
* - A list would have to spell out the exporter's five settings once for all
|
|
4909
|
+
* signals and once per signal — twenty keys — and would miss the next one.
|
|
4910
|
+
*
|
|
4911
|
+
* The pattern names the families the server uses: the OTLP exporter's
|
|
4912
|
+
* endpoint, headers, protocol, timeout and compression, for every signal or
|
|
4913
|
+
* for one (`OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`); sampling; and the resource's
|
|
4914
|
+
* identity.
|
|
4915
|
+
*
|
|
4916
|
+
* Sites get none of it: a site's environment is inlined into a bundle that
|
|
4917
|
+
* every browser downloads, and an exporter's headers are a credential.
|
|
4197
4918
|
*/
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
const named = (kind) => plan.resources.filter((r) => r.kind === kind).map((r) => r.name);
|
|
4204
|
-
return {
|
|
4205
|
-
topics: named("topic"),
|
|
4206
|
-
queues: named("queue")
|
|
4207
|
-
};
|
|
4208
|
-
}
|
|
4209
|
-
/** Create each topic and queue the plan names, skipping those that exist. */
|
|
4210
|
-
async function applyCarriers(client, carriers) {
|
|
4211
|
-
const applied = [];
|
|
4212
|
-
for (const name of carriers.topics) {
|
|
4213
|
-
const created = !await client.topicExists(name);
|
|
4214
|
-
if (created) await client.createTopic(name);
|
|
4215
|
-
applied.push({
|
|
4216
|
-
id: name,
|
|
4217
|
-
describe: `topic ${name}`,
|
|
4218
|
-
created
|
|
4219
|
-
});
|
|
4220
|
-
}
|
|
4221
|
-
for (const name of carriers.queues) {
|
|
4222
|
-
const created = !await client.queueExists(name);
|
|
4223
|
-
if (created) await client.createQueue(name);
|
|
4224
|
-
applied.push({
|
|
4225
|
-
id: name,
|
|
4226
|
-
describe: `queue ${name}`,
|
|
4227
|
-
created
|
|
4228
|
-
});
|
|
4229
|
-
}
|
|
4230
|
-
return applied;
|
|
4919
|
+
/** The `OTEL_*` variables a backend is handed from the stage's secrets. */
|
|
4920
|
+
const OTEL_PASSTHROUGH = /^OTEL_(?:EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS|PROTOCOL|TIMEOUT|COMPRESSION)|TRACES_SAMPLER(?:_ARG)?|RESOURCE_ATTRIBUTES|SERVICE_NAME)$/;
|
|
4921
|
+
/** Whether a stage secret is one of the passed-through `OTEL_*` variables. */
|
|
4922
|
+
function isOtelVariable(key) {
|
|
4923
|
+
return OTEL_PASSTHROUGH.test(key);
|
|
4231
4924
|
}
|
|
4232
|
-
/**
|
|
4233
|
-
function
|
|
4234
|
-
return
|
|
4925
|
+
/** Whether a variable says where an OTLP exporter sends, or how it signs. */
|
|
4926
|
+
function isOtlpDestination(key) {
|
|
4927
|
+
return /^OTEL_EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS)$/.test(key);
|
|
4235
4928
|
}
|
|
4236
4929
|
/**
|
|
4237
|
-
*
|
|
4238
|
-
*
|
|
4239
|
-
*
|
|
4240
|
-
* Unreachable counts as missing: applying again is what finds out why.
|
|
4930
|
+
* The `OTEL_*` variables one backend runs with: every one the stage's secrets
|
|
4931
|
+
* hold, and `OTEL_SERVICE_NAME` — the app's name — where the stage set
|
|
4932
|
+
* telemetry up without naming the service. Empty when the stage set none.
|
|
4241
4933
|
*/
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
} catch {
|
|
4248
|
-
return false;
|
|
4249
|
-
}
|
|
4934
|
+
function otelEnv(custom, app) {
|
|
4935
|
+
const env = {};
|
|
4936
|
+
for (const [key, value] of Object.entries(custom)) if (isOtelVariable(key) && value !== "") env[key] = value;
|
|
4937
|
+
if (Object.keys(env).length > 0 && !env.OTEL_SERVICE_NAME) env.OTEL_SERVICE_NAME = app;
|
|
4938
|
+
return env;
|
|
4250
4939
|
}
|
|
4940
|
+
//#endregion
|
|
4941
|
+
//#region src/target/dokploy/domain.ts
|
|
4942
|
+
/** A stage was deployed that `deploy.domains` gives no domain. */
|
|
4943
|
+
var NoDomainForStage = class extends Error {
|
|
4944
|
+
stage;
|
|
4945
|
+
constructor(stage) {
|
|
4946
|
+
super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
|
|
4947
|
+
this.stage = stage;
|
|
4948
|
+
this.name = "NoDomainForStage";
|
|
4949
|
+
}
|
|
4950
|
+
};
|
|
4251
4951
|
/**
|
|
4252
|
-
*
|
|
4952
|
+
* The hostname an app answers on for a stage.
|
|
4253
4953
|
*
|
|
4254
|
-
*
|
|
4255
|
-
*
|
|
4256
|
-
*
|
|
4954
|
+
* 1. An explicit `app.domain` — a string, or one per stage — wins.
|
|
4955
|
+
* 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
|
|
4956
|
+
* answers on the domain itself, and every other app on
|
|
4957
|
+
* `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
|
|
4958
|
+
*
|
|
4959
|
+
* @throws {NoDomainForStage} when `deploy.domains` names no domain for it
|
|
4257
4960
|
*/
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
const describe = `open paths on ${bucket}`;
|
|
4263
|
-
if (equivalent(await client.policy(bucket), wanted)) {
|
|
4264
|
-
applied.push({
|
|
4265
|
-
id: `${bucket}:policy`,
|
|
4266
|
-
describe,
|
|
4267
|
-
created: false
|
|
4268
|
-
});
|
|
4269
|
-
continue;
|
|
4270
|
-
}
|
|
4271
|
-
await client.setPolicy(bucket, wanted);
|
|
4272
|
-
applied.push({
|
|
4273
|
-
id: `${bucket}:policy`,
|
|
4274
|
-
describe,
|
|
4275
|
-
created: true
|
|
4276
|
-
});
|
|
4961
|
+
function resolveHost(appName, app, stage, domains, isMainFrontend) {
|
|
4962
|
+
if (app.domain) {
|
|
4963
|
+
if (typeof app.domain === "string") return app.domain;
|
|
4964
|
+
if (app.domain[stage]) return app.domain[stage];
|
|
4277
4965
|
}
|
|
4278
|
-
|
|
4966
|
+
const baseDomain = domains?.[stage];
|
|
4967
|
+
if (!baseDomain) throw new NoDomainForStage(stage);
|
|
4968
|
+
if (isMainFrontend) return baseDomain;
|
|
4969
|
+
return `${app.subdomain ?? appName}.${baseDomain}`;
|
|
4279
4970
|
}
|
|
4280
|
-
/**
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4971
|
+
/**
|
|
4972
|
+
* Whether this app is the site the base domain points at — see `rootSite`,
|
|
4973
|
+
* which the local edge asks too.
|
|
4974
|
+
*
|
|
4975
|
+
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
4976
|
+
*/
|
|
4977
|
+
function isMainFrontendApp(appName, app, allApps) {
|
|
4978
|
+
if (app.type !== "web") return false;
|
|
4979
|
+
return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
|
|
4980
|
+
name,
|
|
4981
|
+
root: a.root
|
|
4982
|
+
}))) === appName;
|
|
4288
4983
|
}
|
|
4289
|
-
/**
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
}
|
|
4301
|
-
await client.create(bucket);
|
|
4302
|
-
applied.push({
|
|
4303
|
-
id: bucket,
|
|
4304
|
-
describe: `bucket ${bucket}`,
|
|
4305
|
-
created: true
|
|
4306
|
-
});
|
|
4307
|
-
}
|
|
4308
|
-
return applied;
|
|
4984
|
+
/**
|
|
4985
|
+
* Get public URL arg names from app dependencies.
|
|
4986
|
+
*
|
|
4987
|
+
* @param app - The web/mobile app configuration
|
|
4988
|
+
* @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
|
|
4989
|
+
* without a public prefix (e.g. Remix).
|
|
4990
|
+
*/
|
|
4991
|
+
function getPublicUrlArgNames(app) {
|
|
4992
|
+
const prefix = getPublicEnvPrefix(app.framework);
|
|
4993
|
+
if (!prefix) return [];
|
|
4994
|
+
return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
|
|
4309
4995
|
}
|
|
4310
4996
|
//#endregion
|
|
4311
|
-
//#region src/
|
|
4997
|
+
//#region src/compose/logs.ts
|
|
4312
4998
|
/**
|
|
4313
|
-
*
|
|
4999
|
+
* The stack's log UI: OpenObserve, run beside the apps when
|
|
5000
|
+
* `deploy.compose.logs` asks for it, and every backend's OpenTelemetry
|
|
5001
|
+
* pointed at it.
|
|
4314
5002
|
*
|
|
4315
|
-
*
|
|
4316
|
-
*
|
|
4317
|
-
*
|
|
4318
|
-
*
|
|
5003
|
+
* The production server already exports its traces and pino logs over
|
|
5004
|
+
* OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. OpenObserve takes OTLP
|
|
5005
|
+
* at `/api/<org>/v1/{logs,traces}` with HTTP basic auth, so wiring an app is
|
|
5006
|
+
* two variables: the endpoint, `http://openobserve:5080/api/default`, and
|
|
5007
|
+
* the header that signs in as the root user.
|
|
4319
5008
|
*
|
|
4320
|
-
*
|
|
4321
|
-
*
|
|
4322
|
-
*
|
|
5009
|
+
* It is reached, by default, through an SSH tunnel: published on 127.0.0.1
|
|
5010
|
+
* only, because a port Docker publishes on every interface is opened in the
|
|
5011
|
+
* firewall by Docker itself, past ufw's rules. Served publicly, it is a host
|
|
5012
|
+
* on the stack's Caddy that refuses every address not allowed.
|
|
5013
|
+
*
|
|
5014
|
+
* Pure: the stack decides it, and the phases print and check it.
|
|
4323
5015
|
*/
|
|
4324
|
-
const STATE_DIR = ".gkm/reconcile";
|
|
4325
5016
|
/**
|
|
4326
|
-
* The
|
|
4327
|
-
*
|
|
4328
|
-
* Covers the compose document rather than the plan alone, because the plan is
|
|
4329
|
-
* unchanged when only an image pin moves — and a changed image is exactly a
|
|
4330
|
-
* container that must be recreated. `stringify` is stable here because
|
|
4331
|
-
* `composeFor` sorts its containers, which is what makes the hash meaningful.
|
|
4332
|
-
*
|
|
4333
|
-
* Deliberately a plain function over serialisable input: the deployed target
|
|
4334
|
-
* keys its provisioner invocation on the same idea, and the two must not be able
|
|
4335
|
-
* to disagree about what counts as a change.
|
|
5017
|
+
* The image, pinned. A log store's on-disk format is the thing that must not
|
|
5018
|
+
* move under a running stack, so a new version is a change to this line.
|
|
4336
5019
|
*/
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
/**
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
5020
|
+
const OPENOBSERVE_IMAGE = "public.ecr.aws/zinclabs/openobserve:v1.0.4";
|
|
5021
|
+
/** Its compose service name — the host the apps send to. */
|
|
5022
|
+
const LOGS_SERVICE = "openobserve";
|
|
5023
|
+
/** The organisation every app's telemetry lands in. */
|
|
5024
|
+
const LOGS_ORG = "default";
|
|
5025
|
+
/** The stage secrets that sign in as OpenObserve's root user. */
|
|
5026
|
+
const LOGS_EMAIL_KEY = "ZO_ROOT_USER_EMAIL";
|
|
5027
|
+
const LOGS_PASSWORD_KEY = "ZO_ROOT_USER_PASSWORD";
|
|
5028
|
+
/**
|
|
5029
|
+
* The local stage's root user. Its password is generated per machine with
|
|
5030
|
+
* the other local logins — see `reconcile/localCredentials.ts`.
|
|
5031
|
+
*/
|
|
5032
|
+
const LOCAL_LOGS_EMAIL = "admin@gkm.localhost";
|
|
5033
|
+
/** The stage sends its telemetry somewhere already, and logs are on too. */
|
|
5034
|
+
var LogsEndpointConflict = class extends Error {
|
|
5035
|
+
stage;
|
|
5036
|
+
keys;
|
|
5037
|
+
constructor(stage, keys) {
|
|
5038
|
+
super(`The stage '${stage}' sets ${keys.join(", ")} in its secrets, and deploy.compose.logs also runs OpenObserve for it — each backend can send its telemetry to one place. Either keep your own OTLP backend: remove logs from deploy.compose in gkm.config.ts; or use the bundled OpenObserve: remove ${keys.join(", ")} from the stage's secrets, and every backend is pointed at it.`);
|
|
5039
|
+
this.stage = stage;
|
|
5040
|
+
this.keys = keys;
|
|
5041
|
+
this.name = "LogsEndpointConflict";
|
|
5042
|
+
}
|
|
5043
|
+
};
|
|
5044
|
+
/** A root password OpenObserve would refuse to start with. */
|
|
5045
|
+
var LogsPasswordWeak = class extends Error {
|
|
5046
|
+
stage;
|
|
5047
|
+
constructor(stage) {
|
|
5048
|
+
super(`The stage '${stage}' sets ${LOGS_PASSWORD_KEY}, and OpenObserve refuses it: a root password must be 8 to 128 characters with a lowercase letter, an uppercase letter, a digit and a symbol. Set another with gkm secrets:set ${LOGS_PASSWORD_KEY} '…' --stage ${stage}, or unset it to have one generated.`);
|
|
5049
|
+
this.stage = stage;
|
|
5050
|
+
this.name = "LogsPasswordWeak";
|
|
5051
|
+
}
|
|
5052
|
+
};
|
|
5053
|
+
/** A deployed stage with logs on and no root password generated yet. */
|
|
5054
|
+
var LogsPasswordMissing = class extends Error {
|
|
5055
|
+
stage;
|
|
5056
|
+
constructor(stage) {
|
|
5057
|
+
super(`The stage '${stage}' runs OpenObserve and has no ${LOGS_PASSWORD_KEY} in its secrets. Run gkm compose --stage ${stage} without --dry-run once to generate it, or set one: gkm secrets:set ${LOGS_PASSWORD_KEY} '…' --stage ${stage}`);
|
|
5058
|
+
this.stage = stage;
|
|
5059
|
+
this.name = "LogsPasswordMissing";
|
|
4353
5060
|
}
|
|
5061
|
+
};
|
|
5062
|
+
/** Whether OpenObserve accepts `password` for its root user. */
|
|
5063
|
+
function isStrongLogsPassword(password) {
|
|
5064
|
+
return password.length >= 8 && password.length <= 128 && /[a-z]/.test(password) && /[A-Z]/.test(password) && /\d/.test(password) && /[^A-Za-z0-9]/.test(password);
|
|
4354
5065
|
}
|
|
4355
|
-
/**
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
5066
|
+
/**
|
|
5067
|
+
* A root password: 192 random bits, with a fixed tail so every class
|
|
5068
|
+
* OpenObserve requires is present whatever the random part drew.
|
|
5069
|
+
*/
|
|
5070
|
+
function generateLogsPassword() {
|
|
5071
|
+
return `${randomBytes(24).toString("base64url")}-Zo1`;
|
|
4360
5072
|
}
|
|
4361
5073
|
/**
|
|
4362
|
-
*
|
|
4363
|
-
*
|
|
5074
|
+
* The stage's secrets with a generated root password, where it has none.
|
|
5075
|
+
* Pure, like `withGeneratedSecrets`: the caller keeps the result once the
|
|
5076
|
+
* run goes ahead.
|
|
4364
5077
|
*/
|
|
4365
|
-
|
|
4366
|
-
|
|
5078
|
+
function withLogsPassword(secrets) {
|
|
5079
|
+
if (secrets.custom?.["ZO_ROOT_USER_PASSWORD"]) return {
|
|
5080
|
+
secrets,
|
|
5081
|
+
generated: []
|
|
5082
|
+
};
|
|
5083
|
+
return {
|
|
5084
|
+
secrets: {
|
|
5085
|
+
...secrets,
|
|
5086
|
+
custom: {
|
|
5087
|
+
...secrets.custom,
|
|
5088
|
+
[LOGS_PASSWORD_KEY]: generateLogsPassword()
|
|
5089
|
+
},
|
|
5090
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5091
|
+
},
|
|
5092
|
+
generated: [LOGS_PASSWORD_KEY]
|
|
5093
|
+
};
|
|
4367
5094
|
}
|
|
4368
|
-
|
|
4369
|
-
|
|
5095
|
+
/**
|
|
5096
|
+
* `OTEL_EXPORTER_OTLP_HEADERS` signing in as `email`.
|
|
5097
|
+
*
|
|
5098
|
+
* The variable's values are percent-decoded by the SDK (they follow the W3C
|
|
5099
|
+
* baggage format), so the space after `Basic` is written `%20`. Base64's own
|
|
5100
|
+
* `+`, `/` and `=` pass through decoding unchanged.
|
|
5101
|
+
*/
|
|
5102
|
+
function otlpHeaders(email, password) {
|
|
5103
|
+
return `Authorization=Basic%20${Buffer.from(`${email}:${password}`).toString("base64")}`;
|
|
5104
|
+
}
|
|
5105
|
+
/** The stack's OpenObserve for one stage. */
|
|
5106
|
+
function stackLogs(options) {
|
|
5107
|
+
const { config, stage, local, custom } = options;
|
|
5108
|
+
const conflicting = Object.keys(custom).filter(isOtlpDestination).sort();
|
|
5109
|
+
if (conflicting.length > 0) throw new LogsEndpointConflict(stage, conflicting);
|
|
5110
|
+
if (!local && !options.domain) throw new NoDomainForStage(stage);
|
|
5111
|
+
const email = custom["ZO_ROOT_USER_EMAIL"] ?? (local ? options.localLogin?.email ?? "admin@gkm.localhost" : `admin@${options.domain}`);
|
|
5112
|
+
const set = custom[LOGS_PASSWORD_KEY];
|
|
5113
|
+
const password = set ?? (local ? options.localLogin?.password : void 0);
|
|
5114
|
+
if (!password) throw new LogsPasswordMissing(stage);
|
|
5115
|
+
if (!isStrongLogsPassword(password)) throw new LogsPasswordWeak(stage);
|
|
5116
|
+
const host = config.public ? local ? `logs.${options.project}.localhost` : `logs.${options.domain}` : void 0;
|
|
5117
|
+
const url = host ? `https://${host}${local && options.https !== 443 ? `:${options.https}` : ""}` : `http://localhost:${config.port}`;
|
|
5118
|
+
return {
|
|
5119
|
+
...config,
|
|
5120
|
+
email,
|
|
5121
|
+
password,
|
|
5122
|
+
passwordFromSecrets: Boolean(set),
|
|
5123
|
+
url,
|
|
5124
|
+
...host ? { host } : {},
|
|
5125
|
+
env: {
|
|
5126
|
+
[LOGS_EMAIL_KEY]: email,
|
|
5127
|
+
[LOGS_PASSWORD_KEY]: password,
|
|
5128
|
+
ZO_DATA_DIR: "/data",
|
|
5129
|
+
ZO_COMPACT_DATA_RETENTION_DAYS: String(config.retentionDays),
|
|
5130
|
+
ZO_TELEMETRY: "false"
|
|
5131
|
+
},
|
|
5132
|
+
appEnv: {
|
|
5133
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: `http://${LOGS_SERVICE}:${LOGS_PORT}/api/${LOGS_ORG}`,
|
|
5134
|
+
OTEL_EXPORTER_OTLP_HEADERS: otlpHeaders(email, password)
|
|
5135
|
+
}
|
|
5136
|
+
};
|
|
5137
|
+
}
|
|
5138
|
+
/**
|
|
5139
|
+
* OpenObserve's service. The image has no shell, `wget` or `curl`, so its
|
|
5140
|
+
* health check is its own binary asking the running server for its status —
|
|
5141
|
+
* which exits non-zero when nothing answers on its port.
|
|
5142
|
+
*/
|
|
5143
|
+
function logsService(logs) {
|
|
5144
|
+
return {
|
|
5145
|
+
image: OPENOBSERVE_IMAGE,
|
|
5146
|
+
restart: "unless-stopped",
|
|
5147
|
+
env_file: [{
|
|
5148
|
+
path: `./${LOGS_SERVICE}.env`,
|
|
5149
|
+
format: "raw"
|
|
5150
|
+
}],
|
|
5151
|
+
volumes: ["openobserve-data:/data"],
|
|
5152
|
+
...logs.public ? {} : { ports: [`127.0.0.1:${logs.port}:${LOGS_PORT}`] },
|
|
5153
|
+
healthcheck: {
|
|
5154
|
+
test: [
|
|
5155
|
+
"CMD",
|
|
5156
|
+
"/openobserve",
|
|
5157
|
+
"node",
|
|
5158
|
+
"status"
|
|
5159
|
+
],
|
|
5160
|
+
interval: "10s",
|
|
5161
|
+
timeout: "5s",
|
|
5162
|
+
retries: 12,
|
|
5163
|
+
start_period: "20s"
|
|
5164
|
+
}
|
|
5165
|
+
};
|
|
5166
|
+
}
|
|
5167
|
+
/** What `gkm compose` prints once the stack is up: how to open the logs. */
|
|
5168
|
+
function logsAccess(logs, ctx) {
|
|
5169
|
+
const login = `login: ${logs.email}, password: ${ctx.local && !logs.passwordFromSecrets ? logs.password : `gkm secrets:show --stage ${ctx.stage} --reveal → ${LOGS_PASSWORD_KEY}`}`;
|
|
5170
|
+
if (logs.public) return [`📜 Logs (OpenObserve): ${logs.url} (${login})`, ` answered only for ${logs.public.allow.join(", ")}; every other address gets 403`];
|
|
5171
|
+
return [
|
|
5172
|
+
`📜 Logs (OpenObserve) on 127.0.0.1:${logs.port} — from your computer:`,
|
|
5173
|
+
...ctx.local ? [` on this machine, open http://localhost:${logs.port} directly; from another:`] : [],
|
|
5174
|
+
` ssh -N -L ${logs.port}:localhost:${logs.port} ${ctx.user}@${ctx.hostname} (user and host are guesses: this machine's)`,
|
|
5175
|
+
` then open http://localhost:${logs.port} (${login})`,
|
|
5176
|
+
`⚠️ Docker-published ports bypass ufw, so OpenObserve is bound to 127.0.0.1 only — reach it through the tunnel, not by opening the port.`
|
|
5177
|
+
];
|
|
4370
5178
|
}
|
|
4371
5179
|
//#endregion
|
|
4372
|
-
//#region src/
|
|
5180
|
+
//#region src/compose/redis.ts
|
|
5181
|
+
/**
|
|
5182
|
+
* The stack's Redis: what every declared cache lives in when a workspace runs
|
|
5183
|
+
* as a `gkm compose` stack, on the local stage and on a deployed one alike.
|
|
5184
|
+
*
|
|
5185
|
+
* A cache is the one store whose contents can always be rebuilt, so unlike
|
|
5186
|
+
* Mailpit and MinIO it is a production service, not a dev stand-in: it runs
|
|
5187
|
+
* for every stage, on the compose network only, with nothing published. It
|
|
5188
|
+
* is bounded (`maxmemory`, evicting the least recently used key), persisted
|
|
5189
|
+
* (an append-only file on a named volume, kept by `--down`) and password
|
|
5190
|
+
* protected — the stage's own password, generated once like its seed.
|
|
5191
|
+
*
|
|
5192
|
+
* A stage that sets a cache's URL in its secrets — a managed Redis — keeps
|
|
5193
|
+
* it, and a stack whose every cache is set that way runs no Redis at all.
|
|
5194
|
+
*
|
|
5195
|
+
* Pure: the stack decides it, and the phases keep the password.
|
|
5196
|
+
*/
|
|
5197
|
+
/**
|
|
5198
|
+
* Where a stack's caches live: its own Redis, whatever the deploy target's
|
|
5199
|
+
* default is. A stack is one machine running containers, and a Redis beside
|
|
5200
|
+
* the apps is cheaper for a cache than a table in the database they share.
|
|
5201
|
+
*/
|
|
5202
|
+
const STACK_CACHE = "redis";
|
|
5203
|
+
/** Its compose service name — the host every cache URL points at. */
|
|
5204
|
+
const REDIS_SERVICE = "redis";
|
|
5205
|
+
/** The port it answers on, inside the network. */
|
|
5206
|
+
const REDIS_PORT = 6379;
|
|
5207
|
+
/** Reconcile's pin, so `gkm dev` and the stack run one Redis. */
|
|
5208
|
+
const REDIS_IMAGE = DEFAULT_IMAGES.redis;
|
|
5209
|
+
/**
|
|
5210
|
+
* How much it may hold before it evicts. A cache that grows until the box
|
|
5211
|
+
* runs out of memory takes the apps with it; 256 MB is a lot of sessions and
|
|
5212
|
+
* little of a small server. A project's `docker-compose.<stage>.yml` can
|
|
5213
|
+
* replace the command to change it.
|
|
5214
|
+
*/
|
|
5215
|
+
const REDIS_MAXMEMORY = "256mb";
|
|
5216
|
+
/** The stage secret holding its password. */
|
|
5217
|
+
const REDIS_PASSWORD_KEY = "REDIS_PASSWORD";
|
|
5218
|
+
/** Redis's own count of logical databases, before `--databases` raises it. */
|
|
5219
|
+
const REDIS_DATABASES = 16;
|
|
5220
|
+
/** A deployed stage that runs the stack's Redis and has no password yet. */
|
|
5221
|
+
var RedisPasswordMissing = class extends Error {
|
|
5222
|
+
stage;
|
|
5223
|
+
constructor(stage) {
|
|
5224
|
+
super(`The stage '${stage}' runs the stack's Redis and has no ${REDIS_PASSWORD_KEY} in its secrets. Run gkm compose --stage ${stage} without --dry-run once to generate it, or set one: gkm secrets:set ${REDIS_PASSWORD_KEY} '…' --stage ${stage}`);
|
|
5225
|
+
this.stage = stage;
|
|
5226
|
+
this.name = "RedisPasswordMissing";
|
|
5227
|
+
}
|
|
5228
|
+
};
|
|
4373
5229
|
/**
|
|
4374
|
-
*
|
|
4375
|
-
*
|
|
4376
|
-
*
|
|
4377
|
-
* Three phases, and only this one has side effects: declaring is pure (`gkm
|
|
4378
|
-
* build` and the dev watcher), running reads `<NAME>_URL` and provisions
|
|
4379
|
-
* nothing. Reconcile computes the desired state, compares, and applies the
|
|
4380
|
-
* difference — which is what makes "when does it run" answerable with
|
|
4381
|
-
* *whenever, repeatedly*.
|
|
4382
|
-
*
|
|
4383
|
-
* It is safe to run automatically because its blast radius is entirely local:
|
|
4384
|
-
* this project's containers, this project's `.gkm/`. Nothing it does can reach a
|
|
4385
|
-
* cloud. The line is drawn at data, not at side effects — it allocates ports,
|
|
4386
|
-
* writes compose, starts containers, and creates roles; it never seeds, resets,
|
|
4387
|
-
* or drops.
|
|
4388
|
-
*
|
|
4389
|
-
* Docker is injected rather than imported so the rules above can be asserted
|
|
4390
|
-
* without a daemon. The default implementation is the real one.
|
|
5230
|
+
* Whether a stage's stack runs Redis: a cache is declared and the stage's
|
|
5231
|
+
* secrets do not set the URL of every one.
|
|
4391
5232
|
*/
|
|
5233
|
+
function runsRedis(manifest, custom) {
|
|
5234
|
+
return Object.values(manifest).some((declaration) => declaration?.kind === "cache" && (declaration.provides ?? []).some((key) => custom[key] === void 0));
|
|
5235
|
+
}
|
|
5236
|
+
/** A password: 256 random bits, URL-safe, so it sits in a URL unescaped. */
|
|
5237
|
+
function generateRedisPassword() {
|
|
5238
|
+
return randomBytes(32).toString("base64url");
|
|
5239
|
+
}
|
|
4392
5240
|
/**
|
|
4393
|
-
*
|
|
4394
|
-
*
|
|
4395
|
-
*
|
|
4396
|
-
* are one stack: this file is everything the constructs imply and is never
|
|
4397
|
-
* edited, that one is anything they cannot express — an image pin, an extra
|
|
4398
|
-
* service — and is merged over it, so it wins.
|
|
5241
|
+
* The stage's secrets with a generated Redis password, where it has none.
|
|
5242
|
+
* Pure, like `withGeneratedSecrets`: the caller keeps the result once the run
|
|
5243
|
+
* goes ahead.
|
|
4399
5244
|
*/
|
|
4400
|
-
|
|
5245
|
+
function withRedisPassword(secrets) {
|
|
5246
|
+
if (secrets.custom?.["REDIS_PASSWORD"]) return {
|
|
5247
|
+
secrets,
|
|
5248
|
+
generated: []
|
|
5249
|
+
};
|
|
5250
|
+
return {
|
|
5251
|
+
secrets: {
|
|
5252
|
+
...secrets,
|
|
5253
|
+
custom: {
|
|
5254
|
+
...secrets.custom,
|
|
5255
|
+
[REDIS_PASSWORD_KEY]: generateRedisPassword()
|
|
5256
|
+
},
|
|
5257
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5258
|
+
},
|
|
5259
|
+
generated: [REDIS_PASSWORD_KEY]
|
|
5260
|
+
};
|
|
5261
|
+
}
|
|
4401
5262
|
/**
|
|
4402
|
-
* The
|
|
5263
|
+
* The stack's Redis for one stage, or nothing where no cache needs it.
|
|
4403
5264
|
*
|
|
4404
|
-
*
|
|
4405
|
-
*
|
|
5265
|
+
* Each cache it holds gets a logical database of its own, by the order of
|
|
5266
|
+
* their ids, so two caches never read each other's keys.
|
|
4406
5267
|
*/
|
|
4407
|
-
|
|
5268
|
+
function stackRedis(options) {
|
|
5269
|
+
const { plan, stage, local, custom } = options;
|
|
5270
|
+
const caches = plan.resources.filter((r) => r.kind === "cache" && custom[r.envKey] === void 0).sort((a, b) => a.id.localeCompare(b.id));
|
|
5271
|
+
if (caches.length === 0) return void 0;
|
|
5272
|
+
const set = custom[REDIS_PASSWORD_KEY];
|
|
5273
|
+
const password = set ?? (local ? options.localPassword : void 0);
|
|
5274
|
+
if (!password) throw new RedisPasswordMissing(stage);
|
|
5275
|
+
const auth = encodeURIComponent(password);
|
|
5276
|
+
return {
|
|
5277
|
+
password,
|
|
5278
|
+
passwordFromSecrets: Boolean(set),
|
|
5279
|
+
urls: Object.fromEntries(caches.map((cache, index) => [cache.envKey, `redis://:${auth}@${REDIS_SERVICE}:${REDIS_PORT}/${index}`])),
|
|
5280
|
+
env: {
|
|
5281
|
+
[REDIS_PASSWORD_KEY]: password,
|
|
5282
|
+
REDISCLI_AUTH: password
|
|
5283
|
+
},
|
|
5284
|
+
databases: Math.max(REDIS_DATABASES, caches.length)
|
|
5285
|
+
};
|
|
5286
|
+
}
|
|
4408
5287
|
/**
|
|
4409
|
-
*
|
|
5288
|
+
* Redis's service: bounded, persisted and password protected, on the
|
|
5289
|
+
* network alone.
|
|
4410
5290
|
*
|
|
4411
|
-
*
|
|
4412
|
-
*
|
|
4413
|
-
*
|
|
5291
|
+
* The command runs the image's own entrypoint with `redis-server` first, so
|
|
5292
|
+
* it still drops to the `redis` user and owns `/data`; the password is read
|
|
5293
|
+
* from the env file by the shell (`$$` is compose's escape for `$`), so
|
|
5294
|
+
* neither the compose file nor the container's command holds it.
|
|
4414
5295
|
*/
|
|
4415
|
-
|
|
5296
|
+
function redisService(redis) {
|
|
5297
|
+
const flags = [
|
|
5298
|
+
`--requirepass "$$${REDIS_PASSWORD_KEY}"`,
|
|
5299
|
+
`--maxmemory ${REDIS_MAXMEMORY}`,
|
|
5300
|
+
"--maxmemory-policy allkeys-lru",
|
|
5301
|
+
"--appendonly yes",
|
|
5302
|
+
...redis.databases > REDIS_DATABASES ? [`--databases ${redis.databases}`] : []
|
|
5303
|
+
];
|
|
5304
|
+
return {
|
|
5305
|
+
image: REDIS_IMAGE,
|
|
5306
|
+
restart: "unless-stopped",
|
|
5307
|
+
command: [
|
|
5308
|
+
"sh",
|
|
5309
|
+
"-c",
|
|
5310
|
+
`exec docker-entrypoint.sh redis-server ${flags.join(" ")}`
|
|
5311
|
+
],
|
|
5312
|
+
env_file: [{
|
|
5313
|
+
path: `./${REDIS_SERVICE}.env`,
|
|
5314
|
+
format: "raw"
|
|
5315
|
+
}],
|
|
5316
|
+
volumes: ["redis-data:/data"],
|
|
5317
|
+
healthcheck: {
|
|
5318
|
+
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"],
|
|
5319
|
+
interval: "5s",
|
|
5320
|
+
timeout: "3s",
|
|
5321
|
+
retries: 10
|
|
5322
|
+
}
|
|
5323
|
+
};
|
|
5324
|
+
}
|
|
5325
|
+
//#endregion
|
|
5326
|
+
//#region src/deploy/generated.ts
|
|
4416
5327
|
/**
|
|
4417
|
-
*
|
|
5328
|
+
* The values a deployed stage generates once and keeps: what nothing can
|
|
5329
|
+
* provision and nobody else issues.
|
|
4418
5330
|
*
|
|
4419
|
-
*
|
|
4420
|
-
*
|
|
4421
|
-
*
|
|
4422
|
-
*
|
|
4423
|
-
*
|
|
5331
|
+
* - **A seed**, which salts every password a deploy derives — a database's
|
|
5332
|
+
* master and roles, a bucket's root user. Derived from repo facts alone
|
|
5333
|
+
* (project, stage, construct id), those passwords were computable by anyone
|
|
5334
|
+
* who could read the repo.
|
|
5335
|
+
* - **Each `secret` construct's value** — an auth server's signing secret —
|
|
5336
|
+
* random, rather than derived.
|
|
5337
|
+
* - **Each `encryption` construct's keyring** — one random key and an index
|
|
5338
|
+
* key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
|
|
5339
|
+
*
|
|
5340
|
+
* Both are generated on the first deploy that needs them and written to the
|
|
5341
|
+
* stage's store, so every later deploy reads the same values: sessions survive
|
|
5342
|
+
* a redeploy, and a role's password does not move. A value set by hand with
|
|
5343
|
+
* `gkm secrets:set` is kept, never replaced.
|
|
4424
5344
|
*/
|
|
4425
|
-
const LOCAL_CA_PATH = ".gkm/caddy-root.crt";
|
|
4426
|
-
/** Where Caddy keeps the root of the CA it generated. */
|
|
4427
|
-
const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
|
|
4428
5345
|
/**
|
|
4429
|
-
*
|
|
4430
|
-
*
|
|
4431
|
-
*
|
|
4432
|
-
* file is written or a container is touched, and a converged project pays one
|
|
4433
|
-
* hash and one health check.
|
|
5346
|
+
* The stage's secrets with its seed and every declared `secret`'s value
|
|
5347
|
+
* present, generating what is missing. Pure: the caller writes the result
|
|
5348
|
+
* back to the stage's store when `generated` is not empty.
|
|
4434
5349
|
*/
|
|
4435
|
-
|
|
4436
|
-
const
|
|
4437
|
-
const
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
}
|
|
4445
|
-
const
|
|
4446
|
-
|
|
4447
|
-
const ports = await allocate(project, portKeys(plan.containers, plan.fakes), keptPorts(options.saved, observed), probe);
|
|
4448
|
-
const compose = composeFor(plan, {
|
|
4449
|
-
project,
|
|
4450
|
-
ports,
|
|
4451
|
-
...options.apps ? { apps: options.apps(plan.containers) } : {}
|
|
4452
|
-
});
|
|
4453
|
-
const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
|
|
4454
|
-
const hash = planHash(plan, compose, {
|
|
4455
|
-
caddyfile,
|
|
4456
|
-
postgres: postgresStatements(plan, project).map((statement) => statement.create).join(";\n")
|
|
4457
|
-
});
|
|
4458
|
-
const services = serviceAddresses(plan.containers, ports, plan.fakes);
|
|
4459
|
-
const lan = plan.resources.some((r) => r.kind === "mobile-app") ? options.lanAddress === void 0 ? lanAddress() : options.lanAddress ?? void 0 : void 0;
|
|
4460
|
-
const env = envFor(plan, {
|
|
4461
|
-
ports,
|
|
4462
|
-
project,
|
|
4463
|
-
...options.mailFrom ? { mailFrom: options.mailFrom } : {},
|
|
4464
|
-
...options.addresses ? { addresses: options.addresses } : {},
|
|
4465
|
-
...lan ? { lanAddress: lan } : {},
|
|
4466
|
-
...options.metroPorts ? { metroPorts: options.metroPorts } : {}
|
|
4467
|
-
});
|
|
4468
|
-
if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
|
|
4469
|
-
const result = {
|
|
4470
|
-
stage,
|
|
4471
|
-
plan,
|
|
4472
|
-
compose,
|
|
4473
|
-
ports,
|
|
4474
|
-
services,
|
|
4475
|
-
env,
|
|
4476
|
-
provisioned: [],
|
|
4477
|
-
fakes: options.fakes ?? {},
|
|
4478
|
-
hash,
|
|
4479
|
-
changed: false
|
|
4480
|
-
};
|
|
4481
|
-
const recorded = await loadState(root, stage);
|
|
4482
|
-
const postgresPort = ports[primaryPortKey("postgres")];
|
|
4483
|
-
if (recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers)) && (!start || !provision || postgresPort === void 0 || await databasesExist(sql(postgresPort), postgresDatabaseNames(plan)))) return result;
|
|
4484
|
-
await write(composePath, toYaml(compose));
|
|
4485
|
-
if (plan.containers.includes("caddy")) {
|
|
4486
|
-
await write(join(root, CADDYFILE_PATH), caddyfileRoot());
|
|
4487
|
-
await write(join(root, caddySitesPath(stage)), caddyfile);
|
|
4488
|
-
await pruneCaddySites(root, [
|
|
4489
|
-
stage,
|
|
4490
|
-
options.localStage,
|
|
4491
|
-
TEST_STAGE
|
|
4492
|
-
]);
|
|
4493
|
-
}
|
|
4494
|
-
if (start && plan.containers.length > 0) {
|
|
4495
|
-
options.progress?.(`🐳 Starting ${plan.containers.join(", ")} — the first start pulls each image, which can take a few minutes`);
|
|
4496
|
-
await docker.up(composePath, plan.containers, options.progress ? { show: true } : {});
|
|
4497
|
-
}
|
|
4498
|
-
if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
|
|
4499
|
-
if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
|
|
4500
|
-
if (start && provision) options.progress?.("🗄️ Creating what the constructs declare — databases, roles, buckets, topics");
|
|
4501
|
-
const provisioned = start && provision ? await create(plan, ports, sql, buckets, carriers, project) : [];
|
|
4502
|
-
await saveState(root, {
|
|
4503
|
-
hash,
|
|
4504
|
-
stage
|
|
4505
|
-
});
|
|
5350
|
+
function withGeneratedSecrets(secrets, manifest) {
|
|
5351
|
+
const generated = [];
|
|
5352
|
+
const custom = { ...secrets.custom };
|
|
5353
|
+
for (const declaration of Object.values(manifest)) {
|
|
5354
|
+
if (declaration?.kind !== "secret" && declaration?.kind !== "encryption") continue;
|
|
5355
|
+
const key = declaration.provides?.[0];
|
|
5356
|
+
if (!key || custom[key]) continue;
|
|
5357
|
+
custom[key] = declaration.kind === "encryption" ? generateKeyring() : random();
|
|
5358
|
+
generated.push(key);
|
|
5359
|
+
}
|
|
5360
|
+
const seed = secrets.seed ?? generateSeed();
|
|
5361
|
+
if (!secrets.seed) generated.push("seed");
|
|
4506
5362
|
return {
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
5363
|
+
secrets: generated.length ? {
|
|
5364
|
+
...secrets,
|
|
5365
|
+
seed,
|
|
5366
|
+
custom,
|
|
5367
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5368
|
+
} : secrets,
|
|
5369
|
+
generated
|
|
4510
5370
|
};
|
|
4511
5371
|
}
|
|
5372
|
+
/** A stage's seed: 256 random bits, URL-safe. */
|
|
5373
|
+
function generateSeed() {
|
|
5374
|
+
return random();
|
|
5375
|
+
}
|
|
5376
|
+
/** 256 bits, URL-safe. */
|
|
5377
|
+
function random() {
|
|
5378
|
+
return randomBytes(32).toString("base64url");
|
|
5379
|
+
}
|
|
5380
|
+
/** MinIO's root user, the same for every workspace: it is not a secret. */
|
|
5381
|
+
const LOCAL_MINIO_USER = "minio";
|
|
5382
|
+
/** RabbitMQ's administrator, the same way. */
|
|
5383
|
+
const LOCAL_RABBITMQ_USER = "rabbitmq";
|
|
4512
5384
|
/**
|
|
4513
|
-
*
|
|
4514
|
-
*
|
|
4515
|
-
*
|
|
4516
|
-
* than being something to remember.
|
|
5385
|
+
* The Postgres superuser for a workspace: its name as an identifier, with
|
|
5386
|
+
* `_admin` — never the bare name, which a database construct of the same
|
|
5387
|
+
* name would claim as its runtime role.
|
|
4517
5388
|
*/
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
return
|
|
5389
|
+
function postgresSuperuser(project) {
|
|
5390
|
+
return `${project.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/^(\d)/, "_$1") || "gkm"}_admin`;
|
|
5391
|
+
}
|
|
5392
|
+
/** 256 random bits, URL-safe. */
|
|
5393
|
+
function token() {
|
|
5394
|
+
return randomBytes(32).toString("base64url");
|
|
5395
|
+
}
|
|
5396
|
+
/** `length` characters of letters and digits, which every client takes as is. */
|
|
5397
|
+
function alphanumeric(length) {
|
|
5398
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
5399
|
+
let out = "";
|
|
5400
|
+
for (let i = 0; i < length; i++) out += alphabet[randomInt(62)];
|
|
5401
|
+
return out;
|
|
5402
|
+
}
|
|
5403
|
+
/** A fresh set of local logins for a workspace. */
|
|
5404
|
+
function generateLocalCredentials(project) {
|
|
5405
|
+
return {
|
|
5406
|
+
seed: generateSeed(),
|
|
5407
|
+
postgres: {
|
|
5408
|
+
user: postgresSuperuser(project),
|
|
5409
|
+
password: token()
|
|
5410
|
+
},
|
|
5411
|
+
minio: {
|
|
5412
|
+
user: LOCAL_MINIO_USER,
|
|
5413
|
+
password: alphanumeric(32)
|
|
5414
|
+
},
|
|
5415
|
+
rabbitmq: {
|
|
5416
|
+
user: LOCAL_RABBITMQ_USER,
|
|
5417
|
+
password: token()
|
|
5418
|
+
},
|
|
5419
|
+
redis: { password: generateRedisPassword() },
|
|
5420
|
+
cacheToken: token(),
|
|
5421
|
+
emulator: {
|
|
5422
|
+
accessKeyId: EMULATOR_ACCESS_KEY_ID,
|
|
5423
|
+
secretAccessKey: alphanumeric(40)
|
|
5424
|
+
},
|
|
5425
|
+
logs: {
|
|
5426
|
+
email: LOCAL_LOGS_EMAIL,
|
|
5427
|
+
password: generateLogsPassword()
|
|
5428
|
+
}
|
|
5429
|
+
};
|
|
4535
5430
|
}
|
|
4536
5431
|
/**
|
|
4537
|
-
* The
|
|
4538
|
-
*
|
|
4539
|
-
*
|
|
4540
|
-
* knowing about the other.
|
|
5432
|
+
* The stored logins with anything missing generated — a file an older gkm
|
|
5433
|
+
* wrote before a service existed. Pure: the caller writes the result back
|
|
5434
|
+
* when `generated` is not empty.
|
|
4541
5435
|
*/
|
|
4542
|
-
|
|
4543
|
-
const
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
5436
|
+
function withLocalCredentials(stored, project) {
|
|
5437
|
+
const fresh = generateLocalCredentials(project);
|
|
5438
|
+
const generated = Object.keys(fresh).filter((key) => stored?.[key] === void 0);
|
|
5439
|
+
return {
|
|
5440
|
+
credentials: {
|
|
5441
|
+
...fresh,
|
|
5442
|
+
...stored
|
|
5443
|
+
},
|
|
5444
|
+
generated
|
|
5445
|
+
};
|
|
5446
|
+
}
|
|
5447
|
+
/** Where a workspace's local logins are kept on this machine. */
|
|
5448
|
+
function localCredentialsPath(project) {
|
|
5449
|
+
const keys = getKeystoreDir(project);
|
|
5450
|
+
const name = keys.split(/[\\/]/).at(-1);
|
|
5451
|
+
const namespace = keys.split(/[\\/]/).at(-2);
|
|
5452
|
+
return join(project.home ?? gkmHome(), "local", namespace, `${name}.json`);
|
|
5453
|
+
}
|
|
5454
|
+
/** The local stage's logins file is there, and its key is not. */
|
|
5455
|
+
var LocalCredentialsKeyMissing = class extends Error {
|
|
5456
|
+
path;
|
|
5457
|
+
stage;
|
|
5458
|
+
constructor(path, stage) {
|
|
5459
|
+
super(`The local logins at ${path} are encrypted with the '${stage}' stage's key, and this machine has none. Remove the file to generate new ones — the containers' volumes are rotated to them on the next gkm dev.`);
|
|
5460
|
+
this.path = path;
|
|
5461
|
+
this.stage = stage;
|
|
5462
|
+
this.name = "LocalCredentialsKeyMissing";
|
|
4547
5463
|
}
|
|
4548
|
-
|
|
5464
|
+
};
|
|
5465
|
+
/** Another gkm held the logins file's lock for longer than any write takes. */
|
|
5466
|
+
var LocalCredentialsLocked = class extends Error {
|
|
5467
|
+
lock;
|
|
5468
|
+
constructor(lock) {
|
|
5469
|
+
super(`Another gkm has been writing the local logins for over ${LOCK_TIMEOUT_MS / 1e3}s. If none is running, remove ${lock} and run the command again.`);
|
|
5470
|
+
this.lock = lock;
|
|
5471
|
+
this.name = "LocalCredentialsLocked";
|
|
5472
|
+
}
|
|
5473
|
+
};
|
|
5474
|
+
/** Read the stored logins, or `null` where none were generated yet. */
|
|
5475
|
+
async function readLocalCredentials(workspace, home) {
|
|
5476
|
+
const project = keystoreProject(workspace, home);
|
|
5477
|
+
const path = localCredentialsPath(project);
|
|
5478
|
+
if (!existsSync(path)) return null;
|
|
5479
|
+
const stage = workspace.stages.local;
|
|
5480
|
+
const key = await readKey(stage, project);
|
|
5481
|
+
if (!key) throw new LocalCredentialsKeyMissing(path, stage);
|
|
5482
|
+
return decrypt(JSON.parse(await readFile(path, "utf-8")), key);
|
|
5483
|
+
}
|
|
5484
|
+
/** Write the logins, encrypted with the local stage's key, owner-only. */
|
|
5485
|
+
async function saveLocalCredentials(workspace, credentials, home) {
|
|
5486
|
+
const project = keystoreProject(workspace, home);
|
|
5487
|
+
const path = localCredentialsPath(project);
|
|
5488
|
+
const key = await getOrCreateKey(workspace.stages.local, project);
|
|
5489
|
+
await mkdir(dirname(path), {
|
|
5490
|
+
recursive: true,
|
|
5491
|
+
mode: 448
|
|
5492
|
+
});
|
|
5493
|
+
await writeFile(path, JSON.stringify(encrypt(credentials, key), null, 2), { mode: 384 });
|
|
4549
5494
|
}
|
|
4550
|
-
/** Write a file only when its content would change, so mtimes stay meaningful. */
|
|
4551
5495
|
/**
|
|
4552
|
-
*
|
|
5496
|
+
* The workspace's local logins: read back, or generated and kept the first
|
|
5497
|
+
* time anything needs them.
|
|
4553
5498
|
*
|
|
4554
|
-
*
|
|
4555
|
-
*
|
|
4556
|
-
* imports every file, and the leftover declares the same hosts as its
|
|
4557
|
-
* replacement: it refuses the whole config as ambiguous, and every URL behind
|
|
4558
|
-
* the edge goes with it.
|
|
5499
|
+
* Under a lock, because a workspace's `gkm dev` starts one per app and the
|
|
5500
|
+
* first run of each would otherwise generate — and keep — its own.
|
|
4559
5501
|
*/
|
|
4560
|
-
async function
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
5502
|
+
async function loadLocalCredentials(workspace, home) {
|
|
5503
|
+
return withLock(`${localCredentialsPath(keystoreProject(workspace, home))}.lock`, async () => {
|
|
5504
|
+
const result = withLocalCredentials(await readLocalCredentials(workspace, home), workspace.name);
|
|
5505
|
+
if (result.generated.length > 0) await saveLocalCredentials(workspace, result.credentials, home);
|
|
5506
|
+
return result;
|
|
5507
|
+
});
|
|
4564
5508
|
}
|
|
4565
|
-
|
|
5509
|
+
/** How long a lock may be held before it is someone else's problem. */
|
|
5510
|
+
const LOCK_TIMEOUT_MS = 15e3;
|
|
5511
|
+
async function withLock(lock, body) {
|
|
5512
|
+
await mkdir(dirname(lock), {
|
|
5513
|
+
recursive: true,
|
|
5514
|
+
mode: 448
|
|
5515
|
+
});
|
|
5516
|
+
const started = Date.now();
|
|
5517
|
+
for (;;) try {
|
|
5518
|
+
await mkdir(lock);
|
|
5519
|
+
break;
|
|
5520
|
+
} catch (error) {
|
|
5521
|
+
if (error.code !== "EEXIST") throw error;
|
|
5522
|
+
if (await stat(lock).then((s) => Date.now() - s.mtimeMs).catch(() => 0) > LOCK_TIMEOUT_MS) {
|
|
5523
|
+
await rm(lock, {
|
|
5524
|
+
recursive: true,
|
|
5525
|
+
force: true
|
|
5526
|
+
});
|
|
5527
|
+
continue;
|
|
5528
|
+
}
|
|
5529
|
+
if (Date.now() - started > LOCK_TIMEOUT_MS) throw new LocalCredentialsLocked(lock);
|
|
5530
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
5531
|
+
}
|
|
4566
5532
|
try {
|
|
4567
|
-
|
|
4568
|
-
}
|
|
4569
|
-
|
|
4570
|
-
|
|
5533
|
+
return await body();
|
|
5534
|
+
} finally {
|
|
5535
|
+
await rm(lock, {
|
|
5536
|
+
recursive: true,
|
|
5537
|
+
force: true
|
|
5538
|
+
});
|
|
5539
|
+
}
|
|
4571
5540
|
}
|
|
4572
5541
|
//#endregion
|
|
4573
5542
|
//#region src/reconcile/workspace.ts
|
|
@@ -4657,7 +5626,9 @@ async function reconcileWorkspace(workspace, options) {
|
|
|
4657
5626
|
});
|
|
4658
5627
|
const manifest = options.manifest ?? discovered;
|
|
4659
5628
|
const fakes = fakesApply(options.stage, options.fake) ? await readFakes(workspace.root, manifest) : {};
|
|
5629
|
+
const { credentials } = await loadLocalCredentials(workspace);
|
|
4660
5630
|
const result = await reconcile({
|
|
5631
|
+
credentials,
|
|
4661
5632
|
root: workspace.root,
|
|
4662
5633
|
project: workspace.name,
|
|
4663
5634
|
manifest,
|
|
@@ -4668,11 +5639,13 @@ async function reconcileWorkspace(workspace, options) {
|
|
|
4668
5639
|
addresses: surfaceAddresses(workspace, manifest),
|
|
4669
5640
|
metroPorts: metroPorts(workspace, manifest),
|
|
4670
5641
|
fakes,
|
|
4671
|
-
apps: (containers) => appServices(workspace, manifest, containers, runnables, fakes),
|
|
5642
|
+
apps: (containers) => appServices(workspace, manifest, containers, credentials, runnables, fakes),
|
|
4672
5643
|
...options.start === void 0 ? {} : { start: options.start },
|
|
4673
5644
|
...options.progress ? { progress: options.progress } : {}
|
|
4674
5645
|
});
|
|
4675
5646
|
await savePortState(workspace.root, { ...result.ports });
|
|
5647
|
+
if (result.credentials !== credentials) await saveLocalCredentials(workspace, result.credentials);
|
|
5648
|
+
for (const outcome of result.logins) if (outcome.notice) console.log(outcome.notice);
|
|
4676
5649
|
return {
|
|
4677
5650
|
...result,
|
|
4678
5651
|
manifest
|
|
@@ -5771,112 +6744,6 @@ function reportDevServices(ctx, uses) {
|
|
|
5771
6744
|
}
|
|
5772
6745
|
}
|
|
5773
6746
|
//#endregion
|
|
5774
|
-
//#region src/deploy/otel.ts
|
|
5775
|
-
/**
|
|
5776
|
-
* The OpenTelemetry variables a stage's secrets pass to every backend.
|
|
5777
|
-
*
|
|
5778
|
-
* A production server starts OpenTelemetry when `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
5779
|
-
* is set (`generators/telemetry.ts`), and the SDK reads the rest of its
|
|
5780
|
-
* configuration from the standard `OTEL_*` variables. No construct declares
|
|
5781
|
-
* them — they say where the telemetry goes, not what the app is — so a target
|
|
5782
|
-
* that writes each backend only the keys it reads would never hand them over.
|
|
5783
|
-
* Every target that runs backends asks this module instead.
|
|
5784
|
-
*
|
|
5785
|
-
* Which keys pass is a pattern over the `OTEL_` prefix rather than the prefix
|
|
5786
|
-
* alone or a list:
|
|
5787
|
-
*
|
|
5788
|
-
* - The bare prefix would forward anything a stage happened to name `OTEL_*`
|
|
5789
|
-
* — `OTEL_LOG_LEVEL=debug`, `OTEL_SDK_DISABLED` — to every backend, without
|
|
5790
|
-
* anyone choosing to.
|
|
5791
|
-
* - A list would have to spell out the exporter's five settings once for all
|
|
5792
|
-
* signals and once per signal — twenty keys — and would miss the next one.
|
|
5793
|
-
*
|
|
5794
|
-
* The pattern names the families the server uses: the OTLP exporter's
|
|
5795
|
-
* endpoint, headers, protocol, timeout and compression, for every signal or
|
|
5796
|
-
* for one (`OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`); sampling; and the resource's
|
|
5797
|
-
* identity.
|
|
5798
|
-
*
|
|
5799
|
-
* Sites get none of it: a site's environment is inlined into a bundle that
|
|
5800
|
-
* every browser downloads, and an exporter's headers are a credential.
|
|
5801
|
-
*/
|
|
5802
|
-
/** The `OTEL_*` variables a backend is handed from the stage's secrets. */
|
|
5803
|
-
const OTEL_PASSTHROUGH = /^OTEL_(?:EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS|PROTOCOL|TIMEOUT|COMPRESSION)|TRACES_SAMPLER(?:_ARG)?|RESOURCE_ATTRIBUTES|SERVICE_NAME)$/;
|
|
5804
|
-
/** Whether a stage secret is one of the passed-through `OTEL_*` variables. */
|
|
5805
|
-
function isOtelVariable(key) {
|
|
5806
|
-
return OTEL_PASSTHROUGH.test(key);
|
|
5807
|
-
}
|
|
5808
|
-
/** Whether a variable says where an OTLP exporter sends, or how it signs. */
|
|
5809
|
-
function isOtlpDestination(key) {
|
|
5810
|
-
return /^OTEL_EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS)$/.test(key);
|
|
5811
|
-
}
|
|
5812
|
-
/**
|
|
5813
|
-
* The `OTEL_*` variables one backend runs with: every one the stage's secrets
|
|
5814
|
-
* hold, and `OTEL_SERVICE_NAME` — the app's name — where the stage set
|
|
5815
|
-
* telemetry up without naming the service. Empty when the stage set none.
|
|
5816
|
-
*/
|
|
5817
|
-
function otelEnv(custom, app) {
|
|
5818
|
-
const env = {};
|
|
5819
|
-
for (const [key, value] of Object.entries(custom)) if (isOtelVariable(key) && value !== "") env[key] = value;
|
|
5820
|
-
if (Object.keys(env).length > 0 && !env.OTEL_SERVICE_NAME) env.OTEL_SERVICE_NAME = app;
|
|
5821
|
-
return env;
|
|
5822
|
-
}
|
|
5823
|
-
//#endregion
|
|
5824
|
-
//#region src/target/dokploy/domain.ts
|
|
5825
|
-
/** A stage was deployed that `deploy.domains` gives no domain. */
|
|
5826
|
-
var NoDomainForStage = class extends Error {
|
|
5827
|
-
stage;
|
|
5828
|
-
constructor(stage) {
|
|
5829
|
-
super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
|
|
5830
|
-
this.stage = stage;
|
|
5831
|
-
this.name = "NoDomainForStage";
|
|
5832
|
-
}
|
|
5833
|
-
};
|
|
5834
|
-
/**
|
|
5835
|
-
* The hostname an app answers on for a stage.
|
|
5836
|
-
*
|
|
5837
|
-
* 1. An explicit `app.domain` — a string, or one per stage — wins.
|
|
5838
|
-
* 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
|
|
5839
|
-
* answers on the domain itself, and every other app on
|
|
5840
|
-
* `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
|
|
5841
|
-
*
|
|
5842
|
-
* @throws {NoDomainForStage} when `deploy.domains` names no domain for it
|
|
5843
|
-
*/
|
|
5844
|
-
function resolveHost(appName, app, stage, domains, isMainFrontend) {
|
|
5845
|
-
if (app.domain) {
|
|
5846
|
-
if (typeof app.domain === "string") return app.domain;
|
|
5847
|
-
if (app.domain[stage]) return app.domain[stage];
|
|
5848
|
-
}
|
|
5849
|
-
const baseDomain = domains?.[stage];
|
|
5850
|
-
if (!baseDomain) throw new NoDomainForStage(stage);
|
|
5851
|
-
if (isMainFrontend) return baseDomain;
|
|
5852
|
-
return `${app.subdomain ?? appName}.${baseDomain}`;
|
|
5853
|
-
}
|
|
5854
|
-
/**
|
|
5855
|
-
* Whether this app is the site the base domain points at — see `rootSite`,
|
|
5856
|
-
* which the local edge asks too.
|
|
5857
|
-
*
|
|
5858
|
-
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
5859
|
-
*/
|
|
5860
|
-
function isMainFrontendApp(appName, app, allApps) {
|
|
5861
|
-
if (app.type !== "web") return false;
|
|
5862
|
-
return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
|
|
5863
|
-
name,
|
|
5864
|
-
root: a.root
|
|
5865
|
-
}))) === appName;
|
|
5866
|
-
}
|
|
5867
|
-
/**
|
|
5868
|
-
* Get public URL arg names from app dependencies.
|
|
5869
|
-
*
|
|
5870
|
-
* @param app - The web/mobile app configuration
|
|
5871
|
-
* @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
|
|
5872
|
-
* without a public prefix (e.g. Remix).
|
|
5873
|
-
*/
|
|
5874
|
-
function getPublicUrlArgNames(app) {
|
|
5875
|
-
const prefix = getPublicEnvPrefix(app.framework);
|
|
5876
|
-
if (!prefix) return [];
|
|
5877
|
-
return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
|
|
5878
|
-
}
|
|
5879
|
-
//#endregion
|
|
5880
6747
|
//#region src/docker/imageRef.ts
|
|
5881
6748
|
/**
|
|
5882
6749
|
* An image reference, checked before it reaches `docker`.
|
|
@@ -6186,57 +7053,6 @@ function printRunInstructions(workspace) {
|
|
|
6186
7053
|
logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
|
|
6187
7054
|
}
|
|
6188
7055
|
//#endregion
|
|
6189
|
-
//#region src/deploy/generated.ts
|
|
6190
|
-
/**
|
|
6191
|
-
* The values a deployed stage generates once and keeps: what nothing can
|
|
6192
|
-
* provision and nobody else issues.
|
|
6193
|
-
*
|
|
6194
|
-
* - **A seed**, which salts every password a deploy derives — a database's
|
|
6195
|
-
* master and roles, a bucket's root user. Derived from repo facts alone
|
|
6196
|
-
* (project, stage, construct id), those passwords were computable by anyone
|
|
6197
|
-
* who could read the repo.
|
|
6198
|
-
* - **Each `secret` construct's value** — an auth server's signing secret —
|
|
6199
|
-
* random, rather than derived.
|
|
6200
|
-
* - **Each `encryption` construct's keyring** — one random key and an index
|
|
6201
|
-
* key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
|
|
6202
|
-
*
|
|
6203
|
-
* Both are generated on the first deploy that needs them and written to the
|
|
6204
|
-
* stage's store, so every later deploy reads the same values: sessions survive
|
|
6205
|
-
* a redeploy, and a role's password does not move. A value set by hand with
|
|
6206
|
-
* `gkm secrets:set` is kept, never replaced.
|
|
6207
|
-
*/
|
|
6208
|
-
/**
|
|
6209
|
-
* The stage's secrets with its seed and every declared `secret`'s value
|
|
6210
|
-
* present, generating what is missing. Pure: the caller writes the result
|
|
6211
|
-
* back to the stage's store when `generated` is not empty.
|
|
6212
|
-
*/
|
|
6213
|
-
function withGeneratedSecrets(secrets, manifest) {
|
|
6214
|
-
const generated = [];
|
|
6215
|
-
const custom = { ...secrets.custom };
|
|
6216
|
-
for (const declaration of Object.values(manifest)) {
|
|
6217
|
-
if (declaration?.kind !== "secret" && declaration?.kind !== "encryption") continue;
|
|
6218
|
-
const key = declaration.provides?.[0];
|
|
6219
|
-
if (!key || custom[key]) continue;
|
|
6220
|
-
custom[key] = declaration.kind === "encryption" ? generateKeyring() : random();
|
|
6221
|
-
generated.push(key);
|
|
6222
|
-
}
|
|
6223
|
-
const seed = secrets.seed ?? random();
|
|
6224
|
-
if (!secrets.seed) generated.push("seed");
|
|
6225
|
-
return {
|
|
6226
|
-
secrets: generated.length ? {
|
|
6227
|
-
...secrets,
|
|
6228
|
-
seed,
|
|
6229
|
-
custom,
|
|
6230
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6231
|
-
} : secrets,
|
|
6232
|
-
generated
|
|
6233
|
-
};
|
|
6234
|
-
}
|
|
6235
|
-
/** 256 bits, URL-safe. */
|
|
6236
|
-
function random() {
|
|
6237
|
-
return randomBytes(32).toString("base64url");
|
|
6238
|
-
}
|
|
6239
|
-
//#endregion
|
|
6240
7056
|
//#region src/deploy/journal.ts
|
|
6241
7057
|
var DeployJournal = class DeployJournal {
|
|
6242
7058
|
store;
|
|
@@ -6635,6 +7451,6 @@ function reportPlannedSeeds(planned, log) {
|
|
|
6635
7451
|
for (const { folder, seeds } of planned) log(`🌱 ${folder}: would run ${seeds.length} (${seeds.join(", ")})`);
|
|
6636
7452
|
}
|
|
6637
7453
|
//#endregion
|
|
6638
|
-
export {
|
|
7454
|
+
export { postgresSuperuser as $, EMULATOR_ACCESS_KEY_ID as $t, UnknownDevService as A, projectName as An, appEnvKeys as At, WorkerGenerator as B, imageLayout as Bt, workerDockerfile as C, applicationName as Cn, postgresDatabaseNames as Ct, DevServicesNeedServerTarget as D, isOwnedBy as Dn, ensurePostgresLogin as Dt, DEV_SERVICES as E, imageRef as En, LEGACY_LOCAL_LOGIN as Et, parseDevServices as F, withCustomSecret as Fn, localRolePassword as Ft, FAKE_ENV as G, imageBuildCredentials as Gt, workerEntryDir as H, WORKER_PORT as Ht, reportDevServices as I, planFor as It, derivedContainers as J, eventsBackendFor as Jt, backendsOf as K, isImageBuild as Kt, requiredStageKeys as L, resourceName as Lt, devServicesUsed as M, maskPassword as Mn, networkEnv as Mt, externalServices as N, storage_exports as Nn, workerEnvKeys as Nt, ExternalServicesNotConfigured as O, markerOf as On, bucketClient as Ot, manifestServiceDeclarations as P, toEmbeddableSecrets as Pn, envFor as Pt, loadLocalCredentials as Q, composeFor as Qt, suppliedOnly as R, hostFor as Rt, ensureDockerignore as S, readKey as Sn, bucketPolicies as St, validateImageRef as T, imageName as Tn, quoteIdentifier as Tt, QueueGenerator as U, detectPackageManager as Ut, workerBundleName as V, TURBO_VERSION as Vt, CronGenerator as W, findBuildRoot as Wt, workspace_exports as X, storageBackendFor as Xt, reconcileWorkspace as Y, providerOf as Yt, LOCAL_RABBITMQ_USER as Z, CLUSTER_DATABASE as Zt, DeployJournal as _, store_exports as _n, LOCAL_CA_PATH as _t, createEmptyState as a, primaryPortKey as an, runsRedis as at, appDockerfile as b, keystoreProject as bn, applyPolicies as bt, isDnsVerified as c, findAvailablePort as cn, LOGS_SERVICE as ct, setApplicationId as d, loadPortState as dn, stackLogs as dt, DEFAULT_IMAGES as en, readLocalCredentials as et, setBackupState as f, loadSecretsForApp as fn, withLogsPassword as ft, setPostgresBackupId as g, storeFromConfig as gn, otelEnv as gt, setGeneratedSecret as h, secretsStoreFor as hn, resolveHost as ht, assertNoStaleSecrets as i, portsOf as in, redisService as it, assertExternalServices as j, initStageSecrets as jn, dockerfileOf as jt, STORAGE_KEY_PAIR as k, ownershipMarker as kn, pgClient as kt, recordRelease as l, isPortAvailable as ln, logsAccess as lt, setDnsVerification as m, isRemoteStore as mn, isMainFrontendApp as mt, reportDatabaseRuns as n, fakeKey as nn, REDIS_SERVICE as nt, getBackupState as o, createCredentialsPreload as on, stackRedis as ot, setDnsRecord as p, prepareEntryCredentials as pn, NoDomainForStage as pt, constructGlobs as q, cacheBackendFor as qt, reportPlannedSeeds as r, portKeys as rn, STACK_CACHE as rt, getGeneratedSecret as s, createEntryWrapper as sn, withRedisPassword as st, DeploySeedsFailed as t, describeServices as tn, withGeneratedSecrets as tt, recordRollback as u, loadEnvFiles as un, logsService as ut, DockerBuildFailed as v, FileSecretsStore as vn, forgetState as vt, workerDockerfileOf as w, deployIdentity as wn, postgresStatements as wt, dockerCommand as x, projectKey as xn, applyPostgres as xt, DockerPushFailed as y, getKeyPath as yn, applyBuckets as yt, workerUnits as z, composeBuildPaths as zt };
|
|
6639
7455
|
|
|
6640
|
-
//# sourceMappingURL=seeds-
|
|
7456
|
+
//# sourceMappingURL=seeds-DbyqaXW4.mjs.map
|