266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
|
import type { WorkspaceDescriptor } from "../workspaces/schema.js";
|
|
import { discoverWorkspaceSecretRequirements } from "../workspaces/secret-requirements.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;
|
|
workspaceRevision: { commit: string; blob: string } | null;
|
|
workspaceEvidence: {
|
|
sourceType: "filesystem" | "http" | "s3" | null;
|
|
state:
|
|
| "not_declared"
|
|
| "materialized_current_revision"
|
|
| "configuration_required"
|
|
| "configured_unverified"
|
|
| "workspace_unavailable";
|
|
};
|
|
runtimeBinding: {
|
|
transport: DatabaseBinding["transport"];
|
|
configurationState: "ready" | "configuration_required";
|
|
sessionTransportSupported: boolean;
|
|
} | null;
|
|
configured: boolean;
|
|
secrets: Record<CatalogSecretName, boolean>;
|
|
}
|
|
|
|
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>;
|
|
}
|
|
|
|
function databaseRuntimeState(
|
|
store: WorkspaceSecretStore,
|
|
database: WorkspaceDatabase,
|
|
): NonNullable<CatalogListItem["runtimeBinding"]> {
|
|
const { binding, workspaceId } = database;
|
|
const configured = (id: string) => store.has(workspaceId, id);
|
|
const connectionComplete = binding.transport === "postgres_direct"
|
|
? Boolean(binding.host && binding.port && binding.username && configured(CATALOG_SECRET_IDS.password))
|
|
: binding.transport === "rest_api"
|
|
? Boolean(binding.baseUrl && (binding.restAuth === "none" || configured(CATALOG_SECRET_IDS.apiKey)))
|
|
: Boolean(
|
|
binding.username && binding.sshHost && binding.sshPort && binding.sshUsername
|
|
&& binding.sshTargetHost && binding.sshTargetPort
|
|
&& configured(CATALOG_SECRET_IDS.password)
|
|
&& configured(CATALOG_SECRET_IDS.sshPrivateKey)
|
|
&& configured(CATALOG_SECRET_IDS.sshKnownHosts),
|
|
);
|
|
return {
|
|
transport: binding.transport,
|
|
configurationState: connectionComplete ? "ready" : "configuration_required",
|
|
sessionTransportSupported: binding.transport !== "ssh_tunnel",
|
|
};
|
|
}
|
|
|
|
function workspaceEvidenceState(
|
|
store: WorkspaceSecretStore,
|
|
workspace: WorkspaceDescriptor,
|
|
): CatalogListItem["workspaceEvidence"] {
|
|
const source = workspace.evidence?.source;
|
|
if (!source) return { sourceType: null, state: "not_declared" };
|
|
if (source.type === "filesystem") {
|
|
return { sourceType: source.type, state: "materialized_current_revision" };
|
|
}
|
|
const configurationRequired = discoverWorkspaceSecretRequirements(workspace, process.env)
|
|
.some(({ connector, id, required }) => (
|
|
connector === "evidence" && required && !store.has(workspace.workspace.id, id)
|
|
));
|
|
return {
|
|
sourceType: source.type,
|
|
state: configurationRequired ? "configuration_required" : "configured_unverified",
|
|
};
|
|
}
|
|
|
|
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.readPinned(entry.id, entry.revision.commit);
|
|
const base = database ?? {
|
|
workspaceId: entry.id,
|
|
engine: "postgres" as const,
|
|
databaseName: "",
|
|
schema: "",
|
|
version: 0,
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
binding: { transport: "postgres_direct" as const },
|
|
connectionStatus: "untested" as const,
|
|
metadataContentRevision: 0,
|
|
preprocessingStatus: "failed" as const,
|
|
};
|
|
return {
|
|
...base,
|
|
workspaceName: entry.name,
|
|
workspaceDescription: entry.description,
|
|
workspaceAvailable: true,
|
|
workspaceRevision: {
|
|
commit: entry.revision.commit,
|
|
blob: entry.revision.blob,
|
|
},
|
|
workspaceEvidence: workspaceEvidenceState(this.secretStore, workspace),
|
|
runtimeBinding: database ? databaseRuntimeState(this.secretStore, database) : null,
|
|
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,
|
|
workspaceRevision: null,
|
|
workspaceEvidence: { sourceType: null, state: "workspace_unavailable" },
|
|
runtimeBinding: null,
|
|
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> {
|
|
await this.ensureWorkspace(input.workspaceId);
|
|
if (input.binding.transport !== "rest_api") return input;
|
|
return {
|
|
...input,
|
|
binding: {
|
|
...input.binding,
|
|
restPath: input.binding.restPath ?? "/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<WorkspaceDatabase | undefined> {
|
|
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);
|
|
let result: DatabaseTestResult;
|
|
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",
|
|
},
|
|
});
|
|
}
|
|
result = {
|
|
connectionStatus: "reachable",
|
|
testedVersion: database.version,
|
|
lastTestedAt: testedAt,
|
|
};
|
|
} catch {
|
|
result = {
|
|
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();
|
|
}
|
|
return await this.repository.recordTest(database.id, database.version, result);
|
|
});
|
|
}
|
|
}
|