@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.
- package/README.md +21 -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/runtime.mjs +130 -0
- package/lib/config-api/index.d.ts +22 -0
- package/lib/database/cleanup.mjs +76 -1
- package/lib/database/index.mjs +6 -0
- package/lib/database/local-postgres.mjs +95 -4
- package/lib/database/naming.mjs +7 -0
- package/lib/database/state-files.mjs +12 -0
- package/lib/docker-compat/matrix.mjs +5 -3
- package/lib/kiln/client.mjs +8 -0
- package/lib/local/kiln-driver.mjs +96 -69
- 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 +56 -28
- package/lib/regressions/index.mjs +122 -47
- 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 +5 -4
- package/lib/runner/regressions.mjs +175 -33
- package/lib/runner/run-finalization.mjs +34 -4
- 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
|
@@ -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 {
|
|
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
|
-
(
|
|
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
|
}
|
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)}`,
|
|
@@ -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
|
}
|
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
|
}
|
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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) {
|
package/lib/ownership/docker.mjs
CHANGED
|
@@ -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 {
|