From 049f8675c61d69c342264563e970c0f4a5f6c9f1 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 3 Aug 2026 21:49:57 +0200 Subject: [PATCH] feat: resolve workspace bindings into runtime configs --- backend/src/tht/tht-runner.ts | 11 +- backend/src/workspaces/bindings.ts | 116 ++++++++++++++ backend/src/workspaces/runtime-renderer.ts | 146 ++++++++++++++++++ backend/test/tht-runner.test.ts | 7 + .../test/workspace-runtime-renderer.test.ts | 131 ++++++++++++++++ backend/test/workspaces-bindings.test.ts | 120 ++++++++++++++ 6 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 backend/src/workspaces/bindings.ts create mode 100644 backend/src/workspaces/runtime-renderer.ts create mode 100644 backend/test/workspace-runtime-renderer.test.ts create mode 100644 backend/test/workspaces-bindings.test.ts diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index 26dabe3d..1b64f9a7 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js"; import { secretValue, type SecretBundleConfig } from "../config/secret-bundle.js"; @@ -49,8 +49,13 @@ export class ThtRunner { * back to the default config would point every operation at the wrong workspace * (wrong DB, wrong sessions dir) — fail loud instead. */ - private configArg(workspace?: string): string[] { - if (workspace) { + private configArg(workspaceConfigPath?: string): string[] { + if (workspaceConfigPath) { + if (isAbsolute(workspaceConfigPath)) return ["-c", workspaceConfigPath]; + if (workspaceConfigPath.includes("/")) { + throw new Error("workspace snapshot config path must be absolute"); + } + const workspace = workspaceConfigPath; if (!existsSync(join(this.cfg.harnessDir, "workspaces", `${workspace}.yaml`))) { throw new Error(`workspace non trovato: workspaces/${workspace}.yaml (harness: ${this.cfg.harnessDir})`); } diff --git a/backend/src/workspaces/bindings.ts b/backend/src/workspaces/bindings.ts new file mode 100644 index 00000000..042f3cf1 --- /dev/null +++ b/backend/src/workspaces/bindings.ts @@ -0,0 +1,116 @@ +import { constants, realpathSync, statSync, accessSync } from "node:fs"; +import { isAbsolute, relative } from "node:path"; +import { buildInstallationContract, type InstallationRole, type InstallationSuffix } from "./contracts.js"; +import { + DWH_TRANSPORTS, + VECTOR_TRANSPORTS, + validateCanonicalWorkspace, + type CanonicalWorkspace, + type DwhTransport, + type VectorTransport, +} from "./schema.js"; + +export interface ResolvedBinding { + transport: DwhTransport | VectorTransport; + values: Record; + missing: string[]; +} + +const REQUIRED_SUFFIXES: Record<"DWH" | "VECTOR", Record> = { + DWH: { + postgres_direct: ["HOST", "PORT", "USER", "PASSWORD_FILE"], + rest_api: ["BASE_URL", "API_KEY_FILE"], + ssh_tunnel: [ + "USER", "PASSWORD_FILE", "SSH_HOST", "SSH_PORT", "SSH_USER", + "SSH_PRIVATE_KEY_FILE", "SSH_KNOWN_HOSTS_FILE", "SSH_TARGET_HOST", "SSH_TARGET_PORT", + ], + }, + VECTOR: { + pgvector_direct: ["HOST", "PORT", "USER", "PASSWORD_FILE"], + rest_api: ["BASE_URL", "API_KEY_FILE"], + ssh_tunnel: [ + "USER", "PASSWORD_FILE", "SSH_HOST", "SSH_PORT", "SSH_USER", + "SSH_PRIVATE_KEY_FILE", "SSH_KNOWN_HOSTS_FILE", "SSH_TARGET_HOST", "SSH_TARGET_PORT", + ], + }, +}; + +const EMBEDDING_REQUIRED_SUFFIXES: readonly InstallationSuffix[] = ["BASE_URL"]; + +function isTransport(value: string | undefined): value is DwhTransport | VectorTransport { + return value !== undefined + && ([...DWH_TRANSPORTS, ...VECTOR_TRANSPORTS] as readonly string[]).includes(value); +} + +function isInside(path: string, root: string): boolean { + const pathRelative = relative(root, path); + return pathRelative !== "" && !pathRelative.startsWith("..") && !isAbsolute(pathRelative); +} + +function isSafeSecretFile(path: string, secretRoots: readonly string[]): boolean { + if (!isAbsolute(path)) return false; + + try { + const resolvedPath = realpathSync(path); + const resolvedRoots = secretRoots.map((root) => realpathSync(root)); + if (!resolvedRoots.some((root) => isInside(resolvedPath, root))) return false; + if (!statSync(resolvedPath).isFile()) return false; + accessSync(resolvedPath, constants.R_OK); + return true; + } catch { + return false; + } +} + +function requiredSuffixes( + role: InstallationRole, + transport: DwhTransport | VectorTransport, +): readonly InstallationSuffix[] { + if (role === "EMBEDDING") return EMBEDDING_REQUIRED_SUFFIXES; + return REQUIRED_SUFFIXES[role][transport] ?? []; +} + +/** + * Resolve only installation-local values. Secret files remain file paths: their contents are + * deliberately left for the harness secret-file loader, so bindings cannot leak credentials. + */ +export function resolveBinding( + workspace: CanonicalWorkspace, + role: InstallationRole, + env: NodeJS.ProcessEnv, + secretRoots: readonly string[], +): ResolvedBinding { + const canonical = validateCanonicalWorkspace(workspace); + const contract = buildInstallationContract(canonical); + const variables = contract.variables.filter((variable) => variable.role === role); + const transportVariable = variables.find((variable) => variable.suffix === "TRANSPORT"); + const supported = role === "DWH" + ? canonical.dwh.supported_transports + : role === "VECTOR" + ? canonical.semantic_index.vector_store.supported_transports + : ["rest_api"] as const; + const selectedValue = transportVariable ? env[transportVariable.name] : undefined; + const selectedTransport = isTransport(selectedValue) ? selectedValue : supported[0]; + const missing: string[] = []; + + if (transportVariable && (!isTransport(selectedValue) || !supported.includes(selectedTransport as never))) { + missing.push(transportVariable.name); + } + + const required = new Set(requiredSuffixes(role, selectedTransport)); + const values: Record = {}; + for (const variable of variables) { + if (variable.suffix === "TRANSPORT") continue; + if (variable.transports && !variable.transports.includes(selectedTransport as never)) continue; + + const value = env[variable.name]; + const present = value !== undefined && value.trim() !== ""; + const safe = !variable.secret || (present && isSafeSecretFile(value, secretRoots)); + if ((required.has(variable.suffix) && !present) || (present && !safe)) { + missing.push(variable.name); + } + if (present && safe) values[variable.name] = value; + } + + return { transport: selectedTransport, values, missing }; +} diff --git a/backend/src/workspaces/runtime-renderer.ts b/backend/src/workspaces/runtime-renderer.ts new file mode 100644 index 00000000..f1cf8429 --- /dev/null +++ b/backend/src/workspaces/runtime-renderer.ts @@ -0,0 +1,146 @@ +import { stringify } from "yaml"; +import { buildInstallationContract } from "./contracts.js"; +import { validateCanonicalWorkspace, type CanonicalWorkspace } from "./schema.js"; +import type { ResolvedBinding } from "./bindings.js"; + +export interface RuntimeBindings { + dwh: ResolvedBinding; + vector: ResolvedBinding; + embedding: ResolvedBinding; +} + +export interface RuntimePaths { + sessions: string; + artifacts: string; + indexes: string; +} + +function seconds(timeoutMs: number | undefined): number | undefined { + return timeoutMs === undefined ? undefined : Math.max(1, Math.ceil(timeoutMs / 1_000)); +} + +function bindingValue(binding: ResolvedBinding, name: string): string | undefined { + return binding.values[name]; +} + +function requireBinding(binding: ResolvedBinding, name: string): string { + const value = bindingValue(binding, name); + if (value === undefined) throw new Error(`runtime binding is missing ${name}`); + return value; +} + +function legacyDirectConnection( + binding: ResolvedBinding, + names: { host: string; port: string; user: string; passwordFile: string }, + identity: { database: string; schema: string }, +): Record { + return { + host: requireBinding(binding, names.host), + port: Number(requireBinding(binding, names.port)), + database: identity.database, + schema: identity.schema, + user: requireBinding(binding, names.user), + password_file: requireBinding(binding, names.passwordFile), + }; +} + +function legacyRestEndpoint( + binding: ResolvedBinding, + names: { baseUrl: string; apiKeyFile: string; tlsCaFile: string }, +): Record { + const endpoint: Record = { + base_url: requireBinding(binding, names.baseUrl), + api_key_file: requireBinding(binding, names.apiKeyFile), + }; + const tlsCaFile = bindingValue(binding, names.tlsCaFile); + if (tlsCaFile !== undefined) endpoint.ssl_ca_file = tlsCaFile; + return endpoint; +} + +function placeholderConnection(identity: { database: string; schema: string }): Record { + return { + host: "localhost", + port: 5432, + database: identity.database, + schema: identity.schema, + user: "rest", + password: "", + transport: "rest", + }; +} + +/** Render the compatibility fields consumed by the current Python harness. */ +export function renderRuntimeConfig( + workspace: CanonicalWorkspace, + bindings: RuntimeBindings, + paths: RuntimePaths, +): string { + const canonical = validateCanonicalWorkspace(workspace); + if ([...bindings.dwh.missing, ...bindings.vector.missing, ...bindings.embedding.missing].length > 0) { + throw new Error("runtime configuration requires complete bindings"); + } + + const contract = buildInstallationContract(canonical); + const name = (role: "DWH" | "VECTOR" | "EMBEDDING", suffix: string) => { + const variable = contract.variables.find((entry) => entry.role === role && entry.suffix === suffix); + if (!variable) throw new Error(`workspace contract is missing ${role}_${suffix}`); + return variable.name; + }; + const dwhIdentity = { database: canonical.dwh.database, schema: canonical.dwh.schema }; + const vectorIdentity = dwhIdentity; + const dwhDirect = bindings.dwh.transport === "postgres_direct"; + const vectorDirect = bindings.vector.transport === "pgvector_direct"; + const database = dwhDirect + ? { ...legacyDirectConnection(bindings.dwh, { + host: name("DWH", "HOST"), port: name("DWH", "PORT"), user: name("DWH", "USER"), + passwordFile: name("DWH", "PASSWORD_FILE"), + }, dwhIdentity), transport: "direct" } + : placeholderConnection(dwhIdentity); + const vectorDb = vectorDirect + ? legacyDirectConnection(bindings.vector, { + host: name("VECTOR", "HOST"), port: name("VECTOR", "PORT"), user: name("VECTOR", "USER"), + passwordFile: name("VECTOR", "PASSWORD_FILE"), + }, vectorIdentity) + : placeholderConnection(vectorIdentity); + const embedding: Record = { + base_url: requireBinding(bindings.embedding, name("EMBEDDING", "BASE_URL")), + model: canonical.semantic_index.embedding.model, + dim: canonical.semantic_index.embedding.dimensions, + }; + const embeddingTimeout = seconds(canonical.semantic_index.embedding.timeout_ms); + if (embeddingTimeout !== undefined) embedding.timeout = embeddingTimeout; + + const rendered: Record = { + language: canonical.workspace.language, + database, + vector_db: vectorDb, + embeddings: embedding, + paths, + }; + if (dwhDirect) { + rendered.dwh = { type: "postgres_direct", connection: database }; + } else if (bindings.dwh.transport === "rest_api") { + const rest = legacyRestEndpoint(bindings.dwh, { + baseUrl: name("DWH", "BASE_URL"), apiKeyFile: name("DWH", "API_KEY_FILE"), + tlsCaFile: name("DWH", "TLS_CA_FILE"), + }); + rendered.rest = rest; + rendered.dwh = { type: "thoth_rest", database: dwhIdentity, endpoint: rest }; + } else { + throw new Error("ssh_tunnel runtime configuration requires a diagnostic tunnel"); + } + if (vectorDirect) { + rendered.vectors = { type: "pgvector_direct", connection: vectorDb }; + } else if (bindings.vector.transport === "rest_api") { + const vectorRest = legacyRestEndpoint(bindings.vector, { + baseUrl: name("VECTOR", "BASE_URL"), apiKeyFile: name("VECTOR", "API_KEY_FILE"), + tlsCaFile: name("VECTOR", "TLS_CA_FILE"), + }); + rendered.vector_rest = vectorRest; + rendered.vectors = { type: "thoth_vector_http", reader: vectorRest }; + } else { + throw new Error("ssh_tunnel runtime configuration requires a diagnostic tunnel"); + } + + return stringify(rendered, { lineWidth: 0, sortMapEntries: false }); +} diff --git a/backend/test/tht-runner.test.ts b/backend/test/tht-runner.test.ts index f07a8b4a..4a618606 100644 --- a/backend/test/tht-runner.test.ts +++ b/backend/test/tht-runner.test.ts @@ -169,6 +169,13 @@ test("buildArgv appends -c AFTER the subcommand (never a global -c)", () => { ]); }); +test("buildArgv passes an absolute immutable snapshot after the tht subcommand", () => { + const r = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" }); + expect(r.buildArgv(["session", "new"], "/data/workspace-registry/snapshots/a/psd-clinical.yaml")).toEqual([ + "session", "new", "-c", "/data/workspace-registry/snapshots/a/psd-clinical.yaml", + ]); +}); + test("sqlPreview argv has no positional file — uses --session to resolve path", async () => { // The harness preview_cmd now resolves sql_final.sql from the session workspace; // the backend must NOT pass a sessions//sql_final.sql positional arg. diff --git a/backend/test/workspace-runtime-renderer.test.ts b/backend/test/workspace-runtime-renderer.test.ts new file mode 100644 index 00000000..f7dcbc48 --- /dev/null +++ b/backend/test/workspace-runtime-renderer.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from "vitest"; +import { parse } from "yaml"; +import { renderRuntimeConfig, type RuntimeBindings, type RuntimePaths } from "../src/workspaces/runtime-renderer.js"; +import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; + +const workspace = parseWorkspaceYaml(`workspace: + schema_version: 1 + id: psd-clinical + name: Policlinico San Donato + language: it +dwh: + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: [postgres_direct, rest_api] +semantic_index: + vector_store: + engine: pgvector + collection: clinical_documents + dimensions: 768 + distance: cosine + supported_transports: [pgvector_direct, rest_api] + embedding: + provider: ollama_compatible + model: nomic-embed-text-v2-moe + dimensions: 768 +llm_policy: + allowed: [zai/glm-5.2] +`); +const paths: RuntimePaths = { + sessions: "/data/workspaces/psd-clinical/sessions", + artifacts: "/data/workspaces/psd-clinical/artifacts", + indexes: "/data/workspaces/psd-clinical/indexes", +}; + +const directBindings: RuntimeBindings = { + dwh: { + transport: "postgres_direct", + missing: [], + values: { + THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.internal", + THT_WS_PSD_CLINICAL_DWH_PORT: "5432", + THT_WS_PSD_CLINICAL_DWH_USER: "thoth_reader", + THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE: "/run/secrets/dwh-password", + }, + }, + vector: { + transport: "pgvector_direct", + missing: [], + values: { + THT_WS_PSD_CLINICAL_VECTOR_HOST: "vector.internal", + THT_WS_PSD_CLINICAL_VECTOR_PORT: "5432", + THT_WS_PSD_CLINICAL_VECTOR_USER: "vector_reader", + THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_FILE: "/run/secrets/vector-password", + }, + }, + embedding: { + transport: "rest_api", + missing: [], + values: { THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL: "http://embedding.internal:11434" }, + }, +}; + +test("renders a direct PostgreSQL binding to the legacy harness shape", () => { + const yaml = renderRuntimeConfig(workspace, directBindings, paths); + const rendered = parse(yaml); + + expect(rendered).toMatchObject({ + language: "it", + database: { + host: "dwh.internal", + port: 5432, + database: "postgres", + schema: "datawarehouse", + user: "thoth_reader", + password_file: "/run/secrets/dwh-password", + transport: "direct", + }, + vector_db: { + host: "vector.internal", + schema: "datawarehouse", + password_file: "/run/secrets/vector-password", + }, + embeddings: { + base_url: "http://embedding.internal:11434", + model: "nomic-embed-text-v2-moe", + dim: 768, + }, + paths, + }); + expect(yaml).toContain("type: postgres_direct"); + expect(yaml).toContain("schema: datawarehouse"); +}); + +test("renders REST bindings through the legacy rest sections without secret values", () => { + const yaml = renderRuntimeConfig(workspace, { + ...directBindings, + dwh: { + transport: "rest_api", + missing: [], + values: { + THT_WS_PSD_CLINICAL_DWH_BASE_URL: "https://dwh.example.test", + THT_WS_PSD_CLINICAL_DWH_API_KEY_FILE: "/run/secrets/dwh-api-key", + THT_WS_PSD_CLINICAL_DWH_TLS_CA_FILE: "/run/secrets/ca.pem", + }, + }, + vector: { + transport: "rest_api", + missing: [], + values: { + THT_WS_PSD_CLINICAL_VECTOR_BASE_URL: "https://vector.example.test", + THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: "/run/secrets/vector-api-key", + }, + }, + }, paths); + const rendered = parse(yaml); + + expect(rendered).toMatchObject({ + database: { transport: "rest", schema: "datawarehouse" }, + rest: { + base_url: "https://dwh.example.test", + api_key_file: "/run/secrets/dwh-api-key", + ssl_ca_file: "/run/secrets/ca.pem", + }, + vector_rest: { + base_url: "https://vector.example.test", + api_key_file: "/run/secrets/vector-api-key", + }, + }); + expect(yaml).not.toContain("\n api_key: "); +}); diff --git a/backend/test/workspaces-bindings.test.ts b/backend/test/workspaces-bindings.test.ts new file mode 100644 index 00000000..68fbe6a7 --- /dev/null +++ b/backend/test/workspaces-bindings.test.ts @@ -0,0 +1,120 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { resolveBinding } from "../src/workspaces/bindings.js"; +import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; + +const workspace = parseWorkspaceYaml(`workspace: + schema_version: 1 + id: psd-clinical + name: Policlinico San Donato + language: it +dwh: + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: [postgres_direct, rest_api, ssh_tunnel] +semantic_index: + vector_store: + engine: pgvector + collection: clinical_documents + dimensions: 768 + distance: cosine + supported_transports: [pgvector_direct, rest_api, ssh_tunnel] + embedding: + provider: ollama_compatible + model: nomic-embed-text-v2-moe + dimensions: 768 +llm_policy: + allowed: [zai/glm-5.2] +`); +const temporaryRoots: string[] = []; + +afterEach(() => { + temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +function secretPath(name: string): { root: string; path: string } { + const root = mkdtempSync(join(tmpdir(), "thoth-binding-")); + temporaryRoots.push(root); + const secrets = join(root, "secrets"); + mkdirSync(secrets); + const path = join(secrets, name); + writeFileSync(path, ""); + return { root: secrets, path }; +} + +test("marks a portable workspace non-activatable when its local REST key file is absent", () => { + const result = resolveBinding(workspace, "DWH", { + THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "rest_api", + THT_WS_PSD_CLINICAL_DWH_BASE_URL: "https://dwh.example.test", + }, ["/run/secrets"]); + + expect(result.missing).toContain("THT_WS_PSD_CLINICAL_DWH_API_KEY_FILE"); +}); + +test("resolves direct bindings from the stable workspace namespace", () => { + const password = secretPath("dwh-password"); + const result = resolveBinding(workspace, "DWH", { + THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "postgres_direct", + THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.internal", + THT_WS_PSD_CLINICAL_DWH_PORT: "5432", + THT_WS_PSD_CLINICAL_DWH_USER: "thoth_reader", + THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE: password.path, + }, [password.root]); + + expect(result).toMatchObject({ + transport: "postgres_direct", + missing: [], + values: { + THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.internal", + THT_WS_PSD_CLINICAL_DWH_PORT: "5432", + THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE: password.path, + }, + }); +}); + +test("reports only a FILE variable name when a secret path is outside the configured roots", () => { + const outside = secretPath("outside-password"); + const allowed = secretPath("allowed-password"); + const result = resolveBinding(workspace, "DWH", { + THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "postgres_direct", + THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.internal", + THT_WS_PSD_CLINICAL_DWH_PORT: "5432", + THT_WS_PSD_CLINICAL_DWH_USER: "thoth_reader", + THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE: outside.path, + }, [allowed.root]); + + expect(result.missing).toEqual(["THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE"]); + expect(result.missing.join("\n")).not.toContain(outside.path); +}); + +test("reports an invalid optional secret file instead of silently dropping it", () => { + const password = secretPath("dwh-password"); + const result = resolveBinding(workspace, "DWH", { + THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "postgres_direct", + THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.internal", + THT_WS_PSD_CLINICAL_DWH_PORT: "5432", + THT_WS_PSD_CLINICAL_DWH_USER: "thoth_reader", + THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE: password.path, + THT_WS_PSD_CLINICAL_DWH_TLS_CA_FILE: "relative-ca.pem", + }, [password.root]); + + expect(result.missing).toContain("THT_WS_PSD_CLINICAL_DWH_TLS_CA_FILE"); +}); + +test("rejects a selected transport that the canonical workspace does not support", () => { + const directOnly = { + ...workspace, + dwh: { ...workspace.dwh, supported_transports: ["postgres_direct"] }, + }; + const result = resolveBinding(directOnly, "DWH", { + THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "rest_api", + }, ["/run/secrets"]); + + expect(result).toMatchObject({ + transport: "rest_api", + missing: ["THT_WS_PSD_CLINICAL_DWH_TRANSPORT"], + }); +});