feat: implement metadata catalog database management
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { buildInstallationContract } from "../workspaces/contracts.js";
|
||||
import { resolveBinding } from "../workspaces/bindings.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import type { WorkspaceDescriptor } from "../workspaces/schema.js";
|
||||
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
||||
import { createConcreteDiagnosticAdapters } from "../workspaces/diagnostics.js";
|
||||
import { CatalogOperationCoordinator } from "./operation-coordinator.js";
|
||||
import {
|
||||
ConcreteCatalogPostgresAccess,
|
||||
type CatalogPostgresAccess,
|
||||
} from "./postgres-access.js";
|
||||
import type {
|
||||
CatalogRepository,
|
||||
DatabaseBinding,
|
||||
DatabaseConfigurationInput,
|
||||
DatabaseTestResult,
|
||||
WorkspaceDatabase,
|
||||
} from "./types.js";
|
||||
import { CATALOG_SECRET_IDS, type CatalogSecretName } from "./secrets.js";
|
||||
|
||||
export { CATALOG_SECRET_IDS, type CatalogSecretName } from "./secrets.js";
|
||||
|
||||
export interface CatalogListItem extends Omit<WorkspaceDatabase, "id"> {
|
||||
id?: string;
|
||||
workspaceName: string;
|
||||
workspaceDescription?: string;
|
||||
workspaceAvailable: boolean;
|
||||
configured: boolean;
|
||||
secrets: Record<CatalogSecretName, boolean>;
|
||||
}
|
||||
|
||||
function bindingValue(workspace: WorkspaceDescriptor, values: Record<string, string>, suffix: string) {
|
||||
const variable = buildInstallationContract(workspace).variables.find((entry) => (
|
||||
entry.role === "DWH" && entry.suffix === suffix
|
||||
));
|
||||
return variable ? values[variable.name] : undefined;
|
||||
}
|
||||
|
||||
function numeric(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65_535 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function yamlBinding(workspace: WorkspaceDescriptor, secretRoots: readonly string[]): DatabaseBinding {
|
||||
const effective = resolveBinding(workspace, "DWH", process.env, secretRoots);
|
||||
const value = (suffix: string) => bindingValue(workspace, effective.values, suffix);
|
||||
return {
|
||||
transport: effective.transport,
|
||||
host: value("HOST"),
|
||||
port: numeric(value("PORT")) ?? workspace.dwh.port,
|
||||
username: value("USER"),
|
||||
baseUrl: value("BASE_URL"),
|
||||
restPath: workspace.diagnostics?.dwh_rest?.path ?? "/health",
|
||||
restAuth: workspace.diagnostics?.dwh_rest?.auth ?? "bearer",
|
||||
tlsServername: value("TLS_SERVERNAME"),
|
||||
sshHost: value("SSH_HOST"),
|
||||
sshPort: numeric(value("SSH_PORT")),
|
||||
sshUsername: value("SSH_USER"),
|
||||
sshTargetHost: value("SSH_TARGET_HOST"),
|
||||
sshTargetPort: numeric(value("SSH_TARGET_PORT")),
|
||||
};
|
||||
}
|
||||
|
||||
function secretState(store: WorkspaceSecretStore, workspaceId: string): Record<CatalogSecretName, boolean> {
|
||||
return Object.fromEntries(Object.entries(CATALOG_SECRET_IDS).map(([name, id]) => (
|
||||
[name, store.has(workspaceId, id)]
|
||||
))) as Record<CatalogSecretName, boolean>;
|
||||
}
|
||||
|
||||
export class CatalogService {
|
||||
private readonly adapters = createConcreteDiagnosticAdapters();
|
||||
|
||||
constructor(
|
||||
private readonly repository: CatalogRepository,
|
||||
private readonly registry: WorkspaceRegistry,
|
||||
private readonly secretStore: WorkspaceSecretStore,
|
||||
private readonly secretRoots: readonly string[],
|
||||
private readonly diagnosticTimeoutMs: number,
|
||||
private readonly postgres: CatalogPostgresAccess = new ConcreteCatalogPostgresAccess(secretStore, {
|
||||
connectTimeoutMs: diagnosticTimeoutMs,
|
||||
}),
|
||||
private readonly operations: CatalogOperationCoordinator = new CatalogOperationCoordinator(),
|
||||
) {}
|
||||
|
||||
async list(): Promise<CatalogListItem[]> {
|
||||
const [workspaces, configured] = await Promise.all([
|
||||
this.registry.listCatalog(),
|
||||
this.repository.list(),
|
||||
]);
|
||||
const byWorkspace = new Map(configured.map((database) => [database.workspaceId, database]));
|
||||
const active = await Promise.all(workspaces.map(async (entry) => {
|
||||
const database = byWorkspace.get(entry.id);
|
||||
const { workspace } = await this.registry.read(entry.id);
|
||||
const base = database ?? {
|
||||
workspaceId: entry.id,
|
||||
engine: "postgres" as const,
|
||||
databaseName: workspace.dwh.database,
|
||||
schema: workspace.dwh.schema,
|
||||
version: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
binding: yamlBinding(workspace, this.secretRoots),
|
||||
connectionStatus: "untested" as const,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
workspaceName: entry.name,
|
||||
workspaceDescription: entry.description,
|
||||
workspaceAvailable: true,
|
||||
configured: database !== undefined,
|
||||
secrets: secretState(this.secretStore, entry.id),
|
||||
};
|
||||
}));
|
||||
const known = new Set(workspaces.map((entry) => entry.id));
|
||||
const orphaned: CatalogListItem[] = configured
|
||||
.filter((database) => !known.has(database.workspaceId))
|
||||
.map((database) => ({
|
||||
...database,
|
||||
workspaceName: database.workspaceId,
|
||||
workspaceDescription: "Workspace is no longer present in the repository catalog.",
|
||||
workspaceAvailable: false,
|
||||
configured: true,
|
||||
secrets: secretState(this.secretStore, database.workspaceId),
|
||||
}));
|
||||
return [...active, ...orphaned];
|
||||
}
|
||||
|
||||
async ensureWorkspace(workspaceId: string): Promise<WorkspaceDescriptor> {
|
||||
const { workspace } = await this.registry.read(workspaceId);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async normalizeInput(input: DatabaseConfigurationInput): Promise<DatabaseConfigurationInput> {
|
||||
const workspace = await this.ensureWorkspace(input.workspaceId);
|
||||
if (input.binding.transport !== "rest_api") return input;
|
||||
return {
|
||||
...input,
|
||||
binding: {
|
||||
...input.binding,
|
||||
restPath: workspace.diagnostics?.dwh_rest?.path ?? "/health",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
configuredSecrets(workspaceId: string): Record<CatalogSecretName, boolean> {
|
||||
return secretState(this.secretStore, workspaceId);
|
||||
}
|
||||
|
||||
replaceSecrets(workspaceId: string, values: Partial<Record<CatalogSecretName, string>>): void {
|
||||
const encoded: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(values) as Array<[CatalogSecretName, string | undefined]>) {
|
||||
if (value !== undefined && value.length > 0) encoded[CATALOG_SECRET_IDS[name]] = value;
|
||||
}
|
||||
if (Object.keys(encoded).length > 0) this.secretStore.putMany(workspaceId, encoded);
|
||||
}
|
||||
|
||||
forgetSecrets(workspaceId: string): void {
|
||||
for (const id of Object.values(CATALOG_SECRET_IDS)) this.secretStore.forget(workspaceId, id);
|
||||
}
|
||||
|
||||
async test(database: WorkspaceDatabase): Promise<DatabaseTestResult> {
|
||||
return await this.operations.run(database.id, async () => {
|
||||
const testedAt = new Date().toISOString();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.diagnosticTimeoutMs);
|
||||
const required = database.binding.transport === "rest_api"
|
||||
? database.binding.restAuth === "none" ? [] : [CATALOG_SECRET_IDS.apiKey]
|
||||
: [];
|
||||
const materialized = this.secretStore.materialize(database.workspaceId, required);
|
||||
try {
|
||||
if (database.binding.transport !== "rest_api") {
|
||||
const client = await this.postgres.connect(database, controller.signal);
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT current_database() AS database,
|
||||
CASE WHEN pg_catalog.has_schema_privilege(
|
||||
current_user,
|
||||
(SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1),
|
||||
'USAGE'
|
||||
) THEN $1 ELSE NULL END AS schema`,
|
||||
[database.schema],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (row?.database !== database.databaseName || row.schema !== database.schema) {
|
||||
throw new Error("Database identity mismatch");
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
} else {
|
||||
const credentialId = CATALOG_SECRET_IDS.apiKey;
|
||||
await this.adapters.probeConnector({
|
||||
role: "dwh",
|
||||
transport: database.binding.transport,
|
||||
baseUrl: database.binding.baseUrl,
|
||||
credentialFile: materialized.files.get(credentialId),
|
||||
resource: { database: database.databaseName, schema: database.schema },
|
||||
timeoutMs: this.diagnosticTimeoutMs,
|
||||
signal: controller.signal,
|
||||
diagnostic: {
|
||||
method: "GET" as const,
|
||||
path: database.binding.restPath ?? "/health",
|
||||
auth: database.binding.restAuth ?? "bearer",
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
connectionStatus: "reachable",
|
||||
testedVersion: database.version,
|
||||
lastTestedAt: testedAt,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
connectionStatus: "failed",
|
||||
testedVersion: database.version,
|
||||
lastTestedAt: testedAt,
|
||||
errorCode: "connector_unavailable",
|
||||
errorMessage: "The database connector could not be reached or authenticated.",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
materialized.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user