@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.
- package/README.md +29 -9
- package/lib/cli/assistant/view-model.mjs +1 -1
- package/lib/cli/components/blocks/run-tree.mjs +1 -1
- package/lib/cli/renderers/run/events.mjs +4 -3
- package/lib/cli/renderers/run/failure.mjs +2 -2
- package/lib/cli/renderers/run/inline-detail.mjs +2 -2
- package/lib/cli/renderers/run/interactive.mjs +2 -2
- package/lib/cli/renderers/run/text-reporter.mjs +9 -9
- package/lib/cli/state/run/model.mjs +7 -7
- package/lib/cli/state/run/state.mjs +3 -3
- package/lib/cli/terminal/colors.mjs +1 -1
- package/lib/config/database-materialization.mjs +25 -0
- package/lib/config/database.mjs +30 -0
- package/lib/config/index.mjs +47 -1
- package/lib/config/runtime.mjs +130 -0
- package/lib/config-api/index.d.ts +28 -0
- package/lib/config-api/index.mjs +6 -0
- package/lib/database/cleanup.mjs +76 -1
- package/lib/database/constants.mjs +3 -0
- package/lib/database/index.mjs +6 -0
- package/lib/database/local-postgres.mjs +123 -4
- package/lib/database/naming.mjs +7 -0
- package/lib/database/resource-postgres.mjs +13 -0
- package/lib/database/state-files.mjs +17 -0
- package/lib/docker-compat/matrix.mjs +5 -3
- package/lib/kiln/client.mjs +8 -0
- package/lib/local/kiln-driver.mjs +96 -68
- package/lib/ownership/docker.mjs +67 -1
- package/lib/regressions/github-transport.mjs +178 -4
- package/lib/regressions/github.mjs +52 -16
- package/lib/regressions/index.d.ts +58 -29
- package/lib/regressions/index.mjs +171 -58
- package/lib/regressions/workflow.mjs +266 -0
- package/lib/results/artifacts.mjs +8 -7
- package/lib/runner/formatting.mjs +17 -16
- package/lib/runner/orchestrator.mjs +6 -5
- package/lib/runner/planning.mjs +40 -0
- package/lib/runner/regressions.mjs +183 -33
- package/lib/runner/reporting.mjs +1 -1
- package/lib/runner/run-finalization.mjs +34 -4
- package/lib/runner/runtime-manager.mjs +91 -10
- package/lib/runner/scheduler/index.mjs +30 -1
- package/lib/runtime/index.d.ts +5 -5
- package/lib/runtime-src/k6/http.js +11 -11
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/node_modules/es-toolkit/CHANGELOG.md +801 -0
- package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
- package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
- package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
- package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
- package/node_modules/esprima/ChangeLog +235 -0
- package/package.json +6 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
package/lib/config-api/index.mjs
CHANGED
|
@@ -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 } : {}),
|
package/lib/database/cleanup.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/lib/database/index.mjs
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
}
|
package/lib/database/naming.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/kiln/client.mjs
CHANGED
|
@@ -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
|
}
|