@geekmidas/cli 10.0.0-alpha.66 → 10.0.0-alpha.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -178
- package/dist/{HostingerProvider-CY_VpVud.mjs → HostingerProvider-D85ayzTi.mjs} +2 -2
- package/dist/{HostingerProvider-CY_VpVud.mjs.map → HostingerProvider-D85ayzTi.mjs.map} +1 -1
- package/dist/{HostingerProvider-BznqRC2S.cjs → HostingerProvider-dxkwynSs.cjs} +2 -2
- package/dist/{HostingerProvider-BznqRC2S.cjs.map → HostingerProvider-dxkwynSs.cjs.map} +1 -1
- package/dist/{LegacyStateStore-BFcTT2QL.cjs → LegacyStateStore-BZiO9-Lw.cjs} +2 -2
- package/dist/{LegacyStateStore-BFcTT2QL.cjs.map → LegacyStateStore-BZiO9-Lw.cjs.map} +1 -1
- package/dist/{LegacyStateStore-CFheFo62.mjs → LegacyStateStore-C39UKwmf.mjs} +2 -2
- package/dist/{LegacyStateStore-CFheFo62.mjs.map → LegacyStateStore-C39UKwmf.mjs.map} +1 -1
- package/dist/{LocalStateStore-BVUexSvu.cjs → LocalStateStore-DHVsZLyF.cjs} +2 -2
- package/dist/{LocalStateStore-BVUexSvu.cjs.map → LocalStateStore-DHVsZLyF.cjs.map} +1 -1
- package/dist/{LocalStateStore-Bm3x9oOg.mjs → LocalStateStore-Dk3Wbvzk.mjs} +2 -2
- package/dist/{LocalStateStore-Bm3x9oOg.mjs.map → LocalStateStore-Dk3Wbvzk.mjs.map} +1 -1
- package/dist/{S3StateStore-CoXBhILd.cjs → S3StateStore--4tYVMAL.cjs} +2 -2
- package/dist/{S3StateStore-CoXBhILd.cjs.map → S3StateStore--4tYVMAL.cjs.map} +1 -1
- package/dist/{S3StateStore-BFHuctWM.mjs → S3StateStore-B_ZF9eHt.mjs} +2 -2
- package/dist/{S3StateStore-BFHuctWM.mjs.map → S3StateStore-B_ZF9eHt.mjs.map} +1 -1
- package/dist/{SSMStateStore-BqCA9TP2.cjs → SSMStateStore-ACZ8hRvZ.cjs} +2 -2
- package/dist/{SSMStateStore-BqCA9TP2.cjs.map → SSMStateStore-ACZ8hRvZ.cjs.map} +1 -1
- package/dist/{SSMStateStore-C1kvHScY.mjs → SSMStateStore-DjPuKqrm.mjs} +2 -2
- package/dist/{SSMStateStore-C1kvHScY.mjs.map → SSMStateStore-DjPuKqrm.mjs.map} +1 -1
- package/dist/{bundler-Dvaiud0h.mjs → bundler-C-w7aGoZ.mjs} +20 -5
- package/dist/bundler-C-w7aGoZ.mjs.map +1 -0
- package/dist/{bundler-orRqUJNZ.cjs → bundler-C1pnNrR0.cjs} +20 -5
- package/dist/bundler-C1pnNrR0.cjs.map +1 -0
- package/dist/{cli-D1JkkOwg.cjs → cli-BLyD4c0O.cjs} +14 -5
- package/dist/cli-BLyD4c0O.cjs.map +1 -0
- package/dist/{cli-CfHm0AO7.mjs → cli-Ce4pLL5O.mjs} +14 -5
- package/dist/cli-Ce4pLL5O.mjs.map +1 -0
- package/dist/{compose-CNQL8BnO.mjs → compose-DFxuYhn6.mjs} +291 -152
- package/dist/compose-DFxuYhn6.mjs.map +1 -0
- package/dist/{compose-jrLy7KGO.cjs → compose-DvsgumY2.cjs} +298 -159
- package/dist/compose-DvsgumY2.cjs.map +1 -0
- package/dist/{config-4XFxX1al.d.cts → config-BnA0kPTY.d.cts} +3 -3
- package/dist/{config-4XFxX1al.d.cts.map → config-BnA0kPTY.d.cts.map} +1 -1
- package/dist/{config-UtKzhpFb.d.mts → config-CxEp_g7j.d.mts} +3 -3
- package/dist/{config-UtKzhpFb.d.mts.map → config-CxEp_g7j.d.mts.map} +1 -1
- package/dist/config.d.cts +3 -3
- package/dist/config.d.mts +3 -3
- package/dist/{credentials-Bzbg23n8.cjs → credentials-BVs7mrtw.cjs} +239 -3
- package/dist/credentials-BVs7mrtw.cjs.map +1 -0
- package/dist/{credentials-Dq1MnWQf.mjs → credentials-pAtB70k3.mjs} +223 -5
- package/dist/credentials-pAtB70k3.mjs.map +1 -0
- package/dist/deploy/public.cjs +21 -17
- package/dist/deploy/public.cjs.map +1 -1
- package/dist/deploy/public.d.cts +15 -4
- package/dist/deploy/public.d.cts.map +1 -1
- package/dist/deploy/public.d.mts +15 -4
- package/dist/deploy/public.d.mts.map +1 -1
- package/dist/deploy/public.mjs +10 -10
- package/dist/{deploy-B7aJi_Ah.cjs → deploy-C08GONYW.cjs} +14 -10
- package/dist/deploy-C08GONYW.cjs.map +1 -0
- package/dist/{deploy-DWn7YfIc.mjs → deploy-D-EVOWvu.mjs} +11 -7
- package/dist/deploy-D-EVOWvu.mjs.map +1 -0
- package/dist/{dokploy-B4LcYG1E.cjs → dokploy-CZ6nfSV9.cjs} +2 -2
- package/dist/{dokploy-B4LcYG1E.cjs.map → dokploy-CZ6nfSV9.cjs.map} +1 -1
- package/dist/{dokploy-DalwLML5.mjs → dokploy-DU-XJsoT.mjs} +2 -2
- package/dist/{dokploy-DalwLML5.mjs.map → dokploy-DU-XJsoT.mjs.map} +1 -1
- package/dist/{encryption-3KlGS1sl.cjs → encryption-CCcBSLZs.cjs} +2 -2
- package/dist/{encryption-3KlGS1sl.cjs.map → encryption-CCcBSLZs.cjs.map} +1 -1
- package/dist/{encryption-CPfENwWO.mjs → encryption-DSiLau1-.mjs} +2 -2
- package/dist/{encryption-CPfENwWO.mjs.map → encryption-DSiLau1-.mjs.map} +1 -1
- package/dist/{engine-DR6KurWX.cjs → engine-BM9Dy_DT.cjs} +141 -209
- package/dist/engine-BM9Dy_DT.cjs.map +1 -0
- package/dist/{engine-CunvOG3s.mjs → engine-llWnMZH5.mjs} +137 -205
- package/dist/engine-llWnMZH5.mjs.map +1 -0
- package/dist/{github-BJ5gh9bw.cjs → github-ZoMam0OH.cjs} +2 -2
- package/dist/{github-BJ5gh9bw.cjs.map → github-ZoMam0OH.cjs.map} +1 -1
- package/dist/{github-MXA9v7Ge.mjs → github-yy6WpHIl.mjs} +2 -2
- package/dist/{github-MXA9v7Ge.mjs.map → github-yy6WpHIl.mjs.map} +1 -1
- package/dist/{index-DD1FfUHh.d.mts → index-CkX89m6S.d.mts} +2 -2
- package/dist/{index-CyApaHVe.d.cts.map → index-CkX89m6S.d.mts.map} +1 -1
- package/dist/{index-CyApaHVe.d.cts → index-EvZBkycu.d.cts} +2 -2
- package/dist/{index-DD1FfUHh.d.mts.map → index-EvZBkycu.d.cts.map} +1 -1
- package/dist/index.cjs +59 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +55 -66
- package/dist/index.mjs.map +1 -1
- package/dist/{migrate-BJBcm4sG.mjs → migrate-BWPuwiXk.mjs} +2 -2
- package/dist/{migrate-BJBcm4sG.mjs.map → migrate-BWPuwiXk.mjs.map} +1 -1
- package/dist/{migrate-CF4J4GQS.cjs → migrate-tOwxQBTI.cjs} +2 -2
- package/dist/{migrate-CF4J4GQS.cjs.map → migrate-tOwxQBTI.cjs.map} +1 -1
- package/dist/openapi.d.cts +1 -1
- package/dist/openapi.d.mts +1 -1
- package/dist/reconcile/public.d.cts +1 -1
- package/dist/reconcile/public.d.mts +1 -1
- package/dist/{resolve-CVmch0wI.cjs → resolve-BymzGtuT.cjs} +4 -4
- package/dist/{resolve-CVmch0wI.cjs.map → resolve-BymzGtuT.cjs.map} +1 -1
- package/dist/{resolve-Db0tNlrW.mjs → resolve-pIvXlZ1h.mjs} +4 -4
- package/dist/{resolve-Db0tNlrW.mjs.map → resolve-pIvXlZ1h.mjs.map} +1 -1
- package/dist/{rollback-DvAo4OKS.mjs → rollback-ZlsbQOvj.mjs} +5 -6
- package/dist/{rollback-DvAo4OKS.mjs.map → rollback-ZlsbQOvj.mjs.map} +1 -1
- package/dist/{rollback-DxJK84hy.cjs → rollback-zdfi6Reg.cjs} +6 -7
- package/dist/{rollback-DxJK84hy.cjs.map → rollback-zdfi6Reg.cjs.map} +1 -1
- package/dist/run-BJLIerMM.mjs +133 -0
- package/dist/run-BJLIerMM.mjs.map +1 -0
- package/dist/run-fVZ-rLnc.cjs +174 -0
- package/dist/run-fVZ-rLnc.cjs.map +1 -0
- package/dist/{runtime-BRqFOvPM.d.cts → runtime-B3jNgeP-.d.mts} +2 -2
- package/dist/{runtime-BRqFOvPM.d.cts.map → runtime-B3jNgeP-.d.mts.map} +1 -1
- package/dist/{runtime-CEk5n4VH.d.mts → runtime-DWThEP-r.d.cts} +2 -2
- package/dist/{runtime-CEk5n4VH.d.mts.map → runtime-DWThEP-r.d.cts.map} +1 -1
- package/dist/{sniffer-BAQhLEsI.mjs → sniffer-BdFeLSmA.mjs} +8 -60
- package/dist/sniffer-BdFeLSmA.mjs.map +1 -0
- package/dist/{sniffer-5xmrkh2a.cjs → sniffer-D66HIvi9.cjs} +7 -77
- package/dist/sniffer-D66HIvi9.cjs.map +1 -0
- package/dist/{sst-B2HbrDR3.mjs → sst-DRi3H5TB.mjs} +3 -3
- package/dist/{sst-B2HbrDR3.mjs.map → sst-DRi3H5TB.mjs.map} +1 -1
- package/dist/{sst-DcfsVH6x.cjs → sst-hBrKouBy.cjs} +5 -5
- package/dist/{sst-DcfsVH6x.cjs.map → sst-hBrKouBy.cjs.map} +1 -1
- package/dist/{state-uuYqnRVO.mjs → state-Bn5JDMFT.mjs} +1717 -1334
- package/dist/state-Bn5JDMFT.mjs.map +1 -0
- package/dist/{state-Y6ATakMX.cjs → state-CHZqCDRC.cjs} +2863 -2354
- package/dist/state-CHZqCDRC.cjs.map +1 -0
- 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/{terminal-DUhcZ1ND.mjs → terminal-C3cv-OQu.mjs} +2 -2
- package/dist/{terminal-DUhcZ1ND.mjs.map → terminal-C3cv-OQu.mjs.map} +1 -1
- package/dist/{terminal-CHy_MdjG.cjs → terminal-DjzgLu6m.cjs} +2 -2
- package/dist/{terminal-CHy_MdjG.cjs.map → terminal-DjzgLu6m.cjs.map} +1 -1
- package/dist/{types-CSw2w69a.d.mts → types-CZdCxUCK.d.cts} +64 -2
- package/dist/types-CZdCxUCK.d.cts.map +1 -0
- package/dist/{types-D5WIxRDP.d.cts → types-Da0E3Qv7.d.mts} +63 -3
- package/dist/types-Da0E3Qv7.d.mts.map +1 -0
- package/dist/workspace/index.d.cts +2 -2
- package/dist/workspace/index.d.mts +2 -2
- package/package.json +13 -13
- package/dist/bundler-Dvaiud0h.mjs.map +0 -1
- package/dist/bundler-orRqUJNZ.cjs.map +0 -1
- package/dist/cli-CfHm0AO7.mjs.map +0 -1
- package/dist/cli-D1JkkOwg.cjs.map +0 -1
- package/dist/compose-CNQL8BnO.mjs.map +0 -1
- package/dist/compose-jrLy7KGO.cjs.map +0 -1
- package/dist/credentials-Bzbg23n8.cjs.map +0 -1
- package/dist/credentials-Dq1MnWQf.mjs.map +0 -1
- package/dist/deploy-B7aJi_Ah.cjs.map +0 -1
- package/dist/deploy-DWn7YfIc.mjs.map +0 -1
- package/dist/engine-CunvOG3s.mjs.map +0 -1
- package/dist/engine-DR6KurWX.cjs.map +0 -1
- package/dist/local-BdH4XN0E.cjs +0 -409
- package/dist/local-BdH4XN0E.cjs.map +0 -1
- package/dist/local-CWR4Y_kW.mjs +0 -350
- package/dist/local-CWR4Y_kW.mjs.map +0 -1
- package/dist/sniffer-5xmrkh2a.cjs.map +0 -1
- package/dist/sniffer-BAQhLEsI.mjs.map +0 -1
- package/dist/state-Y6ATakMX.cjs.map +0 -1
- package/dist/state-uuYqnRVO.mjs.map +0 -1
- package/dist/types-CSw2w69a.d.mts.map +0 -1
- package/dist/types-D5WIxRDP.d.cts.map +0 -1
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
|
|
2
|
+
import { a as runOutput, i as run$1, s as gkmHome } from "./run-BJLIerMM.mjs";
|
|
3
3
|
import { d as output, r as discover } from "./discover-BFoJWKGD.mjs";
|
|
4
4
|
import { t as appKey } from "./derive-B4vDpzgP.mjs";
|
|
5
5
|
import { O as TEST_STAGE, h as getPublicEnvPrefix, n as allConstructGlobs } from "./workspace-Be6uBhlF.mjs";
|
|
6
|
-
import { ConfigNotFound, NotInAnApp,
|
|
7
|
-
import { c as runtimeOf } from "./resolve-
|
|
6
|
+
import { ConfigNotFound, NotInAnApp, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
|
|
7
|
+
import { c as runtimeOf } from "./resolve-pIvXlZ1h.mjs";
|
|
8
8
|
import { i as DEFAULT_STORAGE, n as DEFAULT_CACHE, r as DEFAULT_EVENTS$1, t as readFakes } from "./fakes-C9RiT6XG.mjs";
|
|
9
|
-
import { basename, dirname, join, parse, resolve } from "node:path";
|
|
10
|
-
import { constants,
|
|
11
|
-
import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
12
|
-
import { homedir, networkInterfaces } from "node:os";
|
|
9
|
+
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { constants, existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { appendFile, chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
13
13
|
import { promisify } from "node:util";
|
|
14
14
|
import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase, scopedName } from "@geekmidas/manifest";
|
|
15
15
|
import { execFile, spawn } from "node:child_process";
|
|
@@ -271,7 +271,7 @@ function applicationName(identity, app) {
|
|
|
271
271
|
* old file stays, so an older CLI on the same machine keeps working.
|
|
272
272
|
*/
|
|
273
273
|
/** Key length for AES-256 encryption */
|
|
274
|
-
const KEY_LENGTH = 32;
|
|
274
|
+
const KEY_LENGTH$1 = 32;
|
|
275
275
|
/** A keystore project key that is not `<namespace>/<project>`. */
|
|
276
276
|
var KeystoreProjectInvalid = class extends Error {
|
|
277
277
|
key;
|
|
@@ -364,7 +364,7 @@ async function generateKey(stage, project, flag = "w") {
|
|
|
364
364
|
recursive: true,
|
|
365
365
|
mode: 448
|
|
366
366
|
});
|
|
367
|
-
const key = randomBytes(KEY_LENGTH).toString("hex");
|
|
367
|
+
const key = randomBytes(KEY_LENGTH$1).toString("hex");
|
|
368
368
|
await writeFile(keyPath, key, {
|
|
369
369
|
mode: 384,
|
|
370
370
|
encoding: "utf-8",
|
|
@@ -433,8 +433,8 @@ async function getOrCreateKey(stage, project) {
|
|
|
433
433
|
/** Where the files live, relative to the project root. */
|
|
434
434
|
const SECRETS_DIR = ".gkm/secrets";
|
|
435
435
|
/** AES-256-GCM configuration */
|
|
436
|
-
const ALGORITHM = "aes-256-gcm";
|
|
437
|
-
const IV_LENGTH = 12;
|
|
436
|
+
const ALGORITHM$1 = "aes-256-gcm";
|
|
437
|
+
const IV_LENGTH$1 = 12;
|
|
438
438
|
/**
|
|
439
439
|
* A stage's secrets as an encrypted file on this machine:
|
|
440
440
|
* `.gkm/secrets/<stage>.json`, with its key in the CLI's home at
|
|
@@ -455,7 +455,7 @@ var FileSecretsStore = class {
|
|
|
455
455
|
constructor(root, project) {
|
|
456
456
|
this.root = root;
|
|
457
457
|
this.project = project ?? {
|
|
458
|
-
key: projectKey({ name: packageName(root) ?? basename(root) }),
|
|
458
|
+
key: projectKey({ name: packageName$1(root) ?? basename(root) }),
|
|
459
459
|
legacy: [basename(root)]
|
|
460
460
|
};
|
|
461
461
|
}
|
|
@@ -492,7 +492,7 @@ var MissingSecretsKey = class extends Error {
|
|
|
492
492
|
}
|
|
493
493
|
};
|
|
494
494
|
/** A project's package name without its scope, as a workspace name reads it. */
|
|
495
|
-
function packageName(root) {
|
|
495
|
+
function packageName$1(root) {
|
|
496
496
|
try {
|
|
497
497
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
|
|
498
498
|
return typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : void 0;
|
|
@@ -502,8 +502,8 @@ function packageName(root) {
|
|
|
502
502
|
}
|
|
503
503
|
function encrypt(secrets, keyHex) {
|
|
504
504
|
const key = Buffer.from(keyHex, "hex");
|
|
505
|
-
const iv = randomBytes(IV_LENGTH);
|
|
506
|
-
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
505
|
+
const iv = randomBytes(IV_LENGTH$1);
|
|
506
|
+
const cipher = createCipheriv(ALGORITHM$1, key, iv);
|
|
507
507
|
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(secrets), "utf-8"), cipher.final()]);
|
|
508
508
|
return {
|
|
509
509
|
version: 1,
|
|
@@ -514,7 +514,7 @@ function encrypt(secrets, keyHex) {
|
|
|
514
514
|
function decrypt(data, keyHex) {
|
|
515
515
|
const key = Buffer.from(keyHex, "hex");
|
|
516
516
|
const combined = Buffer.from(data.encrypted, "base64");
|
|
517
|
-
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(data.iv, "hex"));
|
|
517
|
+
const decipher = createDecipheriv(ALGORITHM$1, key, Buffer.from(data.iv, "hex"));
|
|
518
518
|
decipher.setAuthTag(combined.subarray(-16));
|
|
519
519
|
const plaintext = Buffer.concat([decipher.update(combined.subarray(0, -16)), decipher.final()]);
|
|
520
520
|
return JSON.parse(plaintext.toString("utf-8"));
|
|
@@ -551,7 +551,7 @@ function isRemoteStore(workspace, stage) {
|
|
|
551
551
|
}
|
|
552
552
|
//#endregion
|
|
553
553
|
//#region src/credentials/index.ts
|
|
554
|
-
const logger$
|
|
554
|
+
const logger$2 = console;
|
|
555
555
|
/**
|
|
556
556
|
* Load environment files
|
|
557
557
|
* @internal Exported for testing
|
|
@@ -602,7 +602,7 @@ async function findAvailablePort(preferredPort, maxAttempts = 10) {
|
|
|
602
602
|
for (let i = 0; i < maxAttempts; i++) {
|
|
603
603
|
const port = preferredPort + i;
|
|
604
604
|
if (await isPortAvailable(port)) return port;
|
|
605
|
-
logger$
|
|
605
|
+
logger$2.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
|
|
606
606
|
}
|
|
607
607
|
throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
|
|
608
608
|
}
|
|
@@ -637,7 +637,7 @@ async function loadSecretsForApp(store, stage, appName) {
|
|
|
637
637
|
let secrets = {};
|
|
638
638
|
const stageSecrets = await store.read(stage);
|
|
639
639
|
if (stageSecrets) {
|
|
640
|
-
logger$
|
|
640
|
+
logger$2.log(`🔐 Loading secrets from stage: ${stage}`);
|
|
641
641
|
secrets = toEmbeddableSecrets(stageSecrets);
|
|
642
642
|
}
|
|
643
643
|
if (Object.keys(secrets).length === 0) return {};
|
|
@@ -743,7 +743,7 @@ async function prepareEntryCredentials(options) {
|
|
|
743
743
|
free: isPortAvailable,
|
|
744
744
|
holder: holderOf
|
|
745
745
|
});
|
|
746
|
-
for (const { app, from, to, holder } of assigned.moved) logger$
|
|
746
|
+
for (const { app, from, to, holder } of assigned.moved) logger$2.log(`↪️ ${app}: ${from} is held by ${describeHolder(holder)}, which is not this workspace's — using ${to}`);
|
|
747
747
|
workspace = withAppPorts(workspace, assigned.ports);
|
|
748
748
|
workspaceAppPort = assigned.ports[appName] ?? workspaceAppPort;
|
|
749
749
|
}
|
|
@@ -767,7 +767,7 @@ async function prepareEntryCredentials(options) {
|
|
|
767
767
|
});
|
|
768
768
|
Object.assign(credentials, reconciled.env);
|
|
769
769
|
declaredKeys.push(...Object.keys(reconciled.env));
|
|
770
|
-
if (Object.keys(reconciled.env).length > 0) logger$
|
|
770
|
+
if (Object.keys(reconciled.env).length > 0) logger$2.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
|
|
771
771
|
}
|
|
772
772
|
const secretsDir = join(secretsRoot, ".gkm");
|
|
773
773
|
await mkdir(secretsDir, { recursive: true });
|
|
@@ -1019,207 +1019,1049 @@ function eventsBackendFor(on) {
|
|
|
1019
1019
|
return DEFAULT_EVENTS$1[on];
|
|
1020
1020
|
}
|
|
1021
1021
|
//#endregion
|
|
1022
|
-
//#region src/
|
|
1022
|
+
//#region src/docker/templates.ts
|
|
1023
1023
|
/**
|
|
1024
|
-
* The
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
* one rather than a near-enough one — and the application is indifferent
|
|
1029
|
-
* either way, because it reads whichever address was injected and never
|
|
1030
|
-
* composes one.
|
|
1031
|
-
*
|
|
1032
|
-
* Adding a kind here is the whole of adding it to the edge.
|
|
1024
|
+
* The turbo every image prunes and builds with when the build root resolves
|
|
1025
|
+
* none of its own: a repository without turbo still needs `turbo prune` to
|
|
1026
|
+
* cut its slice of the workspace, and an unpinned `turbo` is whatever the
|
|
1027
|
+
* registry says on the day of the build.
|
|
1033
1028
|
*/
|
|
1034
|
-
const
|
|
1035
|
-
"file-server": true,
|
|
1036
|
-
"rest-api": true,
|
|
1037
|
-
site: true
|
|
1038
|
-
};
|
|
1029
|
+
const TURBO_VERSION = "2.5.4";
|
|
1039
1030
|
/**
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
* A surface and a site are served by processes `gkm dev` starts on the host,
|
|
1043
|
-
* not by containers — so the edge has to leave Docker's network to reach them.
|
|
1044
|
-
* Docker Desktop resolves this name already; on Linux the compose definition
|
|
1045
|
-
* maps it to the host gateway.
|
|
1031
|
+
* Caddy, which serves a static site's files. A 2.x tag, so a rebuild picks
|
|
1032
|
+
* up patch releases and never a major.
|
|
1046
1033
|
*/
|
|
1047
|
-
const
|
|
1034
|
+
const STATIC_SITE_IMAGE = "caddy:2.10-alpine";
|
|
1035
|
+
const LOCKFILES = [
|
|
1036
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
1037
|
+
["bun.lockb", "bun"],
|
|
1038
|
+
["yarn.lock", "yarn"],
|
|
1039
|
+
["package-lock.json", "npm"]
|
|
1040
|
+
];
|
|
1048
1041
|
/**
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
* `.localhost` rather than a made-up TLD because it resolves to loopback
|
|
1052
|
-
* without touching `/etc/hosts` — macOS and systemd-resolved both answer for
|
|
1053
|
-
* subdomains of it, and Caddy issues internal certificates for it by default.
|
|
1042
|
+
* Detect package manager from lockfiles
|
|
1043
|
+
* Walks up the directory tree to find lockfile (for monorepos)
|
|
1054
1044
|
*/
|
|
1055
|
-
|
|
1045
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
1046
|
+
const lockfile = findLockfilePath(cwd);
|
|
1047
|
+
if (!lockfile) return "pnpm";
|
|
1048
|
+
const name = lockfile.slice(dirname(lockfile).length + 1);
|
|
1049
|
+
return LOCKFILES.find(([file]) => file === name)?.[1] ?? "pnpm";
|
|
1050
|
+
}
|
|
1056
1051
|
/**
|
|
1057
|
-
*
|
|
1052
|
+
* Find the lockfile path by walking up the directory tree
|
|
1053
|
+
* Returns the full path to the lockfile, or null if not found
|
|
1054
|
+
*/
|
|
1055
|
+
function findLockfilePath(cwd = process.cwd()) {
|
|
1056
|
+
for (const dir of upFrom(cwd)) for (const [lockfile] of LOCKFILES) {
|
|
1057
|
+
const lockfilePath = join(dir, lockfile);
|
|
1058
|
+
if (existsSync(lockfilePath)) return lockfilePath;
|
|
1059
|
+
}
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* The directory every image is built from: the nearest at or above `cwd`
|
|
1064
|
+
* holding a lockfile or a `pnpm-workspace.yaml` — the package manager's root,
|
|
1065
|
+
* which is what an install resolves the workspace's dependencies against.
|
|
1058
1066
|
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1067
|
+
* In a project of its own that is the gkm workspace's root. A gkm workspace
|
|
1068
|
+
* nested inside a monorepo, whose dependencies are the monorepo's
|
|
1069
|
+
* `workspace:*` packages, is built from the monorepo's root. `cwd` itself
|
|
1070
|
+
* when neither is found.
|
|
1063
1071
|
*/
|
|
1064
|
-
function
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1072
|
+
function findBuildRoot(cwd = process.cwd()) {
|
|
1073
|
+
for (const dir of upFrom(cwd)) {
|
|
1074
|
+
if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
|
|
1075
|
+
for (const [lockfile] of LOCKFILES) if (existsSync(join(dir, lockfile))) return dir;
|
|
1076
|
+
}
|
|
1077
|
+
return cwd;
|
|
1078
|
+
}
|
|
1079
|
+
/** `cwd` and every directory above it, the filesystem root last. */
|
|
1080
|
+
function upFrom(cwd) {
|
|
1081
|
+
const dirs = [];
|
|
1082
|
+
const root = parse(cwd).root;
|
|
1083
|
+
let dir = cwd;
|
|
1084
|
+
while (dir !== root) {
|
|
1085
|
+
dirs.push(dir);
|
|
1086
|
+
dir = dirname(dir);
|
|
1087
|
+
}
|
|
1088
|
+
dirs.push(root);
|
|
1089
|
+
return dirs;
|
|
1090
|
+
}
|
|
1091
|
+
/** The package manager, its version, turbo's and the shape of a build root. */
|
|
1092
|
+
function resolveBuildTools(buildRoot) {
|
|
1093
|
+
const pkg = readJson(join(buildRoot, "package.json"));
|
|
1094
|
+
const packageManager = detectPackageManager(buildRoot);
|
|
1095
|
+
const field = typeof pkg?.packageManager === "string" ? pkg.packageManager : "";
|
|
1096
|
+
const match = /^([a-z]+)@(\d[^+\s]*)/.exec(field);
|
|
1097
|
+
const packageManagerVersion = match && match[1] === packageManager ? match[2] : void 0;
|
|
1098
|
+
return {
|
|
1099
|
+
packageManager,
|
|
1100
|
+
...packageManagerVersion ? { packageManagerVersion } : {},
|
|
1101
|
+
turboVersion: resolveTurboVersion(buildRoot) ?? "2.5.4",
|
|
1102
|
+
monorepo: existsSync(join(buildRoot, "pnpm-workspace.yaml")) || pkg?.workspaces !== void 0
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* The turbo version a build root resolves: what is installed, or what its
|
|
1107
|
+
* lockfile pins. Undefined when it has none.
|
|
1108
|
+
*/
|
|
1109
|
+
function resolveTurboVersion(buildRoot) {
|
|
1110
|
+
const installed = readJson(join(buildRoot, "node_modules", "turbo", "package.json"));
|
|
1111
|
+
if (typeof installed?.version === "string") return installed.version;
|
|
1112
|
+
const read = (file) => {
|
|
1113
|
+
try {
|
|
1114
|
+
return readFileSync(join(buildRoot, file), "utf-8");
|
|
1115
|
+
} catch {
|
|
1116
|
+
return;
|
|
1079
1117
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1118
|
+
};
|
|
1119
|
+
const pnpm = read("pnpm-lock.yaml");
|
|
1120
|
+
const fromPnpm = pnpm && /^ {2}'?turbo@(\d[^:'(\s]*)'?:/m.exec(pnpm);
|
|
1121
|
+
if (fromPnpm) return fromPnpm[1];
|
|
1122
|
+
const npm = read("package-lock.json");
|
|
1123
|
+
if (npm) {
|
|
1124
|
+
const version = safeParse(npm)?.packages?.["node_modules/turbo"]?.version;
|
|
1125
|
+
if (version) return version;
|
|
1088
1126
|
}
|
|
1089
|
-
|
|
1127
|
+
const yarn = read("yarn.lock");
|
|
1128
|
+
const fromYarn = yarn && /^"?turbo@[^\n]*:\n\s+version:? "?(\d[^"\s]*)"?/m.exec(yarn);
|
|
1129
|
+
if (fromYarn) return fromYarn[1];
|
|
1090
1130
|
}
|
|
1091
|
-
|
|
1092
|
-
function portOf(address) {
|
|
1131
|
+
function readJson(path) {
|
|
1093
1132
|
try {
|
|
1094
|
-
|
|
1095
|
-
|
|
1133
|
+
return safeParse(readFileSync(path, "utf-8"));
|
|
1134
|
+
} catch {
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function safeParse(text) {
|
|
1139
|
+
try {
|
|
1140
|
+
const value = JSON.parse(text);
|
|
1141
|
+
return value && typeof value === "object" ? value : void 0;
|
|
1096
1142
|
} catch {
|
|
1097
1143
|
return;
|
|
1098
1144
|
}
|
|
1099
1145
|
}
|
|
1100
1146
|
/**
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
* The project is a label rather than a decoration: it is what keeps two
|
|
1104
|
-
* checkouts from answering on one address. Omitted when there is no project to
|
|
1105
|
-
* name, because an empty label makes `uploads..localhost` — a host nothing
|
|
1106
|
-
* resolves and no certificate can be issued for.
|
|
1147
|
+
* Check if turbo.json exists (walks up directory tree)
|
|
1107
1148
|
*/
|
|
1108
|
-
function
|
|
1109
|
-
return
|
|
1110
|
-
resource.root && project ? stageOf(resource) : labelOf(resource),
|
|
1111
|
-
project,
|
|
1112
|
-
LOCAL_TLD
|
|
1113
|
-
].filter(Boolean).join(".");
|
|
1149
|
+
function hasTurboConfig(cwd = process.cwd()) {
|
|
1150
|
+
return upFrom(cwd).some((dir) => existsSync(join(dir, "turbo.json")));
|
|
1114
1151
|
}
|
|
1115
1152
|
/**
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
* still answer on different hosts.
|
|
1153
|
+
* Package manager specific commands. `version` pins what corepack (or npm,
|
|
1154
|
+
* for bun) installs; without it, the latest.
|
|
1119
1155
|
*/
|
|
1120
|
-
function
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1156
|
+
function getPmConfig(pm, version) {
|
|
1157
|
+
return {
|
|
1158
|
+
pnpm: {
|
|
1159
|
+
install: `corepack enable && corepack prepare pnpm@${version ?? "latest"} --activate`,
|
|
1160
|
+
lockfile: "pnpm-lock.yaml",
|
|
1161
|
+
installCmd: "pnpm install",
|
|
1162
|
+
cacheTarget: "/root/.local/share/pnpm/store",
|
|
1163
|
+
cacheId: "pnpm",
|
|
1164
|
+
run: "pnpm run",
|
|
1165
|
+
dlx: "pnpm dlx"
|
|
1166
|
+
},
|
|
1167
|
+
npm: {
|
|
1168
|
+
install: version ? `npm install -g npm@${version}` : "",
|
|
1169
|
+
lockfile: "package-lock.json",
|
|
1170
|
+
installCmd: "npm install",
|
|
1171
|
+
cacheTarget: "/root/.npm",
|
|
1172
|
+
cacheId: "npm",
|
|
1173
|
+
run: "npm run",
|
|
1174
|
+
dlx: "npx --yes"
|
|
1175
|
+
},
|
|
1176
|
+
yarn: {
|
|
1177
|
+
install: `corepack enable && corepack prepare yarn@${version ?? "stable"} --activate`,
|
|
1178
|
+
lockfile: "yarn.lock",
|
|
1179
|
+
installCmd: "yarn install",
|
|
1180
|
+
cacheTarget: "/root/.yarn/cache",
|
|
1181
|
+
cacheId: "yarn",
|
|
1182
|
+
run: "yarn run",
|
|
1183
|
+
dlx: "yarn dlx"
|
|
1184
|
+
},
|
|
1185
|
+
bun: {
|
|
1186
|
+
install: `npm install -g bun@${version ?? "latest"}`,
|
|
1187
|
+
lockfile: "bun.lockb",
|
|
1188
|
+
installCmd: "bun install",
|
|
1189
|
+
cacheTarget: "/root/.bun/install/cache",
|
|
1190
|
+
cacheId: "bun",
|
|
1191
|
+
run: "bun run",
|
|
1192
|
+
dlx: "bunx"
|
|
1193
|
+
}
|
|
1194
|
+
}[pm];
|
|
1124
1195
|
}
|
|
1125
|
-
/**
|
|
1126
|
-
function
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1196
|
+
/** What every template's first three stages need, resolved from options. */
|
|
1197
|
+
function layout(options) {
|
|
1198
|
+
const pm = getPmConfig(options.packageManager, options.packageManagerVersion);
|
|
1199
|
+
const turbo = `${pm.dlx} turbo@${options.turboVersion ?? "2.5.4"}`;
|
|
1200
|
+
const gkmRoot = options.gkmRoot ?? ".";
|
|
1201
|
+
return {
|
|
1202
|
+
pm,
|
|
1203
|
+
turbo,
|
|
1204
|
+
installPm: pm.install ? `RUN ${pm.install}` : "",
|
|
1205
|
+
monorepo: options.monorepo ?? true,
|
|
1206
|
+
gkmRoot,
|
|
1207
|
+
/** The app, absolute inside the image. */
|
|
1208
|
+
app: `/app/${options.appPath}`.replace(/\/\.$/, "")
|
|
1209
|
+
};
|
|
1129
1210
|
}
|
|
1130
1211
|
/**
|
|
1131
|
-
* The
|
|
1212
|
+
* The pruner stage: the build context cut to the app's slice, in turbo's
|
|
1213
|
+
* `out/` layout — `out/json` for the install, `out/full` for the build.
|
|
1132
1214
|
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1215
|
+
* `turbo prune` keeps the packages the app depends on and nothing else at
|
|
1216
|
+
* the root, so the root's own files (a `tsconfig.base.json` the packages
|
|
1217
|
+
* extend, the config their build reads) and what the gkm workspace keeps
|
|
1218
|
+
* outside any package are copied in beside them. A single package is copied
|
|
1219
|
+
* whole.
|
|
1138
1220
|
*/
|
|
1139
|
-
function
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1221
|
+
function prunerStage(options) {
|
|
1222
|
+
const { pm, turbo, installPm, monorepo, gkmRoot } = layout(options);
|
|
1223
|
+
const scopes = [options.turboPackage, ...options.prunePackages ?? []].join(" ");
|
|
1224
|
+
if (!monorepo) return `# Stage 1: The build context, in the layout the stages below read
|
|
1225
|
+
FROM ${options.baseImage} AS pruner
|
|
1226
|
+
WORKDIR /app
|
|
1227
|
+
COPY . .
|
|
1228
|
+
RUN mkdir -p /tmp/out/json /tmp/out/full && \\
|
|
1229
|
+
cp package.json /tmp/out/json/ && \\
|
|
1230
|
+
cp ${pm.lockfile} /tmp/out/ && \\
|
|
1231
|
+
cp -a . /tmp/out/full/ && \\
|
|
1232
|
+
mv /tmp/out ./out`;
|
|
1233
|
+
const gkmPaths = options.gkmPaths ?? [];
|
|
1234
|
+
const nested = gkmRoot !== ".";
|
|
1235
|
+
const dest = nested ? `/app/out/full/${gkmRoot}` : "/app/out/full";
|
|
1236
|
+
const copyGkm = gkmPaths.length ? `
|
|
1237
|
+
|
|
1238
|
+
# What the gkm workspace keeps outside any package — its config, the
|
|
1239
|
+
# directories its constructs are declared in — beside the slice.
|
|
1240
|
+
RUN ${nested ? `cd ${gkmRoot} && ` : ""}for path in ${gkmPaths.join(" ")}; do \\
|
|
1241
|
+
if [ -e "$path" ]; then \\
|
|
1242
|
+
mkdir -p "${dest}" && cp -rn "$path" "${dest}/"; \\
|
|
1243
|
+
fi; \\
|
|
1244
|
+
done` : "";
|
|
1245
|
+
return `# Stage 1: Prune the monorepo to the app's slice
|
|
1246
|
+
FROM ${options.baseImage} AS pruner
|
|
1247
|
+
WORKDIR /app
|
|
1248
|
+
${installPm}
|
|
1249
|
+
COPY . .
|
|
1250
|
+
RUN ${turbo} prune ${scopes} --docker
|
|
1251
|
+
|
|
1252
|
+
# The root's own files — the tsconfig the packages extend, the config their
|
|
1253
|
+
# build reads — which turbo prune leaves out. What the install itself reads
|
|
1254
|
+
# (registry config, local tarballs) goes beside the manifests too.
|
|
1255
|
+
RUN find . -maxdepth 1 -type f ! -name '${pm.lockfile}' ! -name package.json \\
|
|
1256
|
+
-exec cp -n {} out/full/ \\; && \\
|
|
1257
|
+
for file in .npmrc .yarnrc.yml .pnpmfile.cjs bunfig.toml *.tgz; do \\
|
|
1258
|
+
if [ -f "$file" ]; then cp "$file" out/json/; fi; \\
|
|
1259
|
+
done${copyGkm}`;
|
|
1260
|
+
}
|
|
1261
|
+
/** The deps stage: the slice's dependencies, installed from its lockfile. */
|
|
1262
|
+
function depsStage(options) {
|
|
1263
|
+
const { pm, installPm } = layout(options);
|
|
1264
|
+
return `# Stage 2: Install dependencies
|
|
1265
|
+
FROM ${options.baseImage} AS deps
|
|
1266
|
+
WORKDIR /app
|
|
1267
|
+
${installPm}
|
|
1268
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
1269
|
+
COPY --from=pruner /app/out/json/ ./
|
|
1270
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
1271
|
+
${pm.installCmd}`;
|
|
1145
1272
|
}
|
|
1273
|
+
/**
|
|
1274
|
+
* The start of the builder stage: the slice's source over its dependencies,
|
|
1275
|
+
* and every workspace package the app depends on built from it.
|
|
1276
|
+
*
|
|
1277
|
+
* Nothing built on the host reaches the image — `.dockerignore` keeps every
|
|
1278
|
+
* `dist` out — so a workspace package (a CLI that is a sibling package) is
|
|
1279
|
+
* built here: the root's `build` script if it has one, then turbo's build of
|
|
1280
|
+
* the app's dependencies (`^build`).
|
|
1281
|
+
*/
|
|
1282
|
+
function builderStage(options, extra = {}) {
|
|
1283
|
+
const { pm, turbo, monorepo } = layout(options);
|
|
1284
|
+
const args = extra.args ?? [];
|
|
1285
|
+
const argLines = args.length ? `\n# Build-time args: public values the bundler inlines\n${args.map((a) => `ARG ${a}=""`).join("\n")}\n${args.map((a) => `ENV ${a}=$${a}`).join("\n")}\n` : "";
|
|
1286
|
+
const scopes = [options.turboPackage, ...options.prunePackages ?? []];
|
|
1287
|
+
const dependencies = monorepo ? `\nRUN ${turbo} run build ${scopes.map((s) => `--filter='${s}^...'`).join(" ")}` : "";
|
|
1288
|
+
return `# Stage 3: Build
|
|
1289
|
+
FROM deps AS builder
|
|
1290
|
+
WORKDIR /app
|
|
1291
|
+
${argLines}
|
|
1292
|
+
COPY --from=pruner /app/out/full/ ./
|
|
1146
1293
|
|
|
1147
|
-
#
|
|
1148
|
-
|
|
1149
|
-
import /etc/caddy/sites/*.caddy
|
|
1150
|
-
`;
|
|
1294
|
+
# The workspace packages the app depends on, built from source.
|
|
1295
|
+
RUN ${pm.run} --if-present build${dependencies}`;
|
|
1151
1296
|
}
|
|
1152
1297
|
/**
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
|
|
1298
|
+
* The PATH a command in the app's directory runs with: the app's binaries,
|
|
1299
|
+
* then the gkm workspace's, then the root's — a nested workspace's apps
|
|
1300
|
+
* declare nothing, and its CLI is installed where its config is.
|
|
1301
|
+
*/
|
|
1302
|
+
function binPath(options) {
|
|
1303
|
+
const { app, gkmRoot } = layout(options);
|
|
1304
|
+
const dirs = [
|
|
1305
|
+
`${app}/node_modules/.bin`,
|
|
1306
|
+
gkmRoot === "." ? "" : `/app/${gkmRoot}/node_modules/.bin`,
|
|
1307
|
+
"/app/node_modules/.bin"
|
|
1308
|
+
].filter(Boolean);
|
|
1309
|
+
return `PATH="${[...new Set(dirs)].join(":")}:$PATH"`;
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Run the gkm CLI the app resolves, from the app's directory: node on its
|
|
1313
|
+
* `bin/gkm.mjs`, found the way Node resolves a package — up from the app.
|
|
1157
1314
|
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1315
|
+
* Not through `node_modules/.bin`: a CLI that is a workspace package has no
|
|
1316
|
+
* bin linked, since its files were not there when the slice's dependencies
|
|
1317
|
+
* were installed.
|
|
1161
1318
|
*/
|
|
1162
|
-
function
|
|
1163
|
-
|
|
1164
|
-
# No file server is declared in this stage, so it routes nothing.
|
|
1165
|
-
`;
|
|
1166
|
-
const blocks = sites.map(({ host, upstream, rewrite }) => `
|
|
1167
|
-
https://${host} {
|
|
1168
|
-
tls internal
|
|
1169
|
-
|
|
1170
|
-
reverse_proxy ${upstream} {${rewrite ? `
|
|
1171
|
-
rewrite ${rewrite}
|
|
1172
|
-
# MinIO routes and signs on the Host header, so forwarding the requested
|
|
1173
|
-
# hostname would make it look for a bucket named after the domain.
|
|
1174
|
-
header_up Host {upstream_hostport}` : `
|
|
1175
|
-
# The application reads whatever address was injected, so it has to see
|
|
1176
|
-
# the one a caller used — that is what makes a redirect, a cookie domain
|
|
1177
|
-
# and a generated link point back here rather than at the upstream.
|
|
1178
|
-
header_up Host {host}
|
|
1179
|
-
header_up X-Forwarded-Proto {scheme}`}
|
|
1180
|
-
}
|
|
1181
|
-
}`);
|
|
1182
|
-
return `${GENERATED}${blocks.join("\n")}\n`;
|
|
1319
|
+
function gkmCommand(args) {
|
|
1320
|
+
return `GKM_BIN="$(node -e 'let d=process.cwd();const{existsSync:e}=require("fs"),{dirname:u,join:j}=require("path");for(;;){const p=j(d,"node_modules/@geekmidas/cli/bin/gkm.mjs");if(e(p)){console.log(p);break}if(u(d)===d){console.error("@geekmidas/cli is not installed where the app can resolve it");process.exit(1)}d=u(d)}')" && node "$GKM_BIN" ${args}`;
|
|
1183
1321
|
}
|
|
1184
|
-
const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
|
|
1185
|
-
#
|
|
1186
|
-
# One host per declared file server, in front of the object store that holds its
|
|
1187
|
-
# objects. This is the shape a file server has deployed — a domain serving a
|
|
1188
|
-
# bucket — which MinIO alone cannot produce: its virtual-host mode reads the
|
|
1189
|
-
# leading label as the bucket name.
|
|
1190
|
-
#
|
|
1191
|
-
# Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
|
|
1192
|
-
# root and point Node at it, so nothing has to be installed to use these URLs
|
|
1193
|
-
# from code; a browser needs the root trusted once.
|
|
1194
|
-
`;
|
|
1195
|
-
//#endregion
|
|
1196
|
-
//#region src/reconcile/emulator.ts
|
|
1197
1322
|
/**
|
|
1198
|
-
* The
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1323
|
+
* The encrypted credentials, from a build secret — never an ARG, which is
|
|
1324
|
+
* recorded in the image history — written where the build embeds them from.
|
|
1325
|
+
* Two lines, ciphertext then IV; absent when the app has no secrets.
|
|
1201
1326
|
*
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1204
|
-
*
|
|
1327
|
+
* A BuildKit secret is not part of the cache key, so `GKM_CIPHERTEXT_HASH`
|
|
1328
|
+
* (a hash of the ciphertext, not a secret) is: credentials encrypted under a
|
|
1329
|
+
* new key rebuild the layer that embeds them.
|
|
1205
1330
|
*/
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1331
|
+
function credentialsStep(appDir, then) {
|
|
1332
|
+
return `ARG GKM_CIPHERTEXT_HASH=""
|
|
1333
|
+
RUN --mount=type=secret,id=gkm_credentials,required=false \\
|
|
1334
|
+
echo "credentials: \${GKM_CIPHERTEXT_HASH:-none}" && \\
|
|
1335
|
+
if [ -s /run/secrets/gkm_credentials ]; then \\
|
|
1336
|
+
mkdir -p ${appDir}/.gkm && \\
|
|
1337
|
+
sed -n 1p /run/secrets/gkm_credentials > ${appDir}/.gkm/credentials.enc && \\
|
|
1338
|
+
sed -n 2p /run/secrets/gkm_credentials > ${appDir}/.gkm/credentials.iv; \\
|
|
1339
|
+
fi && \\
|
|
1340
|
+
${then} && \\
|
|
1341
|
+
rm -f ${appDir}/.gkm/credentials.enc ${appDir}/.gkm/credentials.iv`;
|
|
1342
|
+
}
|
|
1209
1343
|
/**
|
|
1210
|
-
* The
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1344
|
+
* The externals a backend's bundle leaves out (esbuild `--external`), each
|
|
1345
|
+
* installed for Linux at the version the builder resolved, so the runner has
|
|
1346
|
+
* exactly those beside `server.mjs`. Nothing when there are none: the runner
|
|
1347
|
+
* is the one file.
|
|
1213
1348
|
*/
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
return
|
|
1349
|
+
function externalsStages(options, appDir) {
|
|
1350
|
+
const external = options.external ?? [];
|
|
1351
|
+
if (external.length === 0) return {
|
|
1352
|
+
stage: "",
|
|
1353
|
+
copy: ""
|
|
1354
|
+
};
|
|
1355
|
+
return {
|
|
1356
|
+
stage: `
|
|
1357
|
+
# Stage 4: The packages the bundle leaves external, installed for Linux at the
|
|
1358
|
+
# versions the build resolved — and nothing else.
|
|
1359
|
+
FROM builder AS externals-manifest
|
|
1360
|
+
WORKDIR ${appDir}
|
|
1361
|
+
RUN node <<'EOF'
|
|
1362
|
+
const { existsSync, readFileSync, writeFileSync } = require('node:fs');
|
|
1363
|
+
const { dirname, join } = require('node:path');
|
|
1364
|
+
const names = ${JSON.stringify(external)};
|
|
1365
|
+
const dependencies = {};
|
|
1366
|
+
for (const name of names) {
|
|
1367
|
+
let dir = process.cwd();
|
|
1368
|
+
for (;;) {
|
|
1369
|
+
const manifest = join(dir, 'node_modules', name, 'package.json');
|
|
1370
|
+
if (existsSync(manifest)) {
|
|
1371
|
+
dependencies[name] = JSON.parse(readFileSync(manifest, 'utf-8')).version;
|
|
1372
|
+
break;
|
|
1373
|
+
}
|
|
1374
|
+
if (dirname(dir) === dir) {
|
|
1375
|
+
console.error(name + ' is external to the bundle, and is not installed: add it to the app\\'s dependencies.');
|
|
1376
|
+
process.exit(1);
|
|
1377
|
+
}
|
|
1378
|
+
dir = dirname(dir);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
writeFileSync('/tmp/externals.json', JSON.stringify({ private: true, dependencies }));
|
|
1382
|
+
EOF
|
|
1383
|
+
|
|
1384
|
+
FROM ${options.baseImage} AS externals
|
|
1385
|
+
WORKDIR /externals
|
|
1386
|
+
COPY --from=externals-manifest /tmp/externals.json ./package.json
|
|
1387
|
+
RUN --mount=type=cache,id=npm,target=/root/.npm \\
|
|
1388
|
+
npm install --omit=dev --no-package-lock --no-audit --no-fund
|
|
1389
|
+
`,
|
|
1390
|
+
copy: "\nCOPY --from=externals --chown=hono:nodejs /externals/node_modules ./node_modules"
|
|
1391
|
+
};
|
|
1221
1392
|
}
|
|
1222
|
-
|
|
1393
|
+
/**
|
|
1394
|
+
* Generate .dockerignore file
|
|
1395
|
+
*
|
|
1396
|
+
* Written at the build root. Nothing built on the host may reach an image —
|
|
1397
|
+
* every image builds its packages from source — and nothing secret may reach
|
|
1398
|
+
* a build context.
|
|
1399
|
+
*/
|
|
1400
|
+
function generateDockerignore() {
|
|
1401
|
+
return `# Dependencies: installed in the image, for Linux
|
|
1402
|
+
**/node_modules
|
|
1403
|
+
.pnpm-store
|
|
1404
|
+
|
|
1405
|
+
# Build output: every image builds from source, never from the host's
|
|
1406
|
+
**/dist
|
|
1407
|
+
**/.next
|
|
1408
|
+
**/.turbo
|
|
1409
|
+
**/.gkm
|
|
1410
|
+
|
|
1411
|
+
# The key gkm build --stage encrypted secrets with: runtime env only, never
|
|
1412
|
+
# the build context
|
|
1413
|
+
**/master.key
|
|
1414
|
+
|
|
1415
|
+
# gkm compose's stacks: each app's env file holds its stage's secrets
|
|
1416
|
+
**/.gkm/compose
|
|
1417
|
+
|
|
1418
|
+
# IDE and editor
|
|
1419
|
+
.idea
|
|
1420
|
+
.vscode
|
|
1421
|
+
*.swp
|
|
1422
|
+
*.swo
|
|
1423
|
+
|
|
1424
|
+
# Git
|
|
1425
|
+
.git
|
|
1426
|
+
|
|
1427
|
+
# Logs
|
|
1428
|
+
*.log
|
|
1429
|
+
npm-debug.log*
|
|
1430
|
+
pnpm-debug.log*
|
|
1431
|
+
|
|
1432
|
+
# Test files
|
|
1433
|
+
coverage
|
|
1434
|
+
|
|
1435
|
+
# Environment files (handle secrets separately)
|
|
1436
|
+
**/.env
|
|
1437
|
+
**/.env.*
|
|
1438
|
+
!**/.env.example
|
|
1439
|
+
|
|
1440
|
+
# Docker files (don't copy recursively)
|
|
1441
|
+
Dockerfile*
|
|
1442
|
+
docker-compose*
|
|
1443
|
+
.dockerignore
|
|
1444
|
+
`;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Resolve Docker configuration from GkmConfig with defaults
|
|
1448
|
+
*/
|
|
1449
|
+
function resolveDockerConfig(config) {
|
|
1450
|
+
const docker = config.docker ?? {};
|
|
1451
|
+
return {
|
|
1452
|
+
registry: docker.registry ?? "",
|
|
1453
|
+
imageName: docker.imageName ?? config.name ?? "api",
|
|
1454
|
+
baseImage: docker.baseImage ?? "node:22-alpine",
|
|
1455
|
+
port: docker.port ?? 3e3
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
/** A site's root answers its health check. */
|
|
1459
|
+
function siteHealthcheck(port) {
|
|
1460
|
+
return `# A site answers at its root; Docker, and Dokploy, read the container's
|
|
1461
|
+
# health from it rather than from the process merely running. 127.0.0.1, not
|
|
1462
|
+
# localhost: the server listens on IPv4, and localhost can resolve to ::1.
|
|
1463
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
1464
|
+
CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1`;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Generate a Dockerfile for Next.js frontend apps using standalone output.
|
|
1468
|
+
*
|
|
1469
|
+
* Pruned at the build root, Next traces from there, so the standalone
|
|
1470
|
+
* server lands at `<appPath>/server.js` under `.next/standalone`.
|
|
1471
|
+
* @internal Exported for testing
|
|
1472
|
+
*/
|
|
1473
|
+
function generateNextjsDockerfile(options) {
|
|
1474
|
+
const { port, appPath, turboPackage, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
|
|
1475
|
+
const { turbo } = layout(options);
|
|
1476
|
+
return `# syntax=docker/dockerfile:1
|
|
1477
|
+
# Next.js standalone Dockerfile, built from a turbo-pruned slice
|
|
1478
|
+
|
|
1479
|
+
${prunerStage(options)}
|
|
1480
|
+
|
|
1481
|
+
${depsStage(options)}
|
|
1482
|
+
|
|
1483
|
+
${builderStage(options, { args: publicUrlArgs })}
|
|
1484
|
+
|
|
1485
|
+
# Ensure public directory exists (may be empty for scaffolded projects)
|
|
1486
|
+
RUN mkdir -p ${appPath}/public
|
|
1487
|
+
|
|
1488
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
1489
|
+
|
|
1490
|
+
# Build the application
|
|
1491
|
+
RUN ${turbo} run build --filter='${turboPackage}'
|
|
1492
|
+
|
|
1493
|
+
# The runner needs the standalone server; without it there is nothing to run.
|
|
1494
|
+
RUN if [ ! -d ${appPath}/.next/standalone ]; then \\
|
|
1495
|
+
echo "${appPath}/.next/standalone was not built: set output: 'standalone' in next.config so the image can run the app without node_modules." >&2; \\
|
|
1496
|
+
exit 1; \\
|
|
1497
|
+
fi
|
|
1498
|
+
|
|
1499
|
+
# Stage 4: Production
|
|
1500
|
+
FROM ${options.baseImage} AS runner
|
|
1501
|
+
|
|
1502
|
+
WORKDIR /app
|
|
1503
|
+
|
|
1504
|
+
# Install tini for proper signal handling
|
|
1505
|
+
RUN apk add --no-cache tini
|
|
1506
|
+
|
|
1507
|
+
# Create non-root user
|
|
1508
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
1509
|
+
adduser --system --uid 1001 nextjs
|
|
1510
|
+
|
|
1511
|
+
# Set environment
|
|
1512
|
+
ENV NODE_ENV=production
|
|
1513
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
1514
|
+
ENV PORT=${port}
|
|
1515
|
+
ENV HOSTNAME="0.0.0.0"
|
|
1516
|
+
|
|
1517
|
+
# The standalone server, with its static files and public directory beside it
|
|
1518
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
|
|
1519
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
|
|
1520
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
|
|
1521
|
+
|
|
1522
|
+
USER nextjs
|
|
1523
|
+
|
|
1524
|
+
EXPOSE ${port}
|
|
1525
|
+
|
|
1526
|
+
${siteHealthcheck(port)}
|
|
1527
|
+
|
|
1528
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
1529
|
+
CMD ["node", "${appPath}/server.js"]
|
|
1530
|
+
`;
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Generate a Dockerfile for a gkm backend: pruned, installed, and bundled by
|
|
1534
|
+
* `gkm build --provider server --production` inside the image. The runner is
|
|
1535
|
+
* `server.mjs` alone — plus any package the bundle leaves external.
|
|
1536
|
+
* @internal Exported for testing
|
|
1537
|
+
*/
|
|
1538
|
+
function generateBackendDockerfile(options) {
|
|
1539
|
+
const { port, healthCheckPath = "/health" } = options;
|
|
1540
|
+
const { app } = layout(options);
|
|
1541
|
+
const externals = externalsStages(options, app);
|
|
1542
|
+
return `# syntax=docker/dockerfile:1
|
|
1543
|
+
# Backend Dockerfile: a turbo-pruned slice, bundled by gkm build in the image
|
|
1544
|
+
|
|
1545
|
+
${prunerStage(options)}
|
|
1546
|
+
|
|
1547
|
+
${depsStage(options)}
|
|
1548
|
+
|
|
1549
|
+
${builderStage(options)}
|
|
1550
|
+
|
|
1551
|
+
# Bundle the production server, with the encrypted credentials embedded
|
|
1552
|
+
${credentialsStep(app, `cd ${app} && ${gkmCommand("build --provider server --production")}`)}
|
|
1553
|
+
${externals.stage}
|
|
1554
|
+
# Production
|
|
1555
|
+
FROM ${options.baseImage} AS runner
|
|
1556
|
+
|
|
1557
|
+
WORKDIR /app
|
|
1558
|
+
|
|
1559
|
+
RUN apk add --no-cache tini
|
|
1560
|
+
|
|
1561
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
1562
|
+
adduser --system --uid 1001 hono
|
|
1563
|
+
|
|
1564
|
+
# The bundled server
|
|
1565
|
+
COPY --from=builder --chown=hono:nodejs ${app}/.gkm/server/dist/server.mjs ./${externals.copy}
|
|
1566
|
+
|
|
1567
|
+
ENV NODE_ENV=production
|
|
1568
|
+
ENV PORT=${port}
|
|
1569
|
+
|
|
1570
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
1571
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
1572
|
+
|
|
1573
|
+
USER hono
|
|
1574
|
+
|
|
1575
|
+
EXPOSE ${port}
|
|
1576
|
+
|
|
1577
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
1578
|
+
CMD ["node", "server.mjs"]
|
|
1579
|
+
`;
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Generate a Dockerfile for apps with a custom entry point.
|
|
1583
|
+
* Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
|
|
1584
|
+
* This is used for apps that don't use gkm routes (e.g., Better Auth servers).
|
|
1585
|
+
* @internal Exported for testing
|
|
1586
|
+
*/
|
|
1587
|
+
function generateEntryDockerfile(options) {
|
|
1588
|
+
const { port, entry, healthCheckPath = "/health" } = options;
|
|
1589
|
+
const { app } = layout(options);
|
|
1590
|
+
const esbuild = (defines) => `esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \\
|
|
1591
|
+
--outfile=dist/index.mjs --packages=bundle \\
|
|
1592
|
+
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'${defines}`;
|
|
1593
|
+
return `# syntax=docker/dockerfile:1
|
|
1594
|
+
# Entry-based Dockerfile: a turbo-pruned slice, bundled by esbuild in the image
|
|
1595
|
+
|
|
1596
|
+
${prunerStage(options)}
|
|
1597
|
+
|
|
1598
|
+
${depsStage(options)}
|
|
1599
|
+
|
|
1600
|
+
${builderStage(options)}
|
|
1601
|
+
|
|
1602
|
+
# Bundle the entry point with every dependency (dist/index.mjs), embedding the
|
|
1603
|
+
# encrypted credentials when there are any.
|
|
1604
|
+
ARG GKM_CIPHERTEXT_HASH=""
|
|
1605
|
+
RUN --mount=type=secret,id=gkm_credentials,required=false \\
|
|
1606
|
+
echo "credentials: \${GKM_CIPHERTEXT_HASH:-none}" && \\
|
|
1607
|
+
cd ${app} && export ${binPath(options)} && \\
|
|
1608
|
+
if [ -s /run/secrets/gkm_credentials ]; then \\
|
|
1609
|
+
CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \\
|
|
1610
|
+
IV=$(sed -n 2p /run/secrets/gkm_credentials) && \\
|
|
1611
|
+
${esbuild(` \\
|
|
1612
|
+
--define:__GKM_ENCRYPTED_CREDENTIALS__="\\"$CREDS\\"" \\
|
|
1613
|
+
--define:__GKM_CREDENTIALS_IV__="\\"$IV\\""`)}; \\
|
|
1614
|
+
else \\
|
|
1615
|
+
${esbuild("")}; \\
|
|
1616
|
+
fi
|
|
1617
|
+
|
|
1618
|
+
# Production
|
|
1619
|
+
FROM ${options.baseImage} AS runner
|
|
1620
|
+
|
|
1621
|
+
WORKDIR /app
|
|
1622
|
+
|
|
1623
|
+
RUN apk add --no-cache tini
|
|
1624
|
+
|
|
1625
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
1626
|
+
adduser --system --uid 1001 app
|
|
1627
|
+
|
|
1628
|
+
# Copy bundled output only (no node_modules needed - fully bundled)
|
|
1629
|
+
COPY --from=builder --chown=app:nodejs ${app}/dist/index.mjs ./
|
|
1630
|
+
|
|
1631
|
+
ENV NODE_ENV=production
|
|
1632
|
+
ENV PORT=${port}
|
|
1633
|
+
|
|
1634
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
1635
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
1636
|
+
|
|
1637
|
+
USER app
|
|
1638
|
+
|
|
1639
|
+
EXPOSE ${port}
|
|
1640
|
+
|
|
1641
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
1642
|
+
CMD ["node", "index.mjs"]
|
|
1643
|
+
`;
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
|
|
1647
|
+
* Remix). Builds with turbo, then runs the framework's production start
|
|
1648
|
+
* script.
|
|
1649
|
+
*
|
|
1650
|
+
* Public URL build args are declared so framework bundlers (Vite, Vinxi)
|
|
1651
|
+
* can inline them at build time. Per-framework prefix is the caller's job.
|
|
1652
|
+
* @internal Exported for testing
|
|
1653
|
+
*/
|
|
1654
|
+
function generateNodeWebDockerfile(options) {
|
|
1655
|
+
const { port, appPath, turboPackage } = options;
|
|
1656
|
+
const { turbo } = layout(options);
|
|
1657
|
+
return `# syntax=docker/dockerfile:1
|
|
1658
|
+
# Node SSR web Dockerfile (TanStack Start / Remix), from a turbo-pruned slice
|
|
1659
|
+
|
|
1660
|
+
${prunerStage(options)}
|
|
1661
|
+
|
|
1662
|
+
${depsStage(options)}
|
|
1663
|
+
|
|
1664
|
+
${builderStage(options, { args: options.publicUrlArgs ?? [] })}
|
|
1665
|
+
RUN ${turbo} run build --filter='${turboPackage}'
|
|
1666
|
+
|
|
1667
|
+
FROM ${options.baseImage} AS runner
|
|
1668
|
+
WORKDIR /app
|
|
1669
|
+
RUN apk add --no-cache tini
|
|
1670
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
1671
|
+
adduser --system --uid 1001 app
|
|
1672
|
+
|
|
1673
|
+
# Copy the whole built workspace — frameworks differ on output paths
|
|
1674
|
+
COPY --from=builder --chown=app:nodejs /app/ ./
|
|
1675
|
+
|
|
1676
|
+
ENV NODE_ENV=production
|
|
1677
|
+
ENV PORT=${port}
|
|
1678
|
+
ENV HOSTNAME="0.0.0.0"
|
|
1679
|
+
|
|
1680
|
+
USER app
|
|
1681
|
+
EXPOSE ${port}
|
|
1682
|
+
|
|
1683
|
+
${siteHealthcheck(port)}
|
|
1684
|
+
|
|
1685
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
1686
|
+
# npm ships with node, and runs the start script whichever package manager
|
|
1687
|
+
# installed it: the runner needs no other.
|
|
1688
|
+
CMD ["sh", "-c", "cd ${appPath} && npm start"]
|
|
1689
|
+
`;
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
|
|
1693
|
+
* turbo, then serves it with Caddy: Vite's hashed `/assets/*` cached for a
|
|
1694
|
+
* year, everything else — `index.html`, and every deep link that falls back
|
|
1695
|
+
* to it — revalidated on each request.
|
|
1696
|
+
* @internal Exported for testing
|
|
1697
|
+
*/
|
|
1698
|
+
function generateViteStaticDockerfile(options) {
|
|
1699
|
+
const { port, appPath, turboPackage } = options;
|
|
1700
|
+
const { turbo } = layout(options);
|
|
1701
|
+
return `# syntax=docker/dockerfile:1
|
|
1702
|
+
# Vite SPA Dockerfile — a turbo-pruned slice built to static files, served by Caddy
|
|
1703
|
+
|
|
1704
|
+
${prunerStage(options)}
|
|
1705
|
+
|
|
1706
|
+
${depsStage(options)}
|
|
1707
|
+
|
|
1708
|
+
${builderStage(options, { args: options.publicUrlArgs ?? [] })}
|
|
1709
|
+
RUN ${turbo} run build --filter='${turboPackage}'
|
|
1710
|
+
|
|
1711
|
+
FROM ${STATIC_SITE_IMAGE} AS runner
|
|
1712
|
+
|
|
1713
|
+
COPY <<'EOF' /etc/caddy/Caddyfile
|
|
1714
|
+
{
|
|
1715
|
+
admin off
|
|
1716
|
+
auto_https off
|
|
1717
|
+
persist_config off
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
:${port} {
|
|
1721
|
+
root * /srv
|
|
1722
|
+
encode zstd gzip
|
|
1723
|
+
|
|
1724
|
+
# Vite names every built asset by its content hash: a changed file is a
|
|
1725
|
+
# new URL, so the old one can be cached for good.
|
|
1726
|
+
handle /assets/* {
|
|
1727
|
+
header Cache-Control "public, max-age=31536000, immutable"
|
|
1728
|
+
file_server
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
# index.html, and every client-side route that falls back to it, is
|
|
1732
|
+
# asked for again each time so a deploy is seen at once.
|
|
1733
|
+
handle {
|
|
1734
|
+
header Cache-Control "no-cache"
|
|
1735
|
+
try_files {path} /index.html
|
|
1736
|
+
file_server
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
EOF
|
|
1740
|
+
|
|
1741
|
+
COPY --from=builder /app/${appPath}/dist /srv
|
|
1742
|
+
|
|
1743
|
+
RUN addgroup -S -g 1001 site && adduser -S -u 1001 -G site site && \\
|
|
1744
|
+
chown -R site:site /data /config
|
|
1745
|
+
|
|
1746
|
+
USER site
|
|
1747
|
+
|
|
1748
|
+
EXPOSE ${port}
|
|
1749
|
+
|
|
1750
|
+
${siteHealthcheck(port)}
|
|
1751
|
+
|
|
1752
|
+
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
|
1753
|
+
`;
|
|
1754
|
+
}
|
|
1755
|
+
//#endregion
|
|
1756
|
+
//#region src/docker/layout.ts
|
|
1757
|
+
/**
|
|
1758
|
+
* Where a workspace's images are built from, and what each is built with.
|
|
1759
|
+
*
|
|
1760
|
+
* Every builder of images — `gkm docker`, `gkm compose`, a Dokploy deploy —
|
|
1761
|
+
* builds from the same place with the same templates: the build root (the
|
|
1762
|
+
* package manager's root, at or above the gkm workspace), cut by `turbo
|
|
1763
|
+
* prune` to one app's slice. Nothing is built on the host.
|
|
1764
|
+
*/
|
|
1765
|
+
/** A workspace of packages with no turbo.json, which every image prunes with. */
|
|
1766
|
+
var MonorepoNeedsTurbo = class extends Error {
|
|
1767
|
+
buildRoot;
|
|
1768
|
+
constructor(buildRoot) {
|
|
1769
|
+
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:
|
|
1770
|
+
1. Install turbo: pnpm add -Dw turbo
|
|
1771
|
+
2. Create turbo.json in ${buildRoot}\n 3. Run this command again
|
|
1772
|
+
|
|
1773
|
+
See: https://turbo.build/repo/docs/guides/tools/docker`);
|
|
1774
|
+
this.buildRoot = buildRoot;
|
|
1775
|
+
this.name = "MonorepoNeedsTurbo";
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
/**
|
|
1779
|
+
* Where a workspace's images are built from.
|
|
1780
|
+
*
|
|
1781
|
+
* Refuses a workspace of packages with no turbo.json — every image prunes
|
|
1782
|
+
* with turbo — so only a caller about to generate or build images asks.
|
|
1783
|
+
*/
|
|
1784
|
+
function imageLayout(workspace) {
|
|
1785
|
+
const buildRoot = findBuildRoot(workspace.root);
|
|
1786
|
+
const tools = resolveBuildTools(buildRoot);
|
|
1787
|
+
if (tools.monorepo && !hasTurboConfig(buildRoot)) throw new MonorepoNeedsTurbo(buildRoot);
|
|
1788
|
+
const gkmRoot = toPosix(relative(buildRoot, workspace.root)) || ".";
|
|
1789
|
+
const workspacePackage = gkmRoot !== "." ? packageName(workspace.root) : void 0;
|
|
1790
|
+
return {
|
|
1791
|
+
buildRoot,
|
|
1792
|
+
gkmRoot,
|
|
1793
|
+
tools,
|
|
1794
|
+
...workspacePackage ? { workspacePackage } : {},
|
|
1795
|
+
gkmPaths: ["gkm.config.*", ...constructRoots(workspace)]
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* How a compose file builds an app: the build root as the context, relative
|
|
1800
|
+
* to the directory the compose file is in, and the Dockerfile — written under
|
|
1801
|
+
* the gkm root — relative to that context, which is what compose resolves
|
|
1802
|
+
* `dockerfile` against.
|
|
1803
|
+
*/
|
|
1804
|
+
function composeBuildPaths(options) {
|
|
1805
|
+
const { composeDir, buildRoot, workspaceRoot, dockerfile } = options;
|
|
1806
|
+
return {
|
|
1807
|
+
context: toPosix(relative(composeDir, buildRoot)) || ".",
|
|
1808
|
+
dockerfile: toPosix(join(relative(buildRoot, workspaceRoot), dockerfile))
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
/** A path under the gkm root, as the build root sees it. */
|
|
1812
|
+
function fromBuildRoot(layout, path) {
|
|
1813
|
+
if (layout.gkmRoot === ".") return toPosix(path);
|
|
1814
|
+
const joined = toPosix(join(layout.gkmRoot, path));
|
|
1815
|
+
return joined === "" ? "." : joined;
|
|
1816
|
+
}
|
|
1817
|
+
/** Everything a template needs about one app, relative to the build root. */
|
|
1818
|
+
function appImageOptions(layout, appName, app, workspaceRoot) {
|
|
1819
|
+
const turboPackage = packageName(isAbsolute(app.path) ? app.path : join(workspaceRoot, app.path)) ?? appName;
|
|
1820
|
+
return {
|
|
1821
|
+
imageName: appName,
|
|
1822
|
+
baseImage: "node:22-alpine",
|
|
1823
|
+
port: app.port,
|
|
1824
|
+
appPath: fromBuildRoot(layout, app.path),
|
|
1825
|
+
turboPackage,
|
|
1826
|
+
packageManager: layout.tools.packageManager,
|
|
1827
|
+
...layout.tools.packageManagerVersion ? { packageManagerVersion: layout.tools.packageManagerVersion } : {},
|
|
1828
|
+
turboVersion: layout.tools.turboVersion,
|
|
1829
|
+
monorepo: layout.tools.monorepo,
|
|
1830
|
+
gkmRoot: layout.gkmRoot,
|
|
1831
|
+
...app.type === "backend" ? {
|
|
1832
|
+
gkmPaths: layout.gkmPaths,
|
|
1833
|
+
...layout.workspacePackage && layout.workspacePackage !== turboPackage ? { prunePackages: [layout.workspacePackage] } : {}
|
|
1834
|
+
} : {}
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* The directories the workspace's construct globs start in, relative to its
|
|
1839
|
+
* root: `./constructs/**\/*.ts` reads from `constructs`.
|
|
1840
|
+
*/
|
|
1841
|
+
function constructRoots(workspace) {
|
|
1842
|
+
const roots = /* @__PURE__ */ new Set();
|
|
1843
|
+
for (const glob of allConstructGlobs(workspace)) {
|
|
1844
|
+
const rel = toPosix(relative(workspace.root, glob));
|
|
1845
|
+
if (!rel || rel.startsWith("..")) continue;
|
|
1846
|
+
const first = rel.split("/")[0];
|
|
1847
|
+
if (/[*?{}[\]!]/.test(first)) continue;
|
|
1848
|
+
roots.add(first);
|
|
1849
|
+
}
|
|
1850
|
+
return [...roots].sort();
|
|
1851
|
+
}
|
|
1852
|
+
function packageName(dir) {
|
|
1853
|
+
try {
|
|
1854
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
|
1855
|
+
return typeof pkg.name === "string" ? pkg.name : void 0;
|
|
1856
|
+
} catch {
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
function toPosix(path) {
|
|
1861
|
+
return path.split(sep).join("/");
|
|
1862
|
+
}
|
|
1863
|
+
//#endregion
|
|
1864
|
+
//#region src/reconcile/caddyfile.ts
|
|
1865
|
+
/**
|
|
1866
|
+
* The kinds the edge serves.
|
|
1867
|
+
*
|
|
1868
|
+
* Everything that owns a *public address*. Deployed, each of these has a real
|
|
1869
|
+
* hostname and a certificate, so this is what makes the local shape the same
|
|
1870
|
+
* one rather than a near-enough one — and the application is indifferent
|
|
1871
|
+
* either way, because it reads whichever address was injected and never
|
|
1872
|
+
* composes one.
|
|
1873
|
+
*
|
|
1874
|
+
* Adding a kind here is the whole of adding it to the edge.
|
|
1875
|
+
*/
|
|
1876
|
+
const EDGE_KINDS = {
|
|
1877
|
+
"file-server": true,
|
|
1878
|
+
"rest-api": true,
|
|
1879
|
+
site: true
|
|
1880
|
+
};
|
|
1881
|
+
/**
|
|
1882
|
+
* How the edge reaches the host, from inside its container.
|
|
1883
|
+
*
|
|
1884
|
+
* A surface and a site are served by processes `gkm dev` starts on the host,
|
|
1885
|
+
* not by containers — so the edge has to leave Docker's network to reach them.
|
|
1886
|
+
* Docker Desktop resolves this name already; on Linux the compose definition
|
|
1887
|
+
* maps it to the host gateway.
|
|
1888
|
+
*/
|
|
1889
|
+
const HOST_GATEWAY = "host.docker.internal";
|
|
1890
|
+
/**
|
|
1891
|
+
* The suffix every local host shares.
|
|
1892
|
+
*
|
|
1893
|
+
* `.localhost` rather than a made-up TLD because it resolves to loopback
|
|
1894
|
+
* without touching `/etc/hosts` — macOS and systemd-resolved both answer for
|
|
1895
|
+
* subdomains of it, and Caddy issues internal certificates for it by default.
|
|
1896
|
+
*/
|
|
1897
|
+
const LOCAL_TLD = "localhost";
|
|
1898
|
+
/**
|
|
1899
|
+
* The sites a plan implies, one per file server.
|
|
1900
|
+
*
|
|
1901
|
+
* The leading label is the *server's* stage-scoped name rather than the
|
|
1902
|
+
* bucket's, which is the difference that matters: two servers over one bucket
|
|
1903
|
+
* are a legitimate arrangement — two cache behaviours, one origin — and naming
|
|
1904
|
+
* the host after the bucket would make them collide.
|
|
1905
|
+
*/
|
|
1906
|
+
function sitesFor(plan, project, addresses = {}) {
|
|
1907
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
1908
|
+
const sites = [];
|
|
1909
|
+
for (const resource of plan.resources) {
|
|
1910
|
+
if (!EDGE_KINDS[resource.kind]) continue;
|
|
1911
|
+
const host = hostFor(resource, project);
|
|
1912
|
+
if (resource.kind === "file-server") {
|
|
1913
|
+
const origin = resource.of ? byId.get(resource.of) : void 0;
|
|
1914
|
+
if (!origin) continue;
|
|
1915
|
+
sites.push({
|
|
1916
|
+
host,
|
|
1917
|
+
upstream: "http://minio:9000",
|
|
1918
|
+
rewrite: `/${origin.name}{uri}`
|
|
1919
|
+
});
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
1922
|
+
const address = addresses[resource.id];
|
|
1923
|
+
if (!address) continue;
|
|
1924
|
+
const port = portOf(address);
|
|
1925
|
+
if (!port) continue;
|
|
1926
|
+
sites.push({
|
|
1927
|
+
host,
|
|
1928
|
+
upstream: `http://${HOST_GATEWAY}:${port}`
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
return sites;
|
|
1932
|
+
}
|
|
1933
|
+
/** The port a local address answers on, defaulting by scheme. */
|
|
1934
|
+
function portOf(address) {
|
|
1935
|
+
try {
|
|
1936
|
+
const { port, protocol } = new URL(address);
|
|
1937
|
+
return port || (protocol === "https:" ? "443" : "80");
|
|
1938
|
+
} catch {
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Where one file server answers, including the stage its name carries.
|
|
1944
|
+
*
|
|
1945
|
+
* The project is a label rather than a decoration: it is what keeps two
|
|
1946
|
+
* checkouts from answering on one address. Omitted when there is no project to
|
|
1947
|
+
* name, because an empty label makes `uploads..localhost` — a host nothing
|
|
1948
|
+
* resolves and no certificate can be issued for.
|
|
1949
|
+
*/
|
|
1950
|
+
function hostFor(resource, project) {
|
|
1951
|
+
return [
|
|
1952
|
+
resource.root && project ? stageOf(resource) : labelOf(resource),
|
|
1953
|
+
project,
|
|
1954
|
+
LOCAL_TLD
|
|
1955
|
+
].filter(Boolean).join(".");
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* A resource's own label: its `subdomain` where it declared one — the label a
|
|
1959
|
+
* deploy uses too — with the stage it carries kept, so `gkm test` and `gkm dev`
|
|
1960
|
+
* still answer on different hosts.
|
|
1961
|
+
*/
|
|
1962
|
+
function labelOf(resource) {
|
|
1963
|
+
if (!resource.subdomain) return resource.name;
|
|
1964
|
+
const stage = stageOf(resource);
|
|
1965
|
+
return stage ? `${resource.subdomain}-${stage}` : resource.subdomain;
|
|
1966
|
+
}
|
|
1967
|
+
/** The stage a resource's name carries, or nothing for the local stage's. */
|
|
1968
|
+
function stageOf(resource) {
|
|
1969
|
+
const base = resource.id.toLowerCase();
|
|
1970
|
+
return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* The static root config, which only ever imports.
|
|
1974
|
+
*
|
|
1975
|
+
* Written once and identical for every stage, because the *sites* are what
|
|
1976
|
+
* differ and they live in their own files. That split is not tidiness: one
|
|
1977
|
+
* container serves every stage — the same way one Postgres holds `orders` and
|
|
1978
|
+
* `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
|
|
1979
|
+
* routes `gkm dev` is serving, and each command silently breaking the other.
|
|
1980
|
+
*/
|
|
1981
|
+
function caddyfileRoot() {
|
|
1982
|
+
return `${GENERATED}
|
|
1983
|
+
{
|
|
1984
|
+
# No ACME, no public DNS: the CA is local and so are the names.
|
|
1985
|
+
local_certs
|
|
1986
|
+
auto_https disable_redirects
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
# One file per stage, so two stages share this edge the way they already share
|
|
1990
|
+
# one Postgres and one MinIO.
|
|
1991
|
+
import /etc/caddy/sites/*.caddy
|
|
1992
|
+
`;
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* One stage's sites, as the file imported above.
|
|
1996
|
+
*
|
|
1997
|
+
* `rewrite` rather than a path prefix on the address: the bucket is the
|
|
1998
|
+
* origin's business, and the client asks for the key it stored.
|
|
1999
|
+
*
|
|
2000
|
+
* `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
|
|
2001
|
+
* and signs on the Host header, so forwarding the requested hostname would make
|
|
2002
|
+
* it look for a bucket named after the domain.
|
|
2003
|
+
*/
|
|
2004
|
+
function toCaddyfile(sites) {
|
|
2005
|
+
if (sites.length === 0) return `${GENERATED}
|
|
2006
|
+
# No file server is declared in this stage, so it routes nothing.
|
|
2007
|
+
`;
|
|
2008
|
+
const blocks = sites.map(({ host, upstream, rewrite }) => `
|
|
2009
|
+
https://${host} {
|
|
2010
|
+
tls internal
|
|
2011
|
+
|
|
2012
|
+
reverse_proxy ${upstream} {${rewrite ? `
|
|
2013
|
+
rewrite ${rewrite}
|
|
2014
|
+
# MinIO routes and signs on the Host header, so forwarding the requested
|
|
2015
|
+
# hostname would make it look for a bucket named after the domain.
|
|
2016
|
+
header_up Host {upstream_hostport}` : `
|
|
2017
|
+
# The application reads whatever address was injected, so it has to see
|
|
2018
|
+
# the one a caller used — that is what makes a redirect, a cookie domain
|
|
2019
|
+
# and a generated link point back here rather than at the upstream.
|
|
2020
|
+
header_up Host {host}
|
|
2021
|
+
header_up X-Forwarded-Proto {scheme}`}
|
|
2022
|
+
}
|
|
2023
|
+
}`);
|
|
2024
|
+
return `${GENERATED}${blocks.join("\n")}\n`;
|
|
2025
|
+
}
|
|
2026
|
+
const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
|
|
2027
|
+
#
|
|
2028
|
+
# One host per declared file server, in front of the object store that holds its
|
|
2029
|
+
# objects. This is the shape a file server has deployed — a domain serving a
|
|
2030
|
+
# bucket — which MinIO alone cannot produce: its virtual-host mode reads the
|
|
2031
|
+
# leading label as the bucket name.
|
|
2032
|
+
#
|
|
2033
|
+
# Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
|
|
2034
|
+
# root and point Node at it, so nothing has to be installed to use these URLs
|
|
2035
|
+
# from code; a browser needs the root trusted once.
|
|
2036
|
+
`;
|
|
2037
|
+
//#endregion
|
|
2038
|
+
//#region src/reconcile/emulator.ts
|
|
2039
|
+
/**
|
|
2040
|
+
* The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
|
|
2041
|
+
* locally — the facts the compose file, the env writer and the applier must
|
|
2042
|
+
* agree on.
|
|
2043
|
+
*
|
|
2044
|
+
* The emulator has one account and answers with deterministic ARNs and queue
|
|
2045
|
+
* URLs, which is what lets a connection string be composed before the topic
|
|
2046
|
+
* or queue it names has been created.
|
|
2047
|
+
*/
|
|
2048
|
+
/** The emulator's single account. */
|
|
2049
|
+
const EMULATOR_ACCOUNT = "000000000000";
|
|
2050
|
+
const EMULATOR_REGION = "us-east-1";
|
|
2051
|
+
/**
|
|
2052
|
+
* The emulator's credential. The `LSIA` prefix is what LocalStack required on
|
|
2053
|
+
* issued keys; floci does not care, and keeping it means a project that pins
|
|
2054
|
+
* the old image still works.
|
|
2055
|
+
*/
|
|
2056
|
+
const EMULATOR_CREDENTIALS = {
|
|
2057
|
+
accessKeyId: "LSIAQAAAAAAVNCBMPNSG",
|
|
2058
|
+
secretAccessKey: "geekmidas"
|
|
2059
|
+
};
|
|
2060
|
+
/** The emulator's address from the host, on the port it was published on. */
|
|
2061
|
+
function emulatorEndpoint(port) {
|
|
2062
|
+
return `http://localhost:${port}`;
|
|
2063
|
+
}
|
|
2064
|
+
function emulatorTopicArn(name) {
|
|
1223
2065
|
return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
|
|
1224
2066
|
}
|
|
1225
2067
|
function emulatorQueueUrl(name, port) {
|
|
@@ -2168,19 +3010,24 @@ function appServices(workspace, manifest, containers, runnables = {}, fakes = {}
|
|
|
2168
3010
|
addresses: surfaceAddresses(workspace, manifest)
|
|
2169
3011
|
});
|
|
2170
3012
|
const services = {};
|
|
3013
|
+
const buildRoot = findBuildRoot(workspace.root);
|
|
2171
3014
|
for (const [name, app] of Object.entries(workspace.apps)) {
|
|
2172
3015
|
if (app.type === "mobile" || !app.port) continue;
|
|
2173
3016
|
const key = appKey(name);
|
|
2174
3017
|
const health = app.type === "web" ? "/" : "/health";
|
|
2175
|
-
const
|
|
3018
|
+
const build = composeBuildPaths({
|
|
3019
|
+
composeDir: workspace.root,
|
|
3020
|
+
buildRoot,
|
|
3021
|
+
workspaceRoot: workspace.root,
|
|
3022
|
+
dockerfile: dockerfileOf(name, app.path)
|
|
3023
|
+
});
|
|
2176
3024
|
const ports = [`${app.port}:${app.port}`];
|
|
2177
3025
|
if (app.type === "web") {
|
|
2178
3026
|
const args = sitePublicArgs(manifest, name, browser);
|
|
2179
3027
|
services[key] = {
|
|
2180
3028
|
image: `${key}:\${TAG:-latest}`,
|
|
2181
3029
|
build: {
|
|
2182
|
-
|
|
2183
|
-
dockerfile,
|
|
3030
|
+
...build,
|
|
2184
3031
|
...Object.keys(args).length ? { args } : {}
|
|
2185
3032
|
},
|
|
2186
3033
|
profiles: [APPS_PROFILE],
|
|
@@ -2193,10 +3040,7 @@ function appServices(workspace, manifest, containers, runnables = {}, fakes = {}
|
|
|
2193
3040
|
const own = Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey)));
|
|
2194
3041
|
services[key] = {
|
|
2195
3042
|
image: `${key}:\${TAG:-latest}`,
|
|
2196
|
-
build
|
|
2197
|
-
context: ".",
|
|
2198
|
-
dockerfile
|
|
2199
|
-
},
|
|
3043
|
+
build,
|
|
2200
3044
|
profiles: [APPS_PROFILE],
|
|
2201
3045
|
ports,
|
|
2202
3046
|
environment: {
|
|
@@ -2263,15 +3107,18 @@ function pgClient(port, password = LOCAL_USER$1) {
|
|
|
2263
3107
|
}
|
|
2264
3108
|
} };
|
|
2265
3109
|
}
|
|
2266
|
-
/** A bucket client pointed at
|
|
2267
|
-
function bucketClient(port
|
|
3110
|
+
/** A bucket client pointed at a MinIO on this machine. */
|
|
3111
|
+
function bucketClient(port, credentials = {
|
|
3112
|
+
user: LOCAL_USER$1,
|
|
3113
|
+
password: LOCAL_USER$1
|
|
3114
|
+
}) {
|
|
2268
3115
|
const s3 = new S3Client({
|
|
2269
3116
|
region: "us-east-1",
|
|
2270
3117
|
endpoint: `http://localhost:${port}`,
|
|
2271
3118
|
forcePathStyle: true,
|
|
2272
3119
|
credentials: {
|
|
2273
|
-
accessKeyId:
|
|
2274
|
-
secretAccessKey:
|
|
3120
|
+
accessKeyId: credentials.user,
|
|
3121
|
+
secretAccessKey: credentials.password
|
|
2275
3122
|
}
|
|
2276
3123
|
});
|
|
2277
3124
|
return {
|
|
@@ -3630,1007 +4477,436 @@ async function reconcileWorkspace(workspace, options) {
|
|
|
3630
4477
|
...options.progress ? { progress: options.progress } : {}
|
|
3631
4478
|
});
|
|
3632
4479
|
await savePortState(workspace.root, { ...result.ports });
|
|
3633
|
-
return {
|
|
3634
|
-
...result,
|
|
3635
|
-
manifest
|
|
3636
|
-
};
|
|
3637
|
-
}
|
|
3638
|
-
/**
|
|
3639
|
-
* Where each mobile app's Metro runs: its app's port, which `gkm exec` hands
|
|
3640
|
-
* Expo as `RCT_METRO_PORT`. Kept apart from the surfaces' addresses — a phone
|
|
3641
|
-
* reaches Metro by `exp://`, not through the edge.
|
|
3642
|
-
*/
|
|
3643
|
-
function metroPorts(workspace, manifest) {
|
|
3644
|
-
const ports = {};
|
|
3645
|
-
for (const [id, declaration] of Object.entries(manifest)) {
|
|
3646
|
-
if (declaration.kind !== "mobile-app") continue;
|
|
3647
|
-
const port = workspace.apps[appKey(id)]?.port;
|
|
3648
|
-
if (port) ports[id] = port;
|
|
3649
|
-
}
|
|
3650
|
-
return ports;
|
|
3651
|
-
}
|
|
3652
|
-
/**
|
|
3653
|
-
* Where each declared surface and site answers locally.
|
|
3654
|
-
*
|
|
3655
|
-
* The addresses come from the ports the workspace already assigns; which
|
|
3656
|
-
* construct sits at which one comes from the manifest. That split is the point:
|
|
3657
|
-
* this used to answer "who may call whom" by listing every app the workspace
|
|
3658
|
-
* runs, which is a different question that happened to give the same answer in
|
|
3659
|
-
* a single-repo workspace and no answer at all deployed. Now it answers only
|
|
3660
|
-
* "where does this construct answer", and the graph answers the rest.
|
|
3661
|
-
*
|
|
3662
|
-
* A site is matched to its app by `path`, because that is the one thing a site
|
|
3663
|
-
* declaration and a workspace app both name. Surfaces are matched to the
|
|
3664
|
-
* backend app: every `rest-api` in a process answers on that process's port, so
|
|
3665
|
-
* an app serving both its own API and an auth server publishes one address
|
|
3666
|
-
* twice — which is exactly what it does at runtime.
|
|
3667
|
-
*/
|
|
3668
|
-
function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
|
|
3669
|
-
const addresses = {};
|
|
3670
|
-
for (const [id, declaration] of Object.entries(manifest)) {
|
|
3671
|
-
if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
|
|
3672
|
-
const host = appKey(id);
|
|
3673
|
-
const port = workspace.apps[host]?.port;
|
|
3674
|
-
if (port) addresses[id] = at(host, port);
|
|
3675
|
-
}
|
|
3676
|
-
return addresses;
|
|
3677
|
-
}
|
|
3678
|
-
/** Where a local process answers, given the port the workspace gave it. */
|
|
3679
|
-
function localAddress(port) {
|
|
3680
|
-
return `http://localhost:${port}`;
|
|
3681
|
-
}
|
|
3682
|
-
//#endregion
|
|
3683
|
-
//#region src/target/dokploy/domain.ts
|
|
3684
|
-
/** A stage was deployed that `deploy.domains` gives no domain. */
|
|
3685
|
-
var NoDomainForStage = class extends Error {
|
|
3686
|
-
stage;
|
|
3687
|
-
constructor(stage) {
|
|
3688
|
-
super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
|
|
3689
|
-
this.stage = stage;
|
|
3690
|
-
this.name = "NoDomainForStage";
|
|
3691
|
-
}
|
|
3692
|
-
};
|
|
3693
|
-
/**
|
|
3694
|
-
* The hostname an app answers on for a stage.
|
|
3695
|
-
*
|
|
3696
|
-
* 1. An explicit `app.domain` — a string, or one per stage — wins.
|
|
3697
|
-
* 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
|
|
3698
|
-
* answers on the domain itself, and every other app on
|
|
3699
|
-
* `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
|
|
3700
|
-
*
|
|
3701
|
-
* @throws {NoDomainForStage} when `deploy.domains` names no domain for it
|
|
3702
|
-
*/
|
|
3703
|
-
function resolveHost(appName, app, stage, domains, isMainFrontend) {
|
|
3704
|
-
if (app.domain) {
|
|
3705
|
-
if (typeof app.domain === "string") return app.domain;
|
|
3706
|
-
if (app.domain[stage]) return app.domain[stage];
|
|
3707
|
-
}
|
|
3708
|
-
const baseDomain = domains?.[stage];
|
|
3709
|
-
if (!baseDomain) throw new NoDomainForStage(stage);
|
|
3710
|
-
if (isMainFrontend) return baseDomain;
|
|
3711
|
-
return `${app.subdomain ?? appName}.${baseDomain}`;
|
|
3712
|
-
}
|
|
3713
|
-
/**
|
|
3714
|
-
* Whether this app is the site the base domain points at — see `rootSite`,
|
|
3715
|
-
* which the local edge asks too.
|
|
3716
|
-
*
|
|
3717
|
-
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
3718
|
-
*/
|
|
3719
|
-
function isMainFrontendApp(appName, app, allApps) {
|
|
3720
|
-
if (app.type !== "web") return false;
|
|
3721
|
-
return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
|
|
3722
|
-
name,
|
|
3723
|
-
root: a.root
|
|
3724
|
-
}))) === appName;
|
|
3725
|
-
}
|
|
3726
|
-
/**
|
|
3727
|
-
* Get public URL arg names from app dependencies.
|
|
3728
|
-
*
|
|
3729
|
-
* @param app - The web/mobile app configuration
|
|
3730
|
-
* @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
|
|
3731
|
-
* without a public prefix (e.g. Remix).
|
|
3732
|
-
*/
|
|
3733
|
-
function getPublicUrlArgNames(app) {
|
|
3734
|
-
const prefix = getPublicEnvPrefix(app.framework);
|
|
3735
|
-
if (!prefix) return [];
|
|
3736
|
-
return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
|
|
3737
|
-
}
|
|
3738
|
-
//#endregion
|
|
3739
|
-
//#region src/docker/imageRef.ts
|
|
3740
|
-
/**
|
|
3741
|
-
* An image reference, checked before it reaches `docker`.
|
|
3742
|
-
*
|
|
3743
|
-
* The parts come from the workspace — a registry in `gkm.config.ts`, an image
|
|
3744
|
-
* name from the project, a tag from `--tag` — and an argument array already
|
|
3745
|
-
* keeps them out of a shell. What it cannot do is stop `docker` reading a value
|
|
3746
|
-
* that starts with `-` as one of its own flags, so a ref is held to Docker's
|
|
3747
|
-
* own grammar (distribution/reference) and anything else is refused by name
|
|
3748
|
-
* before a build starts.
|
|
3749
|
-
*/
|
|
3750
|
-
const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
|
|
3751
|
-
const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
|
|
3752
|
-
const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
|
|
3753
|
-
const REFERENCE = new RegExp(`^(?:${DOMAIN}/)?${PATH_COMPONENT}(?:/${PATH_COMPONENT})*(?::[\\w][\\w.-]{0,127})?(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,})?$`);
|
|
3754
|
-
/** Docker refuses a repository name longer than this. */
|
|
3755
|
-
const MAX_NAME_LENGTH = 255;
|
|
3756
|
-
/** A value that is not an image reference was about to be built or pushed. */
|
|
3757
|
-
var ImageRefInvalid = class extends Error {
|
|
3758
|
-
ref;
|
|
3759
|
-
constructor(ref) {
|
|
3760
|
-
super(`'${ref}' is not a valid image reference. An image is \`[registry/]name[:tag]\`: the name lowercase letters, digits and . _ - separators, the tag up to 128 of letters, digits, _ . and -, not starting with . or -. Check docker.registry in gkm.config.ts and the --tag passed.`);
|
|
3761
|
-
this.ref = ref;
|
|
3762
|
-
this.name = "ImageRefInvalid";
|
|
3763
|
-
}
|
|
3764
|
-
};
|
|
3765
|
-
/** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
|
|
3766
|
-
function validateImageRef(ref) {
|
|
3767
|
-
const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
3768
|
-
if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
|
|
3769
|
-
return ref;
|
|
3770
|
-
}
|
|
3771
|
-
//#endregion
|
|
3772
|
-
//#region src/docker/templates.ts
|
|
3773
|
-
const LOCKFILES = [
|
|
3774
|
-
["pnpm-lock.yaml", "pnpm"],
|
|
3775
|
-
["bun.lockb", "bun"],
|
|
3776
|
-
["yarn.lock", "yarn"],
|
|
3777
|
-
["package-lock.json", "npm"]
|
|
3778
|
-
];
|
|
3779
|
-
/**
|
|
3780
|
-
* Detect package manager from lockfiles
|
|
3781
|
-
* Walks up the directory tree to find lockfile (for monorepos)
|
|
3782
|
-
*/
|
|
3783
|
-
function detectPackageManager(cwd = process.cwd()) {
|
|
3784
|
-
let dir = cwd;
|
|
3785
|
-
const root = parse(dir).root;
|
|
3786
|
-
while (dir !== root) {
|
|
3787
|
-
for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(dir, lockfile))) return pm;
|
|
3788
|
-
dir = dirname(dir);
|
|
3789
|
-
}
|
|
3790
|
-
for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(root, lockfile))) return pm;
|
|
3791
|
-
return "pnpm";
|
|
4480
|
+
return {
|
|
4481
|
+
...result,
|
|
4482
|
+
manifest
|
|
4483
|
+
};
|
|
3792
4484
|
}
|
|
3793
4485
|
/**
|
|
3794
|
-
*
|
|
3795
|
-
*
|
|
4486
|
+
* Where each mobile app's Metro runs: its app's port, which `gkm exec` hands
|
|
4487
|
+
* Expo as `RCT_METRO_PORT`. Kept apart from the surfaces' addresses — a phone
|
|
4488
|
+
* reaches Metro by `exp://`, not through the edge.
|
|
3796
4489
|
*/
|
|
3797
|
-
function
|
|
3798
|
-
|
|
3799
|
-
const
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
if (existsSync(lockfilePath)) return lockfilePath;
|
|
3804
|
-
}
|
|
3805
|
-
dir = dirname(dir);
|
|
3806
|
-
}
|
|
3807
|
-
for (const [lockfile] of LOCKFILES) {
|
|
3808
|
-
const lockfilePath = join(root, lockfile);
|
|
3809
|
-
if (existsSync(lockfilePath)) return lockfilePath;
|
|
4490
|
+
function metroPorts(workspace, manifest) {
|
|
4491
|
+
const ports = {};
|
|
4492
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
4493
|
+
if (declaration.kind !== "mobile-app") continue;
|
|
4494
|
+
const port = workspace.apps[appKey(id)]?.port;
|
|
4495
|
+
if (port) ports[id] = port;
|
|
3810
4496
|
}
|
|
3811
|
-
return
|
|
3812
|
-
}
|
|
3813
|
-
/**
|
|
3814
|
-
* Check if we're in a monorepo (lockfile is in a parent directory)
|
|
3815
|
-
*/
|
|
3816
|
-
function isMonorepo(cwd = process.cwd()) {
|
|
3817
|
-
const lockfilePath = findLockfilePath(cwd);
|
|
3818
|
-
if (!lockfilePath) return false;
|
|
3819
|
-
return dirname(lockfilePath) !== cwd;
|
|
4497
|
+
return ports;
|
|
3820
4498
|
}
|
|
3821
4499
|
/**
|
|
3822
|
-
*
|
|
4500
|
+
* Where each declared surface and site answers locally.
|
|
4501
|
+
*
|
|
4502
|
+
* The addresses come from the ports the workspace already assigns; which
|
|
4503
|
+
* construct sits at which one comes from the manifest. That split is the point:
|
|
4504
|
+
* this used to answer "who may call whom" by listing every app the workspace
|
|
4505
|
+
* runs, which is a different question that happened to give the same answer in
|
|
4506
|
+
* a single-repo workspace and no answer at all deployed. Now it answers only
|
|
4507
|
+
* "where does this construct answer", and the graph answers the rest.
|
|
4508
|
+
*
|
|
4509
|
+
* A site is matched to its app by `path`, because that is the one thing a site
|
|
4510
|
+
* declaration and a workspace app both name. Surfaces are matched to the
|
|
4511
|
+
* backend app: every `rest-api` in a process answers on that process's port, so
|
|
4512
|
+
* an app serving both its own API and an auth server publishes one address
|
|
4513
|
+
* twice — which is exactly what it does at runtime.
|
|
3823
4514
|
*/
|
|
3824
|
-
function
|
|
3825
|
-
|
|
3826
|
-
const
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
4515
|
+
function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
|
|
4516
|
+
const addresses = {};
|
|
4517
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
4518
|
+
if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
|
|
4519
|
+
const host = appKey(id);
|
|
4520
|
+
const port = workspace.apps[host]?.port;
|
|
4521
|
+
if (port) addresses[id] = at(host, port);
|
|
3830
4522
|
}
|
|
3831
|
-
return
|
|
4523
|
+
return addresses;
|
|
3832
4524
|
}
|
|
3833
|
-
/**
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
*/
|
|
3837
|
-
function getTurboInstallCmd(pm) {
|
|
3838
|
-
return {
|
|
3839
|
-
pnpm: "pnpm install",
|
|
3840
|
-
npm: "npm install",
|
|
3841
|
-
yarn: "yarn install",
|
|
3842
|
-
bun: "bun install"
|
|
3843
|
-
}[pm];
|
|
4525
|
+
/** Where a local process answers, given the port the workspace gave it. */
|
|
4526
|
+
function localAddress(port) {
|
|
4527
|
+
return `http://localhost:${port}`;
|
|
3844
4528
|
}
|
|
4529
|
+
//#endregion
|
|
4530
|
+
//#region src/secrets/encryption.ts
|
|
4531
|
+
var encryption_exports = /* @__PURE__ */ __exportAll({
|
|
4532
|
+
encryptSecrets: () => encryptSecrets,
|
|
4533
|
+
generateDefineOptions: () => generateDefineOptions,
|
|
4534
|
+
keyFingerprint: () => keyFingerprint
|
|
4535
|
+
});
|
|
4536
|
+
/** AES-256-GCM configuration */
|
|
4537
|
+
const ALGORITHM = "aes-256-gcm";
|
|
4538
|
+
const KEY_LENGTH = 32;
|
|
4539
|
+
const IV_LENGTH = 12;
|
|
3845
4540
|
/**
|
|
3846
|
-
*
|
|
4541
|
+
* Encrypt secrets for embedding in a bundle.
|
|
4542
|
+
* Uses AES-256-GCM with a randomly generated ephemeral key.
|
|
4543
|
+
*
|
|
4544
|
+
* @param secrets - Key-value pairs to encrypt
|
|
4545
|
+
* @returns Encrypted payload with ephemeral master key
|
|
3847
4546
|
*/
|
|
3848
|
-
function
|
|
4547
|
+
function encryptSecrets(secrets) {
|
|
4548
|
+
const masterKey = randomBytes(KEY_LENGTH);
|
|
4549
|
+
const iv = randomBytes(IV_LENGTH);
|
|
4550
|
+
const plaintext = JSON.stringify(secrets);
|
|
4551
|
+
const cipher = createCipheriv(ALGORITHM, masterKey, iv);
|
|
4552
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
|
|
4553
|
+
const authTag = cipher.getAuthTag();
|
|
3849
4554
|
return {
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
installCmd: "pnpm install --frozen-lockfile --offline",
|
|
3855
|
-
cacheTarget: "/root/.local/share/pnpm/store",
|
|
3856
|
-
cacheId: "pnpm",
|
|
3857
|
-
run: "pnpm",
|
|
3858
|
-
exec: "pnpm exec",
|
|
3859
|
-
dlx: "pnpm dlx",
|
|
3860
|
-
addGlobal: "pnpm add -g"
|
|
3861
|
-
},
|
|
3862
|
-
npm: {
|
|
3863
|
-
install: "",
|
|
3864
|
-
lockfile: "package-lock.json",
|
|
3865
|
-
fetch: "",
|
|
3866
|
-
installCmd: "npm ci",
|
|
3867
|
-
cacheTarget: "/root/.npm",
|
|
3868
|
-
cacheId: "npm",
|
|
3869
|
-
run: "npm run",
|
|
3870
|
-
exec: "npx",
|
|
3871
|
-
dlx: "npx",
|
|
3872
|
-
addGlobal: "npm install -g"
|
|
3873
|
-
},
|
|
3874
|
-
yarn: {
|
|
3875
|
-
install: "corepack enable && corepack prepare yarn@stable --activate",
|
|
3876
|
-
lockfile: "yarn.lock",
|
|
3877
|
-
fetch: "",
|
|
3878
|
-
installCmd: "yarn install --frozen-lockfile",
|
|
3879
|
-
cacheTarget: "/root/.yarn/cache",
|
|
3880
|
-
cacheId: "yarn",
|
|
3881
|
-
run: "yarn",
|
|
3882
|
-
exec: "yarn exec",
|
|
3883
|
-
dlx: "yarn dlx",
|
|
3884
|
-
addGlobal: "yarn global add"
|
|
3885
|
-
},
|
|
3886
|
-
bun: {
|
|
3887
|
-
install: "npm install -g bun",
|
|
3888
|
-
lockfile: "bun.lockb",
|
|
3889
|
-
fetch: "",
|
|
3890
|
-
installCmd: "bun install --frozen-lockfile",
|
|
3891
|
-
cacheTarget: "/root/.bun/install/cache",
|
|
3892
|
-
cacheId: "bun",
|
|
3893
|
-
run: "bun run",
|
|
3894
|
-
exec: "bunx",
|
|
3895
|
-
dlx: "bunx",
|
|
3896
|
-
addGlobal: "bun add -g"
|
|
3897
|
-
}
|
|
3898
|
-
}[pm];
|
|
3899
|
-
}
|
|
3900
|
-
/**
|
|
3901
|
-
* Generate a multi-stage Dockerfile for building from source
|
|
3902
|
-
* Optimized for build speed with:
|
|
3903
|
-
* - BuildKit cache mounts for package manager store
|
|
3904
|
-
* - pnpm fetch for better layer caching (when using pnpm)
|
|
3905
|
-
* - Optional turbo prune for monorepos
|
|
3906
|
-
*/
|
|
3907
|
-
function generateMultiStageDockerfile(options) {
|
|
3908
|
-
const { baseImage, port, healthCheckPath, turbo, turboPackage, packageManager } = options;
|
|
3909
|
-
if (turbo) return generateTurboDockerfile({
|
|
3910
|
-
...options,
|
|
3911
|
-
turboPackage: turboPackage ?? "api"
|
|
3912
|
-
});
|
|
3913
|
-
const pm = getPmConfig(packageManager);
|
|
3914
|
-
return `# syntax=docker/dockerfile:1
|
|
3915
|
-
# Stage 1: Dependencies
|
|
3916
|
-
FROM ${baseImage} AS deps
|
|
3917
|
-
|
|
3918
|
-
WORKDIR /app
|
|
3919
|
-
${pm.install ? `\n# Install ${packageManager}\nRUN ${pm.install}\n` : ""}
|
|
3920
|
-
${packageManager === "pnpm" ? `# Copy lockfile first for better caching
|
|
3921
|
-
COPY ${pm.lockfile} ./
|
|
3922
|
-
|
|
3923
|
-
# Fetch dependencies (downloads to virtual store, cached separately)
|
|
3924
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3925
|
-
${pm.fetch}
|
|
3926
|
-
|
|
3927
|
-
# Copy package.json after fetch
|
|
3928
|
-
COPY package.json ./
|
|
3929
|
-
|
|
3930
|
-
# Install from cache (fast - no network needed)
|
|
3931
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3932
|
-
${pm.installCmd}` : `# Copy package files
|
|
3933
|
-
COPY package.json ${pm.lockfile} ./
|
|
3934
|
-
|
|
3935
|
-
# Install dependencies with cache
|
|
3936
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3937
|
-
${pm.installCmd}`}
|
|
3938
|
-
|
|
3939
|
-
# Stage 2: Build
|
|
3940
|
-
FROM deps AS builder
|
|
3941
|
-
|
|
3942
|
-
WORKDIR /app
|
|
3943
|
-
|
|
3944
|
-
# Copy source (deps already installed)
|
|
3945
|
-
COPY . .
|
|
3946
|
-
|
|
3947
|
-
# Build any workspace packages the app depends on.
|
|
3948
|
-
#
|
|
3949
|
-
# A no-op for a project whose dependencies come from a registry, and required
|
|
3950
|
-
# for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
|
|
3951
|
-
# workspace package's \`dist\` never reaches the image and its bin has nothing to
|
|
3952
|
-
# import. \`--if-present\` is what makes the same Dockerfile correct for both.
|
|
3953
|
-
RUN ${pm.run} --if-present build
|
|
3954
|
-
|
|
3955
|
-
# Build production server using gkm
|
|
3956
|
-
RUN ${pm.exec} gkm build --provider server --production
|
|
3957
|
-
|
|
3958
|
-
# Stage 3: Production
|
|
3959
|
-
FROM ${baseImage} AS runner
|
|
3960
|
-
|
|
3961
|
-
WORKDIR /app
|
|
3962
|
-
|
|
3963
|
-
# Install tini for proper signal handling as PID 1
|
|
3964
|
-
RUN apk add --no-cache tini
|
|
3965
|
-
|
|
3966
|
-
# Create non-root user
|
|
3967
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3968
|
-
adduser --system --uid 1001 hono
|
|
3969
|
-
|
|
3970
|
-
# Copy bundled server
|
|
3971
|
-
COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
|
|
3972
|
-
|
|
3973
|
-
# Environment
|
|
3974
|
-
ENV NODE_ENV=production
|
|
3975
|
-
ENV PORT=${port}
|
|
3976
|
-
|
|
3977
|
-
# Health check
|
|
3978
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
3979
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
3980
|
-
|
|
3981
|
-
# Switch to non-root user
|
|
3982
|
-
USER hono
|
|
3983
|
-
|
|
3984
|
-
EXPOSE ${port}
|
|
3985
|
-
|
|
3986
|
-
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
3987
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
3988
|
-
CMD ["node", "server.mjs"]
|
|
3989
|
-
`;
|
|
3990
|
-
}
|
|
3991
|
-
/**
|
|
3992
|
-
* Generate a Dockerfile optimized for Turbo monorepos
|
|
3993
|
-
* Uses turbo prune to create minimal Docker context
|
|
3994
|
-
*/
|
|
3995
|
-
function generateTurboDockerfile(options) {
|
|
3996
|
-
const { baseImage, port, healthCheckPath, turboPackage, packageManager } = options;
|
|
3997
|
-
const pm = getPmConfig(packageManager);
|
|
3998
|
-
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
3999
|
-
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4000
|
-
return `# syntax=docker/dockerfile:1
|
|
4001
|
-
# Stage 1: Prune monorepo
|
|
4002
|
-
FROM ${baseImage} AS pruner
|
|
4003
|
-
|
|
4004
|
-
WORKDIR /app
|
|
4005
|
-
|
|
4006
|
-
${installPm}
|
|
4007
|
-
|
|
4008
|
-
COPY . .
|
|
4009
|
-
|
|
4010
|
-
# Prune to only include necessary packages
|
|
4011
|
-
RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
|
|
4012
|
-
|
|
4013
|
-
# Stage 2: Install dependencies
|
|
4014
|
-
FROM ${baseImage} AS deps
|
|
4015
|
-
|
|
4016
|
-
WORKDIR /app
|
|
4017
|
-
|
|
4018
|
-
${installPm}
|
|
4019
|
-
|
|
4020
|
-
# Copy pruned lockfile and package.jsons
|
|
4021
|
-
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4022
|
-
COPY --from=pruner /app/out/json/ ./
|
|
4023
|
-
|
|
4024
|
-
# Install dependencies (no frozen-lockfile since turbo prune creates a subset)
|
|
4025
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4026
|
-
${turboInstallCmd}
|
|
4027
|
-
|
|
4028
|
-
# Stage 3: Build
|
|
4029
|
-
FROM deps AS builder
|
|
4030
|
-
|
|
4031
|
-
WORKDIR /app
|
|
4032
|
-
|
|
4033
|
-
# Copy pruned source
|
|
4034
|
-
COPY --from=pruner /app/out/full/ ./
|
|
4035
|
-
|
|
4036
|
-
# Build any workspace packages the app depends on.
|
|
4037
|
-
#
|
|
4038
|
-
# A no-op for a project whose dependencies come from a registry, and required
|
|
4039
|
-
# for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
|
|
4040
|
-
# workspace package's \`dist\` never reaches the image and its bin has nothing to
|
|
4041
|
-
# import. \`--if-present\` is what makes the same Dockerfile correct for both.
|
|
4042
|
-
RUN ${pm.run} --if-present build
|
|
4043
|
-
|
|
4044
|
-
# Build production server using gkm
|
|
4045
|
-
RUN ${pm.exec} gkm build --provider server --production
|
|
4046
|
-
|
|
4047
|
-
# Stage 4: Production
|
|
4048
|
-
FROM ${baseImage} AS runner
|
|
4049
|
-
|
|
4050
|
-
WORKDIR /app
|
|
4051
|
-
|
|
4052
|
-
RUN apk add --no-cache tini
|
|
4053
|
-
|
|
4054
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4055
|
-
adduser --system --uid 1001 hono
|
|
4056
|
-
|
|
4057
|
-
COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
|
|
4058
|
-
|
|
4059
|
-
ENV NODE_ENV=production
|
|
4060
|
-
ENV PORT=${port}
|
|
4061
|
-
|
|
4062
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4063
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4064
|
-
|
|
4065
|
-
USER hono
|
|
4066
|
-
|
|
4067
|
-
EXPOSE ${port}
|
|
4068
|
-
|
|
4069
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4070
|
-
CMD ["node", "server.mjs"]
|
|
4071
|
-
`;
|
|
4072
|
-
}
|
|
4073
|
-
/**
|
|
4074
|
-
* Generate a slim Dockerfile for pre-built bundles
|
|
4075
|
-
*/
|
|
4076
|
-
function generateSlimDockerfile(options) {
|
|
4077
|
-
const { baseImage, port, healthCheckPath, bundle = ".gkm/server/dist/server.mjs" } = options;
|
|
4078
|
-
return `# Slim Dockerfile for pre-built production bundle
|
|
4079
|
-
FROM ${baseImage}
|
|
4080
|
-
|
|
4081
|
-
WORKDIR /app
|
|
4082
|
-
|
|
4083
|
-
# Install tini for proper signal handling as PID 1
|
|
4084
|
-
# Handles SIGTERM propagation and zombie process reaping
|
|
4085
|
-
RUN apk add --no-cache tini
|
|
4086
|
-
|
|
4087
|
-
# Create non-root user
|
|
4088
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4089
|
-
adduser --system --uid 1001 hono
|
|
4090
|
-
|
|
4091
|
-
# Copy pre-built bundle
|
|
4092
|
-
COPY ${bundle} ./
|
|
4093
|
-
|
|
4094
|
-
# Environment
|
|
4095
|
-
ENV NODE_ENV=production
|
|
4096
|
-
ENV PORT=${port}
|
|
4097
|
-
|
|
4098
|
-
# Health check
|
|
4099
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4100
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4101
|
-
|
|
4102
|
-
# Switch to non-root user
|
|
4103
|
-
USER hono
|
|
4104
|
-
|
|
4105
|
-
EXPOSE ${port}
|
|
4106
|
-
|
|
4107
|
-
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
4108
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4109
|
-
CMD ["node", "server.mjs"]
|
|
4110
|
-
`;
|
|
4555
|
+
encrypted: Buffer.concat([ciphertext, authTag]).toString("base64"),
|
|
4556
|
+
iv: iv.toString("hex"),
|
|
4557
|
+
masterKey: masterKey.toString("hex")
|
|
4558
|
+
};
|
|
4111
4559
|
}
|
|
4112
4560
|
/**
|
|
4113
|
-
* Generate .
|
|
4561
|
+
* Generate the define options for tsdown/esbuild.
|
|
4562
|
+
* These will be injected at build time.
|
|
4114
4563
|
*/
|
|
4115
|
-
function
|
|
4116
|
-
return
|
|
4117
|
-
|
|
4118
|
-
.
|
|
4119
|
-
|
|
4120
|
-
# Build output (except what we need)
|
|
4121
|
-
.gkm/aws*
|
|
4122
|
-
.gkm/server/*.ts
|
|
4123
|
-
!.gkm/server/dist
|
|
4124
|
-
|
|
4125
|
-
# The key gkm build --stage encrypted secrets with: runtime env only, never
|
|
4126
|
-
# the build context
|
|
4127
|
-
.gkm/server/master.key
|
|
4128
|
-
|
|
4129
|
-
# gkm compose's stacks: each app's env file holds its stage's secrets
|
|
4130
|
-
.gkm/compose
|
|
4131
|
-
|
|
4132
|
-
# IDE and editor
|
|
4133
|
-
.idea
|
|
4134
|
-
.vscode
|
|
4135
|
-
*.swp
|
|
4136
|
-
*.swo
|
|
4137
|
-
|
|
4138
|
-
# Git
|
|
4139
|
-
.git
|
|
4140
|
-
.gitignore
|
|
4141
|
-
|
|
4142
|
-
# Logs
|
|
4143
|
-
*.log
|
|
4144
|
-
npm-debug.log*
|
|
4145
|
-
pnpm-debug.log*
|
|
4146
|
-
|
|
4147
|
-
# Test files
|
|
4148
|
-
**/*.test.ts
|
|
4149
|
-
**/*.spec.ts
|
|
4150
|
-
**/__tests__
|
|
4151
|
-
coverage
|
|
4152
|
-
|
|
4153
|
-
# Documentation
|
|
4154
|
-
docs
|
|
4155
|
-
*.md
|
|
4156
|
-
!README.md
|
|
4157
|
-
|
|
4158
|
-
# Environment files (handle secrets separately)
|
|
4159
|
-
.env
|
|
4160
|
-
.env.*
|
|
4161
|
-
!.env.example
|
|
4162
|
-
|
|
4163
|
-
# Docker files (don't copy recursively)
|
|
4164
|
-
Dockerfile*
|
|
4165
|
-
docker-compose*
|
|
4166
|
-
.dockerignore
|
|
4167
|
-
`;
|
|
4564
|
+
function generateDefineOptions(payload) {
|
|
4565
|
+
return {
|
|
4566
|
+
__GKM_ENCRYPTED_CREDENTIALS__: JSON.stringify(payload.encrypted),
|
|
4567
|
+
__GKM_CREDENTIALS_IV__: JSON.stringify(payload.iv)
|
|
4568
|
+
};
|
|
4168
4569
|
}
|
|
4169
4570
|
/**
|
|
4170
|
-
*
|
|
4571
|
+
* A short, one-way name for a master key: the first 8 hex characters of its
|
|
4572
|
+
* SHA-256.
|
|
4573
|
+
*
|
|
4574
|
+
* Printed where the key itself used to be, so a person can tell which key a
|
|
4575
|
+
* build used — and match it against the one they hold — without the output
|
|
4576
|
+
* carrying a value that decrypts every secret of the stage.
|
|
4171
4577
|
*/
|
|
4172
|
-
function
|
|
4173
|
-
return
|
|
4174
|
-
set -e
|
|
4175
|
-
|
|
4176
|
-
# Run any custom startup scripts here
|
|
4177
|
-
# Example: wait for database
|
|
4178
|
-
# until nc -z $DB_HOST $DB_PORT; do
|
|
4179
|
-
# echo "Waiting for database..."
|
|
4180
|
-
# sleep 1
|
|
4181
|
-
# done
|
|
4182
|
-
|
|
4183
|
-
# Execute the main command
|
|
4184
|
-
exec "$@"
|
|
4185
|
-
`;
|
|
4578
|
+
function keyFingerprint(masterKey) {
|
|
4579
|
+
return createHash("sha256").update(masterKey).digest("hex").slice(0, 8);
|
|
4186
4580
|
}
|
|
4581
|
+
//#endregion
|
|
4582
|
+
//#region src/deploy/devServices.ts
|
|
4583
|
+
/**
|
|
4584
|
+
* Mail and object storage on a deployed stage: real ones, or — only when
|
|
4585
|
+
* someone asked — the dev services the local stage runs.
|
|
4586
|
+
*
|
|
4587
|
+
* Locally a declared `Email` is Mailpit and a declared bucket is MinIO, and
|
|
4588
|
+
* nobody has to configure either. Deployed, the same two containers would be
|
|
4589
|
+
* a stage that silently delivers no mail and keeps its files on one
|
|
4590
|
+
* container's disk. So a deployed stage on a server target takes both from
|
|
4591
|
+
* its secrets — the mail server's URL and sending address, and each bucket's
|
|
4592
|
+
* URL — and a stage missing any of them fails before anything is built,
|
|
4593
|
+
* provisioned or written, naming every key at once.
|
|
4594
|
+
*
|
|
4595
|
+
* A bucket's credentials are not checked: they are optional, and come from
|
|
4596
|
+
* either of two places. The bucket's URL may carry a key of its own
|
|
4597
|
+
* (`s3://KEY:SECRET@uploads`), which wins; otherwise the S3 client's default
|
|
4598
|
+
* chain applies — the stage's shared `AWS_ACCESS_KEY_ID`/
|
|
4599
|
+
* `AWS_SECRET_ACCESS_KEY` when it set them, or a role.
|
|
4600
|
+
*
|
|
4601
|
+
* `--allow-dev-services minio,mailpit` is the way out for a stage that is
|
|
4602
|
+
* not production — a preview, a demo: the target runs the dev service and
|
|
4603
|
+
* derives the keys from it. Keys the stage did set still win. Every run that
|
|
4604
|
+
* uses one says so, loudly.
|
|
4605
|
+
*
|
|
4606
|
+
* Pure: what each target runs is decided here from declarations and the
|
|
4607
|
+
* stage's values, and the targets do the running.
|
|
4608
|
+
*/
|
|
4609
|
+
/** The dev services a deployed stage may be allowed to run. */
|
|
4610
|
+
const DEV_SERVICES = ["minio", "mailpit"];
|
|
4611
|
+
/**
|
|
4612
|
+
* The S3 client's shared key pair. Optional: a bucket whose URL carries its own
|
|
4613
|
+
* key (`s3://KEY:SECRET@bucket`) signs with that, and one without signs with
|
|
4614
|
+
* this pair when the stage set it, or with whatever else the SDK's default
|
|
4615
|
+
* chain finds — a role.
|
|
4616
|
+
*/
|
|
4617
|
+
const STORAGE_KEY_PAIR = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
|
|
4618
|
+
/** A `--allow-dev-services` value that is not a dev service. */
|
|
4619
|
+
var UnknownDevService = class extends Error {
|
|
4620
|
+
value;
|
|
4621
|
+
constructor(value) {
|
|
4622
|
+
super(`'${value}' is not a dev service. --allow-dev-services takes a comma-separated list of ${DEV_SERVICES.join(", ")}: --allow-dev-services ${DEV_SERVICES.join(",")}`);
|
|
4623
|
+
this.value = value;
|
|
4624
|
+
this.name = "UnknownDevService";
|
|
4625
|
+
}
|
|
4626
|
+
};
|
|
4627
|
+
/** Dev services asked of a target that runs no containers. */
|
|
4628
|
+
var DevServicesNeedServerTarget = class extends Error {
|
|
4629
|
+
target;
|
|
4630
|
+
services;
|
|
4631
|
+
constructor(target, services) {
|
|
4632
|
+
super(`--allow-dev-services applies to server targets only, and '${target}' deploys to AWS, where a bucket is S3 and mail is the stage's own. Drop --allow-dev-services ${services.join(",")}.`);
|
|
4633
|
+
this.target = target;
|
|
4634
|
+
this.services = services;
|
|
4635
|
+
this.name = "DevServicesNeedServerTarget";
|
|
4636
|
+
}
|
|
4637
|
+
};
|
|
4187
4638
|
/**
|
|
4188
|
-
*
|
|
4639
|
+
* A deployed stage with mail or object storage that its secrets do not
|
|
4640
|
+
* configure — every missing key, across every app, at once.
|
|
4189
4641
|
*/
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4642
|
+
var ExternalServicesNotConfigured = class extends Error {
|
|
4643
|
+
stage;
|
|
4644
|
+
missing;
|
|
4645
|
+
constructor(stage, missing) {
|
|
4646
|
+
const services = [...new Set(missing.map((m) => m.service))].sort();
|
|
4647
|
+
const lines = missing.map((m) => {
|
|
4648
|
+
const by = m.apps?.length ? `, read by ${m.apps.join(", ")}` : "";
|
|
4649
|
+
return ` gkm secrets:set ${m.key} '${m.example}' --stage ${stage}\n ${m.what}${by}` + (m.note ? `\n ${m.note}` : "");
|
|
4650
|
+
});
|
|
4651
|
+
super(`The stage '${stage}' is deployed, and a deployed stage's mail and object storage are real services: gkm runs no Mailpit or MinIO for it. Set ${missing.length === 1 ? "this key" : `these ${missing.length} keys`} in the stage's secrets:\n\n${lines.join("\n")}\n\nFor a stage that is not production — a preview, a demo — the dev services can run instead, with --allow-dev-services ${services.join(",")}. Mailpit delivers no mail, and MinIO keeps every object on one container's disk.`);
|
|
4652
|
+
this.stage = stage;
|
|
4653
|
+
this.missing = missing;
|
|
4654
|
+
this.name = "ExternalServicesNotConfigured";
|
|
4655
|
+
}
|
|
4656
|
+
};
|
|
4657
|
+
/**
|
|
4658
|
+
* The dev services a value names: `'minio,mailpit'`, or a list of them.
|
|
4659
|
+
*
|
|
4660
|
+
* @throws {UnknownDevService} for anything that is not one
|
|
4661
|
+
*/
|
|
4662
|
+
function parseDevServices(value) {
|
|
4663
|
+
if (value === void 0) return [];
|
|
4664
|
+
const items = (typeof value === "string" ? [value] : value).flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
|
|
4665
|
+
const services = /* @__PURE__ */ new Set();
|
|
4666
|
+
for (const item of items) {
|
|
4667
|
+
if (!DEV_SERVICES.includes(item)) throw new UnknownDevService(item);
|
|
4668
|
+
services.add(item);
|
|
4669
|
+
}
|
|
4670
|
+
return [...services].sort();
|
|
4198
4671
|
}
|
|
4199
4672
|
/**
|
|
4200
|
-
*
|
|
4201
|
-
*
|
|
4202
|
-
* @internal Exported for testing
|
|
4673
|
+
* Every mail and storage construct a manifest declares, with the apps that
|
|
4674
|
+
* depend on each — what a target that provisions the whole manifest checks.
|
|
4203
4675
|
*/
|
|
4204
|
-
function
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
${installPm}
|
|
4221
|
-
|
|
4222
|
-
COPY . .
|
|
4223
|
-
|
|
4224
|
-
# Prune to only include necessary packages
|
|
4225
|
-
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
4226
|
-
|
|
4227
|
-
# Stage 2: Install dependencies
|
|
4228
|
-
FROM ${baseImage} AS deps
|
|
4229
|
-
|
|
4230
|
-
WORKDIR /app
|
|
4231
|
-
|
|
4232
|
-
${installPm}
|
|
4233
|
-
|
|
4234
|
-
# Copy pruned lockfile and package.jsons
|
|
4235
|
-
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4236
|
-
COPY --from=pruner /app/out/json/ ./
|
|
4237
|
-
|
|
4238
|
-
# Install dependencies
|
|
4239
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4240
|
-
${turboInstallCmd}
|
|
4241
|
-
|
|
4242
|
-
# Stage 3: Build
|
|
4243
|
-
FROM deps AS builder
|
|
4244
|
-
|
|
4245
|
-
WORKDIR /app
|
|
4246
|
-
|
|
4247
|
-
# Build-time args for public API URLs (populated by gkm deploy)
|
|
4248
|
-
# These get baked into the Next.js build as public environment variables
|
|
4249
|
-
${publicUrlArgDeclarations}
|
|
4250
|
-
|
|
4251
|
-
# Convert ARGs to ENVs for Next.js build
|
|
4252
|
-
${publicUrlEnvDeclarations}
|
|
4253
|
-
|
|
4254
|
-
# Copy pruned source
|
|
4255
|
-
COPY --from=pruner /app/out/full/ ./
|
|
4256
|
-
|
|
4257
|
-
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
4258
|
-
# Using wildcard to make it optional for single-app projects
|
|
4259
|
-
COPY --from=pruner /app/tsconfig.* ./
|
|
4260
|
-
|
|
4261
|
-
# Ensure public directory exists (may be empty for scaffolded projects)
|
|
4262
|
-
RUN mkdir -p ${appPath}/public
|
|
4263
|
-
|
|
4264
|
-
# Set Next.js to produce standalone output
|
|
4265
|
-
ENV NEXT_TELEMETRY_DISABLED=1
|
|
4266
|
-
|
|
4267
|
-
# Build the application
|
|
4268
|
-
RUN ${turboCmd} run build --filter=${turboPackage}
|
|
4269
|
-
|
|
4270
|
-
# Stage 4: Production
|
|
4271
|
-
FROM ${baseImage} AS runner
|
|
4272
|
-
|
|
4273
|
-
WORKDIR /app
|
|
4274
|
-
|
|
4275
|
-
# Install tini for proper signal handling
|
|
4276
|
-
RUN apk add --no-cache tini
|
|
4277
|
-
|
|
4278
|
-
# Create non-root user
|
|
4279
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4280
|
-
adduser --system --uid 1001 nextjs
|
|
4281
|
-
|
|
4282
|
-
# Set environment
|
|
4283
|
-
ENV NODE_ENV=production
|
|
4284
|
-
ENV NEXT_TELEMETRY_DISABLED=1
|
|
4285
|
-
ENV PORT=${port}
|
|
4286
|
-
ENV HOSTNAME="0.0.0.0"
|
|
4287
|
-
|
|
4288
|
-
# Copy static files and standalone output
|
|
4289
|
-
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
|
|
4290
|
-
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
|
|
4291
|
-
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
|
|
4292
|
-
|
|
4293
|
-
USER nextjs
|
|
4294
|
-
|
|
4295
|
-
EXPOSE ${port}
|
|
4296
|
-
|
|
4297
|
-
# A site answers at its root; Docker, and Dokploy, read the container's
|
|
4298
|
-
# health from it rather than from the process merely running. 127.0.0.1, not
|
|
4299
|
-
# localhost: the server listens on IPv4, and localhost can resolve to ::1.
|
|
4300
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4301
|
-
CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
|
|
4302
|
-
|
|
4303
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4304
|
-
CMD ["node", "${appPath}/server.js"]
|
|
4305
|
-
`;
|
|
4676
|
+
function manifestServiceDeclarations(manifest) {
|
|
4677
|
+
return Object.entries(manifest).flatMap(([id, declaration]) => {
|
|
4678
|
+
const { kind } = declaration;
|
|
4679
|
+
if (kind !== "email" && kind !== "objects" && kind !== "file-server") return [];
|
|
4680
|
+
const apps = dependentsOf(manifest, id).filter((caller) => {
|
|
4681
|
+
const callerKind = manifest[caller]?.kind;
|
|
4682
|
+
return callerKind === "rest-api" || callerKind === "site";
|
|
4683
|
+
}).map(appKey);
|
|
4684
|
+
return [{
|
|
4685
|
+
id,
|
|
4686
|
+
kind,
|
|
4687
|
+
...declaration.kind === "file-server" ? { of: declaration.of } : {},
|
|
4688
|
+
...apps.length ? { apps } : {}
|
|
4689
|
+
}];
|
|
4690
|
+
});
|
|
4306
4691
|
}
|
|
4307
4692
|
/**
|
|
4308
|
-
*
|
|
4309
|
-
*
|
|
4310
|
-
*
|
|
4693
|
+
* Where a deployed stage's mail and storage come from, and what it lacks.
|
|
4694
|
+
*
|
|
4695
|
+
* - Mail is external once its URL is set, and then needs its sending
|
|
4696
|
+
* address too. Unset, it is Mailpit where allowed — the address derived
|
|
4697
|
+
* unless set — and missing otherwise.
|
|
4698
|
+
* - A bucket is external once its URL is set; its credentials, in the URL or
|
|
4699
|
+
* the shared pair, are optional. Unset, it is MinIO where allowed, and
|
|
4700
|
+
* missing otherwise.
|
|
4701
|
+
* - A file server's URL is derived where its bucket is MinIO, unless set;
|
|
4702
|
+
* over an external bucket the stage must set it.
|
|
4311
4703
|
*/
|
|
4312
|
-
function
|
|
4313
|
-
const {
|
|
4314
|
-
const
|
|
4315
|
-
const
|
|
4316
|
-
const
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
FROM ${baseImage} AS runner
|
|
4384
|
-
|
|
4385
|
-
WORKDIR /app
|
|
4386
|
-
|
|
4387
|
-
RUN apk add --no-cache tini
|
|
4388
|
-
|
|
4389
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4390
|
-
adduser --system --uid 1001 hono
|
|
4391
|
-
|
|
4392
|
-
# Copy bundled server
|
|
4393
|
-
COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
|
|
4394
|
-
|
|
4395
|
-
ENV NODE_ENV=production
|
|
4396
|
-
ENV PORT=${port}
|
|
4397
|
-
|
|
4398
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4399
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4400
|
-
|
|
4401
|
-
USER hono
|
|
4402
|
-
|
|
4403
|
-
EXPOSE ${port}
|
|
4404
|
-
|
|
4405
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4406
|
-
CMD ["node", "server.mjs"]
|
|
4407
|
-
`;
|
|
4704
|
+
function externalServices(input) {
|
|
4705
|
+
const { supplied, allow } = input;
|
|
4706
|
+
const has = (key) => supplied[key] !== void 0;
|
|
4707
|
+
const domain = input.domain ?? "example.com";
|
|
4708
|
+
const missing = [];
|
|
4709
|
+
const minio = [];
|
|
4710
|
+
const mailpit = [];
|
|
4711
|
+
const add = (entry) => {
|
|
4712
|
+
if (!missing.some((m) => m.key === entry.key)) missing.push(entry);
|
|
4713
|
+
};
|
|
4714
|
+
const declarations = [...input.declarations].sort((a, b) => a.id.localeCompare(b.id));
|
|
4715
|
+
const apps = (d) => d.apps?.length ? { apps: [...d.apps].sort() } : {};
|
|
4716
|
+
for (const d of declarations.filter((d) => d.kind === "email")) {
|
|
4717
|
+
const url = provideKey(d.id, "url");
|
|
4718
|
+
const from = provideKey(d.id, "from");
|
|
4719
|
+
if (!has(url) && allow.includes("mailpit")) {
|
|
4720
|
+
mailpit.push(d.id);
|
|
4721
|
+
continue;
|
|
4722
|
+
}
|
|
4723
|
+
if (!has(url)) add({
|
|
4724
|
+
key: url,
|
|
4725
|
+
id: d.id,
|
|
4726
|
+
what: `where '${d.id}' sends mail — any SMTP server`,
|
|
4727
|
+
example: `smtp://user:password@smtp.${domain}:587`,
|
|
4728
|
+
service: "mailpit",
|
|
4729
|
+
...apps(d)
|
|
4730
|
+
});
|
|
4731
|
+
if (!has(from)) add({
|
|
4732
|
+
key: from,
|
|
4733
|
+
id: d.id,
|
|
4734
|
+
what: `the address '${d.id}' sends from, on a domain the mail server has verified`,
|
|
4735
|
+
example: `noreply@${domain}`,
|
|
4736
|
+
service: "mailpit",
|
|
4737
|
+
...apps(d)
|
|
4738
|
+
});
|
|
4739
|
+
}
|
|
4740
|
+
for (const d of declarations.filter((d) => d.kind === "objects")) {
|
|
4741
|
+
const url = provideKey(d.id, "url");
|
|
4742
|
+
if (has(url)) continue;
|
|
4743
|
+
if (allow.includes("minio")) {
|
|
4744
|
+
minio.push(d.id);
|
|
4745
|
+
continue;
|
|
4746
|
+
}
|
|
4747
|
+
add({
|
|
4748
|
+
key: url,
|
|
4749
|
+
id: d.id,
|
|
4750
|
+
what: `the bucket '${d.id}' — S3, R2, or any S3-compatible store (add &endpoint=… for one that is not S3)`,
|
|
4751
|
+
note: `credentials are optional: a key for this bucket alone in the URL (s3://KEY:SECRET@${appKey(d.id)}?…) wins; without one, the shared AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or a role signs`,
|
|
4752
|
+
example: `s3://${appKey(d.id)}?region=eu-west-1`,
|
|
4753
|
+
service: "minio",
|
|
4754
|
+
...apps(d)
|
|
4755
|
+
});
|
|
4756
|
+
}
|
|
4757
|
+
for (const d of declarations.filter((d) => d.kind === "file-server")) {
|
|
4758
|
+
const url = provideKey(d.id, "url");
|
|
4759
|
+
if (has(url)) continue;
|
|
4760
|
+
if (d.of && minio.includes(d.of)) continue;
|
|
4761
|
+
add({
|
|
4762
|
+
key: url,
|
|
4763
|
+
id: d.id,
|
|
4764
|
+
what: `the public address '${d.id}' serves its bucket on — a CDN or the bucket's own domain`,
|
|
4765
|
+
example: `https://${appKey(d.id)}.${domain}`,
|
|
4766
|
+
service: "minio",
|
|
4767
|
+
...apps(d)
|
|
4768
|
+
});
|
|
4769
|
+
}
|
|
4770
|
+
return {
|
|
4771
|
+
missing,
|
|
4772
|
+
minio,
|
|
4773
|
+
mailpit
|
|
4774
|
+
};
|
|
4408
4775
|
}
|
|
4409
4776
|
/**
|
|
4410
|
-
*
|
|
4411
|
-
*
|
|
4412
|
-
*
|
|
4413
|
-
* @internal Exported for testing
|
|
4777
|
+
* `externalServices`, refusing a stage that lacks anything.
|
|
4778
|
+
*
|
|
4779
|
+
* @throws {ExternalServicesNotConfigured} naming every missing key
|
|
4414
4780
|
*/
|
|
4415
|
-
function
|
|
4416
|
-
const
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
fi
|
|
4483
|
-
|
|
4484
|
-
# Stage 4: Production
|
|
4485
|
-
FROM ${baseImage} AS runner
|
|
4486
|
-
|
|
4487
|
-
WORKDIR /app
|
|
4488
|
-
|
|
4489
|
-
RUN apk add --no-cache tini
|
|
4490
|
-
|
|
4491
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4492
|
-
adduser --system --uid 1001 app
|
|
4493
|
-
|
|
4494
|
-
# Copy bundled output only (no node_modules needed - fully bundled)
|
|
4495
|
-
COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
|
|
4496
|
-
|
|
4497
|
-
ENV NODE_ENV=production
|
|
4498
|
-
ENV PORT=${port}
|
|
4499
|
-
|
|
4500
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4501
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4502
|
-
|
|
4503
|
-
USER app
|
|
4504
|
-
|
|
4505
|
-
EXPOSE ${port}
|
|
4506
|
-
|
|
4507
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4508
|
-
CMD ["node", "index.mjs"]
|
|
4509
|
-
`;
|
|
4781
|
+
function assertExternalServices(input) {
|
|
4782
|
+
const services = externalServices(input);
|
|
4783
|
+
if (services.missing.length > 0) throw new ExternalServicesNotConfigured(input.stage, services.missing);
|
|
4784
|
+
return services;
|
|
4785
|
+
}
|
|
4786
|
+
/** The dev services a run uses, one entry per service it runs. */
|
|
4787
|
+
function devServicesUsed(services) {
|
|
4788
|
+
const used = [];
|
|
4789
|
+
if (services.mailpit.length > 0) used.push({
|
|
4790
|
+
service: "mailpit",
|
|
4791
|
+
ids: services.mailpit
|
|
4792
|
+
});
|
|
4793
|
+
if (services.minio.length > 0) used.push({
|
|
4794
|
+
service: "minio",
|
|
4795
|
+
ids: services.minio
|
|
4796
|
+
});
|
|
4797
|
+
return used;
|
|
4798
|
+
}
|
|
4799
|
+
/** The line a run prints for each dev service a deployed stage uses. */
|
|
4800
|
+
function devServiceWarning(stage, use) {
|
|
4801
|
+
return `⚠️ DEV SERVICE ON A DEPLOYED STAGE (${stage}): ${use.service === "mailpit" ? `Mailpit is catching mail for ${use.ids.join(", ")}. It delivers NO mail: nobody receives a sign-in link, a receipt or a reset` : `MinIO is storing ${use.ids.join(", ")}. Every object is on one container's disk, with no replication and no backup`}. It is not production-grade — set the stage's own keys in its secrets for that.`;
|
|
4802
|
+
}
|
|
4803
|
+
/**
|
|
4804
|
+
* Report each dev service a run uses: a loud line, and a `dev-service.used`
|
|
4805
|
+
* event a host can act on.
|
|
4806
|
+
*/
|
|
4807
|
+
function reportDevServices(ctx, uses) {
|
|
4808
|
+
for (const use of uses) {
|
|
4809
|
+
ctx.logger.warn(`\n${devServiceWarning(ctx.stage, use)}`);
|
|
4810
|
+
ctx.emit({
|
|
4811
|
+
type: "dev-service.used",
|
|
4812
|
+
service: use.service,
|
|
4813
|
+
stage: ctx.stage,
|
|
4814
|
+
constructs: use.ids
|
|
4815
|
+
});
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
4818
|
+
//#endregion
|
|
4819
|
+
//#region src/target/dokploy/domain.ts
|
|
4820
|
+
/** A stage was deployed that `deploy.domains` gives no domain. */
|
|
4821
|
+
var NoDomainForStage = class extends Error {
|
|
4822
|
+
stage;
|
|
4823
|
+
constructor(stage) {
|
|
4824
|
+
super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
|
|
4825
|
+
this.stage = stage;
|
|
4826
|
+
this.name = "NoDomainForStage";
|
|
4827
|
+
}
|
|
4828
|
+
};
|
|
4829
|
+
/**
|
|
4830
|
+
* The hostname an app answers on for a stage.
|
|
4831
|
+
*
|
|
4832
|
+
* 1. An explicit `app.domain` — a string, or one per stage — wins.
|
|
4833
|
+
* 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
|
|
4834
|
+
* answers on the domain itself, and every other app on
|
|
4835
|
+
* `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
|
|
4836
|
+
*
|
|
4837
|
+
* @throws {NoDomainForStage} when `deploy.domains` names no domain for it
|
|
4838
|
+
*/
|
|
4839
|
+
function resolveHost(appName, app, stage, domains, isMainFrontend) {
|
|
4840
|
+
if (app.domain) {
|
|
4841
|
+
if (typeof app.domain === "string") return app.domain;
|
|
4842
|
+
if (app.domain[stage]) return app.domain[stage];
|
|
4843
|
+
}
|
|
4844
|
+
const baseDomain = domains?.[stage];
|
|
4845
|
+
if (!baseDomain) throw new NoDomainForStage(stage);
|
|
4846
|
+
if (isMainFrontend) return baseDomain;
|
|
4847
|
+
return `${app.subdomain ?? appName}.${baseDomain}`;
|
|
4510
4848
|
}
|
|
4511
4849
|
/**
|
|
4512
|
-
*
|
|
4513
|
-
*
|
|
4514
|
-
* script via `npm start`.
|
|
4850
|
+
* Whether this app is the site the base domain points at — see `rootSite`,
|
|
4851
|
+
* which the local edge asks too.
|
|
4515
4852
|
*
|
|
4516
|
-
*
|
|
4517
|
-
* can inline them at build time. Per-framework prefix is the caller's job.
|
|
4518
|
-
* @internal Exported for testing
|
|
4853
|
+
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
4519
4854
|
*/
|
|
4520
|
-
function
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
const startCmd = packageManager === "pnpm" ? "pnpm start" : `${pm.lockfile.startsWith("package-lock") ? "npm" : packageManager} start`;
|
|
4527
|
-
const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
|
|
4528
|
-
const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
|
|
4529
|
-
return `# syntax=docker/dockerfile:1
|
|
4530
|
-
# Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
|
|
4531
|
-
|
|
4532
|
-
FROM ${baseImage} AS pruner
|
|
4533
|
-
WORKDIR /app
|
|
4534
|
-
${installPm}
|
|
4535
|
-
COPY . .
|
|
4536
|
-
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
4537
|
-
|
|
4538
|
-
FROM ${baseImage} AS deps
|
|
4539
|
-
WORKDIR /app
|
|
4540
|
-
${installPm}
|
|
4541
|
-
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4542
|
-
COPY --from=pruner /app/out/json/ ./
|
|
4543
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4544
|
-
${turboInstallCmd}
|
|
4545
|
-
|
|
4546
|
-
FROM deps AS builder
|
|
4547
|
-
WORKDIR /app
|
|
4548
|
-
${argDecls}
|
|
4549
|
-
${envDecls}
|
|
4550
|
-
COPY --from=pruner /app/out/full/ ./
|
|
4551
|
-
COPY --from=pruner /app/tsconfig.* ./
|
|
4552
|
-
RUN ${turboCmd} run build --filter=${turboPackage}
|
|
4553
|
-
|
|
4554
|
-
FROM ${baseImage} AS runner
|
|
4555
|
-
WORKDIR /app
|
|
4556
|
-
RUN apk add --no-cache tini
|
|
4557
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4558
|
-
adduser --system --uid 1001 app
|
|
4559
|
-
|
|
4560
|
-
# Copy the whole built workspace — frameworks differ on output paths
|
|
4561
|
-
COPY --from=builder --chown=app:nodejs /app/ ./
|
|
4562
|
-
|
|
4563
|
-
ENV NODE_ENV=production
|
|
4564
|
-
ENV PORT=${port}
|
|
4565
|
-
ENV HOSTNAME="0.0.0.0"
|
|
4566
|
-
|
|
4567
|
-
USER app
|
|
4568
|
-
EXPOSE ${port}
|
|
4569
|
-
|
|
4570
|
-
# A site answers at its root; Docker, and Dokploy, read the container's
|
|
4571
|
-
# health from it rather than from the process merely running. 127.0.0.1, not
|
|
4572
|
-
# localhost: the server listens on IPv4, and localhost can resolve to ::1.
|
|
4573
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4574
|
-
CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
|
|
4575
|
-
|
|
4576
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4577
|
-
CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
|
|
4578
|
-
`;
|
|
4855
|
+
function isMainFrontendApp(appName, app, allApps) {
|
|
4856
|
+
if (app.type !== "web") return false;
|
|
4857
|
+
return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
|
|
4858
|
+
name,
|
|
4859
|
+
root: a.root
|
|
4860
|
+
}))) === appName;
|
|
4579
4861
|
}
|
|
4580
4862
|
/**
|
|
4581
|
-
*
|
|
4582
|
-
*
|
|
4583
|
-
* @
|
|
4863
|
+
* Get public URL arg names from app dependencies.
|
|
4864
|
+
*
|
|
4865
|
+
* @param app - The web/mobile app configuration
|
|
4866
|
+
* @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
|
|
4867
|
+
* without a public prefix (e.g. Remix).
|
|
4584
4868
|
*/
|
|
4585
|
-
function
|
|
4586
|
-
const
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
${
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
EXPOSE ${port}
|
|
4623
|
-
# A site answers at its root; Docker, and Dokploy, read the container's
|
|
4624
|
-
# health from it rather than from the process merely running. 127.0.0.1, not
|
|
4625
|
-
# localhost: the server listens on IPv4, and localhost can resolve to ::1.
|
|
4626
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4627
|
-
CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
|
|
4628
|
-
CMD ["nginx", "-g", "daemon off;"]
|
|
4629
|
-
`;
|
|
4869
|
+
function getPublicUrlArgNames(app) {
|
|
4870
|
+
const prefix = getPublicEnvPrefix(app.framework);
|
|
4871
|
+
if (!prefix) return [];
|
|
4872
|
+
return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
|
|
4873
|
+
}
|
|
4874
|
+
//#endregion
|
|
4875
|
+
//#region src/docker/imageRef.ts
|
|
4876
|
+
/**
|
|
4877
|
+
* An image reference, checked before it reaches `docker`.
|
|
4878
|
+
*
|
|
4879
|
+
* The parts come from the workspace — a registry in `gkm.config.ts`, an image
|
|
4880
|
+
* name from the project, a tag from `--tag` — and an argument array already
|
|
4881
|
+
* keeps them out of a shell. What it cannot do is stop `docker` reading a value
|
|
4882
|
+
* that starts with `-` as one of its own flags, so a ref is held to Docker's
|
|
4883
|
+
* own grammar (distribution/reference) and anything else is refused by name
|
|
4884
|
+
* before a build starts.
|
|
4885
|
+
*/
|
|
4886
|
+
const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
|
|
4887
|
+
const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
|
|
4888
|
+
const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
|
|
4889
|
+
const REFERENCE = new RegExp(`^(?:${DOMAIN}/)?${PATH_COMPONENT}(?:/${PATH_COMPONENT})*(?::[\\w][\\w.-]{0,127})?(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,})?$`);
|
|
4890
|
+
/** Docker refuses a repository name longer than this. */
|
|
4891
|
+
const MAX_NAME_LENGTH = 255;
|
|
4892
|
+
/** A value that is not an image reference was about to be built or pushed. */
|
|
4893
|
+
var ImageRefInvalid = class extends Error {
|
|
4894
|
+
ref;
|
|
4895
|
+
constructor(ref) {
|
|
4896
|
+
super(`'${ref}' is not a valid image reference. An image is \`[registry/]name[:tag]\`: the name lowercase letters, digits and . _ - separators, the tag up to 128 of letters, digits, _ . and -, not starting with . or -. Check docker.registry in gkm.config.ts and the --tag passed.`);
|
|
4897
|
+
this.ref = ref;
|
|
4898
|
+
this.name = "ImageRefInvalid";
|
|
4899
|
+
}
|
|
4900
|
+
};
|
|
4901
|
+
/** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
|
|
4902
|
+
function validateImageRef(ref) {
|
|
4903
|
+
const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
4904
|
+
if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
|
|
4905
|
+
return ref;
|
|
4630
4906
|
}
|
|
4631
4907
|
//#endregion
|
|
4632
4908
|
//#region src/docker/index.ts
|
|
4633
|
-
const logger = output;
|
|
4909
|
+
const logger$1 = output;
|
|
4634
4910
|
/** `docker build` failed; what docker printed above says why. */
|
|
4635
4911
|
var DockerBuildFailed = class extends Error {
|
|
4636
4912
|
imageRef;
|
|
@@ -4659,130 +4935,45 @@ var PushNeedsRegistry = class extends Error {
|
|
|
4659
4935
|
}
|
|
4660
4936
|
};
|
|
4661
4937
|
/**
|
|
4662
|
-
* Docker command implementation
|
|
4663
|
-
*
|
|
4664
|
-
*
|
|
4665
|
-
*
|
|
4666
|
-
* --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
|
|
4938
|
+
* Docker command implementation: one Dockerfile per deployable app, each
|
|
4939
|
+
* building its image inside Docker from a turbo-pruned slice of the build
|
|
4940
|
+
* root — nothing is built on the host — plus the build root's
|
|
4941
|
+
* `.dockerignore` and `docker-compose.constructs.yml`.
|
|
4667
4942
|
*/
|
|
4668
4943
|
async function dockerCommand(options) {
|
|
4669
4944
|
const cwd = options.cwd ?? process.cwd();
|
|
4670
|
-
const
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
const dockerConfig = resolveDockerConfig(await loadConfig(cwd));
|
|
4676
|
-
const healthCheckPath = "/health";
|
|
4677
|
-
const useSlim = options.slim === true;
|
|
4678
|
-
if (useSlim) {
|
|
4679
|
-
const distDir = join(cwd, ".gkm", "server", "dist");
|
|
4680
|
-
if (!existsSync(join(distDir, "server.mjs"))) throw new Error("Slim Dockerfile requires a pre-built bundle. Run `gkm build --provider server --production` first, or omit --slim to use multi-stage build.");
|
|
4681
|
-
}
|
|
4682
|
-
const dockerDir = join(cwd, ".gkm", "docker");
|
|
4683
|
-
await mkdir(dockerDir, { recursive: true });
|
|
4684
|
-
const packageManager = detectPackageManager(cwd);
|
|
4685
|
-
const inMonorepo = isMonorepo(cwd);
|
|
4686
|
-
const hasTurbo = hasTurboConfig(cwd);
|
|
4687
|
-
let useTurbo = options.turbo ?? false;
|
|
4688
|
-
if (inMonorepo && !useSlim) {
|
|
4689
|
-
if (hasTurbo) {
|
|
4690
|
-
useTurbo = true;
|
|
4691
|
-
logger.log(" Detected monorepo with turbo.json - using turbo prune");
|
|
4692
|
-
} else throw new Error("Monorepo detected but turbo.json not found.\n\nDocker builds in monorepos require Turborepo for proper dependency isolation.\n\nTo fix this:\n 1. Install turbo: pnpm add -Dw turbo\n 2. Create turbo.json in your monorepo root\n 3. Run this command again\n\nSee: https://turbo.build/repo/docs/guides/tools/docker");
|
|
4693
|
-
}
|
|
4694
|
-
let turboPackage = options.turboPackage ?? dockerConfig.imageName;
|
|
4695
|
-
if (useTurbo && !options.turboPackage) try {
|
|
4696
|
-
const pkg = __require(`${cwd}/package.json`);
|
|
4697
|
-
if (pkg.name) {
|
|
4698
|
-
turboPackage = pkg.name;
|
|
4699
|
-
logger.log(` Turbo package: ${turboPackage}`);
|
|
4700
|
-
}
|
|
4701
|
-
} catch {}
|
|
4702
|
-
const templateOptions = {
|
|
4703
|
-
imageName: dockerConfig.imageName,
|
|
4704
|
-
baseImage: dockerConfig.baseImage,
|
|
4705
|
-
port: dockerConfig.port,
|
|
4706
|
-
healthCheckPath,
|
|
4707
|
-
prebuilt: useSlim,
|
|
4708
|
-
turbo: useTurbo,
|
|
4709
|
-
turboPackage,
|
|
4710
|
-
packageManager
|
|
4711
|
-
};
|
|
4712
|
-
const dockerfile = useSlim ? generateSlimDockerfile(templateOptions) : generateMultiStageDockerfile(templateOptions);
|
|
4713
|
-
const dockerMode = useSlim ? "slim" : useTurbo ? "turbo" : "multi-stage";
|
|
4714
|
-
const dockerfilePath = join(dockerDir, "Dockerfile");
|
|
4715
|
-
await writeFile(dockerfilePath, dockerfile);
|
|
4716
|
-
logger.log(`Generated: .gkm/docker/Dockerfile (${dockerMode}, ${packageManager})`);
|
|
4717
|
-
const composePath = await writeConstructsCompose(loadedConfig.workspace);
|
|
4718
|
-
const dockerignore = generateDockerignore();
|
|
4719
|
-
const dockerignorePath = join(cwd, ".dockerignore");
|
|
4720
|
-
await writeFile(dockerignorePath, dockerignore);
|
|
4721
|
-
logger.log("Generated: .dockerignore (project root)");
|
|
4722
|
-
const entrypoint = generateDockerEntrypoint();
|
|
4723
|
-
const entrypointPath = join(dockerDir, "docker-entrypoint.sh");
|
|
4724
|
-
await writeFile(entrypointPath, entrypoint);
|
|
4725
|
-
logger.log("Generated: .gkm/docker/docker-entrypoint.sh");
|
|
4726
|
-
const result = {
|
|
4727
|
-
dockerfile: dockerfilePath,
|
|
4728
|
-
dockerCompose: composePath,
|
|
4729
|
-
dockerignore: dockerignorePath,
|
|
4730
|
-
entrypoint: entrypointPath
|
|
4731
|
-
};
|
|
4732
|
-
if (options.build) await buildDockerImage(dockerConfig.imageName, options, cwd);
|
|
4733
|
-
if (options.push) await pushDockerImage(dockerConfig.imageName, options, cwd);
|
|
4945
|
+
const loaded = await loadWorkspaceConfig(cwd);
|
|
4946
|
+
const names = loaded.type === "single" ? Object.fromEntries(Object.keys(loaded.workspace.apps).map((name) => [name, resolveDockerConfig(loaded.raw).imageName])) : {};
|
|
4947
|
+
const result = await workspaceDockerCommand(loaded.workspace, loaded.manifest, names);
|
|
4948
|
+
if (options.build) for (const app of result.apps) await buildDockerImage(app, result.buildRoot, options);
|
|
4949
|
+
if (options.push) for (const app of result.apps) await pushDockerImage(app.imageName, options, result.buildRoot);
|
|
4734
4950
|
return result;
|
|
4735
4951
|
}
|
|
4736
4952
|
/**
|
|
4737
|
-
*
|
|
4738
|
-
* For monorepos, copies from workspace root if needed
|
|
4739
|
-
* Returns cleanup function if file was copied
|
|
4740
|
-
*/
|
|
4741
|
-
function ensureLockfile(cwd) {
|
|
4742
|
-
const lockfilePath = findLockfilePath(cwd);
|
|
4743
|
-
if (!lockfilePath) {
|
|
4744
|
-
logger.warn("\n⚠️ No lockfile found. Docker build may fail or use stale dependencies.");
|
|
4745
|
-
return null;
|
|
4746
|
-
}
|
|
4747
|
-
const lockfileName = basename(lockfilePath);
|
|
4748
|
-
const localLockfile = join(cwd, lockfileName);
|
|
4749
|
-
if (lockfilePath === localLockfile) return null;
|
|
4750
|
-
logger.log(` Copying ${lockfileName} from monorepo root...`);
|
|
4751
|
-
copyFileSync(lockfilePath, localLockfile);
|
|
4752
|
-
return () => {
|
|
4753
|
-
try {
|
|
4754
|
-
unlinkSync(localLockfile);
|
|
4755
|
-
} catch {}
|
|
4756
|
-
};
|
|
4757
|
-
}
|
|
4758
|
-
/**
|
|
4759
|
-
* Build Docker image
|
|
4953
|
+
* Build one app's image, from the build root.
|
|
4760
4954
|
* Uses BuildKit for cache mount support
|
|
4761
4955
|
*/
|
|
4762
|
-
async function buildDockerImage(
|
|
4956
|
+
async function buildDockerImage(app, buildRoot, options) {
|
|
4763
4957
|
const tag = options.tag ?? "latest";
|
|
4764
4958
|
const registry = options.registry;
|
|
4765
|
-
const fullImageName = validateImageRef(registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`);
|
|
4766
|
-
logger.log(`\n🐳 Building Docker image: ${fullImageName}`);
|
|
4767
|
-
const cleanup = ensureLockfile(cwd);
|
|
4959
|
+
const fullImageName = validateImageRef(registry ? `${registry}/${app.imageName}:${tag}` : `${app.imageName}:${tag}`);
|
|
4960
|
+
logger$1.log(`\n🐳 Building Docker image: ${fullImageName}`);
|
|
4768
4961
|
try {
|
|
4769
4962
|
await run$1("docker", [
|
|
4770
4963
|
"build",
|
|
4771
|
-
|
|
4964
|
+
`--file=${relative(buildRoot, app.dockerfile)}`,
|
|
4772
4965
|
`--tag=${fullImageName}`,
|
|
4773
4966
|
"."
|
|
4774
4967
|
], {
|
|
4775
|
-
cwd,
|
|
4968
|
+
cwd: buildRoot,
|
|
4776
4969
|
env: {
|
|
4777
4970
|
...process.env,
|
|
4778
4971
|
DOCKER_BUILDKIT: "1"
|
|
4779
4972
|
}
|
|
4780
4973
|
});
|
|
4781
|
-
logger.log(`✅ Docker image built: ${fullImageName}`);
|
|
4974
|
+
logger$1.log(`✅ Docker image built: ${fullImageName}`);
|
|
4782
4975
|
} catch (error) {
|
|
4783
4976
|
throw new DockerBuildFailed(fullImageName, error);
|
|
4784
|
-
} finally {
|
|
4785
|
-
cleanup?.();
|
|
4786
4977
|
}
|
|
4787
4978
|
}
|
|
4788
4979
|
/**
|
|
@@ -4793,103 +4984,88 @@ async function pushDockerImage(imageName, options, cwd) {
|
|
|
4793
4984
|
const registry = options.registry;
|
|
4794
4985
|
if (!registry) throw new PushNeedsRegistry(imageName);
|
|
4795
4986
|
const fullImageName = validateImageRef(`${registry}/${imageName}:${tag}`);
|
|
4796
|
-
logger.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
|
|
4987
|
+
logger$1.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
|
|
4797
4988
|
try {
|
|
4798
4989
|
await run$1("docker", ["push", fullImageName], { cwd });
|
|
4799
|
-
logger.log(`✅ Docker image pushed: ${fullImageName}`);
|
|
4990
|
+
logger$1.log(`✅ Docker image pushed: ${fullImageName}`);
|
|
4800
4991
|
} catch (error) {
|
|
4801
4992
|
throw new DockerPushFailed(fullImageName, error);
|
|
4802
4993
|
}
|
|
4803
4994
|
}
|
|
4804
4995
|
/**
|
|
4805
|
-
* Get the package name from package.json in an app directory.
|
|
4806
|
-
*/
|
|
4807
|
-
function getAppPackageName(appPath) {
|
|
4808
|
-
try {
|
|
4809
|
-
const pkgPath = join(appPath, "package.json");
|
|
4810
|
-
if (!existsSync(pkgPath)) return;
|
|
4811
|
-
const content = readFileSync(pkgPath, "utf-8");
|
|
4812
|
-
return JSON.parse(content).name;
|
|
4813
|
-
} catch {
|
|
4814
|
-
return;
|
|
4815
|
-
}
|
|
4816
|
-
}
|
|
4817
|
-
/**
|
|
4818
4996
|
* Generate Dockerfiles for all apps in a workspace.
|
|
4819
4997
|
* @internal Exported for testing
|
|
4820
4998
|
*/
|
|
4821
|
-
async function workspaceDockerCommand(workspace, manifest) {
|
|
4999
|
+
async function workspaceDockerCommand(workspace, manifest, imageNames = {}) {
|
|
4822
5000
|
const results = [];
|
|
4823
|
-
const
|
|
4824
|
-
logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
const
|
|
4828
|
-
logger.log(` Package manager: ${packageManager}`);
|
|
4829
|
-
for (const [appName, app] of apps) {
|
|
5001
|
+
const layout = imageLayout(workspace);
|
|
5002
|
+
logger$1.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
|
|
5003
|
+
logger$1.log(` Build root: ${layout.buildRoot}`);
|
|
5004
|
+
logger$1.log(` Package manager: ${layout.tools.packageManager}${layout.tools.packageManagerVersion ? `@${layout.tools.packageManagerVersion}` : ""}, turbo@${layout.tools.turboVersion}`);
|
|
5005
|
+
for (const [appName, app] of Object.entries(workspace.apps)) {
|
|
4830
5006
|
if (app.type === "mobile") {
|
|
4831
|
-
logger.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
|
|
5007
|
+
logger$1.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
|
|
4832
5008
|
continue;
|
|
4833
5009
|
}
|
|
4834
|
-
const
|
|
4835
|
-
|
|
4836
|
-
const
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
let dockerfile;
|
|
4840
|
-
if (app.type === "web") dockerfile = siteDockerfile(appName, app, {
|
|
4841
|
-
turboPackage,
|
|
4842
|
-
packageManager,
|
|
5010
|
+
const buildType = app.entry ? "entry" : app.type;
|
|
5011
|
+
logger$1.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
|
|
5012
|
+
const dockerfile = appDockerfile(appName, app, {
|
|
5013
|
+
layout,
|
|
5014
|
+
workspaceRoot: workspace.root,
|
|
4843
5015
|
...manifest ? { manifest } : {}
|
|
4844
5016
|
});
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
port: app.port,
|
|
4849
|
-
appPath,
|
|
4850
|
-
entry: app.entry,
|
|
4851
|
-
turboPackage,
|
|
4852
|
-
packageManager,
|
|
4853
|
-
healthCheckPath: "/health"
|
|
4854
|
-
});
|
|
4855
|
-
else dockerfile = generateBackendDockerfile({
|
|
4856
|
-
imageName,
|
|
4857
|
-
baseImage: "node:22-alpine",
|
|
4858
|
-
port: app.port,
|
|
4859
|
-
appPath,
|
|
4860
|
-
turboPackage,
|
|
4861
|
-
packageManager,
|
|
4862
|
-
healthCheckPath: "/health"
|
|
4863
|
-
});
|
|
4864
|
-
const dockerfilePath = join(dockerDir, `Dockerfile.${appName}`);
|
|
5017
|
+
const path = dockerfileOf(appName, app.path);
|
|
5018
|
+
const dockerfilePath = join(workspace.root, path);
|
|
5019
|
+
await mkdir(dirname(dockerfilePath), { recursive: true });
|
|
4865
5020
|
await writeFile(dockerfilePath, dockerfile);
|
|
4866
|
-
logger.log(` Generated:
|
|
5021
|
+
logger$1.log(` Generated: ${path}`);
|
|
4867
5022
|
results.push({
|
|
4868
5023
|
appName,
|
|
4869
5024
|
type: app.type,
|
|
4870
5025
|
dockerfile: dockerfilePath,
|
|
4871
|
-
imageName
|
|
5026
|
+
imageName: imageNames[appName] ?? appName
|
|
4872
5027
|
});
|
|
4873
5028
|
}
|
|
4874
|
-
const
|
|
4875
|
-
|
|
4876
|
-
await writeFile(dockerignorePath, dockerignore);
|
|
4877
|
-
logger.log(`\n Generated: .dockerignore (workspace root)`);
|
|
5029
|
+
const dockerignorePath = await ensureDockerignore(layout.buildRoot);
|
|
5030
|
+
logger$1.log(`\n Ensured: ${dockerignorePath}`);
|
|
4878
5031
|
const composePath = await writeConstructsCompose(workspace);
|
|
4879
|
-
logger.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
|
|
4880
|
-
logger.log(
|
|
5032
|
+
logger$1.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
|
|
5033
|
+
logger$1.log(`\n📋 Build commands (from ${layout.buildRoot}):`);
|
|
4881
5034
|
for (const result of results) {
|
|
4882
5035
|
const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
|
|
4883
|
-
logger.log(` ${icon} docker build -f .
|
|
5036
|
+
logger$1.log(` ${icon} docker build -f ${relative(layout.buildRoot, result.dockerfile)} -t ${result.imageName} .`);
|
|
4884
5037
|
}
|
|
4885
5038
|
printRunInstructions(workspace);
|
|
4886
5039
|
return {
|
|
4887
5040
|
apps: results,
|
|
4888
5041
|
dockerCompose: composePath,
|
|
4889
|
-
dockerignore: dockerignorePath
|
|
5042
|
+
dockerignore: dockerignorePath,
|
|
5043
|
+
buildRoot: layout.buildRoot
|
|
4890
5044
|
};
|
|
4891
5045
|
}
|
|
4892
5046
|
/**
|
|
5047
|
+
* One app's Dockerfile, by what it is: a gkm backend bundled by `gkm build`,
|
|
5048
|
+
* a backend with its own entry bundled by esbuild, or a site by its
|
|
5049
|
+
* framework. The one choice every builder of images makes.
|
|
5050
|
+
*/
|
|
5051
|
+
function appDockerfile(appName, app, options) {
|
|
5052
|
+
const image = appImageOptions(options.layout, appName, app, options.workspaceRoot);
|
|
5053
|
+
if (app.type === "web") return siteDockerfile(appName, app, {
|
|
5054
|
+
image,
|
|
5055
|
+
...options.manifest ? { manifest: options.manifest } : {},
|
|
5056
|
+
...options.publicUrlArgs ? { publicUrlArgs: options.publicUrlArgs } : {}
|
|
5057
|
+
});
|
|
5058
|
+
if (app.entry) return generateEntryDockerfile({
|
|
5059
|
+
...image,
|
|
5060
|
+
entry: app.entry,
|
|
5061
|
+
healthCheckPath: "/health"
|
|
5062
|
+
});
|
|
5063
|
+
return generateBackendDockerfile({
|
|
5064
|
+
...image,
|
|
5065
|
+
healthCheckPath: "/health"
|
|
5066
|
+
});
|
|
5067
|
+
}
|
|
5068
|
+
/**
|
|
4893
5069
|
* A site's Dockerfile, with an `ARG` for every key its bundler inlines.
|
|
4894
5070
|
*
|
|
4895
5071
|
* The keys come from the site's declaration where there is one — every
|
|
@@ -4899,14 +5075,9 @@ async function workspaceDockerCommand(workspace, manifest) {
|
|
|
4899
5075
|
*/
|
|
4900
5076
|
function siteDockerfile(appName, app, options) {
|
|
4901
5077
|
const declared = options.manifest ? Object.entries(options.manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[1] : void 0;
|
|
4902
|
-
const publicUrlArgs = declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app);
|
|
5078
|
+
const publicUrlArgs = options.publicUrlArgs ?? (declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app));
|
|
4903
5079
|
const webOpts = {
|
|
4904
|
-
|
|
4905
|
-
baseImage: "node:22-alpine",
|
|
4906
|
-
port: app.port,
|
|
4907
|
-
appPath: app.path,
|
|
4908
|
-
turboPackage: options.turboPackage,
|
|
4909
|
-
packageManager: options.packageManager,
|
|
5080
|
+
...options.image,
|
|
4910
5081
|
publicUrlArgs
|
|
4911
5082
|
};
|
|
4912
5083
|
switch (app.framework) {
|
|
@@ -4917,10 +5088,37 @@ function siteDockerfile(appName, app, options) {
|
|
|
4917
5088
|
}
|
|
4918
5089
|
}
|
|
4919
5090
|
/**
|
|
4920
|
-
*
|
|
5091
|
+
* What a build root's `.dockerignore` must leave out of every image's
|
|
5092
|
+
* context: dependencies installed for another platform, anything built on the
|
|
5093
|
+
* host (every image builds from source), git, and gkm's own output —
|
|
5094
|
+
* `.gkm/compose`'s env files hold a stage's secrets.
|
|
4921
5095
|
*/
|
|
4922
|
-
|
|
4923
|
-
|
|
5096
|
+
const REQUIRED_IGNORES = [
|
|
5097
|
+
"**/node_modules",
|
|
5098
|
+
".git",
|
|
5099
|
+
"**/dist",
|
|
5100
|
+
"**/.next",
|
|
5101
|
+
"**/.turbo",
|
|
5102
|
+
"**/.gkm",
|
|
5103
|
+
"**/.gkm/compose"
|
|
5104
|
+
];
|
|
5105
|
+
/**
|
|
5106
|
+
* Make sure the build root's `.dockerignore` leaves out what no image may
|
|
5107
|
+
* hold: written whole where there is none, and each missing line appended to
|
|
5108
|
+
* one the project keeps.
|
|
5109
|
+
*/
|
|
5110
|
+
async function ensureDockerignore(buildRoot) {
|
|
5111
|
+
const path = join(buildRoot, ".dockerignore");
|
|
5112
|
+
if (!existsSync(path)) {
|
|
5113
|
+
await writeFile(path, generateDockerignore());
|
|
5114
|
+
return path;
|
|
5115
|
+
}
|
|
5116
|
+
const current = await readFile(path, "utf-8");
|
|
5117
|
+
const lines = new Set(current.split(/\r?\n/).map((line) => line.trim()));
|
|
5118
|
+
const missing = REQUIRED_IGNORES.filter((line) => !lines.has(line));
|
|
5119
|
+
if (missing.length === 0) return path;
|
|
5120
|
+
await appendFile(path, `${current.endsWith("\n") || current === "" ? "" : "\n"}\n# gkm: images build from source, and a stack's env files hold its secrets\n${missing.join("\n")}\n`);
|
|
5121
|
+
return path;
|
|
4924
5122
|
}
|
|
4925
5123
|
/**
|
|
4926
5124
|
* Write `docker-compose.constructs.yml` — the same file `gkm dev` writes, with
|
|
@@ -4934,14 +5132,199 @@ async function writeConstructsCompose(workspace) {
|
|
|
4934
5132
|
stage: workspace.stages.local,
|
|
4935
5133
|
start: false
|
|
4936
5134
|
});
|
|
4937
|
-
logger.log(` Generated: ${COMPOSE_PATH} (project root)`);
|
|
5135
|
+
logger$1.log(` Generated: ${COMPOSE_PATH} (project root)`);
|
|
4938
5136
|
return join(workspace.root, COMPOSE_PATH);
|
|
4939
5137
|
}
|
|
4940
5138
|
function printRunInstructions(workspace) {
|
|
4941
5139
|
const project = existsSync(join(workspace.root, "docker-compose.yml")) ? " -f docker-compose.yml" : "";
|
|
4942
|
-
logger.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
|
|
4943
|
-
logger.log(" gkm setup # starts the containers and creates what they hold");
|
|
4944
|
-
logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
|
|
5140
|
+
logger$1.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
|
|
5141
|
+
logger$1.log(" gkm setup # starts the containers and creates what they hold");
|
|
5142
|
+
logger$1.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
|
|
5143
|
+
}
|
|
5144
|
+
//#endregion
|
|
5145
|
+
//#region src/deploy/docker.ts
|
|
5146
|
+
const logger = output;
|
|
5147
|
+
/**
|
|
5148
|
+
* The id the Dockerfile templates mount the encrypted credentials under:
|
|
5149
|
+
* `RUN --mount=type=secret,id=gkm_credentials`.
|
|
5150
|
+
*/
|
|
5151
|
+
const CREDENTIALS_SECRET_ID = "gkm_credentials";
|
|
5152
|
+
/**
|
|
5153
|
+
* Get the full image reference
|
|
5154
|
+
*/
|
|
5155
|
+
function getImageRef(registry, imageName, tag) {
|
|
5156
|
+
if (registry) return `${registry}/${imageName}:${tag}`;
|
|
5157
|
+
return `${imageName}:${tag}`;
|
|
5158
|
+
}
|
|
5159
|
+
/**
|
|
5160
|
+
* The `docker build` argv for an image.
|
|
5161
|
+
*
|
|
5162
|
+
* One element per value, so nothing in a ref or a build arg is read by a
|
|
5163
|
+
* shell, and `--opt=value` rather than `--opt value`, so a value that starts
|
|
5164
|
+
* with `-` cannot be taken for one of docker's own flags.
|
|
5165
|
+
*
|
|
5166
|
+
* @internal Exported for testing
|
|
5167
|
+
*/
|
|
5168
|
+
function dockerBuildArgs(options) {
|
|
5169
|
+
const { dockerfilePath, imageRef, buildArgs = [], credentialsFile } = options;
|
|
5170
|
+
return [
|
|
5171
|
+
"build",
|
|
5172
|
+
"--platform=linux/amd64",
|
|
5173
|
+
`--file=${dockerfilePath}`,
|
|
5174
|
+
`--tag=${imageRef}`,
|
|
5175
|
+
...buildArgs.map((arg) => `--build-arg=${arg}`),
|
|
5176
|
+
...credentialsFile ? [`--secret=id=${CREDENTIALS_SECRET_ID},src=${credentialsFile}`] : [],
|
|
5177
|
+
"."
|
|
5178
|
+
];
|
|
5179
|
+
}
|
|
5180
|
+
/**
|
|
5181
|
+
* The build arg that names which credentials an image embeds: a hash of the
|
|
5182
|
+
* ciphertext, which is not a secret. A BuildKit secret is no part of the
|
|
5183
|
+
* build cache's key, so without it a rebuild with credentials encrypted under
|
|
5184
|
+
* a new key would reuse the layer that embedded the old ones.
|
|
5185
|
+
*/
|
|
5186
|
+
function credentialsBuildArg(credentials) {
|
|
5187
|
+
return `GKM_CIPHERTEXT_HASH=${createHash("sha256").update(`${credentials.encrypted}\n${credentials.iv}`).digest("hex").slice(0, 16)}`;
|
|
5188
|
+
}
|
|
5189
|
+
/** The secret file's content: ciphertext, then IV, one per line. */
|
|
5190
|
+
function credentialsFileContent(credentials) {
|
|
5191
|
+
return `${credentials.encrypted}\n${credentials.iv}\n`;
|
|
5192
|
+
}
|
|
5193
|
+
/**
|
|
5194
|
+
* Write the encrypted credentials where `docker build --secret` reads them.
|
|
5195
|
+
*
|
|
5196
|
+
* Two lines — ciphertext, then IV — so a template can split them with `sed`
|
|
5197
|
+
* in any base image. Owner-only, in a directory of its own, and the returned
|
|
5198
|
+
* cleanup removes both whatever the build did.
|
|
5199
|
+
*
|
|
5200
|
+
* @internal Exported for testing
|
|
5201
|
+
*/
|
|
5202
|
+
async function writeCredentialsFile(credentials) {
|
|
5203
|
+
const dir = await mkdtemp(join(tmpdir(), "gkm-credentials-"));
|
|
5204
|
+
const path = join(dir, CREDENTIALS_SECRET_ID);
|
|
5205
|
+
await writeFile(path, credentialsFileContent(credentials), { mode: 384 });
|
|
5206
|
+
await chmod(path, 384);
|
|
5207
|
+
return {
|
|
5208
|
+
path,
|
|
5209
|
+
cleanup: () => rm(dir, {
|
|
5210
|
+
recursive: true,
|
|
5211
|
+
force: true
|
|
5212
|
+
})
|
|
5213
|
+
};
|
|
5214
|
+
}
|
|
5215
|
+
/**
|
|
5216
|
+
* Build Docker image
|
|
5217
|
+
* @param imageRef - Full image reference (registry/name:tag)
|
|
5218
|
+
* @param buildArgs - Build arguments to pass to docker build
|
|
5219
|
+
* @param credentials - Encrypted credentials, passed as a build secret
|
|
5220
|
+
*/
|
|
5221
|
+
async function buildImage(imageRef, buildArgs, credentials, { cwd, dockerfile, signal, stdio }) {
|
|
5222
|
+
logger.log(`\n🔨 Building Docker image: ${imageRef}`);
|
|
5223
|
+
logger.log(" Generating Dockerfile...");
|
|
5224
|
+
const { buildRoot } = await dockerCommand({ cwd });
|
|
5225
|
+
const dockerfilePath = relative(buildRoot, dockerfile);
|
|
5226
|
+
const secret = credentials ? await writeCredentialsFile(credentials) : void 0;
|
|
5227
|
+
try {
|
|
5228
|
+
await run$1("docker", dockerBuildArgs({
|
|
5229
|
+
dockerfilePath,
|
|
5230
|
+
imageRef,
|
|
5231
|
+
buildArgs: [...buildArgs ?? [], ...credentials ? [credentialsBuildArg(credentials)] : []],
|
|
5232
|
+
credentialsFile: secret?.path
|
|
5233
|
+
}), {
|
|
5234
|
+
cwd: buildRoot,
|
|
5235
|
+
env: {
|
|
5236
|
+
...process.env,
|
|
5237
|
+
DOCKER_BUILDKIT: "1"
|
|
5238
|
+
},
|
|
5239
|
+
...signal ? { signal } : {},
|
|
5240
|
+
...stdio ? { stdio } : {}
|
|
5241
|
+
});
|
|
5242
|
+
logger.log(`✅ Image built: ${imageRef}`);
|
|
5243
|
+
} catch (error) {
|
|
5244
|
+
throw new DockerBuildFailed(imageRef, error);
|
|
5245
|
+
} finally {
|
|
5246
|
+
await secret?.cleanup();
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
/**
|
|
5250
|
+
* Push Docker image to registry
|
|
5251
|
+
*/
|
|
5252
|
+
async function pushImage(imageRef, { cwd, signal, stdio }) {
|
|
5253
|
+
logger.log(`\n☁️ Pushing image: ${imageRef}`);
|
|
5254
|
+
try {
|
|
5255
|
+
await run$1("docker", ["push", imageRef], {
|
|
5256
|
+
cwd,
|
|
5257
|
+
...signal ? { signal } : {},
|
|
5258
|
+
...stdio ? { stdio } : {}
|
|
5259
|
+
});
|
|
5260
|
+
logger.log(`✅ Image pushed: ${imageRef}`);
|
|
5261
|
+
} catch (error) {
|
|
5262
|
+
throw new DockerPushFailed(imageRef, error);
|
|
5263
|
+
}
|
|
5264
|
+
}
|
|
5265
|
+
/**
|
|
5266
|
+
* The digest the registry gave a pushed image, `sha256:…`.
|
|
5267
|
+
*
|
|
5268
|
+
* A tag can be pushed over — by the next deploy, or by anything else that
|
|
5269
|
+
* shares the repository — and the digest is what says which image a stage
|
|
5270
|
+
* actually ran. `RepoDigests` has one entry per repository the image was
|
|
5271
|
+
* pushed to, so the one for this ref's repository is picked by name.
|
|
5272
|
+
*
|
|
5273
|
+
* @internal Exported for testing
|
|
5274
|
+
*/
|
|
5275
|
+
async function pushedDigest(imageRef, { cwd, signal } = { cwd: process.cwd() }) {
|
|
5276
|
+
const repository = imageRef.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
5277
|
+
const output = await runOutput("docker", [
|
|
5278
|
+
"image",
|
|
5279
|
+
"inspect",
|
|
5280
|
+
"--format={{json .RepoDigests}}",
|
|
5281
|
+
imageRef
|
|
5282
|
+
], {
|
|
5283
|
+
cwd,
|
|
5284
|
+
...signal ? { signal } : {}
|
|
5285
|
+
});
|
|
5286
|
+
let digests;
|
|
5287
|
+
try {
|
|
5288
|
+
digests = JSON.parse(output.trim() || "[]");
|
|
5289
|
+
} catch {
|
|
5290
|
+
return;
|
|
5291
|
+
}
|
|
5292
|
+
if (!Array.isArray(digests)) return void 0;
|
|
5293
|
+
return digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`))?.slice(repository.length + 1);
|
|
5294
|
+
}
|
|
5295
|
+
/**
|
|
5296
|
+
* Deploy using Docker (build and optionally push image)
|
|
5297
|
+
*/
|
|
5298
|
+
async function deployDocker(options) {
|
|
5299
|
+
const { stage, tag, skipPush, masterKey, config, buildArgs, credentials } = options;
|
|
5300
|
+
const imageName = config.imageName;
|
|
5301
|
+
const imageRef = validateImageRef(getImageRef(config.registry, imageName, tag));
|
|
5302
|
+
const cwd = options.cwd ?? process.cwd();
|
|
5303
|
+
const docker = {
|
|
5304
|
+
cwd,
|
|
5305
|
+
dockerfile: join(cwd, dockerfileOf(config.appName ?? imageName, options.appPath ?? ".")),
|
|
5306
|
+
...options.signal ? { signal: options.signal } : {},
|
|
5307
|
+
...options.stdio ? { stdio: options.stdio } : {}
|
|
5308
|
+
};
|
|
5309
|
+
await buildImage(imageRef, buildArgs, credentials, docker);
|
|
5310
|
+
let digest;
|
|
5311
|
+
if (!skipPush) {
|
|
5312
|
+
if (!config.registry) logger.warn("\n⚠️ No registry configured. Use --skip-push or configure docker.registry in gkm.config.ts");
|
|
5313
|
+
else {
|
|
5314
|
+
await pushImage(imageRef, docker);
|
|
5315
|
+
digest = await pushedDigest(imageRef, docker);
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
logger.log("\n✅ Docker deployment ready!");
|
|
5319
|
+
logger.log(`\n📋 Deployment details:`);
|
|
5320
|
+
logger.log(` Image: ${imageRef}`);
|
|
5321
|
+
logger.log(` Stage: ${stage}`);
|
|
5322
|
+
if (masterKey) logger.log(`\n🔐 Run the container with GKM_MASTER_KEY set to the key with fingerprint ${keyFingerprint(masterKey)}.`);
|
|
5323
|
+
return {
|
|
5324
|
+
imageRef,
|
|
5325
|
+
...digest ? { digest } : {},
|
|
5326
|
+
masterKey
|
|
5327
|
+
};
|
|
4945
5328
|
}
|
|
4946
5329
|
//#endregion
|
|
4947
5330
|
//#region src/deploy/generated.ts
|
|
@@ -5245,6 +5628,6 @@ function setPostgresBackupId(state, backupId) {
|
|
|
5245
5628
|
if (state.backups) state.backups.postgresBackupId = backupId;
|
|
5246
5629
|
}
|
|
5247
5630
|
//#endregion
|
|
5248
|
-
export {
|
|
5631
|
+
export { quoteIdentifier as $, assertExternalServices as A, prepareEntryCredentials as At, backendsOf as B, deployIdentity as Bt, isMainFrontendApp as C, primaryPortKey as Ct, ExternalServicesNotConfigured as D, isPortAvailable as Dt, DevServicesNeedServerTarget as E, findAvailablePort as Et, reportDevServices as F, getKeyPath as Ft, LOCAL_CA_PATH as G, ownershipMarker as Gt, derivedContainers as H, imageRef as Ht, encryptSecrets as I, keystoreProject as It, applyPolicies as J, maskPassword as Jt, forgetState as K, projectName as Kt, encryption_exports as L, projectKey as Lt, externalServices as M, secretsStoreFor as Mt, manifestServiceDeclarations as N, store_exports as Nt, STORAGE_KEY_PAIR as O, loadEnvFiles as Ot, parseDevServices as P, FileSecretsStore as Pt, postgresStatements as Q, keyFingerprint as R, readKey as Rt, NoDomainForStage as S, portKeys as St, DEV_SERVICES as T, createEntryWrapper as Tt, reconcileWorkspace as U, isOwnedBy as Ut, constructGlobs as V, imageName as Vt, workspace_exports as W, markerOf as Wt, bucketPolicies as X, toEmbeddableSecrets as Xt, applyPostgres as Y, storage_exports as Yt, postgresDatabaseNames as Z, withCustomSecret as Zt, deployDocker as _, providerOf as _t, recordRelease as a, envFor as at, ensureDockerignore as b, describeServices as bt, setBackupState as c, resourceName as ct, setGeneratedSecret as d, imageLayout as dt, composeFor as et, setPostgresBackupId as f, TURBO_VERSION as ft, credentialsFileContent as g, eventsBackendFor as gt, credentialsBuildArg as h, cacheBackendFor as ht, isDnsVerified as i, networkEnv as it, devServicesUsed as j, isRemoteStore as jt, UnknownDevService as k, loadSecretsForApp as kt, setDnsRecord as l, hostFor as lt, withGeneratedSecrets as m, findBuildRoot as mt, getBackupState as n, pgClient as nt, recordRollback as o, localRolePassword as ot, DeployJournal as p, detectPackageManager as pt, applyBuckets as q, initStageSecrets as qt, getGeneratedSecret as r, appEnvKeys as rt, setApplicationId as s, planFor as st, createEmptyState as t, bucketClient as tt, setDnsVerification as u, composeBuildPaths as ut, appDockerfile as v, storageBackendFor as vt, resolveHost as w, createCredentialsPreload as wt, validateImageRef as x, fakeKey as xt, dockerCommand as y, DEFAULT_IMAGES as yt, FAKE_ENV as z, applicationName as zt };
|
|
5249
5632
|
|
|
5250
|
-
//# sourceMappingURL=state-
|
|
5633
|
+
//# sourceMappingURL=state-Bn5JDMFT.mjs.map
|