@elench/testkit 0.1.143 → 0.1.145
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 +32 -5
- package/lib/cli/commands/cleanup.mjs +12 -0
- package/lib/cli/operations/cleanup/operation.mjs +3 -0
- package/lib/cli/operations/destroy/operation.mjs +6 -1
- package/lib/config/database-materialization.mjs +62 -0
- package/lib/config/database.mjs +10 -30
- package/lib/config/index.mjs +27 -7
- package/lib/config-api/index.d.ts +14 -34
- package/lib/config-api/index.mjs +8 -3
- package/lib/database/index.mjs +181 -9
- package/lib/local/kiln-driver.mjs +8 -1
- package/lib/local/orchestrator.mjs +2 -0
- package/lib/ownership/docker.mjs +135 -0
- package/lib/runner/maintenance.mjs +23 -0
- package/lib/runner/results.mjs +8 -2
- package/lib/runner/template.mjs +14 -7
- 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/README.md
CHANGED
|
@@ -61,6 +61,7 @@ npx @elench/testkit --type int --write-status
|
|
|
61
61
|
npx @elench/testkit status
|
|
62
62
|
npx @elench/testkit destroy
|
|
63
63
|
npx @elench/testkit cleanup
|
|
64
|
+
npx @elench/testkit cleanup --resources --global --dry-run
|
|
64
65
|
|
|
65
66
|
# Local production environment
|
|
66
67
|
npx @elench/testkit local up
|
|
@@ -230,6 +231,7 @@ import {
|
|
|
230
231
|
defineConfig,
|
|
231
232
|
defineFile,
|
|
232
233
|
environment,
|
|
234
|
+
resource,
|
|
233
235
|
toolchain,
|
|
234
236
|
} from "@elench/testkit/config";
|
|
235
237
|
|
|
@@ -257,9 +259,17 @@ export default defineConfig({
|
|
|
257
259
|
}),
|
|
258
260
|
},
|
|
259
261
|
environments: {
|
|
260
|
-
local: environment.
|
|
262
|
+
local: environment.productionLike({
|
|
261
263
|
target: "frontend",
|
|
262
264
|
data: "reuse",
|
|
265
|
+
resources: {
|
|
266
|
+
databases: {
|
|
267
|
+
api: resource.postgres({
|
|
268
|
+
version: "16",
|
|
269
|
+
extensions: ["vector"],
|
|
270
|
+
}),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
263
273
|
}),
|
|
264
274
|
},
|
|
265
275
|
services: {
|
|
@@ -342,10 +352,15 @@ right way to move expensive browser targets from `next dev` / watch mode to
|
|
|
342
352
|
stable build-and-start flows.
|
|
343
353
|
|
|
344
354
|
`testkit local` starts the same service graph as a persistent local production
|
|
345
|
-
environment instead of a test run.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
355
|
+
environment instead of a test run. Service database declarations stay logical:
|
|
356
|
+
`database.postgres(...)` says the service needs Postgres, while the selected
|
|
357
|
+
environment decides where that database is materialized. Normal `testkit run`
|
|
358
|
+
uses transient host Docker Postgres. `environment.productionLike(...)` uses the
|
|
359
|
+
Kiln driver by default and maps `resources.databases.<serviceName>` to durable
|
|
360
|
+
Postgres appliances for `testkit local up`. It runs template setup,
|
|
361
|
+
`runtime.prepare`, starts dependent services, and records state under
|
|
362
|
+
`.testkit/environments/<name>/` rather than `.testkit/_runs`. Local environment
|
|
363
|
+
processes receive `TESTKIT_ACTIVE=1`, `TESTKIT_MODE=local`, and
|
|
349
364
|
`TESTKIT_LOCAL_ENV=<name>`. Use `data: "reuse"` for fast restarts against the
|
|
350
365
|
existing local runtime database, `data: "reset"` to refresh runtime databases
|
|
351
366
|
from their templates on each launch, or `--rebuild` to destroy and recreate the
|
|
@@ -353,6 +368,15 @@ environment state. Testkit only supports local environments here; it does not
|
|
|
353
368
|
copy production data and it refuses managed runtime database URLs that are not
|
|
354
369
|
loopback PostgreSQL URLs.
|
|
355
370
|
|
|
371
|
+
Testkit-owned Docker resources are labelled at creation time so they can be
|
|
372
|
+
found again even if product-local `.testkit` state is deleted. `testkit destroy`
|
|
373
|
+
removes labelled resources for the current product after service state is
|
|
374
|
+
destroyed. `testkit cleanup --resources` cleans stale resources for the current
|
|
375
|
+
product; add `--global` to clean labelled resources whose product directory no
|
|
376
|
+
longer exists, and add `--include-legacy` only when you intentionally want to
|
|
377
|
+
remove old unlabelled `testkit_pg_*` containers from pre-ownership Testkit
|
|
378
|
+
versions. Use `--dry-run` first when cleaning globally.
|
|
379
|
+
|
|
356
380
|
`database.template` is the database-side equivalent for reusable template DB
|
|
357
381
|
state. When `database.sourceSchema` is configured, Testkit treats the configured
|
|
358
382
|
source database as the schema source of truth. A normal `testkit run` resolves a
|
|
@@ -741,6 +765,9 @@ Git metadata.
|
|
|
741
765
|
`@elench/testkit` provisions Docker-managed local Postgres automatically for
|
|
742
766
|
services that define `database: database.postgres(...)`.
|
|
743
767
|
|
|
768
|
+
- normal test runs always use transient host-managed Postgres
|
|
769
|
+
- named production-like local environments can place the same logical service
|
|
770
|
+
database on a Kiln Postgres appliance via `resources.databases.<serviceName>`
|
|
744
771
|
- template databases are cached
|
|
745
772
|
- runtime databases are cloned from templates when binding is `per-runtime`
|
|
746
773
|
- shared databases are reused when binding is `shared`
|
|
@@ -19,6 +19,18 @@ export default class CleanupCommand extends Command {
|
|
|
19
19
|
multiple: true,
|
|
20
20
|
options: ["runtime", "bundles", "assistant", "all"],
|
|
21
21
|
}),
|
|
22
|
+
resources: Flags.boolean({
|
|
23
|
+
description: "Clean Testkit-owned Docker resources that are stale or orphaned",
|
|
24
|
+
default: false,
|
|
25
|
+
}),
|
|
26
|
+
global: Flags.boolean({
|
|
27
|
+
description: "Find orphaned Testkit-owned resources across all products",
|
|
28
|
+
default: false,
|
|
29
|
+
}),
|
|
30
|
+
"include-legacy": Flags.boolean({
|
|
31
|
+
description: "Include old unlabelled testkit_pg_* Docker containers in resource cleanup",
|
|
32
|
+
default: false,
|
|
33
|
+
}),
|
|
22
34
|
};
|
|
23
35
|
|
|
24
36
|
async run() {
|
|
@@ -9,5 +9,8 @@ export async function executeCleanupOperation(flags = {}) {
|
|
|
9
9
|
serviceName: flags.service || null,
|
|
10
10
|
dryRun: flags["dry-run"],
|
|
11
11
|
cache: flags.cache || [],
|
|
12
|
+
resources: flags.resources,
|
|
13
|
+
globalResources: flags.global,
|
|
14
|
+
includeLegacyResources: flags["include-legacy"],
|
|
12
15
|
});
|
|
13
16
|
}
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import * as runner from "../../../runner/index.mjs";
|
|
2
2
|
import { loadManagedConfigs } from "../../../app/configs.mjs";
|
|
3
|
+
import { destroyOwnedLocalDatabaseResources } from "../../../database/index.mjs";
|
|
3
4
|
|
|
4
5
|
export async function executeDestroyOperation(flags = {}) {
|
|
5
|
-
const { configs } = await loadManagedConfigs({ dir: flags.dir, service: flags.service });
|
|
6
|
+
const { allConfigs, configs } = await loadManagedConfigs({ dir: flags.dir, service: flags.service });
|
|
6
7
|
const results = [];
|
|
7
8
|
for (const config of configs) {
|
|
8
9
|
await runner.destroy(config);
|
|
9
10
|
results.push({ name: config.name, destroyed: true });
|
|
10
11
|
}
|
|
12
|
+
const productDir = allConfigs[0]?.productDir || process.cwd();
|
|
13
|
+
if (!flags.service) {
|
|
14
|
+
await destroyOwnedLocalDatabaseResources(productDir);
|
|
15
|
+
}
|
|
11
16
|
return results;
|
|
12
17
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
2
|
+
const DEFAULT_LOCAL_USER = "testkit";
|
|
3
|
+
const DEFAULT_LOCAL_PASSWORD = "testkit";
|
|
4
|
+
|
|
5
|
+
export function buildEnvironmentDatabaseMaterialization(environment = {}) {
|
|
6
|
+
const databases = environment.resources?.databases || {};
|
|
7
|
+
return {
|
|
8
|
+
resourcesByService: Object.fromEntries(
|
|
9
|
+
Object.keys(databases).map((serviceName) => [serviceName, serviceName])
|
|
10
|
+
),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function materializeDatabaseConfig(database, serviceName, materialization = {}) {
|
|
15
|
+
if (!database) return undefined;
|
|
16
|
+
if (database.provider) {
|
|
17
|
+
return materializeExplicitProviderDatabaseConfig(database, serviceName);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const resourceName = materialization.resourcesByService?.[serviceName] || null;
|
|
21
|
+
if (resourceName) {
|
|
22
|
+
return {
|
|
23
|
+
...database,
|
|
24
|
+
provider: "resource",
|
|
25
|
+
selectedBackend: "resource",
|
|
26
|
+
resource: resourceName,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...database,
|
|
32
|
+
provider: "local",
|
|
33
|
+
selectedBackend: "local",
|
|
34
|
+
image: database.image || DEFAULT_LOCAL_IMAGE,
|
|
35
|
+
user: database.user || DEFAULT_LOCAL_USER,
|
|
36
|
+
password: database.password || DEFAULT_LOCAL_PASSWORD,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function materializeExplicitProviderDatabaseConfig(database, serviceName) {
|
|
41
|
+
if (database.provider === "resource") {
|
|
42
|
+
const resource = String(database.resource || "").trim();
|
|
43
|
+
if (!resource) {
|
|
44
|
+
throw new Error(`Service "${serviceName}" database.resource must be a non-empty string`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
...database,
|
|
48
|
+
resource,
|
|
49
|
+
selectedBackend: "resource",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (database.provider === "local") {
|
|
53
|
+
return {
|
|
54
|
+
...database,
|
|
55
|
+
selectedBackend: "local",
|
|
56
|
+
image: database.image || DEFAULT_LOCAL_IMAGE,
|
|
57
|
+
user: database.user || DEFAULT_LOCAL_USER,
|
|
58
|
+
password: database.password || DEFAULT_LOCAL_PASSWORD,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`Service "${serviceName}" database.provider must be "local" or "resource"`);
|
|
62
|
+
}
|
package/lib/config/database.mjs
CHANGED
|
@@ -4,50 +4,30 @@ import {
|
|
|
4
4
|
normalizeConfiguredSteps,
|
|
5
5
|
} from "../shared/configured-steps.mjs";
|
|
6
6
|
|
|
7
|
-
const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
8
|
-
const DEFAULT_LOCAL_USER = "testkit";
|
|
9
|
-
const DEFAULT_LOCAL_PASSWORD = "testkit";
|
|
10
|
-
|
|
11
7
|
export function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
12
8
|
if (explicitService.databaseFrom) return undefined;
|
|
13
9
|
if (!explicitService.database) return undefined;
|
|
14
10
|
|
|
15
11
|
const rawDatabase = explicitService.database;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
if (Object.prototype.hasOwnProperty.call(rawDatabase, "provider")) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Service "${serviceName}" database.provider has been removed. Configure database placement in environments.<name>.resources.databases instead.`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
if (Object.prototype.hasOwnProperty.call(rawDatabase, "resource")) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Service "${serviceName}" database.resource has been removed. Configure database placement in environments.<name>.resources.databases instead.`
|
|
20
|
+
);
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
return {
|
|
22
24
|
...rawDatabase,
|
|
23
|
-
provider,
|
|
24
25
|
binding: normalizeDatabaseBinding(rawDatabase.binding || "per-runtime", `Service "${serviceName}" database.binding`),
|
|
25
26
|
reset: rawDatabase.reset !== false,
|
|
26
27
|
sourceSchema: normalizeSourceSchemaConfig(rawDatabase.sourceSchema, serviceName),
|
|
27
28
|
template: normalizeDatabaseTemplateConfig(rawDatabase.template, serviceName),
|
|
28
29
|
serviceName,
|
|
29
30
|
};
|
|
30
|
-
|
|
31
|
-
if (provider === "resource") {
|
|
32
|
-
const resource = String(rawDatabase.resource || "").trim();
|
|
33
|
-
if (!resource) {
|
|
34
|
-
throw new Error(`Service "${serviceName}" database.resource must be a non-empty string`);
|
|
35
|
-
}
|
|
36
|
-
return {
|
|
37
|
-
...base,
|
|
38
|
-
resource,
|
|
39
|
-
selectedBackend: "resource",
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
...base,
|
|
45
|
-
provider: "local",
|
|
46
|
-
selectedBackend: "local",
|
|
47
|
-
image: rawDatabase.image || DEFAULT_LOCAL_IMAGE,
|
|
48
|
-
user: rawDatabase.user || DEFAULT_LOCAL_USER,
|
|
49
|
-
password: rawDatabase.password || DEFAULT_LOCAL_PASSWORD,
|
|
50
|
-
};
|
|
51
31
|
}
|
|
52
32
|
|
|
53
33
|
export function normalizeDatabaseTemplateConfig(value, serviceName) {
|
package/lib/config/index.mjs
CHANGED
|
@@ -244,19 +244,39 @@ function normalizeEnvironmentConfig(name, environment) {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
function normalizeEnvironmentResources(environmentName, resources = {}) {
|
|
247
|
-
if (resources == null) return {};
|
|
247
|
+
if (resources == null) return { databases: {}, servers: {} };
|
|
248
248
|
if (typeof resources !== "object" || Array.isArray(resources)) {
|
|
249
249
|
throw new Error(`Environment "${environmentName}" resources must be an object`);
|
|
250
250
|
}
|
|
251
|
+
const allowedKeys = new Set(["databases", "servers"]);
|
|
252
|
+
const unexpectedKeys = Object.keys(resources).filter((key) => !allowedKeys.has(key));
|
|
253
|
+
if (unexpectedKeys.length > 0) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Environment "${environmentName}" resources only supports "databases" and "servers". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
databases: normalizeEnvironmentResourceGroup(environmentName, "databases", resources.databases, ["postgres"]),
|
|
260
|
+
servers: normalizeEnvironmentResourceGroup(environmentName, "servers", resources.servers, ["server"]),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeEnvironmentResourceGroup(environmentName, groupName, group = {}, allowedKinds) {
|
|
265
|
+
if (group == null) return {};
|
|
266
|
+
if (typeof group !== "object" || Array.isArray(group)) {
|
|
267
|
+
throw new Error(`Environment "${environmentName}" resources.${groupName} must be an object`);
|
|
268
|
+
}
|
|
251
269
|
return Object.fromEntries(
|
|
252
|
-
Object.entries(
|
|
253
|
-
const normalizedName = normalizeDatabaseEnvToken(name, `Environment "${environmentName}"
|
|
270
|
+
Object.entries(group).map(([name, resource]) => {
|
|
271
|
+
const normalizedName = normalizeDatabaseEnvToken(name, `Environment "${environmentName}" resources.${groupName} name`, false);
|
|
254
272
|
if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
|
|
255
|
-
throw new Error(`Environment "${environmentName}"
|
|
273
|
+
throw new Error(`Environment "${environmentName}" resources.${groupName}.${name} must be an object`);
|
|
256
274
|
}
|
|
257
275
|
const kind = String(resource.kind || "").trim();
|
|
258
|
-
if (!
|
|
259
|
-
throw new Error(
|
|
276
|
+
if (!allowedKinds.includes(kind)) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Environment "${environmentName}" resources.${groupName}.${name} kind must be ${allowedKinds.map((entry) => `"${entry}"`).join(" or ")}`
|
|
279
|
+
);
|
|
260
280
|
}
|
|
261
281
|
return [normalizedName, { ...resource, kind }];
|
|
262
282
|
})
|
|
@@ -310,7 +330,7 @@ function normalizeEnvironmentEnv(env) {
|
|
|
310
330
|
const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
|
|
311
331
|
if (unexpectedKeys.length > 0) {
|
|
312
332
|
throw new Error(
|
|
313
|
-
`Environment env only supports "values" and "
|
|
333
|
+
`Environment env only supports "values", "databases", and "resources". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
|
|
314
334
|
);
|
|
315
335
|
}
|
|
316
336
|
const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
|
|
@@ -85,8 +85,7 @@ export interface StepsBuildConfig {
|
|
|
85
85
|
|
|
86
86
|
export type BuildConfig = TscBuildConfig | ScriptBuildConfig | NextBuildConfig | StepsBuildConfig;
|
|
87
87
|
|
|
88
|
-
export interface
|
|
89
|
-
provider: "local";
|
|
88
|
+
export interface PostgresDatabaseConfig {
|
|
90
89
|
binding?: "shared" | "per-runtime";
|
|
91
90
|
image?: string;
|
|
92
91
|
password?: string;
|
|
@@ -96,17 +95,15 @@ export interface LocalDatabaseConfig {
|
|
|
96
95
|
user?: string;
|
|
97
96
|
}
|
|
98
97
|
|
|
99
|
-
export interface
|
|
98
|
+
export interface MaterializedLocalDatabaseConfig extends PostgresDatabaseConfig {
|
|
99
|
+
provider: "local";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface MaterializedResourceDatabaseConfig extends PostgresDatabaseConfig {
|
|
100
103
|
provider: "resource";
|
|
101
|
-
binding?: "shared" | "per-runtime";
|
|
102
|
-
reset?: boolean;
|
|
103
104
|
resource: string;
|
|
104
|
-
sourceSchema?: DatabaseSourceSchemaConfig | null;
|
|
105
|
-
template?: DatabaseTemplateConfig;
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
export type PostgresDatabaseConfig = LocalDatabaseConfig | ResourceDatabaseConfig;
|
|
109
|
-
|
|
110
107
|
export interface SkipFileRule {
|
|
111
108
|
path: string;
|
|
112
109
|
reason: string;
|
|
@@ -324,7 +321,10 @@ export interface ServerResourceConfig {
|
|
|
324
321
|
vm?: KilnVMResourceConfig;
|
|
325
322
|
}
|
|
326
323
|
|
|
327
|
-
export
|
|
324
|
+
export interface EnvironmentResourceConfig {
|
|
325
|
+
databases?: Record<string, PostgresResourceConfig>;
|
|
326
|
+
servers?: Record<string, ServerResourceConfig>;
|
|
327
|
+
}
|
|
328
328
|
|
|
329
329
|
export interface LocalEnvironmentConfig {
|
|
330
330
|
kind: "local";
|
|
@@ -335,7 +335,7 @@ export interface LocalEnvironmentConfig {
|
|
|
335
335
|
portOffset?: number;
|
|
336
336
|
productionLike?: boolean;
|
|
337
337
|
publicHost?: string;
|
|
338
|
-
resources?:
|
|
338
|
+
resources?: EnvironmentResourceConfig;
|
|
339
339
|
target: string;
|
|
340
340
|
}
|
|
341
341
|
|
|
@@ -625,36 +625,16 @@ export declare const database: {
|
|
|
625
625
|
postgresConnectionFromEnv(prefix: string): unknown;
|
|
626
626
|
};
|
|
627
627
|
postgres(
|
|
628
|
-
options?: Omit<
|
|
628
|
+
options?: Omit<PostgresDatabaseConfig, "template"> & {
|
|
629
629
|
inputs?: string[];
|
|
630
630
|
migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
631
631
|
seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
632
632
|
template?: DatabaseTemplateOptions;
|
|
633
633
|
verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
634
634
|
}
|
|
635
|
-
):
|
|
636
|
-
postgres(
|
|
637
|
-
options?: Omit<ResourceDatabaseConfig, "provider" | "template"> & {
|
|
638
|
-
inputs?: string[];
|
|
639
|
-
migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
640
|
-
seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
641
|
-
template?: DatabaseTemplateOptions;
|
|
642
|
-
verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
643
|
-
}
|
|
644
|
-
): ResourceDatabaseConfig;
|
|
645
|
-
fixture(
|
|
646
|
-
options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
|
|
647
|
-
inputs?: string[];
|
|
648
|
-
migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
649
|
-
seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
650
|
-
template?: DatabaseTemplateOptions;
|
|
651
|
-
verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
652
|
-
discovery?: DiscoveryConfig;
|
|
653
|
-
envFiles?: string[];
|
|
654
|
-
}
|
|
655
|
-
): ServiceConfig;
|
|
635
|
+
): PostgresDatabaseConfig;
|
|
656
636
|
fixture(
|
|
657
|
-
options?: Omit<
|
|
637
|
+
options?: Omit<PostgresDatabaseConfig, "template"> & {
|
|
658
638
|
inputs?: string[];
|
|
659
639
|
migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
660
640
|
seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
package/lib/config-api/index.mjs
CHANGED
|
@@ -27,9 +27,14 @@ export function defineFile(metadata) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function postgresDatabase(options = {}) {
|
|
30
|
-
const
|
|
30
|
+
for (const removedKey of ["provider", "resource"]) {
|
|
31
|
+
if (Object.prototype.hasOwnProperty.call(options, removedKey)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`database.postgres(...) no longer accepts "${removedKey}". Configure database placement in environments.<name>.resources.databases instead.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
31
37
|
return {
|
|
32
|
-
provider,
|
|
33
38
|
...options,
|
|
34
39
|
};
|
|
35
40
|
}
|
|
@@ -561,7 +566,7 @@ function normalizePresetEnv(env) {
|
|
|
561
566
|
const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
|
|
562
567
|
if (unexpectedKeys.length > 0) {
|
|
563
568
|
throw new Error(
|
|
564
|
-
`Preset env only supports "values" and "
|
|
569
|
+
`Preset env only supports "values", "databases", and "resources". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
|
|
565
570
|
);
|
|
566
571
|
}
|
|
567
572
|
|
package/lib/database/index.mjs
CHANGED
|
@@ -28,6 +28,20 @@ import {
|
|
|
28
28
|
runTemplateStage,
|
|
29
29
|
runTemplateStep,
|
|
30
30
|
} from "./template-steps.mjs";
|
|
31
|
+
import { materializeDatabaseConfig } from "../config/database-materialization.mjs";
|
|
32
|
+
import {
|
|
33
|
+
TESTKIT_PRODUCT_DIR_LABEL,
|
|
34
|
+
TESTKIT_PRODUCT_ID_LABEL,
|
|
35
|
+
TESTKIT_RESOURCE_KIND_LABEL,
|
|
36
|
+
TESTKIT_SCOPE_LABEL,
|
|
37
|
+
buildDockerResourceLabels,
|
|
38
|
+
buildProductIdentity,
|
|
39
|
+
dockerContainerSummary,
|
|
40
|
+
dockerLabelArgs,
|
|
41
|
+
listLegacyTestkitPostgresContainers,
|
|
42
|
+
listManagedDockerContainers,
|
|
43
|
+
removeDockerContainer,
|
|
44
|
+
} from "../ownership/docker.mjs";
|
|
31
45
|
import {
|
|
32
46
|
applySourceSchemaCache,
|
|
33
47
|
createSourceSchemaMismatchError,
|
|
@@ -49,16 +63,29 @@ const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
|
|
|
49
63
|
const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;
|
|
50
64
|
|
|
51
65
|
export async function prepareDatabaseRuntime(config, options = {}) {
|
|
52
|
-
const db =
|
|
66
|
+
const db = materializeDatabaseConfig(
|
|
67
|
+
config.testkit.database,
|
|
68
|
+
config.name,
|
|
69
|
+
options.databaseMaterialization || {}
|
|
70
|
+
);
|
|
53
71
|
if (!db) return;
|
|
72
|
+
const runtimeConfig = db === config.testkit.database
|
|
73
|
+
? config
|
|
74
|
+
: {
|
|
75
|
+
...config,
|
|
76
|
+
testkit: {
|
|
77
|
+
...config.testkit,
|
|
78
|
+
database: db,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
54
81
|
|
|
55
|
-
fs.mkdirSync(
|
|
82
|
+
fs.mkdirSync(runtimeConfig.stateDir, { recursive: true });
|
|
56
83
|
if (db.provider === "local") {
|
|
57
|
-
await prepareLocalDatabase(
|
|
84
|
+
await prepareLocalDatabase(runtimeConfig, options);
|
|
58
85
|
return;
|
|
59
86
|
}
|
|
60
87
|
if (db.provider === "resource") {
|
|
61
|
-
await prepareResourceDatabase(
|
|
88
|
+
await prepareResourceDatabase(runtimeConfig, options);
|
|
62
89
|
return;
|
|
63
90
|
}
|
|
64
91
|
|
|
@@ -116,15 +143,45 @@ export async function destroyServiceDatabaseCache(productDir, serviceName) {
|
|
|
116
143
|
|
|
117
144
|
export async function cleanupOrphanedLocalInfrastructure(productDir) {
|
|
118
145
|
const infraDir = getLocalInfraDir(productDir);
|
|
119
|
-
if (!fs.existsSync(infraDir)) return;
|
|
120
146
|
|
|
121
147
|
if (hasRemainingLocalArtifacts(productDir)) return;
|
|
122
148
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
if (fs.existsSync(infraDir)) {
|
|
150
|
+
const containerName = readStateValue(path.join(infraDir, "container_name"));
|
|
151
|
+
if (containerName) {
|
|
152
|
+
await stopAndRemoveContainer(containerName);
|
|
153
|
+
}
|
|
154
|
+
fs.rmSync(infraDir, { recursive: true, force: true });
|
|
126
155
|
}
|
|
127
|
-
|
|
156
|
+
|
|
157
|
+
await cleanupOwnedLocalDatabaseResources(productDir);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function destroyOwnedLocalDatabaseResources(productDir, options = {}) {
|
|
161
|
+
return cleanupOwnedLocalDatabaseResources(productDir, {
|
|
162
|
+
...options,
|
|
163
|
+
force: true,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function cleanupOwnedLocalDatabaseResources(productDir, options = {}) {
|
|
168
|
+
return cleanupLocalPostgresDockerResources({
|
|
169
|
+
productDir,
|
|
170
|
+
dryRun: Boolean(options.dryRun),
|
|
171
|
+
force: Boolean(options.force),
|
|
172
|
+
global: false,
|
|
173
|
+
includeLegacy: Boolean(options.includeLegacy),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function cleanupGlobalLocalDatabaseResources(options = {}) {
|
|
178
|
+
return cleanupLocalPostgresDockerResources({
|
|
179
|
+
productDir: options.productDir || process.cwd(),
|
|
180
|
+
dryRun: Boolean(options.dryRun),
|
|
181
|
+
force: false,
|
|
182
|
+
global: true,
|
|
183
|
+
includeLegacy: Boolean(options.includeLegacy),
|
|
184
|
+
});
|
|
128
185
|
}
|
|
129
186
|
|
|
130
187
|
export function isDatabaseStateDir(dir) {
|
|
@@ -458,11 +515,13 @@ async function ensureLocalContainer(productDir, database = {}) {
|
|
|
458
515
|
}
|
|
459
516
|
|
|
460
517
|
if (!inspect) {
|
|
518
|
+
const labels = buildLocalPostgresContainerLabels(productDir, containerName);
|
|
461
519
|
await execa("docker", [
|
|
462
520
|
"run",
|
|
463
521
|
"-d",
|
|
464
522
|
"--name",
|
|
465
523
|
containerName,
|
|
524
|
+
...dockerLabelArgs(labels),
|
|
466
525
|
"-e",
|
|
467
526
|
`POSTGRES_USER=${user}`,
|
|
468
527
|
"-e",
|
|
@@ -851,6 +910,117 @@ function parsePostgresConnectionUrl(rawUrl) {
|
|
|
851
910
|
};
|
|
852
911
|
}
|
|
853
912
|
|
|
913
|
+
async function cleanupLocalPostgresDockerResources(options = {}) {
|
|
914
|
+
const product = buildProductIdentity(options.productDir || process.cwd());
|
|
915
|
+
const managed = await listManagedDockerContainers();
|
|
916
|
+
const targets = [];
|
|
917
|
+
const kept = [];
|
|
918
|
+
|
|
919
|
+
for (const container of managed) {
|
|
920
|
+
if (!isManagedLocalPostgresContainer(container)) continue;
|
|
921
|
+
const classification = classifyManagedLocalPostgresContainer(container, product, options);
|
|
922
|
+
if (classification.remove) {
|
|
923
|
+
targets.push({ ...container, reason: classification.reason, legacy: false });
|
|
924
|
+
} else if (classification.reason) {
|
|
925
|
+
kept.push({ ...container, reason: classification.reason, legacy: false });
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (options.includeLegacy) {
|
|
930
|
+
const currentLegacyName = buildContainerName(product.dir);
|
|
931
|
+
for (const container of await listLegacyTestkitPostgresContainers()) {
|
|
932
|
+
if (!options.global && container.name !== currentLegacyName) continue;
|
|
933
|
+
targets.push({ ...container, reason: "legacy-unlabelled", legacy: true });
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (!options.dryRun) {
|
|
938
|
+
for (const container of targets) {
|
|
939
|
+
await removeDockerContainer(container.name || container.id);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return {
|
|
944
|
+
removed: options.dryRun ? [] : targets,
|
|
945
|
+
targets,
|
|
946
|
+
kept,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function classifyManagedLocalPostgresContainer(container, product, options) {
|
|
951
|
+
const labels = container.labels || {};
|
|
952
|
+
const containerProductId = labels[TESTKIT_PRODUCT_ID_LABEL] || "";
|
|
953
|
+
const containerProductDir = labels[TESTKIT_PRODUCT_DIR_LABEL] || "";
|
|
954
|
+
const currentProduct = containerProductId === product.id;
|
|
955
|
+
|
|
956
|
+
if (options.force && currentProduct) {
|
|
957
|
+
return { remove: true, reason: "destroy-current-product" };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (!options.global && !currentProduct) {
|
|
961
|
+
return { remove: false, reason: "different-product" };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (currentProduct) {
|
|
965
|
+
if (!hasRemainingLocalArtifacts(product.dir)) {
|
|
966
|
+
return { remove: true, reason: "current-product-no-local-artifacts" };
|
|
967
|
+
}
|
|
968
|
+
if (!localArtifactsReferenceContainer(product.dir, container.name)) {
|
|
969
|
+
return { remove: true, reason: "current-product-unreferenced" };
|
|
970
|
+
}
|
|
971
|
+
return { remove: false, reason: "current-product-referenced" };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (options.global && containerProductDir && !fs.existsSync(containerProductDir)) {
|
|
975
|
+
return { remove: true, reason: "product-dir-missing" };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (options.global && containerProductDir && !hasRemainingLocalArtifacts(containerProductDir)) {
|
|
979
|
+
return { remove: true, reason: "product-has-no-local-artifacts" };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return { remove: false, reason: options.global ? "other-product-retained" : "different-product" };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function isManagedLocalPostgresContainer(container) {
|
|
986
|
+
const labels = container.labels || {};
|
|
987
|
+
return (
|
|
988
|
+
labels[TESTKIT_RESOURCE_KIND_LABEL] === "postgres-container" &&
|
|
989
|
+
labels[TESTKIT_SCOPE_LABEL] === "local-postgres"
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function buildLocalPostgresContainerLabels(productDir, containerName) {
|
|
994
|
+
return buildDockerResourceLabels(productDir, {
|
|
995
|
+
kind: "postgres-container",
|
|
996
|
+
name: containerName,
|
|
997
|
+
scope: "local-postgres",
|
|
998
|
+
cachePolicy: "product-cache",
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function localArtifactsReferenceContainer(productDir, containerName) {
|
|
1003
|
+
if (!containerName) return false;
|
|
1004
|
+
const root = path.join(productDir, ".testkit");
|
|
1005
|
+
let referenced = false;
|
|
1006
|
+
visitDirs(root, (dir) => {
|
|
1007
|
+
if (referenced) return;
|
|
1008
|
+
for (const fileName of ["container_name", "local_container_name"]) {
|
|
1009
|
+
if (readStateValue(path.join(dir, fileName)) === containerName) {
|
|
1010
|
+
referenced = true;
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
return referenced;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
export function formatDatabaseResourceCleanupLine(entry, dryRun = false) {
|
|
1019
|
+
const action = dryRun ? "Would remove" : "Removed";
|
|
1020
|
+
const legacy = entry.legacy ? " legacy" : "";
|
|
1021
|
+
return `${action}${legacy} database resource ${dockerContainerSummary(entry)} reason=${entry.reason}`;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
854
1024
|
function buildContainerName(productDir) {
|
|
855
1025
|
return buildContainerNameModel(productDir);
|
|
856
1026
|
}
|
|
@@ -864,9 +1034,11 @@ function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
|
|
|
864
1034
|
}
|
|
865
1035
|
|
|
866
1036
|
function writeLocalInfraState(infraDir, infra) {
|
|
1037
|
+
const product = buildProductIdentity(path.resolve(infraDir, "..", "..", ".."));
|
|
867
1038
|
fs.writeFileSync(path.join(infraDir, "database_backend"), "local");
|
|
868
1039
|
fs.writeFileSync(path.join(infraDir, "container_name"), infra.containerName);
|
|
869
1040
|
fs.writeFileSync(path.join(infraDir, "container_id"), infra.containerId);
|
|
1041
|
+
fs.writeFileSync(path.join(infraDir, "ownership_product_id"), product.id);
|
|
870
1042
|
fs.writeFileSync(path.join(infraDir, "image"), infra.image);
|
|
871
1043
|
fs.writeFileSync(path.join(infraDir, "user"), infra.user);
|
|
872
1044
|
fs.writeFileSync(path.join(infraDir, "password"), infra.password);
|
|
@@ -250,7 +250,7 @@ async function attachExistingVMs(client, network, vmRefs) {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
async function ensureResources(client, environment, network) {
|
|
253
|
-
const configured = environment.resources || {};
|
|
253
|
+
const configured = flattenEnvironmentResources(environment.resources || {});
|
|
254
254
|
const manifest = {};
|
|
255
255
|
const connections = {};
|
|
256
256
|
for (const [name, resource] of Object.entries(configured)) {
|
|
@@ -282,6 +282,13 @@ async function ensureResources(client, environment, network) {
|
|
|
282
282
|
return { manifest, connections };
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
export function flattenEnvironmentResources(resources = {}) {
|
|
286
|
+
return {
|
|
287
|
+
...(resources.databases || {}),
|
|
288
|
+
...(resources.servers || {}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
285
292
|
export function buildPostgresApplianceRequest(name, resource, environment, network) {
|
|
286
293
|
const extensions = normalizePostgresExtensions(resource.extensions);
|
|
287
294
|
const image = resolvePostgresResourceImage(resource, name);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
|
+
import { buildEnvironmentDatabaseMaterialization } from "../config/database-materialization.mjs";
|
|
4
5
|
import { loadConfigContext } from "../config/index.mjs";
|
|
5
6
|
import { prepareDatabaseRuntime } from "../database/index.mjs";
|
|
6
7
|
import { prepareRuntimeServices } from "../runner/runtime-preparation.mjs";
|
|
@@ -186,6 +187,7 @@ export function buildLocalRuntimeConfigs(allConfigs, environment, runtimeDir) {
|
|
|
186
187
|
graphDirName: runtimeConfigs.map((config) => config.name).sort().join("__"),
|
|
187
188
|
portOffset: environment.portOffset || 0,
|
|
188
189
|
publicHost: environment.publicHost || null,
|
|
190
|
+
databaseMaterialization: buildEnvironmentDatabaseMaterialization(environment),
|
|
189
191
|
}).map((config) => applyLocalEnvironmentConfig(config, environment));
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
|
|
6
|
+
export const TESTKIT_MANAGED_LABEL = "com.elench.testkit.managed";
|
|
7
|
+
export const TESTKIT_RESOURCE_KIND_LABEL = "com.elench.testkit.resource.kind";
|
|
8
|
+
export const TESTKIT_RESOURCE_NAME_LABEL = "com.elench.testkit.resource.name";
|
|
9
|
+
export const TESTKIT_PRODUCT_DIR_LABEL = "com.elench.testkit.product.dir";
|
|
10
|
+
export const TESTKIT_PRODUCT_ID_LABEL = "com.elench.testkit.product.id";
|
|
11
|
+
export const TESTKIT_SCOPE_LABEL = "com.elench.testkit.scope";
|
|
12
|
+
export const TESTKIT_CACHE_POLICY_LABEL = "com.elench.testkit.cache.policy";
|
|
13
|
+
export const TESTKIT_CREATED_AT_LABEL = "com.elench.testkit.created-at";
|
|
14
|
+
|
|
15
|
+
const TESTKIT_POSTGRES_CONTAINER_PREFIX = "testkit_pg_";
|
|
16
|
+
|
|
17
|
+
export function buildProductIdentity(productDir) {
|
|
18
|
+
const canonicalDir = canonicalizeProductDir(productDir);
|
|
19
|
+
return {
|
|
20
|
+
dir: canonicalDir,
|
|
21
|
+
id: hashString(canonicalDir, 24),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildDockerResourceLabels(productDir, resource) {
|
|
26
|
+
const product = buildProductIdentity(productDir);
|
|
27
|
+
return {
|
|
28
|
+
[TESTKIT_MANAGED_LABEL]: "true",
|
|
29
|
+
[TESTKIT_RESOURCE_KIND_LABEL]: resource.kind,
|
|
30
|
+
[TESTKIT_RESOURCE_NAME_LABEL]: resource.name,
|
|
31
|
+
[TESTKIT_PRODUCT_DIR_LABEL]: product.dir,
|
|
32
|
+
[TESTKIT_PRODUCT_ID_LABEL]: product.id,
|
|
33
|
+
[TESTKIT_SCOPE_LABEL]: resource.scope || resource.kind,
|
|
34
|
+
[TESTKIT_CACHE_POLICY_LABEL]: resource.cachePolicy || "ephemeral",
|
|
35
|
+
[TESTKIT_CREATED_AT_LABEL]: resource.createdAt || new Date().toISOString(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function dockerLabelArgs(labels) {
|
|
40
|
+
return Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${String(value)}`]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function inspectDockerContainer(containerRef) {
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execa("docker", ["inspect", containerRef]);
|
|
46
|
+
const parsed = JSON.parse(stdout);
|
|
47
|
+
return parsed[0] || null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function listManagedDockerContainers() {
|
|
54
|
+
return listDockerContainers([
|
|
55
|
+
"--filter",
|
|
56
|
+
`label=${TESTKIT_MANAGED_LABEL}=true`,
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function listLegacyTestkitPostgresContainers() {
|
|
61
|
+
const containers = await listDockerContainers([]);
|
|
62
|
+
return containers.filter((container) => {
|
|
63
|
+
if (!container.name.startsWith(TESTKIT_POSTGRES_CONTAINER_PREFIX)) return false;
|
|
64
|
+
return container.labels[TESTKIT_MANAGED_LABEL] !== "true";
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function removeDockerContainer(containerRef) {
|
|
69
|
+
try {
|
|
70
|
+
await execa("docker", ["rm", "-f", containerRef]);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function dockerContainerSummary(container) {
|
|
78
|
+
const status = container.running ? "running" : "stopped";
|
|
79
|
+
return `${container.name} (${status})`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function listDockerContainers(filters) {
|
|
83
|
+
let stdout = "";
|
|
84
|
+
try {
|
|
85
|
+
const result = await execa("docker", [
|
|
86
|
+
"ps",
|
|
87
|
+
"-a",
|
|
88
|
+
"--format",
|
|
89
|
+
"{{.ID}}",
|
|
90
|
+
...filters,
|
|
91
|
+
]);
|
|
92
|
+
stdout = result.stdout;
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ids = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
98
|
+
if (ids.length === 0) return [];
|
|
99
|
+
|
|
100
|
+
const containers = [];
|
|
101
|
+
for (const id of ids) {
|
|
102
|
+
const inspect = await inspectDockerContainer(id);
|
|
103
|
+
if (inspect) containers.push(inspect);
|
|
104
|
+
}
|
|
105
|
+
return containers.map(normalizeInspectContainer).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeInspectContainer(inspect) {
|
|
109
|
+
if (!inspect?.Id) return null;
|
|
110
|
+
const name = String(inspect.Name || "").replace(/^\//, "");
|
|
111
|
+
const labels = inspect.Config?.Labels || {};
|
|
112
|
+
return {
|
|
113
|
+
id: inspect.Id,
|
|
114
|
+
shortId: inspect.Id.slice(0, 12),
|
|
115
|
+
name,
|
|
116
|
+
image: inspect.Config?.Image || "",
|
|
117
|
+
createdAt: inspect.Created || null,
|
|
118
|
+
running: Boolean(inspect.State?.Running),
|
|
119
|
+
status: inspect.State?.Status || "unknown",
|
|
120
|
+
labels,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function canonicalizeProductDir(productDir) {
|
|
125
|
+
const resolved = path.resolve(productDir || process.cwd());
|
|
126
|
+
try {
|
|
127
|
+
return fs.realpathSync.native(resolved);
|
|
128
|
+
} catch {
|
|
129
|
+
return resolved;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hashString(value, length = 24) {
|
|
134
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, length);
|
|
135
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import {
|
|
4
|
+
cleanupGlobalLocalDatabaseResources,
|
|
5
|
+
cleanupOwnedLocalDatabaseResources,
|
|
4
6
|
cleanupOrphanedLocalInfrastructure,
|
|
5
7
|
destroyRuntimeDatabase,
|
|
6
8
|
destroyServiceDatabaseCache,
|
|
9
|
+
formatDatabaseResourceCleanupLine,
|
|
7
10
|
isDatabaseStateDir,
|
|
8
11
|
} from "../database/index.mjs";
|
|
9
12
|
import { cleanupRuns, formatRunSummary, isPidRunning, listRunManifests } from "./lifecycle.mjs";
|
|
@@ -51,6 +54,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
51
54
|
const allConfigs = options.allConfigs || [];
|
|
52
55
|
const serviceName = options.serviceName || null;
|
|
53
56
|
const cache = normalizeCacheSelection(options.cache);
|
|
57
|
+
const cleanResources = Boolean(options.resources || options.globalResources || options.includeLegacyResources);
|
|
54
58
|
const summary = dryRun
|
|
55
59
|
? collectRunCleanupPreview(productDir)
|
|
56
60
|
: await cleanupRuns(productDir, { includeActive: false });
|
|
@@ -66,6 +70,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
66
70
|
const runtimeCleaned = [];
|
|
67
71
|
const bundleCleaned = [];
|
|
68
72
|
const assistantCleaned = [];
|
|
73
|
+
let resourceCleanup = { targets: [], removed: [], kept: [] };
|
|
69
74
|
|
|
70
75
|
if (!dryRun) {
|
|
71
76
|
for (const target of targets.runtime) {
|
|
@@ -83,6 +88,19 @@ export async function cleanup(productDir, options = {}) {
|
|
|
83
88
|
pruneKnownEmptyDirs(productDir);
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
if (cleanResources) {
|
|
92
|
+
resourceCleanup = options.globalResources
|
|
93
|
+
? await cleanupGlobalLocalDatabaseResources({
|
|
94
|
+
productDir,
|
|
95
|
+
dryRun,
|
|
96
|
+
includeLegacy: Boolean(options.includeLegacyResources),
|
|
97
|
+
})
|
|
98
|
+
: await cleanupOwnedLocalDatabaseResources(productDir, {
|
|
99
|
+
dryRun,
|
|
100
|
+
includeLegacy: Boolean(options.includeLegacyResources),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
const lines = [];
|
|
87
105
|
for (const manifest of summary.cleaned) {
|
|
88
106
|
lines.push(`${dryRun ? "Would clean" : "Cleaned"} stale run ${formatRunSummary(manifest)}`);
|
|
@@ -108,6 +126,9 @@ export async function cleanup(productDir, options = {}) {
|
|
|
108
126
|
label: "assistant session",
|
|
109
127
|
dryRun,
|
|
110
128
|
});
|
|
129
|
+
for (const target of resourceCleanup.targets) {
|
|
130
|
+
lines.push(formatDatabaseResourceCleanupLine(target, dryRun));
|
|
131
|
+
}
|
|
111
132
|
|
|
112
133
|
if (lines.length === 0) {
|
|
113
134
|
return {
|
|
@@ -118,6 +139,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
118
139
|
bundleCleaned,
|
|
119
140
|
assistantCleaned,
|
|
120
141
|
localCleaned,
|
|
142
|
+
resourceCleanup,
|
|
121
143
|
lines: ["No stale runs to clean."],
|
|
122
144
|
};
|
|
123
145
|
}
|
|
@@ -130,6 +152,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
130
152
|
bundleCleaned,
|
|
131
153
|
assistantCleaned,
|
|
132
154
|
localCleaned,
|
|
155
|
+
resourceCleanup,
|
|
133
156
|
lines,
|
|
134
157
|
};
|
|
135
158
|
}
|
package/lib/runner/results.mjs
CHANGED
|
@@ -8,7 +8,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
8
8
|
if (plan.skipped) {
|
|
9
9
|
trackers.set(plan.config.name, {
|
|
10
10
|
name: plan.config.name,
|
|
11
|
-
dbBackend: plan.config
|
|
11
|
+
dbBackend: inferDatabaseBackend(plan.config),
|
|
12
12
|
skipped: true,
|
|
13
13
|
suiteCount: 0,
|
|
14
14
|
suites: [],
|
|
@@ -73,7 +73,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
73
73
|
|
|
74
74
|
trackers.set(plan.config.name, {
|
|
75
75
|
name: plan.config.name,
|
|
76
|
-
dbBackend: plan.config
|
|
76
|
+
dbBackend: inferDatabaseBackend(plan.config),
|
|
77
77
|
skipped: false,
|
|
78
78
|
suiteCount: suites.length,
|
|
79
79
|
suites,
|
|
@@ -90,6 +90,12 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
90
90
|
return trackers;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function inferDatabaseBackend(config) {
|
|
94
|
+
const database = config.testkit.database;
|
|
95
|
+
if (!database) return null;
|
|
96
|
+
return database.selectedBackend || database.provider || "local";
|
|
97
|
+
}
|
|
98
|
+
|
|
93
99
|
export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now()) {
|
|
94
100
|
const tracker = trackers.get(task.serviceName);
|
|
95
101
|
if (!tracker || tracker.skipped) return;
|
package/lib/runner/template.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
finalizeConfiguredInputs,
|
|
4
4
|
finalizeConfiguredSteps,
|
|
5
5
|
} from "../shared/configured-steps.mjs";
|
|
6
|
+
import { materializeDatabaseConfig } from "../config/database-materialization.mjs";
|
|
6
7
|
import { readDatabaseInfo } from "./state-io.mjs";
|
|
7
8
|
|
|
8
9
|
const PORT_STRIDE = 100;
|
|
@@ -75,7 +76,8 @@ export function resolveRuntimeInstanceConfigs(runtimeConfigs, runtimeId, runtime
|
|
|
75
76
|
publicBaseUrlByService,
|
|
76
77
|
options.publicHost || null,
|
|
77
78
|
stateDirByService,
|
|
78
|
-
urlMappings
|
|
79
|
+
urlMappings,
|
|
80
|
+
options.databaseMaterialization || {}
|
|
79
81
|
)
|
|
80
82
|
);
|
|
81
83
|
}
|
|
@@ -123,7 +125,8 @@ export function resolveRuntimeConfig(
|
|
|
123
125
|
publicBaseUrlByService,
|
|
124
126
|
publicHost,
|
|
125
127
|
stateDirByService,
|
|
126
|
-
urlMappings
|
|
128
|
+
urlMappings,
|
|
129
|
+
databaseMaterialization = {}
|
|
127
130
|
) {
|
|
128
131
|
const stateDir = resolveServiceStateDir(runtimeDir, config);
|
|
129
132
|
const prepareDir = resolveServicePrepareDir(runtimeDir, config);
|
|
@@ -147,11 +150,15 @@ export function resolveRuntimeConfig(
|
|
|
147
150
|
};
|
|
148
151
|
|
|
149
152
|
const database = config.testkit.database
|
|
150
|
-
?
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
? materializeDatabaseConfig(
|
|
154
|
+
{
|
|
155
|
+
...config.testkit.database,
|
|
156
|
+
sourceSchema: finalizeSourceSchema(config.testkit.database.sourceSchema, context),
|
|
157
|
+
template: finalizeDatabaseTemplate(config.testkit.database.template, context),
|
|
158
|
+
},
|
|
159
|
+
config.name,
|
|
160
|
+
databaseMaterialization
|
|
161
|
+
)
|
|
155
162
|
: undefined;
|
|
156
163
|
|
|
157
164
|
const runtime = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.145",
|
|
4
4
|
"description": "Browser bridge helpers for testkit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@elench/testkit-protocol": "0.1.
|
|
25
|
+
"@elench/testkit-protocol": "0.1.145"
|
|
26
26
|
},
|
|
27
27
|
"private": false
|
|
28
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.145",
|
|
4
4
|
"description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -98,10 +98,10 @@
|
|
|
98
98
|
},
|
|
99
99
|
"dependencies": {
|
|
100
100
|
"@babel/code-frame": "^7.29.0",
|
|
101
|
-
"@elench/next-analysis": "0.1.
|
|
102
|
-
"@elench/testkit-bridge": "0.1.
|
|
103
|
-
"@elench/testkit-protocol": "0.1.
|
|
104
|
-
"@elench/ts-analysis": "0.1.
|
|
101
|
+
"@elench/next-analysis": "0.1.145",
|
|
102
|
+
"@elench/testkit-bridge": "0.1.145",
|
|
103
|
+
"@elench/testkit-protocol": "0.1.145",
|
|
104
|
+
"@elench/ts-analysis": "0.1.145",
|
|
105
105
|
"@oclif/core": "^4.10.6",
|
|
106
106
|
"@playwright/test": "^1.52.0",
|
|
107
107
|
"esbuild": "^0.25.11",
|