okengine 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -4
- package/docs/spec/example.md +12 -3
- package/docs/spec/four-applications.md +10 -3
- package/package.json +9 -40
- package/src/cli/dev-app-runner.ts +45 -6
- package/src/cli/dev.ts +136 -17
- package/src/cli/docker-cli.test.ts +10 -8
- package/src/cli/docker.ts +18 -7
- package/src/cli/hero-meta.test.ts +85 -0
- package/src/cli/hero-meta.ts +252 -0
- package/src/cli/load-config.images.test.ts +51 -0
- package/src/cli/load-config.ts +84 -6
- package/src/config/define-config.test.ts +61 -0
- package/src/config/index.ts +89 -6
- package/src/config/resolve-driver.test.ts +30 -0
- package/src/console/server/flows.ts +3 -1
- package/src/console/server/operator-db.test.ts +140 -2
- package/src/console/server/operator-db.ts +147 -1
- package/src/console/server/plugin.ts +20 -0
- package/src/console/server/serve.ts +8 -1
- package/src/console/server/state.ts +12 -1
- package/src/console/ui/dist/assets/{index-Bnf_3Hei.js → index-Dy4jht9P.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/App.tsx +10 -2
- package/src/docker/compose.ts +45 -14
- package/src/docker/derive.ts +29 -9
- package/src/docker/docker.test.ts +28 -4
- package/src/docker/dockerfile.integration.test.ts +2 -0
- package/src/docker/index.ts +10 -0
- package/src/docker/stack-id.test.ts +86 -0
- package/src/docker/stack-id.ts +108 -0
- package/src/docker/stack.integration.test.ts +16 -7
- package/src/docker/types.ts +20 -1
- package/src/kernel/app.ts +39 -8
- package/src/kernel/boot-bind/store.test.ts +60 -0
- package/src/kernel/boot-bind/store.ts +142 -12
- package/src/kernel/boot.ts +28 -4
- package/src/mcp/server.ts +99 -57
- package/src/runtime/dev-request-log.test.ts +33 -0
- package/src/runtime/dev-request-log.ts +130 -0
- package/src/term.test.ts +98 -5
- package/src/term.ts +287 -6
|
@@ -17,7 +17,10 @@ export function App() {
|
|
|
17
17
|
queryKey: ["console.setup.status"],
|
|
18
18
|
queryFn: async () => {
|
|
19
19
|
const res = await consoleCalls.setupStatus();
|
|
20
|
-
if (res.error)
|
|
20
|
+
if (res.error) {
|
|
21
|
+
if (res.error.code === "Unauthorized") setAccessToken(null);
|
|
22
|
+
throw new Error(res.error.code);
|
|
23
|
+
}
|
|
21
24
|
return res.data as { setupClosed: boolean; claimRequired: boolean };
|
|
22
25
|
},
|
|
23
26
|
retry: 2,
|
|
@@ -36,7 +39,12 @@ export function App() {
|
|
|
36
39
|
retry: false,
|
|
37
40
|
queryFn: async () => {
|
|
38
41
|
const res = await consoleCalls.sessionMe();
|
|
39
|
-
if (res.error)
|
|
42
|
+
if (res.error) {
|
|
43
|
+
if (res.error.code === "Unauthorized" || res.error.code === "AuthFailed") {
|
|
44
|
+
setAccessToken(null);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(res.error.code);
|
|
47
|
+
}
|
|
40
48
|
return res.data;
|
|
41
49
|
},
|
|
42
50
|
});
|
package/src/docker/compose.ts
CHANGED
|
@@ -21,12 +21,32 @@ import type {
|
|
|
21
21
|
ServiceCredentials,
|
|
22
22
|
ServiceSpec,
|
|
23
23
|
} from "./types.ts";
|
|
24
|
+
import { DEFAULT_DOCKER_DIR } from "./types.ts";
|
|
24
25
|
import { generateCredentials } from "./credentials.ts";
|
|
26
|
+
import { hostPortForInstance } from "./stack-id.ts";
|
|
25
27
|
import { APP_PORT } from "../runtime/types.ts";
|
|
26
28
|
|
|
27
29
|
/** Canonical layer-4 filename — never written by derivation. */
|
|
28
30
|
export const COMPOSE_OVERRIDE = "compose.override.yml";
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Relative path refs for compose files living under {@link DeriveOptions.composeDir}.
|
|
34
|
+
*
|
|
35
|
+
* @param composeDir - Directory relative to project root (`docker` or `.`)
|
|
36
|
+
*/
|
|
37
|
+
export function composePathRefs(composeDir: string = DEFAULT_DOCKER_DIR): {
|
|
38
|
+
readonly envFile: string;
|
|
39
|
+
readonly buildContext: string;
|
|
40
|
+
readonly dockerfile: string;
|
|
41
|
+
} {
|
|
42
|
+
const flat = composeDir === "." || composeDir === "";
|
|
43
|
+
return {
|
|
44
|
+
envFile: flat ? ".env.stack" : "../.env.stack",
|
|
45
|
+
buildContext: flat ? "." : "..",
|
|
46
|
+
dockerfile: "Dockerfile",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
/**
|
|
31
51
|
* Build normalised {@link ServiceSpec} list from image pins.
|
|
32
52
|
*
|
|
@@ -40,12 +60,15 @@ export function buildSpecs(options: DeriveOptions): ServiceSpec[] {
|
|
|
40
60
|
const creds =
|
|
41
61
|
options.credentials?.[role] ?? generateCredentials(role);
|
|
42
62
|
const port = recipe.port;
|
|
63
|
+
const hostPort = options.instanceId
|
|
64
|
+
? hostPortForInstance(role, port, options.instanceId)
|
|
65
|
+
: defaultHostPort(role, port);
|
|
43
66
|
specs.push({
|
|
44
67
|
role,
|
|
45
68
|
serviceName: serviceNameFor(role),
|
|
46
69
|
image,
|
|
47
70
|
port,
|
|
48
|
-
hostPort
|
|
71
|
+
hostPort,
|
|
49
72
|
credentials: creds,
|
|
50
73
|
});
|
|
51
74
|
}
|
|
@@ -65,24 +88,31 @@ export function emitComposeLayers(
|
|
|
65
88
|
const recipes = options.recipes ?? [];
|
|
66
89
|
const appPort = options.appPort ?? APP_PORT;
|
|
67
90
|
const app = options.app ?? "app";
|
|
91
|
+
const includeApp = options.includeApp !== false;
|
|
92
|
+
const paths = composePathRefs(options.composeDir ?? DEFAULT_DOCKER_DIR);
|
|
68
93
|
const files: GeneratedFile[] = [];
|
|
69
94
|
|
|
70
|
-
// Layer 1 —
|
|
71
|
-
const base = {
|
|
95
|
+
// Layer 1 — project name + network (+ optional app for deploy / oke docker)
|
|
96
|
+
const base: Record<string, unknown> = {
|
|
72
97
|
name: `oke-${app}`,
|
|
73
|
-
|
|
98
|
+
networks: { oke: { driver: "bridge" } },
|
|
99
|
+
};
|
|
100
|
+
if (includeApp) {
|
|
101
|
+
base.services = {
|
|
74
102
|
app: {
|
|
75
|
-
build: {
|
|
103
|
+
build: {
|
|
104
|
+
context: paths.buildContext,
|
|
105
|
+
dockerfile: paths.dockerfile,
|
|
106
|
+
},
|
|
76
107
|
ports: [`${appPort}:${appPort}`],
|
|
77
|
-
env_file: [
|
|
108
|
+
env_file: [paths.envFile],
|
|
78
109
|
depends_on: Object.fromEntries(
|
|
79
110
|
specs.map((s) => [s.serviceName, { condition: "service_healthy" }]),
|
|
80
111
|
),
|
|
81
112
|
networks: ["oke"],
|
|
82
113
|
},
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
};
|
|
114
|
+
};
|
|
115
|
+
}
|
|
86
116
|
files.push({ path: "compose.yml", content: `${toYaml(base)}\n` });
|
|
87
117
|
|
|
88
118
|
// Layer 2 — per-role
|
|
@@ -93,7 +123,7 @@ export function emitComposeLayers(
|
|
|
93
123
|
image: spec.image,
|
|
94
124
|
ports: [`${spec.hostPort}:${spec.port}`],
|
|
95
125
|
networks: ["oke"],
|
|
96
|
-
env_file: [
|
|
126
|
+
env_file: [paths.envFile],
|
|
97
127
|
};
|
|
98
128
|
if (applied.environment) service.environment = applied.environment;
|
|
99
129
|
if (applied.command) service.command = applied.command;
|
|
@@ -111,8 +141,9 @@ export function emitComposeLayers(
|
|
|
111
141
|
|
|
112
142
|
// Layer 3 — prod overlay
|
|
113
143
|
if (options.prod) {
|
|
114
|
-
const prodServices: Record<string, unknown> = {
|
|
115
|
-
|
|
144
|
+
const prodServices: Record<string, unknown> = {};
|
|
145
|
+
if (includeApp) {
|
|
146
|
+
prodServices.app = {
|
|
116
147
|
deploy: {
|
|
117
148
|
replicas: 1,
|
|
118
149
|
resources: {
|
|
@@ -120,8 +151,8 @@ export function emitComposeLayers(
|
|
|
120
151
|
},
|
|
121
152
|
},
|
|
122
153
|
secrets: specs.flatMap((s) => secretNames(s)),
|
|
123
|
-
}
|
|
124
|
-
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
125
156
|
for (const spec of specs) {
|
|
126
157
|
prodServices[spec.serviceName] = {
|
|
127
158
|
deploy: {
|
package/src/docker/derive.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Derive Dockerfile + compose files from config image pins.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
5
6
|
import {
|
|
6
7
|
assertNoCredentialsInYaml,
|
|
7
8
|
buildSpecs,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
} from "./compose.ts";
|
|
12
13
|
import { emitDockerfile } from "./dockerfile.ts";
|
|
13
14
|
import type { DeriveOptions, DeriveResult, GeneratedFile } from "./types.ts";
|
|
15
|
+
import { DEFAULT_DOCKER_DIR } from "./types.ts";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Derive infrastructure files from normalised image pins.
|
|
@@ -28,15 +30,22 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
|
|
|
28
30
|
);
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
const
|
|
33
|
+
const normalised: DeriveOptions = {
|
|
34
|
+
...options,
|
|
35
|
+
composeDir: options.composeDir ?? DEFAULT_DOCKER_DIR,
|
|
36
|
+
includeApp: options.includeApp !== false,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const specs = buildSpecs(normalised);
|
|
32
40
|
const { files: composeFilesContent, composeFiles } = emitComposeLayers(
|
|
33
41
|
specs,
|
|
34
|
-
|
|
42
|
+
normalised,
|
|
35
43
|
);
|
|
36
44
|
const dockerfile: GeneratedFile = {
|
|
37
45
|
path: "Dockerfile",
|
|
38
|
-
content: emitDockerfile({ appPort:
|
|
46
|
+
content: emitDockerfile({ appPort: normalised.appPort }),
|
|
39
47
|
};
|
|
48
|
+
// Always emit Dockerfile for deploy; stack-only runs ignore it.
|
|
40
49
|
const files = [dockerfile, ...composeFilesContent];
|
|
41
50
|
|
|
42
51
|
for (const f of files) {
|
|
@@ -50,8 +59,8 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
|
|
|
50
59
|
|
|
51
60
|
const stackEnv = buildStackEnv(
|
|
52
61
|
specs,
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
normalised.recipes ?? [],
|
|
63
|
+
normalised.host ?? "127.0.0.1",
|
|
55
64
|
);
|
|
56
65
|
|
|
57
66
|
return { specs, files, stackEnv, composeFiles };
|
|
@@ -62,22 +71,33 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
|
|
|
62
71
|
* credential values into YAML. Optionally writes `.env.stack`.
|
|
63
72
|
*
|
|
64
73
|
* @param result - Derive result
|
|
65
|
-
* @param outDir - Destination
|
|
74
|
+
* @param outDir - Destination for Dockerfile / compose (usually `docker/`)
|
|
66
75
|
* @param options - Write controls
|
|
67
76
|
*/
|
|
68
77
|
export async function writeDerivedFiles(
|
|
69
78
|
result: DeriveResult,
|
|
70
79
|
outDir: string,
|
|
71
|
-
options: {
|
|
80
|
+
options: {
|
|
81
|
+
readonly writeStackEnv?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Directory for `.env.stack` (project root). Defaults to `outDir`.
|
|
84
|
+
* Prefer the project cwd when compose lives under `docker/`.
|
|
85
|
+
*/
|
|
86
|
+
readonly stackEnvDir?: string;
|
|
87
|
+
} = {},
|
|
72
88
|
): Promise<readonly string[]> {
|
|
73
89
|
const written: string[] = [];
|
|
90
|
+
const root = outDir.replace(/\/$/, "");
|
|
91
|
+
mkdirSync(root, { recursive: true });
|
|
74
92
|
for (const file of result.files) {
|
|
75
|
-
const path = `${
|
|
93
|
+
const path = `${root}/${file.path}`;
|
|
76
94
|
await Bun.write(path, file.content);
|
|
77
95
|
written.push(path);
|
|
78
96
|
}
|
|
79
97
|
if (options.writeStackEnv) {
|
|
80
|
-
const
|
|
98
|
+
const envRoot = (options.stackEnvDir ?? outDir).replace(/\/$/, "");
|
|
99
|
+
mkdirSync(envRoot, { recursive: true });
|
|
100
|
+
const envPath = `${envRoot}/.env.stack`;
|
|
81
101
|
await Bun.write(envPath, formatStackEnv(result.stackEnv));
|
|
82
102
|
written.push(envPath);
|
|
83
103
|
}
|
|
@@ -125,8 +125,13 @@ describe("deriveInfrastructure", () => {
|
|
|
125
125
|
expect(sqlYml).toContain("pgvector/pgvector:pg17");
|
|
126
126
|
expect(sqlYml).toContain("POSTGRES_PASSWORD");
|
|
127
127
|
expect(sqlYml).toContain("${OKE_STORE_SQL_PASSWORD}");
|
|
128
|
+
expect(sqlYml).toContain("../.env.stack");
|
|
128
129
|
expect(sqlYml).not.toContain(fixedCreds["store.sql"].password);
|
|
129
130
|
|
|
131
|
+
const baseYml = result.files.find((f) => f.path === "compose.yml")!.content;
|
|
132
|
+
expect(baseYml).toContain("context: \"..\"");
|
|
133
|
+
expect(baseYml).toContain("app:");
|
|
134
|
+
|
|
130
135
|
for (const f of result.files) {
|
|
131
136
|
assertNoCredentialsInYaml(
|
|
132
137
|
f.content,
|
|
@@ -140,6 +145,20 @@ describe("deriveInfrastructure", () => {
|
|
|
140
145
|
);
|
|
141
146
|
});
|
|
142
147
|
|
|
148
|
+
test("includeApp false omits app service (infra-only stack)", () => {
|
|
149
|
+
const result = deriveInfrastructure({
|
|
150
|
+
images: { "store.sql": "postgres:16" },
|
|
151
|
+
credentials: { "store.sql": fixedCreds["store.sql"] },
|
|
152
|
+
includeApp: false,
|
|
153
|
+
app: "dev",
|
|
154
|
+
});
|
|
155
|
+
const base = result.files.find((f) => f.path === "compose.yml")!.content;
|
|
156
|
+
expect(base).toContain("oke-dev");
|
|
157
|
+
expect(base).toContain("networks:");
|
|
158
|
+
expect(base).not.toContain("app:");
|
|
159
|
+
expect(base).not.toContain("build:");
|
|
160
|
+
});
|
|
161
|
+
|
|
143
162
|
test("prod overlay adds deploy.replicas and is layer 3", () => {
|
|
144
163
|
const result = deriveInfrastructure({
|
|
145
164
|
images: { "store.sql": "postgres:16" },
|
|
@@ -160,15 +179,20 @@ describe("deriveInfrastructure", () => {
|
|
|
160
179
|
});
|
|
161
180
|
|
|
162
181
|
test("writeDerivedFiles never writes compose.override.yml", async () => {
|
|
163
|
-
const
|
|
182
|
+
const root = await mkdtemp(join(tmpdir(), "oke-docker-"));
|
|
183
|
+
const dockerDir = join(root, "docker");
|
|
164
184
|
const result = deriveInfrastructure({
|
|
165
185
|
images: { "store.sql": "postgres:16" },
|
|
166
186
|
credentials: { "store.sql": fixedCreds["store.sql"] },
|
|
167
187
|
});
|
|
168
|
-
const written = await writeDerivedFiles(result,
|
|
188
|
+
const written = await writeDerivedFiles(result, dockerDir, {
|
|
189
|
+
writeStackEnv: true,
|
|
190
|
+
stackEnvDir: root,
|
|
191
|
+
});
|
|
169
192
|
expect(written.some((p) => p.endsWith(COMPOSE_OVERRIDE))).toBe(false);
|
|
170
|
-
expect(await Bun.file(join(
|
|
171
|
-
|
|
193
|
+
expect(await Bun.file(join(root, ".env.stack")).exists()).toBe(true);
|
|
194
|
+
expect(await Bun.file(join(dockerDir, "compose.yml")).exists()).toBe(true);
|
|
195
|
+
const envText = await Bun.file(join(root, ".env.stack")).text();
|
|
172
196
|
expect(envText).toContain("DATABASE_URL=");
|
|
173
197
|
expect(formatStackEnv(result.stackEnv)).toContain("OKE_STORE_SQL_PASSWORD=");
|
|
174
198
|
});
|
package/src/docker/index.ts
CHANGED
|
@@ -19,11 +19,14 @@ export type {
|
|
|
19
19
|
ServiceSpec,
|
|
20
20
|
} from "./types.ts";
|
|
21
21
|
|
|
22
|
+
export { DEFAULT_DOCKER_DIR } from "./types.ts";
|
|
23
|
+
|
|
22
24
|
export {
|
|
23
25
|
COMPOSE_OVERRIDE,
|
|
24
26
|
assertNoCredentialsInYaml,
|
|
25
27
|
buildSpecs,
|
|
26
28
|
buildStackEnv,
|
|
29
|
+
composePathRefs,
|
|
27
30
|
emitComposeLayers,
|
|
28
31
|
formatStackEnv,
|
|
29
32
|
} from "./compose.ts";
|
|
@@ -43,6 +46,13 @@ export {
|
|
|
43
46
|
resolveStack,
|
|
44
47
|
type StackRow,
|
|
45
48
|
} from "./stack.ts";
|
|
49
|
+
export {
|
|
50
|
+
hostPortForInstance,
|
|
51
|
+
loadExistingStackCredentials,
|
|
52
|
+
parseStackCredentials,
|
|
53
|
+
stackAppSlug,
|
|
54
|
+
stackInstanceId,
|
|
55
|
+
} from "./stack-id.ts";
|
|
46
56
|
export {
|
|
47
57
|
formatImagesLock,
|
|
48
58
|
pinImages,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project stack identity.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
hostPortForInstance,
|
|
8
|
+
parseStackCredentials,
|
|
9
|
+
stackAppSlug,
|
|
10
|
+
stackInstanceId,
|
|
11
|
+
} from "./stack-id.ts";
|
|
12
|
+
import { deriveInfrastructure } from "./derive.ts";
|
|
13
|
+
|
|
14
|
+
describe("stackInstanceId", () => {
|
|
15
|
+
test("is stable for the same cwd and differs across paths", () => {
|
|
16
|
+
const a = stackInstanceId("/tmp/oke-project-a");
|
|
17
|
+
const b = stackInstanceId("/tmp/oke-project-a");
|
|
18
|
+
const c = stackInstanceId("/tmp/oke-project-b");
|
|
19
|
+
expect(a).toBe(b);
|
|
20
|
+
expect(a).toHaveLength(6);
|
|
21
|
+
expect(a).not.toBe(c);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("stackAppSlug is dev-<id>", () => {
|
|
25
|
+
const id = stackInstanceId("/tmp/oke-x");
|
|
26
|
+
expect(stackAppSlug("/tmp/oke-x")).toBe(`dev-${id}`);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("hostPortForInstance", () => {
|
|
31
|
+
test("offsets sql and kv into separate ranges", () => {
|
|
32
|
+
const id = "abcd12";
|
|
33
|
+
const sql = hostPortForInstance("store.sql", 5432, id);
|
|
34
|
+
const kv = hostPortForInstance("store.kv", 6379, id);
|
|
35
|
+
expect(sql).toBeGreaterThanOrEqual(15_000);
|
|
36
|
+
expect(sql).toBeLessThan(16_000);
|
|
37
|
+
expect(kv).toBeGreaterThanOrEqual(16_000);
|
|
38
|
+
expect(kv).toBeLessThan(17_000);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("parseStackCredentials", () => {
|
|
43
|
+
test("reads OKE_* credential keys", () => {
|
|
44
|
+
const text = `
|
|
45
|
+
OKE_STORE_SQL_USER=oke
|
|
46
|
+
OKE_STORE_SQL_PASSWORD=s3cret
|
|
47
|
+
OKE_STORE_SQL_DB=oke
|
|
48
|
+
OKE_STORE_KV_USER=oke
|
|
49
|
+
OKE_STORE_KV_PASSWORD=kvpass
|
|
50
|
+
OKE_STORE_KV_DB=oke
|
|
51
|
+
`;
|
|
52
|
+
const creds = parseStackCredentials(text, ["store.sql", "store.kv"]);
|
|
53
|
+
expect(creds["store.sql"]?.password).toBe("s3cret");
|
|
54
|
+
expect(creds["store.kv"]?.password).toBe("kvpass");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("deriveInfrastructure instanceId", () => {
|
|
59
|
+
test("unique app name and host ports land in stackEnv URLs", () => {
|
|
60
|
+
const result = deriveInfrastructure({
|
|
61
|
+
images: {
|
|
62
|
+
"store.sql": "postgres:18-alpine",
|
|
63
|
+
"store.kv": "redis:8-alpine",
|
|
64
|
+
},
|
|
65
|
+
app: "dev-abcdef",
|
|
66
|
+
instanceId: "abcdef",
|
|
67
|
+
includeApp: false,
|
|
68
|
+
credentials: {
|
|
69
|
+
"store.sql": {
|
|
70
|
+
user: "oke",
|
|
71
|
+
password: "stack-id-test-sql-password",
|
|
72
|
+
database: "oke",
|
|
73
|
+
},
|
|
74
|
+
"store.kv": {
|
|
75
|
+
user: "oke",
|
|
76
|
+
password: "stack-id-test-kv-password",
|
|
77
|
+
database: "oke",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const base = result.files.find((f) => f.path === "compose.yml")!.content;
|
|
82
|
+
expect(base).toContain("oke-dev-abcdef");
|
|
83
|
+
expect(result.stackEnv.DATABASE_URL).toMatch(/:15\d{3}\//);
|
|
84
|
+
expect(result.stackEnv.REDIS_URL).toMatch(/:16\d{3}/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project local stack identity — unique compose project + host ports.
|
|
3
|
+
*
|
|
4
|
+
* `oke dev --stack` must not share one `oke-dev` Postgres across every app on
|
|
5
|
+
* the machine. Identity is a stable short hash of the project cwd.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import type { ServiceCredentials } from "./types.ts";
|
|
11
|
+
import { defaultHostPort, envPrefix } from "./helpers.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Stable 6-hex id for a project directory (local stacks only).
|
|
15
|
+
*
|
|
16
|
+
* @param cwd - Project root
|
|
17
|
+
*/
|
|
18
|
+
export function stackInstanceId(cwd: string): string {
|
|
19
|
+
return createHash("sha256")
|
|
20
|
+
.update(resolve(cwd))
|
|
21
|
+
.digest("hex")
|
|
22
|
+
.slice(0, 6);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compose project / app slug: `dev-<id>` → Docker name `oke-dev-<id>`.
|
|
27
|
+
*
|
|
28
|
+
* @param cwd - Project root
|
|
29
|
+
*/
|
|
30
|
+
export function stackAppSlug(cwd: string): string {
|
|
31
|
+
return `dev-${stackInstanceId(cwd)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Host port for a role, offset by instance id so two stacks can run at once.
|
|
36
|
+
*
|
|
37
|
+
* @param role - Role key
|
|
38
|
+
* @param containerPort - Container listen port
|
|
39
|
+
* @param instanceId - 6-hex stack id
|
|
40
|
+
*/
|
|
41
|
+
export function hostPortForInstance(
|
|
42
|
+
role: string,
|
|
43
|
+
containerPort: number,
|
|
44
|
+
instanceId: string,
|
|
45
|
+
): number {
|
|
46
|
+
const n = Number.parseInt(instanceId.slice(0, 4), 16) % 1000;
|
|
47
|
+
if (role === "store.sql") return 15_000 + n;
|
|
48
|
+
if (role === "store.kv") return 16_000 + n;
|
|
49
|
+
if (role === "signal") return 17_000 + n;
|
|
50
|
+
return defaultHostPort(role, containerPort) + n;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse role credentials from an existing `.env.stack` body (reuse on restart).
|
|
55
|
+
*
|
|
56
|
+
* @param text - Dotenv contents
|
|
57
|
+
* @param roles - Roles to look up
|
|
58
|
+
*/
|
|
59
|
+
export function parseStackCredentials(
|
|
60
|
+
text: string,
|
|
61
|
+
roles: readonly string[],
|
|
62
|
+
): Record<string, ServiceCredentials> {
|
|
63
|
+
const map = new Map<string, string>();
|
|
64
|
+
for (const line of text.split("\n")) {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
67
|
+
const eq = trimmed.indexOf("=");
|
|
68
|
+
if (eq <= 0) continue;
|
|
69
|
+
const key = trimmed.slice(0, eq);
|
|
70
|
+
let value = trimmed.slice(eq + 1);
|
|
71
|
+
if (
|
|
72
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
73
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
74
|
+
) {
|
|
75
|
+
value = value.slice(1, -1);
|
|
76
|
+
}
|
|
77
|
+
map.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const out: Record<string, ServiceCredentials> = {};
|
|
81
|
+
for (const role of roles) {
|
|
82
|
+
const prefix = envPrefix(role);
|
|
83
|
+
const user = map.get(`${prefix}_USER`);
|
|
84
|
+
const password = map.get(`${prefix}_PASSWORD`);
|
|
85
|
+
const database = map.get(`${prefix}_DB`);
|
|
86
|
+
if (user && password && database) {
|
|
87
|
+
out[role] = { user, password, database };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Load credentials from project `.env.stack` when present.
|
|
95
|
+
*
|
|
96
|
+
* @param cwd - Project root
|
|
97
|
+
* @param roles - Image roles
|
|
98
|
+
*/
|
|
99
|
+
export async function loadExistingStackCredentials(
|
|
100
|
+
cwd: string,
|
|
101
|
+
roles: readonly string[],
|
|
102
|
+
): Promise<Readonly<Record<string, ServiceCredentials>> | undefined> {
|
|
103
|
+
const path = resolve(cwd, ".env.stack");
|
|
104
|
+
const file = Bun.file(path);
|
|
105
|
+
if (!(await file.exists())) return undefined;
|
|
106
|
+
const parsed = parseStackCredentials(await file.text(), roles);
|
|
107
|
+
return Object.keys(parsed).length > 0 ? parsed : undefined;
|
|
108
|
+
}
|
|
@@ -32,6 +32,7 @@ describe("oke dev --stack postgres integration", () => {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const dir = await mkdtemp(join(tmpdir(), "oke-stack-pg-"));
|
|
35
|
+
const dockerDir = join(dir, "docker");
|
|
35
36
|
const project = `oke-pg-${Date.now()}`;
|
|
36
37
|
try {
|
|
37
38
|
const derived = deriveInfrastructure({
|
|
@@ -45,11 +46,16 @@ describe("oke dev --stack postgres integration", () => {
|
|
|
45
46
|
},
|
|
46
47
|
app: "stacktest",
|
|
47
48
|
host: "127.0.0.1",
|
|
49
|
+
includeApp: false,
|
|
50
|
+
composeDir: "docker",
|
|
51
|
+
});
|
|
52
|
+
await writeDerivedFiles(derived, dockerDir, {
|
|
53
|
+
writeStackEnv: true,
|
|
54
|
+
stackEnvDir: dir,
|
|
48
55
|
});
|
|
49
|
-
await writeDerivedFiles(derived, dir, { writeStackEnv: true });
|
|
50
56
|
|
|
51
|
-
//
|
|
52
|
-
const composeFiles = ["compose.store.sql.yml"];
|
|
57
|
+
// Infra-only: network + role compose (no app build).
|
|
58
|
+
const composeFiles = ["compose.yml", "compose.store.sql.yml"];
|
|
53
59
|
const up = Bun.spawn(
|
|
54
60
|
[
|
|
55
61
|
"docker",
|
|
@@ -61,7 +67,7 @@ describe("oke dev --stack postgres integration", () => {
|
|
|
61
67
|
"-d",
|
|
62
68
|
],
|
|
63
69
|
{
|
|
64
|
-
cwd:
|
|
70
|
+
cwd: dockerDir,
|
|
65
71
|
stdout: "pipe",
|
|
66
72
|
stderr: "pipe",
|
|
67
73
|
env: {
|
|
@@ -92,7 +98,7 @@ describe("oke dev --stack postgres integration", () => {
|
|
|
92
98
|
"--format",
|
|
93
99
|
"json",
|
|
94
100
|
],
|
|
95
|
-
{ cwd:
|
|
101
|
+
{ cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
|
|
96
102
|
);
|
|
97
103
|
const text = await new Response(ps.stdout).text();
|
|
98
104
|
await ps.exited;
|
|
@@ -115,11 +121,12 @@ describe("oke dev --stack postgres integration", () => {
|
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
// Prove credentials live in .env.stack, not YAML.
|
|
118
|
-
const yml = await Bun.file(join(
|
|
124
|
+
const yml = await Bun.file(join(dockerDir, "compose.store.sql.yml")).text();
|
|
119
125
|
expect(yml).not.toContain("stack-integration-pass");
|
|
120
126
|
expect(formatStackEnv(derived.stackEnv)).toContain(
|
|
121
127
|
"stack-integration-pass",
|
|
122
128
|
);
|
|
129
|
+
expect(await Bun.file(join(dir, ".env.stack")).exists()).toBe(true);
|
|
123
130
|
} finally {
|
|
124
131
|
await Bun.spawn(
|
|
125
132
|
[
|
|
@@ -128,11 +135,13 @@ describe("oke dev --stack postgres integration", () => {
|
|
|
128
135
|
"-p",
|
|
129
136
|
project,
|
|
130
137
|
"-f",
|
|
138
|
+
"compose.yml",
|
|
139
|
+
"-f",
|
|
131
140
|
"compose.store.sql.yml",
|
|
132
141
|
"down",
|
|
133
142
|
"-v",
|
|
134
143
|
],
|
|
135
|
-
{ cwd:
|
|
144
|
+
{ cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
|
|
136
145
|
).exited.catch(() => {});
|
|
137
146
|
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
138
147
|
}
|
package/src/docker/types.ts
CHANGED
|
@@ -100,7 +100,18 @@ export interface DeriveOptions {
|
|
|
100
100
|
readonly appPort?: number;
|
|
101
101
|
/** When true, emit prod overlays (limits, secret refs, deploy.replicas). */
|
|
102
102
|
readonly prod?: boolean;
|
|
103
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Include the `app` service in `compose.yml` (default true).
|
|
105
|
+
* `oke dev --stack` sets false — host Bun runs the app; Docker is infra only.
|
|
106
|
+
*/
|
|
107
|
+
readonly includeApp?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Compose artefact directory relative to the project root (default `docker`).
|
|
110
|
+
* Controls generated `env_file` / `build.context` paths (e.g. `../.env.stack`).
|
|
111
|
+
* Pass `"."` for legacy root-level layout.
|
|
112
|
+
*/
|
|
113
|
+
readonly composeDir?: string;
|
|
114
|
+
/** Output directory (default `docker`). */
|
|
104
115
|
readonly outDir?: string;
|
|
105
116
|
/** Inject credentials (tests). Defaults to generated randoms. */
|
|
106
117
|
readonly credentials?: Readonly<Record<string, ServiceCredentials>>;
|
|
@@ -108,8 +119,16 @@ export interface DeriveOptions {
|
|
|
108
119
|
readonly host?: string;
|
|
109
120
|
/** Extra image recipes (plugins). */
|
|
110
121
|
readonly recipes?: readonly ImageRecipe[];
|
|
122
|
+
/**
|
|
123
|
+
* Local stack instance id (6-hex). When set, host ports are offset so two
|
|
124
|
+
* `oke dev -s` projects do not share one Postgres on `:5432`.
|
|
125
|
+
*/
|
|
126
|
+
readonly instanceId?: string;
|
|
111
127
|
}
|
|
112
128
|
|
|
129
|
+
/** Default relative directory for generated Docker / compose artefacts. */
|
|
130
|
+
export const DEFAULT_DOCKER_DIR = "docker";
|
|
131
|
+
|
|
113
132
|
/** One generated file. */
|
|
114
133
|
export interface GeneratedFile {
|
|
115
134
|
/** Relative path under `outDir`. */
|