@elench/testkit 0.1.13 → 0.1.15
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 +14 -2
- package/lib/cli.mjs +2 -1
- package/lib/config.mjs +237 -33
- package/lib/database.mjs +656 -0
- package/lib/runner.mjs +275 -98
- package/package.json +1 -1
package/lib/database.mjs
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import { requireNeonApiKey, resolveServiceCwd } from "./config.mjs";
|
|
6
|
+
import { runScript } from "./exec.mjs";
|
|
7
|
+
|
|
8
|
+
const LOCAL_IMAGE = "postgres:16";
|
|
9
|
+
const LOCAL_USER = "testkit";
|
|
10
|
+
const LOCAL_PASSWORD = "testkit";
|
|
11
|
+
const LOCAL_ADMIN_DB = "postgres";
|
|
12
|
+
const LOCAL_READY_TIMEOUT_MS = 60_000;
|
|
13
|
+
const LOCAL_POLL_INTERVAL_MS = 1_000;
|
|
14
|
+
|
|
15
|
+
export async function prepareDatabaseRuntime(config, hooks = {}) {
|
|
16
|
+
const db = config.testkit.database;
|
|
17
|
+
if (!db) return;
|
|
18
|
+
|
|
19
|
+
fs.mkdirSync(config.stateDir, { recursive: true });
|
|
20
|
+
if (db.provider === "neon") {
|
|
21
|
+
await prepareNeonDatabase(config, hooks);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (db.provider === "local") {
|
|
25
|
+
await prepareLocalDatabase(config, hooks);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error(`Unsupported database provider "${db.provider}"`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function destroyRuntimeDatabase({ productDir, stateDir }) {
|
|
33
|
+
const backend = readStateValue(path.join(stateDir, "database_backend"));
|
|
34
|
+
if (!backend || backend === "neon") {
|
|
35
|
+
await destroyNeonDatabase(stateDir);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (backend === "local") {
|
|
40
|
+
await destroyLocalRuntimeDatabase(productDir, stateDir);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function destroyServiceDatabaseCache(productDir, serviceName) {
|
|
46
|
+
const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
|
|
47
|
+
if (!fs.existsSync(cacheDir)) return;
|
|
48
|
+
|
|
49
|
+
const backend = readStateValue(path.join(cacheDir, "database_backend"));
|
|
50
|
+
if (backend !== "local") {
|
|
51
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const lockDir = getLocalLocksDir(productDir);
|
|
56
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
57
|
+
await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
|
|
58
|
+
const infra = await loadExistingLocalContainer(productDir);
|
|
59
|
+
if (!infra) {
|
|
60
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
64
|
+
if (templateDbName) {
|
|
65
|
+
await dropDatabaseIfExists(infra, templateDbName);
|
|
66
|
+
}
|
|
67
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function cleanupOrphanedLocalInfrastructure(productDir) {
|
|
72
|
+
const infraDir = getLocalInfraDir(productDir);
|
|
73
|
+
if (!fs.existsSync(infraDir)) return;
|
|
74
|
+
|
|
75
|
+
if (hasRemainingLocalArtifacts(productDir)) return;
|
|
76
|
+
|
|
77
|
+
const containerName = readStateValue(path.join(infraDir, "container_name"));
|
|
78
|
+
if (containerName) {
|
|
79
|
+
await stopAndRemoveContainer(containerName);
|
|
80
|
+
}
|
|
81
|
+
fs.rmSync(infraDir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isDatabaseStateDir(dir) {
|
|
85
|
+
if (!fs.existsSync(dir)) return false;
|
|
86
|
+
return (
|
|
87
|
+
fs.existsSync(path.join(dir, "database_backend")) ||
|
|
88
|
+
fs.existsSync(path.join(dir, "neon_branch_id")) ||
|
|
89
|
+
fs.existsSync(path.join(dir, "neon_project_id"))
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function showServiceDatabaseStatus(productDir, serviceName) {
|
|
94
|
+
const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
|
|
95
|
+
const infraDir = getLocalInfraDir(productDir);
|
|
96
|
+
if (!fs.existsSync(cacheDir) && !fs.existsSync(infraDir)) return false;
|
|
97
|
+
|
|
98
|
+
if (fs.existsSync(cacheDir)) {
|
|
99
|
+
console.log(" database-cache/");
|
|
100
|
+
printStateDir(cacheDir, " ");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (fs.existsSync(infraDir)) {
|
|
104
|
+
console.log(" database-infra/");
|
|
105
|
+
printStateDir(infraDir, " ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function prepareNeonDatabase(config, hooks) {
|
|
112
|
+
const db = config.testkit.database;
|
|
113
|
+
requireNeonApiKey();
|
|
114
|
+
|
|
115
|
+
removeLocalRuntimeState(config.stateDir);
|
|
116
|
+
|
|
117
|
+
await runScript("neon-up.sh", {
|
|
118
|
+
NEON_PROJECT_ID: db.projectId,
|
|
119
|
+
NEON_DB_NAME: db.dbName,
|
|
120
|
+
NEON_BRANCH_NAME: db.branchName,
|
|
121
|
+
NEON_RESET: db.reset === false ? "false" : "true",
|
|
122
|
+
STATE_DIR: config.stateDir,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
fs.writeFileSync(path.join(config.stateDir, "database_backend"), "neon");
|
|
126
|
+
fs.writeFileSync(path.join(config.stateDir, "neon_project_id"), db.projectId);
|
|
127
|
+
|
|
128
|
+
const databaseUrl = readStateValue(path.join(config.stateDir, "database_url"));
|
|
129
|
+
if (hooks.runMigrate) {
|
|
130
|
+
await hooks.runMigrate(databaseUrl);
|
|
131
|
+
}
|
|
132
|
+
if (hooks.runSeed) {
|
|
133
|
+
await hooks.runSeed(databaseUrl);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function destroyNeonDatabase(stateDir) {
|
|
138
|
+
const projectId = readStateValue(path.join(stateDir, "neon_project_id"));
|
|
139
|
+
if (!projectId) return;
|
|
140
|
+
|
|
141
|
+
await runScript("neon-down.sh", {
|
|
142
|
+
NEON_PROJECT_ID: projectId,
|
|
143
|
+
STATE_DIR: stateDir,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function prepareLocalDatabase(config, hooks) {
|
|
148
|
+
const db = config.testkit.database;
|
|
149
|
+
const productDir = config.productDir;
|
|
150
|
+
const serviceName = config.name;
|
|
151
|
+
const lockDir = getLocalLocksDir(productDir);
|
|
152
|
+
const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
|
|
153
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
154
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
155
|
+
|
|
156
|
+
const templateFingerprint = await computeTemplateFingerprint(config);
|
|
157
|
+
const infra = await withLock(path.join(lockDir, "container.lock"), () =>
|
|
158
|
+
ensureLocalContainer(productDir, db)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
|
|
162
|
+
await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, hooks);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(config.stateDir, 10)}.lock`), async () => {
|
|
166
|
+
await ensureWorkerClone(config, infra, cacheDir, templateFingerprint);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, hooks) {
|
|
171
|
+
const serviceName = config.name;
|
|
172
|
+
const existingFingerprint = readStateValue(path.join(cacheDir, "template_fingerprint"));
|
|
173
|
+
const existingDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
174
|
+
const desiredDbName = buildTemplateDatabaseName(serviceName, templateFingerprint);
|
|
175
|
+
|
|
176
|
+
if (
|
|
177
|
+
existingFingerprint === templateFingerprint &&
|
|
178
|
+
existingDbName &&
|
|
179
|
+
(await databaseExists(infra, existingDbName))
|
|
180
|
+
) {
|
|
181
|
+
writeLocalCacheState(cacheDir, infra, existingDbName, templateFingerprint);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (existingDbName && existingDbName !== desiredDbName) {
|
|
186
|
+
await dropDatabaseIfExists(infra, existingDbName);
|
|
187
|
+
}
|
|
188
|
+
if (await databaseExists(infra, desiredDbName)) {
|
|
189
|
+
await dropDatabaseIfExists(infra, desiredDbName);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await createEmptyDatabase(infra, desiredDbName);
|
|
193
|
+
const templateUrl = buildDatabaseUrl(infra, desiredDbName);
|
|
194
|
+
if (hooks.runMigrate) {
|
|
195
|
+
await hooks.runMigrate(templateUrl);
|
|
196
|
+
}
|
|
197
|
+
if (hooks.runSeed) {
|
|
198
|
+
await hooks.runSeed(templateUrl);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
writeLocalCacheState(cacheDir, infra, desiredDbName, templateFingerprint);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function ensureWorkerClone(config, infra, cacheDir, templateFingerprint) {
|
|
205
|
+
const serviceName = config.name;
|
|
206
|
+
const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
207
|
+
if (!templateDbName) {
|
|
208
|
+
throw new Error(`Missing template database for service "${serviceName}"`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const desiredDbName = buildWorkerDatabaseName(serviceName, config.stateDir, templateFingerprint);
|
|
212
|
+
const existingDbName = readStateValue(path.join(config.stateDir, "local_database_name"));
|
|
213
|
+
const existingFingerprint = readStateValue(path.join(config.stateDir, "local_template_fingerprint"));
|
|
214
|
+
const needsReset =
|
|
215
|
+
config.testkit.database.reset !== false ||
|
|
216
|
+
existingFingerprint !== templateFingerprint ||
|
|
217
|
+
existingDbName !== desiredDbName ||
|
|
218
|
+
!(await databaseExists(infra, desiredDbName));
|
|
219
|
+
|
|
220
|
+
if (existingDbName && existingDbName !== desiredDbName) {
|
|
221
|
+
await dropDatabaseIfExists(infra, existingDbName);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (needsReset) {
|
|
225
|
+
await dropDatabaseIfExists(infra, desiredDbName);
|
|
226
|
+
await cloneDatabaseFromTemplate(infra, desiredDbName, templateDbName);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fs.mkdirSync(config.stateDir, { recursive: true });
|
|
230
|
+
removeNeonRuntimeState(config.stateDir);
|
|
231
|
+
fs.writeFileSync(path.join(config.stateDir, "database_backend"), "local");
|
|
232
|
+
fs.writeFileSync(path.join(config.stateDir, "database_url"), buildDatabaseUrl(infra, desiredDbName));
|
|
233
|
+
fs.writeFileSync(path.join(config.stateDir, "local_database_name"), desiredDbName);
|
|
234
|
+
fs.writeFileSync(path.join(config.stateDir, "local_template_fingerprint"), templateFingerprint);
|
|
235
|
+
fs.writeFileSync(path.join(config.stateDir, "local_template_database_name"), templateDbName);
|
|
236
|
+
fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function destroyLocalRuntimeDatabase(productDir, stateDir) {
|
|
240
|
+
const dbName = readStateValue(path.join(stateDir, "local_database_name"));
|
|
241
|
+
if (!dbName) return;
|
|
242
|
+
|
|
243
|
+
const infra = await loadExistingLocalContainer(productDir);
|
|
244
|
+
if (!infra) return;
|
|
245
|
+
await dropDatabaseIfExists(infra, dbName);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function ensureLocalContainer(productDir, database = {}) {
|
|
249
|
+
const infraDir = getLocalInfraDir(productDir);
|
|
250
|
+
fs.mkdirSync(infraDir, { recursive: true });
|
|
251
|
+
|
|
252
|
+
const containerName =
|
|
253
|
+
readStateValue(path.join(infraDir, "container_name")) || buildContainerName(productDir);
|
|
254
|
+
const image = database.image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE;
|
|
255
|
+
const user = database.user || readStateValue(path.join(infraDir, "user")) || LOCAL_USER;
|
|
256
|
+
const password =
|
|
257
|
+
database.password || readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD;
|
|
258
|
+
|
|
259
|
+
let inspect = await inspectContainer(containerName);
|
|
260
|
+
if (inspect && inspect.Config?.Image !== image) {
|
|
261
|
+
await stopAndRemoveContainer(containerName);
|
|
262
|
+
inspect = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!inspect) {
|
|
266
|
+
await execa("docker", [
|
|
267
|
+
"run",
|
|
268
|
+
"-d",
|
|
269
|
+
"--name",
|
|
270
|
+
containerName,
|
|
271
|
+
"-e",
|
|
272
|
+
`POSTGRES_USER=${user}`,
|
|
273
|
+
"-e",
|
|
274
|
+
`POSTGRES_PASSWORD=${password}`,
|
|
275
|
+
"-e",
|
|
276
|
+
`POSTGRES_DB=${LOCAL_ADMIN_DB}`,
|
|
277
|
+
"-p",
|
|
278
|
+
"127.0.0.1::5432",
|
|
279
|
+
image,
|
|
280
|
+
]);
|
|
281
|
+
inspect = await inspectContainer(containerName);
|
|
282
|
+
} else if (!inspect.State?.Running) {
|
|
283
|
+
await execa("docker", ["start", containerName]);
|
|
284
|
+
inspect = await inspectContainer(containerName);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const hostPort = inspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
|
|
288
|
+
if (!hostPort) {
|
|
289
|
+
throw new Error(`Could not determine published port for local database container ${containerName}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const infra = {
|
|
293
|
+
containerName,
|
|
294
|
+
containerId: inspect.Id,
|
|
295
|
+
image,
|
|
296
|
+
user,
|
|
297
|
+
password,
|
|
298
|
+
host: "127.0.0.1",
|
|
299
|
+
port: Number(hostPort),
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
await waitForLocalContainerReady(infra);
|
|
303
|
+
writeLocalInfraState(infraDir, infra);
|
|
304
|
+
return infra;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function loadExistingLocalContainer(productDir) {
|
|
308
|
+
const infraDir = getLocalInfraDir(productDir);
|
|
309
|
+
const containerName = readStateValue(path.join(infraDir, "container_name"));
|
|
310
|
+
if (!containerName) return null;
|
|
311
|
+
|
|
312
|
+
const inspect = await inspectContainer(containerName);
|
|
313
|
+
if (!inspect) return null;
|
|
314
|
+
|
|
315
|
+
if (!inspect.State?.Running) {
|
|
316
|
+
await execa("docker", ["start", containerName]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const nextInspect = await inspectContainer(containerName);
|
|
320
|
+
const hostPort = nextInspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
|
|
321
|
+
if (!hostPort) return null;
|
|
322
|
+
|
|
323
|
+
const infra = {
|
|
324
|
+
containerName,
|
|
325
|
+
containerId: nextInspect.Id,
|
|
326
|
+
image: nextInspect.Config?.Image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE,
|
|
327
|
+
user: readStateValue(path.join(infraDir, "user")) || LOCAL_USER,
|
|
328
|
+
password: readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD,
|
|
329
|
+
host: "127.0.0.1",
|
|
330
|
+
port: Number(hostPort),
|
|
331
|
+
};
|
|
332
|
+
await waitForLocalContainerReady(infra);
|
|
333
|
+
return infra;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function waitForLocalContainerReady(infra) {
|
|
337
|
+
const startedAt = Date.now();
|
|
338
|
+
while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
|
|
339
|
+
try {
|
|
340
|
+
await execa("docker", [
|
|
341
|
+
"exec",
|
|
342
|
+
infra.containerName,
|
|
343
|
+
"pg_isready",
|
|
344
|
+
"-U",
|
|
345
|
+
infra.user,
|
|
346
|
+
"-d",
|
|
347
|
+
LOCAL_ADMIN_DB,
|
|
348
|
+
]);
|
|
349
|
+
return;
|
|
350
|
+
} catch {
|
|
351
|
+
await sleep(LOCAL_POLL_INTERVAL_MS);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
throw new Error(`Timed out waiting for local database container ${infra.containerName}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function inspectContainer(containerName) {
|
|
359
|
+
try {
|
|
360
|
+
const { stdout } = await execa("docker", ["inspect", containerName]);
|
|
361
|
+
const parsed = JSON.parse(stdout);
|
|
362
|
+
return parsed[0] || null;
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function stopAndRemoveContainer(containerName) {
|
|
369
|
+
try {
|
|
370
|
+
await execa("docker", ["rm", "-f", containerName]);
|
|
371
|
+
} catch {
|
|
372
|
+
// Already gone.
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function databaseExists(infra, dbName) {
|
|
377
|
+
const result = await runAdminQuery(infra, [
|
|
378
|
+
"-tAc",
|
|
379
|
+
`SELECT 1 FROM pg_database WHERE datname = '${escapeSqlLiteral(dbName)}'`,
|
|
380
|
+
]);
|
|
381
|
+
return result.trim() === "1";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function createEmptyDatabase(infra, dbName) {
|
|
385
|
+
await runAdminQuery(infra, ["-c", `CREATE DATABASE "${escapeIdentifier(dbName)}"`]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function cloneDatabaseFromTemplate(infra, dbName, templateDbName) {
|
|
389
|
+
await runAdminQuery(infra, [
|
|
390
|
+
"-c",
|
|
391
|
+
`CREATE DATABASE "${escapeIdentifier(dbName)}" TEMPLATE "${escapeIdentifier(templateDbName)}"`,
|
|
392
|
+
]);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function dropDatabaseIfExists(infra, dbName) {
|
|
396
|
+
await runAdminQuery(infra, [
|
|
397
|
+
"-c",
|
|
398
|
+
[
|
|
399
|
+
`SELECT pg_terminate_backend(pid)`,
|
|
400
|
+
`FROM pg_stat_activity`,
|
|
401
|
+
`WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
|
|
402
|
+
].join(" "),
|
|
403
|
+
]);
|
|
404
|
+
await runAdminQuery(infra, [
|
|
405
|
+
"-c",
|
|
406
|
+
`DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}"`,
|
|
407
|
+
]);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function runAdminQuery(infra, args) {
|
|
411
|
+
const commandArgs = [
|
|
412
|
+
"exec",
|
|
413
|
+
"-e",
|
|
414
|
+
`PGPASSWORD=${infra.password}`,
|
|
415
|
+
infra.containerName,
|
|
416
|
+
"psql",
|
|
417
|
+
"-v",
|
|
418
|
+
"ON_ERROR_STOP=1",
|
|
419
|
+
"-U",
|
|
420
|
+
infra.user,
|
|
421
|
+
"-d",
|
|
422
|
+
LOCAL_ADMIN_DB,
|
|
423
|
+
...args,
|
|
424
|
+
];
|
|
425
|
+
const { stdout } = await execa("docker", commandArgs);
|
|
426
|
+
return stdout;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function computeTemplateFingerprint(config) {
|
|
430
|
+
const hash = crypto.createHash("sha256");
|
|
431
|
+
const db = config.testkit.database;
|
|
432
|
+
hash.update(JSON.stringify({
|
|
433
|
+
provider: db.provider,
|
|
434
|
+
selectedBackend: db.selectedBackend,
|
|
435
|
+
image: db.image || LOCAL_IMAGE,
|
|
436
|
+
user: db.user || LOCAL_USER,
|
|
437
|
+
migrate: config.testkit.migrate || null,
|
|
438
|
+
seed: config.testkit.seed || null,
|
|
439
|
+
}));
|
|
440
|
+
|
|
441
|
+
const rootEnvPath = path.join(config.productDir, ".env");
|
|
442
|
+
appendFileToHash(hash, config.productDir, rootEnvPath);
|
|
443
|
+
for (const envFile of config.testkit.envFiles || []) {
|
|
444
|
+
appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile));
|
|
445
|
+
}
|
|
446
|
+
for (const input of db.template.inputs || []) {
|
|
447
|
+
appendInputToHash(hash, config.productDir, input);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return hash.digest("hex");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function appendInputToHash(hash, productDir, input) {
|
|
454
|
+
const absPath = resolveServiceCwd(productDir, input);
|
|
455
|
+
if (!fs.existsSync(absPath)) {
|
|
456
|
+
hash.update(`missing:${path.relative(productDir, absPath)}`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const stat = fs.statSync(absPath);
|
|
461
|
+
if (stat.isDirectory()) {
|
|
462
|
+
hash.update(`dir:${path.relative(productDir, absPath)}`);
|
|
463
|
+
for (const entry of fs.readdirSync(absPath).sort()) {
|
|
464
|
+
if (entry === ".git" || entry === "node_modules" || entry === ".testkit") continue;
|
|
465
|
+
appendInputToHash(hash, productDir, path.join(input, entry));
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
appendFileToHash(hash, productDir, absPath);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function appendFileToHash(hash, productDir, absPath) {
|
|
474
|
+
if (!fs.existsSync(absPath)) {
|
|
475
|
+
hash.update(`missing:${path.relative(productDir, absPath)}`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const stat = fs.statSync(absPath);
|
|
479
|
+
if (!stat.isFile()) return;
|
|
480
|
+
|
|
481
|
+
hash.update(`file:${path.relative(productDir, absPath)}:${stat.size}:${stat.mtimeMs}`);
|
|
482
|
+
hash.update(fs.readFileSync(absPath));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function buildDatabaseUrl(infra, dbName) {
|
|
486
|
+
return `postgresql://${encodeURIComponent(infra.user)}:${encodeURIComponent(infra.password)}@${infra.host}:${infra.port}/${dbName}?sslmode=disable`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function buildContainerName(productDir) {
|
|
490
|
+
return limitIdentifier(
|
|
491
|
+
`testkit_pg_${slugSegment(path.basename(productDir))}_${hashString(productDir, 10)}`,
|
|
492
|
+
63
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function buildTemplateDatabaseName(serviceName, fingerprint) {
|
|
497
|
+
return limitIdentifier(
|
|
498
|
+
`tk_tpl_${slugSegment(serviceName)}_${fingerprint.slice(0, 16)}`,
|
|
499
|
+
63
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function buildWorkerDatabaseName(serviceName, stateDir, fingerprint) {
|
|
504
|
+
return limitIdentifier(
|
|
505
|
+
`tk_${slugSegment(serviceName)}_${hashString(stateDir, 10)}_${fingerprint.slice(0, 12)}`,
|
|
506
|
+
63
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function writeLocalInfraState(infraDir, infra) {
|
|
511
|
+
fs.writeFileSync(path.join(infraDir, "database_backend"), "local");
|
|
512
|
+
fs.writeFileSync(path.join(infraDir, "container_name"), infra.containerName);
|
|
513
|
+
fs.writeFileSync(path.join(infraDir, "container_id"), infra.containerId);
|
|
514
|
+
fs.writeFileSync(path.join(infraDir, "image"), infra.image);
|
|
515
|
+
fs.writeFileSync(path.join(infraDir, "user"), infra.user);
|
|
516
|
+
fs.writeFileSync(path.join(infraDir, "password"), infra.password);
|
|
517
|
+
fs.writeFileSync(path.join(infraDir, "host"), infra.host);
|
|
518
|
+
fs.writeFileSync(path.join(infraDir, "port"), String(infra.port));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function writeLocalCacheState(cacheDir, infra, templateDbName, fingerprint) {
|
|
522
|
+
fs.writeFileSync(path.join(cacheDir, "database_backend"), "local");
|
|
523
|
+
fs.writeFileSync(path.join(cacheDir, "template_database_name"), templateDbName);
|
|
524
|
+
fs.writeFileSync(path.join(cacheDir, "template_fingerprint"), fingerprint);
|
|
525
|
+
fs.writeFileSync(path.join(cacheDir, "container_name"), infra.containerName);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function getLocalInfraDir(productDir) {
|
|
529
|
+
return path.join(productDir, ".testkit", "_infra", "local-postgres");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function getLocalLocksDir(productDir) {
|
|
533
|
+
return path.join(getLocalInfraDir(productDir), "locks");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function getLocalServiceCacheDir(productDir, serviceName) {
|
|
537
|
+
return path.join(productDir, ".testkit", "_dbcache", serviceName);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function hasRemainingLocalArtifacts(productDir) {
|
|
541
|
+
const root = path.join(productDir, ".testkit");
|
|
542
|
+
if (!fs.existsSync(root)) return false;
|
|
543
|
+
|
|
544
|
+
let found = false;
|
|
545
|
+
visitDirs(root, (dir) => {
|
|
546
|
+
if (found) return;
|
|
547
|
+
if (dir.includes(`${path.sep}.testkit${path.sep}_infra`)) return;
|
|
548
|
+
if (readStateValue(path.join(dir, "database_backend")) === "local") {
|
|
549
|
+
found = true;
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
if (found) return true;
|
|
554
|
+
|
|
555
|
+
const cacheRoot = path.join(productDir, ".testkit", "_dbcache");
|
|
556
|
+
if (!fs.existsSync(cacheRoot)) return false;
|
|
557
|
+
return fs.readdirSync(cacheRoot).some((entry) => {
|
|
558
|
+
const entryPath = path.join(cacheRoot, entry);
|
|
559
|
+
return fs.statSync(entryPath).isDirectory();
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function visitDirs(root, visitor) {
|
|
564
|
+
if (!fs.existsSync(root)) return;
|
|
565
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
566
|
+
if (!entry.isDirectory()) continue;
|
|
567
|
+
const dir = path.join(root, entry.name);
|
|
568
|
+
visitor(dir);
|
|
569
|
+
visitDirs(dir, visitor);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function printStateDir(dir, indent) {
|
|
574
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
575
|
+
const filePath = path.join(dir, entry.name);
|
|
576
|
+
if (entry.isDirectory()) {
|
|
577
|
+
console.log(`${indent}${entry.name}/`);
|
|
578
|
+
printStateDir(filePath, `${indent} `);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const value =
|
|
582
|
+
entry.name === "password" ? "********" : fs.readFileSync(filePath, "utf8").trim();
|
|
583
|
+
console.log(`${indent}${entry.name}: ${value}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function withLock(lockPath, fn) {
|
|
588
|
+
const lockDir = `${lockPath}.dir`;
|
|
589
|
+
const timeoutMs = 60_000;
|
|
590
|
+
const startedAt = Date.now();
|
|
591
|
+
|
|
592
|
+
while (true) {
|
|
593
|
+
try {
|
|
594
|
+
fs.mkdirSync(lockDir, { recursive: false });
|
|
595
|
+
break;
|
|
596
|
+
} catch (error) {
|
|
597
|
+
if (error.code !== "EEXIST") throw error;
|
|
598
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
599
|
+
throw new Error(`Timed out waiting for lock ${path.basename(lockPath)}`);
|
|
600
|
+
}
|
|
601
|
+
await sleep(200);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
return await fn();
|
|
607
|
+
} finally {
|
|
608
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function hashString(value, length = 12) {
|
|
613
|
+
return crypto.createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function slugSegment(value) {
|
|
617
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "db";
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function limitIdentifier(value, maxLength) {
|
|
621
|
+
return value.length <= maxLength ? value : value.slice(0, maxLength);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function escapeIdentifier(value) {
|
|
625
|
+
return String(value).replace(/"/g, "\"\"");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function escapeSqlLiteral(value) {
|
|
629
|
+
return String(value).replace(/'/g, "''");
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function readStateValue(filePath) {
|
|
633
|
+
if (!fs.existsSync(filePath)) return null;
|
|
634
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function sleep(ms) {
|
|
638
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function removeLocalRuntimeState(stateDir) {
|
|
642
|
+
for (const file of [
|
|
643
|
+
"local_database_name",
|
|
644
|
+
"local_template_fingerprint",
|
|
645
|
+
"local_template_database_name",
|
|
646
|
+
"local_container_name",
|
|
647
|
+
]) {
|
|
648
|
+
fs.rmSync(path.join(stateDir, file), { force: true });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function removeNeonRuntimeState(stateDir) {
|
|
653
|
+
for (const file of ["neon_project_id", "neon_branch_id"]) {
|
|
654
|
+
fs.rmSync(path.join(stateDir, file), { force: true });
|
|
655
|
+
}
|
|
656
|
+
}
|