feat: complete catalog fleet management workflow

This commit is contained in:
Codex
2026-08-31 15:58:43 +02:00
parent 866aee4249
commit 9c697dc062
56 changed files with 6692 additions and 611 deletions
+168 -2
View File
@@ -5,6 +5,7 @@ import { afterEach, expect, test, vi } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { MemoryCatalogRepository } from "../src/catalog/memory-repository.js";
import type { ObservedSchemaSnapshot } from "../src/catalog/types.js";
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/registry.js";
import type { WorkspaceDescriptor } from "../src/workspaces/schema.js";
@@ -27,7 +28,7 @@ const workspace: WorkspaceDescriptor = {
};
const revision: WorkspaceRevision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd.yaml" };
function setup() {
function setup(environment: Record<string, string> = {}) {
const secretRoot = mkdtempSync(join(tmpdir(), "catalog-secret-"));
const runtimeRoot = mkdtempSync(join(tmpdir(), "catalog-secret-runtime-"));
roots.push(secretRoot, runtimeRoot);
@@ -38,7 +39,11 @@ function setup() {
listCatalog: vi.fn(async () => [{ id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision }]),
read: vi.fn(async () => ({ workspace, revision })),
} as unknown as WorkspaceRegistry;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/missing", NODE_ENV: "test" }), {
const app = buildApp(loadConfig({
THT_HARNESS_DIR: "/missing",
NODE_ENV: "test",
...environment,
}), {
thtRunner: {} as never,
workspaceRegistry: registry,
workspaceSecretStore: secretStore,
@@ -56,6 +61,31 @@ const direct = {
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
};
const fleetSnapshot: ObservedSchemaSnapshot = {
schemaVersion: 1,
capabilities: { tables: "available", columns: "available", relationships: "available" },
tables: [
{ name: "patients", sourceComment: "Clinical patients" },
{ name: "visits", sourceComment: null },
],
columns: [
{ tableName: "patients", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
{ tableName: "patients", name: "name", ordinalPosition: 2, dataType: "text", isNullable: true, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
{ tableName: "visits", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
{ tableName: "visits", name: "patient_id", ordinalPosition: 2, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
],
relationships: [{
constraintName: "visits_patient_id_fkey",
sourceTableName: "visits",
targetTableName: "patients",
updateRule: "NO ACTION",
deleteRule: "CASCADE",
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnName: "patient_id", targetColumnName: "id" }],
}],
};
test("lists every YAML workspace and creates its one database configuration", async () => {
const { app } = setup();
const initial = await app.inject({ method: "GET", url: "/catalog/databases" });
@@ -120,3 +150,139 @@ test("uses optimistic versions, keeps secrets write-only, and hard-deletes only
expect(secretStore.has("psd-clinical", "catalog.dwh.password")).toBe(false);
expect((await app.inject({ method: "GET", url: "/catalog/databases" })).json()).toMatchObject([{ configured: false }]);
});
test("returns exact global and per-database fleet metrics", async () => {
const { app, repository } = setup();
const database = await repository.create(direct);
await repository.applySchemaSync(database.id, database.version, "all", [], fleetSnapshot);
const patients = (await repository.listTables(database.id))
.find((table) => table.name === "patients")!;
await repository.updateTableDescription(
database.id,
patients.id,
patients.version,
"Curated patients",
);
const patientName = (await repository.listColumns(database.id, patients.id))
.find((column) => column.name === "name")!;
await repository.updateColumnMetadata(
database.id,
patients.id,
patientName.id,
patientName.version,
null,
"Generated patient name",
true,
);
const archive = await repository.create({
workspaceId: "removed-workspace",
engine: "postgres",
databaseName: "archive",
schema: "public",
binding: {
transport: "postgres_direct",
host: "archive.internal",
port: 5432,
username: "reader",
},
});
await repository.applySchemaSync(archive.id, archive.version, "all", [], {
schemaVersion: 1,
capabilities: { tables: "available", columns: "available", relationships: "available" },
tables: [{ name: "events", sourceComment: null }],
columns: [{
tableName: "events",
name: "id",
ordinalPosition: 1,
dataType: "bigint",
isNullable: false,
defaultExpression: null,
primaryKeyPosition: 1,
sourceComment: null,
}],
relationships: [],
});
const events = (await repository.listTables(archive.id))[0]!;
await repository.updateTableMetadata(
archive.id,
events.id,
events.version,
null,
"Generated archive events",
);
const scoped = await app.inject({
method: "GET",
url: `/catalog/metrics?databaseId=${database.id}`,
});
expect(scoped.statusCode).toBe(200);
expect(scoped.json()).toEqual({
scope: "database",
databaseId: database.id,
tables: 2,
columns: 4,
sensitiveColumns: 1,
relationships: 1,
descriptionTargets: 6,
describedTargets: 2,
descriptionCoverage: 33,
updatedAt: expect.any(String),
});
const global = await app.inject({ method: "GET", url: "/catalog/metrics" });
expect(global.statusCode).toBe(200);
expect(global.json()).toEqual({
scope: "global",
databaseId: null,
tables: 3,
columns: 5,
sensitiveColumns: 1,
relationships: 1,
descriptionTargets: 8,
describedTargets: 3,
descriptionCoverage: 38,
updatedAt: expect.any(String),
});
expect(Number.isNaN(Date.parse(global.json().updatedAt))).toBe(false);
});
test("validates fleet metric scope and requires database.manage", async () => {
const { app } = setup();
const unknown = await app.inject({
method: "GET",
url: "/catalog/metrics?databaseId=99999999-9999-4999-8999-999999999999",
});
expect(unknown.statusCode).toBe(404);
expect(unknown.json()).toEqual({
code: "database_not_found",
message: "Database configuration was not found.",
});
const invalid = await app.inject({
method: "GET",
url: "/catalog/metrics?databaseId=not-a-uuid",
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toEqual({
code: "database_invalid",
message: "Database configuration is invalid.",
});
const { app: restrictedApp } = setup({ AUTH_MODE: "upstream" });
const forbidden = await restrictedApp.inject({
method: "GET",
url: "/catalog/metrics",
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "catalog-reader",
"x-thoth-is-admin": "0",
},
});
expect(forbidden.statusCode).toBe(403);
expect(forbidden.json()).toEqual({
code: "auth_forbidden",
error: "This operation is not permitted",
});
});
@@ -160,7 +160,18 @@ test("suggests sensitive flags from structural metadata without persisting them"
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
const responseBody = response.json();
expect(responseBody).toMatchObject({
run: {
databaseId: database.id,
scope: "all",
modelId: configuredModel.id,
status: "completed",
total: 1,
suggestedSensitive: 1,
suggestedNonSensitive: 0,
errorSummary: null,
},
suggestions: [{
columnId: column.id,
tableId: table.id,
@@ -173,6 +184,43 @@ test("suggests sensitive flags from structural metadata without persisting them"
});
expect(await repository.getColumn(database.id, column.tableId, column.id))
.toMatchObject({ sensitive: false });
expect(responseBody.run).not.toHaveProperty("suggestions");
const history = await app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs?limit=1",
});
expect(history.statusCode).toBe(200);
expect(history.json()).toEqual([responseBody.run]);
expect(history.body).not.toContain(column.id);
const detail = await app.inject({
method: "GET",
url: `/catalog/sensitive-data-suggestion-runs/${responseBody.run.id}`,
});
expect(detail.statusCode).toBe(200);
expect(detail.json()).toEqual(responseBody.run);
const events = await app.inject({
method: "GET",
url: `/catalog/sensitive-data-suggestion-runs/${responseBody.run.id}/events-list`,
});
expect(events.statusCode).toBe(200);
expect(events.body).not.toContain(column.id);
expect(events.json()).toMatchObject([
{
runId: responseBody.run.id,
sequence: 1,
level: "info",
message: "Sensitive-field suggestion generation started.",
},
{
runId: responseBody.run.id,
sequence: 2,
level: "info",
message: "Sensitive-field suggestion generation completed for 1 column.",
},
]);
const request = modelCompleter.complete.mock.calls[0]![0] as ModelCompletionRequest;
const prompt = request.messages.map((message) => message.content).join("\n");
@@ -505,6 +553,42 @@ test("explains a sensitive-data suggestion provider failure without exposing pro
expect(response.body).not.toContain("model completion failed");
expect(await repository.getColumn(database.id, column.tableId, column.id))
.toMatchObject({ sensitive: false });
const history = await app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs",
});
expect(history.statusCode).toBe(200);
const [failedRun] = history.json();
expect(failedRun).toMatchObject({
databaseId: database.id,
status: "failed",
total: 1,
suggestedSensitive: 0,
suggestedNonSensitive: 0,
errorSummary: "Sensitive-field suggestion generation failed.",
});
const events = await app.inject({
method: "GET",
url: `/catalog/sensitive-data-suggestion-runs/${failedRun.id}/events-list`,
});
expect(events.statusCode).toBe(200);
expect(events.json()).toMatchObject([
{
runId: failedRun.id,
sequence: 1,
level: "info",
message: "Sensitive-field suggestion generation started.",
},
{
runId: failedRun.id,
sequence: 2,
level: "error",
message: "Sensitive-field suggestion generation failed.",
},
]);
expect(events.body).not.toContain("model completion failed");
} finally {
await app.close();
}
@@ -2676,10 +2760,25 @@ test("requires database.manage for every Description Generation route", async ()
url: "/catalog/description-generation-runs/99999999-9999-4999-8999-999999999999/events",
headers,
}),
app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs",
headers,
}),
app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs/99999999-9999-4999-8999-999999999999",
headers,
}),
app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs/99999999-9999-4999-8999-999999999999/events-list",
headers,
}),
]);
expect(responses.map((response) => response.statusCode)).toEqual([
403, 403, 403, 403, 403, 403, 403,
403, 403, 403, 403, 403, 403, 403, 403, 403, 403,
]);
expect(modelCompleter.complete).not.toHaveBeenCalled();
} finally {
@@ -12,6 +12,7 @@ import { up as upTables } from "../src/catalog/migrations/002_catalog_tables.js"
import { up as upSchemaSync } from "../src/catalog/migrations/003_catalog_schema_sync.js";
import { up as upDescriptionGeneration } from "../src/catalog/migrations/005_description_generation_runs.js";
import { up as upSensitiveDataFlag } from "../src/catalog/migrations/006_sensitive_data_flag.js";
import { up as upSensitiveSuggestionRuns } from "../src/catalog/migrations/007_sensitive_data_suggestion_runs.js";
import { KyselyCatalogRepository, type CatalogDatabase } from "../src/catalog/repository.js";
import { loadConfig } from "../src/config.js";
import type { WorkspaceRegistry } from "../src/workspaces/registry.js";
@@ -46,6 +47,7 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a
await upSchemaSync(db);
await upSensitiveDataFlag(db);
await upDescriptionGeneration(db);
await upSensitiveSuggestionRuns(db);
const repository = new KyselyCatalogRepository(db);
const database = await repository.create({
workspaceId: "psd-clinical",
@@ -11,6 +11,7 @@ import { up as upSchemaSync } from "../src/catalog/migrations/003_catalog_schema
import { up as upRuntimeSequencePrivileges } from "../src/catalog/migrations/004_catalog_runtime_sequence_privileges.js";
import { up as upDescriptionGeneration } from "../src/catalog/migrations/005_description_generation_runs.js";
import { up as upSensitiveDataFlag } from "../src/catalog/migrations/006_sensitive_data_flag.js";
import { up as upSensitiveSuggestionRuns } from "../src/catalog/migrations/007_sensitive_data_suggestion_runs.js";
const dockerAvailable = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0;
@@ -98,6 +99,33 @@ test.skipIf(!dockerAvailable)("PostgreSQL migration enforces one database per wo
patientName.generatedDescription,
true,
)).toMatchObject({ sensitive: true });
expect(await repository.getCatalogMetrics(created.id)).toEqual({
scope: "database",
databaseId: created.id,
tables: 2,
columns: 4,
sensitiveColumns: 1,
relationships: 0,
descriptionTargets: 6,
describedTargets: 1,
descriptionCoverage: 17,
updatedAt: expect.any(String),
});
expect(await repository.getCatalogMetrics()).toEqual({
scope: "global",
databaseId: null,
tables: 2,
columns: 4,
sensitiveColumns: 1,
relationships: 0,
descriptionTargets: 6,
describedTargets: 1,
descriptionCoverage: 17,
updatedAt: expect.any(String),
});
expect(await repository.getCatalogMetrics(
"99999999-9999-4999-8999-999999999999",
)).toBeUndefined();
const refreshedColumnsSnapshot: ObservedSchemaSnapshot = {
...fullColumnsSnapshot,
schemaVersion: 2,
@@ -340,7 +368,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository atomically consolidates sel
}
}, 60_000);
test.skipIf(!dockerAvailable)("PostgreSQL repository persists globally exclusive Description Generation Runs and ordered events", async () => {
test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and sensitive suggestion run histories", async () => {
const container = await new PostgreSqlContainer("postgres:17.6-bookworm").start();
const db = new Kysely<CatalogDatabase>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: container.getConnectionUri() }) }),
@@ -352,6 +380,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists globally exclusive
await upSchemaSync(db);
await upSensitiveDataFlag(db);
await upDescriptionGeneration(db);
await upSensitiveSuggestionRuns(db);
const repository = new KyselyCatalogRepository(db);
const firstDatabase = await repository.create({
workspaceId: "generation-one",
@@ -519,6 +548,63 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists globally exclusive
}),
]);
expect(await repository.getActiveDescriptionGenerationRun()).toBeUndefined();
const suggestionRun = await repository.createSensitiveDataSuggestionRun(
firstDatabase.id,
"selected_columns",
"openai-mini",
);
expect(suggestionRun).toMatchObject({
databaseId: firstDatabase.id,
status: "running",
total: 0,
suggestedSensitive: 0,
suggestedNonSensitive: 0,
startedAt: expect.any(String),
});
await repository.appendSensitiveDataSuggestionEvent(
suggestionRun.id,
"info",
"Sensitive-field suggestion generation started.",
);
await repository.appendSensitiveDataSuggestionEvent(
suggestionRun.id,
"info",
"Sensitive-field suggestion generation completed for 2 columns.",
);
expect(await repository.updateSensitiveDataSuggestionRun(suggestionRun.id, {
status: "completed",
total: 2,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
finishedAt: new Date().toISOString(),
})).toMatchObject({
status: "completed",
total: 2,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
});
expect(await repository.listSensitiveDataSuggestionEvents(suggestionRun.id, 1)).toEqual([
expect.objectContaining({ sequence: 2, level: "info" }),
]);
expect((await repository.listSensitiveDataSuggestionRuns(1))[0]).toMatchObject({
id: suggestionRun.id,
});
const interruptedSuggestionRun = await repository.createSensitiveDataSuggestionRun(
secondDatabase.id,
"all",
"openai-mini",
);
expect(await repository.interruptActiveSensitiveDataSuggestionRuns(
"Sensitive-field suggestion generation was interrupted by backend restart.",
)).toEqual([
expect.objectContaining({
id: interruptedSuggestionRun.id,
status: "interrupted",
finishedAt: expect.any(String),
}),
]);
} finally {
await db.destroy();
await container.stop();