@elench/testkit 0.1.136 → 0.1.137
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/lib/config/index.mjs +37 -0
- package/lib/config-api/index.d.ts +31 -0
- package/lib/docker-compat/matrix.mjs +135 -0
- package/lib/kiln/client.mjs +100 -0
- package/lib/local/kiln-driver.mjs +544 -0
- package/lib/local/lifecycle.mjs +2 -0
- package/lib/local/orchestrator.mjs +34 -5
- package/lib/runner/template.mjs +33 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/node_modules/es-toolkit/CHANGELOG.md +801 -0
- package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
- package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
- package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
- package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
- package/node_modules/esprima/ChangeLog +235 -0
- package/package.json +7 -5
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { execa } from "execa";
|
|
7
|
+
import { KilnClient } from "../kiln/client.mjs";
|
|
8
|
+
import {
|
|
9
|
+
createLocalEnvironmentLifecycle,
|
|
10
|
+
getEnvironmentDir,
|
|
11
|
+
isLocalEnvironmentActive,
|
|
12
|
+
readLocalEnvironmentManifest,
|
|
13
|
+
} from "./lifecycle.mjs";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_REMOTE_PRODUCT_DIR = "/workspace/testkit-local";
|
|
16
|
+
const BOOTSTRAP_MANIFEST_PATH = ".testkit/kiln/bootstrap-manifest.json";
|
|
17
|
+
const DEFAULT_RSYNC_EXCLUDES = [
|
|
18
|
+
".git/",
|
|
19
|
+
"node_modules/",
|
|
20
|
+
"frontend/node_modules/",
|
|
21
|
+
".next/",
|
|
22
|
+
"frontend/.next/",
|
|
23
|
+
"dist/",
|
|
24
|
+
"coverage/",
|
|
25
|
+
"playwright-report/",
|
|
26
|
+
".testkit/kiln/packages/",
|
|
27
|
+
".testkit/results/",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export async function kilnLocalUp(context, environment, options = {}) {
|
|
31
|
+
const vmConfig = normalizeVMConfig(environment);
|
|
32
|
+
const remoteProductDir = environment.kiln?.workspace?.remotePath || DEFAULT_REMOTE_PRODUCT_DIR;
|
|
33
|
+
const client = new KilnClient(environment.kiln?.api || {});
|
|
34
|
+
const existing = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
35
|
+
if (existing && isLocalEnvironmentActive(existing)) {
|
|
36
|
+
throw new Error(`Local environment "${environment.name}" is already running. Stop it with testkit local down ${environment.name}.`);
|
|
37
|
+
}
|
|
38
|
+
const lifecycle = createLocalEnvironmentLifecycle(context.productDir, environment.name, {
|
|
39
|
+
target: environment.target,
|
|
40
|
+
data: environment.data,
|
|
41
|
+
portOffset: environment.portOffset,
|
|
42
|
+
driver: "kiln",
|
|
43
|
+
});
|
|
44
|
+
let ssh = null;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const network = await ensureNetwork(client, environment);
|
|
48
|
+
let vm = await ensureVM(client, vmConfig, network?.id || "");
|
|
49
|
+
vm = await ensureStarted(client, vm.name);
|
|
50
|
+
vm = await waitForVMReady(client, vm.name);
|
|
51
|
+
|
|
52
|
+
if (network && vm.network_id !== network.id) {
|
|
53
|
+
if (vm.state !== "stopped") {
|
|
54
|
+
await client.stopVM(vm.name);
|
|
55
|
+
await waitForVMState(client, vm.name, "stopped");
|
|
56
|
+
}
|
|
57
|
+
vm = await client.attachVMNetwork(vm.name, network.id);
|
|
58
|
+
vm = await ensureStarted(client, vm.name);
|
|
59
|
+
vm = await waitForVMReady(client, vm.name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const targetHost = vm.private_ip || vm.ip;
|
|
63
|
+
if (!targetHost) throw new Error(`Kiln VM ${vm.name} has no reachable IP`);
|
|
64
|
+
ssh = await prepareSSH(client, vm);
|
|
65
|
+
await waitForSSHReady(ssh, vm.name);
|
|
66
|
+
await syncWorkspace({ ssh, productDir: context.productDir, remoteProductDir, environment });
|
|
67
|
+
await bootstrapRemoteWorkspace({ ssh, context, remoteProductDir });
|
|
68
|
+
|
|
69
|
+
const remote = await remoteTestkit(ssh, remoteProductDir, [
|
|
70
|
+
"local",
|
|
71
|
+
"up",
|
|
72
|
+
environment.name,
|
|
73
|
+
...(options.service ? ["--service", options.service] : []),
|
|
74
|
+
...(environment.data === "reset" ? ["--reset"] : []),
|
|
75
|
+
...(environment.data === "rebuild" ? ["--rebuild"] : []),
|
|
76
|
+
...(environment.portOffset ? ["--port-offset", String(environment.portOffset)] : []),
|
|
77
|
+
], { TESTKIT_PUBLIC_HOST: targetHost });
|
|
78
|
+
if (remote.exitCode !== 0) {
|
|
79
|
+
throw new Error(`remote testkit local up failed\n${remote.stdout}${remote.stderr}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const status = await remoteLocalStatus(ssh, remoteProductDir, environment.name);
|
|
83
|
+
lifecycle.markRunning({
|
|
84
|
+
driver: "kiln",
|
|
85
|
+
target: environment.target,
|
|
86
|
+
remoteProductDir,
|
|
87
|
+
kiln: {
|
|
88
|
+
api: pickAPIConfig(environment.kiln?.api || {}),
|
|
89
|
+
vm: pickVM(vm),
|
|
90
|
+
network: network ? { id: network.id, name: network.name, cidr: network.cidr } : null,
|
|
91
|
+
},
|
|
92
|
+
endpoints: rewriteEndpoints(status?.manifest?.endpoints || {}, targetHost),
|
|
93
|
+
remoteStatus: status?.manifest || null,
|
|
94
|
+
});
|
|
95
|
+
return buildKilnLocalStatus(context.productDir, environment.name);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
lifecycle.requestStop("startup-failed");
|
|
98
|
+
throw error;
|
|
99
|
+
} finally {
|
|
100
|
+
ssh?.cleanup?.();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function kilnLocalDown(context, name, options = {}) {
|
|
105
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, name);
|
|
106
|
+
if (!manifest) return null;
|
|
107
|
+
if (!manifest.kiln?.vm?.name) {
|
|
108
|
+
writeKilnManifest(context.productDir, name, {
|
|
109
|
+
...manifest,
|
|
110
|
+
status: "stopped",
|
|
111
|
+
stoppedAt: new Date().toISOString(),
|
|
112
|
+
services: [],
|
|
113
|
+
});
|
|
114
|
+
return manifest;
|
|
115
|
+
}
|
|
116
|
+
const ssh = await sshFromManifest(manifest);
|
|
117
|
+
const args = ["local", "down", name, ...(options.destroyState ? ["--destroy-state"] : [])];
|
|
118
|
+
try {
|
|
119
|
+
const result = await remoteTestkit(ssh, manifest.remoteProductDir, args);
|
|
120
|
+
if (result.exitCode !== 0) {
|
|
121
|
+
throw new Error(`remote testkit local down failed\n${result.stdout}${result.stderr}`);
|
|
122
|
+
}
|
|
123
|
+
writeKilnManifest(context.productDir, name, {
|
|
124
|
+
...manifest,
|
|
125
|
+
status: "stopped",
|
|
126
|
+
stoppedAt: new Date().toISOString(),
|
|
127
|
+
services: [],
|
|
128
|
+
});
|
|
129
|
+
return manifest;
|
|
130
|
+
} finally {
|
|
131
|
+
ssh.cleanup?.();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function kilnLocalLogs(context, name, options = {}) {
|
|
136
|
+
const manifest = requireKilnManifest(context.productDir, name);
|
|
137
|
+
const ssh = await sshFromManifest(manifest);
|
|
138
|
+
const args = ["local", "logs", name, ...(options.service ? ["--service", options.service] : []), "--lines", String(options.lines || 200)];
|
|
139
|
+
try {
|
|
140
|
+
const result = await remoteTestkit(ssh, manifest.remoteProductDir, args);
|
|
141
|
+
if (result.exitCode !== 0) throw new Error(`remote testkit local logs failed\n${result.stdout}${result.stderr}`);
|
|
142
|
+
return { name, lines: splitOutput(result) };
|
|
143
|
+
} finally {
|
|
144
|
+
ssh.cleanup?.();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function kilnLocalEnv(context, name, options = {}) {
|
|
149
|
+
const manifest = requireKilnManifest(context.productDir, name);
|
|
150
|
+
const ssh = await sshFromManifest(manifest);
|
|
151
|
+
const args = ["local", "env", name, ...(options.service ? ["--service", options.service] : [])];
|
|
152
|
+
try {
|
|
153
|
+
const result = await remoteTestkit(ssh, manifest.remoteProductDir, args);
|
|
154
|
+
if (result.exitCode !== 0) throw new Error(`remote testkit local env failed\n${result.stdout}${result.stderr}`);
|
|
155
|
+
return { name, service: options.service || manifest.target, lines: splitOutput(result) };
|
|
156
|
+
} finally {
|
|
157
|
+
ssh.cleanup?.();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function kilnLocalShell(context, name, options = {}) {
|
|
162
|
+
const manifest = requireKilnManifest(context.productDir, name);
|
|
163
|
+
const ssh = await sshFromManifest(manifest);
|
|
164
|
+
const command = options.command || [];
|
|
165
|
+
if (command.length === 0) throw new Error("testkit local shell requires a command after --");
|
|
166
|
+
try {
|
|
167
|
+
return await remoteTestkit(ssh, manifest.remoteProductDir, [
|
|
168
|
+
"local",
|
|
169
|
+
"shell",
|
|
170
|
+
name,
|
|
171
|
+
...(options.service ? ["--service", options.service] : []),
|
|
172
|
+
"--",
|
|
173
|
+
...command,
|
|
174
|
+
]);
|
|
175
|
+
} finally {
|
|
176
|
+
ssh.cleanup?.();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function buildKilnLocalStatus(productDir, name) {
|
|
181
|
+
const manifest = readLocalEnvironmentManifest(productDir, name);
|
|
182
|
+
if (!manifest) return { name, exists: false, active: false, lines: [`Local Environment: ${name}`, " not found"] };
|
|
183
|
+
const endpoints = manifest.endpoints || {};
|
|
184
|
+
const lines = [
|
|
185
|
+
`Local Environment: ${name}`,
|
|
186
|
+
` status: ${manifest.status || "unknown"}`,
|
|
187
|
+
` driver: kiln`,
|
|
188
|
+
` target: ${manifest.target || "unknown"}`,
|
|
189
|
+
` vm: ${manifest.kiln?.vm?.name || "unknown"} ${manifest.kiln?.vm?.private_ip || manifest.kiln?.vm?.ip || ""}`.trimEnd(),
|
|
190
|
+
` remote: ${manifest.remoteProductDir || "unknown"}`,
|
|
191
|
+
];
|
|
192
|
+
if (Object.keys(endpoints).length > 0) {
|
|
193
|
+
lines.push(" endpoints:");
|
|
194
|
+
for (const [service, url] of Object.entries(endpoints)) lines.push(` ${service}: ${url}`);
|
|
195
|
+
}
|
|
196
|
+
return { name, exists: true, active: manifest.status === "running", manifest, services: [], lines };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function ensureNetwork(client, environment) {
|
|
200
|
+
const networkConfig = environment.kiln?.network;
|
|
201
|
+
if (!networkConfig) return null;
|
|
202
|
+
if (networkConfig.id) {
|
|
203
|
+
const network = { id: networkConfig.id, name: networkConfig.name || networkConfig.id };
|
|
204
|
+
await attachExistingVMs(client, network, networkConfig.attachExistingVMs || []);
|
|
205
|
+
return network;
|
|
206
|
+
}
|
|
207
|
+
const name = networkConfig.name || `testkit-${environment.name}`;
|
|
208
|
+
const networks = await client.listNetworks();
|
|
209
|
+
const existing = networks.find((network) => network.name === name);
|
|
210
|
+
const network = existing || (await client.createNetwork({ name, ...(networkConfig.cidr ? { cidr: networkConfig.cidr } : {}) }));
|
|
211
|
+
await attachExistingVMs(client, network, networkConfig.attachExistingVMs || []);
|
|
212
|
+
return network;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function attachExistingVMs(client, network, vmRefs) {
|
|
216
|
+
for (const ref of vmRefs || []) {
|
|
217
|
+
let vm = await client.getVM(ref);
|
|
218
|
+
if (vm.network_id === network.id) continue;
|
|
219
|
+
if (vm.state !== "stopped") {
|
|
220
|
+
await client.stopVM(ref);
|
|
221
|
+
await waitForVMState(client, ref, "stopped");
|
|
222
|
+
}
|
|
223
|
+
await client.attachVMNetwork(ref, network.id);
|
|
224
|
+
await client.startVM(ref);
|
|
225
|
+
await waitForVMReady(client, ref);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function ensureVM(client, vmConfig, networkId) {
|
|
230
|
+
try {
|
|
231
|
+
const existing = await client.getVM(vmConfig.name);
|
|
232
|
+
if (existing.profile && existing.profile !== vmConfig.profile) {
|
|
233
|
+
throw new Error(`Kiln VM ${vmConfig.name} uses profile ${existing.profile}; expected ${vmConfig.profile}`);
|
|
234
|
+
}
|
|
235
|
+
return existing;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (!String(error.message || "").includes("404")) throw error;
|
|
238
|
+
}
|
|
239
|
+
return client.createVM({
|
|
240
|
+
name: vmConfig.name,
|
|
241
|
+
profile: vmConfig.profile,
|
|
242
|
+
network_id: networkId || vmConfig.networkId || "",
|
|
243
|
+
disk_size_mb: vmConfig.diskSizeMB,
|
|
244
|
+
memory_mb: vmConfig.memoryMB,
|
|
245
|
+
vcpus: vmConfig.vcpus,
|
|
246
|
+
autostart: Boolean(vmConfig.autostart),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function ensureStarted(client, ref) {
|
|
251
|
+
const vm = await client.getVM(ref);
|
|
252
|
+
if (vm.state === "running" || vm.state === "creating") return vm;
|
|
253
|
+
return client.startVM(ref);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function waitForVMReady(client, ref) {
|
|
257
|
+
const startedAt = Date.now();
|
|
258
|
+
let last = null;
|
|
259
|
+
let lastError = null;
|
|
260
|
+
while (Date.now() - startedAt < 180_000) {
|
|
261
|
+
last = await client.getVM(ref);
|
|
262
|
+
if (last.state === "running") {
|
|
263
|
+
try {
|
|
264
|
+
const result = await client.execVM(ref, "echo testkit-kiln-ready");
|
|
265
|
+
if (result.exit_code === 0 && result.stdout.includes("testkit-kiln-ready")) return await client.getVM(ref);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
lastError = error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
await sleep(1000);
|
|
271
|
+
}
|
|
272
|
+
const suffix = lastError ? `; last readiness error: ${lastError.message}` : "";
|
|
273
|
+
throw new Error(`Timed out waiting for Kiln VM ${ref}; last state ${last?.state || "unknown"}${suffix}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function waitForVMState(client, ref, state) {
|
|
277
|
+
const startedAt = Date.now();
|
|
278
|
+
let last = null;
|
|
279
|
+
while (Date.now() - startedAt < 120_000) {
|
|
280
|
+
last = await client.getVM(ref);
|
|
281
|
+
if (last.state === state) return last;
|
|
282
|
+
await sleep(1000);
|
|
283
|
+
}
|
|
284
|
+
throw new Error(`Timed out waiting for Kiln VM ${ref} to become ${state}; last state ${last?.state || "unknown"}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function prepareSSH(client, vm) {
|
|
288
|
+
const key = await client.sshKey(vm.machine_id);
|
|
289
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-kiln-ssh-"));
|
|
290
|
+
const keyPath = path.join(dir, "id_ed25519");
|
|
291
|
+
fs.writeFileSync(keyPath, key, { mode: 0o600 });
|
|
292
|
+
return { host: vm.ip, keyPath, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function sshFromManifest(manifest) {
|
|
296
|
+
const client = new KilnClient(manifest.kiln?.api || {});
|
|
297
|
+
const vm = await client.getVM(manifest.kiln.vm.name);
|
|
298
|
+
const started = vm.state === "running" ? vm : await ensureStarted(client, vm.name);
|
|
299
|
+
const running = started.state === "running" ? started : await waitForVMReady(client, vm.name);
|
|
300
|
+
const ssh = await prepareSSH(client, running);
|
|
301
|
+
await waitForSSHReady(ssh, vm.name);
|
|
302
|
+
return ssh;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function waitForSSHReady(ssh, vmName) {
|
|
306
|
+
const startedAt = Date.now();
|
|
307
|
+
let lastError = null;
|
|
308
|
+
while (Date.now() - startedAt < 120_000) {
|
|
309
|
+
try {
|
|
310
|
+
await sshCommand(ssh, "true");
|
|
311
|
+
return;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
lastError = error;
|
|
314
|
+
await sleep(1000);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw new Error(`Timed out waiting for SSH in Kiln VM ${vmName}: ${lastError?.message || "unknown error"}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function syncWorkspace({ ssh, productDir, remoteProductDir, environment }) {
|
|
321
|
+
await sshCommand(ssh, `mkdir -p ${shellQuote(remoteProductDir)}`);
|
|
322
|
+
const excludes = [...DEFAULT_RSYNC_EXCLUDES, ...(environment.kiln?.workspace?.exclude || [])];
|
|
323
|
+
const args = [
|
|
324
|
+
"-az",
|
|
325
|
+
"--delete",
|
|
326
|
+
...excludes.flatMap((entry) => ["--exclude", entry]),
|
|
327
|
+
"-e",
|
|
328
|
+
sshTransport(ssh),
|
|
329
|
+
`${path.resolve(productDir)}/`,
|
|
330
|
+
`root@${ssh.host}:${remoteProductDir}/`,
|
|
331
|
+
];
|
|
332
|
+
await execa("rsync", args, { stdio: "inherit" });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function bootstrapRemoteWorkspace({ ssh, context, remoteProductDir }) {
|
|
336
|
+
const nodeVersion = readDesiredNodeVersion(context.productDir);
|
|
337
|
+
const packPath = await packCurrentTestkit(context.productDir);
|
|
338
|
+
const manifest = buildBootstrapManifest(context.productDir, { nodeVersion, testkitPackPath: packPath });
|
|
339
|
+
const remotePack = `/tmp/${path.basename(packPath)}`;
|
|
340
|
+
const localManifest = path.join(context.productDir, ".testkit", "kiln", "bootstrap-manifest.json");
|
|
341
|
+
fs.mkdirSync(path.dirname(localManifest), { recursive: true });
|
|
342
|
+
fs.writeFileSync(localManifest, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
343
|
+
await execa("rsync", ["-az", "-e", sshTransport(ssh), packPath, `root@${ssh.host}:${remotePack}`], { stdio: "inherit" });
|
|
344
|
+
await execa("rsync", ["-az", "-e", sshTransport(ssh), localManifest, `root@${ssh.host}:/tmp/testkit-bootstrap-manifest.json`], { stdio: "inherit" });
|
|
345
|
+
await sshCommand(ssh, [
|
|
346
|
+
"set -eu",
|
|
347
|
+
`NODE_VERSION=${shellQuote(nodeVersion)}`,
|
|
348
|
+
`REMOTE_DIR=${shellQuote(remoteProductDir)}`,
|
|
349
|
+
`BOOTSTRAP_MANIFEST=${shellQuote(BOOTSTRAP_MANIFEST_PATH)}`,
|
|
350
|
+
"NODE_HOME=/opt/testkit-node-$NODE_VERSION",
|
|
351
|
+
"if [ ! -x \"$NODE_HOME/bin/node\" ]; then",
|
|
352
|
+
" mkdir -p /opt",
|
|
353
|
+
" curl -fsSL \"https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz\" -o /tmp/testkit-node.tar.xz",
|
|
354
|
+
" rm -rf \"$NODE_HOME\"",
|
|
355
|
+
" mkdir -p \"$NODE_HOME\"",
|
|
356
|
+
" tar -xJf /tmp/testkit-node.tar.xz -C \"$NODE_HOME\" --strip-components=1",
|
|
357
|
+
"fi",
|
|
358
|
+
"export PATH=\"$NODE_HOME/bin:$PATH\"",
|
|
359
|
+
"deadline=$(($(date +%s) + 120))",
|
|
360
|
+
"until docker info >/dev/null 2>&1; do",
|
|
361
|
+
" if [ \"$(date +%s)\" -ge \"$deadline\" ]; then tail -80 /var/log/dockerd.log >&2 || true; exit 1; fi",
|
|
362
|
+
" sleep 1",
|
|
363
|
+
"done",
|
|
364
|
+
"cd \"$REMOTE_DIR\"",
|
|
365
|
+
"BOOTSTRAP_READY=1",
|
|
366
|
+
"[ -x ./node_modules/.bin/testkit ] || BOOTSTRAP_READY=0",
|
|
367
|
+
"if [ -f frontend/package-lock.json ] && [ ! -d frontend/node_modules ]; then BOOTSTRAP_READY=0; fi",
|
|
368
|
+
"if [ -f \"$BOOTSTRAP_MANIFEST\" ] && cmp -s /tmp/testkit-bootstrap-manifest.json \"$BOOTSTRAP_MANIFEST\" && [ \"$BOOTSTRAP_READY\" = 1 ]; then",
|
|
369
|
+
" echo 'testkit kiln bootstrap cache hit'",
|
|
370
|
+
"else",
|
|
371
|
+
" npm ci",
|
|
372
|
+
" if [ -f frontend/package-lock.json ]; then npm --prefix frontend ci; fi",
|
|
373
|
+
` npm install --no-save ${shellQuote(remotePack)}`,
|
|
374
|
+
" mkdir -p \"$(dirname \"$BOOTSTRAP_MANIFEST\")\"",
|
|
375
|
+
" cp /tmp/testkit-bootstrap-manifest.json \"$BOOTSTRAP_MANIFEST\"",
|
|
376
|
+
"fi",
|
|
377
|
+
].join("\n"));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function remoteTestkit(ssh, remoteProductDir, args, extraEnv = {}) {
|
|
381
|
+
const envPrefix = Object.entries({ TESTKIT_KILN_INNER: "1", TESTKIT_LOCAL_DRIVER: "host", ...extraEnv })
|
|
382
|
+
.map(([key, value]) => `${key}=${shellQuote(value)}`)
|
|
383
|
+
.join(" ");
|
|
384
|
+
const command = [
|
|
385
|
+
`cd ${shellQuote(remoteProductDir)}`,
|
|
386
|
+
`BOOTSTRAP_MANIFEST=${shellQuote(BOOTSTRAP_MANIFEST_PATH)}`,
|
|
387
|
+
"NODE_VERSION=$(sed -n 's/.*\"nodeVersion\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$BOOTSTRAP_MANIFEST\" | head -1)",
|
|
388
|
+
"[ -n \"$NODE_VERSION\" ]",
|
|
389
|
+
"NODE_HOME=/opt/testkit-node-$NODE_VERSION",
|
|
390
|
+
"export PATH=\"$NODE_HOME/bin:$PATH\"",
|
|
391
|
+
`export ${envPrefix}`,
|
|
392
|
+
`./node_modules/.bin/testkit ${args.map(shellQuote).join(" ")}`,
|
|
393
|
+
].join(" && ");
|
|
394
|
+
return sshCommand(ssh, command, { reject: false });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function remoteLocalStatus(ssh, remoteProductDir, name) {
|
|
398
|
+
const result = await remoteTestkit(ssh, remoteProductDir, ["local", "status", name, "--json"]);
|
|
399
|
+
if (result.exitCode !== 0) return null;
|
|
400
|
+
try {
|
|
401
|
+
return JSON.parse(result.stdout)?.result || null;
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function sshCommand(ssh, command, options = {}) {
|
|
408
|
+
const args = sshArgs(ssh, command);
|
|
409
|
+
return execa("ssh", args, { reject: options.reject !== false });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function sshArgs(ssh, command) {
|
|
413
|
+
return [
|
|
414
|
+
"-i",
|
|
415
|
+
ssh.keyPath,
|
|
416
|
+
"-o",
|
|
417
|
+
"StrictHostKeyChecking=no",
|
|
418
|
+
"-o",
|
|
419
|
+
"UserKnownHostsFile=/dev/null",
|
|
420
|
+
"-o",
|
|
421
|
+
"LogLevel=ERROR",
|
|
422
|
+
`root@${ssh.host}`,
|
|
423
|
+
command,
|
|
424
|
+
];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function sshTransport(ssh) {
|
|
428
|
+
return `ssh -i ${ssh.keyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normalizeVMConfig(environment) {
|
|
432
|
+
const vm = environment.kiln?.vm || {};
|
|
433
|
+
const name = vm.name || `testkit-${environment.name}`;
|
|
434
|
+
return {
|
|
435
|
+
name,
|
|
436
|
+
profile: vm.profile || "ubuntu-docker",
|
|
437
|
+
diskSizeMB: parseDiskMB(vm.disk || vm.diskSize || vm.diskSizeMB || "64G"),
|
|
438
|
+
memoryMB: Number(vm.memoryMB || vm.memoryMb || vm.memory_mib || 8192),
|
|
439
|
+
vcpus: Number(vm.vcpus || 4),
|
|
440
|
+
autostart: Boolean(vm.autostart),
|
|
441
|
+
networkId: vm.networkId || vm.network_id || "",
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function parseDiskMB(value) {
|
|
446
|
+
if (typeof value === "number") return value;
|
|
447
|
+
const raw = String(value || "").trim();
|
|
448
|
+
const match = raw.match(/^(\d+)([gGmM])?$/);
|
|
449
|
+
if (!match) throw new Error(`Invalid Kiln VM disk size: ${value}`);
|
|
450
|
+
const amount = Number(match[1]);
|
|
451
|
+
return match[2]?.toLowerCase() === "g" ? amount * 1024 : amount;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function readDesiredNodeVersion(productDir) {
|
|
455
|
+
try {
|
|
456
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(productDir, "package.json"), "utf8"));
|
|
457
|
+
return pkg.volta?.node || String(pkg.engines?.node || "20.19.5").match(/\d+\.\d+\.\d+/)?.[0] || "20.19.5";
|
|
458
|
+
} catch {
|
|
459
|
+
return "20.19.5";
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function packCurrentTestkit(productDir) {
|
|
464
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
465
|
+
const outDir = path.join(productDir, ".testkit", "kiln", "packages");
|
|
466
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
467
|
+
const { stdout } = await execa("npm", ["pack", "--pack-destination", outDir, "--silent"], { cwd: packageRoot });
|
|
468
|
+
return path.join(outDir, stdout.trim().split(/\r?\n/).at(-1));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function buildBootstrapManifest(productDir, { nodeVersion, testkitPackPath }) {
|
|
472
|
+
return {
|
|
473
|
+
version: 1,
|
|
474
|
+
nodeVersion,
|
|
475
|
+
testkitPackSha256: sha256File(testkitPackPath),
|
|
476
|
+
locks: Object.fromEntries(
|
|
477
|
+
["package-lock.json", "frontend/package-lock.json"]
|
|
478
|
+
.filter((entry) => fs.existsSync(path.join(productDir, entry)))
|
|
479
|
+
.map((entry) => [entry, sha256File(path.join(productDir, entry))])
|
|
480
|
+
),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function sha256File(filePath) {
|
|
485
|
+
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function rewriteEndpoints(endpoints, host) {
|
|
489
|
+
return Object.fromEntries(
|
|
490
|
+
Object.entries(endpoints || {}).map(([service, raw]) => {
|
|
491
|
+
try {
|
|
492
|
+
const url = new URL(raw);
|
|
493
|
+
url.hostname = host;
|
|
494
|
+
return [service, url.toString().replace(/\/$/, "")];
|
|
495
|
+
} catch {
|
|
496
|
+
return [service, raw];
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function requireKilnManifest(productDir, name) {
|
|
503
|
+
const manifest = readLocalEnvironmentManifest(productDir, name);
|
|
504
|
+
if (!manifest) throw new Error(`Local environment "${name}" has not been started.`);
|
|
505
|
+
if (manifest.driver !== "kiln") throw new Error(`Local environment "${name}" is not managed by the Kiln driver.`);
|
|
506
|
+
if (!manifest.kiln?.vm?.name) throw new Error(`Local environment "${name}" has no Kiln VM recorded.`);
|
|
507
|
+
return manifest;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function writeKilnManifest(productDir, name, manifest) {
|
|
511
|
+
fs.writeFileSync(
|
|
512
|
+
path.join(getEnvironmentDir(productDir, name), "manifest.json"),
|
|
513
|
+
`${JSON.stringify(manifest, null, 2)}\n`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function pickVM(vm) {
|
|
518
|
+
return {
|
|
519
|
+
id: vm.id,
|
|
520
|
+
name: vm.name,
|
|
521
|
+
machine_id: vm.machine_id,
|
|
522
|
+
ip: vm.ip,
|
|
523
|
+
private_ip: vm.private_ip,
|
|
524
|
+
state: vm.state,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function pickAPIConfig(api) {
|
|
529
|
+
return {
|
|
530
|
+
...(api.apiUrl ? { apiUrl: api.apiUrl } : {}),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function splitOutput(result) {
|
|
535
|
+
return [result.stdout, result.stderr].filter(Boolean).join("").split(/\r?\n/).filter(Boolean);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function shellQuote(value) {
|
|
539
|
+
return "'" + String(value ?? "").replaceAll("'", "'\"'\"'") + "'";
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function sleep(ms) {
|
|
543
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
544
|
+
}
|
package/lib/local/lifecycle.mjs
CHANGED
|
@@ -19,6 +19,7 @@ export function createLocalEnvironmentLifecycle(productDir, name, options = {})
|
|
|
19
19
|
const state = {
|
|
20
20
|
schemaVersion: SCHEMA_VERSION,
|
|
21
21
|
kind: "local",
|
|
22
|
+
driver: options.driver || "host",
|
|
22
23
|
name,
|
|
23
24
|
productDir,
|
|
24
25
|
pid: process.pid,
|
|
@@ -200,6 +201,7 @@ export function findLocalPortOwner(productDir, { host, port }) {
|
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
export function isLocalEnvironmentActive(manifest) {
|
|
204
|
+
if (manifest.driver === "kiln") return manifest.status === "running";
|
|
203
205
|
return (manifest.services || []).some(isLocalServiceActive);
|
|
204
206
|
}
|
|
205
207
|
|
|
@@ -8,6 +8,14 @@ import { resolveRuntimeConfigs } from "../runner/planning.mjs";
|
|
|
8
8
|
import { startLocalServices } from "../runner/services.mjs";
|
|
9
9
|
import { buildExecutionEnv, resolveRuntimeInstanceConfigs } from "../runner/template.mjs";
|
|
10
10
|
import { readDatabaseUrl } from "../runner/state-io.mjs";
|
|
11
|
+
import {
|
|
12
|
+
buildKilnLocalStatus,
|
|
13
|
+
kilnLocalDown,
|
|
14
|
+
kilnLocalEnv,
|
|
15
|
+
kilnLocalLogs,
|
|
16
|
+
kilnLocalShell,
|
|
17
|
+
kilnLocalUp,
|
|
18
|
+
} from "./kiln-driver.mjs";
|
|
11
19
|
import {
|
|
12
20
|
cleanupStaleLocalEnvironments,
|
|
13
21
|
createLocalEnvironmentLifecycle,
|
|
@@ -21,6 +29,9 @@ import {
|
|
|
21
29
|
export async function localUp(options = {}) {
|
|
22
30
|
const context = await loadConfigContext({ dir: options.dir });
|
|
23
31
|
const environment = resolveEnvironment(context, options);
|
|
32
|
+
if (environment.driver === "kiln") {
|
|
33
|
+
return kilnLocalUp(context, environment, options);
|
|
34
|
+
}
|
|
24
35
|
const existing = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
25
36
|
if (existing && isLocalEnvironmentActive(existing)) {
|
|
26
37
|
throw new Error(`Local environment "${environment.name}" is already running. Stop it with testkit local down ${environment.name}.`);
|
|
@@ -77,21 +88,30 @@ export async function localUp(options = {}) {
|
|
|
77
88
|
export async function localDown(options = {}) {
|
|
78
89
|
const context = await loadConfigContext({ dir: options.dir });
|
|
79
90
|
const name = normalizeEnvironmentName(options.name || "local");
|
|
80
|
-
const manifest =
|
|
91
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, name);
|
|
92
|
+
if (manifest?.driver === "kiln") {
|
|
93
|
+
await kilnLocalDown(context, name, { destroyState: Boolean(options.destroyState) });
|
|
94
|
+
return buildKilnLocalStatus(context.productDir, name);
|
|
95
|
+
}
|
|
96
|
+
const stopped = await stopLocalEnvironment(context.productDir, name, {
|
|
81
97
|
removeRuntimeState: Boolean(options.destroyState),
|
|
82
98
|
});
|
|
83
|
-
return
|
|
99
|
+
return stopped
|
|
84
100
|
? buildLocalStatus(context.productDir, name)
|
|
85
101
|
: { name, exists: false, lines: [`Local Environment: ${name}`, " not found"] };
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
export async function localStatus(options = {}) {
|
|
89
105
|
const context = await loadConfigContext({ dir: options.dir });
|
|
90
|
-
if (options.name)
|
|
106
|
+
if (options.name) {
|
|
107
|
+
const name = normalizeEnvironmentName(options.name);
|
|
108
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, name);
|
|
109
|
+
return manifest?.driver === "kiln" ? buildKilnLocalStatus(context.productDir, name) : buildLocalStatus(context.productDir, name);
|
|
110
|
+
}
|
|
91
111
|
return {
|
|
92
112
|
productDir: context.productDir,
|
|
93
113
|
environments: listLocalEnvironmentManifests(context.productDir).map((manifest) =>
|
|
94
|
-
buildLocalStatus(context.productDir, manifest.name)
|
|
114
|
+
manifest.driver === "kiln" ? buildKilnLocalStatus(context.productDir, manifest.name) : buildLocalStatus(context.productDir, manifest.name)
|
|
95
115
|
),
|
|
96
116
|
};
|
|
97
117
|
}
|
|
@@ -101,6 +121,7 @@ export async function localEnv(options = {}) {
|
|
|
101
121
|
const environment = resolveEnvironment(context, options);
|
|
102
122
|
const manifest = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
103
123
|
if (!manifest) throw new Error(`Local environment "${environment.name}" has not been started.`);
|
|
124
|
+
if (manifest.driver === "kiln") return kilnLocalEnv(context, environment.name, options);
|
|
104
125
|
const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, manifest.runtimeDir);
|
|
105
126
|
const serviceConfig = resolveService(runtimeConfigs, options.service || environment.target);
|
|
106
127
|
const env = buildServiceEnv(serviceConfig, environment.env);
|
|
@@ -119,6 +140,7 @@ export async function localLogs(options = {}) {
|
|
|
119
140
|
const name = normalizeEnvironmentName(options.name || "local");
|
|
120
141
|
const manifest = readLocalEnvironmentManifest(context.productDir, name);
|
|
121
142
|
if (!manifest) throw new Error(`Local environment "${name}" has not been started.`);
|
|
143
|
+
if (manifest.driver === "kiln") return kilnLocalLogs(context, name, options);
|
|
122
144
|
const logsDir = path.join(getEnvironmentDir(context.productDir, name), "logs");
|
|
123
145
|
const serviceNames = options.service
|
|
124
146
|
? [options.service]
|
|
@@ -140,6 +162,7 @@ export async function localShell(options = {}) {
|
|
|
140
162
|
const environment = resolveEnvironment(context, options);
|
|
141
163
|
const manifest = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
142
164
|
if (!manifest) throw new Error(`Local environment "${environment.name}" has not been started.`);
|
|
165
|
+
if (manifest.driver === "kiln") return kilnLocalShell(context, environment.name, options);
|
|
143
166
|
const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, manifest.runtimeDir);
|
|
144
167
|
const serviceConfig = resolveService(runtimeConfigs, options.service || environment.target);
|
|
145
168
|
const env = buildServiceEnv(serviceConfig, environment.env);
|
|
@@ -162,6 +185,7 @@ export function buildLocalRuntimeConfigs(allConfigs, environment, runtimeDir) {
|
|
|
162
185
|
return resolveRuntimeInstanceConfigs(runtimeConfigs, "local", runtimeDir, {
|
|
163
186
|
graphDirName: runtimeConfigs.map((config) => config.name).sort().join("__"),
|
|
164
187
|
portOffset: environment.portOffset || 0,
|
|
188
|
+
publicHost: environment.publicHost || null,
|
|
165
189
|
}).map((config) => applyLocalEnvironmentConfig(config, environment));
|
|
166
190
|
}
|
|
167
191
|
|
|
@@ -202,17 +226,22 @@ export function buildLocalStatus(productDir, name) {
|
|
|
202
226
|
};
|
|
203
227
|
}
|
|
204
228
|
|
|
205
|
-
function resolveEnvironment(context, options = {}) {
|
|
229
|
+
export function resolveEnvironment(context, options = {}) {
|
|
206
230
|
const name = normalizeEnvironmentName(options.name || "local");
|
|
207
231
|
const configured = context.environments[name] || null;
|
|
208
232
|
const target = options.service || configured?.target || inferDefaultTarget(context.configs);
|
|
233
|
+
const requestedDriver = process.env.TESTKIT_LOCAL_DRIVER || options.driver || configured?.driver || "host";
|
|
234
|
+
const driver = process.env.TESTKIT_KILN_INNER === "1" ? "host" : requestedDriver;
|
|
209
235
|
return {
|
|
210
236
|
name,
|
|
211
237
|
kind: "local",
|
|
238
|
+
driver,
|
|
212
239
|
target,
|
|
213
240
|
data: options.rebuild ? "rebuild" : options.reset ? "reset" : configured?.data || "reuse",
|
|
214
241
|
portOffset: Number(options.portOffset ?? configured?.portOffset ?? 0),
|
|
215
242
|
env: { ...(configured?.env || {}) },
|
|
243
|
+
kiln: configured?.kiln || null,
|
|
244
|
+
publicHost: process.env.TESTKIT_PUBLIC_HOST || configured?.publicHost || null,
|
|
216
245
|
};
|
|
217
246
|
}
|
|
218
247
|
|