@elench/testkit 0.1.144 → 0.1.146
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 +10 -0
- package/lib/cli/commands/cleanup.mjs +12 -0
- package/lib/cli/operations/cleanup/operation.mjs +3 -0
- package/lib/cli/operations/db/schema/refresh/operation.mjs +11 -1
- package/lib/cli/operations/db/schema/verify/operation.mjs +11 -1
- package/lib/cli/operations/destroy/operation.mjs +6 -1
- package/lib/database/admin.mjs +227 -0
- package/lib/database/cleanup.mjs +201 -0
- package/lib/database/constants.mjs +10 -0
- package/lib/database/index.mjs +74 -590
- package/lib/database/local-postgres.mjs +158 -0
- package/lib/database/locks.mjs +31 -0
- package/lib/database/resource-postgres.mjs +72 -0
- package/lib/database/state-files.mjs +53 -0
- package/lib/ownership/docker.mjs +144 -0
- package/lib/runner/lifecycle.mjs +1 -1
- package/lib/runner/maintenance.mjs +26 -0
- 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/package.json +5 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +1 -1
package/lib/database/index.mjs
CHANGED
|
@@ -1,28 +1,21 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { execa } from "execa";
|
|
4
3
|
import {
|
|
5
4
|
computeTemplateFingerprint as computeTemplateFingerprintModel,
|
|
6
5
|
} from "./fingerprint.mjs";
|
|
7
6
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
escapeIdentifier as escapeIdentifierModel,
|
|
13
|
-
escapeSqlLiteral as escapeSqlLiteralModel,
|
|
14
|
-
hashString as hashStringModel,
|
|
15
|
-
limitIdentifier as limitIdentifierModel,
|
|
16
|
-
slugSegment as slugSegmentModel,
|
|
7
|
+
buildDatabaseUrl,
|
|
8
|
+
buildRuntimeDatabaseName,
|
|
9
|
+
buildTemplateDatabaseName,
|
|
10
|
+
hashString,
|
|
17
11
|
} from "./naming.mjs";
|
|
18
12
|
import {
|
|
19
|
-
getLocalInfraDir
|
|
20
|
-
getLocalLocksDir
|
|
21
|
-
getLocalServiceCacheDir
|
|
22
|
-
getResourceLocksDir
|
|
23
|
-
hasRemainingLocalArtifacts
|
|
24
|
-
readStateValue
|
|
25
|
-
visitDirs as visitDirsModel,
|
|
13
|
+
getLocalInfraDir,
|
|
14
|
+
getLocalLocksDir,
|
|
15
|
+
getLocalServiceCacheDir,
|
|
16
|
+
getResourceLocksDir,
|
|
17
|
+
hasRemainingLocalArtifacts,
|
|
18
|
+
readStateValue,
|
|
26
19
|
} from "./state.mjs";
|
|
27
20
|
import {
|
|
28
21
|
runTemplateStage,
|
|
@@ -37,17 +30,36 @@ import {
|
|
|
37
30
|
verifyLocalSchemaMatchesSource,
|
|
38
31
|
} from "./schema-source.mjs";
|
|
39
32
|
import { collectStateDirLines } from "../runner/state-io.mjs";
|
|
33
|
+
import {
|
|
34
|
+
cloneDatabaseFromTemplate,
|
|
35
|
+
createEmptyDatabase,
|
|
36
|
+
databaseExists,
|
|
37
|
+
dropDatabaseIfExists,
|
|
38
|
+
waitForResourcePostgresReady,
|
|
39
|
+
} from "./admin.mjs";
|
|
40
|
+
import {
|
|
41
|
+
ensureLocalContainer,
|
|
42
|
+
loadExistingLocalContainer,
|
|
43
|
+
} from "./local-postgres.mjs";
|
|
44
|
+
import {
|
|
45
|
+
resolveResourcePostgresInfra,
|
|
46
|
+
resolveResourcePostgresInfraFromState,
|
|
47
|
+
} from "./resource-postgres.mjs";
|
|
48
|
+
import {
|
|
49
|
+
writeCacheState,
|
|
50
|
+
writeResourceConnectionState,
|
|
51
|
+
} from "./state-files.mjs";
|
|
52
|
+
import { withLock } from "./locks.mjs";
|
|
53
|
+
import {
|
|
54
|
+
cleanupLocalPostgresDockerResources,
|
|
55
|
+
} from "./cleanup.mjs";
|
|
40
56
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const LOCAL_ADMIN_QUERY_RETRY_MS = 15_000;
|
|
48
|
-
const LOCAL_ADMIN_QUERY_RETRY_INTERVAL_MS = 250;
|
|
49
|
-
const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
|
|
50
|
-
const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;
|
|
57
|
+
export {
|
|
58
|
+
dropDatabaseWithForceOrDrain,
|
|
59
|
+
isTransientAdminQueryConnectionError,
|
|
60
|
+
waitForDatabaseConnectionsToDrain,
|
|
61
|
+
} from "./admin.mjs";
|
|
62
|
+
export { formatDatabaseResourceCleanupLine } from "./cleanup.mjs";
|
|
51
63
|
|
|
52
64
|
export async function prepareDatabaseRuntime(config, options = {}) {
|
|
53
65
|
const db = materializeDatabaseConfig(
|
|
@@ -99,7 +111,7 @@ export async function destroyServiceDatabaseCache(productDir, serviceName) {
|
|
|
99
111
|
const resourceLockDir = getResourceLocksDir(productDir, resourceName || "unknown");
|
|
100
112
|
fs.mkdirSync(resourceLockDir, { recursive: true });
|
|
101
113
|
await withLock(path.join(resourceLockDir, `template-${serviceName}.lock`), async () => {
|
|
102
|
-
const infra = resolveResourcePostgresInfraFromState(cacheDir);
|
|
114
|
+
const infra = resolveResourcePostgresInfraFromState(cacheDir, readStateValue);
|
|
103
115
|
const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
104
116
|
if (infra && templateDbName) {
|
|
105
117
|
await dropDatabaseIfExists(infra, templateDbName);
|
|
@@ -130,15 +142,41 @@ export async function destroyServiceDatabaseCache(productDir, serviceName) {
|
|
|
130
142
|
|
|
131
143
|
export async function cleanupOrphanedLocalInfrastructure(productDir) {
|
|
132
144
|
const infraDir = getLocalInfraDir(productDir);
|
|
133
|
-
if (!fs.existsSync(infraDir)) return;
|
|
134
|
-
|
|
135
|
-
if (hasRemainingLocalArtifacts(productDir)) return;
|
|
136
145
|
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
139
|
-
|
|
146
|
+
const resourceCleanup = await cleanupOwnedLocalDatabaseResources(productDir, { stopIdle: true });
|
|
147
|
+
if (!hasRemainingLocalArtifacts(productDir, readStateValue)) {
|
|
148
|
+
fs.rmSync(infraDir, { recursive: true, force: true });
|
|
140
149
|
}
|
|
141
|
-
|
|
150
|
+
return resourceCleanup;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function destroyOwnedLocalDatabaseResources(productDir, options = {}) {
|
|
154
|
+
return cleanupOwnedLocalDatabaseResources(productDir, {
|
|
155
|
+
...options,
|
|
156
|
+
force: true,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function cleanupOwnedLocalDatabaseResources(productDir, options = {}) {
|
|
161
|
+
return cleanupLocalPostgresDockerResources({
|
|
162
|
+
productDir,
|
|
163
|
+
dryRun: Boolean(options.dryRun),
|
|
164
|
+
force: Boolean(options.force),
|
|
165
|
+
global: false,
|
|
166
|
+
includeLegacy: Boolean(options.includeLegacy),
|
|
167
|
+
stopIdle: options.stopIdle !== false,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function cleanupGlobalLocalDatabaseResources(options = {}) {
|
|
172
|
+
return cleanupLocalPostgresDockerResources({
|
|
173
|
+
productDir: options.productDir || process.cwd(),
|
|
174
|
+
dryRun: Boolean(options.dryRun),
|
|
175
|
+
force: false,
|
|
176
|
+
global: true,
|
|
177
|
+
includeLegacy: Boolean(options.includeLegacy),
|
|
178
|
+
stopIdle: options.stopIdle !== false,
|
|
179
|
+
});
|
|
142
180
|
}
|
|
143
181
|
|
|
144
182
|
export function isDatabaseStateDir(dir) {
|
|
@@ -449,565 +487,11 @@ async function destroyResourceRuntimeDatabase(stateDir) {
|
|
|
449
487
|
const dbName = readStateValue(path.join(stateDir, "resource_database_name"));
|
|
450
488
|
if (!dbName) return;
|
|
451
489
|
|
|
452
|
-
const infra = resolveResourcePostgresInfraFromState(stateDir);
|
|
490
|
+
const infra = resolveResourcePostgresInfraFromState(stateDir, readStateValue);
|
|
453
491
|
if (!infra) return;
|
|
454
492
|
await dropDatabaseIfExists(infra, dbName);
|
|
455
493
|
}
|
|
456
494
|
|
|
457
|
-
async function ensureLocalContainer(productDir, database = {}) {
|
|
458
|
-
const infraDir = getLocalInfraDir(productDir);
|
|
459
|
-
fs.mkdirSync(infraDir, { recursive: true });
|
|
460
|
-
|
|
461
|
-
const containerName =
|
|
462
|
-
readStateValue(path.join(infraDir, "container_name")) || buildContainerName(productDir);
|
|
463
|
-
const image = database.image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE;
|
|
464
|
-
const user = database.user || readStateValue(path.join(infraDir, "user")) || LOCAL_USER;
|
|
465
|
-
const password =
|
|
466
|
-
database.password || readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD;
|
|
467
|
-
|
|
468
|
-
let inspect = await inspectContainer(containerName);
|
|
469
|
-
if (inspect && inspect.Config?.Image !== image) {
|
|
470
|
-
await stopAndRemoveContainer(containerName);
|
|
471
|
-
inspect = null;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
if (!inspect) {
|
|
475
|
-
await execa("docker", [
|
|
476
|
-
"run",
|
|
477
|
-
"-d",
|
|
478
|
-
"--name",
|
|
479
|
-
containerName,
|
|
480
|
-
"-e",
|
|
481
|
-
`POSTGRES_USER=${user}`,
|
|
482
|
-
"-e",
|
|
483
|
-
`POSTGRES_PASSWORD=${password}`,
|
|
484
|
-
"-e",
|
|
485
|
-
`POSTGRES_DB=${LOCAL_ADMIN_DB}`,
|
|
486
|
-
"-p",
|
|
487
|
-
"127.0.0.1::5432",
|
|
488
|
-
image,
|
|
489
|
-
]);
|
|
490
|
-
inspect = await inspectContainer(containerName);
|
|
491
|
-
} else if (!inspect.State?.Running) {
|
|
492
|
-
await execa("docker", ["start", containerName]);
|
|
493
|
-
inspect = await inspectContainer(containerName);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const hostPort = inspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
|
|
497
|
-
if (!hostPort) {
|
|
498
|
-
throw new Error(`Could not determine published port for local database container ${containerName}`);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
const infra = {
|
|
502
|
-
containerName,
|
|
503
|
-
containerId: inspect.Id,
|
|
504
|
-
image,
|
|
505
|
-
user,
|
|
506
|
-
password,
|
|
507
|
-
host: "127.0.0.1",
|
|
508
|
-
port: Number(hostPort),
|
|
509
|
-
};
|
|
510
|
-
|
|
511
|
-
await waitForLocalContainerReady(infra);
|
|
512
|
-
writeLocalInfraState(infraDir, infra);
|
|
513
|
-
return infra;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
async function loadExistingLocalContainer(productDir) {
|
|
517
|
-
const infraDir = getLocalInfraDir(productDir);
|
|
518
|
-
const containerName = readStateValue(path.join(infraDir, "container_name"));
|
|
519
|
-
if (!containerName) return null;
|
|
520
|
-
|
|
521
|
-
const inspect = await inspectContainer(containerName);
|
|
522
|
-
if (!inspect) return null;
|
|
523
|
-
|
|
524
|
-
if (!inspect.State?.Running) {
|
|
525
|
-
await execa("docker", ["start", containerName]);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const nextInspect = await inspectContainer(containerName);
|
|
529
|
-
const hostPort = nextInspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
|
|
530
|
-
if (!hostPort) return null;
|
|
531
|
-
|
|
532
|
-
const infra = {
|
|
533
|
-
containerName,
|
|
534
|
-
containerId: nextInspect.Id,
|
|
535
|
-
image: nextInspect.Config?.Image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE,
|
|
536
|
-
user: readStateValue(path.join(infraDir, "user")) || LOCAL_USER,
|
|
537
|
-
password: readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD,
|
|
538
|
-
host: "127.0.0.1",
|
|
539
|
-
port: Number(hostPort),
|
|
540
|
-
};
|
|
541
|
-
await waitForLocalContainerReady(infra);
|
|
542
|
-
return infra;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
async function waitForLocalContainerReady(infra) {
|
|
546
|
-
const startedAt = Date.now();
|
|
547
|
-
while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
|
|
548
|
-
try {
|
|
549
|
-
await execa("docker", [
|
|
550
|
-
"exec",
|
|
551
|
-
infra.containerName,
|
|
552
|
-
"pg_isready",
|
|
553
|
-
"-U",
|
|
554
|
-
infra.user,
|
|
555
|
-
"-d",
|
|
556
|
-
LOCAL_ADMIN_DB,
|
|
557
|
-
]);
|
|
558
|
-
return;
|
|
559
|
-
} catch {
|
|
560
|
-
await sleep(LOCAL_POLL_INTERVAL_MS);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
throw new Error(`Timed out waiting for local database container ${infra.containerName}`);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
async function waitForResourcePostgresReady(infra) {
|
|
568
|
-
const startedAt = Date.now();
|
|
569
|
-
while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
|
|
570
|
-
try {
|
|
571
|
-
await runAdminQuery(infra, ["-tAc", "SELECT 1"]);
|
|
572
|
-
return;
|
|
573
|
-
} catch (error) {
|
|
574
|
-
if (!isTransientAdminQueryConnectionError(error)) throw error;
|
|
575
|
-
await sleep(LOCAL_POLL_INTERVAL_MS);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
throw new Error(`Timed out waiting for Postgres resource "${infra.resourceName}" at ${infra.host}:${infra.port}`);
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
async function inspectContainer(containerName) {
|
|
583
|
-
try {
|
|
584
|
-
const { stdout } = await execa("docker", ["inspect", containerName]);
|
|
585
|
-
const parsed = JSON.parse(stdout);
|
|
586
|
-
return parsed[0] || null;
|
|
587
|
-
} catch {
|
|
588
|
-
return null;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
async function stopAndRemoveContainer(containerName) {
|
|
593
|
-
try {
|
|
594
|
-
await execa("docker", ["rm", "-f", containerName]);
|
|
595
|
-
} catch {
|
|
596
|
-
// Already gone.
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
async function databaseExists(infra, dbName) {
|
|
601
|
-
const result = await runAdminQuery(infra, [
|
|
602
|
-
"-tAc",
|
|
603
|
-
`SELECT 1 FROM pg_database WHERE datname = '${escapeSqlLiteral(dbName)}'`,
|
|
604
|
-
]);
|
|
605
|
-
return result.trim() === "1";
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
async function createEmptyDatabase(infra, dbName) {
|
|
609
|
-
await runAdminQuery(infra, ["-c", `CREATE DATABASE "${escapeIdentifier(dbName)}"`]);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
async function cloneDatabaseFromTemplate(infra, dbName, templateDbName) {
|
|
613
|
-
await runAdminQuery(infra, [
|
|
614
|
-
"-c",
|
|
615
|
-
`CREATE DATABASE "${escapeIdentifier(dbName)}" TEMPLATE "${escapeIdentifier(templateDbName)}"`,
|
|
616
|
-
]);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
async function dropDatabaseIfExists(infra, dbName) {
|
|
620
|
-
await dropDatabaseWithForceOrDrain(infra, dbName);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
export async function dropDatabaseWithForceOrDrain(infra, dbName, hooks = {}) {
|
|
624
|
-
const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
|
|
625
|
-
const databaseExistsFn = hooks.databaseExists || databaseExists;
|
|
626
|
-
const sleepFn = hooks.sleep || sleep;
|
|
627
|
-
|
|
628
|
-
try {
|
|
629
|
-
await runAdminQueryFn(infra, [
|
|
630
|
-
"-c",
|
|
631
|
-
`DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}" WITH (FORCE)`,
|
|
632
|
-
]);
|
|
633
|
-
return;
|
|
634
|
-
} catch (error) {
|
|
635
|
-
if (!isUnsupportedForceDropError(error)) {
|
|
636
|
-
throw error;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (!(await databaseExistsFn(infra, dbName))) {
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
let restoreConnections = false;
|
|
645
|
-
try {
|
|
646
|
-
await runAdminQueryFn(infra, [
|
|
647
|
-
"-c",
|
|
648
|
-
`ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS false`,
|
|
649
|
-
]);
|
|
650
|
-
restoreConnections = true;
|
|
651
|
-
await waitForDatabaseConnectionsToDrain(infra, dbName, {
|
|
652
|
-
runAdminQuery: runAdminQueryFn,
|
|
653
|
-
sleep: sleepFn,
|
|
654
|
-
});
|
|
655
|
-
await runAdminQueryFn(infra, [
|
|
656
|
-
"-c",
|
|
657
|
-
`DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}"`,
|
|
658
|
-
]);
|
|
659
|
-
restoreConnections = false;
|
|
660
|
-
} finally {
|
|
661
|
-
if (restoreConnections && (await databaseExistsFn(infra, dbName))) {
|
|
662
|
-
await runAdminQueryFn(infra, [
|
|
663
|
-
"-c",
|
|
664
|
-
`ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS true`,
|
|
665
|
-
]).catch(() => {
|
|
666
|
-
// Best-effort restoration for failed fallback drops.
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
export async function waitForDatabaseConnectionsToDrain(infra, dbName, hooks = {}) {
|
|
673
|
-
const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
|
|
674
|
-
const sleepFn = hooks.sleep || sleep;
|
|
675
|
-
const now = hooks.now || Date.now;
|
|
676
|
-
const timeoutMs = hooks.timeoutMs ?? LOCAL_DROP_DATABASE_TIMEOUT_MS;
|
|
677
|
-
const deadline = now() + timeoutMs;
|
|
678
|
-
|
|
679
|
-
while (true) {
|
|
680
|
-
await terminateDatabaseConnections(infra, dbName, runAdminQueryFn);
|
|
681
|
-
const remainingConnections = await countDatabaseConnections(infra, dbName, runAdminQueryFn);
|
|
682
|
-
if (remainingConnections === 0) {
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
if (now() >= deadline) {
|
|
686
|
-
throw new Error(
|
|
687
|
-
`Timed out waiting for database "${dbName}" connections to close (${remainingConnections} remaining)`
|
|
688
|
-
);
|
|
689
|
-
}
|
|
690
|
-
await sleepFn(LOCAL_DROP_DATABASE_POLL_INTERVAL_MS);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
async function runAdminQuery(infra, args) {
|
|
695
|
-
const command = infra.containerName ? "docker" : "psql";
|
|
696
|
-
const commandArgs = infra.containerName
|
|
697
|
-
? [
|
|
698
|
-
"exec",
|
|
699
|
-
"-e",
|
|
700
|
-
`PGPASSWORD=${infra.password}`,
|
|
701
|
-
infra.containerName,
|
|
702
|
-
"psql",
|
|
703
|
-
"-v",
|
|
704
|
-
"ON_ERROR_STOP=1",
|
|
705
|
-
"-U",
|
|
706
|
-
infra.user,
|
|
707
|
-
"-d",
|
|
708
|
-
infra.adminDatabase || LOCAL_ADMIN_DB,
|
|
709
|
-
...args,
|
|
710
|
-
]
|
|
711
|
-
: [
|
|
712
|
-
"-v",
|
|
713
|
-
"ON_ERROR_STOP=1",
|
|
714
|
-
"-h",
|
|
715
|
-
infra.host,
|
|
716
|
-
"-p",
|
|
717
|
-
String(infra.port),
|
|
718
|
-
"-U",
|
|
719
|
-
infra.user,
|
|
720
|
-
"-d",
|
|
721
|
-
infra.adminDatabase || LOCAL_ADMIN_DB,
|
|
722
|
-
...args,
|
|
723
|
-
];
|
|
724
|
-
const commandOptions = infra.containerName
|
|
725
|
-
? {}
|
|
726
|
-
: {
|
|
727
|
-
env: {
|
|
728
|
-
...process.env,
|
|
729
|
-
PGPASSWORD: infra.password,
|
|
730
|
-
...(infra.sslMode ? { PGSSLMODE: infra.sslMode } : {}),
|
|
731
|
-
},
|
|
732
|
-
};
|
|
733
|
-
const startedAt = Date.now();
|
|
734
|
-
while (true) {
|
|
735
|
-
try {
|
|
736
|
-
const { stdout } = await execa(command, commandArgs, commandOptions);
|
|
737
|
-
return stdout;
|
|
738
|
-
} catch (error) {
|
|
739
|
-
if (
|
|
740
|
-
Date.now() - startedAt >= LOCAL_ADMIN_QUERY_RETRY_MS ||
|
|
741
|
-
!isTransientAdminQueryConnectionError(error)
|
|
742
|
-
) {
|
|
743
|
-
throw error;
|
|
744
|
-
}
|
|
745
|
-
await sleep(LOCAL_ADMIN_QUERY_RETRY_INTERVAL_MS);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
export function isTransientAdminQueryConnectionError(error) {
|
|
751
|
-
const text = `${error?.shortMessage || ""}\n${error?.stderr || ""}\n${error?.stdout || ""}\n${error?.message || ""}`;
|
|
752
|
-
return (
|
|
753
|
-
text.includes('connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed') ||
|
|
754
|
-
text.includes("No such file or directory") ||
|
|
755
|
-
text.includes("the database system is starting up") ||
|
|
756
|
-
text.includes("could not connect to server")
|
|
757
|
-
);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
async function terminateDatabaseConnections(infra, dbName, runAdminQueryFn) {
|
|
761
|
-
await runAdminQueryFn(infra, [
|
|
762
|
-
"-c",
|
|
763
|
-
[
|
|
764
|
-
"SELECT pg_terminate_backend(pid)",
|
|
765
|
-
"FROM pg_stat_activity",
|
|
766
|
-
`WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
|
|
767
|
-
].join(" "),
|
|
768
|
-
]);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
async function countDatabaseConnections(infra, dbName, runAdminQueryFn) {
|
|
772
|
-
const result = await runAdminQueryFn(infra, [
|
|
773
|
-
"-tAc",
|
|
774
|
-
[
|
|
775
|
-
"SELECT COUNT(*)",
|
|
776
|
-
"FROM pg_stat_activity",
|
|
777
|
-
`WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
|
|
778
|
-
].join(" "),
|
|
779
|
-
]);
|
|
780
|
-
return Number.parseInt(result.trim(), 10) || 0;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function isUnsupportedForceDropError(error) {
|
|
784
|
-
const text = `${error?.shortMessage || ""}\n${error?.stderr || ""}\n${error?.stdout || ""}\n${error?.message || ""}`;
|
|
785
|
-
return (
|
|
786
|
-
text.includes('syntax error at or near "WITH"') ||
|
|
787
|
-
text.includes('option "force"')
|
|
788
|
-
);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
495
|
async function computeTemplateFingerprint(config, options = {}) {
|
|
792
496
|
return computeTemplateFingerprintModel(config, options);
|
|
793
497
|
}
|
|
794
|
-
|
|
795
|
-
function buildDatabaseUrl(infra, dbName) {
|
|
796
|
-
return buildDatabaseUrlModel(infra, dbName);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
function resolveResourcePostgresInfra(config, processEnv = process.env) {
|
|
800
|
-
const resourceName = String(config.testkit?.database?.resource || "").trim();
|
|
801
|
-
if (!resourceName) {
|
|
802
|
-
throw new Error("Resource-backed Postgres database requires database.resource");
|
|
803
|
-
}
|
|
804
|
-
const connections = parseResourceConnections(processEnv.TESTKIT_RESOURCE_CONNECTIONS_JSON);
|
|
805
|
-
const connection = connections[resourceName];
|
|
806
|
-
if (!connection) {
|
|
807
|
-
const available = Object.keys(connections).sort().join(", ") || "none";
|
|
808
|
-
throw new Error(`Postgres resource "${resourceName}" is not available. Available resources: ${available}`);
|
|
809
|
-
}
|
|
810
|
-
return normalizeResourcePostgresConnection(resourceName, connection);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
function resolveResourcePostgresInfraFromState(stateDir, processEnv = process.env) {
|
|
814
|
-
const resourceName = readStateValue(path.join(stateDir, "resource_name"));
|
|
815
|
-
if (!resourceName) return null;
|
|
816
|
-
const connections = parseResourceConnections(processEnv.TESTKIT_RESOURCE_CONNECTIONS_JSON);
|
|
817
|
-
const connection = connections[resourceName] || readResourceConnectionState(stateDir);
|
|
818
|
-
return connection ? normalizeResourcePostgresConnection(resourceName, connection) : null;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
function parseResourceConnections(raw) {
|
|
822
|
-
if (!raw) return {};
|
|
823
|
-
try {
|
|
824
|
-
const parsed = JSON.parse(raw);
|
|
825
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
826
|
-
} catch (error) {
|
|
827
|
-
throw new Error(`Invalid TESTKIT_RESOURCE_CONNECTIONS_JSON: ${error.message}`);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function normalizeResourcePostgresConnection(resourceName, connection) {
|
|
832
|
-
const fromUrl = connection.url ? parsePostgresConnectionUrl(connection.url) : {};
|
|
833
|
-
const host = connection.host || fromUrl.host;
|
|
834
|
-
const port = Number(connection.port || fromUrl.port || 5432);
|
|
835
|
-
const user = connection.user || fromUrl.user;
|
|
836
|
-
const password = connection.password || fromUrl.password || "";
|
|
837
|
-
const adminDatabase = connection.adminDatabase || connection.admin_database || fromUrl.database || LOCAL_ADMIN_DB;
|
|
838
|
-
const sslMode = connection.sslMode || connection.sslmode || fromUrl.sslMode || "disable";
|
|
839
|
-
if (!host) throw new Error(`Postgres resource "${resourceName}" connection is missing host`);
|
|
840
|
-
if (!Number.isInteger(port) || port <= 0) {
|
|
841
|
-
throw new Error(`Postgres resource "${resourceName}" connection has invalid port`);
|
|
842
|
-
}
|
|
843
|
-
if (!user) throw new Error(`Postgres resource "${resourceName}" connection is missing user`);
|
|
844
|
-
return {
|
|
845
|
-
backend: "resource",
|
|
846
|
-
resourceName,
|
|
847
|
-
host,
|
|
848
|
-
port,
|
|
849
|
-
user,
|
|
850
|
-
password,
|
|
851
|
-
adminDatabase,
|
|
852
|
-
sslMode,
|
|
853
|
-
};
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
function parsePostgresConnectionUrl(rawUrl) {
|
|
857
|
-
const parsed = new URL(rawUrl);
|
|
858
|
-
return {
|
|
859
|
-
host: parsed.hostname,
|
|
860
|
-
port: parsed.port ? Number(parsed.port) : 5432,
|
|
861
|
-
database: decodeURIComponent(parsed.pathname.replace(/^\//, "")) || LOCAL_ADMIN_DB,
|
|
862
|
-
user: decodeURIComponent(parsed.username || ""),
|
|
863
|
-
password: decodeURIComponent(parsed.password || ""),
|
|
864
|
-
sslMode: parsed.searchParams.get("sslmode") || undefined,
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function buildContainerName(productDir) {
|
|
869
|
-
return buildContainerNameModel(productDir);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function buildTemplateDatabaseName(serviceName, fingerprint) {
|
|
873
|
-
return buildTemplateDatabaseNameModel(serviceName, fingerprint);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
|
|
877
|
-
return buildRuntimeDatabaseNameModel(serviceName, bindingKey, fingerprint);
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
function writeLocalInfraState(infraDir, infra) {
|
|
881
|
-
fs.writeFileSync(path.join(infraDir, "database_backend"), "local");
|
|
882
|
-
fs.writeFileSync(path.join(infraDir, "container_name"), infra.containerName);
|
|
883
|
-
fs.writeFileSync(path.join(infraDir, "container_id"), infra.containerId);
|
|
884
|
-
fs.writeFileSync(path.join(infraDir, "image"), infra.image);
|
|
885
|
-
fs.writeFileSync(path.join(infraDir, "user"), infra.user);
|
|
886
|
-
fs.writeFileSync(path.join(infraDir, "password"), infra.password);
|
|
887
|
-
fs.writeFileSync(path.join(infraDir, "host"), infra.host);
|
|
888
|
-
fs.writeFileSync(path.join(infraDir, "port"), String(infra.port));
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
function writeCacheState(cacheDir, config, infra, templateDbName, fingerprint) {
|
|
892
|
-
const backend = config.testkit.database.provider;
|
|
893
|
-
fs.writeFileSync(path.join(cacheDir, "database_backend"), backend);
|
|
894
|
-
fs.writeFileSync(path.join(cacheDir, "template_database_name"), templateDbName);
|
|
895
|
-
fs.writeFileSync(path.join(cacheDir, "template_fingerprint"), fingerprint);
|
|
896
|
-
if (backend === "local") {
|
|
897
|
-
fs.writeFileSync(path.join(cacheDir, "container_name"), infra.containerName);
|
|
898
|
-
} else {
|
|
899
|
-
fs.writeFileSync(path.join(cacheDir, "resource_name"), infra.resourceName);
|
|
900
|
-
writeResourceConnectionState(cacheDir, infra);
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
function writeResourceConnectionState(stateDir, infra) {
|
|
905
|
-
fs.writeFileSync(path.join(stateDir, "resource_host"), infra.host);
|
|
906
|
-
fs.writeFileSync(path.join(stateDir, "resource_port"), String(infra.port));
|
|
907
|
-
fs.writeFileSync(path.join(stateDir, "resource_user"), infra.user);
|
|
908
|
-
fs.writeFileSync(path.join(stateDir, "resource_password"), infra.password);
|
|
909
|
-
fs.writeFileSync(path.join(stateDir, "resource_admin_database"), infra.adminDatabase || LOCAL_ADMIN_DB);
|
|
910
|
-
fs.writeFileSync(path.join(stateDir, "resource_sslmode"), infra.sslMode || "disable");
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
function readResourceConnectionState(stateDir) {
|
|
914
|
-
const host = readStateValue(path.join(stateDir, "resource_host"));
|
|
915
|
-
const user = readStateValue(path.join(stateDir, "resource_user"));
|
|
916
|
-
if (!host || !user) return null;
|
|
917
|
-
return {
|
|
918
|
-
host,
|
|
919
|
-
port: Number(readStateValue(path.join(stateDir, "resource_port")) || 5432),
|
|
920
|
-
user,
|
|
921
|
-
password: readStateValue(path.join(stateDir, "resource_password")) || "",
|
|
922
|
-
adminDatabase: readStateValue(path.join(stateDir, "resource_admin_database")) || LOCAL_ADMIN_DB,
|
|
923
|
-
sslMode: readStateValue(path.join(stateDir, "resource_sslmode")) || "disable",
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
function getLocalInfraDir(productDir) {
|
|
928
|
-
return getLocalInfraDirModel(productDir);
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
function getLocalLocksDir(productDir) {
|
|
932
|
-
return getLocalLocksDirModel(productDir);
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
function getLocalServiceCacheDir(productDir, serviceName) {
|
|
936
|
-
return getLocalServiceCacheDirModel(productDir, serviceName);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
function getResourceLocksDir(productDir, resourceName) {
|
|
940
|
-
return getResourceLocksDirModel(productDir, resourceName);
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
function hasRemainingLocalArtifacts(productDir) {
|
|
944
|
-
return hasRemainingLocalArtifactsModel(productDir, readStateValue);
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
function visitDirs(root, visitor) {
|
|
948
|
-
return visitDirsModel(root, visitor);
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
async function withLock(lockPath, fn) {
|
|
952
|
-
const lockDir = `${lockPath}.dir`;
|
|
953
|
-
const timeoutMs = 60_000;
|
|
954
|
-
const startedAt = Date.now();
|
|
955
|
-
|
|
956
|
-
while (true) {
|
|
957
|
-
try {
|
|
958
|
-
fs.mkdirSync(lockDir, { recursive: false });
|
|
959
|
-
break;
|
|
960
|
-
} catch (error) {
|
|
961
|
-
if (error.code !== "EEXIST") throw error;
|
|
962
|
-
if (Date.now() - startedAt > timeoutMs) {
|
|
963
|
-
throw new Error(`Timed out waiting for lock ${path.basename(lockPath)}`);
|
|
964
|
-
}
|
|
965
|
-
await sleep(200);
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
try {
|
|
970
|
-
return await fn();
|
|
971
|
-
} finally {
|
|
972
|
-
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
function hashString(value, length = 12) {
|
|
977
|
-
return hashStringModel(value, length);
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
function slugSegment(value) {
|
|
981
|
-
return slugSegmentModel(value);
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
function limitIdentifier(value, maxLength) {
|
|
985
|
-
return limitIdentifierModel(value, maxLength);
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
function escapeIdentifier(value) {
|
|
989
|
-
return escapeIdentifierModel(value);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
function escapeSqlLiteral(value) {
|
|
993
|
-
return escapeSqlLiteralModel(value);
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
function readStateValue(filePath) {
|
|
997
|
-
return readStateValueModel(filePath);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
function sleep(ms) {
|
|
1001
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function removeLocalRuntimeState(stateDir) {
|
|
1005
|
-
for (const file of [
|
|
1006
|
-
"local_database_name",
|
|
1007
|
-
"local_template_fingerprint",
|
|
1008
|
-
"local_template_database_name",
|
|
1009
|
-
"local_container_name",
|
|
1010
|
-
]) {
|
|
1011
|
-
fs.rmSync(path.join(stateDir, file), { force: true });
|
|
1012
|
-
}
|
|
1013
|
-
}
|