@elench/testkit 0.1.150 → 0.1.151

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.
Files changed (48) hide show
  1. package/README.md +21 -9
  2. package/lib/cli/assistant/view-model.mjs +1 -1
  3. package/lib/cli/components/blocks/run-tree.mjs +1 -1
  4. package/lib/cli/renderers/run/events.mjs +4 -3
  5. package/lib/cli/renderers/run/failure.mjs +2 -2
  6. package/lib/cli/renderers/run/inline-detail.mjs +2 -2
  7. package/lib/cli/renderers/run/interactive.mjs +2 -2
  8. package/lib/cli/renderers/run/text-reporter.mjs +9 -9
  9. package/lib/cli/state/run/model.mjs +7 -7
  10. package/lib/cli/state/run/state.mjs +3 -3
  11. package/lib/cli/terminal/colors.mjs +1 -1
  12. package/lib/config/runtime.mjs +130 -0
  13. package/lib/config-api/index.d.ts +22 -0
  14. package/lib/database/cleanup.mjs +76 -1
  15. package/lib/database/index.mjs +6 -0
  16. package/lib/database/local-postgres.mjs +95 -4
  17. package/lib/database/naming.mjs +7 -0
  18. package/lib/database/state-files.mjs +12 -0
  19. package/lib/docker-compat/matrix.mjs +5 -3
  20. package/lib/kiln/client.mjs +8 -0
  21. package/lib/local/kiln-driver.mjs +96 -69
  22. package/lib/ownership/docker.mjs +67 -1
  23. package/lib/regressions/github-transport.mjs +178 -4
  24. package/lib/regressions/github.mjs +52 -16
  25. package/lib/regressions/index.d.ts +56 -28
  26. package/lib/regressions/index.mjs +122 -47
  27. package/lib/regressions/workflow.mjs +266 -0
  28. package/lib/results/artifacts.mjs +8 -7
  29. package/lib/runner/formatting.mjs +17 -16
  30. package/lib/runner/orchestrator.mjs +5 -4
  31. package/lib/runner/regressions.mjs +175 -33
  32. package/lib/runner/run-finalization.mjs +34 -4
  33. package/node_modules/@elench/next-analysis/package.json +1 -1
  34. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  35. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  36. package/node_modules/@elench/ts-analysis/package.json +1 -1
  37. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  38. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  39. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  40. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  41. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  42. package/node_modules/esprima/ChangeLog +235 -0
  43. package/package.json +6 -5
  44. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
  45. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
  46. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
  47. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
  48. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
@@ -10,18 +10,30 @@ import {
10
10
  LOCAL_READY_TIMEOUT_MS,
11
11
  LOCAL_USER,
12
12
  } from "./constants.mjs";
13
- import { buildContainerName } from "./naming.mjs";
13
+ import { buildContainerName, buildVolumeName } from "./naming.mjs";
14
14
  import { getLocalInfraDir, readStateValue } from "./state.mjs";
15
- import { buildDockerResourceLabels, dockerLabelArgs } from "../ownership/docker.mjs";
15
+ import {
16
+ buildDockerResourceLabels,
17
+ dockerLabelArgs,
18
+ inspectDockerVolume,
19
+ removeDockerVolume,
20
+ } from "../ownership/docker.mjs";
16
21
  import { writeLocalInfraState } from "./state-files.mjs";
17
22
 
