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
|
@@ -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
|
});
|
package/src/docker/helpers.ts
CHANGED
|
@@ -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
|
|
package/src/docker/index.ts
CHANGED
|
@@ -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 {
|
|
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.
|