okengine 0.8.0 → 0.9.0
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/package.json +3 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +3 -1
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +34 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +2 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -2
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/environment-variables.mdx +9 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +4 -0
- package/src/cli/load-config.ts +3 -0
- package/src/cli/registry.ts +1 -1
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +1 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +152 -114
- package/src/elements/channel.ts +12 -2
- package/src/index.ts +3 -0
- package/src/kernel/app.ts +60 -4
- package/src/kernel/boot-bind/channel.ts +51 -0
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +18 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +5 -5
- package/src/kernel/fx.test.ts +3 -0
- package/src/kernel/fx.ts +49 -0
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +8 -0
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +19 -29
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +4 -6
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -172
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -206
package/src/docker/compose.ts
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* 2. `compose.<role>.yml` — per-role services (generated)
|
|
6
6
|
* 3. `compose.prod.yml` — prod overlays (generated when `--prod`)
|
|
7
7
|
* 4. `compose.override.yml` — user-owned; oke never writes it
|
|
8
|
+
*
|
|
9
|
+
* Also emits {@link COMPOSE_ALL} — layers 1–3 deep-merged into one file
|
|
10
|
+
* (for Swarm `stack deploy -c` and single-file preferrers). Not part of the
|
|
11
|
+
* `-f` merge order; never includes layer 4.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import { defaultHostPort, envPrefix, serviceNameFor, toYaml } from "./helpers.ts";
|
|
@@ -24,6 +28,12 @@ import { APP_PORT } from "../runtime/types.ts";
|
|
|
24
28
|
/** Canonical layer-4 filename — never written by derivation. */
|
|
25
29
|
export const COMPOSE_OVERRIDE = "compose.override.yml";
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Fully merged compose (layers 1–3) — additive single-file alternative.
|
|
33
|
+
* Does not replace or shadow the base {@code compose.yml} layer.
|
|
34
|
+
*/
|
|
35
|
+
export const COMPOSE_ALL = "compose.all.yml";
|
|
36
|
+
|
|
27
37
|
/**
|
|
28
38
|
* Relative path refs for compose files living under {@link DeriveOptions.composeDir}.
|
|
29
39
|
*
|
|
@@ -85,6 +95,8 @@ export function emitComposeLayers(
|
|
|
85
95
|
const includeApp = options.includeApp !== false;
|
|
86
96
|
const paths = composePathRefs(options.composeDir ?? DEFAULT_DOCKER_DIR);
|
|
87
97
|
const files: GeneratedFile[] = [];
|
|
98
|
+
/** Layers 1–3 as objects — same merge Compose would apply via `-f` order. */
|
|
99
|
+
const mergeLayers: Record<string, unknown>[] = [];
|
|
88
100
|
|
|
89
101
|
// Layer 1 — project name + network (+ optional app for deploy / oke docker)
|
|
90
102
|
const base: Record<string, unknown> = {
|
|
@@ -92,21 +104,29 @@ export function emitComposeLayers(
|
|
|
92
104
|
networks: { oke: { driver: "bridge" } },
|
|
93
105
|
};
|
|
94
106
|
if (includeApp) {
|
|
107
|
+
// `image` + `build`: local compose builds and tags; Swarm stack deploy
|
|
108
|
+
// ignores `build` and pulls/uses the pre-built tag.
|
|
109
|
+
// When an opt-in `proxy` role is present, the edge publishes 80/443 —
|
|
110
|
+
// leave `app` unpublished so `docker compose up --scale app=N` works.
|
|
111
|
+
const hasProxy = specs.some((s) => s.role === "proxy");
|
|
112
|
+
const backendDeps = specs.filter((s) => s.role !== "proxy");
|
|
95
113
|
base.services = {
|
|
96
114
|
app: {
|
|
115
|
+
image: `oke-${app}:latest`,
|
|
97
116
|
build: {
|
|
98
117
|
context: paths.buildContext,
|
|
99
118
|
dockerfile: paths.dockerfile,
|
|
100
119
|
},
|
|
101
|
-
ports: [`${appPort}:${appPort}`],
|
|
120
|
+
...(hasProxy ? {} : { ports: [`${appPort}:${appPort}`] }),
|
|
102
121
|
env_file: [paths.envFile],
|
|
103
122
|
depends_on: Object.fromEntries(
|
|
104
|
-
|
|
123
|
+
backendDeps.map((s) => [s.serviceName, { condition: "service_healthy" }]),
|
|
105
124
|
),
|
|
106
125
|
networks: ["oke"],
|
|
107
126
|
},
|
|
108
127
|
};
|
|
109
128
|
}
|
|
129
|
+
mergeLayers.push(base);
|
|
110
130
|
files.push({ path: "compose.yml", content: `${toYaml(base)}\n` });
|
|
111
131
|
|
|
112
132
|
// Layer 2 — per-role
|
|
@@ -134,26 +154,67 @@ export function emitComposeLayers(
|
|
|
134
154
|
if (applied.healthcheck) service.healthcheck = applied.healthcheck;
|
|
135
155
|
if (applied.volumes) service.volumes = applied.volumes;
|
|
136
156
|
if (applied.user) service.user = applied.user;
|
|
157
|
+
if (applied.ulimits) service.ulimits = applied.ulimits;
|
|
158
|
+
if (applied.labels) service.labels = applied.labels;
|
|
159
|
+
if (applied.dependsOn) {
|
|
160
|
+
const deps = { ...applied.dependsOn };
|
|
161
|
+
if (!includeApp) delete deps.app;
|
|
162
|
+
if (Object.keys(deps).length > 0) service.depends_on = deps;
|
|
163
|
+
}
|
|
137
164
|
|
|
138
|
-
const namedVolumes = namedVolumeDecls(
|
|
165
|
+
const namedVolumes = namedVolumeDecls([
|
|
166
|
+
...(applied.volumes ?? []),
|
|
167
|
+
...extraServiceVolumes(applied.services),
|
|
168
|
+
]);
|
|
139
169
|
const doc: Record<string, unknown> = {
|
|
140
|
-
services: { [spec.serviceName]: service },
|
|
170
|
+
services: { [spec.serviceName]: service, ...applied.services },
|
|
141
171
|
networks: { oke: { external: false } },
|
|
142
172
|
};
|
|
143
173
|
if (Object.keys(namedVolumes).length > 0) {
|
|
144
174
|
doc.volumes = namedVolumes;
|
|
145
175
|
}
|
|
176
|
+
mergeLayers.push(doc);
|
|
146
177
|
const path = `compose.${spec.role}.yml`;
|
|
147
178
|
files.push({ path, content: `${toYaml(doc)}\n` });
|
|
148
179
|
}
|
|
149
180
|
|
|
150
|
-
// Layer 3 — prod overlay
|
|
181
|
+
// Layer 3 — prod overlay (Swarm-aware deploy + app readiness healthcheck)
|
|
151
182
|
if (options.prod) {
|
|
152
183
|
const prodServices: Record<string, unknown> = {};
|
|
153
184
|
if (includeApp) {
|
|
154
185
|
prodServices.app = {
|
|
186
|
+
// Single Docker HEALTHCHECK — Swarm has no separate readiness/liveness.
|
|
187
|
+
// Prefer kernel readiness so the routing mesh and rolling updates wait
|
|
188
|
+
// out booting / orphan_scan (see GET /_/ready). App GET /health stays
|
|
189
|
+
// for external monitors only.
|
|
190
|
+
healthcheck: {
|
|
191
|
+
test: [
|
|
192
|
+
"CMD",
|
|
193
|
+
"bun",
|
|
194
|
+
"-e",
|
|
195
|
+
`fetch("http://127.0.0.1:${appPort}/_/ready").then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))`,
|
|
196
|
+
],
|
|
197
|
+
interval: "10s",
|
|
198
|
+
timeout: "3s",
|
|
199
|
+
retries: 3,
|
|
200
|
+
start_period: "60s",
|
|
201
|
+
},
|
|
202
|
+
// Match installGracefulShutdown lease release window (≥ Signal TTL).
|
|
203
|
+
stop_grace_period: "30s",
|
|
155
204
|
deploy: {
|
|
156
205
|
replicas: 1,
|
|
206
|
+
update_config: {
|
|
207
|
+
parallelism: 1,
|
|
208
|
+
delay: "10s",
|
|
209
|
+
failure_action: "rollback",
|
|
210
|
+
order: "start-first",
|
|
211
|
+
},
|
|
212
|
+
restart_policy: {
|
|
213
|
+
condition: "on-failure",
|
|
214
|
+
delay: "5s",
|
|
215
|
+
max_attempts: 3,
|
|
216
|
+
window: "120s",
|
|
217
|
+
},
|
|
157
218
|
resources: {
|
|
158
219
|
limits: { cpus: "1.0", memory: "512M" },
|
|
159
220
|
},
|
|
@@ -171,12 +232,21 @@ export function emitComposeLayers(
|
|
|
171
232
|
secrets: secretNames(spec),
|
|
172
233
|
};
|
|
173
234
|
}
|
|
235
|
+
const prodDoc = { services: prodServices };
|
|
236
|
+
mergeLayers.push(prodDoc);
|
|
174
237
|
files.push({
|
|
175
238
|
path: "compose.prod.yml",
|
|
176
|
-
content: `${toYaml(
|
|
239
|
+
content: `${toYaml(prodDoc)}\n`,
|
|
177
240
|
});
|
|
178
241
|
}
|
|
179
242
|
|
|
243
|
+
// Additive single-file merge of layers 1–3 (not in `-f` order; no layer 4).
|
|
244
|
+
const merged = mergeLayers.reduce<Record<string, unknown>>(
|
|
245
|
+
(acc, layer) => deepMergeCompose(acc, layer),
|
|
246
|
+
{},
|
|
247
|
+
);
|
|
248
|
+
files.push({ path: COMPOSE_ALL, content: `${toYaml(merged)}\n` });
|
|
249
|
+
|
|
180
250
|
// Layer 4 — never written. Document merge order only.
|
|
181
251
|
const composeFiles = [
|
|
182
252
|
"compose.yml",
|
|
@@ -188,6 +258,33 @@ export function emitComposeLayers(
|
|
|
188
258
|
return { files, composeFiles };
|
|
189
259
|
}
|
|
190
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Deep-merge compose documents the way Docker Compose merges `-f` overlays:
|
|
263
|
+
* maps recurse; sequences and scalars from the later document replace earlier.
|
|
264
|
+
*
|
|
265
|
+
* @param base - Earlier layer
|
|
266
|
+
* @param overlay - Later layer
|
|
267
|
+
*/
|
|
268
|
+
export function deepMergeCompose(
|
|
269
|
+
base: Record<string, unknown>,
|
|
270
|
+
overlay: Record<string, unknown>,
|
|
271
|
+
): Record<string, unknown> {
|
|
272
|
+
const out: Record<string, unknown> = { ...base };
|
|
273
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
274
|
+
const prev = out[key];
|
|
275
|
+
if (isPlainObject(prev) && isPlainObject(value)) {
|
|
276
|
+
out[key] = deepMergeCompose(prev, value);
|
|
277
|
+
} else {
|
|
278
|
+
out[key] = value;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
285
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
286
|
+
}
|
|
287
|
+
|
|
191
288
|
/**
|
|
192
289
|
* Build docker env key/value map (credentials + connection URLs).
|
|
193
290
|
*
|
|
@@ -227,6 +324,19 @@ export function buildStackEnv(
|
|
|
227
324
|
env[`${prefix}_DB`] = spec.credentials.database;
|
|
228
325
|
env[`${prefix}_URL`] = url;
|
|
229
326
|
env.DATABASE_URL = url;
|
|
327
|
+
} else if (spec.role === "pgdog") {
|
|
328
|
+
// URL uses store.sql credentials (same user/db the pooler proxies).
|
|
329
|
+
const sql = specs.find((s) => s.role === "store.sql");
|
|
330
|
+
const creds = sql?.credentials ?? spec.credentials;
|
|
331
|
+
const poolUrl = recipe.url(spec, {
|
|
332
|
+
host,
|
|
333
|
+
port: spec.hostPort,
|
|
334
|
+
user: creds.user,
|
|
335
|
+
password: creds.password,
|
|
336
|
+
database: creds.database,
|
|
337
|
+
});
|
|
338
|
+
env[`${prefix}_URL`] = poolUrl;
|
|
339
|
+
env.OKE_PGDOG_URL = poolUrl;
|
|
230
340
|
} else if (spec.role === "store.kv") {
|
|
231
341
|
env[`${prefix}_PASSWORD`] = spec.credentials.password;
|
|
232
342
|
env[`${prefix}_URL`] = url;
|
|
@@ -259,6 +369,10 @@ export function buildStackEnv(
|
|
|
259
369
|
// Ollama: standalone HTTP URL; model is a stack control (OKE_AI_MODEL).
|
|
260
370
|
env[`${prefix}_URL`] = url;
|
|
261
371
|
env.OKE_AI_URL = url;
|
|
372
|
+
} else if (spec.role === "proxy") {
|
|
373
|
+
// Edge TLS terminator — host is the public URL, not a driver DSN.
|
|
374
|
+
env[`${prefix}_URL`] = url;
|
|
375
|
+
env.OKE_PROXY_URL = url;
|
|
262
376
|
} else {
|
|
263
377
|
env[`${prefix}_USER`] = spec.credentials.user;
|
|
264
378
|
env[`${prefix}_PASSWORD`] = spec.credentials.password;
|
|
@@ -266,6 +380,21 @@ export function buildStackEnv(
|
|
|
266
380
|
env[`${prefix}_URL`] = url;
|
|
267
381
|
}
|
|
268
382
|
}
|
|
383
|
+
// When PgDog sits in front of Postgres, apps talk to the pooler — wire-protocol
|
|
384
|
+
// transparent; zero application changes (Bun.SQL / Drizzle / any Postgres client).
|
|
385
|
+
const sqlSpec = specs.find((s) => s.role === "store.sql");
|
|
386
|
+
const pgdogSpec = specs.find((s) => s.role === "pgdog");
|
|
387
|
+
if (sqlSpec && pgdogSpec) {
|
|
388
|
+
const poolRecipe = recipeFor(pgdogSpec.image, recipes);
|
|
389
|
+
env.DATABASE_URL = poolRecipe.url(pgdogSpec, {
|
|
390
|
+
host,
|
|
391
|
+
port: pgdogSpec.hostPort,
|
|
392
|
+
user: sqlSpec.credentials.user,
|
|
393
|
+
password: sqlSpec.credentials.password,
|
|
394
|
+
database: sqlSpec.credentials.database,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
269
398
|
for (const key of STACK_CONTROL_KEYS) {
|
|
270
399
|
const value = controls[key];
|
|
271
400
|
if (value !== undefined) env[key] = value;
|
|
@@ -276,6 +405,7 @@ export function buildStackEnv(
|
|
|
276
405
|
/** Friendly section titles for known compose roles. */
|
|
277
406
|
const ROLE_SECTION_TITLE: Readonly<Record<string, string>> = {
|
|
278
407
|
"store.sql": "store.sql — Postgres",
|
|
408
|
+
pgdog: "pgdog — connection pooler (in front of Postgres)",
|
|
279
409
|
"store.kv": "store.kv — Redis",
|
|
280
410
|
"store.files": "store.files — object storage (S3)",
|
|
281
411
|
"store.index": "store.index — search index",
|
|
@@ -283,11 +413,13 @@ const ROLE_SECTION_TITLE: Readonly<Record<string, string>> = {
|
|
|
283
413
|
signal: "signal — message bus",
|
|
284
414
|
vault: "vault — OpenBao",
|
|
285
415
|
ai: "ai — Ollama (local models)",
|
|
416
|
+
proxy: "proxy — TLS terminator (Caddy / Traefik)",
|
|
286
417
|
};
|
|
287
418
|
|
|
288
419
|
/** Friendly aliases emitted beside their role block. */
|
|
289
420
|
const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
290
421
|
"store.sql": ["DATABASE_URL", "PGDATA", "POSTGRES_INITDB_ARGS"],
|
|
422
|
+
pgdog: ["OKE_PGDOG_URL"],
|
|
291
423
|
"store.kv": ["REDIS_URL", "OKE_STORE_KV_MAXMEMORY", "OKE_STORE_KV_MAXMEMORY_POLICY"],
|
|
292
424
|
"store.files": [
|
|
293
425
|
"S3_ACCESS_KEY_ID",
|
|
@@ -311,6 +443,7 @@ const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
|
311
443
|
"MP_SMTP_AUTH_ALLOW_INSECURE",
|
|
312
444
|
],
|
|
313
445
|
ai: ["OKE_AI_URL", "OKE_AI_MODEL"],
|
|
446
|
+
proxy: ["OKE_PROXY_HOST", "OKE_PROXY_ACME_EMAIL"],
|
|
314
447
|
};
|
|
315
448
|
|
|
316
449
|
/** Optional controls documented in `.env.docker` and preserved on regeneration. */
|
|
@@ -327,6 +460,7 @@ const ROLE_CONTROL_EXAMPLES: Readonly<Record<string, readonly string[]>> = {
|
|
|
327
460
|
],
|
|
328
461
|
// qwen3.5:9b is a balanced local-dev starting point — override freely.
|
|
329
462
|
ai: ["OKE_AI_MODEL=qwen3.5:9b"],
|
|
463
|
+
proxy: ["OKE_PROXY_HOST=localhost", "OKE_PROXY_ACME_EMAIL=admin@example.com"],
|
|
330
464
|
};
|
|
331
465
|
|
|
332
466
|
/**
|
|
@@ -344,6 +478,9 @@ function roleFromEnvKey(key: string): string | undefined {
|
|
|
344
478
|
if (key === "PGDATA" || key === "POSTGRES_INITDB_ARGS") return "store.sql";
|
|
345
479
|
if (key.startsWith("OKE_STORE_KV_MAXMEMORY")) return "store.kv";
|
|
346
480
|
if (key === "OKE_AI_URL" || key === "OKE_AI_MODEL" || key === "OLLAMA_HOST") return "ai";
|
|
481
|
+
if (key === "OKE_PROXY_URL" || key === "OKE_PROXY_HOST" || key === "OKE_PROXY_ACME_EMAIL") {
|
|
482
|
+
return "proxy";
|
|
483
|
+
}
|
|
347
484
|
return undefined;
|
|
348
485
|
}
|
|
349
486
|
|
|
@@ -451,6 +588,25 @@ function namedVolumeDecls(
|
|
|
451
588
|
return out;
|
|
452
589
|
}
|
|
453
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Volume mounts declared on companion services from {@link RecipeApplyResult.services}.
|
|
593
|
+
*
|
|
594
|
+
* @param services - Extra compose service fragments
|
|
595
|
+
*/
|
|
596
|
+
function extraServiceVolumes(
|
|
597
|
+
services: Readonly<Record<string, Record<string, unknown>>> | undefined,
|
|
598
|
+
): string[] {
|
|
599
|
+
const out: string[] = [];
|
|
600
|
+
for (const svc of Object.values(services ?? {})) {
|
|
601
|
+
const vols = svc.volumes;
|
|
602
|
+
if (!Array.isArray(vols)) continue;
|
|
603
|
+
for (const v of vols) {
|
|
604
|
+
if (typeof v === "string") out.push(v);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
|
|
454
610
|
function escapeEnv(value: string): string {
|
|
455
611
|
if (/[\s#"'$\\]/.test(value)) {
|
|
456
612
|
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
package/src/docker/derive.ts
CHANGED
|
@@ -11,14 +11,17 @@ import {
|
|
|
11
11
|
formatStackEnv,
|
|
12
12
|
} from "./compose.ts";
|
|
13
13
|
import { emitDockerfile } from "./dockerfile.ts";
|
|
14
|
+
import { buildCaddyfile } from "./recipes/caddy.ts";
|
|
15
|
+
import { buildPgDogToml, buildPgDogUsersToml } from "./recipes/pgdog.ts";
|
|
14
16
|
import type { DeriveOptions, DeriveResult, GeneratedFile } from "./types.ts";
|
|
15
17
|
import { DEFAULT_DOCKER_DIR } from "./types.ts";
|
|
18
|
+
import { APP_PORT } from "../runtime/types.ts";
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Derive infrastructure files from normalised image pins.
|
|
19
22
|
*
|
|
20
|
-
* Credentials land only in the returned `stackEnv` (for `.env.docker`)
|
|
21
|
-
*
|
|
23
|
+
* Credentials land only in the returned `stackEnv` (for `.env.docker`) and
|
|
24
|
+
* in `users.toml` when PgDog is present — never in generated YAML. Layer 4
|
|
22
25
|
* (`compose.override.yml`) is listed in `composeFiles` but never written.
|
|
23
26
|
*
|
|
24
27
|
* @param options - Images / app / prod flag
|
|
@@ -43,7 +46,12 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
|
|
|
43
46
|
content: emitDockerfile({ appPort: normalised.appPort }),
|
|
44
47
|
};
|
|
45
48
|
// Always emit Dockerfile for deploy; stack-only runs ignore it.
|
|
46
|
-
const files = [
|
|
49
|
+
const files = [
|
|
50
|
+
dockerfile,
|
|
51
|
+
...composeFilesContent,
|
|
52
|
+
...pgdogConfigFiles(specs),
|
|
53
|
+
...proxyConfigFiles(specs, normalised.appPort),
|
|
54
|
+
];
|
|
47
55
|
|
|
48
56
|
for (const f of files) {
|
|
49
57
|
if (f.path.endsWith(".yml") || f.path === "Dockerfile") {
|
|
@@ -65,6 +73,55 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
|
|
|
65
73
|
return { specs, files, stackEnv, composeFiles };
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Emit PgDog TOML configs when both `pgdog` and `store.sql` are in the stack.
|
|
78
|
+
*
|
|
79
|
+
* `pgdog.toml` has no secrets; `users.toml` mirrors store.sql credentials
|
|
80
|
+
* (same trust boundary as `.env.docker` — do not commit).
|
|
81
|
+
*
|
|
82
|
+
* @param specs - Normalised services
|
|
83
|
+
*/
|
|
84
|
+
function pgdogConfigFiles(specs: DeriveResult["specs"]): GeneratedFile[] {
|
|
85
|
+
const sql = specs.find((s) => s.role === "store.sql");
|
|
86
|
+
const pooler = specs.find((s) => s.role === "pgdog");
|
|
87
|
+
if (!sql || !pooler) return [];
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
path: "pgdog.toml",
|
|
91
|
+
content: buildPgDogToml({ database: sql.credentials.database }),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
path: "users.toml",
|
|
95
|
+
content: buildPgDogUsersToml({
|
|
96
|
+
user: sql.credentials.user,
|
|
97
|
+
password: sql.credentials.password,
|
|
98
|
+
database: sql.credentials.database,
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Emit proxy companion configs when `images.proxy` is pinned.
|
|
106
|
+
*
|
|
107
|
+
* Caddy gets a generated `Caddyfile`. Traefik configures via Docker labels
|
|
108
|
+
* (no companion file).
|
|
109
|
+
*
|
|
110
|
+
* @param specs - Normalised services
|
|
111
|
+
* @param appPort - App listen port (default 6530)
|
|
112
|
+
*/
|
|
113
|
+
function proxyConfigFiles(
|
|
114
|
+
specs: DeriveResult["specs"],
|
|
115
|
+
appPort: number | undefined,
|
|
116
|
+
): GeneratedFile[] {
|
|
117
|
+
const proxy = specs.find((s) => s.role === "proxy");
|
|
118
|
+
if (!proxy) return [];
|
|
119
|
+
if (/caddy/i.test(proxy.image)) {
|
|
120
|
+
return [{ path: "Caddyfile", content: buildCaddyfile({ appPort: appPort ?? APP_PORT }) }];
|
|
121
|
+
}
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
68
125
|
/**
|
|
69
126
|
* Write derived files to disk. Never writes `compose.override.yml` or
|
|
70
127
|
* credential values into YAML. Optionally writes `docker/.env.docker`.
|