23
+ const LEGACY_POSTGRES_DATA_VOLUME_TARGET = "/var/lib/postgresql/data";
24
+ const POSTGRES_18_VOLUME_TARGET = "/var/lib/postgresql";
25
+
18
26
  export async function ensureLocalContainer(productDir, database = {}) {
19
27
  const infraDir = getLocalInfraDir(productDir);
20
28
  fs.mkdirSync(infraDir, { recursive: true });
21
29
 
22
30
  const containerName =
23
31
  readStateValue(path.join(infraDir, "container_name")) || buildContainerName(productDir);
32
+ const volumeName =
33
+ readStateValue(path.join(infraDir, "volume_name")) || buildVolumeName(productDir);
24
34
  const image = database.image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE;
35
+ const previousImage = readStateValue(path.join(infraDir, "image"));
36
+ const volumeMountPath = resolveLocalPostgresVolumeTarget(image);
25
37
  const user = database.user || readStateValue(path.join(infraDir, "user")) || LOCAL_USER;
26
38
  const password =
27
39
  database.password || readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD;
@@ -32,13 +44,21 @@ export async function ensureLocalContainer(productDir, database = {}) {
32
44
  let inspect = await inspectContainer(containerName);
33
45
  if (
34
46
  inspect &&
35
- (inspect.Config?.Image !== image || !containerUsesMaxConnections(inspect, maxConnections))
47
+ (
48
+ inspect.Config?.Image !== image ||
49
+ !containerUsesMaxConnections(inspect, maxConnections) ||
50
+ !containerUsesNamedVolume(inspect, volumeName, volumeMountPath)
51
+ )
36
52
  ) {
37
53
  await stopAndRemoveContainer(containerName);
54
+ if (previousImage && previousImage !== image) {
55
+ await removeOwnedLocalPostgresVolume(productDir, volumeName);
56
+ }
38
57
  inspect = null;
39
58
  }
40
59
 
41
60
  if (!inspect) {
61
+ await ensureLocalPostgresVolume(productDir, volumeName);
42
62
  const labels = buildLocalPostgresContainerLabels(productDir, containerName);
43
63
  await execa("docker", [
44
64
  "run",
@@ -46,6 +66,8 @@ export async function ensureLocalContainer(productDir, database = {}) {
46
66
  "--name",
47
67
  containerName,
48
68
  ...dockerLabelArgs(labels),
69
+ "--mount",
70
+ `type=volume,source=${volumeName},target=${volumeMountPath}`,
49
71
  "-e",
50
72
  `POSTGRES_USER=${user}`,
51
73
  "-e",
@@ -73,6 +95,8 @@ export async function ensureLocalContainer(productDir, database = {}) {
73
95
  const infra = {
74
96
  containerName,
75
97
  containerId: inspect.Id,
98
+ volumeName,
99
+ volumeMountPath,
76
100
  image,
77
101
  user,
78
102
  password,
@@ -90,6 +114,7 @@ export async function loadExistingLocalContainer(productDir) {
90
114
  const infraDir = getLocalInfraDir(productDir);
91
115
  const containerName = readStateValue(path.join(infraDir, "container_name"));
92
116
  if (!containerName) return null;
117
+ const volumeName = readStateValue(path.join(infraDir, "volume_name")) || buildVolumeName(productDir);
93
118
 
94
119
  const inspect = await inspectContainer(containerName);
95
120
  if (!inspect) return null;
@@ -105,6 +130,9 @@ export async function loadExistingLocalContainer(productDir) {
105
130
  const infra = {
106
131
  containerName,
107
132
  containerId: nextInspect.Id,
133
+ volumeName,
134
+ volumeMountPath: readStateValue(path.join(infraDir, "volume_mount_path")) ||
135
+ resolveLocalPostgresVolumeTarget(nextInspect.Config?.Image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE),
108
136
  image: nextInspect.Config?.Image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE,
109
137
  user: readStateValue(path.join(infraDir, "user")) || LOCAL_USER,
110
138
  password: readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD,
@@ -124,6 +152,15 @@ function containerUsesMaxConnections(inspect, maxConnections) {
124
152
  return cmd.join(" ") === `postgres -c max_connections=${maxConnections}`;
125
153
  }
126
154
 
155
+ function containerUsesNamedVolume(inspect, volumeName, volumeMountPath) {
156
+ const mounts = Array.isArray(inspect?.Mounts) ? inspect.Mounts : [];
157
+ return mounts.some((mount) =>
158
+ mount?.Type === "volume" &&
159
+ mount?.Name === volumeName &&
160
+ mount?.Destination === volumeMountPath
161
+ );
162
+ }
163
+
127
164
  function normalizeMaxConnections(value) {
128
165
  const number = Number(value);
129
166
  if (!Number.isInteger(number) || number < 10) {
@@ -144,12 +181,57 @@ export async function inspectContainer(containerName) {
144
181
 
145
182
  export async function stopAndRemoveContainer(containerName) {
146
183
  try {
147
- await execa("docker", ["rm", "-f", containerName]);
184
+ await execa("docker", ["rm", "-f", "-v", containerName]);
148
185
  } catch {
149
186
  // Already gone.
150
187
  }
151
188
  }
152
189
 
190
+ async function ensureLocalPostgresVolume(productDir, volumeName) {
191
+ const labels = buildLocalPostgresVolumeLabels(productDir, volumeName);
192
+ const existing = await inspectDockerVolume(volumeName);
193
+ if (existing) {
194
+ const existingLabels = existing.Labels || {};
195
+ const mismatched = Object.entries(labels).some(([key, value]) => existingLabels[key] !== String(value));
196
+ if (mismatched) {
197
+ throw new Error(
198
+ `Docker volume ${volumeName} already exists but is not owned by this Testkit product. ` +
199
+ `Remove or rename it before running Testkit.`
200
+ );
201
+ }
202
+ return;
203
+ }
204
+ await execa("docker", [
205
+ "volume",
206
+ "create",
207
+ ...dockerLabelArgs(labels),
208
+ volumeName,
209
+ ]);
210
+ }
211
+
212
+ async function removeOwnedLocalPostgresVolume(productDir, volumeName) {
213
+ const labels = buildLocalPostgresVolumeLabels(productDir, volumeName);
214
+ const existing = await inspectDockerVolume(volumeName);
215
+ if (!existing) return;
216
+ const existingLabels = existing.Labels || {};
217
+ const ownedByThisProduct = Object.entries(labels).every(([key, value]) => existingLabels[key] === String(value));
218
+ if (!ownedByThisProduct) return;
219
+ await removeDockerVolume(volumeName);
220
+ }
221
+
222
+ export function resolveLocalPostgresVolumeTarget(image) {
223
+ return postgresImageMajorVersion(image) >= 18
224
+ ? POSTGRES_18_VOLUME_TARGET
225
+ : LEGACY_POSTGRES_DATA_VOLUME_TARGET;
226
+ }
227
+
228
+ function postgresImageMajorVersion(image) {
229
+ const text = String(image || "");
230
+ const match = text.match(/(?:^|[:@/-])(?:pg|postgres)?(\d{2})(?:\D|$)/i);
231
+ if (!match) return 16;
232
+ return Number(match[1]);
233
+ }
234
+
153
235
  async function waitForLocalContainerReady(infra) {
154
236
  const startedAt = Date.now();
155
237
  while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
@@ -181,6 +263,15 @@ function buildLocalPostgresContainerLabels(productDir, containerName) {
181
263
  });
182
264
  }
183
265
 
266
+ function buildLocalPostgresVolumeLabels(productDir, volumeName) {
267
+ return buildDockerResourceLabels(productDir, {
268
+ kind: "postgres-volume",
269
+ name: volumeName,
270
+ scope: "local-postgres",
271
+ cachePolicy: "product-cache",
272
+ });
273
+ }
274
+
184
275
  function sleep(ms) {
185
276
  return new Promise((resolve) => setTimeout(resolve, ms));
186
277
  }
@@ -13,6 +13,13 @@ export function buildContainerName(productDir) {
13
13
  );
14
14
  }
15
15
 
16
+ export function buildVolumeName(productDir) {
17
+ return limitIdentifier(
18
+ `testkit_pgdata_${slugSegment(path.basename(productDir))}_${hashString(productDir, 10)}`,
19
+ 63
20
+ );
21
+ }
22
+
16
23
  export function buildTemplateDatabaseName(serviceName, fingerprint) {
17
24
  return limitIdentifier(
18
25
  `tk_tpl_${slugSegment(serviceName)}_${fingerprint.slice(0, 16)}`,
@@ -8,6 +8,12 @@ export function writeLocalInfraState(infraDir, infra) {
8
8
  fs.writeFileSync(path.join(infraDir, "database_backend"), "local");
9
9
  fs.writeFileSync(path.join(infraDir, "container_name"), infra.containerName);
10
10
  fs.writeFileSync(path.join(infraDir, "container_id"), infra.containerId);
11
+ if (infra.volumeName) {
12
+ fs.writeFileSync(path.join(infraDir, "volume_name"), infra.volumeName);
13
+ }
14
+ if (infra.volumeMountPath) {
15
+ fs.writeFileSync(path.join(infraDir, "volume_mount_path"), infra.volumeMountPath);
16
+ }
11
17
  fs.writeFileSync(path.join(infraDir, "ownership_product_id"), product.id);
12
18
  fs.writeFileSync(path.join(infraDir, "image"), infra.image);
13
19
  fs.writeFileSync(path.join(infraDir, "user"), infra.user);
@@ -24,6 +30,12 @@ export function writeCacheState(cacheDir, config, infra, templateDbName, fingerp
24
30
  fs.writeFileSync(path.join(cacheDir, "template_fingerprint"), fingerprint);
25
31
  if (backend === "local") {
26
32
  fs.writeFileSync(path.join(cacheDir, "container_name"), infra.containerName);
33
+ if (infra.volumeName) {
34
+ fs.writeFileSync(path.join(cacheDir, "volume_name"), infra.volumeName);
35
+ }
36
+ if (infra.volumeMountPath) {
37
+ fs.writeFileSync(path.join(cacheDir, "volume_mount_path"), infra.volumeMountPath);
38
+ }
27
39
  } else {
28
40
  fs.writeFileSync(path.join(cacheDir, "resource_name"), infra.resourceName);
29
41
  writeResourceConnectionState(cacheDir, infra);
@@ -77,7 +77,7 @@ export function buildDockerCompatProbeCommand(entry, options = {}) {
77
77
  "done",
78
78
  "actual=$(docker version --format '{{.Server.Version}}')",
79
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`,
80
+ `docker rm -f -v ${shellQuote(containerName)} >/dev/null 2>&1 || true`,
81
81
  [
82
82
  "docker run -d",
83
83
  "--name",
@@ -90,20 +90,22 @@ export function buildDockerCompatProbeCommand(entry, options = {}) {
90
90
  "POSTGRES_DB=postgres",
91
91
  "-p",
92
92
  "127.0.0.1::5432",
93
+ "--tmpfs",
94
+ "/var/lib/postgresql/data:rw",
93
95
  shellQuote(postgresImage),
94
96
  ].join(" "),
95
97
  "ready_deadline=$(($(date +%s) + 120))",
96
98
  "until docker exec " + shellQuote(containerName) + " pg_isready -U testkit -d postgres >/dev/null 2>&1; do",
97
99
  " if [ \"$(date +%s)\" -ge \"$ready_deadline\" ]; then",
98
100
  " docker logs " + shellQuote(containerName) + " >&2 || true",
99
- " docker rm -f " + shellQuote(containerName) + " >/dev/null 2>&1 || true",
101
+ " docker rm -f -v " + shellQuote(containerName) + " >/dev/null 2>&1 || true",
100
102
  " exit 1",
101
103
  " fi",
102
104
  " sleep 1",
103
105
  "done",
104
106
  "published_port=$(docker inspect --format '{{(index (index .NetworkSettings.Ports \"5432/tcp\") 0).HostPort}}' " + shellQuote(containerName) + ")",
105
107
  "test -n \"$published_port\"",
106
- "docker rm -f " + shellQuote(containerName) + " >/dev/null",
108
+ "docker rm -f -v " + shellQuote(containerName) + " >/dev/null",
107
109
  "echo \"Docker $actual compatibility probe passed\"",
108
110
  ].join("\n");
109
111
  }
@@ -92,6 +92,14 @@ export class KilnClient {
92
92
  return this.request("DELETE", `/appliances/${encodeURIComponent(ref)}`);
93
93
  }
94
94
 
95
+ diskUsage() {
96
+ return this.request("GET", "/disk/usage");
97
+ }
98
+
99
+ cleanup(req = {}) {
100
+ return this.request("POST", "/cleanup", req);
101
+ }
102
+
95
103
  sshKey(machineId) {
96
104
  return this.requestRaw("GET", `/machines/${encodeURIComponent(machineId)}/ssh-key`);
97
105
  }
@@ -112,12 +112,16 @@ export async function kilnLocalDown(context, name, options = {}) {
112
112
  const manifest = readLocalEnvironmentManifest(context.productDir, name);
113
113
  if (!manifest) return null;
114
114
  if (!manifest.kiln?.vm?.name) {
115
- writeKilnManifest(context.productDir, name, {
116
- ...manifest,
117
- status: "stopped",
118
- stoppedAt: new Date().toISOString(),
119
- services: [],
120
- });
115
+ if (options.destroyState) {
116
+ removeKilnEnvironmentState(context.productDir, name);
117
+ } else {
118
+ writeKilnManifest(context.productDir, name, {
119
+ ...manifest,
120
+ status: "stopped",
121
+ stoppedAt: new Date().toISOString(),
122
+ services: [],
123
+ });
124
+ }
121
125
  return manifest;
122
126
  }
123
127
  const ssh = await sshFromManifest(manifest);
@@ -132,14 +136,17 @@ export async function kilnLocalDown(context, name, options = {}) {
132
136
  if (result.exitCode !== 0) {
133
137
  throw new Error(`remote testkit local down failed\n${result.stdout}${result.stderr}`);
134
138
  }
135
- writeKilnManifest(context.productDir, name, {
136
- ...manifest,
137
- status: "stopped",
138
- stoppedAt: new Date().toISOString(),
139
- services: [],
140
- });
141
139
  if (options.destroyState) {
142
- await deleteManifestResources(manifest);
140
+ await deleteManifestResources(manifest, { includeEnvironmentVM: true });
141
+ await cleanupKilnOrphans(manifest);
142
+ removeKilnEnvironmentState(context.productDir, name);
143
+ } else {
144
+ writeKilnManifest(context.productDir, name, {
145
+ ...manifest,
146
+ status: "stopped",
147
+ stoppedAt: new Date().toISOString(),
148
+ services: [],
149
+ });
143
150
  }
144
151
  return manifest;
145
152
  } finally {
@@ -250,16 +257,16 @@ async function attachExistingVMs(client, network, vmRefs) {
250
257
  }
251
258
 
252
259
  async function ensureResources(client, environment, network) {
253
- const configured = flattenEnvironmentResources(environment.resources || {});
254
- const manifest = {};
255
- const connections = {};
256
- for (const [name, resource] of Object.entries(configured)) {
257
- if (resource.kind === "postgres") {
258
- const appliance = await client.ensureAppliance(buildPostgresApplianceRequest(name, resource, environment, network));
259
- manifest[name] = pickAppliance(appliance);
260
- connections[name] = appliance.connection || {};
261
- continue;
262
- }
260
+ const configured = flattenEnvironmentResources(environment.resources || {});
261
+ const manifest = {};
262
+ const connections = {};
263
+ for (const [name, resource] of Object.entries(configured)) {
264
+ if (resource.kind === "postgres") {
265
+ const appliance = await client.ensureAppliance(buildPostgresApplianceRequest(name, resource, environment, network));
266
+ manifest[name] = pickAppliance(appliance);
267
+ connections[name] = appliance.connection || {};
268
+ continue;
269
+ }
263
270
  if (resource.kind === "server") {
264
271
  const appliance = await client.ensureAppliance({
265
272
  name: resource.vm?.name || `${environment.name}-${name}`,
@@ -279,7 +286,7 @@ async function ensureResources(client, environment, network) {
279
286
  connections[name] = appliance.connection || {};
280
287
  }
281
288
  }
282
- return { manifest, connections };
289
+ return { manifest, connections };
283
290
  }
284
291
 
285
292
  export function flattenEnvironmentResources(resources = {}) {
@@ -290,69 +297,89 @@ export function flattenEnvironmentResources(resources = {}) {
290
297
  }
291
298
 
292
299
  export function buildPostgresApplianceRequest(name, resource, environment, network) {
293
- const extensions = normalizePostgresExtensions(resource.extensions);
294
- const image = resolvePostgresResourceImage(resource, name);
295
- return {
296
- name: resource.vm?.name || `${environment.name}-${name}`,
297
- kind: "postgres",
298
- network_id: resource.vm?.networkId || network?.id || "",
299
- profile: resource.vm?.profile || "ubuntu-docker",
300
- disk_size_mb: parseDiskMB(resource.vm?.disk || resource.vm?.diskSize || resource.vm?.diskSizeMB || resource.disk || "24G"),
301
- memory_mb: Number(resource.vm?.memoryMB || resource.memoryMB || 1536),
302
- vcpus: Number(resource.vm?.vcpus || resource.vcpus || 1),
303
- autostart: Boolean(resource.vm?.autostart || resource.autostart),
304
- postgres: {
305
- version: resource.version || "16",
306
- ...(image ? { image } : {}),
307
- database: resource.database || name,
308
- user: resource.user || "app",
309
- ...(resource.password ? { password: resource.password } : {}),
310
- ...(resource.port ? { port: Number(resource.port) } : {}),
311
- ...(resource.maxConnections ? { max_connections: Number(resource.maxConnections) } : {}),
312
- },
313
- metadata: {
314
- "testkit.environment": environment.name,
315
- "testkit.resource": name,
316
- ...(extensions.length > 0 ? { "testkit.postgres.extensions": extensions.join(",") } : {}),
317
- },
318
- };
300
+ const extensions = normalizePostgresExtensions(resource.extensions);
301
+ const image = resolvePostgresResourceImage(resource, name);
302
+ return {
303
+ name: resource.vm?.name || `${environment.name}-${name}`,
304
+ kind: "postgres",
305
+ network_id: resource.vm?.networkId || network?.id || "",
306
+ profile: resource.vm?.profile || "ubuntu-docker",
307
+ disk_size_mb: parseDiskMB(resource.vm?.disk || resource.vm?.diskSize || resource.vm?.diskSizeMB || resource.disk || "24G"),
308
+ memory_mb: Number(resource.vm?.memoryMB || resource.memoryMB || 1536),
309
+ vcpus: Number(resource.vm?.vcpus || resource.vcpus || 1),
310
+ autostart: Boolean(resource.vm?.autostart || resource.autostart),
311
+ postgres: {
312
+ version: resource.version || "16",
313
+ ...(image ? { image } : {}),
314
+ database: resource.database || name,
315
+ user: resource.user || "app",
316
+ ...(resource.password ? { password: resource.password } : {}),
317
+ ...(resource.port ? { port: Number(resource.port) } : {}),
318
+ ...(resource.maxConnections ? { max_connections: Number(resource.maxConnections) } : {}),
319
+ },
320
+ metadata: {
321
+ "testkit.environment": environment.name,
322
+ "testkit.resource": name,
323
+ ...(extensions.length > 0 ? { "testkit.postgres.extensions": extensions.join(",") } : {}),
324
+ },
325
+ };
319
326
  }
320
327
 
321
328
  export function resolvePostgresResourceImage(resource, resourceName = "postgres") {
322
- if (resource.image) return resource.image;
323
- const extensions = normalizePostgresExtensions(resource.extensions);
324
- if (extensions.length === 0) return null;
325
- const unsupported = extensions.filter((extension) => extension !== "vector" && extension !== "pgvector");
326
- if (unsupported.length > 0) {
327
- throw new Error(
328
- `Postgres resource "${resourceName}" requests unsupported extensions: ${unsupported.join(", ")}. ` +
329
- `Set resource.postgres({ image }) to provide a custom Postgres image.`
330
- );
331
- }
332
- return `pgvector/pgvector:pg${postgresMajorVersion(resource.version || "16")}-trixie`;
329
+ if (resource.image) return resource.image;
330
+ const extensions = normalizePostgresExtensions(resource.extensions);
331
+ if (extensions.length === 0) return null;
332
+ const unsupported = extensions.filter((extension) => extension !== "vector" && extension !== "pgvector");
333
+ if (unsupported.length > 0) {
334
+ throw new Error(
335
+ `Postgres resource "${resourceName}" requests unsupported extensions: ${unsupported.join(", ")}. ` +
336
+ `Set resource.postgres({ image }) to provide a custom Postgres image.`
337
+ );
338
+ }
339
+ return `pgvector/pgvector:pg${postgresMajorVersion(resource.version || "16")}-trixie`;
333
340
  }
334
341
 
335
342
  function normalizePostgresExtensions(extensions) {
336
- if (extensions == null) return [];
337
- if (!Array.isArray(extensions)) throw new Error("resource.postgres({ extensions }) must be an array");
338
- return [...new Set(extensions.map((extension) => String(extension).trim().toLowerCase()).filter(Boolean))].sort();
343
+ if (extensions == null) return [];
344
+ if (!Array.isArray(extensions)) throw new Error("resource.postgres({ extensions }) must be an array");
345
+ return [...new Set(extensions.map((extension) => String(extension).trim().toLowerCase()).filter(Boolean))].sort();
339
346
  }
340
347
 
341
348
  function postgresMajorVersion(version) {
342
- const match = String(version || "16").match(/^\d+/);
343
- if (!match) throw new Error(`Invalid Postgres version for resource image selection: ${version}`);
344
- return match[0];
349
+ const match = String(version || "16").match(/^\d+/);
350
+ if (!match) throw new Error(`Invalid Postgres version for resource image selection: ${version}`);
351
+ return match[0];
345
352
  }
346
353
 
347
- async function deleteManifestResources(manifest) {
354
+ async function deleteManifestResources(manifest, options = {}) {
348
355
  const resources = manifest.resources || {};
349
- if (Object.keys(resources).length === 0) return;
350
356
  const client = new KilnClient(manifest.kiln?.api || {});
351
357
  for (const resource of Object.values(resources)) {
352
358
  if (resource?.name) {
353
359
  await client.deleteAppliance(resource.name).catch(() => {});
354
360
  }
355
361
  }
362
+ if (options.includeEnvironmentVM && manifest.kiln?.vm?.name) {
363
+ await client.deleteVM(manifest.kiln.vm.name).catch(() => {});
364
+ }
365
+ }
366
+
367
+ async function cleanupKilnOrphans(manifest) {
368
+ const client = new KilnClient(manifest.kiln?.api || {});
369
+ await client.cleanup({ dry_run: false });
370
+ }
371
+
372
+ function removeKilnEnvironmentState(productDir, name) {
373
+ const environmentDir = getEnvironmentDir(productDir, name);
374
+ fs.rmSync(environmentDir, { recursive: true, force: true });
375
+ const parent = path.dirname(environmentDir);
376
+ try {
377
+ if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) {
378
+ fs.rmdirSync(parent);
379
+ }
380
+ } catch {
381
+ // Best-effort pruning only.
382
+ }
356
383
  }
357
384
 
358
385
  async function ensureVM(client, vmConfig, networkId) {
@@ -57,6 +57,23 @@ export async function listManagedDockerContainers() {
57
57
  ]);
58
58
  }
59
59
 
60
+ export async function inspectDockerVolume(volumeRef) {
61
+ try {
62
+ const { stdout } = await execa("docker", ["volume", "inspect", volumeRef]);
63
+ const parsed = JSON.parse(stdout);
64
+ return parsed[0] || null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export async function listManagedDockerVolumes() {
71
+ return listDockerVolumes([
72
+ "--filter",
73
+ `label=${TESTKIT_MANAGED_LABEL}=true`,
74
+ ]);
75
+ }
76
+
60
77
  export async function listLegacyTestkitPostgresContainers() {
61
78
  const containers = await listDockerContainers([]);
62
79
  return containers.filter((container) => {
@@ -67,7 +84,16 @@ export async function listLegacyTestkitPostgresContainers() {
67
84
 
68
85
  export async function removeDockerContainer(containerRef) {
69
86
  try {
70
- await execa("docker", ["rm", "-f", containerRef]);
87
+ await execa("docker", ["rm", "-f", "-v", containerRef]);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ export async function removeDockerVolume(volumeRef) {
95
+ try {
96
+ await execa("docker", ["volume", "rm", "-f", volumeRef]);
71
97
  return true;
72
98
  } catch {
73
99
  return false;
@@ -88,6 +114,10 @@ export function dockerContainerSummary(container) {
88
114
  return `${container.name} (${status})`;
89
115
  }
90
116
 
117
+ export function dockerVolumeSummary(volume) {
118
+ return `${volume.name}`;
119
+ }
120
+
91
121
  async function listDockerContainers(filters) {
92
122
  let stdout = "";
93
123
  try {
@@ -114,6 +144,31 @@ async function listDockerContainers(filters) {
114
144
  return containers.map(normalizeInspectContainer).filter(Boolean);
115
145
  }
116
146
 
147
+ async function listDockerVolumes(filters) {
148
+ let stdout = "";
149
+ try {
150
+ const result = await execa("docker", [
151
+ "volume",
152
+ "ls",
153
+ "-q",
154
+ ...filters,
155
+ ]);
156
+ stdout = result.stdout;
157
+ } catch {
158
+ return [];
159
+ }
160
+
161
+ const names = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
162
+ if (names.length === 0) return [];
163
+
164
+ const volumes = [];
165
+ for (const name of names) {
166
+ const inspect = await inspectDockerVolume(name);
167
+ if (inspect) volumes.push(inspect);
168
+ }
169
+ return volumes.map(normalizeInspectVolume).filter(Boolean);
170
+ }
171
+
117
172
  function normalizeInspectContainer(inspect) {
118
173
  if (!inspect?.Id) return null;
119
174
  const name = String(inspect.Name || "").replace(/^\//, "");
@@ -130,6 +185,17 @@ function normalizeInspectContainer(inspect) {
130
185
  };
131
186
  }
132
187
 
188
+ function normalizeInspectVolume(inspect) {
189
+ if (!inspect?.Name) return null;
190
+ return {
191
+ name: inspect.Name,
192
+ driver: inspect.Driver || "",
193
+ mountpoint: inspect.Mountpoint || "",
194
+ labels: inspect.Labels || {},
195
+ createdAt: inspect.CreatedAt || null,
196
+ };
197
+ }
198
+
133
199
  function canonicalizeProductDir(productDir) {
134
200
  const resolved = path.resolve(productDir || process.cwd());
135
201
  try {