@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.
@@ -224,12 +224,49 @@ 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
+ ...(environment.publicHost ? { publicHost: String(environment.publicHost) } : {}),
240
+ ...(kiln ? { kiln } : {}),
241
+ };
242
+ }
243
+
244
+ function normalizeKilnEnvironmentConfig(name, kiln) {
245
+ if (!kiln) return null;
246
+ if (typeof kiln !== "object" || Array.isArray(kiln)) {
247
+ throw new Error(`Environment "${name}" kiln must be an object`);
248
+ }
249
+ const vm = kiln.vm || {};
250
+ if (vm && (typeof vm !== "object" || Array.isArray(vm))) {
251
+ throw new Error(`Environment "${name}" kiln.vm must be an object`);
252
+ }
253
+ const network = kiln.network || null;
254
+ if (network && (typeof network !== "object" || Array.isArray(network))) {
255
+ throw new Error(`Environment "${name}" kiln.network must be an object`);
256
+ }
257
+ const workspace = kiln.workspace || null;
258
+ if (workspace && (typeof workspace !== "object" || Array.isArray(workspace))) {
259
+ throw new Error(`Environment "${name}" kiln.workspace must be an object`);
260
+ }
261
+ return {
262
+ ...kiln,
263
+ vm: {
264
+ ...(vm || {}),
265
+ ...(vm.name ? { name: String(vm.name) } : {}),
266
+ ...(vm.profile ? { profile: String(vm.profile) } : {}),
267
+ },
268
+ ...(network ? { network: { ...network } } : {}),
269
+ ...(workspace ? { workspace: { ...workspace } } : {}),
233
270
  };
234
271
  }
235
272
 
@@ -285,8 +285,11 @@ export interface PresetEnvConfig {
285
285
  export interface LocalEnvironmentConfig {
286
286
  kind: "local";
287
287
  data?: "reuse" | "reset" | "rebuild";
288
+ driver?: "host" | "kiln";
288
289
  env?: Record<string, string>;
290
+ kiln?: KilnLocalEnvironmentConfig;
289
291
  portOffset?: number;
292
+ publicHost?: string;
290
293
  target: string;
291
294
  }
292
295
 
@@ -294,6 +297,34 @@ export interface LocalEnvironmentOptions extends Omit<LocalEnvironmentConfig, "k
294
297
  env?: PresetEnvConfig;
295
298
  }
296
299
 
300
+ export interface KilnLocalEnvironmentConfig {
301
+ api?: {
302
+ apiUrl?: string;
303
+ token?: string;
304
+ };
305
+ network?: {
306
+ attachExistingVMs?: string[];
307
+ cidr?: string;
308
+ id?: string;
309
+ name?: string;
310
+ };
311
+ vm?: {
312
+ autostart?: boolean;
313
+ disk?: string;
314
+ diskSize?: string | number;
315
+ diskSizeMB?: number;
316
+ memoryMB?: number;
317
+ name?: string;
318
+ networkId?: string;
319
+ profile?: string;
320
+ vcpus?: number;
321
+ };
322
+ workspace?: {
323
+ exclude?: string[];
324
+ remotePath?: string;
325
+ };
326
+ }
327
+
297
328
  export interface TestkitFileMetadata {
298
329
  locks?: string[];
299
330
  skip?: string | { reason: string };
@@ -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,100 @@
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
+ sshKey(machineId) {
76
+ return this.requestRaw("GET", `/machines/${encodeURIComponent(machineId)}/ssh-key`);
77
+ }
78
+
79
+ async requestRaw(method, requestPath) {
80
+ const response = await fetch(`${this.apiUrl}${requestPath}`, {
81
+ method,
82
+ headers: { authorization: `Bearer ${this.token}` },
83
+ });
84
+ const text = await response.text();
85
+ if (!response.ok) {
86
+ throw new Error(`${method} ${requestPath} returned ${response.status}: ${text.trim()}`);
87
+ }
88
+ return text;
89
+ }
90
+ }
91
+
92
+ function readKilnConfig() {
93
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
94
+ const configPath = path.join(configHome, "kiln", "config.json");
95
+ try {
96
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
97
+ } catch {
98
+ return {};
99
+ }
100
+ }