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.
Files changed (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -7,21 +7,50 @@ import { mkdtemp } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import {
10
+ COMPOSE_ALL,
10
11
  COMPOSE_OVERRIDE,
11
12
  assertNoCredentialsInYaml,
13
+ buildCaddyfile,
14
+ buildPgDogToml,
15
+ buildPgDogUsersToml,
12
16
  builtinRecipes,
17
+ caddy,
13
18
  deriveInfrastructure,
19
+ dragonfly,
14
20
  emitDockerfile,
15
21
  formatStackEnv,
22
+ pgdog,
16
23
  postgres,
17
24
  recipeFor,
18
25
  redis,
19
26
  resolveStack,
27
+ SOCKET_PROXY_IMAGE,
28
+ SOCKET_PROXY_SERVICE,
29
+ traefik,
30
+ traefikAppLabels,
31
+ valkey,
20
32
  writeDerivedFiles,
21
33
  type ImageRecipe,
22
34
  type ServiceSpec,
23
35
  } from "./index.ts";
24
36
 
37
+ /** Service keys under a top-level `services:` mapping (2-space indent). */
38
+ function serviceNamesFromComposeYaml(yml: string): Set<string> {
39
+ const names = new Set<string>();
40
+ let inServices = false;
41
+ for (const line of yml.split("\n")) {
42
+ if (line === "services:") {
43
+ inServices = true;
44
+ continue;
45
+ }
46
+ if (!inServices) continue;
47
+ if (/^[A-Za-z]/.test(line)) break;
48
+ const m = /^ ([A-Za-z0-9_-]+):\s*$/.exec(line);
49
+ if (m?.[1]) names.add(m[1]);
50
+ }
51
+ return names;
52
+ }
53
+
25
54
  const fixedCreds = {
26
55
  "store.sql": {
27
56
  user: "oke",
@@ -38,9 +67,129 @@ const fixedCreds = {
38
67
  describe("image recipes", () => {
39
68
  test("postgres and redis match vendor images by protocol", () => {
40
69
  expect(recipeFor("pgvector/pgvector:pg17").id).toBe("postgres");
41
- expect(recipeFor("valkey/valkey:8-alpine").id).toBe("redis");
42
70
  expect(postgres.match("postgres:16")).toBe(true);
43
71
  expect(redis.match("redis:7")).toBe(true);
72
+ expect(redis.match("redis:8-alpine")).toBe(true);
73
+ expect(redis.match("valkey/valkey:8-alpine")).toBe(false);
74
+ });
75
+
76
+ test("valkey matches the official image, keeps redis:// URL, uses valkey-server", () => {
77
+ const image = "valkey/valkey:8-alpine";
78
+ expect(recipeFor(image).id).toBe("valkey");
79
+ expect(valkey.match(image)).toBe(true);
80
+ expect(redis.match(image)).toBe(false);
81
+ const spec: ServiceSpec = {
82
+ role: "store.kv",
83
+ serviceName: "store-kv",
84
+ image,
85
+ port: 6379,
86
+ hostPort: 6379,
87
+ credentials: fixedCreds["store.kv"],
88
+ };
89
+ const applied = recipeFor(spec.image).apply(spec);
90
+ expect(String(applied.command)).toContain("exec valkey-server");
91
+ expect(String(applied.command)).toContain("$$OKE_STORE_KV_PASSWORD");
92
+ expect(applied.healthcheck?.test[1]).toBe("valkey-cli");
93
+ const url = recipeFor(spec.image).url(spec, {
94
+ host: "127.0.0.1",
95
+ port: 6379,
96
+ user: "oke",
97
+ password: fixedCreds["store.kv"].password,
98
+ database: "oke",
99
+ });
100
+ expect(url).toBe(
101
+ `redis://:${encodeURIComponent(fixedCreds["store.kv"].password)}@127.0.0.1:6379`,
102
+ );
103
+
104
+ const derived = deriveInfrastructure({
105
+ images: { "store.kv": image },
106
+ credentials: { "store.kv": fixedCreds["store.kv"] },
107
+ });
108
+ const kvYml = derived.files.find((f) => f.path === "compose.store.kv.yml")?.content ?? "";
109
+ expect(kvYml).toContain(image);
110
+ expect(kvYml).toContain("valkey-server");
111
+ expect(derived.stackEnv.REDIS_URL).toContain("redis://");
112
+ });
113
+
114
+ test("dragonfly matches the official image, keeps redis:// URL, distinct from redis recipe", () => {
115
+ const image = "docker.dragonflydb.io/dragonflydb/dragonfly";
116
+ expect(recipeFor(image).id).toBe("dragonfly");
117
+ expect(dragonfly.match(image)).toBe(true);
118
+ expect(redis.match(image)).toBe(false);
119
+ const spec: ServiceSpec = {
120
+ role: "store.kv",
121
+ serviceName: "store-kv",
122
+ image,
123
+ port: 6379,
124
+ hostPort: 6379,
125
+ credentials: fixedCreds["store.kv"],
126
+ };
127
+ const applied = recipeFor(spec.image).apply(spec);
128
+ expect(String(applied.command)).toContain("exec dragonfly");
129
+ expect(String(applied.command)).toContain("$$OKE_STORE_KV_PASSWORD");
130
+ expect(applied.ulimits?.memlock).toBe(-1);
131
+ expect(applied.environment?.HEALTHCHECK_PORT).toBe("6379");
132
+ expect(applied.healthcheck?.test).toEqual(["CMD", "/usr/local/bin/healthcheck.sh"]);
133
+ const url = recipeFor(spec.image).url(spec, {
134
+ host: "127.0.0.1",
135
+ port: 6379,
136
+ user: "oke",
137
+ password: fixedCreds["store.kv"].password,
138
+ database: "oke",
139
+ });
140
+ expect(url).toBe(
141
+ `redis://:${encodeURIComponent(fixedCreds["store.kv"].password)}@127.0.0.1:6379`,
142
+ );
143
+
144
+ const derived = deriveInfrastructure({
145
+ images: { "store.kv": image },
146
+ credentials: { "store.kv": fixedCreds["store.kv"] },
147
+ });
148
+ const kvYml = derived.files.find((f) => f.path === "compose.store.kv.yml")?.content ?? "";
149
+ expect(kvYml).toContain(image);
150
+ expect(kvYml).toContain("memlock");
151
+ expect(kvYml).toContain("HEALTHCHECK_PORT");
152
+ expect(derived.stackEnv.REDIS_URL).toContain("redis://");
153
+ });
154
+
155
+ test("pgdog matches the official image and waits on store-sql", () => {
156
+ expect(recipeFor("ghcr.io/pgdogdev/pgdog:v0.1.51").id).toBe("pgdog");
157
+ expect(pgdog.match("ghcr.io/pgdogdev/pgdog:main")).toBe(true);
158
+ const applied = pgdog.apply({
159
+ role: "pgdog",
160
+ serviceName: "pgdog",
161
+ image: "ghcr.io/pgdogdev/pgdog:v0.1.51",
162
+ port: 6432,
163
+ hostPort: 6432,
164
+ credentials: fixedCreds["store.sql"],
165
+ });
166
+ expect(applied.dependsOn?.["store-sql"]?.condition).toBe("service_healthy");
167
+ expect(applied.volumes).toContain("./pgdog.toml:/pgdog/pgdog.toml:ro");
168
+ expect(applied.volumes).toContain("./users.toml:/pgdog/users.toml:ro");
169
+ expect(applied.healthcheck?.test.join(" ")).toContain("pg_isready");
170
+ });
171
+
172
+ test("pgdog.toml + users.toml match upstream config shape", () => {
173
+ const pgdogToml = buildPgDogToml({ database: "oke", postgresHost: "store-sql" });
174
+ expect(pgdogToml).toContain("[general]");
175
+ expect(pgdogToml).toContain('host = "0.0.0.0"');
176
+ expect(pgdogToml).toContain("port = 6432");
177
+ expect(pgdogToml).toContain('pooler_mode = "transaction"');
178
+ expect(pgdogToml).toContain("[[databases]]");
179
+ expect(pgdogToml).toContain('name = "oke"');
180
+ expect(pgdogToml).toContain('host = "store-sql"');
181
+ expect(pgdogToml).toContain("port = 5432");
182
+ expect(pgdogToml).toContain('database_name = "oke"');
183
+
184
+ const usersToml = buildPgDogUsersToml({
185
+ user: "oke",
186
+ password: "s3cret-sql-password-xyz",
187
+ database: "oke",
188
+ });
189
+ expect(usersToml).toContain("[[users]]");
190
+ expect(usersToml).toContain('name = "oke"');
191
+ expect(usersToml).toContain('password = "s3cret-sql-password-xyz"');
192
+ expect(usersToml).toContain('database = "oke"');
44
193
  });
45
194
 
46
195
  test("meilisearch matches the official image and emits a http URL", () => {
@@ -104,6 +253,56 @@ describe("image recipes", () => {
104
253
  expect(exportLines.length).toBeLessThanOrEqual(15);
105
254
  });
106
255
 
256
+ test("caddy matches official images and emits a Caddyfile reverse_proxy", () => {
257
+ expect(recipeFor("caddy:2-alpine").id).toBe("caddy");
258
+ expect(caddy.match("library/caddy:2")).toBe(true);
259
+ const spec: ServiceSpec = {
260
+ role: "proxy",
261
+ serviceName: "proxy",
262
+ image: "caddy:2-alpine",
263
+ port: 80,
264
+ hostPort: 80,
265
+ credentials: { user: "oke", password: "unused-proxy", database: "oke" },
266
+ };
267
+ const applied = caddy.apply(spec);
268
+ expect(applied.extraPorts).toEqual([{ host: 443, container: 443 }]);
269
+ expect(applied.volumes).toContain("./Caddyfile:/etc/caddy/Caddyfile:ro");
270
+ expect(applied.volumes).toContain("proxy-data:/data");
271
+ const file = buildCaddyfile();
272
+ expect(file).toContain("{$OKE_PROXY_HOST:localhost}");
273
+ expect(file).toContain("reverse_proxy app:6530");
274
+ expect(caddy.url(spec, { host: "app.example.com", port: 443, ...spec.credentials })).toBe(
275
+ "https://app.example.com",
276
+ );
277
+ });
278
+
279
+ test("traefik matches official images, labels app, and uses socket-proxy", () => {
280
+ expect(recipeFor("traefik:v3.3").id).toBe("traefik");
281
+ expect(traefik.match("traefik:v3.1")).toBe(true);
282
+ const applied = traefik.apply({
283
+ role: "proxy",
284
+ serviceName: "proxy",
285
+ image: "traefik:v3.3",
286
+ port: 80,
287
+ hostPort: 80,
288
+ credentials: { user: "oke", password: "unused-proxy", database: "oke" },
289
+ });
290
+ expect(applied.extraPorts).toEqual([{ host: 443, container: 443 }]);
291
+ expect(String(applied.command)).toContain("--providers.docker=true");
292
+ expect(String(applied.command)).toContain(`tcp://${SOCKET_PROXY_SERVICE}:2375`);
293
+ expect(String(applied.command)).not.toContain("docker.sock");
294
+ expect(applied.volumes?.some((v) => v.includes("docker.sock"))).toBeFalsy();
295
+ expect(applied.dependsOn?.[SOCKET_PROXY_SERVICE]?.condition).toBe("service_started");
296
+ const socket = applied.services?.[SOCKET_PROXY_SERVICE] as Record<string, unknown>;
297
+ expect(socket.image).toBe(SOCKET_PROXY_IMAGE);
298
+ expect(socket.volumes).toContain("/var/run/docker.sock:/var/run/docker.sock:ro");
299
+ const labels = (applied.services?.app as { labels: Record<string, string> }).labels;
300
+ expect(labels).toEqual(traefikAppLabels());
301
+ expect(labels["traefik.enable"]).toBe("true");
302
+ expect(labels["traefik.http.services.app.loadbalancer.server.port"]).toBe("6530");
303
+ expect(labels["traefik.http.routers.app.rule"]).toContain("OKE_PROXY_HOST");
304
+ });
305
+
107
306
  test("recipe.url builds a connection string without env-var names in the kernel", () => {
108
307
  const spec: ServiceSpec = {
109
308
  role: "store.sql",
@@ -168,6 +367,7 @@ describe("deriveInfrastructure", () => {
168
367
  expect(paths).toContain("compose.yml");
169
368
  expect(paths).toContain("compose.store.sql.yml");
170
369
  expect(paths).toContain("compose.store.kv.yml");
370
+ expect(paths).toContain(COMPOSE_ALL);
171
371
  expect(paths).not.toContain(COMPOSE_OVERRIDE);
172
372
 
173
373
  expect(result.composeFiles).toEqual([
@@ -176,6 +376,7 @@ describe("deriveInfrastructure", () => {
176
376
  "compose.store.sql.yml",
177
377
  COMPOSE_OVERRIDE,
178
378
  ]);
379
+ expect(result.composeFiles).not.toContain(COMPOSE_ALL);
179
380
 
180
381
  const sqlYml = result.files.find((f) => f.path === "compose.store.sql.yml")!.content;
181
382
  expect(sqlYml).toContain("pgvector/pgvector:pg17");
@@ -187,6 +388,7 @@ describe("deriveInfrastructure", () => {
187
388
  const baseYml = result.files.find((f) => f.path === "compose.yml")!.content;
188
389
  expect(baseYml).toContain('context: ".."');
189
390
  expect(baseYml).toContain("app:");
391
+ expect(baseYml).toContain("oke-skyport:latest");
190
392
 
191
393
  for (const f of result.files) {
192
394
  assertNoCredentialsInYaml(f.content, Object.values(fixedCreds));
@@ -221,6 +423,56 @@ describe("deriveInfrastructure", () => {
221
423
  expect(result.composeFiles.at(-1)).toBe(COMPOSE_OVERRIDE);
222
424
  const prod = result.files.find((f) => f.path === "compose.prod.yml")!.content;
223
425
  expect(prod).toContain("replicas");
426
+ expect(prod).toContain("/_/ready");
427
+ expect(prod).toContain("update_config");
428
+ expect(prod).toContain("restart_policy");
429
+ expect(prod).toContain("stop_grace_period");
430
+ expect(prod).toContain("start-first");
431
+ expect(prod).toContain("on-failure");
432
+ });
433
+
434
+ test("when postgres + pgdog are both pinned, DATABASE_URL points at PgDog", () => {
435
+ const result = deriveInfrastructure({
436
+ images: {
437
+ "store.sql": "postgres:18-alpine",
438
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
439
+ },
440
+ credentials: { "store.sql": fixedCreds["store.sql"] },
441
+ prod: true,
442
+ app: "skyport",
443
+ });
444
+
445
+ expect(result.stackEnv.DATABASE_URL).toContain(":6432/");
446
+ expect(result.stackEnv.DATABASE_URL).toContain("postgres://oke:");
447
+ expect(result.stackEnv.DATABASE_URL).toContain("/oke");
448
+ expect(result.stackEnv.OKE_PGDOG_URL).toBe(result.stackEnv.DATABASE_URL);
449
+ // Direct Postgres URL stays on the role key for ops / escape hatch.
450
+ expect(result.stackEnv.OKE_STORE_SQL_URL).toContain(":5432/");
451
+
452
+ const pgdogYml = result.files.find((f) => f.path === "compose.pgdog.yml")!.content;
453
+ expect(pgdogYml).toContain("ghcr.io/pgdogdev/pgdog:v0.1.51");
454
+ expect(pgdogYml).toContain("store-sql");
455
+ expect(pgdogYml).toContain("service_healthy");
456
+ expect(pgdogYml).not.toContain(fixedCreds["store.sql"].password);
457
+
458
+ const pgdogToml = result.files.find((f) => f.path === "pgdog.toml")!.content;
459
+ expect(pgdogToml).toContain('pooler_mode = "transaction"');
460
+ expect(pgdogToml).toContain('host = "store-sql"');
461
+ expect(pgdogToml).not.toContain(fixedCreds["store.sql"].password);
462
+
463
+ const usersToml = result.files.find((f) => f.path === "users.toml")!.content;
464
+ expect(usersToml).toContain('password = "s3cret-sql-password-xyz"');
465
+ expect(usersToml).toContain('database = "oke"');
466
+ });
467
+
468
+ test("postgres alone keeps DATABASE_URL on 5432 (no pooler)", () => {
469
+ const result = deriveInfrastructure({
470
+ images: { "store.sql": "postgres:18-alpine" },
471
+ credentials: { "store.sql": fixedCreds["store.sql"] },
472
+ });
473
+ expect(result.stackEnv.DATABASE_URL).toContain(":5432/");
474
+ expect(result.stackEnv.OKE_PGDOG_URL).toBeUndefined();
475
+ expect(result.files.some((f) => f.path === "pgdog.toml")).toBe(false);
224
476
  });
225
477
 
226
478
  test("Dockerfile CMD is oke start", () => {
@@ -393,4 +645,125 @@ describe("deriveInfrastructure", () => {
393
645
  expect(files.hostPort).toBe(18_000 + n);
394
646
  expect(files.extraPorts).toEqual([{ hostPort: 19_000 + n, containerPort: 9001 }]);
395
647
  });
648
+
649
+ test("opt-in caddy proxy emits Caddyfile and unpublishes app ports", () => {
650
+ const result = deriveInfrastructure({
651
+ images: {
652
+ "store.sql": "postgres:16",
653
+ proxy: "caddy:2-alpine",
654
+ },
655
+ credentials: { "store.sql": fixedCreds["store.sql"] },
656
+ app: "skyport",
657
+ });
658
+ const base = result.files.find((f) => f.path === "compose.yml")!.content;
659
+ expect(base).toContain("app:");
660
+ expect(base).not.toContain("6530:6530");
661
+ expect(base).not.toContain("proxy:");
662
+ expect(base).toContain("store-sql");
663
+ expect(base).not.toMatch(/depends_on:[\s\S]*proxy/);
664
+
665
+ const proxyYml = result.files.find((f) => f.path === "compose.proxy.yml")!.content;
666
+ expect(proxyYml).toContain("caddy:2-alpine");
667
+ expect(proxyYml).toContain("80:80");
668
+ expect(proxyYml).toContain("443:443");
669
+ expect(proxyYml).toContain("./Caddyfile:/etc/caddy/Caddyfile:ro");
670
+
671
+ const caddyfile = result.files.find((f) => f.path === "Caddyfile")!.content;
672
+ expect(caddyfile).toContain("reverse_proxy app:6530");
673
+ expect(result.stackEnv.OKE_PROXY_URL).toBe("https://127.0.0.1");
674
+ expect(formatStackEnv(result.stackEnv)).toContain("# ── proxy — TLS terminator");
675
+ expect(formatStackEnv(result.stackEnv)).toContain("# OKE_PROXY_HOST=localhost");
676
+ });
677
+
678
+ test("opt-in traefik proxy labels app and mounts socket only on socket-proxy", () => {
679
+ const result = deriveInfrastructure({
680
+ images: {
681
+ "store.sql": "postgres:16",
682
+ proxy: "traefik:v3.3",
683
+ },
684
+ credentials: { "store.sql": fixedCreds["store.sql"] },
685
+ app: "skyport",
686
+ prod: true,
687
+ });
688
+ const base = result.files.find((f) => f.path === "compose.yml")!.content;
689
+ expect(base).not.toContain("6530:6530");
690
+
691
+ const proxyYml = result.files.find((f) => f.path === "compose.proxy.yml")!.content;
692
+ expect(proxyYml).toContain("traefik:v3.3");
693
+ expect(proxyYml).toContain(SOCKET_PROXY_IMAGE);
694
+ expect(proxyYml).toContain(SOCKET_PROXY_SERVICE);
695
+ expect(proxyYml).toContain("/var/run/docker.sock:/var/run/docker.sock:ro");
696
+ expect(proxyYml).toContain("tcp://socket-proxy:2375");
697
+ expect(proxyYml).toContain("traefik.enable");
698
+ expect(proxyYml).toContain("traefik.http.routers.app.rule");
699
+ expect(proxyYml).toContain("loadbalancer.server.port");
700
+ // Raw socket must not be on the Traefik service itself.
701
+ const traefikBlock = proxyYml.split("socket-proxy:")[0]!;
702
+ expect(traefikBlock).toContain("traefik:v3.3");
703
+ expect(traefikBlock).not.toContain("docker.sock");
704
+
705
+ expect(result.files.some((f) => f.path === "Caddyfile")).toBe(false);
706
+ expect(result.stackEnv.OKE_PROXY_URL).toBe("https://127.0.0.1");
707
+ expect(result.composeFiles).toContain("compose.proxy.yml");
708
+ expect(result.composeFiles).toContain("compose.prod.yml");
709
+ });
710
+
711
+ test("without proxy role, app still publishes 6530 (default unchanged)", () => {
712
+ const result = deriveInfrastructure({
713
+ images: { "store.sql": "postgres:16" },
714
+ credentials: { "store.sql": fixedCreds["store.sql"] },
715
+ app: "skyport",
716
+ });
717
+ const base = result.files.find((f) => f.path === "compose.yml")!.content;
718
+ expect(base).toContain("6530:6530");
719
+ expect(result.files.some((f) => f.path === "compose.proxy.yml")).toBe(false);
720
+ expect(result.stackEnv.OKE_PROXY_URL).toBeUndefined();
721
+ });
722
+
723
+ test("compose.all.yml merges every service from separate layer files", () => {
724
+ const result = deriveInfrastructure({
725
+ images: {
726
+ "store.sql": "postgres:16",
727
+ "store.kv": "redis:8-alpine",
728
+ proxy: "traefik:v3.3",
729
+ },
730
+ credentials: {
731
+ "store.sql": fixedCreds["store.sql"],
732
+ "store.kv": fixedCreds["store.kv"],
733
+ },
734
+ app: "skyport",
735
+ prod: true,
736
+ });
737
+
738
+ const allFile = result.files.find((f) => f.path === COMPOSE_ALL);
739
+ expect(allFile).toBeDefined();
740
+ const allYml = allFile!.content;
741
+ const allServices = serviceNamesFromComposeYaml(allYml);
742
+
743
+ const expected = new Set<string>();
744
+ for (const f of result.files) {
745
+ if (f.path === COMPOSE_ALL) continue;
746
+ if (f.path !== "compose.yml" && !/^compose\..+\.yml$/.test(f.path)) continue;
747
+ for (const name of serviceNamesFromComposeYaml(f.content)) {
748
+ expected.add(name);
749
+ }
750
+ }
751
+
752
+ expect(expected.size).toBeGreaterThan(0);
753
+ for (const name of expected) {
754
+ expect(allServices.has(name)).toBe(true);
755
+ }
756
+ // Companion + role services survive the merge (not dropped).
757
+ expect(allServices.has("app")).toBe(true);
758
+ expect(allServices.has("store-sql")).toBe(true);
759
+ expect(allServices.has("store-kv")).toBe(true);
760
+ expect(allServices.has("proxy")).toBe(true);
761
+ expect(allServices.has(SOCKET_PROXY_SERVICE)).toBe(true);
762
+ // Prod overlay fields land on app in the merged document.
763
+ expect(allYml).toContain("/_/ready");
764
+ expect(allYml).toContain("stop_grace_period");
765
+ expect(allYml).toContain("update_config");
766
+ // Still not in the layered `-f` list.
767
+ expect(result.composeFiles).not.toContain(COMPOSE_ALL);
768
+ });
396
769
  });
@@ -43,6 +43,8 @@ export function defaultHostPort(role: string, containerPort: number): number {
43
43
  if (role === "store.sql") return 5432;
44
44
  if (role === "store.kv") return 6379;
45
45
  if (role === "signal") return 4222;
46
+ if (role === "pgdog") return 6432;
47
+ if (role === "proxy") return 80;
46
48
  return containerPort;
47
49
  }
48
50
 
@@ -15,6 +15,7 @@ export type {
15
15
  ImageRecipe,
16
16
  RecipeApplyResult,
17
17
  RecipeExtraPort,
18
+ RecipeUlimit,
18
19
  ServiceCredentials,
19
20
  ServiceEndpoint,
20
21
  ServiceSpec,
@@ -23,11 +24,13 @@ export type {
23
24
  export { DEFAULT_DOCKER_DIR } from "./types.ts";
24
25
 
25
26
  export {
27
+ COMPOSE_ALL,
26
28
  COMPOSE_OVERRIDE,
27
29
  assertNoCredentialsInYaml,
28
30
  buildSpecs,
29
31
  buildStackEnv,
30
32
  composePathRefs,
33
+ deepMergeCompose,
31
34
  emitComposeLayers,
32
35
  formatStackEnv,
33
36
  } from "./compose.ts";
@@ -38,15 +41,23 @@ export { credEnv, envPrefix, serviceNameFor } from "./helpers.ts";
38
41
  export { generateCredentials } from "./credentials.ts";
39
42
  export {
40
43
  builtinRecipes,
44
+ caddy,
45
+ dragonfly,
41
46
  mailpit,
42
47
  meilisearch,
43
48
  ollama,
44
49
  openbao,
50
+ pgdog,
45
51
  postgres,
46
52
  recipeFor,
47
53
  redis,
48
54
  rustfs,
55
+ traefik,
56
+ valkey,
49
57
  } from "./recipes/index.ts";
58
+ export { CADDY_APP_SERVICE, buildCaddyfile } from "./recipes/caddy.ts";
59
+ export { PGDOG_BACKEND_SERVICE, buildPgDogToml, buildPgDogUsersToml } from "./recipes/pgdog.ts";
60
+ export { SOCKET_PROXY_IMAGE, SOCKET_PROXY_SERVICE, traefikAppLabels } from "./recipes/traefik.ts";
50
61
  export {
51
62
  formatStackPreview,
52
63
  resolveExtraPorts,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Caddy image recipe — automatic HTTPS reverse proxy (simplest TLS path).
3
+ *
4
+ * Opt-in via `images.proxy`. Emits a `Caddyfile` that terminates TLS and
5
+ * reverse-proxies to the compose `app` service on {@link APP_PORT}.
6
+ */
7
+
8
+ import { APP_PORT } from "../../runtime/types.ts";
9
+ import type { ImageRecipe } from "../types.ts";
10
+
11
+ /** Compose service name for the application container. */
12
+ export const CADDY_APP_SERVICE = "app";
13
+
14
+ /**
15
+ * Build a minimal Caddyfile — automatic HTTPS for `OKE_PROXY_HOST`.
16
+ *
17
+ * @param opts - Upstream app port on the compose network
18
+ */
19
+ export function buildCaddyfile(opts: { readonly appPort?: number } = {}): string {
20
+ const port = opts.appPort ?? APP_PORT;
21
+ return [
22
+ "# Generated by `oke docker` — edit via compose.override.yml / replace this file.",
23
+ "# Set OKE_PROXY_HOST to your public hostname (Let's Encrypt). localhost → local TLS.",
24
+ "{$OKE_PROXY_HOST:localhost} {",
25
+ ` reverse_proxy ${CADDY_APP_SERVICE}:${port}`,
26
+ "}",
27
+ "",
28
+ ].join("\n");
29
+ }
30
+
31
+ /** Caddy reverse proxy. HTTP 80 + HTTPS 443. */
32
+ export const caddy: ImageRecipe = {
33
+ id: "caddy",
34
+ port: 80,
35
+ match: (i) => /caddy/i.test(i),
36
+ apply: (s) => ({
37
+ extraPorts: [{ host: 443, container: 443 }],
38
+ volumes: [
39
+ "./Caddyfile:/etc/caddy/Caddyfile:ro",
40
+ `${s.serviceName}-data:/data`,
41
+ `${s.serviceName}-config:/config`,
42
+ ],
43
+ healthcheck: {
44
+ test: ["CMD", "caddy", "version"],
45
+ interval: "10s",
46
+ timeout: "3s",
47
+ retries: 5,
48
+ },
49
+ }),
50
+ url: (_s, c) => `https://${c.host}`,
51
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Dragonfly image recipe — Redis-wire-compatible, multi-threaded alternative.
3
+ *
4
+ * Peer of Redis / Valkey via `images["store.kv"]` (driver id stays `redis`).
5
+ * Official image: `docker.dragonflydb.io/dragonflydb/dragonfly`.
6
+ */
7
+
8
+ import type { ImageRecipe } from "../types.ts";
9
+
10
+ /** Dragonfly — Redis protocol on 6379. */
11
+ export const dragonfly: ImageRecipe = {
12
+ id: "dragonfly",
13
+ port: 6379,
14
+ match: (i) => /dragonfly/i.test(i),
15
+ apply: () => ({
16
+ environment: { HEALTHCHECK_PORT: "6379" },
17
+ command: [
18
+ "sh",
19
+ "-c",
20
+ 'exec dragonfly --requirepass "$$OKE_STORE_KV_PASSWORD" --maxmemory "$${OKE_STORE_KV_MAXMEMORY:-0}"',
21
+ ],
22
+ ulimits: { memlock: -1 },
23
+ healthcheck: {
24
+ test: ["CMD", "/usr/local/bin/healthcheck.sh"],
25
+ interval: "5s",
26
+ timeout: "3s",
27
+ retries: 10,
28
+ },
29
+ }),
30
+ url: (_s, c) => `redis://:${encodeURIComponent(c.password)}@${c.host}:${c.port}`,
31
+ };
@@ -3,26 +3,49 @@
3
3
  */
4
4
 
5
5
  import type { ImageRecipe } from "../types.ts";
6
+ import { caddy } from "./caddy.ts";
7
+ import { dragonfly } from "./dragonfly.ts";
6
8
  import { mailpit } from "./mailpit.ts";
7
9
  import { meilisearch } from "./meilisearch.ts";
8
10
  import { ollama } from "./ollama.ts";
9
11
  import { openbao } from "./openbao.ts";
12
+ import { pgdog } from "./pgdog.ts";
10
13
  import { postgres } from "./postgres.ts";
11
14
  import { redis } from "./redis.ts";
12
15
  import { rustfs } from "./rustfs.ts";
16
+ import { traefik } from "./traefik.ts";
17
+ import { valkey } from "./valkey.ts";
13
18
 
14
- /** Default recipe catalogue. */
19
+ /** Default recipe catalogue — more specific image matches before protocol peers. */
15
20
  export const builtinRecipes: readonly ImageRecipe[] = [
16
21
  postgres,
22
+ pgdog,
23
+ dragonfly,
24
+ valkey,
17
25
  redis,
18
26
  mailpit,
19
27
  rustfs,
20
28
  openbao,
21
29
  meilisearch,
22
30
  ollama,
31
+ caddy,
32
+ traefik,
23
33
  ];
24
34
 
25
- export { mailpit, meilisearch, ollama, openbao, postgres, redis, rustfs };
35
+ export {
36
+ caddy,
37
+ dragonfly,
38
+ mailpit,
39
+ meilisearch,
40
+ ollama,
41
+ openbao,
42
+ pgdog,
43
+ postgres,
44
+ redis,
45
+ rustfs,
46
+ traefik,
47
+ valkey,
48
+ };
26
49
 
27
50
  /**
28
51
  * Resolve the recipe for an image reference.