@elench/testkit 0.1.149 → 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 (60) hide show
  1. package/README.md +29 -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/database-materialization.mjs +25 -0
  13. package/lib/config/database.mjs +30 -0
  14. package/lib/config/index.mjs +47 -1
  15. package/lib/config/runtime.mjs +130 -0
  16. package/lib/config-api/index.d.ts +28 -0
  17. package/lib/config-api/index.mjs +6 -0
  18. package/lib/database/cleanup.mjs +76 -1
  19. package/lib/database/constants.mjs +3 -0
  20. package/lib/database/index.mjs +6 -0
  21. package/lib/database/local-postgres.mjs +123 -4
  22. package/lib/database/naming.mjs +7 -0
  23. package/lib/database/resource-postgres.mjs +13 -0
  24. package/lib/database/state-files.mjs +17 -0
  25. package/lib/docker-compat/matrix.mjs +5 -3
  26. package/lib/kiln/client.mjs +8 -0
  27. package/lib/local/kiln-driver.mjs +96 -68
  28. package/lib/ownership/docker.mjs +67 -1
  29. package/lib/regressions/github-transport.mjs +178 -4
  30. package/lib/regressions/github.mjs +52 -16
  31. package/lib/regressions/index.d.ts +58 -29
  32. package/lib/regressions/index.mjs +171 -58
  33. package/lib/regressions/workflow.mjs +266 -0
  34. package/lib/results/artifacts.mjs +8 -7
  35. package/lib/runner/formatting.mjs +17 -16
  36. package/lib/runner/orchestrator.mjs +6 -5
  37. package/lib/runner/planning.mjs +40 -0
  38. package/lib/runner/regressions.mjs +183 -33
  39. package/lib/runner/reporting.mjs +1 -1
  40. package/lib/runner/run-finalization.mjs +34 -4
  41. package/lib/runner/runtime-manager.mjs +91 -10
  42. package/lib/runner/scheduler/index.mjs +30 -1
  43. package/lib/runtime/index.d.ts +5 -5
  44. package/lib/runtime-src/k6/http.js +11 -11
  45. package/node_modules/@elench/next-analysis/package.json +1 -1
  46. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  47. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  48. package/node_modules/@elench/ts-analysis/package.json +1 -1
  49. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  50. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  51. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  52. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  53. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  54. package/node_modules/esprima/ChangeLog +235 -0
  55. package/package.json +6 -5
  56. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
  57. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
  58. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
  59. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
  60. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
@@ -36,6 +36,9 @@ function postgresDatabase(options = {}) {
36
36
  }
37
37
  return {
38
38
  ...options,
39
+ ...(options.maxConnections ? { maxConnections: options.maxConnections } : {}),
40
+ ...(options.reservedConnections ? { reservedConnections: options.reservedConnections } : {}),
41
+ ...(options.runtimeConnections ? { runtimeConnections: options.runtimeConnections } : {}),
39
42
  };
40
43
  }
41
44
 
