@geekmidas/cli 10.0.0-alpha.83 → 10.0.0-alpha.84
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-DtnIGafh.mjs → EndpointGenerator-CVc6Oi1Y.mjs} +185 -82
- package/dist/EndpointGenerator-CVc6Oi1Y.mjs.map +1 -0
- package/dist/{EndpointGenerator-cKlnllIu.cjs → EndpointGenerator-CwLjBCFE.cjs} +203 -82
- package/dist/EndpointGenerator-CwLjBCFE.cjs.map +1 -0
- package/dist/{LegacyStateStore-DFO3aJgs.mjs → LegacyStateStore-CTsUFLNQ.mjs} +2 -2
- package/dist/{LegacyStateStore-DFO3aJgs.mjs.map → LegacyStateStore-CTsUFLNQ.mjs.map} +1 -1
- package/dist/{LegacyStateStore-iUWVxrXU.cjs → LegacyStateStore-EDT2TUSe.cjs} +2 -2
- package/dist/{LegacyStateStore-iUWVxrXU.cjs.map → LegacyStateStore-EDT2TUSe.cjs.map} +1 -1
- package/dist/{LocalStateStore-BsFHdSeA.cjs → LocalStateStore-CdpzTvPn.cjs} +2 -2
- package/dist/{LocalStateStore-BsFHdSeA.cjs.map → LocalStateStore-CdpzTvPn.cjs.map} +1 -1
- package/dist/{LocalStateStore-DIfuh0Z1.mjs → LocalStateStore-D1VfxRuK.mjs} +2 -2
- package/dist/{LocalStateStore-DIfuh0Z1.mjs.map → LocalStateStore-D1VfxRuK.mjs.map} +1 -1
- package/dist/{OpenApiTsGenerator-CdUM6Hvv.cjs → OpenApiTsGenerator-B--HV-sW.cjs} +2 -2
- package/dist/{OpenApiTsGenerator-CdUM6Hvv.cjs.map → OpenApiTsGenerator-B--HV-sW.cjs.map} +1 -1
- package/dist/{OpenApiTsGenerator-ByRPMbBN.mjs → OpenApiTsGenerator-CkvsPdgg.mjs} +2 -2
- package/dist/{OpenApiTsGenerator-ByRPMbBN.mjs.map → OpenApiTsGenerator-CkvsPdgg.mjs.map} +1 -1
- package/dist/{S3StateStore-ytvpIiv8.cjs → S3StateStore-BPM74zwv.cjs} +2 -2
- package/dist/{S3StateStore-ytvpIiv8.cjs.map → S3StateStore-BPM74zwv.cjs.map} +1 -1
- package/dist/{S3StateStore-DR2f78Dj.mjs → S3StateStore-DVZ1YuGJ.mjs} +2 -2
- package/dist/{S3StateStore-DR2f78Dj.mjs.map → S3StateStore-DVZ1YuGJ.mjs.map} +1 -1
- package/dist/{SSMStateStore-BZ3XL0Mr.mjs → SSMStateStore-DLvnGyyl.mjs} +2 -2
- package/dist/{SSMStateStore-BZ3XL0Mr.mjs.map → SSMStateStore-DLvnGyyl.mjs.map} +1 -1
- package/dist/{SSMStateStore-C1JruCD9.cjs → SSMStateStore-qerm5c36.cjs} +2 -2
- package/dist/{SSMStateStore-C1JruCD9.cjs.map → SSMStateStore-qerm5c36.cjs.map} +1 -1
- package/dist/{SubscriberGenerator-DQZeGkDW.mjs → SubscriberGenerator-BPqQf8fB.mjs} +2 -2
- package/dist/{SubscriberGenerator-DQZeGkDW.mjs.map → SubscriberGenerator-BPqQf8fB.mjs.map} +1 -1
- package/dist/{SubscriberGenerator-CYriX5BV.cjs → SubscriberGenerator-BVn7cQ-2.cjs} +2 -2
- package/dist/{SubscriberGenerator-CYriX5BV.cjs.map → SubscriberGenerator-BVn7cQ-2.cjs.map} +1 -1
- package/dist/{add-Bwddb4KD.cjs → add-BQ8v1ZII.cjs} +3 -3
- package/dist/{add-Bwddb4KD.cjs.map → add-BQ8v1ZII.cjs.map} +1 -1
- package/dist/{add-DkBEZ5_s.mjs → add-DbnzC7Kl.mjs} +3 -3
- package/dist/{add-DkBEZ5_s.mjs.map → add-DbnzC7Kl.mjs.map} +1 -1
- package/dist/{bundler-B8TGsunu.mjs → bundler-D4030Afw.mjs} +5 -5
- package/dist/{bundler-B8TGsunu.mjs.map → bundler-D4030Afw.mjs.map} +1 -1
- package/dist/{bundler-BkH1kPEX.cjs → bundler-DjrRffko.cjs} +5 -5
- package/dist/{bundler-BkH1kPEX.cjs.map → bundler-DjrRffko.cjs.map} +1 -1
- package/dist/{cli-DO7DQqCq.cjs → cli-D8YQAYUX.cjs} +3 -3
- package/dist/{cli-DO7DQqCq.cjs.map → cli-D8YQAYUX.cjs.map} +1 -1
- package/dist/{cli-BgDd4cZB.mjs → cli-DwTa9d_8.mjs} +3 -3
- package/dist/{cli-BgDd4cZB.mjs.map → cli-DwTa9d_8.mjs.map} +1 -1
- package/dist/{compose-BPJ9bUKp.mjs → compose-C5wz8mf9.mjs} +64 -24
- package/dist/compose-C5wz8mf9.mjs.map +1 -0
- package/dist/{compose-CGyxYe-h.cjs → compose-DqWh1TaO.cjs} +64 -24
- package/dist/compose-DqWh1TaO.cjs.map +1 -0
- package/dist/{config-DMdzdO5u.d.cts → config-BzIfP8a-.d.cts} +16 -3
- package/dist/config-BzIfP8a-.d.cts.map +1 -0
- package/dist/{config-BBFiH4qk.d.mts → config-DhdzmndX.d.mts} +16 -3
- package/dist/config-DhdzmndX.d.mts.map +1 -0
- package/dist/config.cjs +1 -1
- package/dist/config.d.cts +3 -3
- package/dist/config.d.mts +3 -3
- package/dist/config.mjs +1 -1
- package/dist/deploy/public.cjs +6 -6
- package/dist/deploy/public.d.cts +3 -3
- package/dist/deploy/public.d.cts.map +1 -1
- package/dist/deploy/public.d.mts +3 -3
- package/dist/deploy/public.d.mts.map +1 -1
- package/dist/deploy/public.mjs +6 -6
- package/dist/{deploy-0XpZDYAr.mjs → deploy-BwIKGxWH.mjs} +14 -5
- package/dist/deploy-BwIKGxWH.mjs.map +1 -0
- package/dist/{deploy-BlZx6J5x.cjs → deploy-C8sWg81_.cjs} +14 -5
- package/dist/deploy-C8sWg81_.cjs.map +1 -0
- package/dist/{dokploy-rjXmkuwH.cjs → dokploy-BkRxJbfi.cjs} +2 -2
- package/dist/{dokploy-rjXmkuwH.cjs.map → dokploy-BkRxJbfi.cjs.map} +1 -1
- package/dist/{dokploy-Crh4uDmP.mjs → dokploy-ijjms1-h.mjs} +2 -2
- package/dist/{dokploy-Crh4uDmP.mjs.map → dokploy-ijjms1-h.mjs.map} +1 -1
- package/dist/{drivers-B4RP2B2O.mjs → drivers-DTZzxeIl.mjs} +2 -2
- package/dist/{drivers-B4RP2B2O.mjs.map → drivers-DTZzxeIl.mjs.map} +1 -1
- package/dist/{drivers-CJx76mcb.cjs → drivers-LYSdQDmd.cjs} +2 -2
- package/dist/{drivers-CJx76mcb.cjs.map → drivers-LYSdQDmd.cjs.map} +1 -1
- package/dist/{encryption-0eo9aY_H.mjs → encryption-BRcBAmjL.mjs} +3 -3
- package/dist/{encryption-0eo9aY_H.mjs.map → encryption-BRcBAmjL.mjs.map} +1 -1
- package/dist/{encryption-CxTfRx4h.cjs → encryption-g6PzwwfL.cjs} +3 -3
- package/dist/{encryption-CxTfRx4h.cjs.map → encryption-g6PzwwfL.cjs.map} +1 -1
- package/dist/{engine-DxWswfXe.mjs → engine-BP0CZ5fh.mjs} +33 -13
- package/dist/engine-BP0CZ5fh.mjs.map +1 -0
- package/dist/{engine-CMzSebbs.cjs → engine-DLEJsZ3i.cjs} +33 -13
- package/dist/engine-DLEJsZ3i.cjs.map +1 -0
- package/dist/{github-Dukt5cJe.cjs → github-B8YjwdLK.cjs} +3 -3
- package/dist/{github-Dukt5cJe.cjs.map → github-B8YjwdLK.cjs.map} +1 -1
- package/dist/{github-HOfOpFI_.mjs → github-Zs-dVJbR.mjs} +3 -3
- package/dist/{github-HOfOpFI_.mjs.map → github-Zs-dVJbR.mjs.map} +1 -1
- package/dist/{index-Wuqpf316.d.mts → index-D1ODeM9s.d.cts} +2 -2
- package/dist/{index-DFrbhRwg.d.cts.map → index-D1ODeM9s.d.cts.map} +1 -1
- package/dist/{index-DFrbhRwg.d.cts → index-f77HdqN5.d.mts} +2 -2
- package/dist/{index-Wuqpf316.d.mts.map → index-f77HdqN5.d.mts.map} +1 -1
- package/dist/index.cjs +173 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +173 -70
- package/dist/index.mjs.map +1 -1
- package/dist/{migrate-BgPWVnAZ.cjs → migrate-BfZN1S32.cjs} +3 -3
- package/dist/{migrate-BgPWVnAZ.cjs.map → migrate-BfZN1S32.cjs.map} +1 -1
- package/dist/{migrate-1dGEzuJ-.mjs → migrate-CdXcX0XR.mjs} +3 -3
- package/dist/{migrate-1dGEzuJ-.mjs.map → migrate-CdXcX0XR.mjs.map} +1 -1
- package/dist/{migrate-C14NNbFx.mjs → migrate-DOMU4uKe.mjs} +3 -3
- package/dist/{migrate-C14NNbFx.mjs.map → migrate-DOMU4uKe.mjs.map} +1 -1
- package/dist/{migrate-Bemd4tkz.cjs → migrate-FoL_mZIz.cjs} +3 -3
- package/dist/{migrate-Bemd4tkz.cjs.map → migrate-FoL_mZIz.cjs.map} +1 -1
- package/dist/openapi.cjs +3 -3
- package/dist/openapi.d.cts +1 -1
- package/dist/openapi.d.mts +1 -1
- package/dist/openapi.mjs +3 -3
- package/dist/{ready-4kx2pAhm.cjs → ready-BpNbORRB.cjs} +5 -5
- package/dist/{ready-4kx2pAhm.cjs.map → ready-BpNbORRB.cjs.map} +1 -1
- package/dist/{ready-DhZbZtFw.mjs → ready-b6vm0-W-.mjs} +5 -5
- package/dist/{ready-DhZbZtFw.mjs.map → ready-b6vm0-W-.mjs.map} +1 -1
- package/dist/reconcile/public.d.cts +1 -1
- package/dist/reconcile/public.d.mts +1 -1
- package/dist/{resolve-DCTcD823.cjs → resolve-T2I-5FcT.cjs} +3 -3
- package/dist/{resolve-DCTcD823.cjs.map → resolve-T2I-5FcT.cjs.map} +1 -1
- package/dist/{resolve-y1PkSZrF.mjs → resolve-r7PAs6ng.mjs} +3 -3
- package/dist/{resolve-y1PkSZrF.mjs.map → resolve-r7PAs6ng.mjs.map} +1 -1
- package/dist/{rollback-CwVhG_xa.mjs → rollback-BSdxnW3X.mjs} +5 -5
- package/dist/{rollback-CwVhG_xa.mjs.map → rollback-BSdxnW3X.mjs.map} +1 -1
- package/dist/{rollback-BVQFDjxr.cjs → rollback-CPSDjGse.cjs} +5 -5
- package/dist/{rollback-BVQFDjxr.cjs.map → rollback-CPSDjGse.cjs.map} +1 -1
- package/dist/{runtime-ekxfPNM-.d.mts → runtime-CNu2C2hB.d.mts} +2 -2
- package/dist/{runtime-D6A-C6zh.d.cts.map → runtime-CNu2C2hB.d.mts.map} +1 -1
- package/dist/{runtime-D6A-C6zh.d.cts → runtime-DMLgL26v.d.cts} +2 -2
- package/dist/{runtime-ekxfPNM-.d.mts.map → runtime-DMLgL26v.d.cts.map} +1 -1
- package/dist/{seeds-BoKQ74Od.mjs → seeds-BK8caVMm.mjs} +1707 -1612
- package/dist/seeds-BK8caVMm.mjs.map +1 -0
- package/dist/{seeds-Ccju3zGl.cjs → seeds-DQ0FG57Z.cjs} +2558 -2421
- package/dist/seeds-DQ0FG57Z.cjs.map +1 -0
- package/dist/{sniffer-6oYI-MpT.mjs → sniffer-CqE_KTTp.mjs} +6 -6
- package/dist/{sniffer-6oYI-MpT.mjs.map → sniffer-CqE_KTTp.mjs.map} +1 -1
- package/dist/{sniffer-DeyYI9Iv.cjs → sniffer-hSf5mRgV.cjs} +6 -6
- package/dist/{sniffer-DeyYI9Iv.cjs.map → sniffer-hSf5mRgV.cjs.map} +1 -1
- package/dist/{stages-CwrYVX9u.cjs → stages-CS1sjcRb.cjs} +2 -2
- package/dist/{stages-CwrYVX9u.cjs.map → stages-CS1sjcRb.cjs.map} +1 -1
- package/dist/{stages-CHF9RFog.mjs → stages-CSXt7N2b.mjs} +2 -2
- package/dist/{stages-CHF9RFog.mjs.map → stages-CSXt7N2b.mjs.map} +1 -1
- package/dist/target/public.cjs +1 -1
- package/dist/target/public.d.cts +2 -2
- package/dist/target/public.d.mts +2 -2
- package/dist/target/public.mjs +1 -1
- package/dist/{types-BzCavjGt.d.mts → types-BZn-8ssD.d.mts} +75 -21
- package/dist/types-BZn-8ssD.d.mts.map +1 -0
- package/dist/{types-DZtj9jeI.d.cts → types-DiZj6hqp.d.cts} +75 -21
- package/dist/types-DiZj6hqp.d.cts.map +1 -0
- package/dist/vitest.cjs +1 -1
- package/dist/vitest.mjs +1 -1
- package/dist/workspace/index.cjs +1 -1
- package/dist/workspace/index.d.cts +3 -3
- package/dist/workspace/index.d.mts +3 -3
- package/dist/workspace/index.mjs +1 -1
- package/dist/{workspace-4i6Jwsoa.cjs → workspace-Bic_R3tz.cjs} +324 -98
- package/dist/workspace-Bic_R3tz.cjs.map +1 -0
- package/dist/{workspace-DD9mtPy8.mjs → workspace-Drz4jsvy.mjs} +305 -97
- package/dist/workspace-Drz4jsvy.mjs.map +1 -0
- package/package.json +15 -15
- package/dist/EndpointGenerator-DtnIGafh.mjs.map +0 -1
- package/dist/EndpointGenerator-cKlnllIu.cjs.map +0 -1
- package/dist/compose-BPJ9bUKp.mjs.map +0 -1
- package/dist/compose-CGyxYe-h.cjs.map +0 -1
- package/dist/config-BBFiH4qk.d.mts.map +0 -1
- package/dist/config-DMdzdO5u.d.cts.map +0 -1
- package/dist/deploy-0XpZDYAr.mjs.map +0 -1
- package/dist/deploy-BlZx6J5x.cjs.map +0 -1
- package/dist/engine-CMzSebbs.cjs.map +0 -1
- package/dist/engine-DxWswfXe.mjs.map +0 -1
- package/dist/seeds-BoKQ74Od.mjs.map +0 -1
- package/dist/seeds-Ccju3zGl.cjs.map +0 -1
- package/dist/types-BzCavjGt.d.mts.map +0 -1
- package/dist/types-DZtj9jeI.d.cts.map +0 -1
- package/dist/workspace-4i6Jwsoa.cjs.map +0 -1
- package/dist/workspace-DD9mtPy8.mjs.map +0 -1
|
@@ -2,11 +2,11 @@ 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 {
|
|
5
|
+
import { I as stageSampleRate, L as telemetryEnv, R as LOGS_PORT, U as UnknownSecretsStoreProvider, g as getPublicEnvPrefix, h as PUBLIC_ENV_PREFIXES, k as TEST_STAGE, r as allConstructGlobs } from "./workspace-Drz4jsvy.mjs";
|
|
6
6
|
import { ConfigNotFound, NotInAnApp, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { a as telemetryArgs, c as startTelemetryCode, n as runtimeFor, u as writeTelemetryModule } from "./EndpointGenerator-CVc6Oi1Y.mjs";
|
|
8
|
+
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-BPqQf8fB.mjs";
|
|
9
|
+
import { c as runtimeOf } from "./resolve-r7PAs6ng.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
12
|
import { appendFile, chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
@@ -14,11 +14,11 @@ import { homedir, networkInterfaces } from "node:os";
|
|
|
14
14
|
import { promisify } from "node:util";
|
|
15
15
|
import { Cron } from "@geekmidas/constructs/crons";
|
|
16
16
|
import { Queue } from "@geekmidas/constructs/queue";
|
|
17
|
-
import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase, scopedName } from "@geekmidas/manifest";
|
|
17
|
+
import { DEFAULT_POSTGRES_VERSION, TELEMETRY_KEYS, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase, scopedName } from "@geekmidas/manifest";
|
|
18
18
|
import { createServer } from "node:net";
|
|
19
19
|
import { execFile, spawn } from "node:child_process";
|
|
20
|
-
import { config } from "dotenv";
|
|
21
20
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomInt } from "node:crypto";
|
|
21
|
+
import { config } from "dotenv";
|
|
22
22
|
import { stringify } from "yaml";
|
|
23
23
|
import { formatKeyring, generateKeyring } from "@geekmidas/constructs/encryption";
|
|
24
24
|
import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
|
|
@@ -29,425 +29,694 @@ import { CreateTopicCommand, GetTopicAttributesCommand, SNSClient } from "@aws-s
|
|
|
29
29
|
import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
30
30
|
import { Client } from "pg";
|
|
31
31
|
import { cacheTableStatements } from "@geekmidas/cache/postgres";
|
|
32
|
-
//#region src/
|
|
33
|
-
var storage_exports = /* @__PURE__ */ __exportAll({
|
|
34
|
-
initStageSecrets: () => initStageSecrets,
|
|
35
|
-
maskPassword: () => maskPassword,
|
|
36
|
-
toEmbeddableSecrets: () => toEmbeddableSecrets,
|
|
37
|
-
validateEnvironmentVariables: () => validateEnvironmentVariables,
|
|
38
|
-
withCustomSecret: () => withCustomSecret
|
|
39
|
-
});
|
|
32
|
+
//#region src/workspace/rootSite.ts
|
|
40
33
|
/**
|
|
41
|
-
*
|
|
34
|
+
* Which site the base domain points at — deployed and locally alike.
|
|
35
|
+
*
|
|
36
|
+
* Three rules, in order, and the last one is the point:
|
|
37
|
+
*
|
|
38
|
+
* 1. **One site** — it is the root. Unambiguous, so nothing has to be said.
|
|
39
|
+
* 2. **Named `web`** — the convention wins, because people already rely on it.
|
|
40
|
+
* 3. **Declared `root: true`** — the site says so itself.
|
|
41
|
+
*
|
|
42
|
+
* Anything else throws. It used to take the *first* site it iterated, and that
|
|
43
|
+
* order came from the manifest, which is built in glob traversal order — so two
|
|
44
|
+
* sites with neither named `web` meant renaming a file could move the root
|
|
45
|
+
* domain, silently.
|
|
46
|
+
*
|
|
47
|
+
* One rule for both targets: deployed the root site gets the base domain, and
|
|
48
|
+
* locally it gets the project's bare host behind the edge. Before this was
|
|
49
|
+
* shared, the local edge put every site on a subdomain, so what a developer
|
|
50
|
+
* browsed was not the shape production served.
|
|
51
|
+
*
|
|
52
|
+
* @returns the root site's name, or `undefined` when there are no sites
|
|
53
|
+
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
42
54
|
*/
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
urls: {},
|
|
51
|
-
custom: {}
|
|
52
|
-
};
|
|
55
|
+
function rootSite(sites) {
|
|
56
|
+
if (sites.length === 0) return void 0;
|
|
57
|
+
if (sites.length === 1) return sites[0].name;
|
|
58
|
+
if (sites.some(({ name }) => name === "web")) return "web";
|
|
59
|
+
const declared = sites.filter(({ root }) => root === true);
|
|
60
|
+
if (declared.length === 1) return declared[0].name;
|
|
61
|
+
throw new AmbiguousRootSite(sites.map(({ name }) => name), declared.length > 1);
|
|
53
62
|
}
|
|
63
|
+
/** Several sites could hold the base domain, and none of them says it does. */
|
|
64
|
+
var AmbiguousRootSite = class extends Error {
|
|
65
|
+
sites;
|
|
66
|
+
tooMany;
|
|
67
|
+
constructor(sites, tooMany = false) {
|
|
68
|
+
super(tooMany ? `More than one site declares \`root: true\` — ${sites.join(", ")}. The base domain points at one of them.` : `${sites.length} sites and nothing says which holds the base domain: ${sites.join(", ")}. Name one of them \`web\`, or declare \`root: true\` on the one the base domain points at. It used to be whichever the glob reached first, which is not a thing to decide a hostname.`);
|
|
69
|
+
this.sites = sites;
|
|
70
|
+
this.tooMany = tooMany;
|
|
71
|
+
this.name = "AmbiguousRootSite";
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/target/dokploy/domain.ts
|
|
76
|
+
/** A stage was deployed that `deploy.domains` gives no domain. */
|
|
77
|
+
var NoDomainForStage = class extends Error {
|
|
78
|
+
stage;
|
|
79
|
+
constructor(stage) {
|
|
80
|
+
super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
|
|
81
|
+
this.stage = stage;
|
|
82
|
+
this.name = "NoDomainForStage";
|
|
83
|
+
}
|
|
84
|
+
};
|
|
54
85
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
86
|
+
* The hostname an app answers on for a stage.
|
|
87
|
+
*
|
|
88
|
+
* 1. An explicit `app.domain` — a string, or one per stage — wins.
|
|
89
|
+
* 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
|
|
90
|
+
* answers on the domain itself, and every other app on
|
|
91
|
+
* `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
|
|
92
|
+
*
|
|
93
|
+
* @throws {NoDomainForStage} when `deploy.domains` names no domain for it
|
|
57
94
|
*/
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
POSTGRES_PORT: String(secrets.services.postgres.port)
|
|
68
|
-
},
|
|
69
|
-
...secrets.services.redis && {
|
|
70
|
-
REDIS_PASSWORD: secrets.services.redis.password,
|
|
71
|
-
REDIS_HOST: secrets.services.redis.host,
|
|
72
|
-
REDIS_PORT: String(secrets.services.redis.port)
|
|
73
|
-
},
|
|
74
|
-
...secrets.services.minio && {
|
|
75
|
-
STORAGE_ACCESS_KEY_ID: secrets.services.minio.username,
|
|
76
|
-
STORAGE_SECRET_ACCESS_KEY: secrets.services.minio.password,
|
|
77
|
-
STORAGE_BUCKET: secrets.services.minio.bucket ?? "app",
|
|
78
|
-
STORAGE_REGION: "eu-west-1",
|
|
79
|
-
STORAGE_FORCE_PATH_STYLE: "true"
|
|
80
|
-
},
|
|
81
|
-
...secrets.services.mailpit && {
|
|
82
|
-
SMTP_HOST: secrets.services.mailpit.host,
|
|
83
|
-
SMTP_PORT: String(secrets.services.mailpit.port),
|
|
84
|
-
SMTP_USER: secrets.services.mailpit.username,
|
|
85
|
-
SMTP_PASS: secrets.services.mailpit.password,
|
|
86
|
-
SMTP_SECURE: "false",
|
|
87
|
-
MAIL_FROM: "noreply@localhost"
|
|
88
|
-
},
|
|
89
|
-
...secrets.services.localstack && {
|
|
90
|
-
AWS_ACCESS_KEY_ID: secrets.services.localstack.accessKeyId ?? secrets.services.localstack.username,
|
|
91
|
-
AWS_SECRET_ACCESS_KEY: secrets.services.localstack.password,
|
|
92
|
-
AWS_REGION: secrets.services.localstack.region ?? "us-east-1",
|
|
93
|
-
AWS_ENDPOINT_URL: `http://${secrets.services.localstack.host}:${secrets.services.localstack.port}`
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
/** The same secrets with one custom value set, and `updatedAt` moved on. */
|
|
98
|
-
function withCustomSecret(secrets, key, value) {
|
|
99
|
-
return {
|
|
100
|
-
...secrets,
|
|
101
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
102
|
-
custom: {
|
|
103
|
-
...secrets.custom,
|
|
104
|
-
[key]: value
|
|
105
|
-
}
|
|
106
|
-
};
|
|
95
|
+
function resolveHost(appName, app, stage, domains, isMainFrontend) {
|
|
96
|
+
if (app.domain) {
|
|
97
|
+
if (typeof app.domain === "string") return app.domain;
|
|
98
|
+
if (app.domain[stage]) return app.domain[stage];
|
|
99
|
+
}
|
|
100
|
+
const baseDomain = domains?.[stage];
|
|
101
|
+
if (!baseDomain) throw new NoDomainForStage(stage);
|
|
102
|
+
if (isMainFrontend) return baseDomain;
|
|
103
|
+
return `${app.subdomain ?? appName}.${baseDomain}`;
|
|
107
104
|
}
|
|
108
105
|
/**
|
|
109
|
-
*
|
|
106
|
+
* Whether this app is the site the base domain points at — see `rootSite`,
|
|
107
|
+
* which the local edge asks too.
|
|
108
|
+
*
|
|
109
|
+
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
110
110
|
*/
|
|
111
|
-
function
|
|
112
|
-
if (
|
|
113
|
-
return
|
|
111
|
+
function isMainFrontendApp(appName, app, allApps) {
|
|
112
|
+
if (app.type !== "web") return false;
|
|
113
|
+
return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
|
|
114
|
+
name,
|
|
115
|
+
root: a.root
|
|
116
|
+
}))) === appName;
|
|
114
117
|
}
|
|
115
118
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* @param requiredVars - Array of environment variable names required by the application
|
|
119
|
-
* @param secrets - Stage secrets to validate against
|
|
120
|
-
* @returns Validation result with missing and provided variables
|
|
121
|
-
*
|
|
122
|
-
* @example
|
|
123
|
-
* ```typescript
|
|
124
|
-
* const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
|
|
125
|
-
* const secrets = await secretsStoreFor(workspace, 'production').then((s) => s.read('production'));
|
|
126
|
-
* const result = validateEnvironmentVariables(required, secrets);
|
|
119
|
+
* Get public URL arg names from app dependencies.
|
|
127
120
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* ```
|
|
121
|
+
* @param app - The web/mobile app configuration
|
|
122
|
+
* @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
|
|
123
|
+
* without a public prefix (e.g. Remix).
|
|
132
124
|
*/
|
|
133
|
-
function
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const provided = [];
|
|
138
|
-
for (const varName of requiredVars) if (availableVars.has(varName)) provided.push(varName);
|
|
139
|
-
else missing.push(varName);
|
|
140
|
-
return {
|
|
141
|
-
valid: missing.length === 0,
|
|
142
|
-
missing: missing.sort(),
|
|
143
|
-
provided: provided.sort(),
|
|
144
|
-
required: [...requiredVars].sort()
|
|
145
|
-
};
|
|
125
|
+
function getPublicUrlArgNames(app) {
|
|
126
|
+
const prefix = getPublicEnvPrefix(app.framework);
|
|
127
|
+
if (!prefix) return [];
|
|
128
|
+
return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
|
|
146
129
|
}
|
|
147
130
|
//#endregion
|
|
148
|
-
//#region src/
|
|
131
|
+
//#region src/compose/logs.ts
|
|
149
132
|
/**
|
|
150
|
-
*
|
|
133
|
+
* The stack's log UI: OpenObserve, run beside the apps as telemetry's
|
|
134
|
+
* self-hosted provider, and every process with a `Telemetry` edge pointed at
|
|
135
|
+
* it.
|
|
151
136
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
137
|
+
* The production server already exports its traces and pino logs over
|
|
138
|
+
* OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. OpenObserve takes OTLP
|
|
139
|
+
* at `/api/<org>/v1/{logs,traces}` with HTTP basic auth, so wiring an app is
|
|
140
|
+
* two variables: the endpoint, `http://openobserve:5080/api/default`, and
|
|
141
|
+
* the header that signs in as the root user.
|
|
142
|
+
*
|
|
143
|
+
* It is reached, by default, through an SSH tunnel: published on 127.0.0.1
|
|
144
|
+
* only, because a port Docker publishes on every interface is opened in the
|
|
145
|
+
* firewall by Docker itself, past ufw's rules. Served publicly, it is a host
|
|
146
|
+
* on the stack's Caddy that refuses every address not allowed.
|
|
147
|
+
*
|
|
148
|
+
* Pure: the stack decides it, and the phases print and check it.
|
|
158
149
|
*/
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
150
|
+
/**
|
|
151
|
+
* The image, pinned. A log store's on-disk format is the thing that must not
|
|
152
|
+
* move under a running stack, so a new version is a change to this line.
|
|
153
|
+
*/
|
|
154
|
+
const OPENOBSERVE_IMAGE = "public.ecr.aws/zinclabs/openobserve:v1.0.4";
|
|
155
|
+
/** Its compose service name — the host the apps send to. */
|
|
156
|
+
const LOGS_SERVICE = "openobserve";
|
|
157
|
+
/** The organisation every app's telemetry lands in. */
|
|
158
|
+
const LOGS_ORG = "default";
|
|
159
|
+
/** The stage secrets that sign in as OpenObserve's root user. */
|
|
160
|
+
const LOGS_EMAIL_KEY = "ZO_ROOT_USER_EMAIL";
|
|
161
|
+
const LOGS_PASSWORD_KEY = "ZO_ROOT_USER_PASSWORD";
|
|
162
|
+
/**
|
|
163
|
+
* The local stage's root user. Its password is generated per machine with
|
|
164
|
+
* the other local logins — see `reconcile/localCredentials.ts`.
|
|
165
|
+
*/
|
|
166
|
+
const LOCAL_LOGS_EMAIL = "admin@gkm.localhost";
|
|
167
|
+
/** A root password OpenObserve would refuse to start with. */
|
|
168
|
+
var LogsPasswordWeak = class extends Error {
|
|
169
|
+
stage;
|
|
170
|
+
constructor(stage) {
|
|
171
|
+
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.`);
|
|
172
|
+
this.stage = stage;
|
|
173
|
+
this.name = "LogsPasswordWeak";
|
|
166
174
|
}
|
|
167
175
|
};
|
|
168
|
-
/** A
|
|
169
|
-
var
|
|
170
|
-
|
|
171
|
-
constructor(
|
|
172
|
-
super(`The
|
|
173
|
-
this.
|
|
174
|
-
this.name = "
|
|
176
|
+
/** A deployed stage with logs on and no root password generated yet. */
|
|
177
|
+
var LogsPasswordMissing = class extends Error {
|
|
178
|
+
stage;
|
|
179
|
+
constructor(stage) {
|
|
180
|
+
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}`);
|
|
181
|
+
this.stage = stage;
|
|
182
|
+
this.name = "LogsPasswordMissing";
|
|
175
183
|
}
|
|
176
184
|
};
|
|
177
|
-
|
|
185
|
+
/** Whether OpenObserve accepts `password` for its root user. */
|
|
186
|
+
function isStrongLogsPassword(password) {
|
|
187
|
+
return password.length >= 8 && password.length <= 128 && /[a-z]/.test(password) && /[A-Z]/.test(password) && /\d/.test(password) && /[^A-Za-z0-9]/.test(password);
|
|
188
|
+
}
|
|
178
189
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
190
|
+
* A root password: 192 random bits, with a fixed tail so every class
|
|
191
|
+
* OpenObserve requires is present whatever the random part drew.
|
|
181
192
|
*/
|
|
182
|
-
function
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
/** The namespace a workspace is in when it does not name one. */
|
|
186
|
-
function defaultNamespace(workspaceName) {
|
|
187
|
-
return slug(kebabCase(workspaceName));
|
|
188
|
-
}
|
|
189
|
-
function deployIdentity(workspace, stage) {
|
|
190
|
-
const project = slug(workspace.name);
|
|
191
|
-
if (!project) throw new DeployProjectNameInvalid(workspace.name);
|
|
192
|
-
const fallback = defaultNamespace(workspace.name);
|
|
193
|
-
const namespace = workspace.deploy?.namespace ?? fallback;
|
|
194
|
-
if (!NAME.test(namespace)) throw new DeployNamespaceInvalid(namespace);
|
|
195
|
-
return {
|
|
196
|
-
namespace,
|
|
197
|
-
project,
|
|
198
|
-
stage,
|
|
199
|
-
key: `${namespace}/${project}`,
|
|
200
|
-
scope: namespace === fallback ? project : `${namespace}-${project}`
|
|
201
|
-
};
|
|
193
|
+
function generateLogsPassword() {
|
|
194
|
+
return `${randomBytes(24).toString("base64url")}-Zo1`;
|
|
202
195
|
}
|
|
203
196
|
/**
|
|
204
|
-
* The
|
|
205
|
-
*
|
|
206
|
-
*
|
|
197
|
+
* The stage's secrets with a generated root password, where it has none.
|
|
198
|
+
* Pure, like `withGeneratedSecrets`: the caller keeps the result once the
|
|
199
|
+
* run goes ahead.
|
|
207
200
|
*/
|
|
208
|
-
function
|
|
209
|
-
return
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
201
|
+
function withLogsPassword(secrets) {
|
|
202
|
+
if (secrets.custom?.["ZO_ROOT_USER_PASSWORD"]) return {
|
|
203
|
+
secrets,
|
|
204
|
+
generated: []
|
|
205
|
+
};
|
|
206
|
+
return {
|
|
207
|
+
secrets: {
|
|
208
|
+
...secrets,
|
|
209
|
+
custom: {
|
|
210
|
+
...secrets.custom,
|
|
211
|
+
[LOGS_PASSWORD_KEY]: generateLogsPassword()
|
|
212
|
+
},
|
|
213
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
214
|
+
},
|
|
215
|
+
generated: [LOGS_PASSWORD_KEY]
|
|
216
|
+
};
|
|
223
217
|
}
|
|
224
218
|
/**
|
|
225
|
-
*
|
|
226
|
-
* `<namespace>/<project>-<app>`.
|
|
219
|
+
* `OTEL_EXPORTER_OTLP_HEADERS` signing in as `email`.
|
|
227
220
|
*
|
|
228
|
-
* The
|
|
229
|
-
*
|
|
230
|
-
*
|
|
221
|
+
* The variable's values are percent-decoded by the SDK (they follow the W3C
|
|
222
|
+
* baggage format), so the space after `Basic` is written `%20`. Base64's own
|
|
223
|
+
* `+`, `/` and `=` pass through decoding unchanged.
|
|
231
224
|
*/
|
|
232
|
-
function
|
|
233
|
-
return `${
|
|
225
|
+
function otlpHeaders(email, password) {
|
|
226
|
+
return `Authorization=Basic%20${Buffer.from(`${email}:${password}`).toString("base64")}`;
|
|
234
227
|
}
|
|
235
|
-
/** The
|
|
236
|
-
function
|
|
237
|
-
const
|
|
238
|
-
|
|
228
|
+
/** The stack's OpenObserve for one stage. */
|
|
229
|
+
function stackLogs(options) {
|
|
230
|
+
const { config, stage, local, custom } = options;
|
|
231
|
+
if (!local && !options.domain) throw new NoDomainForStage(stage);
|
|
232
|
+
const email = custom["ZO_ROOT_USER_EMAIL"] ?? (local ? options.localLogin?.email ?? "admin@gkm.localhost" : `admin@${options.domain}`);
|
|
233
|
+
const set = custom[LOGS_PASSWORD_KEY];
|
|
234
|
+
const password = set ?? (local ? options.localLogin?.password : void 0);
|
|
235
|
+
if (!password) throw new LogsPasswordMissing(stage);
|
|
236
|
+
if (!isStrongLogsPassword(password)) throw new LogsPasswordWeak(stage);
|
|
237
|
+
const host = config.public ? local ? `logs.${options.project}.localhost` : `logs.${options.domain}` : void 0;
|
|
238
|
+
const url = host ? `https://${host}${local && options.https !== 443 ? `:${options.https}` : ""}` : `http://localhost:${config.port}`;
|
|
239
|
+
return {
|
|
240
|
+
...config,
|
|
241
|
+
email,
|
|
242
|
+
password,
|
|
243
|
+
passwordFromSecrets: Boolean(set),
|
|
244
|
+
url,
|
|
245
|
+
...host ? { host } : {},
|
|
246
|
+
env: {
|
|
247
|
+
[LOGS_EMAIL_KEY]: email,
|
|
248
|
+
[LOGS_PASSWORD_KEY]: password,
|
|
249
|
+
ZO_DATA_DIR: "/data",
|
|
250
|
+
ZO_COMPACT_DATA_RETENTION_DAYS: String(config.retentionDays),
|
|
251
|
+
ZO_TELEMETRY: "false"
|
|
252
|
+
},
|
|
253
|
+
appEnv: {
|
|
254
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: `http://${LOGS_SERVICE}:${LOGS_PORT}/api/${LOGS_ORG}`,
|
|
255
|
+
OTEL_EXPORTER_OTLP_HEADERS: otlpHeaders(email, password)
|
|
256
|
+
}
|
|
257
|
+
};
|
|
239
258
|
}
|
|
240
259
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* the stage and the identity: the application beside
|
|
245
|
-
* `production-kitchen-sink-database` is `production-kitchen-sink-api`. A
|
|
246
|
-
* Dokploy application's name is also its Docker service name, which is unique
|
|
247
|
-
* on the whole server, so the namespace has to be in it too — `acme`'s and
|
|
248
|
-
* `globex`'s `shop` would otherwise both run `production-shop-api`.
|
|
249
|
-
*
|
|
250
|
-
* The app id is dropped when it repeats the project, so a project named for its
|
|
251
|
-
* one application is `production-kitchen-sink` rather than
|
|
252
|
-
* `production-kitchen-sink-kitchen-sink`.
|
|
260
|
+
* OpenObserve's service. The image has no shell, `wget` or `curl`, so its
|
|
261
|
+
* health check is its own binary asking the running server for its status —
|
|
262
|
+
* which exits non-zero when nothing answers on its port.
|
|
253
263
|
*/
|
|
254
|
-
function
|
|
255
|
-
return
|
|
264
|
+
function logsService(logs) {
|
|
265
|
+
return {
|
|
266
|
+
image: OPENOBSERVE_IMAGE,
|
|
267
|
+
restart: "unless-stopped",
|
|
268
|
+
env_file: [{
|
|
269
|
+
path: `./${LOGS_SERVICE}.env`,
|
|
270
|
+
format: "raw"
|
|
271
|
+
}],
|
|
272
|
+
volumes: ["openobserve-data:/data"],
|
|
273
|
+
...logs.public ? {} : { ports: [`127.0.0.1:${logs.port}:${LOGS_PORT}`] },
|
|
274
|
+
healthcheck: {
|
|
275
|
+
test: [
|
|
276
|
+
"CMD",
|
|
277
|
+
"/openobserve",
|
|
278
|
+
"node",
|
|
279
|
+
"status"
|
|
280
|
+
],
|
|
281
|
+
interval: "10s",
|
|
282
|
+
timeout: "5s",
|
|
283
|
+
retries: 12,
|
|
284
|
+
start_period: "20s"
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** What `gkm compose` prints once the stack is up: how to open the logs. */
|
|
289
|
+
function logsAccess(logs, ctx) {
|
|
290
|
+
const login = `login: ${logs.email}, password: ${ctx.local && !logs.passwordFromSecrets ? logs.password : `gkm secrets:show --stage ${ctx.stage} --reveal → ${LOGS_PASSWORD_KEY}`}`;
|
|
291
|
+
if (logs.public) return [`📜 Logs (OpenObserve): ${logs.url} (${login})`, ` answered only for ${logs.public.allow.join(", ")}; every other address gets 403`];
|
|
292
|
+
return [
|
|
293
|
+
`📜 Logs (OpenObserve) on 127.0.0.1:${logs.port} — from your computer:`,
|
|
294
|
+
...ctx.local ? [` on this machine, open http://localhost:${logs.port} directly; from another:`] : [],
|
|
295
|
+
` ssh -N -L ${logs.port}:localhost:${logs.port} ${ctx.user}@${ctx.hostname} (user and host are guesses: this machine's)`,
|
|
296
|
+
` then open http://localhost:${logs.port} (${login})`,
|
|
297
|
+
`⚠️ 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.`
|
|
298
|
+
];
|
|
256
299
|
}
|
|
257
300
|
//#endregion
|
|
258
|
-
//#region src/secrets/
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
* overwrote the first project's. They are now kept under the project's
|
|
267
|
-
* identity, `<home>/keys/<namespace>/<project>/<stage>.key`, the same
|
|
268
|
-
* `<namespace>/<project>` a deploy claims its Dokploy project by. A key still
|
|
269
|
-
* at the old place is copied to the new one the first time it is read; the
|
|
270
|
-
* old file stays, so an older CLI on the same machine keeps working.
|
|
271
|
-
*/
|
|
272
|
-
/** Key length for AES-256 encryption */
|
|
273
|
-
const KEY_LENGTH = 32;
|
|
274
|
-
/** A keystore project key that is not `<namespace>/<project>`. */
|
|
275
|
-
var KeystoreProjectInvalid = class extends Error {
|
|
276
|
-
key;
|
|
277
|
-
constructor(key) {
|
|
278
|
-
super(`'${key}' is not a keystore project: expected '<namespace>/<project>' in lowercase letters, digits and '-', as deployIdentity() produces. Pass the workspace's identity key.`);
|
|
279
|
-
this.key = key;
|
|
280
|
-
this.name = "KeystoreProjectInvalid";
|
|
281
|
-
}
|
|
282
|
-
};
|
|
283
|
-
/** A stage has no key, here or at the place keys used to be kept. */
|
|
284
|
-
var KeyNotFound = class extends Error {
|
|
285
|
-
stage;
|
|
286
|
-
project;
|
|
287
|
-
path;
|
|
288
|
-
constructor(stage, project, path) {
|
|
289
|
-
super(`Encryption key not found for stage "${stage}" in project "${project}". Expected key at: ${path}. Run \`gkm secrets:init --stage ${stage}\` to create one, or copy the stage's key there.`);
|
|
290
|
-
this.stage = stage;
|
|
291
|
-
this.project = project;
|
|
292
|
-
this.path = path;
|
|
293
|
-
this.name = "KeyNotFound";
|
|
294
|
-
}
|
|
295
|
-
};
|
|
296
|
-
const PROJECT_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
301
|
+
//#region src/secrets/storage.ts
|
|
302
|
+
var storage_exports = /* @__PURE__ */ __exportAll({
|
|
303
|
+
initStageSecrets: () => initStageSecrets,
|
|
304
|
+
maskPassword: () => maskPassword,
|
|
305
|
+
toEmbeddableSecrets: () => toEmbeddableSecrets,
|
|
306
|
+
validateEnvironmentVariables: () => validateEnvironmentVariables,
|
|
307
|
+
withCustomSecret: () => withCustomSecret
|
|
308
|
+
});
|
|
297
309
|
/**
|
|
298
|
-
*
|
|
299
|
-
* a project's keys and its Dokploy project are named by the same thing.
|
|
310
|
+
* Initialize an empty StageSecrets object for a stage.
|
|
300
311
|
*/
|
|
301
|
-
function
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
/** The keystore project of a loaded workspace. */
|
|
305
|
-
function keystoreProject(workspace, home) {
|
|
306
|
-
const key = projectKey(workspace);
|
|
307
|
-
const unscoped = projectKey({ name: workspace.name });
|
|
312
|
+
function initStageSecrets(stage) {
|
|
313
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
308
314
|
return {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
315
|
+
stage,
|
|
316
|
+
createdAt: now,
|
|
317
|
+
updatedAt: now,
|
|
318
|
+
services: {},
|
|
319
|
+
urls: {},
|
|
320
|
+
custom: {}
|
|
313
321
|
};
|
|
314
322
|
}
|
|
315
323
|
/**
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*/
|
|
319
|
-
function isLegacyName(name) {
|
|
320
|
-
return name.split(/[\\/]/).every((part) => part !== ".." && part !== "");
|
|
321
|
-
}
|
|
322
|
-
/** The last segment of a path, as the old keystore named projects. */
|
|
323
|
-
function folderName(path) {
|
|
324
|
-
const name = path.split(/[\\/]/).filter(Boolean).at(-1);
|
|
325
|
-
return name && name !== "." && name !== ".." ? name : void 0;
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Get the keystore directory for a project:
|
|
329
|
-
* `<home>/keys/<namespace>/<project>`.
|
|
324
|
+
* Convert StageSecrets to embeddable format (flat key-value pairs).
|
|
325
|
+
* This is what gets encrypted and embedded in the bundle.
|
|
330
326
|
*/
|
|
331
|
-
function
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
327
|
+
function toEmbeddableSecrets(secrets) {
|
|
328
|
+
return {
|
|
329
|
+
...secrets.urls,
|
|
330
|
+
...secrets.custom,
|
|
331
|
+
...secrets.services.postgres && {
|
|
332
|
+
POSTGRES_USER: secrets.services.postgres.username,
|
|
333
|
+
POSTGRES_PASSWORD: secrets.services.postgres.password,
|
|
334
|
+
POSTGRES_DB: secrets.services.postgres.database ?? "app",
|
|
335
|
+
POSTGRES_HOST: secrets.services.postgres.host,
|
|
336
|
+
POSTGRES_PORT: String(secrets.services.postgres.port)
|
|
337
|
+
},
|
|
338
|
+
...secrets.services.redis && {
|
|
339
|
+
REDIS_PASSWORD: secrets.services.redis.password,
|
|
340
|
+
REDIS_HOST: secrets.services.redis.host,
|
|
341
|
+
REDIS_PORT: String(secrets.services.redis.port)
|
|
342
|
+
},
|
|
343
|
+
...secrets.services.minio && {
|
|
344
|
+
STORAGE_ACCESS_KEY_ID: secrets.services.minio.username,
|
|
345
|
+
STORAGE_SECRET_ACCESS_KEY: secrets.services.minio.password,
|
|
346
|
+
STORAGE_BUCKET: secrets.services.minio.bucket ?? "app",
|
|
347
|
+
STORAGE_REGION: "eu-west-1",
|
|
348
|
+
STORAGE_FORCE_PATH_STYLE: "true"
|
|
349
|
+
},
|
|
350
|
+
...secrets.services.mailpit && {
|
|
351
|
+
SMTP_HOST: secrets.services.mailpit.host,
|
|
352
|
+
SMTP_PORT: String(secrets.services.mailpit.port),
|
|
353
|
+
SMTP_USER: secrets.services.mailpit.username,
|
|
354
|
+
SMTP_PASS: secrets.services.mailpit.password,
|
|
355
|
+
SMTP_SECURE: "false",
|
|
356
|
+
MAIL_FROM: "noreply@localhost"
|
|
357
|
+
},
|
|
358
|
+
...secrets.services.localstack && {
|
|
359
|
+
AWS_ACCESS_KEY_ID: secrets.services.localstack.accessKeyId ?? secrets.services.localstack.username,
|
|
360
|
+
AWS_SECRET_ACCESS_KEY: secrets.services.localstack.password,
|
|
361
|
+
AWS_REGION: secrets.services.localstack.region ?? "us-east-1",
|
|
362
|
+
AWS_ENDPOINT_URL: `http://${secrets.services.localstack.host}:${secrets.services.localstack.port}`
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/** The same secrets with one custom value set, and `updatedAt` moved on. */
|
|
367
|
+
function withCustomSecret(secrets, key, value) {
|
|
368
|
+
return {
|
|
369
|
+
...secrets,
|
|
370
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
371
|
+
custom: {
|
|
372
|
+
...secrets.custom,
|
|
373
|
+
[key]: value
|
|
374
|
+
}
|
|
375
|
+
};
|
|
339
376
|
}
|
|
340
377
|
/**
|
|
341
|
-
*
|
|
342
|
-
* project, then where keys were kept before they were keyed by identity —
|
|
343
|
-
* under the configured home, then under `~/.gkm`, which is where every CLI
|
|
344
|
-
* before `GKM_HOME` wrote.
|
|
378
|
+
* Mask a password for display (show first 4 and last 2 chars).
|
|
345
379
|
*/
|
|
346
|
-
function
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
home: project.home
|
|
350
|
-
}));
|
|
351
|
-
const homes = [.../* @__PURE__ */ new Set([project.home ?? gkmHome(), join(homedir(), ".gkm")])];
|
|
352
|
-
const names = (project.legacy ?? []).filter(isLegacyName);
|
|
353
|
-
return [...aliases, ...names.flatMap((name) => homes.map((home) => join(home, name, `${stage}.key`)))];
|
|
380
|
+
function maskPassword(password) {
|
|
381
|
+
if (password.length <= 8) return "********";
|
|
382
|
+
return `${password.slice(0, 4)}${"*".repeat(password.length - 6)}${password.slice(-2)}`;
|
|
354
383
|
}
|
|
355
384
|
/**
|
|
356
|
-
*
|
|
357
|
-
*
|
|
385
|
+
* Validate that all required environment variables are present in secrets.
|
|
386
|
+
*
|
|
387
|
+
* @param requiredVars - Array of environment variable names required by the application
|
|
388
|
+
* @param secrets - Stage secrets to validate against
|
|
389
|
+
* @returns Validation result with missing and provided variables
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* ```typescript
|
|
393
|
+
* const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
|
|
394
|
+
* const secrets = await secretsStoreFor(workspace, 'production').then((s) => s.read('production'));
|
|
395
|
+
* const result = validateEnvironmentVariables(required, secrets);
|
|
396
|
+
*
|
|
397
|
+
* if (!result.valid) {
|
|
398
|
+
* console.error(`Missing environment variables: ${result.missing.join(', ')}`);
|
|
399
|
+
* }
|
|
400
|
+
* ```
|
|
358
401
|
*/
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
await chmod(keyPath, 384);
|
|
373
|
-
return key;
|
|
402
|
+
function validateEnvironmentVariables(requiredVars, secrets) {
|
|
403
|
+
const embeddable = toEmbeddableSecrets(secrets);
|
|
404
|
+
const availableVars = new Set(Object.keys(embeddable));
|
|
405
|
+
const missing = [];
|
|
406
|
+
const provided = [];
|
|
407
|
+
for (const varName of requiredVars) if (availableVars.has(varName)) provided.push(varName);
|
|
408
|
+
else missing.push(varName);
|
|
409
|
+
return {
|
|
410
|
+
valid: missing.length === 0,
|
|
411
|
+
missing: missing.sort(),
|
|
412
|
+
provided: provided.sort(),
|
|
413
|
+
required: [...requiredVars].sort()
|
|
414
|
+
};
|
|
374
415
|
}
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/deploy/identity.ts
|
|
375
418
|
/**
|
|
376
|
-
*
|
|
419
|
+
* Who a deploy is, on a target it shares with other workspaces.
|
|
377
420
|
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
421
|
+
* A Dokploy server is one flat namespace of projects, images and Docker
|
|
422
|
+
* service names. Deploys used to identify everything they made by the
|
|
423
|
+
* workspace name alone, so two workspaces called `shop` — or `Shop` and
|
|
424
|
+
* `shop` — deploying to one server landed in the same project, pushed over
|
|
425
|
+
* each other's images and redeployed each other's applications. A
|
|
426
|
+
* `DeployIdentity` is what a deploy names and claims its resources by instead.
|
|
381
427
|
*/
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
});
|
|
390
|
-
try {
|
|
391
|
-
await copyFile(legacy, keyPath, constants.COPYFILE_EXCL);
|
|
392
|
-
} catch (error) {
|
|
393
|
-
if (error.code !== "EEXIST") throw error;
|
|
428
|
+
/** A namespace that cannot be part of a Dokploy name or an image path. */
|
|
429
|
+
var DeployNamespaceInvalid = class extends Error {
|
|
430
|
+
namespace;
|
|
431
|
+
constructor(namespace) {
|
|
432
|
+
super(`deploy.namespace '${namespace}' is not usable as a name: use lowercase letters, digits and single '-' between them (e.g. 'acme' or 'acme-platform') in gkm.config.ts.`);
|
|
433
|
+
this.namespace = namespace;
|
|
434
|
+
this.name = "DeployNamespaceInvalid";
|
|
394
435
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
436
|
+
};
|
|
437
|
+
/** A workspace name with nothing a target can name a resource by. */
|
|
438
|
+
var DeployProjectNameInvalid = class extends Error {
|
|
439
|
+
workspaceName;
|
|
440
|
+
constructor(workspaceName) {
|
|
441
|
+
super(`The workspace name '${workspaceName}' has no letters or digits to name deployed resources by. Set "name" in gkm.config.ts.`);
|
|
442
|
+
this.workspaceName = workspaceName;
|
|
443
|
+
this.name = "DeployProjectNameInvalid";
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
398
447
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
448
|
+
* Lowercase `[a-z0-9-]`, runs of anything else collapsed to one `-`: valid as
|
|
449
|
+
* a Docker image path component, a Docker service name and a Dokploy name.
|
|
401
450
|
*/
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
451
|
+
function slug(value) {
|
|
452
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
453
|
+
}
|
|
454
|
+
/** The namespace a workspace is in when it does not name one. */
|
|
455
|
+
function defaultNamespace(workspaceName) {
|
|
456
|
+
return slug(kebabCase(workspaceName));
|
|
457
|
+
}
|
|
458
|
+
function deployIdentity(workspace, stage) {
|
|
459
|
+
const project = slug(workspace.name);
|
|
460
|
+
if (!project) throw new DeployProjectNameInvalid(workspace.name);
|
|
461
|
+
const fallback = defaultNamespace(workspace.name);
|
|
462
|
+
const namespace = workspace.deploy?.namespace ?? fallback;
|
|
463
|
+
if (!NAME.test(namespace)) throw new DeployNamespaceInvalid(namespace);
|
|
464
|
+
return {
|
|
465
|
+
namespace,
|
|
466
|
+
project,
|
|
467
|
+
stage,
|
|
468
|
+
key: `${namespace}/${project}`,
|
|
469
|
+
scope: namespace === fallback ? project : `${namespace}-${project}`
|
|
470
|
+
};
|
|
406
471
|
}
|
|
407
472
|
/**
|
|
408
|
-
*
|
|
409
|
-
*
|
|
473
|
+
* The token a Dokploy project's description carries to say which identity
|
|
474
|
+
* created it. A project with the right name and no marker belongs to someone
|
|
475
|
+
* else, and a deploy never adopts it.
|
|
410
476
|
*/
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
477
|
+
function ownershipMarker(identity) {
|
|
478
|
+
return `gkm:${identity.key}`;
|
|
479
|
+
}
|
|
480
|
+
/** Whether a project description carries `identity`'s marker. */
|
|
481
|
+
function isOwnedBy(description, identity) {
|
|
482
|
+
const marker = ownershipMarker(identity);
|
|
483
|
+
return (description ?? "").split(/\s+/).includes(marker);
|
|
484
|
+
}
|
|
485
|
+
/** Any `gkm:` marker a description carries — whoever's it is. */
|
|
486
|
+
function markerOf(description) {
|
|
487
|
+
return (description ?? "").split(/\s+/).find((t) => t.startsWith("gkm:"));
|
|
488
|
+
}
|
|
489
|
+
/** What the Dokploy project is called. */
|
|
490
|
+
function projectName(identity) {
|
|
491
|
+
return identity.scope;
|
|
415
492
|
}
|
|
416
493
|
/**
|
|
417
|
-
*
|
|
418
|
-
*
|
|
494
|
+
* The repository an app's image is pushed to, below the registry:
|
|
495
|
+
* `<namespace>/<project>-<app>`.
|
|
496
|
+
*
|
|
497
|
+
* The namespace is a path segment of its own so that a registry that maps
|
|
498
|
+
* paths to owners (an organisation's packages, a Harbor project) can grant it
|
|
499
|
+
* as one; the app is joined to the project so the two read as one name.
|
|
419
500
|
*/
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return requireKey(stage, project);
|
|
428
|
-
}
|
|
501
|
+
function imageName(identity, app) {
|
|
502
|
+
return `${identity.namespace}/${identity.project}-${slug(kebabCase(app))}`;
|
|
503
|
+
}
|
|
504
|
+
/** The full image ref an app is built, pushed and deployed as. */
|
|
505
|
+
function imageRef(identity, app, registry, tag) {
|
|
506
|
+
const name = imageName(identity, app);
|
|
507
|
+
return registry ? `${registry.replace(/\/+$/, "")}/${name}:${tag}` : `${name}:${tag}`;
|
|
429
508
|
}
|
|
430
|
-
//#endregion
|
|
431
|
-
//#region src/secrets/file.ts
|
|
432
|
-
/** Where the files live, relative to the project root. */
|
|
433
|
-
const SECRETS_DIR = ".gkm/secrets";
|
|
434
|
-
/** AES-256-GCM configuration */
|
|
435
|
-
const ALGORITHM = "aes-256-gcm";
|
|
436
|
-
const IV_LENGTH = 12;
|
|
437
509
|
/**
|
|
438
|
-
*
|
|
439
|
-
* `.gkm/secrets/<stage>.json`, with its key in the CLI's home at
|
|
440
|
-
* `keys/<namespace>/<project>/<stage>.key`.
|
|
510
|
+
* What one application is called on the target.
|
|
441
511
|
*
|
|
442
|
-
* The
|
|
443
|
-
*
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
512
|
+
* The same rule the constructs use, through the same `scopedName`, scoped by
|
|
513
|
+
* the stage and the identity: the application beside
|
|
514
|
+
* `production-kitchen-sink-database` is `production-kitchen-sink-api`. A
|
|
515
|
+
* Dokploy application's name is also its Docker service name, which is unique
|
|
516
|
+
* on the whole server, so the namespace has to be in it too — `acme`'s and
|
|
517
|
+
* `globex`'s `shop` would otherwise both run `production-shop-api`.
|
|
518
|
+
*
|
|
519
|
+
* The app id is dropped when it repeats the project, so a project named for its
|
|
520
|
+
* one application is `production-kitchen-sink` rather than
|
|
521
|
+
* `production-kitchen-sink-kitchen-sink`.
|
|
522
|
+
*/
|
|
523
|
+
function applicationName(identity, app) {
|
|
524
|
+
return slug(app) === identity.project ? `${identity.stage}-${identity.scope}` : scopedName([identity.stage, identity.scope], app);
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/secrets/keystore.ts
|
|
528
|
+
/**
|
|
529
|
+
* The keys that open each stage's encrypted secrets file, kept outside the
|
|
530
|
+
* project in the CLI's home.
|
|
531
|
+
*
|
|
532
|
+
* Keys were kept at `~/.gkm/<folder>/<stage>.key`, named after the project
|
|
533
|
+
* directory — so two checkouts in folders with the same name (`~/work/api`
|
|
534
|
+
* and `~/oss/api`) shared one key, and the second `gkm secrets:init`
|
|
535
|
+
* overwrote the first project's. They are now kept under the project's
|
|
536
|
+
* identity, `<home>/keys/<namespace>/<project>/<stage>.key`, the same
|
|
537
|
+
* `<namespace>/<project>` a deploy claims its Dokploy project by. A key still
|
|
538
|
+
* at the old place is copied to the new one the first time it is read; the
|
|
539
|
+
* old file stays, so an older CLI on the same machine keeps working.
|
|
540
|
+
*/
|
|
541
|
+
/** Key length for AES-256 encryption */
|
|
542
|
+
const KEY_LENGTH = 32;
|
|
543
|
+
/** A keystore project key that is not `<namespace>/<project>`. */
|
|
544
|
+
var KeystoreProjectInvalid = class extends Error {
|
|
545
|
+
key;
|
|
546
|
+
constructor(key) {
|
|
547
|
+
super(`'${key}' is not a keystore project: expected '<namespace>/<project>' in lowercase letters, digits and '-', as deployIdentity() produces. Pass the workspace's identity key.`);
|
|
548
|
+
this.key = key;
|
|
549
|
+
this.name = "KeystoreProjectInvalid";
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
/** A stage has no key, here or at the place keys used to be kept. */
|
|
553
|
+
var KeyNotFound = class extends Error {
|
|
554
|
+
stage;
|
|
555
|
+
project;
|
|
556
|
+
path;
|
|
557
|
+
constructor(stage, project, path) {
|
|
558
|
+
super(`Encryption key not found for stage "${stage}" in project "${project}". Expected key at: ${path}. Run \`gkm secrets:init --stage ${stage}\` to create one, or copy the stage's key there.`);
|
|
559
|
+
this.stage = stage;
|
|
560
|
+
this.project = project;
|
|
561
|
+
this.path = path;
|
|
562
|
+
this.name = "KeyNotFound";
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const PROJECT_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
566
|
+
/**
|
|
567
|
+
* The keystore key of a workspace: its deploy identity without the stage, so
|
|
568
|
+
* a project's keys and its Dokploy project are named by the same thing.
|
|
569
|
+
*/
|
|
570
|
+
function projectKey(workspace) {
|
|
571
|
+
return deployIdentity(workspace, "").key;
|
|
572
|
+
}
|
|
573
|
+
/** The keystore project of a loaded workspace. */
|
|
574
|
+
function keystoreProject(workspace, home) {
|
|
575
|
+
const key = projectKey(workspace);
|
|
576
|
+
const unscoped = projectKey({ name: workspace.name });
|
|
577
|
+
return {
|
|
578
|
+
key,
|
|
579
|
+
legacy: [.../* @__PURE__ */ new Set([folderName(workspace.root), workspace.name])].filter((name) => !!name),
|
|
580
|
+
...unscoped !== key ? { aliases: [unscoped] } : {},
|
|
581
|
+
...home ? { home } : {}
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Whether `name` stays inside the home it is joined to. A workspace name was
|
|
586
|
+
* joined as is — `@acme/shop` is `~/.gkm/@acme/shop` — but never upwards.
|
|
587
|
+
*/
|
|
588
|
+
function isLegacyName(name) {
|
|
589
|
+
return name.split(/[\\/]/).every((part) => part !== ".." && part !== "");
|
|
590
|
+
}
|
|
591
|
+
/** The last segment of a path, as the old keystore named projects. */
|
|
592
|
+
function folderName(path) {
|
|
593
|
+
const name = path.split(/[\\/]/).filter(Boolean).at(-1);
|
|
594
|
+
return name && name !== "." && name !== ".." ? name : void 0;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Get the keystore directory for a project:
|
|
598
|
+
* `<home>/keys/<namespace>/<project>`.
|
|
599
|
+
*/
|
|
600
|
+
function getKeystoreDir(project) {
|
|
601
|
+
if (!PROJECT_KEY.test(project.key)) throw new KeystoreProjectInvalid(project.key);
|
|
602
|
+
const [namespace, name] = project.key.split("/");
|
|
603
|
+
return join(project.home ?? gkmHome(), "keys", namespace, name);
|
|
604
|
+
}
|
|
605
|
+
/** Get the path to a stage's encryption key. */
|
|
606
|
+
function getKeyPath(stage, project) {
|
|
607
|
+
return join(getKeystoreDir(project), `${stage}.key`);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Where else a stage's key may be, most likely first: under an alias of the
|
|
611
|
+
* project, then where keys were kept before they were keyed by identity —
|
|
612
|
+
* under the configured home, then under `~/.gkm`, which is where every CLI
|
|
613
|
+
* before `GKM_HOME` wrote.
|
|
614
|
+
*/
|
|
615
|
+
function legacyKeyPaths(stage, project) {
|
|
616
|
+
const aliases = (project.aliases ?? []).map((key) => getKeyPath(stage, {
|
|
617
|
+
key,
|
|
618
|
+
home: project.home
|
|
619
|
+
}));
|
|
620
|
+
const homes = [.../* @__PURE__ */ new Set([project.home ?? gkmHome(), join(homedir(), ".gkm")])];
|
|
621
|
+
const names = (project.legacy ?? []).filter(isLegacyName);
|
|
622
|
+
return [...aliases, ...names.flatMap((name) => homes.map((home) => join(home, name, `${stage}.key`)))];
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Generate a new encryption key for a stage, stored owner-only at
|
|
626
|
+
* `<home>/keys/<namespace>/<project>/<stage>.key`.
|
|
627
|
+
*/
|
|
628
|
+
async function generateKey(stage, project, flag = "w") {
|
|
629
|
+
const keyDir = getKeystoreDir(project);
|
|
630
|
+
const keyPath = getKeyPath(stage, project);
|
|
631
|
+
await mkdir(keyDir, {
|
|
632
|
+
recursive: true,
|
|
633
|
+
mode: 448
|
|
634
|
+
});
|
|
635
|
+
const key = randomBytes(KEY_LENGTH).toString("hex");
|
|
636
|
+
await writeFile(keyPath, key, {
|
|
637
|
+
mode: 384,
|
|
638
|
+
encoding: "utf-8",
|
|
639
|
+
flag
|
|
640
|
+
});
|
|
641
|
+
await chmod(keyPath, 384);
|
|
642
|
+
return key;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Copy a key from where it used to be kept to where it is kept now.
|
|
646
|
+
*
|
|
647
|
+
* Copied, not moved: an older CLI on the same machine still reads the old
|
|
648
|
+
* place. Exclusive, so two processes migrating at once cannot overwrite a key
|
|
649
|
+
* one of them already put there.
|
|
650
|
+
*/
|
|
651
|
+
async function migrateLegacyKey(stage, project) {
|
|
652
|
+
const legacy = legacyKeyPaths(stage, project).find((path) => existsSync(path));
|
|
653
|
+
if (!legacy) return false;
|
|
654
|
+
const keyPath = getKeyPath(stage, project);
|
|
655
|
+
await mkdir(getKeystoreDir(project), {
|
|
656
|
+
recursive: true,
|
|
657
|
+
mode: 448
|
|
658
|
+
});
|
|
659
|
+
try {
|
|
660
|
+
await copyFile(legacy, keyPath, constants.COPYFILE_EXCL);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (error.code !== "EEXIST") throw error;
|
|
663
|
+
}
|
|
664
|
+
await chmod(keyPath, 384);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Read an encryption key for a stage, or `null` when there is none — after
|
|
669
|
+
* copying it over from where keys used to be kept, if it is still there.
|
|
670
|
+
*/
|
|
671
|
+
async function readKey(stage, project) {
|
|
672
|
+
const keyPath = getKeyPath(stage, project);
|
|
673
|
+
if (!existsSync(keyPath) && !await migrateLegacyKey(stage, project)) return null;
|
|
674
|
+
return (await readFile(keyPath, "utf-8")).trim();
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Read an encryption key for a stage, throwing {@link KeyNotFound} if there
|
|
678
|
+
* is none.
|
|
679
|
+
*/
|
|
680
|
+
async function requireKey(stage, project) {
|
|
681
|
+
const key = await readKey(stage, project);
|
|
682
|
+
if (!key) throw new KeyNotFound(stage, project.key, getKeyPath(stage, project));
|
|
683
|
+
return key;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Get or create a key for a stage.
|
|
687
|
+
* If the key already exists, it is returned. Otherwise, a new key is generated.
|
|
688
|
+
*/
|
|
689
|
+
async function getOrCreateKey(stage, project) {
|
|
690
|
+
const existingKey = await readKey(stage, project);
|
|
691
|
+
if (existingKey) return existingKey;
|
|
692
|
+
try {
|
|
693
|
+
return await generateKey(stage, project, "wx");
|
|
694
|
+
} catch (error) {
|
|
695
|
+
if (error.code !== "EEXIST") throw error;
|
|
696
|
+
return requireKey(stage, project);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region src/secrets/file.ts
|
|
701
|
+
/** Where the files live, relative to the project root. */
|
|
702
|
+
const SECRETS_DIR = ".gkm/secrets";
|
|
703
|
+
/** AES-256-GCM configuration */
|
|
704
|
+
const ALGORITHM = "aes-256-gcm";
|
|
705
|
+
const IV_LENGTH = 12;
|
|
706
|
+
/**
|
|
707
|
+
* A stage's secrets as an encrypted file on this machine:
|
|
708
|
+
* `.gkm/secrets/<stage>.json`, with its key in the CLI's home at
|
|
709
|
+
* `keys/<namespace>/<project>/<stage>.key`.
|
|
710
|
+
*
|
|
711
|
+
* The local stage's store, always — its secrets belong to the machine running
|
|
712
|
+
* `gkm dev` — and a deployed stage's when `secrets.store` is `'file'`.
|
|
713
|
+
*/
|
|
714
|
+
var FileSecretsStore = class {
|
|
715
|
+
root;
|
|
716
|
+
name = "file";
|
|
717
|
+
project;
|
|
718
|
+
/**
|
|
719
|
+
* @param project - Whose keys: a workspace's `keystoreProject()`. Outside a
|
|
451
720
|
* workspace there is no config to name the project, so it is named the
|
|
452
721
|
* way a workspace without `name` is — its package name, else its folder.
|
|
453
722
|
*/
|
|
@@ -850,7 +1119,8 @@ const DEFAULT_IMAGES = {
|
|
|
850
1119
|
"redis-http": "hiett/serverless-redis-http:latest",
|
|
851
1120
|
rabbitmq: "rabbitmq:4-management-alpine",
|
|
852
1121
|
localstack: "floci/floci:2.1.0",
|
|
853
|
-
caddy: "caddy:2-alpine"
|
|
1122
|
+
caddy: "caddy:2-alpine",
|
|
1123
|
+
openobserve: OPENOBSERVE_IMAGE
|
|
854
1124
|
};
|
|
855
1125
|
/**
|
|
856
1126
|
* The ports each container listens on.
|
|
@@ -914,6 +1184,12 @@ const PORTS = {
|
|
|
914
1184
|
key: "caddy",
|
|
915
1185
|
inside: 443,
|
|
916
1186
|
label: "https edge"
|
|
1187
|
+
}],
|
|
1188
|
+
openobserve: [{
|
|
1189
|
+
key: "openobserve",
|
|
1190
|
+
inside: LOGS_PORT,
|
|
1191
|
+
label: "openobserve",
|
|
1192
|
+
web: true
|
|
917
1193
|
}]
|
|
918
1194
|
};
|
|
919
1195
|
/** The port key — and container name, for an image — of an API's fake. */
|
|
@@ -985,50 +1261,630 @@ function volumeOf(container) {
|
|
|
985
1261
|
redis: "redis-data",
|
|
986
1262
|
rabbitmq: "rabbitmq-data",
|
|
987
1263
|
localstack: "localstack-data",
|
|
988
|
-
caddy: "caddy-data"
|
|
1264
|
+
caddy: "caddy-data",
|
|
1265
|
+
openobserve: "openobserve-data"
|
|
989
1266
|
}[container];
|
|
990
1267
|
}
|
|
991
1268
|
//#endregion
|
|
992
|
-
//#region src/
|
|
1269
|
+
//#region src/telemetry/edges.ts
|
|
1270
|
+
/**
|
|
1271
|
+
* Which processes use a `Telemetry` node — read off the manifest's edges.
|
|
1272
|
+
*
|
|
1273
|
+
* A process uses telemetry when its own declaration names the node: a
|
|
1274
|
+
* surface's `telemetry`, a worker's. The server the build generates for a
|
|
1275
|
+
* surface whose endpoints a glob finds also runs the workers' crons and
|
|
1276
|
+
* consumers under `gkm dev`, and is handed their edges' keys by `appEnvKeys`,
|
|
1277
|
+
* so a worker's edge counts for it too — the same rule, read the same way.
|
|
1278
|
+
*
|
|
1279
|
+
* A site's edge is recorded and never followed here: a site is handed none of
|
|
1280
|
+
* the node's keys (see `PUBLIC.telemetry`).
|
|
1281
|
+
*/
|
|
1282
|
+
/** The `OTEL_*` keys a `Telemetry` node provides. */
|
|
1283
|
+
const TELEMETRY_ENV_KEYS = TELEMETRY_KEYS;
|
|
1284
|
+
/** Whether `key` is one of the keys a `Telemetry` node provides. */
|
|
1285
|
+
function isTelemetryKey(key) {
|
|
1286
|
+
return TELEMETRY_ENV_KEYS.includes(key);
|
|
1287
|
+
}
|
|
1288
|
+
/** The node a declaration's `telemetry` edge names, when it names one. */
|
|
1289
|
+
function nodeOf(manifest, id) {
|
|
1290
|
+
if (!id) return void 0;
|
|
1291
|
+
const node = manifest[id];
|
|
1292
|
+
return node?.kind === "telemetry" ? node : void 0;
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* The `Telemetry` node the process for `id` — a surface or a worker, by
|
|
1296
|
+
* construct id — emits through, or undefined when it has no edge to one.
|
|
1297
|
+
*/
|
|
1298
|
+
function telemetryOf(manifest, id) {
|
|
1299
|
+
const declaration = manifest[id];
|
|
1300
|
+
if (!declaration) return void 0;
|
|
1301
|
+
if (declaration.kind === "worker") return nodeOf(manifest, declaration.telemetry);
|
|
1302
|
+
if (declaration.kind !== "rest-api") return void 0;
|
|
1303
|
+
const own = nodeOf(manifest, declaration.telemetry);
|
|
1304
|
+
if (own) return own;
|
|
1305
|
+
if (declaration.endpoints.length > 0) return void 0;
|
|
1306
|
+
for (const other of Object.values(manifest)) {
|
|
1307
|
+
if (other.kind !== "worker") continue;
|
|
1308
|
+
const node = nodeOf(manifest, other.telemetry);
|
|
1309
|
+
if (node) return node;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
993
1312
|
/**
|
|
994
|
-
* The
|
|
995
|
-
*
|
|
996
|
-
* agree on.
|
|
997
|
-
*
|
|
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.
|
|
1313
|
+
* The `Telemetry` node the app `appName` emits through — its surface's, or
|
|
1314
|
+
* undefined for a site, an app without one, or one with no edge.
|
|
1001
1315
|
*/
|
|
1002
|
-
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1316
|
+
function appTelemetry(manifest, appName) {
|
|
1317
|
+
const id = Object.entries(manifest).find(([id, d]) => d.kind === "rest-api" && appKey(id) === appName)?.[0];
|
|
1318
|
+
return id ? telemetryOf(manifest, id) : void 0;
|
|
1319
|
+
}
|
|
1320
|
+
/** Whether any process in the manifest emits through a `Telemetry` node. */
|
|
1321
|
+
function usesTelemetry(manifest) {
|
|
1322
|
+
return Object.entries(manifest).some(([id, d]) => (d.kind === "rest-api" || d.kind === "worker") && telemetryOf(manifest, id) !== void 0);
|
|
1323
|
+
}
|
|
1005
1324
|
/**
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1325
|
+
* One process's share of the telemetry keys a stage resolved: all of them,
|
|
1326
|
+
* named for the process, when it has an edge to the node — and none
|
|
1327
|
+
* otherwise.
|
|
1328
|
+
*
|
|
1329
|
+
* `env` is everything else the process is handed; its `OTEL_*` keys are
|
|
1330
|
+
* replaced, never merged, so a process without the edge cannot inherit them.
|
|
1011
1331
|
*/
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1332
|
+
function scopeTelemetryEnv(env, options) {
|
|
1333
|
+
const scoped = Object.fromEntries(Object.entries(env).filter(([key]) => !isTelemetryKey(key)));
|
|
1334
|
+
if (!options.uses || !options.telemetry) return scoped;
|
|
1335
|
+
const values = Object.fromEntries(Object.entries(options.telemetry).filter(([key]) => isTelemetryKey(key)));
|
|
1336
|
+
if (!values.OTEL_EXPORTER_OTLP_ENDPOINT) return scoped;
|
|
1337
|
+
return {
|
|
1338
|
+
...scoped,
|
|
1339
|
+
...values,
|
|
1340
|
+
OTEL_SERVICE_NAME: options.serviceName
|
|
1341
|
+
};
|
|
1016
1342
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1343
|
+
/** Whether the site `siteId` has an edge to a `Telemetry` node. */
|
|
1344
|
+
function siteUsesTelemetry(manifest, siteId) {
|
|
1345
|
+
const site = manifest[siteId];
|
|
1346
|
+
return site?.kind === "site" && nodeOf(manifest, site.telemetry) !== void 0;
|
|
1019
1347
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1348
|
+
/**
|
|
1349
|
+
* Whether the client of the surface `surfaceId` propagates trace context:
|
|
1350
|
+
* when a site that calls it has an edge to a `Telemetry` node. One client
|
|
1351
|
+
* serves every site that calls the surface on the host, so one with the
|
|
1352
|
+
* edge turns it on; a site's image generates its own, from its own edge.
|
|
1353
|
+
*/
|
|
1354
|
+
function surfaceClientTraced(manifest, surfaceId) {
|
|
1355
|
+
return Object.entries(manifest).some(([id, d]) => d.kind === "site" && siteUsesTelemetry(manifest, id) && d.dependencies.some((edge) => edge.target === surfaceId));
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Whether the site app `appName`'s clients propagate trace context, and at
|
|
1359
|
+
* what rate: when the site has an edge to a `Telemetry` construct, at the
|
|
1360
|
+
* stage's sample rate — a public value, every trace locally — and not at all
|
|
1361
|
+
* for a stage that sends no telemetry.
|
|
1362
|
+
*/
|
|
1363
|
+
function siteClientTelemetry(manifest, appName, workspace, stage) {
|
|
1364
|
+
const id = Object.entries(manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[0];
|
|
1365
|
+
if (!id || !siteUsesTelemetry(manifest, id)) return void 0;
|
|
1366
|
+
const sampleRate = stageSampleRate(workspace.deploy?.telemetry, stage ?? workspace.stages.local, !stage || stage === workspace.stages.local);
|
|
1367
|
+
return sampleRate === void 0 ? void 0 : { sampleRate };
|
|
1022
1368
|
}
|
|
1023
1369
|
//#endregion
|
|
1024
|
-
//#region src/reconcile/
|
|
1370
|
+
//#region src/reconcile/caddyfile.ts
|
|
1025
1371
|
/**
|
|
1026
|
-
* The
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1372
|
+
* The kinds the edge serves.
|
|
1373
|
+
*
|
|
1374
|
+
* Everything that owns a *public address*. Deployed, each of these has a real
|
|
1375
|
+
* hostname and a certificate, so this is what makes the local shape the same
|
|
1376
|
+
* one rather than a near-enough one — and the application is indifferent
|
|
1377
|
+
* either way, because it reads whichever address was injected and never
|
|
1378
|
+
* composes one.
|
|
1379
|
+
*
|
|
1380
|
+
* Adding a kind here is the whole of adding it to the edge.
|
|
1381
|
+
*/
|
|
1382
|
+
const EDGE_KINDS = {
|
|
1383
|
+
"file-server": true,
|
|
1384
|
+
"rest-api": true,
|
|
1385
|
+
site: true
|
|
1386
|
+
};
|
|
1387
|
+
/**
|
|
1388
|
+
* How the edge reaches the host, from inside its container.
|
|
1389
|
+
*
|
|
1390
|
+
* A surface and a site are served by processes `gkm dev` starts on the host,
|
|
1391
|
+
* not by containers — so the edge has to leave Docker's network to reach them.
|
|
1392
|
+
* Docker Desktop resolves this name already; on Linux the compose definition
|
|
1393
|
+
* maps it to the host gateway.
|
|
1394
|
+
*/
|
|
1395
|
+
const HOST_GATEWAY = "host.docker.internal";
|
|
1396
|
+
/**
|
|
1397
|
+
* The suffix every local host shares.
|
|
1398
|
+
*
|
|
1399
|
+
* `.localhost` rather than a made-up TLD because it resolves to loopback
|
|
1400
|
+
* without touching `/etc/hosts` — macOS and systemd-resolved both answer for
|
|
1401
|
+
* subdomains of it, and Caddy issues internal certificates for it by default.
|
|
1402
|
+
*/
|
|
1403
|
+
const LOCAL_TLD = "localhost";
|
|
1404
|
+
/**
|
|
1405
|
+
* The sites a plan implies, one per file server.
|
|
1406
|
+
*
|
|
1407
|
+
* The leading label is the *server's* stage-scoped name rather than the
|
|
1408
|
+
* bucket's, which is the difference that matters: two servers over one bucket
|
|
1409
|
+
* are a legitimate arrangement — two cache behaviours, one origin — and naming
|
|
1410
|
+
* the host after the bucket would make them collide.
|
|
1411
|
+
*/
|
|
1412
|
+
function sitesFor(plan, project, addresses = {}) {
|
|
1413
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1414
|
+
const sites = [];
|
|
1415
|
+
for (const resource of plan.resources) {
|
|
1416
|
+
if (!EDGE_KINDS[resource.kind]) continue;
|
|
1417
|
+
const host = hostFor(resource, project);
|
|
1418
|
+
if (resource.kind === "file-server") {
|
|
1419
|
+
const origin = resource.of ? byId.get(resource.of) : void 0;
|
|
1420
|
+
if (!origin) continue;
|
|
1421
|
+
sites.push({
|
|
1422
|
+
host,
|
|
1423
|
+
upstream: "http://minio:9000",
|
|
1424
|
+
rewrite: `/${origin.name}{uri}`
|
|
1425
|
+
});
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
const address = addresses[resource.id];
|
|
1429
|
+
if (!address) continue;
|
|
1430
|
+
const port = portOf(address);
|
|
1431
|
+
if (!port) continue;
|
|
1432
|
+
sites.push({
|
|
1433
|
+
host,
|
|
1434
|
+
upstream: `http://${HOST_GATEWAY}:${port}`
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
return sites;
|
|
1438
|
+
}
|
|
1439
|
+
/** The port a local address answers on, defaulting by scheme. */
|
|
1440
|
+
function portOf(address) {
|
|
1441
|
+
try {
|
|
1442
|
+
const { port, protocol } = new URL(address);
|
|
1443
|
+
return port || (protocol === "https:" ? "443" : "80");
|
|
1444
|
+
} catch {
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Where one file server answers, including the stage its name carries.
|
|
1450
|
+
*
|
|
1451
|
+
* The project is a label rather than a decoration: it is what keeps two
|
|
1452
|
+
* checkouts from answering on one address. Omitted when there is no project to
|
|
1453
|
+
* name, because an empty label makes `uploads..localhost` — a host nothing
|
|
1454
|
+
* resolves and no certificate can be issued for.
|
|
1455
|
+
*/
|
|
1456
|
+
function hostFor(resource, project) {
|
|
1457
|
+
return [
|
|
1458
|
+
resource.root && project ? stageOf(resource) : labelOf(resource),
|
|
1459
|
+
project,
|
|
1460
|
+
LOCAL_TLD
|
|
1461
|
+
].filter(Boolean).join(".");
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* A resource's own label: its `subdomain` where it declared one — the label a
|
|
1465
|
+
* deploy uses too — with the stage it carries kept, so `gkm test` and `gkm dev`
|
|
1466
|
+
* still answer on different hosts.
|
|
1467
|
+
*/
|
|
1468
|
+
function labelOf(resource) {
|
|
1469
|
+
if (!resource.subdomain) return resource.name;
|
|
1470
|
+
const stage = stageOf(resource);
|
|
1471
|
+
return stage ? `${resource.subdomain}-${stage}` : resource.subdomain;
|
|
1472
|
+
}
|
|
1473
|
+
/** The stage a resource's name carries, or nothing for the local stage's. */
|
|
1474
|
+
function stageOf(resource) {
|
|
1475
|
+
const base = resource.id.toLowerCase();
|
|
1476
|
+
return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* The static root config, which only ever imports.
|
|
1480
|
+
*
|
|
1481
|
+
* Written once and identical for every stage, because the *sites* are what
|
|
1482
|
+
* differ and they live in their own files. That split is not tidiness: one
|
|
1483
|
+
* container serves every stage — the same way one Postgres holds `orders` and
|
|
1484
|
+
* `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
|
|
1485
|
+
* routes `gkm dev` is serving, and each command silently breaking the other.
|
|
1486
|
+
*/
|
|
1487
|
+
function caddyfileRoot() {
|
|
1488
|
+
return `${GENERATED}
|
|
1489
|
+
{
|
|
1490
|
+
# No ACME, no public DNS: the CA is local and so are the names.
|
|
1491
|
+
local_certs
|
|
1492
|
+
auto_https disable_redirects
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
# One file per stage, so two stages share this edge the way they already share
|
|
1496
|
+
# one Postgres and one MinIO.
|
|
1497
|
+
import /etc/caddy/sites/*.caddy
|
|
1498
|
+
`;
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* One stage's sites, as the file imported above.
|
|
1502
|
+
*
|
|
1503
|
+
* `rewrite` rather than a path prefix on the address: the bucket is the
|
|
1504
|
+
* origin's business, and the client asks for the key it stored.
|
|
1505
|
+
*
|
|
1506
|
+
* `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
|
|
1507
|
+
* and signs on the Host header, so forwarding the requested hostname would make
|
|
1508
|
+
* it look for a bucket named after the domain.
|
|
1509
|
+
*/
|
|
1510
|
+
function toCaddyfile(sites) {
|
|
1511
|
+
if (sites.length === 0) return `${GENERATED}
|
|
1512
|
+
# No file server is declared in this stage, so it routes nothing.
|
|
1513
|
+
`;
|
|
1514
|
+
const blocks = sites.map(({ host, upstream, rewrite }) => `
|
|
1515
|
+
https://${host} {
|
|
1516
|
+
tls internal
|
|
1517
|
+
|
|
1518
|
+
reverse_proxy ${upstream} {${rewrite ? `
|
|
1519
|
+
rewrite ${rewrite}
|
|
1520
|
+
# MinIO routes and signs on the Host header, so forwarding the requested
|
|
1521
|
+
# hostname would make it look for a bucket named after the domain.
|
|
1522
|
+
header_up Host {upstream_hostport}` : `
|
|
1523
|
+
# The application reads whatever address was injected, so it has to see
|
|
1524
|
+
# the one a caller used — that is what makes a redirect, a cookie domain
|
|
1525
|
+
# and a generated link point back here rather than at the upstream.
|
|
1526
|
+
header_up Host {host}
|
|
1527
|
+
header_up X-Forwarded-Proto {scheme}`}
|
|
1528
|
+
}
|
|
1529
|
+
}`);
|
|
1530
|
+
return `${GENERATED}${blocks.join("\n")}\n`;
|
|
1531
|
+
}
|
|
1532
|
+
const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
|
|
1533
|
+
#
|
|
1534
|
+
# One host per declared file server, in front of the object store that holds its
|
|
1535
|
+
# objects. This is the shape a file server has deployed — a domain serving a
|
|
1536
|
+
# bucket — which MinIO alone cannot produce: its virtual-host mode reads the
|
|
1537
|
+
# leading label as the bucket name.
|
|
1538
|
+
#
|
|
1539
|
+
# Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
|
|
1540
|
+
# root and point Node at it, so nothing has to be installed to use these URLs
|
|
1541
|
+
# from code; a browser needs the root trusted once.
|
|
1542
|
+
`;
|
|
1543
|
+
//#endregion
|
|
1544
|
+
//#region src/reconcile/plan.ts
|
|
1545
|
+
/**
|
|
1546
|
+
* The reconcile plan — what a manifest wants to exist, for one stage.
|
|
1547
|
+
*
|
|
1548
|
+
* Pure. Turning the plan into running containers and provisioned resources is
|
|
1549
|
+
* the applier's job; deciding *what* should exist is this, so the decisions can
|
|
1550
|
+
* be asserted as data rather than by starting Docker.
|
|
1551
|
+
*
|
|
1552
|
+
* One plan serves every stage. `gkm dev` reconciles `development` and `gkm test`
|
|
1553
|
+
* reconciles `test`; they differ only in what the resources are called, never in
|
|
1554
|
+
* what infrastructure exists. Roles are cluster-scoped in Postgres while grants
|
|
1555
|
+
* are per-object, so one container and one role pair serve both.
|
|
1556
|
+
*/
|
|
1557
|
+
/**
|
|
1558
|
+
* Which container serves a kind.
|
|
1559
|
+
*
|
|
1560
|
+
* Derived kinds map to the same container as their parent — a schema tenant
|
|
1561
|
+
* lives in its parent's Postgres — so the set is deduplicated rather than
|
|
1562
|
+
* counted.
|
|
1563
|
+
*/
|
|
1564
|
+
const CONTAINERS = {
|
|
1565
|
+
database: "postgres",
|
|
1566
|
+
"database-reader": "postgres",
|
|
1567
|
+
"database-schema": "postgres",
|
|
1568
|
+
objects: "minio",
|
|
1569
|
+
"file-server": "caddy",
|
|
1570
|
+
email: "mailpit"
|
|
1571
|
+
};
|
|
1572
|
+
/**
|
|
1573
|
+
* The container a cache backend needs locally.
|
|
1574
|
+
*
|
|
1575
|
+
* Each one matches the protocol its deployed form speaks, which is the property
|
|
1576
|
+
* worth having: a cache that behaves differently in the two places is worse than
|
|
1577
|
+
* a slower one.
|
|
1578
|
+
*
|
|
1579
|
+
* `db` is deliberately absent, the same way `pgboss` is absent from
|
|
1580
|
+
* `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
|
|
1581
|
+
* declares, so mapping it here would start a container for a project that needs
|
|
1582
|
+
* none.
|
|
1583
|
+
*/
|
|
1584
|
+
const CACHE_CONTAINERS = {
|
|
1585
|
+
upstash: "redis-http",
|
|
1586
|
+
elasticache: "redis",
|
|
1587
|
+
redis: "redis"
|
|
1588
|
+
};
|
|
1589
|
+
/**
|
|
1590
|
+
* Kinds that resolve a value without anything running.
|
|
1591
|
+
*
|
|
1592
|
+
* A secret is the case: it has no address, so there is nothing to start and
|
|
1593
|
+
* nothing to connect to — but it still appears in the plan, because the target
|
|
1594
|
+
* still has to resolve it.
|
|
1595
|
+
*/
|
|
1596
|
+
const CONTAINERLESS = {
|
|
1597
|
+
secret: true,
|
|
1598
|
+
credential: true,
|
|
1599
|
+
encryption: true,
|
|
1600
|
+
"rest-api": true,
|
|
1601
|
+
site: true,
|
|
1602
|
+
"mobile-app": true,
|
|
1603
|
+
"external-api": true,
|
|
1604
|
+
telemetry: true
|
|
1605
|
+
};
|
|
1606
|
+
/** The container that collects telemetry locally, and serves its UI. */
|
|
1607
|
+
const TELEMETRY_CONTAINER = "openobserve";
|
|
1608
|
+
/**
|
|
1609
|
+
* Containers that cannot run alone.
|
|
1610
|
+
*
|
|
1611
|
+
* The one case is the cache: `serverless-redis-http` is a proxy and needs the
|
|
1612
|
+
* Redis it proxies. Kept here rather than in the compose definition so the plan
|
|
1613
|
+
* -- which is what decides what must be running -- still names everything.
|
|
1614
|
+
*/
|
|
1615
|
+
const REQUIRES = { "redis-http": ["redis"] };
|
|
1616
|
+
/**
|
|
1617
|
+
* The role each kind's one key plays, where it is not a URL.
|
|
1618
|
+
*
|
|
1619
|
+
* The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
|
|
1620
|
+
* construct declares, this is what the local target resolves, and both derive
|
|
1621
|
+
* the key from the same `provideKey` so they cannot disagree.
|
|
1622
|
+
*/
|
|
1623
|
+
const ROLES = {
|
|
1624
|
+
queue: "publisherConnectionString",
|
|
1625
|
+
topic: "publisherConnectionString",
|
|
1626
|
+
credential: "credentials",
|
|
1627
|
+
"mobile-app": "scheme"
|
|
1628
|
+
};
|
|
1629
|
+
/** The kinds whose container is the events backend's rather than their own. */
|
|
1630
|
+
const EVENT_KINDS = {
|
|
1631
|
+
queue: true,
|
|
1632
|
+
topic: true
|
|
1633
|
+
};
|
|
1634
|
+
/**
|
|
1635
|
+
* Which kinds provision something *inside* their container.
|
|
1636
|
+
*
|
|
1637
|
+
* A database creates a database, a bucket creates a bucket; mail creates
|
|
1638
|
+
* nothing — Mailpit accepts whatever is sent to it and has no per-stage object
|
|
1639
|
+
* to name. Both still appear in the plan, because both still resolve a URL; the
|
|
1640
|
+
* flag is the difference between "must be running" and "must be created".
|
|
1641
|
+
*/
|
|
1642
|
+
const PROVISIONS = {
|
|
1643
|
+
database: true,
|
|
1644
|
+
"database-reader": true,
|
|
1645
|
+
"database-schema": true,
|
|
1646
|
+
objects: true,
|
|
1647
|
+
cache: true,
|
|
1648
|
+
"file-server": true
|
|
1649
|
+
};
|
|
1650
|
+
/**
|
|
1651
|
+
* The container each events backend needs, where it needs one of its own.
|
|
1652
|
+
*
|
|
1653
|
+
* `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
|
|
1654
|
+
* schema tenant in a database the manifest already declares, so mapping it here
|
|
1655
|
+
* would start a Postgres for a project that declared no database at all.
|
|
1656
|
+
*
|
|
1657
|
+
* This one comes from config rather than from a declaration because there are
|
|
1658
|
+
* no `topic` or `queue` kinds yet. When they land the mapping moves to
|
|
1659
|
+
* {@link CONTAINERS} keyed by kind, and this constant goes with them.
|
|
1660
|
+
*/
|
|
1661
|
+
const EVENT_CONTAINERS = {
|
|
1662
|
+
sns: "localstack",
|
|
1663
|
+
rabbitmq: "rabbitmq"
|
|
1664
|
+
};
|
|
1665
|
+
/** The backend a project gets when it declares queues or topics and says nothing. */
|
|
1666
|
+
const DEFAULT_EVENTS = "pgboss";
|
|
1667
|
+
/**
|
|
1668
|
+
* How a resource of each kind separates its stage suffix.
|
|
1669
|
+
*
|
|
1670
|
+
* Postgres identifiers are snake_case and a hyphen would need quoting
|
|
1671
|
+
* everywhere; buckets and queues are kebab and disallow underscores in some
|
|
1672
|
+
* providers. The suffix is the same word either way.
|
|
1673
|
+
*/
|
|
1674
|
+
const SEPARATORS = {
|
|
1675
|
+
database: "_",
|
|
1676
|
+
"database-reader": "_",
|
|
1677
|
+
"database-schema": "_",
|
|
1678
|
+
objects: "-"
|
|
1679
|
+
};
|
|
1680
|
+
/**
|
|
1681
|
+
* The stage-scoped name for a resource.
|
|
1682
|
+
*
|
|
1683
|
+
* The local stage carries no suffix, so `gkm dev` keeps using `orders` and a
|
|
1684
|
+
* developer's psql history, saved connections, and muscle memory survive a
|
|
1685
|
+
* change of stage name. Every other stage is suffixed.
|
|
1686
|
+
*/
|
|
1687
|
+
function resourceName(id, kind, stage, localStage) {
|
|
1688
|
+
const base = id.toLowerCase();
|
|
1689
|
+
if (stage === localStage) return base;
|
|
1690
|
+
return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* The container a kind needs, or `undefined` if it needs none.
|
|
1694
|
+
*
|
|
1695
|
+
* Queues and topics have no container of their own — they live in whichever
|
|
1696
|
+
* broker the project selected, which is why the backend is an argument here
|
|
1697
|
+
* rather than a second pass that adds containers after the fact.
|
|
1698
|
+
*/
|
|
1699
|
+
function containerFor(kind, events = DEFAULT_EVENTS, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
|
|
1700
|
+
if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
|
|
1701
|
+
if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
|
|
1702
|
+
if (kind === "cache") {
|
|
1703
|
+
if (derived && !holdsEveryCache(cache)) return CONTAINERS.database;
|
|
1704
|
+
return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
|
|
1705
|
+
}
|
|
1706
|
+
return CONTAINERS[kind];
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Build the plan for a manifest and stage.
|
|
1710
|
+
*
|
|
1711
|
+
* `order` is the caller's provisioning order — parents before children — which
|
|
1712
|
+
* `provisionOrder` produces from the manifest. Passing it in keeps this
|
|
1713
|
+
* function free of the traversal and makes the ordering assertable on its own.
|
|
1714
|
+
*/
|
|
1715
|
+
function planFor(manifest, stage, order, options = {}) {
|
|
1716
|
+
const containers = /* @__PURE__ */ new Set();
|
|
1717
|
+
const resources = [];
|
|
1718
|
+
const fakes = {};
|
|
1719
|
+
const events = options.events ?? "pgboss";
|
|
1720
|
+
const cache = options.cache ?? DEFAULT_CACHE.aws;
|
|
1721
|
+
for (const id of order) {
|
|
1722
|
+
const declaration = manifest[id];
|
|
1723
|
+
if (!declaration) continue;
|
|
1724
|
+
const local = declaration.kind === "external-api" ? options.fakes?.[id] : void 0;
|
|
1725
|
+
const fake = local && {
|
|
1726
|
+
key: fakeKey(id),
|
|
1727
|
+
...local
|
|
1728
|
+
};
|
|
1729
|
+
if (fake) fakes[fake.key] = {
|
|
1730
|
+
id,
|
|
1731
|
+
...fake.image ? {
|
|
1732
|
+
image: fake.image,
|
|
1733
|
+
port: fake.port
|
|
1734
|
+
} : {}
|
|
1735
|
+
};
|
|
1736
|
+
const detached = declaration.kind === "cache" && holdsEveryCache(cache);
|
|
1737
|
+
const container = fake?.image ? fake.key : declaration.kind === "telemetry" ? usesTelemetry(manifest) ? TELEMETRY_CONTAINER : void 0 : containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
|
|
1738
|
+
if (!container && !CONTAINERLESS[declaration.kind]) continue;
|
|
1739
|
+
if (container) {
|
|
1740
|
+
containers.add(container);
|
|
1741
|
+
for (const required of REQUIRES[container] ?? []) containers.add(required);
|
|
1742
|
+
}
|
|
1743
|
+
resources.push({
|
|
1744
|
+
id,
|
|
1745
|
+
kind: declaration.kind,
|
|
1746
|
+
...container ? { container } : {},
|
|
1747
|
+
name: resourceName(id, declaration.kind, stage, options.localStage),
|
|
1748
|
+
envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
|
|
1749
|
+
provisions: PROVISIONS[declaration.kind] === true,
|
|
1750
|
+
...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
|
|
1751
|
+
...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
|
|
1752
|
+
...declaration.kind === "mobile-app" ? {
|
|
1753
|
+
publicEnv: publicEnvFor(declaration, manifest),
|
|
1754
|
+
...declaration.scheme ? { scheme: declaration.scheme } : {}
|
|
1755
|
+
} : {},
|
|
1756
|
+
...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
|
|
1757
|
+
...(declaration.kind === "rest-api" || declaration.kind === "site") && declaration.subdomain ? { subdomain: declaration.subdomain } : {},
|
|
1758
|
+
...fake ? { fake: {
|
|
1759
|
+
key: fake.key,
|
|
1760
|
+
credentials: fake.credentials
|
|
1761
|
+
} } : {},
|
|
1762
|
+
...declaration.kind === "external-api" && !fake ? { url: declaration.url } : {},
|
|
1763
|
+
..."of" in declaration && !detached ? { of: declaration.of } : {},
|
|
1764
|
+
..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
|
|
1765
|
+
..."version" in declaration && declaration.version ? { version: declaration.version } : {},
|
|
1766
|
+
..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
|
|
1767
|
+
..."table" in declaration && declaration.table && !detached ? { table: declaration.table } : {}
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
|
|
1771
|
+
for (const extra of options.extraContainers ?? []) containers.add(extra);
|
|
1772
|
+
if (events === "pgboss" && resources.some((r) => EVENT_KINDS[r.kind]) && !resources.some((r) => r.kind === "database")) throw new PgBossNeedsDatabase(resources.filter((r) => EVENT_KINDS[r.kind]).map((r) => r.id));
|
|
1773
|
+
if (cache === "db") {
|
|
1774
|
+
const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
|
|
1775
|
+
if (placeless.length > 0) {
|
|
1776
|
+
const databases = resources.filter((r) => r.kind === "database");
|
|
1777
|
+
const [only] = databases;
|
|
1778
|
+
if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
|
|
1779
|
+
if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
|
|
1780
|
+
for (const resource of placeless) resource.of = only.id;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
const sites = resources.filter((r) => r.kind === "site");
|
|
1784
|
+
const root = rootSite(sites.map((r) => {
|
|
1785
|
+
const declared = manifest[r.id];
|
|
1786
|
+
return {
|
|
1787
|
+
name: appKey(r.id),
|
|
1788
|
+
...declared?.kind === "site" && declared.root ? { root: true } : {}
|
|
1789
|
+
};
|
|
1790
|
+
}));
|
|
1791
|
+
for (const site of sites) if (appKey(site.id) === root) site.root = true;
|
|
1792
|
+
const workerBroker = events === "pgboss" && Object.values(manifest).some((d) => d?.kind === "worker") && resources.some((r) => r.kind === "database");
|
|
1793
|
+
return {
|
|
1794
|
+
stage,
|
|
1795
|
+
events,
|
|
1796
|
+
cache,
|
|
1797
|
+
containers: [...containers],
|
|
1798
|
+
resources,
|
|
1799
|
+
...Object.keys(fakes).length ? { fakes } : {},
|
|
1800
|
+
...workerBroker ? { workerBroker: true } : {}
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Queues or topics were declared with pg-boss and no database to hold them.
|
|
1805
|
+
*
|
|
1806
|
+
* Not a reconcile failure but a manifest one: the fix is a line of application
|
|
1807
|
+
* code either way — declare a database, or select a backend that brings its own
|
|
1808
|
+
* broker.
|
|
1809
|
+
*/
|
|
1810
|
+
var CacheNeedsDatabase = class extends Error {
|
|
1811
|
+
ids;
|
|
1812
|
+
constructor(ids) {
|
|
1813
|
+
super(`A cache backed by the database needs a declared database, and none was declared, and on this deploy target that is where a cache lives. Declare one. Caches affected: ${ids.join(", ")}.`);
|
|
1814
|
+
this.ids = ids;
|
|
1815
|
+
this.name = "CacheNeedsDatabase";
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
/**
|
|
1819
|
+
* A `'db'` cache — a server target's default — in an app that declares more
|
|
1820
|
+
* than one database.
|
|
1821
|
+
*
|
|
1822
|
+
* The target says *a* database and the graph offers several, so there is no
|
|
1823
|
+
* answer to give — and the wrong kind of failure would be to pick one, since a
|
|
1824
|
+
* cache silently landing in the wrong database is a bug that surfaces as
|
|
1825
|
+
* missing rows much later. The fix is a stronger statement in application code:
|
|
1826
|
+
* `orders.cache('Sessions')` names the database, which is a fact about the
|
|
1827
|
+
* application rather than a deployment choice.
|
|
1828
|
+
*/
|
|
1829
|
+
var CacheIsAmbiguous = class extends Error {
|
|
1830
|
+
ids;
|
|
1831
|
+
databases;
|
|
1832
|
+
constructor(ids, databases) {
|
|
1833
|
+
super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database — e.g. ${databases[0]}.cache('Sessions') — rather than as a bare new Cache(). Caches affected: ${ids.join(", ")}.`);
|
|
1834
|
+
this.ids = ids;
|
|
1835
|
+
this.databases = databases;
|
|
1836
|
+
this.name = "CacheIsAmbiguous";
|
|
1837
|
+
}
|
|
1838
|
+
};
|
|
1839
|
+
var PgBossNeedsDatabase = class extends Error {
|
|
1840
|
+
ids;
|
|
1841
|
+
constructor(ids) {
|
|
1842
|
+
super(`pg-boss keeps its queues in a declared database, and none was declared. Declare one: on this deploy target, events are carried by pg-boss. Queues and topics affected: ${ids.join(", ")}.`);
|
|
1843
|
+
this.ids = ids;
|
|
1844
|
+
this.name = "PgBossNeedsDatabase";
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
//#endregion
|
|
1848
|
+
//#region src/reconcile/emulator.ts
|
|
1849
|
+
/**
|
|
1850
|
+
* The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
|
|
1851
|
+
* locally — the facts the compose file, the env writer and the applier must
|
|
1852
|
+
* agree on.
|
|
1853
|
+
*
|
|
1854
|
+
* The emulator has one account and answers with deterministic ARNs and queue
|
|
1855
|
+
* URLs, which is what lets a connection string be composed before the topic
|
|
1856
|
+
* or queue it names has been created.
|
|
1857
|
+
*/
|
|
1858
|
+
/** The emulator's single account. */
|
|
1859
|
+
const EMULATOR_ACCOUNT = "000000000000";
|
|
1860
|
+
const EMULATOR_REGION = "us-east-1";
|
|
1861
|
+
/**
|
|
1862
|
+
* The emulator's access key id. The `LSIA` prefix is what LocalStack required
|
|
1863
|
+
* on issued keys, and LocalStack reads the account from the id — so it is
|
|
1864
|
+
* fixed, where the secret key beside it is generated per machine (see
|
|
1865
|
+
* `localCredentials.ts`). Floci does not care; keeping it means a project
|
|
1866
|
+
* that pins the old image still works.
|
|
1867
|
+
*/
|
|
1868
|
+
const EMULATOR_ACCESS_KEY_ID = "LSIAQAAAAAAVNCBMPNSG";
|
|
1869
|
+
/** The emulator's address from the host, on the port it was published on. */
|
|
1870
|
+
function emulatorEndpoint(port) {
|
|
1871
|
+
return `http://localhost:${port}`;
|
|
1872
|
+
}
|
|
1873
|
+
function emulatorTopicArn(name) {
|
|
1874
|
+
return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
|
|
1875
|
+
}
|
|
1876
|
+
function emulatorQueueUrl(name, port) {
|
|
1877
|
+
return `${emulatorEndpoint(port)}/${EMULATOR_ACCOUNT}/${name}`;
|
|
1878
|
+
}
|
|
1879
|
+
//#endregion
|
|
1880
|
+
//#region src/reconcile/compose.ts
|
|
1881
|
+
/**
|
|
1882
|
+
* The compose document a plan implies.
|
|
1883
|
+
*
|
|
1884
|
+
* The third declaration of the same containers disappears here: today a
|
|
1885
|
+
* `services: ['postgres','minio']` block is kept in step with the code by hand,
|
|
1886
|
+
* which is the triplication this design exists to remove, in the one place you
|
|
1887
|
+
* touch every day. A `database` implies Postgres; the list is derived.
|
|
1032
1888
|
*
|
|
1033
1889
|
* Returns an object rather than a string for the same reason `plan` is pure —
|
|
1034
1890
|
* what should exist is asserted as data, and turning it into YAML is one line at
|
|
@@ -1271,6 +2127,34 @@ function define(container, image, published, credentials) {
|
|
|
1271
2127
|
start_period: "10s"
|
|
1272
2128
|
}
|
|
1273
2129
|
};
|
|
2130
|
+
case "openobserve": {
|
|
2131
|
+
const login = credentials.logs;
|
|
2132
|
+
return {
|
|
2133
|
+
image,
|
|
2134
|
+
restart: "unless-stopped",
|
|
2135
|
+
ports: published,
|
|
2136
|
+
environment: {
|
|
2137
|
+
[LOGS_EMAIL_KEY]: login.email,
|
|
2138
|
+
[LOGS_PASSWORD_KEY]: login.password,
|
|
2139
|
+
ZO_DATA_DIR: "/data",
|
|
2140
|
+
ZO_COMPACT_DATA_RETENTION_DAYS: String(30),
|
|
2141
|
+
ZO_TELEMETRY: "false"
|
|
2142
|
+
},
|
|
2143
|
+
volumes: ["openobserve-data:/data"],
|
|
2144
|
+
healthcheck: {
|
|
2145
|
+
test: [
|
|
2146
|
+
"CMD",
|
|
2147
|
+
"/openobserve",
|
|
2148
|
+
"node",
|
|
2149
|
+
"status"
|
|
2150
|
+
],
|
|
2151
|
+
interval: "10s",
|
|
2152
|
+
timeout: "5s",
|
|
2153
|
+
retries: 12,
|
|
2154
|
+
start_period: "20s"
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
1274
2158
|
default: throw new UnknownContainer(container);
|
|
1275
2159
|
}
|
|
1276
2160
|
}
|
|
@@ -1881,987 +2765,470 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
|
1881
2765
|
}
|
|
1882
2766
|
/**
|
|
1883
2767
|
* Generate a Dockerfile for Next.js frontend apps using standalone output.
|
|
1884
|
-
*
|
|
1885
|
-
* Pruned at the build root, Next traces from there, so the standalone
|
|
1886
|
-
* server lands at `<appPath>/server.js` under `.next/standalone`.
|
|
1887
|
-
* @internal Exported for testing
|
|
1888
|
-
*/
|
|
1889
|
-
function generateNextjsDockerfile(options) {
|
|
1890
|
-
const { port, appPath, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
|
|
1891
|
-
return `# syntax=docker/dockerfile:1
|
|
1892
|
-
# Next.js standalone Dockerfile, built from a turbo-pruned slice
|
|
1893
|
-
|
|
1894
|
-
${prunerStage(options)}
|
|
1895
|
-
|
|
1896
|
-
${depsStage(options)}
|
|
1897
|
-
|
|
1898
|
-
${builderStage(options, { args: publicUrlArgs })}
|
|
1899
|
-
|
|
1900
|
-
${siteWorkspaceSteps(options)}
|
|
1901
|
-
|
|
1902
|
-
# Ensure public directory exists (may be empty for scaffolded projects)
|
|
1903
|
-
RUN mkdir -p ${appPath}/public
|
|
1904
|
-
|
|
1905
|
-
ENV NEXT_TELEMETRY_DISABLED=1
|
|
1906
|
-
|
|
1907
|
-
# Build the application
|
|
1908
|
-
${siteBuild(options)}
|
|
1909
|
-
|
|
1910
|
-
# The runner needs the standalone server; without it there is nothing to run.
|
|
1911
|
-
RUN if [ ! -d ${appPath}/.next/standalone ]; then \\
|
|
1912
|
-
echo "${appPath}/.next/standalone was not built: set output: 'standalone' in next.config so the image can run the app without node_modules." >&2; \\
|
|
1913
|
-
exit 1; \\
|
|
1914
|
-
fi
|
|
1915
|
-
|
|
1916
|
-
# Stage 4: Production
|
|
1917
|
-
FROM ${options.baseImage} AS runner
|
|
1918
|
-
|
|
1919
|
-
WORKDIR /app
|
|
1920
|
-
|
|
1921
|
-
# Install tini for proper signal handling
|
|
1922
|
-
RUN apk add --no-cache tini
|
|
1923
|
-
|
|
1924
|
-
# Create non-root user
|
|
1925
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
1926
|
-
adduser --system --uid 1001 nextjs
|
|
1927
|
-
|
|
1928
|
-
# Set environment
|
|
1929
|
-
ENV NODE_ENV=production
|
|
1930
|
-
ENV NEXT_TELEMETRY_DISABLED=1
|
|
1931
|
-
ENV PORT=${port}
|
|
1932
|
-
ENV HOSTNAME="0.0.0.0"
|
|
1933
|
-
|
|
1934
|
-
# The standalone server, with its static files and public directory beside it
|
|
1935
|
-
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
|
|
1936
|
-
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
|
|
1937
|
-
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
|
|
1938
|
-
|
|
1939
|
-
USER nextjs
|
|
1940
|
-
|
|
1941
|
-
EXPOSE ${port}
|
|
1942
|
-
|
|
1943
|
-
${siteHealthcheck(port)}
|
|
1944
|
-
|
|
1945
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
1946
|
-
CMD ["node", "${appPath}/server.js"]
|
|
1947
|
-
`;
|
|
1948
|
-
}
|
|
1949
|
-
/**
|
|
1950
|
-
* Generate a Dockerfile for a gkm backend: pruned, installed, and bundled by
|
|
1951
|
-
* `gkm build --provider server --production` inside the image. The runner is
|
|
1952
|
-
* `server.mjs` alone — plus any package the bundle leaves external.
|
|
1953
|
-
* @internal Exported for testing
|
|
1954
|
-
*/
|
|
1955
|
-
function generateBackendDockerfile(options) {
|
|
1956
|
-
return gkmBundleDockerfile(options, {
|
|
1957
|
-
title: "Backend Dockerfile",
|
|
1958
|
-
bundle: "server.mjs",
|
|
1959
|
-
as: "server.mjs",
|
|
1960
|
-
expose: true
|
|
1961
|
-
});
|
|
1962
|
-
}
|
|
1963
|
-
/** The port a worker answers its health check on, inside its container. */
|
|
1964
|
-
const WORKER_PORT = 3e3;
|
|
1965
|
-
/**
|
|
1966
|
-
* A Worker's image: the same build as its host app's backend — a pruned
|
|
1967
|
-
* slice, `gkm build --provider server --production` in the image, with the
|
|
1968
|
-
* encrypted credentials from the `gkm_credentials` secret — and a runner that
|
|
1969
|
-
* is the worker's bundle alone. It publishes nothing: its one route is the
|
|
1970
|
-
* health check the HEALTHCHECK asks.
|
|
1971
|
-
* @internal Exported for testing
|
|
1972
|
-
*/
|
|
1973
|
-
function generateWorkerDockerfile(options) {
|
|
1974
|
-
return gkmBundleDockerfile(options, {
|
|
1975
|
-
title: `Worker Dockerfile (${options.worker})`,
|
|
1976
|
-
bundle: options.bundle,
|
|
1977
|
-
as: "worker.mjs",
|
|
1978
|
-
expose: false
|
|
1979
|
-
});
|
|
1980
|
-
}
|
|
1981
|
-
function gkmBundleDockerfile(options, runner) {
|
|
1982
|
-
const { port, healthCheckPath = "/health" } = options;
|
|
1983
|
-
const { app } = layout(options);
|
|
1984
|
-
const externals = externalsStages(options, app);
|
|
1985
|
-
return `# syntax=docker/dockerfile:1
|
|
1986
|
-
# ${runner.title}: a turbo-pruned slice, bundled by gkm build in the image
|
|
1987
|
-
|
|
1988
|
-
${prunerStage(options)}
|
|
1989
|
-
|
|
1990
|
-
${depsStage(options)}
|
|
1991
|
-
|
|
1992
|
-
${builderStage(options)}
|
|
1993
|
-
|
|
1994
|
-
# Bundle the production server, with the encrypted credentials embedded
|
|
1995
|
-
${credentialsStep(app, `cd ${app} && ${gkmCommand(`build --provider server --production${options.cache ? ` --cache ${options.cache}` : ""}`)}`)}
|
|
1996
|
-
${externals.stage}
|
|
1997
|
-
# Production
|
|
1998
|
-
FROM ${options.baseImage} AS runner
|
|
1999
|
-
|
|
2000
|
-
WORKDIR /app
|
|
2001
|
-
|
|
2002
|
-
RUN apk add --no-cache tini
|
|
2003
|
-
|
|
2004
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2005
|
-
adduser --system --uid 1001 hono
|
|
2006
|
-
|
|
2007
|
-
# The bundled ${runner.as === "server.mjs" ? "server" : "worker"}
|
|
2008
|
-
COPY --from=builder --chown=hono:nodejs ${app}/.gkm/server/dist/${runner.bundle} ./${runner.as === runner.bundle ? "" : runner.as}${externals.copy}
|
|
2009
|
-
|
|
2010
|
-
ENV NODE_ENV=production
|
|
2011
|
-
ENV PORT=${port}
|
|
2012
|
-
|
|
2013
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
2014
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
2015
|
-
|
|
2016
|
-
USER hono
|
|
2017
|
-
${runner.expose ? `\nEXPOSE ${port}\n` : ""}
|
|
2018
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
2019
|
-
CMD ["node", "${runner.as}"]
|
|
2020
|
-
`;
|
|
2021
|
-
}
|
|
2022
|
-
/**
|
|
2023
|
-
* Generate a Dockerfile for apps with a custom entry point.
|
|
2024
|
-
* Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
|
|
2025
|
-
* This is used for apps that don't use gkm routes (e.g., Better Auth servers).
|
|
2026
|
-
* @internal Exported for testing
|
|
2027
|
-
*/
|
|
2028
|
-
function generateEntryDockerfile(options) {
|
|
2029
|
-
const { port, entry, healthCheckPath = "/health" } = options;
|
|
2030
|
-
const { app } = layout(options);
|
|
2031
|
-
const esbuild = (defines) => `esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \\
|
|
2032
|
-
--outfile=dist/index.mjs --packages=bundle \\
|
|
2033
|
-
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'${defines}`;
|
|
2034
|
-
return `# syntax=docker/dockerfile:1
|
|
2035
|
-
# Entry-based Dockerfile: a turbo-pruned slice, bundled by esbuild in the image
|
|
2036
|
-
|
|
2037
|
-
${prunerStage(options)}
|
|
2038
|
-
|
|
2039
|
-
${depsStage(options)}
|
|
2040
|
-
|
|
2041
|
-
${builderStage(options)}
|
|
2042
|
-
|
|
2043
|
-
# Bundle the entry point with every dependency (dist/index.mjs), embedding the
|
|
2044
|
-
# encrypted credentials when there are any.
|
|
2045
|
-
ARG GKM_CIPHERTEXT_HASH=""
|
|
2046
|
-
RUN --mount=type=secret,id=gkm_credentials,required=false \\
|
|
2047
|
-
echo "credentials: \${GKM_CIPHERTEXT_HASH:-none}" && \\
|
|
2048
|
-
cd ${app} && export ${binPath(options)} && \\
|
|
2049
|
-
if [ -s /run/secrets/gkm_credentials ]; then \\
|
|
2050
|
-
CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \\
|
|
2051
|
-
IV=$(sed -n 2p /run/secrets/gkm_credentials) && \\
|
|
2052
|
-
${esbuild(` \\
|
|
2053
|
-
--define:__GKM_ENCRYPTED_CREDENTIALS__="\\"$CREDS\\"" \\
|
|
2054
|
-
--define:__GKM_CREDENTIALS_IV__="\\"$IV\\""`)}; \\
|
|
2055
|
-
else \\
|
|
2056
|
-
${esbuild("")}; \\
|
|
2057
|
-
fi
|
|
2058
|
-
|
|
2059
|
-
# Production
|
|
2060
|
-
FROM ${options.baseImage} AS runner
|
|
2061
|
-
|
|
2062
|
-
WORKDIR /app
|
|
2063
|
-
|
|
2064
|
-
RUN apk add --no-cache tini
|
|
2065
|
-
|
|
2066
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2067
|
-
adduser --system --uid 1001 app
|
|
2068
|
-
|
|
2069
|
-
# Copy bundled output only (no node_modules needed - fully bundled)
|
|
2070
|
-
COPY --from=builder --chown=app:nodejs ${app}/dist/index.mjs ./
|
|
2071
|
-
|
|
2072
|
-
ENV NODE_ENV=production
|
|
2073
|
-
ENV PORT=${port}
|
|
2074
|
-
|
|
2075
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
2076
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
2077
|
-
|
|
2078
|
-
USER app
|
|
2079
|
-
|
|
2080
|
-
EXPOSE ${port}
|
|
2081
|
-
|
|
2082
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
2083
|
-
CMD ["node", "index.mjs"]
|
|
2084
|
-
`;
|
|
2085
|
-
}
|
|
2086
|
-
/**
|
|
2087
|
-
* Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
|
|
2088
|
-
* Remix). Builds with turbo, then runs the framework's production start
|
|
2089
|
-
* script.
|
|
2090
|
-
*
|
|
2091
|
-
* Public URL build args are declared so framework bundlers (Vite, Vinxi)
|
|
2092
|
-
* can inline them at build time. Per-framework prefix is the caller's job.
|
|
2093
|
-
* @internal Exported for testing
|
|
2094
|
-
*/
|
|
2095
|
-
function generateNodeWebDockerfile(options) {
|
|
2096
|
-
const { port, appPath } = options;
|
|
2097
|
-
return `# syntax=docker/dockerfile:1
|
|
2098
|
-
# Node SSR web Dockerfile (TanStack Start / Remix), from a turbo-pruned slice
|
|
2099
|
-
|
|
2100
|
-
${prunerStage(options)}
|
|
2101
|
-
|
|
2102
|
-
${depsStage(options)}
|
|
2103
|
-
|
|
2104
|
-
${builderStage(options, { args: options.publicUrlArgs ?? [] })}
|
|
2105
|
-
|
|
2106
|
-
${siteWorkspaceSteps(options)}
|
|
2107
|
-
|
|
2108
|
-
${siteBuild(options)}
|
|
2109
|
-
|
|
2110
|
-
FROM ${options.baseImage} AS runner
|
|
2111
|
-
WORKDIR /app
|
|
2112
|
-
RUN apk add --no-cache tini
|
|
2113
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2114
|
-
adduser --system --uid 1001 app
|
|
2115
|
-
|
|
2116
|
-
# Copy the whole built workspace — frameworks differ on output paths
|
|
2117
|
-
COPY --from=builder --chown=app:nodejs /app/ ./
|
|
2118
|
-
|
|
2119
|
-
ENV NODE_ENV=production
|
|
2120
|
-
ENV PORT=${port}
|
|
2121
|
-
ENV HOSTNAME="0.0.0.0"
|
|
2122
|
-
|
|
2123
|
-
USER app
|
|
2124
|
-
EXPOSE ${port}
|
|
2125
|
-
|
|
2126
|
-
${siteHealthcheck(port)}
|
|
2127
|
-
|
|
2128
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
2129
|
-
# npm ships with node, and runs the start script whichever package manager
|
|
2130
|
-
# installed it: the runner needs no other.
|
|
2131
|
-
CMD ["sh", "-c", "cd ${appPath} && npm start"]
|
|
2132
|
-
`;
|
|
2133
|
-
}
|
|
2134
|
-
/**
|
|
2135
|
-
* Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
|
|
2136
|
-
* turbo, then serves it with Caddy: Vite's hashed `/assets/*` cached for a
|
|
2137
|
-
* year, everything else — `index.html`, and every deep link that falls back
|
|
2138
|
-
* to it — revalidated on each request.
|
|
2768
|
+
*
|
|
2769
|
+
* Pruned at the build root, Next traces from there, so the standalone
|
|
2770
|
+
* server lands at `<appPath>/server.js` under `.next/standalone`.
|
|
2139
2771
|
* @internal Exported for testing
|
|
2140
2772
|
*/
|
|
2141
|
-
function
|
|
2142
|
-
const { port, appPath } = options;
|
|
2773
|
+
function generateNextjsDockerfile(options) {
|
|
2774
|
+
const { port, appPath, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
|
|
2143
2775
|
return `# syntax=docker/dockerfile:1
|
|
2144
|
-
#
|
|
2776
|
+
# Next.js standalone Dockerfile, built from a turbo-pruned slice
|
|
2145
2777
|
|
|
2146
2778
|
${prunerStage(options)}
|
|
2147
2779
|
|
|
2148
2780
|
${depsStage(options)}
|
|
2149
2781
|
|
|
2150
|
-
${builderStage(options, { args:
|
|
2782
|
+
${builderStage(options, { args: publicUrlArgs })}
|
|
2151
2783
|
|
|
2152
2784
|
${siteWorkspaceSteps(options)}
|
|
2153
2785
|
|
|
2786
|
+
# Ensure public directory exists (may be empty for scaffolded projects)
|
|
2787
|
+
RUN mkdir -p ${appPath}/public
|
|
2788
|
+
|
|
2789
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
2790
|
+
|
|
2791
|
+
# Build the application
|
|
2154
2792
|
${siteBuild(options)}
|
|
2155
2793
|
|
|
2156
|
-
|
|
2794
|
+
# The runner needs the standalone server; without it there is nothing to run.
|
|
2795
|
+
RUN if [ ! -d ${appPath}/.next/standalone ]; then \\
|
|
2796
|
+
echo "${appPath}/.next/standalone was not built: set output: 'standalone' in next.config so the image can run the app without node_modules." >&2; \\
|
|
2797
|
+
exit 1; \\
|
|
2798
|
+
fi
|
|
2157
2799
|
|
|
2158
|
-
|
|
2159
|
-
{
|
|
2160
|
-
admin off
|
|
2161
|
-
auto_https off
|
|
2162
|
-
persist_config off
|
|
2163
|
-
}
|
|
2800
|
+
# Stage 4: Production
|
|
2801
|
+
FROM ${options.baseImage} AS runner
|
|
2164
2802
|
|
|
2165
|
-
|
|
2166
|
-
root * /srv
|
|
2167
|
-
encode zstd gzip
|
|
2803
|
+
WORKDIR /app
|
|
2168
2804
|
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
handle /assets/* {
|
|
2172
|
-
header Cache-Control "public, max-age=31536000, immutable"
|
|
2173
|
-
file_server
|
|
2174
|
-
}
|
|
2805
|
+
# Install tini for proper signal handling
|
|
2806
|
+
RUN apk add --no-cache tini
|
|
2175
2807
|
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
header Cache-Control "no-cache"
|
|
2180
|
-
try_files {path} /index.html
|
|
2181
|
-
file_server
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
EOF
|
|
2808
|
+
# Create non-root user
|
|
2809
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2810
|
+
adduser --system --uid 1001 nextjs
|
|
2185
2811
|
|
|
2186
|
-
|
|
2812
|
+
# Set environment
|
|
2813
|
+
ENV NODE_ENV=production
|
|
2814
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
2815
|
+
ENV PORT=${port}
|
|
2816
|
+
ENV HOSTNAME="0.0.0.0"
|
|
2187
2817
|
|
|
2188
|
-
|
|
2189
|
-
|
|
2818
|
+
# The standalone server, with its static files and public directory beside it
|
|
2819
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
|
|
2820
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
|
|
2821
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
|
|
2190
2822
|
|
|
2191
|
-
USER
|
|
2823
|
+
USER nextjs
|
|
2192
2824
|
|
|
2193
2825
|
EXPOSE ${port}
|
|
2194
2826
|
|
|
2195
2827
|
${siteHealthcheck(port)}
|
|
2196
2828
|
|
|
2197
|
-
|
|
2829
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
2830
|
+
CMD ["node", "${appPath}/server.js"]
|
|
2198
2831
|
`;
|
|
2199
2832
|
}
|
|
2200
|
-
//#endregion
|
|
2201
|
-
//#region src/docker/layout.ts
|
|
2202
|
-
/**
|
|
2203
|
-
* Where a workspace's images are built from, and what each is built with.
|
|
2204
|
-
*
|
|
2205
|
-
* Every builder of images — `gkm docker`, `gkm compose`, a Dokploy deploy —
|
|
2206
|
-
* builds from the same place with the same templates: the build root (the
|
|
2207
|
-
* package manager's root, at or above the gkm workspace), cut by `turbo
|
|
2208
|
-
* prune` to one app's slice. Nothing is built on the host.
|
|
2209
|
-
*/
|
|
2210
|
-
/** A workspace of packages with no turbo.json, which every image prunes with. */
|
|
2211
|
-
var MonorepoNeedsTurbo = class extends Error {
|
|
2212
|
-
buildRoot;
|
|
2213
|
-
constructor(buildRoot) {
|
|
2214
|
-
super(`${buildRoot} is a workspace of packages, and its images are built from a turbo-pruned slice of it — but it has no turbo.json.\n\nTo fix this:
|
|
2215
|
-
1. Install turbo: pnpm add -Dw turbo
|
|
2216
|
-
2. Create turbo.json in ${buildRoot}\n 3. Run this command again
|
|
2217
|
-
|
|
2218
|
-
See: https://turbo.build/repo/docs/guides/tools/docker`);
|
|
2219
|
-
this.buildRoot = buildRoot;
|
|
2220
|
-
this.name = "MonorepoNeedsTurbo";
|
|
2221
|
-
}
|
|
2222
|
-
};
|
|
2223
|
-
/**
|
|
2224
|
-
* Where a workspace's images are built from.
|
|
2225
|
-
*
|
|
2226
|
-
* Refuses a workspace of packages with no turbo.json — every image prunes
|
|
2227
|
-
* with turbo — so only a caller about to generate or build images asks.
|
|
2228
|
-
*/
|
|
2229
|
-
function imageLayout(workspace) {
|
|
2230
|
-
const buildRoot = findBuildRoot(workspace.root);
|
|
2231
|
-
const tools = resolveBuildTools(buildRoot);
|
|
2232
|
-
if (tools.monorepo && !hasTurboConfig(buildRoot)) throw new MonorepoNeedsTurbo(buildRoot);
|
|
2233
|
-
const gkmRoot = toPosix(relative(buildRoot, workspace.root)) || ".";
|
|
2234
|
-
const workspacePackage = gkmRoot !== "." ? packageName(workspace.root) : void 0;
|
|
2235
|
-
return {
|
|
2236
|
-
buildRoot,
|
|
2237
|
-
gkmRoot,
|
|
2238
|
-
tools,
|
|
2239
|
-
...workspacePackage ? { workspacePackage } : {},
|
|
2240
|
-
gkmPaths: ["gkm.config.*", ...constructRoots(workspace)]
|
|
2241
|
-
};
|
|
2242
|
-
}
|
|
2243
|
-
/**
|
|
2244
|
-
* How a compose file builds an app: the build root as the context, relative
|
|
2245
|
-
* to the directory the compose file is in, and the Dockerfile — written under
|
|
2246
|
-
* the gkm root — relative to that context, which is what compose resolves
|
|
2247
|
-
* `dockerfile` against.
|
|
2248
|
-
*/
|
|
2249
|
-
function composeBuildPaths(options) {
|
|
2250
|
-
const { composeDir, buildRoot, workspaceRoot, dockerfile } = options;
|
|
2251
|
-
return {
|
|
2252
|
-
context: toPosix(relative(composeDir, buildRoot)) || ".",
|
|
2253
|
-
dockerfile: toPosix(join(relative(buildRoot, workspaceRoot), dockerfile))
|
|
2254
|
-
};
|
|
2255
|
-
}
|
|
2256
|
-
/** A path under the gkm root, as the build root sees it. */
|
|
2257
|
-
function fromBuildRoot(layout, path) {
|
|
2258
|
-
if (layout.gkmRoot === ".") return toPosix(path);
|
|
2259
|
-
const joined = toPosix(join(layout.gkmRoot, path));
|
|
2260
|
-
return joined === "" ? "." : joined;
|
|
2261
|
-
}
|
|
2262
|
-
/** Everything a template needs about one app, relative to the build root. */
|
|
2263
|
-
function appImageOptions(layout, appName, app, workspaceRoot, apps = {}) {
|
|
2264
|
-
const turboPackage = packageName(isAbsolute(app.path) ? app.path : join(workspaceRoot, app.path)) ?? appName;
|
|
2265
|
-
return {
|
|
2266
|
-
imageName: appName,
|
|
2267
|
-
baseImage: "node:22-alpine",
|
|
2268
|
-
port: app.port,
|
|
2269
|
-
appPath: fromBuildRoot(layout, app.path),
|
|
2270
|
-
turboPackage,
|
|
2271
|
-
packageManager: layout.tools.packageManager,
|
|
2272
|
-
...layout.tools.packageManagerVersion ? { packageManagerVersion: layout.tools.packageManagerVersion } : {},
|
|
2273
|
-
turboVersion: layout.tools.turboVersion,
|
|
2274
|
-
monorepo: layout.tools.monorepo,
|
|
2275
|
-
gkmRoot: layout.gkmRoot,
|
|
2276
|
-
...app.type === "backend" ? {
|
|
2277
|
-
gkmPaths: layout.gkmPaths,
|
|
2278
|
-
...layout.workspacePackage && layout.workspacePackage !== turboPackage ? { prunePackages: [layout.workspacePackage] } : {}
|
|
2279
|
-
} : app.type === "web" ? siteWorkspace(layout, app, apps, workspaceRoot, turboPackage) : {}
|
|
2280
|
-
};
|
|
2281
|
-
}
|
|
2282
|
-
/**
|
|
2283
|
-
* What a site's image carries of the gkm workspace: its config and construct
|
|
2284
|
-
* directories, the workspace's own package where it is nested in a monorepo
|
|
2285
|
-
* (it holds the CLI), and each backend the site calls — whose endpoints its
|
|
2286
|
-
* client is generated from, and whose package their imports resolve in.
|
|
2287
|
-
*/
|
|
2288
|
-
function siteWorkspace(layout, site, apps, workspaceRoot, turboPackage) {
|
|
2289
|
-
const clients = siteClients(site, apps);
|
|
2290
|
-
const packages = /* @__PURE__ */ new Set();
|
|
2291
|
-
if (layout.workspacePackage) packages.add(layout.workspacePackage);
|
|
2292
|
-
for (const { app } of clients) {
|
|
2293
|
-
const backend = apps[app];
|
|
2294
|
-
const name = packageName(isAbsolute(backend.path) ? backend.path : join(workspaceRoot, backend.path));
|
|
2295
|
-
if (name) packages.add(name);
|
|
2296
|
-
}
|
|
2297
|
-
packages.delete(turboPackage);
|
|
2298
|
-
return {
|
|
2299
|
-
gkmPaths: layout.gkmPaths,
|
|
2300
|
-
...packages.size > 0 ? { prunePackages: [...packages].sort() } : {},
|
|
2301
|
-
...clients.length > 0 ? { clients: clients.map(({ app }) => ({
|
|
2302
|
-
app,
|
|
2303
|
-
path: fromBuildRoot(layout, apps[app].path)
|
|
2304
|
-
})) } : {}
|
|
2305
|
-
};
|
|
2306
|
-
}
|
|
2307
|
-
/**
|
|
2308
|
-
* The backends whose clients a site imports: each it depends on that gkm
|
|
2309
|
-
* builds from endpoints — not one with an entry of its own, and not one that
|
|
2310
|
-
* turned OpenAPI off.
|
|
2311
|
-
*/
|
|
2312
|
-
function siteClients(site, apps) {
|
|
2313
|
-
return site.dependencies.filter((name) => {
|
|
2314
|
-
const app = apps[name];
|
|
2315
|
-
if (!app || app.type !== "backend" || app.entry) return false;
|
|
2316
|
-
if (app.openapi === false) return false;
|
|
2317
|
-
if (typeof app.openapi === "object" && app.openapi.enabled === false) return false;
|
|
2318
|
-
return true;
|
|
2319
|
-
}).sort().map((app) => ({ app }));
|
|
2320
|
-
}
|
|
2321
|
-
/**
|
|
2322
|
-
* The directories the workspace's construct globs start in, relative to its
|
|
2323
|
-
* root: `./constructs/**\/*.ts` reads from `constructs`.
|
|
2324
|
-
*/
|
|
2325
|
-
function constructRoots(workspace) {
|
|
2326
|
-
const roots = /* @__PURE__ */ new Set();
|
|
2327
|
-
for (const glob of allConstructGlobs(workspace)) {
|
|
2328
|
-
const rel = toPosix(relative(workspace.root, glob));
|
|
2329
|
-
if (!rel || rel.startsWith("..")) continue;
|
|
2330
|
-
const first = rel.split("/")[0];
|
|
2331
|
-
if (/[*?{}[\]!]/.test(first)) continue;
|
|
2332
|
-
roots.add(first);
|
|
2333
|
-
}
|
|
2334
|
-
return [...roots].sort();
|
|
2335
|
-
}
|
|
2336
|
-
function packageName(dir) {
|
|
2337
|
-
try {
|
|
2338
|
-
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
|
2339
|
-
return typeof pkg.name === "string" ? pkg.name : void 0;
|
|
2340
|
-
} catch {
|
|
2341
|
-
return;
|
|
2342
|
-
}
|
|
2343
|
-
}
|
|
2344
|
-
function toPosix(path) {
|
|
2345
|
-
return path.split(sep).join("/");
|
|
2346
|
-
}
|
|
2347
|
-
//#endregion
|
|
2348
|
-
//#region src/reconcile/caddyfile.ts
|
|
2349
|
-
/**
|
|
2350
|
-
* The kinds the edge serves.
|
|
2351
|
-
*
|
|
2352
|
-
* Everything that owns a *public address*. Deployed, each of these has a real
|
|
2353
|
-
* hostname and a certificate, so this is what makes the local shape the same
|
|
2354
|
-
* one rather than a near-enough one — and the application is indifferent
|
|
2355
|
-
* either way, because it reads whichever address was injected and never
|
|
2356
|
-
* composes one.
|
|
2357
|
-
*
|
|
2358
|
-
* Adding a kind here is the whole of adding it to the edge.
|
|
2359
|
-
*/
|
|
2360
|
-
const EDGE_KINDS = {
|
|
2361
|
-
"file-server": true,
|
|
2362
|
-
"rest-api": true,
|
|
2363
|
-
site: true
|
|
2364
|
-
};
|
|
2365
|
-
/**
|
|
2366
|
-
* How the edge reaches the host, from inside its container.
|
|
2367
|
-
*
|
|
2368
|
-
* A surface and a site are served by processes `gkm dev` starts on the host,
|
|
2369
|
-
* not by containers — so the edge has to leave Docker's network to reach them.
|
|
2370
|
-
* Docker Desktop resolves this name already; on Linux the compose definition
|
|
2371
|
-
* maps it to the host gateway.
|
|
2372
|
-
*/
|
|
2373
|
-
const HOST_GATEWAY = "host.docker.internal";
|
|
2374
2833
|
/**
|
|
2375
|
-
*
|
|
2376
|
-
*
|
|
2377
|
-
*
|
|
2378
|
-
*
|
|
2379
|
-
* subdomains of it, and Caddy issues internal certificates for it by default.
|
|
2834
|
+
* Generate a Dockerfile for a gkm backend: pruned, installed, and bundled by
|
|
2835
|
+
* `gkm build --provider server --production` inside the image. The runner is
|
|
2836
|
+
* `server.mjs` alone — plus any package the bundle leaves external.
|
|
2837
|
+
* @internal Exported for testing
|
|
2380
2838
|
*/
|
|
2381
|
-
|
|
2839
|
+
function generateBackendDockerfile(options) {
|
|
2840
|
+
return gkmBundleDockerfile(options, {
|
|
2841
|
+
title: "Backend Dockerfile",
|
|
2842
|
+
bundle: "server.mjs",
|
|
2843
|
+
as: "server.mjs",
|
|
2844
|
+
expose: true
|
|
2845
|
+
});
|
|
2846
|
+
}
|
|
2847
|
+
/** The port a worker answers its health check on, inside its container. */
|
|
2848
|
+
const WORKER_PORT = 3e3;
|
|
2382
2849
|
/**
|
|
2383
|
-
*
|
|
2384
|
-
*
|
|
2385
|
-
*
|
|
2386
|
-
*
|
|
2387
|
-
*
|
|
2388
|
-
*
|
|
2850
|
+
* A Worker's image: the same build as its host app's backend — a pruned
|
|
2851
|
+
* slice, `gkm build --provider server --production` in the image, with the
|
|
2852
|
+
* encrypted credentials from the `gkm_credentials` secret — and a runner that
|
|
2853
|
+
* is the worker's bundle alone. It publishes nothing: its one route is the
|
|
2854
|
+
* health check the HEALTHCHECK asks.
|
|
2855
|
+
* @internal Exported for testing
|
|
2389
2856
|
*/
|
|
2390
|
-
function
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
const origin = resource.of ? byId.get(resource.of) : void 0;
|
|
2398
|
-
if (!origin) continue;
|
|
2399
|
-
sites.push({
|
|
2400
|
-
host,
|
|
2401
|
-
upstream: "http://minio:9000",
|
|
2402
|
-
rewrite: `/${origin.name}{uri}`
|
|
2403
|
-
});
|
|
2404
|
-
continue;
|
|
2405
|
-
}
|
|
2406
|
-
const address = addresses[resource.id];
|
|
2407
|
-
if (!address) continue;
|
|
2408
|
-
const port = portOf(address);
|
|
2409
|
-
if (!port) continue;
|
|
2410
|
-
sites.push({
|
|
2411
|
-
host,
|
|
2412
|
-
upstream: `http://${HOST_GATEWAY}:${port}`
|
|
2413
|
-
});
|
|
2414
|
-
}
|
|
2415
|
-
return sites;
|
|
2857
|
+
function generateWorkerDockerfile(options) {
|
|
2858
|
+
return gkmBundleDockerfile(options, {
|
|
2859
|
+
title: `Worker Dockerfile (${options.worker})`,
|
|
2860
|
+
bundle: options.bundle,
|
|
2861
|
+
as: "worker.mjs",
|
|
2862
|
+
expose: false
|
|
2863
|
+
});
|
|
2416
2864
|
}
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2865
|
+
function gkmBundleDockerfile(options, runner) {
|
|
2866
|
+
const { port, healthCheckPath = "/health" } = options;
|
|
2867
|
+
const { app } = layout(options);
|
|
2868
|
+
const externals = externalsStages(options, app);
|
|
2869
|
+
return `# syntax=docker/dockerfile:1
|
|
2870
|
+
# ${runner.title}: a turbo-pruned slice, bundled by gkm build in the image
|
|
2871
|
+
|
|
2872
|
+
${prunerStage(options)}
|
|
2873
|
+
|
|
2874
|
+
${depsStage(options)}
|
|
2875
|
+
|
|
2876
|
+
${builderStage(options)}
|
|
2877
|
+
|
|
2878
|
+
# Bundle the production server, with the encrypted credentials embedded
|
|
2879
|
+
${credentialsStep(app, `cd ${app} && ${gkmCommand(`build --provider server --production${options.cache ? ` --cache ${options.cache}` : ""}`)}`)}
|
|
2880
|
+
${externals.stage}
|
|
2881
|
+
# Production
|
|
2882
|
+
FROM ${options.baseImage} AS runner
|
|
2883
|
+
|
|
2884
|
+
WORKDIR /app
|
|
2885
|
+
|
|
2886
|
+
RUN apk add --no-cache tini
|
|
2887
|
+
|
|
2888
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2889
|
+
adduser --system --uid 1001 hono
|
|
2890
|
+
|
|
2891
|
+
# The bundled ${runner.as === "server.mjs" ? "server" : "worker"}
|
|
2892
|
+
COPY --from=builder --chown=hono:nodejs ${app}/.gkm/server/dist/${runner.bundle} ./${runner.as === runner.bundle ? "" : runner.as}${externals.copy}
|
|
2893
|
+
|
|
2894
|
+
ENV NODE_ENV=production
|
|
2895
|
+
ENV PORT=${port}
|
|
2896
|
+
|
|
2897
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
2898
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
2899
|
+
|
|
2900
|
+
USER hono
|
|
2901
|
+
${runner.expose ? `\nEXPOSE ${port}\n` : ""}
|
|
2902
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
2903
|
+
CMD ["node", "${runner.as}"]
|
|
2904
|
+
`;
|
|
2425
2905
|
}
|
|
2426
2906
|
/**
|
|
2427
|
-
*
|
|
2428
|
-
*
|
|
2429
|
-
*
|
|
2430
|
-
*
|
|
2431
|
-
* name, because an empty label makes `uploads..localhost` — a host nothing
|
|
2432
|
-
* resolves and no certificate can be issued for.
|
|
2907
|
+
* Generate a Dockerfile for apps with a custom entry point.
|
|
2908
|
+
* Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
|
|
2909
|
+
* This is used for apps that don't use gkm routes (e.g., Better Auth servers).
|
|
2910
|
+
* @internal Exported for testing
|
|
2433
2911
|
*/
|
|
2434
|
-
function
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2912
|
+
function generateEntryDockerfile(options) {
|
|
2913
|
+
const { port, entry, healthCheckPath = "/health" } = options;
|
|
2914
|
+
const { app } = layout(options);
|
|
2915
|
+
const esbuild = (defines) => `esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \\
|
|
2916
|
+
--outfile=dist/index.mjs --packages=bundle \\
|
|
2917
|
+
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'${defines}`;
|
|
2918
|
+
return `# syntax=docker/dockerfile:1
|
|
2919
|
+
# Entry-based Dockerfile: a turbo-pruned slice, bundled by esbuild in the image
|
|
2920
|
+
|
|
2921
|
+
${prunerStage(options)}
|
|
2922
|
+
|
|
2923
|
+
${depsStage(options)}
|
|
2924
|
+
|
|
2925
|
+
${builderStage(options)}
|
|
2926
|
+
|
|
2927
|
+
# Bundle the entry point with every dependency (dist/index.mjs), embedding the
|
|
2928
|
+
# encrypted credentials when there are any.
|
|
2929
|
+
ARG GKM_CIPHERTEXT_HASH=""
|
|
2930
|
+
RUN --mount=type=secret,id=gkm_credentials,required=false \\
|
|
2931
|
+
echo "credentials: \${GKM_CIPHERTEXT_HASH:-none}" && \\
|
|
2932
|
+
cd ${app} && export ${binPath(options)} && \\
|
|
2933
|
+
if [ -s /run/secrets/gkm_credentials ]; then \\
|
|
2934
|
+
CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \\
|
|
2935
|
+
IV=$(sed -n 2p /run/secrets/gkm_credentials) && \\
|
|
2936
|
+
${esbuild(` \\
|
|
2937
|
+
--define:__GKM_ENCRYPTED_CREDENTIALS__="\\"$CREDS\\"" \\
|
|
2938
|
+
--define:__GKM_CREDENTIALS_IV__="\\"$IV\\""`)}; \\
|
|
2939
|
+
else \\
|
|
2940
|
+
${esbuild("")}; \\
|
|
2941
|
+
fi
|
|
2942
|
+
|
|
2943
|
+
# Production
|
|
2944
|
+
FROM ${options.baseImage} AS runner
|
|
2945
|
+
|
|
2946
|
+
WORKDIR /app
|
|
2947
|
+
|
|
2948
|
+
RUN apk add --no-cache tini
|
|
2949
|
+
|
|
2950
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2951
|
+
adduser --system --uid 1001 app
|
|
2952
|
+
|
|
2953
|
+
# Copy bundled output only (no node_modules needed - fully bundled)
|
|
2954
|
+
COPY --from=builder --chown=app:nodejs ${app}/dist/index.mjs ./
|
|
2955
|
+
|
|
2956
|
+
ENV NODE_ENV=production
|
|
2957
|
+
ENV PORT=${port}
|
|
2958
|
+
|
|
2959
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
2960
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
2961
|
+
|
|
2962
|
+
USER app
|
|
2963
|
+
|
|
2964
|
+
EXPOSE ${port}
|
|
2965
|
+
|
|
2966
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
2967
|
+
CMD ["node", "index.mjs"]
|
|
2968
|
+
`;
|
|
2440
2969
|
}
|
|
2441
2970
|
/**
|
|
2442
|
-
*
|
|
2443
|
-
*
|
|
2444
|
-
*
|
|
2971
|
+
* Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
|
|
2972
|
+
* Remix). Builds with turbo, then runs the framework's production start
|
|
2973
|
+
* script.
|
|
2974
|
+
*
|
|
2975
|
+
* Public URL build args are declared so framework bundlers (Vite, Vinxi)
|
|
2976
|
+
* can inline them at build time. Per-framework prefix is the caller's job.
|
|
2977
|
+
* @internal Exported for testing
|
|
2445
2978
|
*/
|
|
2446
|
-
function
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2979
|
+
function generateNodeWebDockerfile(options) {
|
|
2980
|
+
const { port, appPath } = options;
|
|
2981
|
+
return `# syntax=docker/dockerfile:1
|
|
2982
|
+
# Node SSR web Dockerfile (TanStack Start / Remix), from a turbo-pruned slice
|
|
2983
|
+
|
|
2984
|
+
${prunerStage(options)}
|
|
2985
|
+
|
|
2986
|
+
${depsStage(options)}
|
|
2987
|
+
|
|
2988
|
+
${builderStage(options, { args: options.publicUrlArgs ?? [] })}
|
|
2989
|
+
|
|
2990
|
+
${siteWorkspaceSteps(options)}
|
|
2991
|
+
|
|
2992
|
+
${siteBuild(options)}
|
|
2993
|
+
|
|
2994
|
+
FROM ${options.baseImage} AS runner
|
|
2995
|
+
WORKDIR /app
|
|
2996
|
+
RUN apk add --no-cache tini
|
|
2997
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2998
|
+
adduser --system --uid 1001 app
|
|
2999
|
+
|
|
3000
|
+
# Copy the whole built workspace — frameworks differ on output paths
|
|
3001
|
+
COPY --from=builder --chown=app:nodejs /app/ ./
|
|
3002
|
+
|
|
3003
|
+
ENV NODE_ENV=production
|
|
3004
|
+
ENV PORT=${port}
|
|
3005
|
+
ENV HOSTNAME="0.0.0.0"
|
|
3006
|
+
|
|
3007
|
+
USER app
|
|
3008
|
+
EXPOSE ${port}
|
|
3009
|
+
|
|
3010
|
+
${siteHealthcheck(port)}
|
|
3011
|
+
|
|
3012
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
3013
|
+
# npm ships with node, and runs the start script whichever package manager
|
|
3014
|
+
# installed it: the runner needs no other.
|
|
3015
|
+
CMD ["sh", "-c", "cd ${appPath} && npm start"]
|
|
3016
|
+
`;
|
|
2455
3017
|
}
|
|
2456
3018
|
/**
|
|
2457
|
-
*
|
|
2458
|
-
*
|
|
2459
|
-
*
|
|
2460
|
-
*
|
|
2461
|
-
*
|
|
2462
|
-
* `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
|
|
2463
|
-
* routes `gkm dev` is serving, and each command silently breaking the other.
|
|
3019
|
+
* Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
|
|
3020
|
+
* turbo, then serves it with Caddy: Vite's hashed `/assets/*` cached for a
|
|
3021
|
+
* year, everything else — `index.html`, and every deep link that falls back
|
|
3022
|
+
* to it — revalidated on each request.
|
|
3023
|
+
* @internal Exported for testing
|
|
2464
3024
|
*/
|
|
2465
|
-
function
|
|
2466
|
-
|
|
3025
|
+
function generateViteStaticDockerfile(options) {
|
|
3026
|
+
const { port, appPath } = options;
|
|
3027
|
+
return `# syntax=docker/dockerfile:1
|
|
3028
|
+
# Vite SPA Dockerfile — a turbo-pruned slice built to static files, served by Caddy
|
|
3029
|
+
|
|
3030
|
+
${prunerStage(options)}
|
|
3031
|
+
|
|
3032
|
+
${depsStage(options)}
|
|
3033
|
+
|
|
3034
|
+
${builderStage(options, { args: options.publicUrlArgs ?? [] })}
|
|
3035
|
+
|
|
3036
|
+
${siteWorkspaceSteps(options)}
|
|
3037
|
+
|
|
3038
|
+
${siteBuild(options)}
|
|
3039
|
+
|
|
3040
|
+
FROM ${STATIC_SITE_IMAGE} AS runner
|
|
3041
|
+
|
|
3042
|
+
COPY <<'EOF' /etc/caddy/Caddyfile
|
|
2467
3043
|
{
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
3044
|
+
admin off
|
|
3045
|
+
auto_https off
|
|
3046
|
+
persist_config off
|
|
2471
3047
|
}
|
|
2472
3048
|
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
`;
|
|
2477
|
-
}
|
|
2478
|
-
/**
|
|
2479
|
-
* One stage's sites, as the file imported above.
|
|
2480
|
-
*
|
|
2481
|
-
* `rewrite` rather than a path prefix on the address: the bucket is the
|
|
2482
|
-
* origin's business, and the client asks for the key it stored.
|
|
2483
|
-
*
|
|
2484
|
-
* `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
|
|
2485
|
-
* and signs on the Host header, so forwarding the requested hostname would make
|
|
2486
|
-
* it look for a bucket named after the domain.
|
|
2487
|
-
*/
|
|
2488
|
-
function toCaddyfile(sites) {
|
|
2489
|
-
if (sites.length === 0) return `${GENERATED}
|
|
2490
|
-
# No file server is declared in this stage, so it routes nothing.
|
|
2491
|
-
`;
|
|
2492
|
-
const blocks = sites.map(({ host, upstream, rewrite }) => `
|
|
2493
|
-
https://${host} {
|
|
2494
|
-
tls internal
|
|
3049
|
+
:${port} {
|
|
3050
|
+
root * /srv
|
|
3051
|
+
encode zstd gzip
|
|
2495
3052
|
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
3053
|
+
# Vite names every built asset by its content hash: a changed file is a
|
|
3054
|
+
# new URL, so the old one can be cached for good.
|
|
3055
|
+
handle /assets/* {
|
|
3056
|
+
header Cache-Control "public, max-age=31536000, immutable"
|
|
3057
|
+
file_server
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
# index.html, and every client-side route that falls back to it, is
|
|
3061
|
+
# asked for again each time so a deploy is seen at once.
|
|
3062
|
+
handle {
|
|
3063
|
+
header Cache-Control "no-cache"
|
|
3064
|
+
try_files {path} /index.html
|
|
3065
|
+
file_server
|
|
2506
3066
|
}
|
|
2507
|
-
}`);
|
|
2508
|
-
return `${GENERATED}${blocks.join("\n")}\n`;
|
|
2509
3067
|
}
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
3068
|
+
EOF
|
|
3069
|
+
|
|
3070
|
+
COPY --from=builder /app/${appPath}/dist /srv
|
|
3071
|
+
|
|
3072
|
+
RUN addgroup -S -g 1001 site && adduser -S -u 1001 -G site site && \\
|
|
3073
|
+
chown -R site:site /data /config
|
|
3074
|
+
|
|
3075
|
+
USER site
|
|
3076
|
+
|
|
3077
|
+
EXPOSE ${port}
|
|
3078
|
+
|
|
3079
|
+
${siteHealthcheck(port)}
|
|
3080
|
+
|
|
3081
|
+
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
|
2520
3082
|
`;
|
|
2521
|
-
//#endregion
|
|
2522
|
-
//#region src/workspace/rootSite.ts
|
|
2523
|
-
/**
|
|
2524
|
-
* Which site the base domain points at — deployed and locally alike.
|
|
2525
|
-
*
|
|
2526
|
-
* Three rules, in order, and the last one is the point:
|
|
2527
|
-
*
|
|
2528
|
-
* 1. **One site** — it is the root. Unambiguous, so nothing has to be said.
|
|
2529
|
-
* 2. **Named `web`** — the convention wins, because people already rely on it.
|
|
2530
|
-
* 3. **Declared `root: true`** — the site says so itself.
|
|
2531
|
-
*
|
|
2532
|
-
* Anything else throws. It used to take the *first* site it iterated, and that
|
|
2533
|
-
* order came from the manifest, which is built in glob traversal order — so two
|
|
2534
|
-
* sites with neither named `web` meant renaming a file could move the root
|
|
2535
|
-
* domain, silently.
|
|
2536
|
-
*
|
|
2537
|
-
* One rule for both targets: deployed the root site gets the base domain, and
|
|
2538
|
-
* locally it gets the project's bare host behind the edge. Before this was
|
|
2539
|
-
* shared, the local edge put every site on a subdomain, so what a developer
|
|
2540
|
-
* browsed was not the shape production served.
|
|
2541
|
-
*
|
|
2542
|
-
* @returns the root site's name, or `undefined` when there are no sites
|
|
2543
|
-
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
2544
|
-
*/
|
|
2545
|
-
function rootSite(sites) {
|
|
2546
|
-
if (sites.length === 0) return void 0;
|
|
2547
|
-
if (sites.length === 1) return sites[0].name;
|
|
2548
|
-
if (sites.some(({ name }) => name === "web")) return "web";
|
|
2549
|
-
const declared = sites.filter(({ root }) => root === true);
|
|
2550
|
-
if (declared.length === 1) return declared[0].name;
|
|
2551
|
-
throw new AmbiguousRootSite(sites.map(({ name }) => name), declared.length > 1);
|
|
2552
3083
|
}
|
|
2553
|
-
/** Several sites could hold the base domain, and none of them says it does. */
|
|
2554
|
-
var AmbiguousRootSite = class extends Error {
|
|
2555
|
-
sites;
|
|
2556
|
-
tooMany;
|
|
2557
|
-
constructor(sites, tooMany = false) {
|
|
2558
|
-
super(tooMany ? `More than one site declares \`root: true\` — ${sites.join(", ")}. The base domain points at one of them.` : `${sites.length} sites and nothing says which holds the base domain: ${sites.join(", ")}. Name one of them \`web\`, or declare \`root: true\` on the one the base domain points at. It used to be whichever the glob reached first, which is not a thing to decide a hostname.`);
|
|
2559
|
-
this.sites = sites;
|
|
2560
|
-
this.tooMany = tooMany;
|
|
2561
|
-
this.name = "AmbiguousRootSite";
|
|
2562
|
-
}
|
|
2563
|
-
};
|
|
2564
3084
|
//#endregion
|
|
2565
|
-
//#region src/
|
|
2566
|
-
/**
|
|
2567
|
-
* The reconcile plan — what a manifest wants to exist, for one stage.
|
|
2568
|
-
*
|
|
2569
|
-
* Pure. Turning the plan into running containers and provisioned resources is
|
|
2570
|
-
* the applier's job; deciding *what* should exist is this, so the decisions can
|
|
2571
|
-
* be asserted as data rather than by starting Docker.
|
|
2572
|
-
*
|
|
2573
|
-
* One plan serves every stage. `gkm dev` reconciles `development` and `gkm test`
|
|
2574
|
-
* reconciles `test`; they differ only in what the resources are called, never in
|
|
2575
|
-
* what infrastructure exists. Roles are cluster-scoped in Postgres while grants
|
|
2576
|
-
* are per-object, so one container and one role pair serve both.
|
|
2577
|
-
*/
|
|
2578
|
-
/**
|
|
2579
|
-
* Which container serves a kind.
|
|
2580
|
-
*
|
|
2581
|
-
* Derived kinds map to the same container as their parent — a schema tenant
|
|
2582
|
-
* lives in its parent's Postgres — so the set is deduplicated rather than
|
|
2583
|
-
* counted.
|
|
2584
|
-
*/
|
|
2585
|
-
const CONTAINERS = {
|
|
2586
|
-
database: "postgres",
|
|
2587
|
-
"database-reader": "postgres",
|
|
2588
|
-
"database-schema": "postgres",
|
|
2589
|
-
objects: "minio",
|
|
2590
|
-
"file-server": "caddy",
|
|
2591
|
-
email: "mailpit"
|
|
2592
|
-
};
|
|
2593
|
-
/**
|
|
2594
|
-
* The container a cache backend needs locally.
|
|
2595
|
-
*
|
|
2596
|
-
* Each one matches the protocol its deployed form speaks, which is the property
|
|
2597
|
-
* worth having: a cache that behaves differently in the two places is worse than
|
|
2598
|
-
* a slower one.
|
|
2599
|
-
*
|
|
2600
|
-
* `db` is deliberately absent, the same way `pgboss` is absent from
|
|
2601
|
-
* `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
|
|
2602
|
-
* declares, so mapping it here would start a container for a project that needs
|
|
2603
|
-
* none.
|
|
2604
|
-
*/
|
|
2605
|
-
const CACHE_CONTAINERS = {
|
|
2606
|
-
upstash: "redis-http",
|
|
2607
|
-
elasticache: "redis",
|
|
2608
|
-
redis: "redis"
|
|
2609
|
-
};
|
|
2610
|
-
/**
|
|
2611
|
-
* Kinds that resolve a value without anything running.
|
|
2612
|
-
*
|
|
2613
|
-
* A secret is the case: it has no address, so there is nothing to start and
|
|
2614
|
-
* nothing to connect to — but it still appears in the plan, because the target
|
|
2615
|
-
* still has to resolve it.
|
|
2616
|
-
*/
|
|
2617
|
-
const CONTAINERLESS = {
|
|
2618
|
-
secret: true,
|
|
2619
|
-
credential: true,
|
|
2620
|
-
encryption: true,
|
|
2621
|
-
"rest-api": true,
|
|
2622
|
-
site: true,
|
|
2623
|
-
"mobile-app": true,
|
|
2624
|
-
"external-api": true
|
|
2625
|
-
};
|
|
2626
|
-
/**
|
|
2627
|
-
* Containers that cannot run alone.
|
|
2628
|
-
*
|
|
2629
|
-
* The one case is the cache: `serverless-redis-http` is a proxy and needs the
|
|
2630
|
-
* Redis it proxies. Kept here rather than in the compose definition so the plan
|
|
2631
|
-
* -- which is what decides what must be running -- still names everything.
|
|
2632
|
-
*/
|
|
2633
|
-
const REQUIRES = { "redis-http": ["redis"] };
|
|
2634
|
-
/**
|
|
2635
|
-
* The role each kind's one key plays, where it is not a URL.
|
|
2636
|
-
*
|
|
2637
|
-
* The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
|
|
2638
|
-
* construct declares, this is what the local target resolves, and both derive
|
|
2639
|
-
* the key from the same `provideKey` so they cannot disagree.
|
|
2640
|
-
*/
|
|
2641
|
-
const ROLES = {
|
|
2642
|
-
queue: "publisherConnectionString",
|
|
2643
|
-
topic: "publisherConnectionString",
|
|
2644
|
-
credential: "credentials",
|
|
2645
|
-
"mobile-app": "scheme"
|
|
2646
|
-
};
|
|
2647
|
-
/** The kinds whose container is the events backend's rather than their own. */
|
|
2648
|
-
const EVENT_KINDS = {
|
|
2649
|
-
queue: true,
|
|
2650
|
-
topic: true
|
|
2651
|
-
};
|
|
2652
|
-
/**
|
|
2653
|
-
* Which kinds provision something *inside* their container.
|
|
2654
|
-
*
|
|
2655
|
-
* A database creates a database, a bucket creates a bucket; mail creates
|
|
2656
|
-
* nothing — Mailpit accepts whatever is sent to it and has no per-stage object
|
|
2657
|
-
* to name. Both still appear in the plan, because both still resolve a URL; the
|
|
2658
|
-
* flag is the difference between "must be running" and "must be created".
|
|
2659
|
-
*/
|
|
2660
|
-
const PROVISIONS = {
|
|
2661
|
-
database: true,
|
|
2662
|
-
"database-reader": true,
|
|
2663
|
-
"database-schema": true,
|
|
2664
|
-
objects: true,
|
|
2665
|
-
cache: true,
|
|
2666
|
-
"file-server": true
|
|
2667
|
-
};
|
|
2668
|
-
/**
|
|
2669
|
-
* The container each events backend needs, where it needs one of its own.
|
|
2670
|
-
*
|
|
2671
|
-
* `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
|
|
2672
|
-
* schema tenant in a database the manifest already declares, so mapping it here
|
|
2673
|
-
* would start a Postgres for a project that declared no database at all.
|
|
2674
|
-
*
|
|
2675
|
-
* This one comes from config rather than from a declaration because there are
|
|
2676
|
-
* no `topic` or `queue` kinds yet. When they land the mapping moves to
|
|
2677
|
-
* {@link CONTAINERS} keyed by kind, and this constant goes with them.
|
|
2678
|
-
*/
|
|
2679
|
-
const EVENT_CONTAINERS = {
|
|
2680
|
-
sns: "localstack",
|
|
2681
|
-
rabbitmq: "rabbitmq"
|
|
2682
|
-
};
|
|
2683
|
-
/** The backend a project gets when it declares queues or topics and says nothing. */
|
|
2684
|
-
const DEFAULT_EVENTS = "pgboss";
|
|
3085
|
+
//#region src/docker/layout.ts
|
|
2685
3086
|
/**
|
|
2686
|
-
*
|
|
3087
|
+
* Where a workspace's images are built from, and what each is built with.
|
|
2687
3088
|
*
|
|
2688
|
-
*
|
|
2689
|
-
*
|
|
2690
|
-
*
|
|
3089
|
+
* Every builder of images — `gkm docker`, `gkm compose`, a Dokploy deploy —
|
|
3090
|
+
* builds from the same place with the same templates: the build root (the
|
|
3091
|
+
* package manager's root, at or above the gkm workspace), cut by `turbo
|
|
3092
|
+
* prune` to one app's slice. Nothing is built on the host.
|
|
2691
3093
|
*/
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
3094
|
+
/** A workspace of packages with no turbo.json, which every image prunes with. */
|
|
3095
|
+
var MonorepoNeedsTurbo = class extends Error {
|
|
3096
|
+
buildRoot;
|
|
3097
|
+
constructor(buildRoot) {
|
|
3098
|
+
super(`${buildRoot} is a workspace of packages, and its images are built from a turbo-pruned slice of it — but it has no turbo.json.\n\nTo fix this:
|
|
3099
|
+
1. Install turbo: pnpm add -Dw turbo
|
|
3100
|
+
2. Create turbo.json in ${buildRoot}\n 3. Run this command again
|
|
3101
|
+
|
|
3102
|
+
See: https://turbo.build/repo/docs/guides/tools/docker`);
|
|
3103
|
+
this.buildRoot = buildRoot;
|
|
3104
|
+
this.name = "MonorepoNeedsTurbo";
|
|
3105
|
+
}
|
|
2697
3106
|
};
|
|
2698
3107
|
/**
|
|
2699
|
-
*
|
|
3108
|
+
* Where a workspace's images are built from.
|
|
2700
3109
|
*
|
|
2701
|
-
*
|
|
2702
|
-
*
|
|
2703
|
-
* change of stage name. Every other stage is suffixed.
|
|
3110
|
+
* Refuses a workspace of packages with no turbo.json — every image prunes
|
|
3111
|
+
* with turbo — so only a caller about to generate or build images asks.
|
|
2704
3112
|
*/
|
|
2705
|
-
function
|
|
2706
|
-
const
|
|
2707
|
-
|
|
2708
|
-
|
|
3113
|
+
function imageLayout(workspace) {
|
|
3114
|
+
const buildRoot = findBuildRoot(workspace.root);
|
|
3115
|
+
const tools = resolveBuildTools(buildRoot);
|
|
3116
|
+
if (tools.monorepo && !hasTurboConfig(buildRoot)) throw new MonorepoNeedsTurbo(buildRoot);
|
|
3117
|
+
const gkmRoot = toPosix(relative(buildRoot, workspace.root)) || ".";
|
|
3118
|
+
const workspacePackage = gkmRoot !== "." ? packageName(workspace.root) : void 0;
|
|
3119
|
+
return {
|
|
3120
|
+
buildRoot,
|
|
3121
|
+
gkmRoot,
|
|
3122
|
+
tools,
|
|
3123
|
+
...workspacePackage ? { workspacePackage } : {},
|
|
3124
|
+
gkmPaths: ["gkm.config.*", ...constructRoots(workspace)]
|
|
3125
|
+
};
|
|
2709
3126
|
}
|
|
2710
3127
|
/**
|
|
2711
|
-
*
|
|
2712
|
-
*
|
|
2713
|
-
*
|
|
2714
|
-
*
|
|
2715
|
-
* rather than a second pass that adds containers after the fact.
|
|
3128
|
+
* How a compose file builds an app: the build root as the context, relative
|
|
3129
|
+
* to the directory the compose file is in, and the Dockerfile — written under
|
|
3130
|
+
* the gkm root — relative to that context, which is what compose resolves
|
|
3131
|
+
* `dockerfile` against.
|
|
2716
3132
|
*/
|
|
2717
|
-
function
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
3133
|
+
function composeBuildPaths(options) {
|
|
3134
|
+
const { composeDir, buildRoot, workspaceRoot, dockerfile } = options;
|
|
3135
|
+
return {
|
|
3136
|
+
context: toPosix(relative(composeDir, buildRoot)) || ".",
|
|
3137
|
+
dockerfile: toPosix(join(relative(buildRoot, workspaceRoot), dockerfile))
|
|
3138
|
+
};
|
|
3139
|
+
}
|
|
3140
|
+
/** A path under the gkm root, as the build root sees it. */
|
|
3141
|
+
function fromBuildRoot(layout, path) {
|
|
3142
|
+
if (layout.gkmRoot === ".") return toPosix(path);
|
|
3143
|
+
const joined = toPosix(join(layout.gkmRoot, path));
|
|
3144
|
+
return joined === "" ? "." : joined;
|
|
3145
|
+
}
|
|
3146
|
+
/** Everything a template needs about one app, relative to the build root. */
|
|
3147
|
+
function appImageOptions(layout, appName, app, workspaceRoot, apps = {}, clientTelemetry) {
|
|
3148
|
+
const turboPackage = packageName(isAbsolute(app.path) ? app.path : join(workspaceRoot, app.path)) ?? appName;
|
|
3149
|
+
return {
|
|
3150
|
+
imageName: appName,
|
|
3151
|
+
baseImage: "node:22-alpine",
|
|
3152
|
+
port: app.port,
|
|
3153
|
+
appPath: fromBuildRoot(layout, app.path),
|
|
3154
|
+
turboPackage,
|
|
3155
|
+
packageManager: layout.tools.packageManager,
|
|
3156
|
+
...layout.tools.packageManagerVersion ? { packageManagerVersion: layout.tools.packageManagerVersion } : {},
|
|
3157
|
+
turboVersion: layout.tools.turboVersion,
|
|
3158
|
+
monorepo: layout.tools.monorepo,
|
|
3159
|
+
gkmRoot: layout.gkmRoot,
|
|
3160
|
+
...app.type === "backend" ? {
|
|
3161
|
+
gkmPaths: layout.gkmPaths,
|
|
3162
|
+
...layout.workspacePackage && layout.workspacePackage !== turboPackage ? { prunePackages: [layout.workspacePackage] } : {}
|
|
3163
|
+
} : app.type === "web" ? siteWorkspace(layout, app, apps, workspaceRoot, turboPackage, clientTelemetry) : {}
|
|
3164
|
+
};
|
|
2725
3165
|
}
|
|
2726
3166
|
/**
|
|
2727
|
-
*
|
|
2728
|
-
*
|
|
2729
|
-
*
|
|
2730
|
-
*
|
|
2731
|
-
* function free of the traversal and makes the ordering assertable on its own.
|
|
3167
|
+
* What a site's image carries of the gkm workspace: its config and construct
|
|
3168
|
+
* directories, the workspace's own package where it is nested in a monorepo
|
|
3169
|
+
* (it holds the CLI), and each backend the site calls — whose endpoints its
|
|
3170
|
+
* client is generated from, and whose package their imports resolve in.
|
|
2732
3171
|
*/
|
|
2733
|
-
function
|
|
2734
|
-
const
|
|
2735
|
-
const
|
|
2736
|
-
|
|
2737
|
-
const
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
if (!declaration) continue;
|
|
2742
|
-
const local = declaration.kind === "external-api" ? options.fakes?.[id] : void 0;
|
|
2743
|
-
const fake = local && {
|
|
2744
|
-
key: fakeKey(id),
|
|
2745
|
-
...local
|
|
2746
|
-
};
|
|
2747
|
-
if (fake) fakes[fake.key] = {
|
|
2748
|
-
id,
|
|
2749
|
-
...fake.image ? {
|
|
2750
|
-
image: fake.image,
|
|
2751
|
-
port: fake.port
|
|
2752
|
-
} : {}
|
|
2753
|
-
};
|
|
2754
|
-
const detached = declaration.kind === "cache" && holdsEveryCache(cache);
|
|
2755
|
-
const container = fake?.image ? fake.key : containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
|
|
2756
|
-
if (!container && !CONTAINERLESS[declaration.kind]) continue;
|
|
2757
|
-
if (container) {
|
|
2758
|
-
containers.add(container);
|
|
2759
|
-
for (const required of REQUIRES[container] ?? []) containers.add(required);
|
|
2760
|
-
}
|
|
2761
|
-
resources.push({
|
|
2762
|
-
id,
|
|
2763
|
-
kind: declaration.kind,
|
|
2764
|
-
...container ? { container } : {},
|
|
2765
|
-
name: resourceName(id, declaration.kind, stage, options.localStage),
|
|
2766
|
-
envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
|
|
2767
|
-
provisions: PROVISIONS[declaration.kind] === true,
|
|
2768
|
-
...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
|
|
2769
|
-
...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
|
|
2770
|
-
...declaration.kind === "mobile-app" ? {
|
|
2771
|
-
publicEnv: publicEnvFor(declaration, manifest),
|
|
2772
|
-
...declaration.scheme ? { scheme: declaration.scheme } : {}
|
|
2773
|
-
} : {},
|
|
2774
|
-
...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
|
|
2775
|
-
...(declaration.kind === "rest-api" || declaration.kind === "site") && declaration.subdomain ? { subdomain: declaration.subdomain } : {},
|
|
2776
|
-
...fake ? { fake: {
|
|
2777
|
-
key: fake.key,
|
|
2778
|
-
credentials: fake.credentials
|
|
2779
|
-
} } : {},
|
|
2780
|
-
...declaration.kind === "external-api" && !fake ? { url: declaration.url } : {},
|
|
2781
|
-
..."of" in declaration && !detached ? { of: declaration.of } : {},
|
|
2782
|
-
..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
|
|
2783
|
-
..."version" in declaration && declaration.version ? { version: declaration.version } : {},
|
|
2784
|
-
..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
|
|
2785
|
-
..."table" in declaration && declaration.table && !detached ? { table: declaration.table } : {}
|
|
2786
|
-
});
|
|
2787
|
-
}
|
|
2788
|
-
if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
|
|
2789
|
-
for (const extra of options.extraContainers ?? []) containers.add(extra);
|
|
2790
|
-
if (events === "pgboss" && resources.some((r) => EVENT_KINDS[r.kind]) && !resources.some((r) => r.kind === "database")) throw new PgBossNeedsDatabase(resources.filter((r) => EVENT_KINDS[r.kind]).map((r) => r.id));
|
|
2791
|
-
if (cache === "db") {
|
|
2792
|
-
const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
|
|
2793
|
-
if (placeless.length > 0) {
|
|
2794
|
-
const databases = resources.filter((r) => r.kind === "database");
|
|
2795
|
-
const [only] = databases;
|
|
2796
|
-
if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
|
|
2797
|
-
if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
|
|
2798
|
-
for (const resource of placeless) resource.of = only.id;
|
|
2799
|
-
}
|
|
3172
|
+
function siteWorkspace(layout, site, apps, workspaceRoot, turboPackage, clientTelemetry) {
|
|
3173
|
+
const clients = siteClients(site, apps);
|
|
3174
|
+
const packages = /* @__PURE__ */ new Set();
|
|
3175
|
+
if (layout.workspacePackage) packages.add(layout.workspacePackage);
|
|
3176
|
+
for (const { app } of clients) {
|
|
3177
|
+
const backend = apps[app];
|
|
3178
|
+
const name = packageName(isAbsolute(backend.path) ? backend.path : join(workspaceRoot, backend.path));
|
|
3179
|
+
if (name) packages.add(name);
|
|
2800
3180
|
}
|
|
2801
|
-
|
|
2802
|
-
const root = rootSite(sites.map((r) => {
|
|
2803
|
-
const declared = manifest[r.id];
|
|
2804
|
-
return {
|
|
2805
|
-
name: appKey(r.id),
|
|
2806
|
-
...declared?.kind === "site" && declared.root ? { root: true } : {}
|
|
2807
|
-
};
|
|
2808
|
-
}));
|
|
2809
|
-
for (const site of sites) if (appKey(site.id) === root) site.root = true;
|
|
2810
|
-
const workerBroker = events === "pgboss" && Object.values(manifest).some((d) => d?.kind === "worker") && resources.some((r) => r.kind === "database");
|
|
3181
|
+
packages.delete(turboPackage);
|
|
2811
3182
|
return {
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
3183
|
+
gkmPaths: layout.gkmPaths,
|
|
3184
|
+
...packages.size > 0 ? { prunePackages: [...packages].sort() } : {},
|
|
3185
|
+
...clients.length > 0 ? { clients: clients.map(({ app }) => ({
|
|
3186
|
+
app,
|
|
3187
|
+
path: fromBuildRoot(layout, apps[app].path),
|
|
3188
|
+
...clientTelemetry ? { telemetry: clientTelemetry } : {}
|
|
3189
|
+
})) } : {}
|
|
2819
3190
|
};
|
|
2820
3191
|
}
|
|
2821
3192
|
/**
|
|
2822
|
-
*
|
|
2823
|
-
*
|
|
2824
|
-
*
|
|
2825
|
-
* code either way — declare a database, or select a backend that brings its own
|
|
2826
|
-
* broker.
|
|
3193
|
+
* The backends whose clients a site imports: each it depends on that gkm
|
|
3194
|
+
* builds from endpoints — not one with an entry of its own, and not one that
|
|
3195
|
+
* turned OpenAPI off.
|
|
2827
3196
|
*/
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
};
|
|
3197
|
+
function siteClients(site, apps) {
|
|
3198
|
+
return site.dependencies.filter((name) => {
|
|
3199
|
+
const app = apps[name];
|
|
3200
|
+
if (!app || app.type !== "backend" || app.entry) return false;
|
|
3201
|
+
if (app.openapi === false) return false;
|
|
3202
|
+
if (typeof app.openapi === "object" && app.openapi.enabled === false) return false;
|
|
3203
|
+
return true;
|
|
3204
|
+
}).sort().map((app) => ({ app }));
|
|
3205
|
+
}
|
|
2836
3206
|
/**
|
|
2837
|
-
*
|
|
2838
|
-
*
|
|
2839
|
-
*
|
|
2840
|
-
* The target says *a* database and the graph offers several, so there is no
|
|
2841
|
-
* answer to give — and the wrong kind of failure would be to pick one, since a
|
|
2842
|
-
* cache silently landing in the wrong database is a bug that surfaces as
|
|
2843
|
-
* missing rows much later. The fix is a stronger statement in application code:
|
|
2844
|
-
* `orders.cache('Sessions')` names the database, which is a fact about the
|
|
2845
|
-
* application rather than a deployment choice.
|
|
3207
|
+
* The directories the workspace's construct globs start in, relative to its
|
|
3208
|
+
* root: `./constructs/**\/*.ts` reads from `constructs`.
|
|
2846
3209
|
*/
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
3210
|
+
function constructRoots(workspace) {
|
|
3211
|
+
const roots = /* @__PURE__ */ new Set();
|
|
3212
|
+
for (const glob of allConstructGlobs(workspace)) {
|
|
3213
|
+
const rel = toPosix(relative(workspace.root, glob));
|
|
3214
|
+
if (!rel || rel.startsWith("..")) continue;
|
|
3215
|
+
const first = rel.split("/")[0];
|
|
3216
|
+
if (/[*?{}[\]!]/.test(first)) continue;
|
|
3217
|
+
roots.add(first);
|
|
2855
3218
|
}
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
3219
|
+
return [...roots].sort();
|
|
3220
|
+
}
|
|
3221
|
+
function packageName(dir) {
|
|
3222
|
+
try {
|
|
3223
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
|
3224
|
+
return typeof pkg.name === "string" ? pkg.name : void 0;
|
|
3225
|
+
} catch {
|
|
3226
|
+
return;
|
|
2863
3227
|
}
|
|
2864
|
-
}
|
|
3228
|
+
}
|
|
3229
|
+
function toPosix(path) {
|
|
3230
|
+
return path.split(sep).join("/");
|
|
3231
|
+
}
|
|
2865
3232
|
//#endregion
|
|
2866
3233
|
//#region src/reconcile/env.ts
|
|
2867
3234
|
/**
|
|
@@ -2938,6 +3305,13 @@ function envFor(plan, options) {
|
|
|
2938
3305
|
if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
|
|
2939
3306
|
}
|
|
2940
3307
|
if (resource.kind === "external-api" && url && resource.fake) env[provideKey(resource.id, "credentials")] = resource.fake.credentials;
|
|
3308
|
+
if (resource.kind === "telemetry" && url) {
|
|
3309
|
+
const login = options.credentials.logs;
|
|
3310
|
+
Object.assign(env, telemetryEnv({
|
|
3311
|
+
endpoint: url,
|
|
3312
|
+
headers: otlpHeaders(login.email, login.password)
|
|
3313
|
+
}, 1));
|
|
3314
|
+
}
|
|
2941
3315
|
if (resource.kind === "email" && url) {
|
|
2942
3316
|
env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
|
|
2943
3317
|
const inbox = options.ports[MAILPIT_INBOX_PORT];
|
|
@@ -3118,6 +3492,7 @@ function urlFor(resource, plan, ports, project, addresses, credential) {
|
|
|
3118
3492
|
password: localRolePassword(project, plan, role, credential.seed)
|
|
3119
3493
|
});
|
|
3120
3494
|
}
|
|
3495
|
+
case "telemetry": return `http://${LOCAL_HOST}:${port}/api/${LOGS_ORG}`;
|
|
3121
3496
|
case "email": return `smtp://${LOCAL_HOST}:${port}`;
|
|
3122
3497
|
case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
|
|
3123
3498
|
case "file-server": {
|
|
@@ -3429,7 +3804,10 @@ function appEnvKeys(manifest, appName, runnables = {}) {
|
|
|
3429
3804
|
}
|
|
3430
3805
|
}
|
|
3431
3806
|
addEdgeKeys(manifest, edges, keys);
|
|
3432
|
-
if (declaration.kind === "site" || declaration.kind === "mobile-app")
|
|
3807
|
+
if (declaration.kind === "site" || declaration.kind === "mobile-app") {
|
|
3808
|
+
for (const key of TELEMETRY_KEYS) keys.delete(key);
|
|
3809
|
+
for (const key of Object.keys(publicEnvFor(declaration, manifest))) keys.add(key);
|
|
3810
|
+
}
|
|
3433
3811
|
return keys;
|
|
3434
3812
|
}
|
|
3435
3813
|
/**
|
|
@@ -3519,7 +3897,11 @@ function appServices(workspace, manifest, containers, credentials, runnables = {
|
|
|
3519
3897
|
continue;
|
|
3520
3898
|
}
|
|
3521
3899
|
const allowed = appEnvKeys(manifest, name, runnables);
|
|
3522
|
-
const own = Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey)))
|
|
3900
|
+
const own = scopeTelemetryEnv(Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey))), {
|
|
3901
|
+
uses: appTelemetry(manifest, name) !== void 0,
|
|
3902
|
+
serviceName: name,
|
|
3903
|
+
telemetry: env
|
|
3904
|
+
});
|
|
3523
3905
|
services[key] = {
|
|
3524
3906
|
image: `${key}:\${TAG:-latest}`,
|
|
3525
3907
|
build,
|
|
@@ -4732,6 +5114,8 @@ async function reconcile(options) {
|
|
|
4732
5114
|
...options.fakes ? { fakes: options.fakes } : {},
|
|
4733
5115
|
...options.edge === void 0 ? {} : { edge: options.edge }
|
|
4734
5116
|
});
|
|
5117
|
+
const exports = stage !== TEST_STAGE;
|
|
5118
|
+
const running = exports ? plan.containers : plan.containers.filter((c) => c !== TELEMETRY_CONTAINER);
|
|
4735
5119
|
const composePath = join(root, COMPOSE_PATH);
|
|
4736
5120
|
const observed = await observedPorts(docker, composePath, plan.containers, plan.fakes);
|
|
4737
5121
|
const ports = await allocate(project, portKeys(plan.containers, plan.fakes), keptPorts(options.saved, observed), probe);
|
|
@@ -4746,10 +5130,10 @@ async function reconcile(options) {
|
|
|
4746
5130
|
caddyfile,
|
|
4747
5131
|
postgres: postgresStatements(plan, project, seed).map((statement) => statement.create).join(";\n")
|
|
4748
5132
|
});
|
|
4749
|
-
const services = serviceAddresses(
|
|
5133
|
+
const services = serviceAddresses(running, ports, plan.fakes);
|
|
4750
5134
|
const lan = plan.resources.some((r) => r.kind === "mobile-app") ? options.lanAddress === void 0 ? lanAddress() : options.lanAddress ?? void 0 : void 0;
|
|
4751
5135
|
const envWith = (credentials) => {
|
|
4752
|
-
const
|
|
5136
|
+
const resolved = envFor(plan, {
|
|
4753
5137
|
ports,
|
|
4754
5138
|
project,
|
|
4755
5139
|
credentials,
|
|
@@ -4759,6 +5143,10 @@ async function reconcile(options) {
|
|
|
4759
5143
|
...lan ? { lanAddress: lan } : {},
|
|
4760
5144
|
...options.metroPorts ? { metroPorts: options.metroPorts } : {}
|
|
4761
5145
|
});
|
|
5146
|
+
const env = exports ? resolved : scopeTelemetryEnv(resolved, {
|
|
5147
|
+
uses: false,
|
|
5148
|
+
serviceName: project
|
|
5149
|
+
});
|
|
4762
5150
|
if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
|
|
4763
5151
|
return env;
|
|
4764
5152
|
};
|
|
@@ -4778,7 +5166,7 @@ async function reconcile(options) {
|
|
|
4778
5166
|
};
|
|
4779
5167
|
const recorded = await loadState(root, stage);
|
|
4780
5168
|
const postgresPort = ports[primaryPortKey("postgres")];
|
|
4781
|
-
if (recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath,
|
|
5169
|
+
if (recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, running)) && (!start || !provision || postgresPort === void 0 || await databasesExist(sql(postgresPort, options.credentials.postgres), postgresDatabaseNames(plan)))) return result;
|
|
4782
5170
|
await write(composePath, toYaml(compose), 384);
|
|
4783
5171
|
if (plan.containers.includes("caddy")) {
|
|
4784
5172
|
await write(join(root, CADDYFILE_PATH), caddyfileRoot());
|
|
@@ -4789,9 +5177,9 @@ async function reconcile(options) {
|
|
|
4789
5177
|
TEST_STAGE
|
|
4790
5178
|
]);
|
|
4791
5179
|
}
|
|
4792
|
-
if (start &&
|
|
4793
|
-
options.progress?.(`🐳 Starting ${
|
|
4794
|
-
await docker.up(composePath,
|
|
5180
|
+
if (start && running.length > 0) {
|
|
5181
|
+
options.progress?.(`🐳 Starting ${running.join(", ")} — the first start pulls each image, which can take a few minutes`);
|
|
5182
|
+
await docker.up(composePath, running, options.progress ? { show: true } : {});
|
|
4795
5183
|
}
|
|
4796
5184
|
if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
|
|
4797
5185
|
if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
|
|
@@ -4888,295 +5276,6 @@ async function write(path, content, mode) {
|
|
|
4888
5276
|
if (mode !== void 0) await chmod(path, mode);
|
|
4889
5277
|
}
|
|
4890
5278
|
//#endregion
|
|
4891
|
-
//#region src/deploy/otel.ts
|
|
4892
|
-
/**
|
|
4893
|
-
* The OpenTelemetry variables a stage's secrets pass to every backend.
|
|
4894
|
-
*
|
|
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.
|
|
4918
|
-
*/
|
|
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);
|
|
4924
|
-
}
|
|
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);
|
|
4928
|
-
}
|
|
4929
|
-
/**
|
|
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.
|
|
4933
|
-
*/
|
|
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;
|
|
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
|
-
};
|
|
4951
|
-
/**
|
|
4952
|
-
* The hostname an app answers on for a stage.
|
|
4953
|
-
*
|
|
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
|
|
4960
|
-
*/
|
|
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];
|
|
4965
|
-
}
|
|
4966
|
-
const baseDomain = domains?.[stage];
|
|
4967
|
-
if (!baseDomain) throw new NoDomainForStage(stage);
|
|
4968
|
-
if (isMainFrontend) return baseDomain;
|
|
4969
|
-
return `${app.subdomain ?? appName}.${baseDomain}`;
|
|
4970
|
-
}
|
|
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;
|
|
4983
|
-
}
|
|
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`);
|
|
4995
|
-
}
|
|
4996
|
-
//#endregion
|
|
4997
|
-
//#region src/compose/logs.ts
|
|
4998
|
-
/**
|
|
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.
|
|
5002
|
-
*
|
|
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.
|
|
5008
|
-
*
|
|
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.
|
|
5015
|
-
*/
|
|
5016
|
-
/**
|
|
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.
|
|
5019
|
-
*/
|
|
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";
|
|
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);
|
|
5065
|
-
}
|
|
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`;
|
|
5072
|
-
}
|
|
5073
|
-
/**
|
|
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.
|
|
5077
|
-
*/
|
|
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
|
-
};
|
|
5094
|
-
}
|
|
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
|
-
];
|
|
5178
|
-
}
|
|
5179
|
-
//#endregion
|
|
5180
5279
|
//#region src/compose/redis.ts
|
|
5181
5280
|
/**
|
|
5182
5281
|
* The stack's Redis: what every declared cache lives in when a workspace runs
|
|
@@ -6212,10 +6311,7 @@ var WorkerGenerator = class {
|
|
|
6212
6311
|
this.generators.subscriber.build(context, input.subscribers, outputDir, options)
|
|
6213
6312
|
]);
|
|
6214
6313
|
await writeFile(join(outputDir, "app.ts"), workerApp(context, workerId, outputDir));
|
|
6215
|
-
await
|
|
6216
|
-
...context.telemetry,
|
|
6217
|
-
serviceName: workerId
|
|
6218
|
-
} : void 0));
|
|
6314
|
+
await writeTelemetryModule(outputDir, input.telemetry);
|
|
6219
6315
|
const entry = join(outputDir, "worker.ts");
|
|
6220
6316
|
await writeFile(entry, workerEntry(context.production?.healthCheck ?? "/health"));
|
|
6221
6317
|
return entry;
|
|
@@ -6339,11 +6435,8 @@ function workerEntry(healthCheck) {
|
|
|
6339
6435
|
*/
|
|
6340
6436
|
import { createServer } from 'node:http';
|
|
6341
6437
|
import { runShutdownHooks } from '@geekmidas/constructs';
|
|
6342
|
-
import { startTelemetry } from './telemetry.js';
|
|
6343
|
-
|
|
6344
|
-
// Before the worker is imported, so the libraries it loads are instrumented.
|
|
6345
|
-
await startTelemetry({ ignorePaths: [${JSON.stringify(healthCheck)}] });
|
|
6346
6438
|
|
|
6439
|
+
${startTelemetryCode([healthCheck])}
|
|
6347
6440
|
const { startWorker, logger } = await import('./app.js');
|
|
6348
6441
|
|
|
6349
6442
|
const port = Number(process.env.PORT) || 3000;
|
|
@@ -6816,7 +6909,7 @@ async function dockerCommand(options) {
|
|
|
6816
6909
|
const cwd = options.cwd ?? process.cwd();
|
|
6817
6910
|
const loaded = await loadWorkspaceConfig(cwd);
|
|
6818
6911
|
const names = loaded.type === "single" ? Object.fromEntries(Object.keys(loaded.workspace.apps).map((name) => [name, resolveDockerConfig(loaded.raw).imageName])) : {};
|
|
6819
|
-
const result = await workspaceDockerCommand(loaded.workspace, loaded.manifest, names, loaded.background);
|
|
6912
|
+
const result = await workspaceDockerCommand(loaded.workspace, loaded.manifest, names, loaded.background, options.stage);
|
|
6820
6913
|
if (options.build) for (const app of result.apps) await buildDockerImage(app, result.buildRoot, options);
|
|
6821
6914
|
if (options.push) for (const app of result.apps) await pushDockerImage(app.imageName, options, result.buildRoot);
|
|
6822
6915
|
return result;
|
|
@@ -6868,7 +6961,7 @@ async function pushDockerImage(imageName, options, cwd) {
|
|
|
6868
6961
|
* Generate Dockerfiles for all apps in a workspace.
|
|
6869
6962
|
* @internal Exported for testing
|
|
6870
6963
|
*/
|
|
6871
|
-
async function workspaceDockerCommand(workspace, manifest, imageNames = {}, background = {}) {
|
|
6964
|
+
async function workspaceDockerCommand(workspace, manifest, imageNames = {}, background = {}, stage) {
|
|
6872
6965
|
const results = [];
|
|
6873
6966
|
const layout = imageLayout(workspace);
|
|
6874
6967
|
logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
|
|
@@ -6885,7 +6978,9 @@ async function workspaceDockerCommand(workspace, manifest, imageNames = {}, back
|
|
|
6885
6978
|
layout,
|
|
6886
6979
|
workspaceRoot: workspace.root,
|
|
6887
6980
|
apps: workspace.apps,
|
|
6888
|
-
...manifest ? { manifest } : {}
|
|
6981
|
+
...manifest ? { manifest } : {},
|
|
6982
|
+
workspace,
|
|
6983
|
+
...stage ? { stage } : {}
|
|
6889
6984
|
});
|
|
6890
6985
|
const path = dockerfileOf(appName, app.path);
|
|
6891
6986
|
const dockerfilePath = join(workspace.root, path);
|
|
@@ -6940,7 +7035,7 @@ async function workspaceDockerCommand(workspace, manifest, imageNames = {}, back
|
|
|
6940
7035
|
* framework. The one choice every builder of images makes.
|
|
6941
7036
|
*/
|
|
6942
7037
|
function appDockerfile(appName, app, options) {
|
|
6943
|
-
const image = appImageOptions(options.layout, appName, app, options.workspaceRoot, options.apps);
|
|
7038
|
+
const image = appImageOptions(options.layout, appName, app, options.workspaceRoot, options.apps, app.type === "web" && options.manifest && options.workspace ? siteClientTelemetry(options.manifest, appName, options.workspace, options.stage) : void 0);
|
|
6944
7039
|
if (app.type === "web") return siteDockerfile(appName, app, {
|
|
6945
7040
|
image,
|
|
6946
7041
|
...options.manifest ? { manifest: options.manifest } : {},
|
|
@@ -7451,6 +7546,6 @@ function reportPlannedSeeds(planned, log) {
|
|
|
7451
7546
|
for (const { folder, seeds } of planned) log(`🌱 ${folder}: would run ${seeds.length} (${seeds.join(", ")})`);
|
|
7452
7547
|
}
|
|
7453
7548
|
//#endregion
|
|
7454
|
-
export { postgresSuperuser as $,
|
|
7549
|
+
export { postgresSuperuser as $, describeServices as $t, UnknownDevService as A, maskPassword as An, WORKER_PORT as At, WorkerGenerator as B, NoDomainForStage as Bn, composeFor as Bt, workerDockerfile as C, imageName as Cn, networkEnv as Ct, DevServicesNeedServerTarget as D, ownershipMarker as Dn, composeBuildPaths as Dt, DEV_SERVICES as E, markerOf as En, localRolePassword as Et, parseDevServices as F, LOGS_SERVICE as Fn, cacheBackendFor as Ft, FAKE_ENV as G, hostFor as Gt, workerEntryDir as H, resolveHost as Hn, TELEMETRY_CONTAINER as Ht, reportDevServices as I, logsAccess as In, eventsBackendFor as It, derivedContainers as J, scopeTelemetryEnv as Jt, backendsOf as K, appTelemetry as Kt, requiredStageKeys as L, logsService as Ln, providerOf as Lt, devServicesUsed as M, toEmbeddableSecrets as Mn, findBuildRoot as Mt, externalServices as N, withCustomSecret as Nn, imageBuildCredentials as Nt, ExternalServicesNotConfigured as O, projectName as On, imageLayout as Ot, manifestServiceDeclarations as P, LOCAL_LOGS_EMAIL as Pn, isImageBuild as Pt, loadLocalCredentials as Q, DEFAULT_IMAGES as Qt, suppliedOnly as R, stackLogs as Rn, storageBackendFor as Rt, ensureDockerignore as S, deployIdentity as Sn, dockerfileOf as St, validateImageRef as T, isOwnedBy as Tn, envFor as Tt, QueueGenerator as U, planFor as Ut, workerBundleName as V, isMainFrontendApp as Vn, EMULATOR_ACCESS_KEY_ID as Vt, CronGenerator as W, resourceName as Wt, workspace_exports as X, telemetryOf as Xt, reconcileWorkspace as Y, surfaceClientTraced as Yt, LOCAL_RABBITMQ_USER as Z, usesTelemetry as Zt, DeployJournal as _, getKeyPath as _n, LEGACY_LOCAL_LOGIN as _t, createEmptyState as a, createEntryWrapper as an, runsRedis as at, appDockerfile as b, readKey as bn, pgClient as bt, isDnsVerified as c, loadEnvFiles as cn, LOCAL_CA_PATH as ct, setApplicationId as d, prepareEntryCredentials as dn, applyPolicies as dt, fakeKey as en, readLocalCredentials as et, setBackupState as f, isRemoteStore as fn, applyPostgres as ft, setPostgresBackupId as g, FileSecretsStore as gn, quoteIdentifier as gt, setGeneratedSecret as h, store_exports as hn, postgresStatements as ht, assertNoStaleSecrets as i, createCredentialsPreload as in, redisService as it, assertExternalServices as j, storage_exports as jn, detectPackageManager as jt, STORAGE_KEY_PAIR as k, initStageSecrets as kn, TURBO_VERSION as kt, recordRelease as l, loadPortState as ln, forgetState as lt, setDnsVerification as m, storeFromConfig as mn, postgresDatabaseNames as mt, reportDatabaseRuns as n, portsOf as nn, REDIS_SERVICE as nt, getBackupState as o, findAvailablePort as on, stackRedis as ot, setDnsRecord as p, secretsStoreFor as pn, bucketPolicies as pt, constructGlobs as q, isTelemetryKey as qt, reportPlannedSeeds as r, primaryPortKey as rn, STACK_CACHE as rt, getGeneratedSecret as s, isPortAvailable as sn, withRedisPassword as st, DeploySeedsFailed as t, portKeys as tn, withGeneratedSecrets as tt, recordRollback as u, loadSecretsForApp as un, applyBuckets as ut, DockerBuildFailed as v, keystoreProject as vn, ensurePostgresLogin as vt, workerDockerfileOf as w, imageRef as wn, workerEnvKeys as wt, dockerCommand as x, applicationName as xn, appEnvKeys as xt, DockerPushFailed as y, projectKey as yn, bucketClient as yt, workerUnits as z, withLogsPassword as zn, CLUSTER_DATABASE as zt };
|
|
7455
7550
|
|
|
7456
|
-
//# sourceMappingURL=seeds-
|
|
7551
|
+
//# sourceMappingURL=seeds-BK8caVMm.mjs.map
|