okengine 0.7.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 +25 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +165 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- 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 +44 -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 -1
- package/site/content/docs/reference/cli.md +5 -2
- package/site/content/docs/reference/configuration.mdx +21 -3
- package/site/content/docs/reference/environment-variables.mdx +21 -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/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +26 -0
- package/src/cli/load-config.ts +10 -2
- package/src/cli/registry.ts +38 -2
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- 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-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +72 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +18 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -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 +203 -114
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/channel.ts +12 -2
- package/src/elements/clock/chaos-child.ts +280 -41
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +2 -2
- package/src/elements/clock/runtime.ts +5 -3
- package/src/elements/clock.ts +1 -1
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +5 -0
- package/src/index.ts +18 -0
- package/src/kernel/app.ts +221 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +64 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +123 -4
- package/src/kernel/boot-bind/journal.ts +89 -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 +6 -4
- package/src/kernel/boot.ts +53 -13
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/fx.test.ts +9 -0
- package/src/kernel/fx.ts +175 -5
- 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 +14 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- 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 +20 -27
- 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 +172 -0
- 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 -111
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -149
package/src/cli/registry.ts
CHANGED
|
@@ -202,7 +202,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
|
|
|
202
202
|
},
|
|
203
203
|
{
|
|
204
204
|
name: "db",
|
|
205
|
-
summary: "domain schema — push · generate · migrate",
|
|
205
|
+
summary: "domain schema — push · generate · migrate · seed",
|
|
206
206
|
subcommands: [
|
|
207
207
|
{
|
|
208
208
|
name: "push",
|
|
@@ -215,6 +215,12 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
|
|
|
215
215
|
valueName: "path",
|
|
216
216
|
summary: "drizzle.config.ts path",
|
|
217
217
|
},
|
|
218
|
+
{
|
|
219
|
+
long: "--env",
|
|
220
|
+
takesValue: true,
|
|
221
|
+
valueName: "name",
|
|
222
|
+
summary: "Config env (local|docker|test|prod)",
|
|
223
|
+
},
|
|
218
224
|
HELP,
|
|
219
225
|
],
|
|
220
226
|
},
|
|
@@ -229,6 +235,12 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
|
|
|
229
235
|
valueName: "path",
|
|
230
236
|
summary: "drizzle.config.ts path",
|
|
231
237
|
},
|
|
238
|
+
{
|
|
239
|
+
long: "--env",
|
|
240
|
+
takesValue: true,
|
|
241
|
+
valueName: "name",
|
|
242
|
+
summary: "Config env (local|docker|test|prod)",
|
|
243
|
+
},
|
|
232
244
|
HELP,
|
|
233
245
|
],
|
|
234
246
|
},
|
|
@@ -243,6 +255,30 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
|
|
|
243
255
|
valueName: "path",
|
|
244
256
|
summary: "drizzle.config.ts path",
|
|
245
257
|
},
|
|
258
|
+
{
|
|
259
|
+
long: "--env",
|
|
260
|
+
takesValue: true,
|
|
261
|
+
valueName: "name",
|
|
262
|
+
summary: "Config env (local|docker|test|prod)",
|
|
263
|
+
},
|
|
264
|
+
HELP,
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: "seed",
|
|
269
|
+
summary: "Run defineSeed (essential + env category); never at boot",
|
|
270
|
+
flags: [
|
|
271
|
+
{
|
|
272
|
+
long: "--env",
|
|
273
|
+
takesValue: true,
|
|
274
|
+
valueName: "name",
|
|
275
|
+
summary: "Config env (local|docker|test|prod)",
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
long: "--force",
|
|
279
|
+
summary: "Skip docker/prod confirmation prompt",
|
|
280
|
+
},
|
|
281
|
+
ENTRY,
|
|
246
282
|
HELP,
|
|
247
283
|
],
|
|
248
284
|
},
|
|
@@ -293,7 +329,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
|
|
|
293
329
|
{
|
|
294
330
|
long: "--prod",
|
|
295
331
|
short: "-p",
|
|
296
|
-
summary: "Prod overlays (
|
|
332
|
+
summary: "Prod overlays (app /_/ready healthcheck, deploy, limits)",
|
|
297
333
|
},
|
|
298
334
|
OUT,
|
|
299
335
|
CONFIG,
|
package/src/config/index.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface DriversConfig {
|
|
|
73
73
|
readonly store?: StoreDriversConfig;
|
|
74
74
|
readonly signal?: EnvDriverMap;
|
|
75
75
|
readonly clock?: EnvDriverMap;
|
|
76
|
+
/** Durable-run journal: `memory` · `file` · `postgres`. */
|
|
77
|
+
readonly journal?: EnvDriverMap;
|
|
76
78
|
readonly vault?: EnvDriverMap;
|
|
77
79
|
readonly channel?: ChannelDriversConfig;
|
|
78
80
|
readonly ai?: EnvDriverMap;
|
|
@@ -283,6 +285,7 @@ export function normalizeDriversConfig(
|
|
|
283
285
|
...(store !== undefined ? { store } : {}),
|
|
284
286
|
signal: normalizeEnvDriverMap(drivers.signal as RawEnvDriverMap),
|
|
285
287
|
clock: normalizeEnvDriverMap(drivers.clock as RawEnvDriverMap),
|
|
288
|
+
journal: normalizeEnvDriverMap(drivers.journal as RawEnvDriverMap),
|
|
286
289
|
vault: normalizeEnvDriverMap(drivers.vault as RawEnvDriverMap),
|
|
287
290
|
...(channel !== undefined ? { channel } : {}),
|
|
288
291
|
ai: normalizeEnvDriverMap(drivers.ai as RawEnvDriverMap),
|
|
@@ -333,6 +336,7 @@ export function fillDriversDockerFromProd(
|
|
|
333
336
|
...(store !== undefined ? { store } : {}),
|
|
334
337
|
signal: fillDockerFromProd(drivers.signal),
|
|
335
338
|
clock: fillDockerFromProd(drivers.clock),
|
|
339
|
+
journal: fillDockerFromProd(drivers.journal),
|
|
336
340
|
vault: fillDockerFromProd(drivers.vault),
|
|
337
341
|
...(channel !== undefined ? { channel } : {}),
|
|
338
342
|
ai: fillDockerFromProd(drivers.ai),
|
|
@@ -158,7 +158,9 @@ export function migrateOperatorSchema(db: Database): void {
|
|
|
158
158
|
revoked_at INTEGER,
|
|
159
159
|
created_at INTEGER NOT NULL,
|
|
160
160
|
expires_at INTEGER NOT NULL,
|
|
161
|
-
last_active_at INTEGER
|
|
161
|
+
last_active_at INTEGER,
|
|
162
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
163
|
+
audience TEXT
|
|
162
164
|
);
|
|
163
165
|
CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.refreshTokens} (
|
|
164
166
|
id TEXT PRIMARY KEY NOT NULL,
|
|
@@ -170,20 +172,28 @@ export function migrateOperatorSchema(db: Database): void {
|
|
|
170
172
|
revoked_at INTEGER
|
|
171
173
|
);
|
|
172
174
|
`);
|
|
173
|
-
|
|
175
|
+
ensureSessionColumns(db);
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
/**
|
|
177
|
-
* Add
|
|
179
|
+
* Add session columns when opening an older console.sqlite.
|
|
178
180
|
*
|
|
179
181
|
* @param db - Open database
|
|
180
182
|
*/
|
|
181
|
-
function
|
|
183
|
+
function ensureSessionColumns(db: Database): void {
|
|
182
184
|
const cols = db.query(`PRAGMA table_info(${AUTH_TABLES.sessions})`).all() as Array<{
|
|
183
185
|
name: string;
|
|
184
186
|
}>;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
+
const names = new Set(cols.map((c) => c.name));
|
|
188
|
+
if (!names.has("last_active_at")) {
|
|
189
|
+
db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN last_active_at INTEGER`);
|
|
190
|
+
}
|
|
191
|
+
if (!names.has("scopes")) {
|
|
192
|
+
db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN scopes TEXT NOT NULL DEFAULT '[]'`);
|
|
193
|
+
}
|
|
194
|
+
if (!names.has("audience")) {
|
|
195
|
+
db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN audience TEXT`);
|
|
196
|
+
}
|
|
187
197
|
}
|
|
188
198
|
|
|
189
199
|
/**
|
|
@@ -196,7 +206,7 @@ export function loadSessionStore(db: Database): SessionStore {
|
|
|
196
206
|
|
|
197
207
|
const sessionRows = db
|
|
198
208
|
.query(
|
|
199
|
-
`SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at
|
|
209
|
+
`SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at, scopes, audience
|
|
200
210
|
FROM ${AUTH_TABLES.sessions}`,
|
|
201
211
|
)
|
|
202
212
|
.all() as Array<{
|
|
@@ -208,9 +218,20 @@ export function loadSessionStore(db: Database): SessionStore {
|
|
|
208
218
|
created_at: number;
|
|
209
219
|
expires_at: number;
|
|
210
220
|
last_active_at: number | null;
|
|
221
|
+
scopes: string | null;
|
|
222
|
+
audience: string | null;
|
|
211
223
|
}>;
|
|
212
224
|
|
|
213
225
|
for (const row of sessionRows) {
|
|
226
|
+
let scopes: string[] = [];
|
|
227
|
+
if (row.scopes) {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(row.scopes) as unknown;
|
|
230
|
+
if (Array.isArray(parsed)) scopes = parsed.map(String);
|
|
231
|
+
} catch {
|
|
232
|
+
scopes = [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
214
235
|
const session: SessionRow = {
|
|
215
236
|
id: row.id,
|
|
216
237
|
plane: row.plane,
|
|
@@ -220,6 +241,8 @@ export function loadSessionStore(db: Database): SessionStore {
|
|
|
220
241
|
createdAt: row.created_at,
|
|
221
242
|
expiresAt: row.expires_at,
|
|
222
243
|
lastActiveAt: row.last_active_at ?? row.created_at,
|
|
244
|
+
scopes,
|
|
245
|
+
...(row.audience ? { audience: row.audience } : {}),
|
|
223
246
|
};
|
|
224
247
|
store.sessions.set(session.id, session);
|
|
225
248
|
}
|
|
@@ -267,8 +290,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
|
|
|
267
290
|
|
|
268
291
|
const insertSession = db.query(
|
|
269
292
|
`INSERT INTO ${AUTH_TABLES.sessions}
|
|
270
|
-
(id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at)
|
|
271
|
-
VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires, $lastActive)`,
|
|
293
|
+
(id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at, scopes, audience)
|
|
294
|
+
VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires, $lastActive, $scopes, $audience)`,
|
|
272
295
|
);
|
|
273
296
|
for (const session of store.sessions.values()) {
|
|
274
297
|
insertSession.run({
|
|
@@ -280,6 +303,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
|
|
|
280
303
|
$created: session.createdAt,
|
|
281
304
|
$expires: session.expiresAt,
|
|
282
305
|
$lastActive: session.lastActiveAt,
|
|
306
|
+
$scopes: JSON.stringify(session.scopes ?? []),
|
|
307
|
+
$audience: session.audience ?? null,
|
|
283
308
|
});
|
|
284
309
|
}
|
|
285
310
|
|
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`.
|