@@ -44,6 +47,9 @@ function postgresResource(options = {}) {
44
47
  kind: "postgres",
45
48
  version: options.version || "16",
46
49
  ...(options.image ? { image: options.image } : {}),
50
+ ...(options.maxConnections ? { maxConnections: options.maxConnections } : {}),
51
+ ...(options.reservedConnections ? { reservedConnections: options.reservedConnections } : {}),
52
+ ...(options.runtimeConnections ? { runtimeConnections: options.runtimeConnections } : {}),
47
53
  database: options.database || options.name || "app",
48
54
  user: options.user || "app",
49
55
  ...(options.password ? { password: options.password } : {}),
@@ -7,9 +7,12 @@ import {
7
7
  TESTKIT_SCOPE_LABEL,
8
8
  buildProductIdentity,
9
9
  dockerContainerSummary,
10
+ dockerVolumeSummary,
10
11
  listLegacyTestkitPostgresContainers,
11
12
  listManagedDockerContainers,
13
+ listManagedDockerVolumes,
12
14
  removeDockerContainer,
15
+ removeDockerVolume,
13
16
  stopDockerContainer,
14
17
  } from "../ownership/docker.mjs";
15
18
  import { buildContainerName } from "./naming.mjs";
@@ -22,6 +25,7 @@ import {
22
25
  export async function cleanupLocalPostgresDockerResources(options = {}) {
23
26
  const product = buildProductIdentity(options.productDir || process.cwd());
24
27
  const managed = await listManagedDockerContainers();
28
+ const managedVolumes = await listManagedDockerVolumes();
25
29
  const targets = [];
26
30
  const kept = [];
27
31
  const stopped = [];
@@ -38,6 +42,16 @@ export async function cleanupLocalPostgresDockerResources(options = {}) {
38
42
  }
39
43
  }
40
44
 
45
+ for (const volume of managedVolumes) {
46
+ if (!isManagedLocalPostgresVolume(volume)) continue;
47
+ const classification = classifyManagedLocalPostgresVolume(volume, product, options);
48
+ if (classification.action === "remove") {
49
+ targets.push({ ...volume, action: "remove", reason: classification.reason, legacy: false, resourceType: "volume" });
50
+ } else if (classification.reason) {
51
+ kept.push({ ...volume, reason: classification.reason, legacy: false, resourceType: "volume" });
52
+ }
53
+ }
54
+
41
55
  if (options.includeLegacy) {
42
56
  const currentLegacyName = buildContainerName(product.dir);
43
57
  for (const container of await listLegacyTestkitPostgresContainers()) {
@@ -51,6 +65,8 @@ export async function cleanupLocalPostgresDockerResources(options = {}) {
51
65
  if (container.action === "stop") {
52
66
  await stopDockerContainer(container.name || container.id);
53
67
  stopped.push(container);
68
+ } else if (container.resourceType === "volume") {
69
+ await removeDockerVolume(container.name);
54
70
  } else {
55
71
  await removeDockerContainer(container.name || container.id);
56
72
  }
@@ -71,7 +87,10 @@ export function formatDatabaseResourceCleanupLine(entry, dryRun = false) {
71
87
  ? isStop ? "Would stop" : "Would remove"
72
88
  : isStop ? "Stopped" : "Removed";
73
89
  const legacy = entry.legacy ? " legacy" : "";
74
- return `${action}${legacy} database resource ${dockerContainerSummary(entry)} reason=${entry.reason}`;
90
+ const summary = entry.resourceType === "volume"
91
+ ? `volume ${dockerVolumeSummary(entry)}`
92
+ : dockerContainerSummary(entry);
93
+ return `${action}${legacy} database resource ${summary} reason=${entry.reason}`;
75
94
  }
76
95
 
77
96
  function classifyManagedLocalPostgresContainer(container, product, options) {
@@ -112,6 +131,41 @@ function classifyManagedLocalPostgresContainer(container, product, options) {
112
131
  return { action: "keep", reason: options.global ? "other-product-retained" : "different-product" };
113
132
  }
114
133
 
134
+ function classifyManagedLocalPostgresVolume(volume, product, options) {
135
+ const labels = volume.labels || {};
136
+ const volumeProductId = labels[TESTKIT_PRODUCT_ID_LABEL] || "";
137
+ const volumeProductDir = labels[TESTKIT_PRODUCT_DIR_LABEL] || "";
138
+ const currentProduct = volumeProductId === product.id;
139
+
140
+ if (options.force && currentProduct) {
141
+ return { action: "remove", reason: "destroy-current-product" };
142
+ }
143
+
144
+ if (!options.global && !currentProduct) {
145
+ return { action: "keep", reason: "different-product" };
146
+ }
147
+
148
+ if (currentProduct) {
149
+ if (!hasRemainingLocalArtifacts(product.dir, readStateValue)) {
150
+ return { action: "remove", reason: "current-product-no-local-artifacts" };
151
+ }
152
+ if (!localArtifactsReferenceVolume(product.dir, volume.name)) {
153
+ return { action: "remove", reason: "current-product-unreferenced" };
154
+ }
155
+ return { action: "keep", reason: "current-product-referenced" };
156
+ }
157
+
158
+ if (options.global && volumeProductDir && !fs.existsSync(volumeProductDir)) {
159
+ return { action: "remove", reason: "product-dir-missing" };
160
+ }
161
+
162
+ if (options.global && volumeProductDir && !hasRemainingLocalArtifacts(volumeProductDir, readStateValue)) {
163
+ return { action: "remove", reason: "product-has-no-local-artifacts" };
164
+ }
165
+
166
+ return { action: "keep", reason: options.global ? "other-product-retained" : "different-product" };
167
+ }
168
+
115
169
  function shouldStopIdleLocalPostgresContainer(container, productDir, options) {
116
170
  return Boolean(
117
171
  options.stopIdle &&
@@ -130,6 +184,14 @@ function isManagedLocalPostgresContainer(container) {
130
184
  );
131
185
  }
132
186
 
187
+ function isManagedLocalPostgresVolume(volume) {
188
+ const labels = volume.labels || {};
189
+ return (
190
+ labels[TESTKIT_RESOURCE_KIND_LABEL] === "postgres-volume" &&
191
+ labels[TESTKIT_SCOPE_LABEL] === "local-postgres"
192
+ );
193
+ }
194
+
133
195
  function localArtifactsReferenceContainer(productDir, containerName) {
134
196
  if (!containerName) return false;
135
197
  const root = path.join(productDir, ".testkit");
@@ -146,6 +208,19 @@ function localArtifactsReferenceContainer(productDir, containerName) {
146
208
  return referenced;
147
209
  }
148
210
 
211
+ function localArtifactsReferenceVolume(productDir, volumeName) {
212
+ if (!volumeName) return false;
213
+ const root = path.join(productDir, ".testkit");
214
+ let referenced = false;
215
+ visitDirs(root, (dir) => {
216
+ if (referenced) return;
217
+ if (readStateValue(path.join(dir, "volume_name")) === volumeName) {
218
+ referenced = true;
219
+ }
220
+ });
221
+ return referenced;
222
+ }
223
+
149
224
  function hasActiveProductRuntime(productDir) {
150
225
  return hasActiveRunManifest(productDir) || hasActiveLocalEnvironmentManifest(productDir);
151
226
  }
@@ -2,6 +2,9 @@ export const LOCAL_IMAGE = "pgvector/pgvector:pg16";
2
2
  export const LOCAL_USER = "testkit";
3
3
  export const LOCAL_PASSWORD = "testkit";
4
4
  export const LOCAL_ADMIN_DB = "postgres";
5
+ export const LOCAL_MAX_CONNECTIONS = 200;
6
+ export const LOCAL_RESERVED_CONNECTIONS = 20;
7
+ export const LOCAL_RUNTIME_CONNECTIONS = 20;
5
8
  export const LOCAL_READY_TIMEOUT_MS = 60_000;
6
9
  export const LOCAL_POLL_INTERVAL_MS = 1_000;
7
10
  export const LOCAL_ADMIN_QUERY_RETRY_MS = 15_000;
@@ -457,6 +457,12 @@ async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint,
457
457
  fs.writeFileSync(path.join(config.stateDir, `${backend}_template_database_name`), templateDbName);
458
458
  if (backend === "local") {
459
459
  fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
460
+ if (infra.volumeName) {
461
+ fs.writeFileSync(path.join(config.stateDir, "volume_name"), infra.volumeName);
462
+ }
463
+ if (infra.volumeMountPath) {
464
+ fs.writeFileSync(path.join(config.stateDir, "volume_mount_path"), infra.volumeMountPath);
465
+ }
460
466
  } else {
461
467
  fs.writeFileSync(path.join(config.stateDir, "resource_name"), infra.resourceName);
462
468
  writeResourceConnectionState(config.stateDir, infra);
@@ -4,34 +4,61 @@ import { execa } from "execa";
4
4
  import {
5
5
  LOCAL_ADMIN_DB,
6
6
  LOCAL_IMAGE,
7
+ LOCAL_MAX_CONNECTIONS,
7
8
  LOCAL_PASSWORD,
8
9
  LOCAL_POLL_INTERVAL_MS,
9
10
  LOCAL_READY_TIMEOUT_MS,
10
11
  LOCAL_USER,
11
12
  } from "./constants.mjs";
12
- import { buildContainerName } from "./naming.mjs";
13
+ import { buildContainerName, buildVolumeName } from "./naming.mjs";
13
14
  import { getLocalInfraDir, readStateValue } from "./state.mjs";
14
- import { buildDockerResourceLabels, dockerLabelArgs } from "../ownership/docker.mjs";
15
+ import {
16
+ buildDockerResourceLabels,
17
+ dockerLabelArgs,
18
+ inspectDockerVolume,
19
+ removeDockerVolume,
20
+ } from "../ownership/docker.mjs";
15
21
  import { writeLocalInfraState } from "./state-files.mjs";
16
22
 
23
+ const LEGACY_POSTGRES_DATA_VOLUME_TARGET = "/var/lib/postgresql/data";
24
+ const POSTGRES_18_VOLUME_TARGET = "/var/lib/postgresql";
25
+
17
26
  export async function ensureLocalContainer(productDir, database = {}) {
18
27
  const infraDir = getLocalInfraDir(productDir);
19
28
  fs.mkdirSync(infraDir, { recursive: true });
20
29
 
21
30
  const containerName =
22
31
  readStateValue(path.join(infraDir, "container_name")) || buildContainerName(productDir);
32
+ const volumeName =
33
+ readStateValue(path.join(infraDir, "volume_name")) || buildVolumeName(productDir);
23
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);
24
37
  const user = database.user || readStateValue(path.join(infraDir, "user")) || LOCAL_USER;
25
38
  const password =
26
39
  database.password || readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD;
40
+ const maxConnections = normalizeMaxConnections(
41
+ database.maxConnections || readStateValue(path.join(infraDir, "max_connections")) || LOCAL_MAX_CONNECTIONS
42
+ );
27
43
 
28
44
  let inspect = await inspectContainer(containerName);
29
- if (inspect && inspect.Config?.Image !== image) {
45
+ if (
46
+ inspect &&
47
+ (
48
+ inspect.Config?.Image !== image ||
49
+ !containerUsesMaxConnections(inspect, maxConnections) ||
50
+ !containerUsesNamedVolume(inspect, volumeName, volumeMountPath)
51
+ )
52
+ ) {
30
53
  await stopAndRemoveContainer(containerName);
54
+ if (previousImage && previousImage !== image) {
55
+ await removeOwnedLocalPostgresVolume(productDir, volumeName);
56
+ }
31
57
  inspect = null;
32
58
  }
33
59
 
34
60
  if (!inspect) {
61
+ await ensureLocalPostgresVolume(productDir, volumeName);
35
62
  const labels = buildLocalPostgresContainerLabels(productDir, containerName);
36
63
  await execa("docker", [
37
64
  "run",
@@ -39,6 +66,8 @@ export async function ensureLocalContainer(productDir, database = {}) {
39
66
  "--name",
40
67
  containerName,
41
68
  ...dockerLabelArgs(labels),
69
+ "--mount",
70
+ `type=volume,source=${volumeName},target=${volumeMountPath}`,
42
71
  "-e",
43
72
  `POSTGRES_USER=${user}`,
44
73
  "-e",
@@ -48,6 +77,9 @@ export async function ensureLocalContainer(productDir, database = {}) {
48
77
  "-p",
49
78
  "127.0.0.1::5432",
50
79
  image,
80
+ "postgres",
81
+ "-c",
82
+ `max_connections=${maxConnections}`,
51
83
  ]);
52
84
  inspect = await inspectContainer(containerName);
53
85
  } else if (!inspect.State?.Running) {
@@ -63,9 +95,12 @@ export async function ensureLocalContainer(productDir, database = {}) {
63
95
  const infra = {
64
96
  containerName,
65
97
  containerId: inspect.Id,
98
+ volumeName,
99
+ volumeMountPath,
66
100
  image,
67
101
  user,
68
102
  password,
103
+ maxConnections,
69
104
  host: "127.0.0.1",
70
105
  port: Number(hostPort),
71
106
  };
@@ -79,6 +114,7 @@ export async function loadExistingLocalContainer(productDir) {
79
114
  const infraDir = getLocalInfraDir(productDir);
80
115
  const containerName = readStateValue(path.join(infraDir, "container_name"));
81
116
  if (!containerName) return null;
117
+ const volumeName = readStateValue(path.join(infraDir, "volume_name")) || buildVolumeName(productDir);
82
118
 
83
119
  const inspect = await inspectContainer(containerName);
84
120
  if (!inspect) return null;
@@ -94,9 +130,15 @@ export async function loadExistingLocalContainer(productDir) {
94
130
  const infra = {
95
131
  containerName,
96
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),
97
136
  image: nextInspect.Config?.Image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE,
98
137
  user: readStateValue(path.join(infraDir, "user")) || LOCAL_USER,
99
138
  password: readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD,
139
+ maxConnections: normalizeMaxConnections(
140
+ readStateValue(path.join(infraDir, "max_connections")) || LOCAL_MAX_CONNECTIONS
141
+ ),
100
142
  host: "127.0.0.1",
101
143
  port: Number(hostPort),
102
144
  };
@@ -104,6 +146,29 @@ export async function loadExistingLocalContainer(productDir) {
104
146
  return infra;
105
147
  }
106
148
 
149
+ function containerUsesMaxConnections(inspect, maxConnections) {
150
+ const cmd = inspect?.Config?.Cmd;
151
+ if (!Array.isArray(cmd)) return false;
152
+ return cmd.join(" ") === `postgres -c max_connections=${maxConnections}`;
153
+ }
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
+
164
+ function normalizeMaxConnections(value) {
165
+ const number = Number(value);
166
+ if (!Number.isInteger(number) || number < 10) {
167
+ throw new Error("database.maxConnections must be an integer greater than or equal to 10");
168
+ }
169
+ return number;
170
+ }
171
+
107
172
  export async function inspectContainer(containerName) {
108
173
  try {
109
174
  const { stdout } = await execa("docker", ["inspect", containerName]);
@@ -116,12 +181,57 @@ export async function inspectContainer(containerName) {
116
181
 
117
182
  export async function stopAndRemoveContainer(containerName) {
118
183
  try {
119
- await execa("docker", ["rm", "-f", containerName]);
184
+ await execa("docker", ["rm", "-f", "-v", containerName]);
120
185
  } catch {
121
186
  // Already gone.
122
187
  }
123
188
  }
124
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
+
125
235
  async function waitForLocalContainerReady(infra) {
126
236
  const startedAt = Date.now();
127
237
  while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
@@ -153,6 +263,15 @@ function buildLocalPostgresContainerLabels(productDir, containerName) {
153
263
  });
154
264
  }
155
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
+
156
275
  function sleep(ms) {
157
276
  return new Promise((resolve) => setTimeout(resolve, ms));
158
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)}`,
@@ -42,6 +42,9 @@ function normalizeResourcePostgresConnection(resourceName, connection) {
42
42
  const password = connection.password || fromUrl.password || "";
43
43
  const adminDatabase = connection.adminDatabase || connection.admin_database || fromUrl.database || LOCAL_ADMIN_DB;
44
44
  const sslMode = connection.sslMode || connection.sslmode || fromUrl.sslMode || "disable";
45
+ const maxConnections = normalizeOptionalMaxConnections(
46
+ connection.maxConnections || connection.max_connections
47
+ );
45
48
  if (!host) throw new Error(`Postgres resource "${resourceName}" connection is missing host`);
46
49
  if (!Number.isInteger(port) || port <= 0) {
47
50
  throw new Error(`Postgres resource "${resourceName}" connection has invalid port`);
@@ -54,11 +57,21 @@ function normalizeResourcePostgresConnection(resourceName, connection) {
54
57
  port,
55
58
  user,
56
59
  password,
60
+ ...(maxConnections ? { maxConnections } : {}),
57
61
  adminDatabase,
58
62
  sslMode,
59
63
  };
60
64
  }
61
65
 
66
+ function normalizeOptionalMaxConnections(value) {
67
+ if (value == null || value === "") return undefined;
68
+ const number = Number(value);
69
+ if (!Number.isInteger(number) || number < 10) {
70
+ throw new Error("Postgres resource connection max_connections must be an integer greater than or equal to 10");
71
+ }
72
+ return number;
73
+ }
74
+
62
75
  function parsePostgresConnectionUrl(rawUrl) {
63
76
  const parsed = new URL(rawUrl);
64
77
  return {
@@ -8,10 +8,17 @@ 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);
14
20
  fs.writeFileSync(path.join(infraDir, "password"), infra.password);
21
+ fs.writeFileSync(path.join(infraDir, "max_connections"), String(infra.maxConnections));
15
22
  fs.writeFileSync(path.join(infraDir, "host"), infra.host);
16
23
  fs.writeFileSync(path.join(infraDir, "port"), String(infra.port));
17
24
  }
@@ -23,6 +30,12 @@ export function writeCacheState(cacheDir, config, infra, templateDbName, fingerp
23
30
  fs.writeFileSync(path.join(cacheDir, "template_fingerprint"), fingerprint);
24
31
  if (backend === "local") {
25
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
+ }
26
39
  } else {
27
40
  fs.writeFileSync(path.join(cacheDir, "resource_name"), infra.resourceName);
28
41
  writeResourceConnectionState(cacheDir, infra);
@@ -34,6 +47,9 @@ export function writeResourceConnectionState(stateDir, infra) {
34
47
  fs.writeFileSync(path.join(stateDir, "resource_port"), String(infra.port));
35
48
  fs.writeFileSync(path.join(stateDir, "resource_user"), infra.user);
36
49
  fs.writeFileSync(path.join(stateDir, "resource_password"), infra.password);
50
+ if (infra.maxConnections) {
51
+ fs.writeFileSync(path.join(stateDir, "resource_max_connections"), String(infra.maxConnections));
52
+ }
37
53
  fs.writeFileSync(path.join(stateDir, "resource_admin_database"), infra.adminDatabase || LOCAL_ADMIN_DB);
38
54
  fs.writeFileSync(path.join(stateDir, "resource_sslmode"), infra.sslMode || "disable");
39
55
  }
@@ -47,6 +63,7 @@ export function readResourceConnectionState(stateDir, readStateValue) {
47
63
  port: Number(readStateValue(path.join(stateDir, "resource_port")) || 5432),
48
64
  user,
49
65
  password: readStateValue(path.join(stateDir, "resource_password")) || "",
66
+ maxConnections: Number(readStateValue(path.join(stateDir, "resource_max_connections")) || 0) || undefined,
50
67
  adminDatabase: readStateValue(path.join(stateDir, "resource_admin_database")) || LOCAL_ADMIN_DB,
51
68
  sslMode: readStateValue(path.join(stateDir, "resource_sslmode")) || "disable",
52
69
  };
@@ -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
  }