@elench/testkit 0.1.136 → 0.1.138
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 +81 -2
- package/lib/config-api/index.d.ts +77 -0
- package/lib/config-api/index.mjs +78 -1
- package/lib/docker-compat/matrix.mjs +135 -0
- package/lib/kiln/client.mjs +120 -0
- package/lib/local/kiln-driver.mjs +640 -0
- package/lib/local/lifecycle.mjs +2 -0
- package/lib/local/orchestrator.mjs +36 -5
- package/lib/runner/template.mjs +70 -5
- 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/package.json +7 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +188 -0
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +1 -0
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +293 -0
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +1 -0
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +25 -0
package/lib/config/index.mjs
CHANGED
|
@@ -224,12 +224,71 @@ function normalizeEnvironmentConfig(name, environment) {
|
|
|
224
224
|
if (!Number.isInteger(portOffset) || portOffset < 0) {
|
|
225
225
|
throw new Error(`Environment "${name}" portOffset must be a non-negative integer`);
|
|
226
226
|
}
|
|
227
|
+
const driver = environment.driver || "host";
|
|
228
|
+
if (!["host", "kiln"].includes(driver)) {
|
|
229
|
+
throw new Error(`Environment "${name}" driver must be one of: host, kiln`);
|
|
230
|
+
}
|
|
231
|
+
const kiln = normalizeKilnEnvironmentConfig(name, environment.kiln);
|
|
227
232
|
return {
|
|
228
233
|
kind: "local",
|
|
234
|
+
driver,
|
|
229
235
|
target,
|
|
230
236
|
data,
|
|
231
237
|
portOffset,
|
|
232
238
|
env: normalizeEnvironmentEnv(environment.env),
|
|
239
|
+
resources: normalizeEnvironmentResources(name, environment.resources),
|
|
240
|
+
productionLike: Boolean(environment.productionLike),
|
|
241
|
+
...(environment.publicHost ? { publicHost: String(environment.publicHost) } : {}),
|
|
242
|
+
...(kiln ? { kiln } : {}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeEnvironmentResources(environmentName, resources = {}) {
|
|
247
|
+
if (resources == null) return {};
|
|
248
|
+
if (typeof resources !== "object" || Array.isArray(resources)) {
|
|
249
|
+
throw new Error(`Environment "${environmentName}" resources must be an object`);
|
|
250
|
+
}
|
|
251
|
+
return Object.fromEntries(
|
|
252
|
+
Object.entries(resources).map(([name, resource]) => {
|
|
253
|
+
const normalizedName = normalizeDatabaseEnvToken(name, `Environment "${environmentName}" resource name`, false);
|
|
254
|
+
if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
|
|
255
|
+
throw new Error(`Environment "${environmentName}" resource "${name}" must be an object`);
|
|
256
|
+
}
|
|
257
|
+
const kind = String(resource.kind || "").trim();
|
|
258
|
+
if (!["postgres", "server"].includes(kind)) {
|
|
259
|
+
throw new Error(`Environment "${environmentName}" resource "${name}" kind must be "postgres" or "server"`);
|
|
260
|
+
}
|
|
261
|
+
return [normalizedName, { ...resource, kind }];
|
|
262
|
+
})
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeKilnEnvironmentConfig(name, kiln) {
|
|
267
|
+
if (!kiln) return null;
|
|
268
|
+
if (typeof kiln !== "object" || Array.isArray(kiln)) {
|
|
269
|
+
throw new Error(`Environment "${name}" kiln must be an object`);
|
|
270
|
+
}
|
|
271
|
+
const vm = kiln.vm || {};
|
|
272
|
+
if (vm && (typeof vm !== "object" || Array.isArray(vm))) {
|
|
273
|
+
throw new Error(`Environment "${name}" kiln.vm must be an object`);
|
|
274
|
+
}
|
|
275
|
+
const network = kiln.network || null;
|
|
276
|
+
if (network && (typeof network !== "object" || Array.isArray(network))) {
|
|
277
|
+
throw new Error(`Environment "${name}" kiln.network must be an object`);
|
|
278
|
+
}
|
|
279
|
+
const workspace = kiln.workspace || null;
|
|
280
|
+
if (workspace && (typeof workspace !== "object" || Array.isArray(workspace))) {
|
|
281
|
+
throw new Error(`Environment "${name}" kiln.workspace must be an object`);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
...kiln,
|
|
285
|
+
vm: {
|
|
286
|
+
...(vm || {}),
|
|
287
|
+
...(vm.name ? { name: String(vm.name) } : {}),
|
|
288
|
+
...(vm.profile ? { profile: String(vm.profile) } : {}),
|
|
289
|
+
},
|
|
290
|
+
...(network ? { network: { ...network } } : {}),
|
|
291
|
+
...(workspace ? { workspace: { ...workspace } } : {}),
|
|
233
292
|
};
|
|
234
293
|
}
|
|
235
294
|
|
|
@@ -240,13 +299,14 @@ function normalizeEnvironmentEnv(env) {
|
|
|
240
299
|
}
|
|
241
300
|
const hasPresetShape =
|
|
242
301
|
Object.prototype.hasOwnProperty.call(env, "values") ||
|
|
243
|
-
Object.prototype.hasOwnProperty.call(env, "databases")
|
|
302
|
+
Object.prototype.hasOwnProperty.call(env, "databases") ||
|
|
303
|
+
Object.prototype.hasOwnProperty.call(env, "resources");
|
|
244
304
|
if (!hasPresetShape) {
|
|
245
305
|
return Object.fromEntries(
|
|
246
306
|
Object.entries(env).map(([key, value]) => [key, String(value)])
|
|
247
307
|
);
|
|
248
308
|
}
|
|
249
|
-
const allowedKeys = new Set(["values", "databases"]);
|
|
309
|
+
const allowedKeys = new Set(["values", "databases", "resources"]);
|
|
250
310
|
const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
|
|
251
311
|
if (unexpectedKeys.length > 0) {
|
|
252
312
|
throw new Error(
|
|
@@ -256,8 +316,11 @@ function normalizeEnvironmentEnv(env) {
|
|
|
256
316
|
const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
|
|
257
317
|
const databases =
|
|
258
318
|
env.databases && typeof env.databases === "object" && !Array.isArray(env.databases) ? env.databases : {};
|
|
319
|
+
const resources =
|
|
320
|
+
env.resources && typeof env.resources === "object" && !Array.isArray(env.resources) ? env.resources : {};
|
|
259
321
|
return {
|
|
260
322
|
...expandDatabaseBindings(databases),
|
|
323
|
+
...expandResourceBindings(resources),
|
|
261
324
|
...Object.fromEntries(Object.entries(values).map(([key, value]) => [key, String(value)])),
|
|
262
325
|
};
|
|
263
326
|
}
|
|
@@ -280,6 +343,22 @@ function expandDatabaseBindings(bindings) {
|
|
|
280
343
|
return env;
|
|
281
344
|
}
|
|
282
345
|
|
|
346
|
+
function expandResourceBindings(bindings) {
|
|
347
|
+
const env = {};
|
|
348
|
+
for (const [envName, binding] of Object.entries(bindings || {})) {
|
|
349
|
+
if (typeof binding === "string") {
|
|
350
|
+
const [resourceName, field = "url"] = binding.split(".");
|
|
351
|
+
env[envName] = `{resource:${normalizeDatabaseEnvToken(resourceName, `env.resources.${envName}`, false)}:${normalizeDatabaseEnvToken(field, `env.resources.${envName} field`, false)}}`;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
|
|
355
|
+
throw new Error(`env.resources.${envName} must be a string or object`);
|
|
356
|
+
}
|
|
357
|
+
env[envName] = `{resource:${normalizeDatabaseEnvToken(binding.resource, `env.resources.${envName}.resource`, false)}:${normalizeDatabaseEnvToken(binding.field || "url", `env.resources.${envName}.field`, false)}}`;
|
|
358
|
+
}
|
|
359
|
+
return env;
|
|
360
|
+
}
|
|
361
|
+
|
|
283
362
|
function normalizeDatabaseEnvToken(value, label, sanitize = true) {
|
|
284
363
|
const raw = String(value || "").trim();
|
|
285
364
|
const normalized = sanitize
|
|
@@ -280,13 +280,50 @@ export interface DatabaseBindingEnvConfig {
|
|
|
280
280
|
export interface PresetEnvConfig {
|
|
281
281
|
values?: Record<string, string>;
|
|
282
282
|
databases?: Record<string, DatabaseBindingEnvConfig>;
|
|
283
|
+
resources?: Record<string, string | { resource: string; field?: string }>;
|
|
283
284
|
}
|
|
284
285
|
|
|
286
|
+
export interface KilnVMResourceConfig {
|
|
287
|
+
autostart?: boolean;
|
|
288
|
+
disk?: string;
|
|
289
|
+
diskSize?: string | number;
|
|
290
|
+
diskSizeMB?: number;
|
|
291
|
+
memoryMB?: number;
|
|
292
|
+
name?: string;
|
|
293
|
+
networkId?: string;
|
|
294
|
+
profile?: string;
|
|
295
|
+
vcpus?: number;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface PostgresResourceConfig {
|
|
299
|
+
kind: "postgres";
|
|
300
|
+
data?: "reuse" | "reset" | "rebuild";
|
|
301
|
+
database?: string;
|
|
302
|
+
extensions?: string[];
|
|
303
|
+
password?: string;
|
|
304
|
+
port?: number;
|
|
305
|
+
user?: string;
|
|
306
|
+
version?: string;
|
|
307
|
+
vm?: KilnVMResourceConfig;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface ServerResourceConfig {
|
|
311
|
+
kind: "server";
|
|
312
|
+
vm?: KilnVMResourceConfig;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type ResourceConfig = PostgresResourceConfig | ServerResourceConfig;
|
|
316
|
+
|
|
285
317
|
export interface LocalEnvironmentConfig {
|
|
286
318
|
kind: "local";
|
|
287
319
|
data?: "reuse" | "reset" | "rebuild";
|
|
320
|
+
driver?: "host" | "kiln";
|
|
288
321
|
env?: Record<string, string>;
|
|
322
|
+
kiln?: KilnLocalEnvironmentConfig;
|
|
289
323
|
portOffset?: number;
|
|
324
|
+
productionLike?: boolean;
|
|
325
|
+
publicHost?: string;
|
|
326
|
+
resources?: Record<string, ResourceConfig>;
|
|
290
327
|
target: string;
|
|
291
328
|
}
|
|
292
329
|
|
|
@@ -294,6 +331,34 @@ export interface LocalEnvironmentOptions extends Omit<LocalEnvironmentConfig, "k
|
|
|
294
331
|
env?: PresetEnvConfig;
|
|
295
332
|
}
|
|
296
333
|
|
|
334
|
+
export interface KilnLocalEnvironmentConfig {
|
|
335
|
+
api?: {
|
|
336
|
+
apiUrl?: string;
|
|
337
|
+
token?: string;
|
|
338
|
+
};
|
|
339
|
+
network?: {
|
|
340
|
+
attachExistingVMs?: string[];
|
|
341
|
+
cidr?: string;
|
|
342
|
+
id?: string;
|
|
343
|
+
name?: string;
|
|
344
|
+
};
|
|
345
|
+
vm?: {
|
|
346
|
+
autostart?: boolean;
|
|
347
|
+
disk?: string;
|
|
348
|
+
diskSize?: string | number;
|
|
349
|
+
diskSizeMB?: number;
|
|
350
|
+
memoryMB?: number;
|
|
351
|
+
name?: string;
|
|
352
|
+
networkId?: string;
|
|
353
|
+
profile?: string;
|
|
354
|
+
vcpus?: number;
|
|
355
|
+
};
|
|
356
|
+
workspace?: {
|
|
357
|
+
exclude?: string[];
|
|
358
|
+
remotePath?: string;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
297
362
|
export interface TestkitFileMetadata {
|
|
298
363
|
locks?: string[];
|
|
299
364
|
skip?: string | { reason: string };
|
|
@@ -568,6 +633,17 @@ export declare const database: {
|
|
|
568
633
|
}
|
|
569
634
|
): ServiceConfig;
|
|
570
635
|
};
|
|
636
|
+
export declare const resource: {
|
|
637
|
+
postgres(options?: Omit<PostgresResourceConfig, "kind">): PostgresResourceConfig;
|
|
638
|
+
server(options?: Omit<ServerResourceConfig, "kind">): ServerResourceConfig;
|
|
639
|
+
field(resourceName: string, field?: string): string;
|
|
640
|
+
url(resourceName: string): string;
|
|
641
|
+
host(resourceName: string): string;
|
|
642
|
+
port(resourceName: string): string;
|
|
643
|
+
database(resourceName: string): string;
|
|
644
|
+
user(resourceName: string): string;
|
|
645
|
+
password(resourceName: string): string;
|
|
646
|
+
};
|
|
571
647
|
export declare const step: {
|
|
572
648
|
command(run: string, options?: TemplateLifecycleStepOptions): TemplateCommandStepConfig;
|
|
573
649
|
module(target: string, options?: TemplateLifecycleStepOptions): TemplateModuleStepConfig;
|
|
@@ -584,6 +660,7 @@ export declare const ui: {
|
|
|
584
660
|
};
|
|
585
661
|
export declare const environment: {
|
|
586
662
|
local(options: LocalEnvironmentOptions): LocalEnvironmentConfig;
|
|
663
|
+
productionLike(options: LocalEnvironmentOptions): LocalEnvironmentConfig;
|
|
587
664
|
};
|
|
588
665
|
export declare const auth: {
|
|
589
666
|
fixture(options: { contract: JsonSessionContract; topology: AuthTopology }): AuthFixture;
|
package/lib/config-api/index.mjs
CHANGED
|
@@ -33,6 +33,33 @@ function postgresDatabase(options = {}) {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function postgresResource(options = {}) {
|
|
37
|
+
return {
|
|
38
|
+
kind: "postgres",
|
|
39
|
+
version: options.version || "16",
|
|
40
|
+
database: options.database || options.name || "app",
|
|
41
|
+
user: options.user || "app",
|
|
42
|
+
...(options.password ? { password: options.password } : {}),
|
|
43
|
+
...(options.port ? { port: options.port } : {}),
|
|
44
|
+
...(options.vm ? { vm: { ...options.vm } } : {}),
|
|
45
|
+
...(options.data ? { data: options.data } : {}),
|
|
46
|
+
...(options.extensions ? { extensions: [...options.extensions] } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function serverResource(options = {}) {
|
|
51
|
+
return {
|
|
52
|
+
kind: "server",
|
|
53
|
+
...(options.vm ? { vm: { ...options.vm } } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resourceField(resourceName, field = "url") {
|
|
58
|
+
const normalizedResourceName = normalizeDatabaseEnvToken(resourceName, "resource name", false);
|
|
59
|
+
const normalizedField = normalizeDatabaseEnvToken(field, "resource field", false);
|
|
60
|
+
return `{resource:${normalizedResourceName}:${normalizedField}}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
36
63
|
function buildDatabaseTemplateConfig(options = {}) {
|
|
37
64
|
for (const legacyKey of ["schema"]) {
|
|
38
65
|
if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
|
|
@@ -412,6 +439,30 @@ export const database = {
|
|
|
412
439
|
fixture: postgresFixture,
|
|
413
440
|
};
|
|
414
441
|
|
|
442
|
+
export const resource = {
|
|
443
|
+
postgres: postgresResource,
|
|
444
|
+
server: serverResource,
|
|
445
|
+
field: resourceField,
|
|
446
|
+
url(resourceName) {
|
|
447
|
+
return resourceField(resourceName, "url");
|
|
448
|
+
},
|
|
449
|
+
host(resourceName) {
|
|
450
|
+
return resourceField(resourceName, "host");
|
|
451
|
+
},
|
|
452
|
+
port(resourceName) {
|
|
453
|
+
return resourceField(resourceName, "port");
|
|
454
|
+
},
|
|
455
|
+
database(resourceName) {
|
|
456
|
+
return resourceField(resourceName, "database");
|
|
457
|
+
},
|
|
458
|
+
user(resourceName) {
|
|
459
|
+
return resourceField(resourceName, "user");
|
|
460
|
+
},
|
|
461
|
+
password(resourceName) {
|
|
462
|
+
return resourceField(resourceName, "password");
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
|
|
415
466
|
export const step = {
|
|
416
467
|
command: commandStep,
|
|
417
468
|
module: moduleStep,
|
|
@@ -456,6 +507,13 @@ function localEnvironment(options = {}) {
|
|
|
456
507
|
|
|
457
508
|
export const environment = {
|
|
458
509
|
local: localEnvironment,
|
|
510
|
+
productionLike(options = {}) {
|
|
511
|
+
return localEnvironment({
|
|
512
|
+
driver: "kiln",
|
|
513
|
+
...options,
|
|
514
|
+
productionLike: true,
|
|
515
|
+
});
|
|
516
|
+
},
|
|
459
517
|
};
|
|
460
518
|
|
|
461
519
|
export const auth = {
|
|
@@ -497,7 +555,7 @@ function normalizePresetEnv(env) {
|
|
|
497
555
|
if (typeof env !== "object" || Array.isArray(env)) {
|
|
498
556
|
throw new Error("Preset env must be an object");
|
|
499
557
|
}
|
|
500
|
-
const allowedKeys = new Set(["values", "databases"]);
|
|
558
|
+
const allowedKeys = new Set(["values", "databases", "resources"]);
|
|
501
559
|
const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
|
|
502
560
|
if (unexpectedKeys.length > 0) {
|
|
503
561
|
throw new Error(
|
|
@@ -508,9 +566,12 @@ function normalizePresetEnv(env) {
|
|
|
508
566
|
const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
|
|
509
567
|
const databases =
|
|
510
568
|
env.databases && typeof env.databases === "object" && !Array.isArray(env.databases) ? env.databases : {};
|
|
569
|
+
const resources =
|
|
570
|
+
env.resources && typeof env.resources === "object" && !Array.isArray(env.resources) ? env.resources : {};
|
|
511
571
|
|
|
512
572
|
return {
|
|
513
573
|
...expandDatabaseBindings(databases),
|
|
574
|
+
...expandResourceBindings(resources),
|
|
514
575
|
...values,
|
|
515
576
|
};
|
|
516
577
|
}
|
|
@@ -533,6 +594,22 @@ function expandDatabaseBindings(bindings) {
|
|
|
533
594
|
return env;
|
|
534
595
|
}
|
|
535
596
|
|
|
597
|
+
function expandResourceBindings(bindings) {
|
|
598
|
+
const env = {};
|
|
599
|
+
for (const [envName, binding] of Object.entries(bindings || {})) {
|
|
600
|
+
if (typeof binding === "string") {
|
|
601
|
+
const [resourceName, field = "url"] = binding.split(".");
|
|
602
|
+
env[envName] = resourceField(resourceName, field);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
|
|
606
|
+
throw new Error(`env.resources.${envName} must be a string or object`);
|
|
607
|
+
}
|
|
608
|
+
env[envName] = resourceField(binding.resource, binding.field || "url");
|
|
609
|
+
}
|
|
610
|
+
return env;
|
|
611
|
+
}
|
|
612
|
+
|
|
536
613
|
function normalizeDatabaseEnvToken(value, label, sanitize = true) {
|
|
537
614
|
const raw = String(value || "").trim();
|
|
538
615
|
const normalized = sanitize
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
export const DEFAULT_DOCKER_COMPAT_MATRIX = Object.freeze([
|
|
2
|
+
dockerCompatEntry({ dockerVersion: "24.0.9", image: "docker:24.0.9-dind" }),
|
|
3
|
+
dockerCompatEntry({ dockerVersion: "25.0.5", image: "docker:25.0.5-dind" }),
|
|
4
|
+
dockerCompatEntry({ dockerVersion: "26.1.4", image: "docker:26.1.4-dind" }),
|
|
5
|
+
dockerCompatEntry({ dockerVersion: "27.5.1", image: "docker:27.5.1-dind" }),
|
|
6
|
+
dockerCompatEntry({ dockerVersion: "28.5.2", image: "docker:28.5.2-dind" }),
|
|
7
|
+
dockerCompatEntry({ dockerVersion: "29.0.0", image: "docker:29.0.0-dind" }),
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function dockerCompatEntry(entry = {}) {
|
|
11
|
+
const dockerVersion = normalizeNonEmptyString(entry.dockerVersion, "dockerVersion");
|
|
12
|
+
const image = normalizeNonEmptyString(entry.image, "image");
|
|
13
|
+
return Object.freeze({
|
|
14
|
+
dockerVersion,
|
|
15
|
+
image,
|
|
16
|
+
label: entry.label || `Docker ${dockerVersion}`,
|
|
17
|
+
rootfsSizeMB: normalizePositiveInteger(entry.rootfsSizeMB ?? 4096, "rootfsSizeMB"),
|
|
18
|
+
vcpus: normalizePositiveInteger(entry.vcpus ?? 1, "vcpus"),
|
|
19
|
+
memoryMB: normalizePositiveInteger(entry.memoryMB ?? 2048, "memoryMB"),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function selectDockerCompatMatrix(matrix = DEFAULT_DOCKER_COMPAT_MATRIX, selection = []) {
|
|
24
|
+
const requested = normalizeSelection(selection);
|
|
25
|
+
if (requested.length === 0) return [...matrix];
|
|
26
|
+
|
|
27
|
+
const byVersion = new Map(matrix.map((entry) => [entry.dockerVersion, entry]));
|
|
28
|
+
const byLabel = new Map(matrix.map((entry) => [entry.label, entry]));
|
|
29
|
+
const selected = [];
|
|
30
|
+
const unknown = [];
|
|
31
|
+
|
|
32
|
+
for (const key of requested) {
|
|
33
|
+
const entry = byVersion.get(key) || byLabel.get(key);
|
|
34
|
+
if (entry) {
|
|
35
|
+
selected.push(entry);
|
|
36
|
+
} else {
|
|
37
|
+
unknown.push(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (unknown.length > 0) {
|
|
42
|
+
throw new Error(`Unknown Docker compatibility matrix selection: ${unknown.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
return selected;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseDockerCompatSelection(value) {
|
|
48
|
+
if (Array.isArray(value)) return normalizeSelection(value);
|
|
49
|
+
return normalizeSelection(String(value || "").split(","));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildDockerCompatDockerfile(entry) {
|
|
53
|
+
const normalized = dockerCompatEntry(entry);
|
|
54
|
+
return [
|
|
55
|
+
`FROM ${normalized.image}`,
|
|
56
|
+
"RUN apk add --no-cache postgresql-client",
|
|
57
|
+
"ENV DOCKER_TLS_CERTDIR=",
|
|
58
|
+
"ENTRYPOINT []",
|
|
59
|
+
"CMD [\"dockerd-entrypoint.sh\"]",
|
|
60
|
+
"",
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildDockerCompatProbeCommand(entry, options = {}) {
|
|
65
|
+
const normalized = dockerCompatEntry(entry);
|
|
66
|
+
const postgresImage = options.postgresImage || "pgvector/pgvector:pg16";
|
|
67
|
+
const containerName = options.containerName || `testkit-docker-compat-${normalized.dockerVersion.replaceAll(".", "-")}`;
|
|
68
|
+
return [
|
|
69
|
+
"set -eu",
|
|
70
|
+
"deadline=$(($(date +%s) + 90))",
|
|
71
|
+
"until docker version >/tmp/testkit-docker-version.txt 2>/tmp/testkit-docker-version.err; do",
|
|
72
|
+
" if [ \"$(date +%s)\" -ge \"$deadline\" ]; then",
|
|
73
|
+
" cat /tmp/testkit-docker-version.err >&2 || true",
|
|
74
|
+
" exit 1",
|
|
75
|
+
" fi",
|
|
76
|
+
" sleep 1",
|
|
77
|
+
"done",
|
|
78
|
+
"actual=$(docker version --format '{{.Server.Version}}')",
|
|
79
|
+
`case "$actual" in ${shellCasePattern(normalized.dockerVersion)}*) ;; *) echo "expected Docker ${normalized.dockerVersion}, got $actual" >&2; exit 1 ;; esac`,
|
|
80
|
+
`docker rm -f ${shellQuote(containerName)} >/dev/null 2>&1 || true`,
|
|
81
|
+
[
|
|
82
|
+
"docker run -d",
|
|
83
|
+
"--name",
|
|
84
|
+
shellQuote(containerName),
|
|
85
|
+
"-e",
|
|
86
|
+
"POSTGRES_USER=testkit",
|
|
87
|
+
"-e",
|
|
88
|
+
"POSTGRES_PASSWORD=testkit",
|
|
89
|
+
"-e",
|
|
90
|
+
"POSTGRES_DB=postgres",
|
|
91
|
+
"-p",
|
|
92
|
+
"127.0.0.1::5432",
|
|
93
|
+
shellQuote(postgresImage),
|
|
94
|
+
].join(" "),
|
|
95
|
+
"ready_deadline=$(($(date +%s) + 120))",
|
|
96
|
+
"until docker exec " + shellQuote(containerName) + " pg_isready -U testkit -d postgres >/dev/null 2>&1; do",
|
|
97
|
+
" if [ \"$(date +%s)\" -ge \"$ready_deadline\" ]; then",
|
|
98
|
+
" docker logs " + shellQuote(containerName) + " >&2 || true",
|
|
99
|
+
" docker rm -f " + shellQuote(containerName) + " >/dev/null 2>&1 || true",
|
|
100
|
+
" exit 1",
|
|
101
|
+
" fi",
|
|
102
|
+
" sleep 1",
|
|
103
|
+
"done",
|
|
104
|
+
"published_port=$(docker inspect --format '{{(index (index .NetworkSettings.Ports \"5432/tcp\") 0).HostPort}}' " + shellQuote(containerName) + ")",
|
|
105
|
+
"test -n \"$published_port\"",
|
|
106
|
+
"docker rm -f " + shellQuote(containerName) + " >/dev/null",
|
|
107
|
+
"echo \"Docker $actual compatibility probe passed\"",
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeSelection(selection) {
|
|
112
|
+
return selection.map((entry) => String(entry || "").trim()).filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeNonEmptyString(value, name) {
|
|
116
|
+
const normalized = String(value || "").trim();
|
|
117
|
+
if (!normalized) throw new Error(`${name} is required`);
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizePositiveInteger(value, name) {
|
|
122
|
+
const parsed = Number(value);
|
|
123
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
124
|
+
throw new Error(`${name} must be a positive integer`);
|
|
125
|
+
}
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function shellQuote(value) {
|
|
130
|
+
return "'" + String(value).replaceAll("'", "'\"'\"'") + "'";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function shellCasePattern(value) {
|
|
134
|
+
return String(value).replaceAll(".", "\\.");
|
|
135
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
export class KilnClient {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
const config = readKilnConfig();
|
|
8
|
+
this.apiUrl = String(options.apiUrl || process.env.KILN_API_URL || config.api_url || "").replace(/\/+$/, "");
|
|
9
|
+
this.token = String(options.token || process.env.KILN_TOKEN || config.token || "");
|
|
10
|
+
if (!this.apiUrl) throw new Error("Kiln API URL is not configured; run kiln login or set KILN_API_URL");
|
|
11
|
+
if (!this.token) throw new Error("Kiln token is not configured; run kiln login or set KILN_TOKEN");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async request(method, requestPath, body = null) {
|
|
15
|
+
const response = await fetch(`${this.apiUrl}${requestPath}`, {
|
|
16
|
+
method,
|
|
17
|
+
headers: {
|
|
18
|
+
authorization: `Bearer ${this.token}`,
|
|
19
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
20
|
+
},
|
|
21
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
22
|
+
});
|
|
23
|
+
const text = await response.text();
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
throw new Error(`${method} ${requestPath} returned ${response.status}: ${text.trim()}`);
|
|
26
|
+
}
|
|
27
|
+
if (!text || response.status === 204) return null;
|
|
28
|
+
return JSON.parse(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getVM(ref) {
|
|
32
|
+
return this.request("GET", `/vms/${encodeURIComponent(ref)}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
listVMs() {
|
|
36
|
+
return this.request("GET", "/vms");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
createVM(req) {
|
|
40
|
+
return this.request("POST", "/vms", req);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
startVM(ref) {
|
|
44
|
+
return this.request("POST", `/vms/${encodeURIComponent(ref)}/start`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
stopVM(ref) {
|
|
48
|
+
return this.request("POST", `/vms/${encodeURIComponent(ref)}/stop`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
deleteVM(ref) {
|
|
52
|
+
return this.request("DELETE", `/vms/${encodeURIComponent(ref)}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
attachVMNetwork(ref, networkId) {
|
|
56
|
+
return this.request("POST", `/vms/${encodeURIComponent(ref)}/network`, { network_id: networkId });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
listNetworks() {
|
|
60
|
+
return this.request("GET", "/networks");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
createNetwork(req) {
|
|
64
|
+
return this.request("POST", "/networks", req);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
deleteNetwork(ref) {
|
|
68
|
+
return this.request("DELETE", `/networks/${encodeURIComponent(ref)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
execVM(ref, command) {
|
|
72
|
+
return this.request("POST", `/vms/${encodeURIComponent(ref)}/exec`, { command });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ensureAppliance(req) {
|
|
76
|
+
return this.request("POST", "/appliances/ensure", req);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getAppliance(ref) {
|
|
80
|
+
return this.request("GET", `/appliances/${encodeURIComponent(ref)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
listAppliances() {
|
|
84
|
+
return this.request("GET", "/appliances");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
stopAppliance(ref) {
|
|
88
|
+
return this.request("POST", `/appliances/${encodeURIComponent(ref)}/stop`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
deleteAppliance(ref) {
|
|
92
|
+
return this.request("DELETE", `/appliances/${encodeURIComponent(ref)}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
sshKey(machineId) {
|
|
96
|
+
return this.requestRaw("GET", `/machines/${encodeURIComponent(machineId)}/ssh-key`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async requestRaw(method, requestPath) {
|
|
100
|
+
const response = await fetch(`${this.apiUrl}${requestPath}`, {
|
|
101
|
+
method,
|
|
102
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
103
|
+
});
|
|
104
|
+
const text = await response.text();
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
throw new Error(`${method} ${requestPath} returned ${response.status}: ${text.trim()}`);
|
|
107
|
+
}
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readKilnConfig() {
|
|
113
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
114
|
+
const configPath = path.join(configHome, "kiln", "config.json");
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
117
|
+
} catch {
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
}
|