feat: configure workspace runtime secrets through API
This commit is contained in:
+26
-6
@@ -21,8 +21,10 @@ import { WorkspaceRegistry } from "./workspaces/registry.js";
|
|||||||
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
||||||
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
|
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
|
||||||
import { piManagementRoutes } from "./routes/pi-management.js";
|
import { piManagementRoutes } from "./routes/pi-management.js";
|
||||||
import { resolveRuntimeBindings, supportsSessionRuntime } from "./workspaces/bindings.js";
|
import { supportsSessionRuntime } from "./workspaces/bindings.js";
|
||||||
|
import { resolveRuntimeBindingsWithWorkspaceSecrets } from "./workspaces/secret-requirements.js";
|
||||||
import type { WorkspaceDescriptor } from "./workspaces/schema.js";
|
import type { WorkspaceDescriptor } from "./workspaces/schema.js";
|
||||||
|
import { WorkspaceSecretStore } from "./workspaces/secret-store.js";
|
||||||
|
|
||||||
export interface BuildAppDeps {
|
export interface BuildAppDeps {
|
||||||
thtRunner?: ThtRunner;
|
thtRunner?: ThtRunner;
|
||||||
@@ -34,6 +36,7 @@ export interface BuildAppDeps {
|
|||||||
hub?: SseHub;
|
hub?: SseHub;
|
||||||
workspaceRegistry?: WorkspaceRegistry;
|
workspaceRegistry?: WorkspaceRegistry;
|
||||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||||
|
workspaceSecretStore?: WorkspaceSecretStore;
|
||||||
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||||
maintenanceBarrier?: MaintenanceBarrier;
|
maintenanceBarrier?: MaintenanceBarrier;
|
||||||
piManagement?: PiManagementService;
|
piManagement?: PiManagementService;
|
||||||
@@ -41,6 +44,11 @@ export interface BuildAppDeps {
|
|||||||
|
|
||||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||||
const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true });
|
const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true });
|
||||||
|
const workspaceSecretStore = deps?.workspaceSecretStore ?? new WorkspaceSecretStore({
|
||||||
|
root: config.workspaceSecretStoreRoot,
|
||||||
|
runtimeRoot: config.workspaceSecretRuntimeRoot,
|
||||||
|
installationId: config.workspaceRegistry.installationId,
|
||||||
|
});
|
||||||
|
|
||||||
// Allow any origin in dev/e2e; tighten in production via config if needed.
|
// Allow any origin in dev/e2e; tighten in production via config if needed.
|
||||||
app.register(cors, {
|
app.register(cors, {
|
||||||
@@ -58,6 +66,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
secretRoots: config.workspaceRegistry.secretRoots,
|
secretRoots: config.workspaceRegistry.secretRoots,
|
||||||
secretsFile: config.secretsFile,
|
secretsFile: config.secretsFile,
|
||||||
secretFiles: config.secretFiles,
|
secretFiles: config.secretFiles,
|
||||||
|
workspaceSecretStore,
|
||||||
semanticRuntime: {
|
semanticRuntime: {
|
||||||
internalQdrantUrl: config.internalQdrantUrl,
|
internalQdrantUrl: config.internalQdrantUrl,
|
||||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||||
@@ -75,13 +84,19 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
internalEmbeddingModel: config.internalEmbeddingModel,
|
internalEmbeddingModel: config.internalEmbeddingModel,
|
||||||
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
||||||
});
|
});
|
||||||
const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => (
|
const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => {
|
||||||
supportsSessionRuntime(resolveRuntimeBindings(
|
const lease = resolveRuntimeBindingsWithWorkspaceSecrets(
|
||||||
workspace,
|
workspace,
|
||||||
process.env,
|
process.env,
|
||||||
config.workspaceRegistry.secretRoots,
|
config.workspaceRegistry.secretRoots,
|
||||||
))
|
workspaceSecretStore,
|
||||||
));
|
);
|
||||||
|
try {
|
||||||
|
return supportsSessionRuntime(lease.bindings);
|
||||||
|
} finally {
|
||||||
|
lease.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
const readiness = deps?.readiness ?? new ReadinessManager(
|
const readiness = deps?.readiness ?? new ReadinessManager(
|
||||||
tht as ThtRunner,
|
tht as ThtRunner,
|
||||||
Math.round(config.ollamaEnsureTimeoutMs / 1000),
|
Math.round(config.ollamaEnsureTimeoutMs / 1000),
|
||||||
@@ -180,7 +195,12 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
});
|
});
|
||||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry });
|
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry });
|
||||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||||
workspaceRoutes(app, { registry: workspaceRegistry, config: config.workspaceRegistry, diagnose: workspaceDiagnoser });
|
workspaceRoutes(app, {
|
||||||
|
registry: workspaceRegistry,
|
||||||
|
config: config.workspaceRegistry,
|
||||||
|
diagnose: workspaceDiagnoser,
|
||||||
|
secretStore: workspaceSecretStore,
|
||||||
|
});
|
||||||
settingsRoutes(app, { cfg: config, listModels, getSettings });
|
settingsRoutes(app, { cfg: config, listModels, getSettings });
|
||||||
piManagementRoutes(app, { config, service: piManagement });
|
piManagementRoutes(app, { config, service: piManagement });
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface AppConfig {
|
|||||||
legacyWorkspaceMode: boolean;
|
legacyWorkspaceMode: boolean;
|
||||||
workspaceDiagnosticTimeoutMs: number;
|
workspaceDiagnosticTimeoutMs: number;
|
||||||
workspaceRegistry: WorkspaceRegistryConfig;
|
workspaceRegistry: WorkspaceRegistryConfig;
|
||||||
|
workspaceSecretStoreRoot: string;
|
||||||
|
workspaceSecretRuntimeRoot: string;
|
||||||
internalQdrantUrl: string;
|
internalQdrantUrl: string;
|
||||||
internalEmbeddingUrl: string;
|
internalEmbeddingUrl: string;
|
||||||
internalEmbeddingModel: string;
|
internalEmbeddingModel: string;
|
||||||
@@ -248,6 +250,14 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
|||||||
maxEvidencePathBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_PATH_BYTES, 4096),
|
maxEvidencePathBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_PATH_BYTES, 4096),
|
||||||
maxEvidenceManifestBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_MANIFEST_BYTES, 1024 * 1024),
|
maxEvidenceManifestBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_MANIFEST_BYTES, 1024 * 1024),
|
||||||
};
|
};
|
||||||
|
const workspaceSecretStoreRoot = absoluteRegistryPath(
|
||||||
|
env.THT_WORKSPACE_SECRET_STORE_ROOT ?? path.join(path.dirname(registryRoot), "workspace-secrets"),
|
||||||
|
"secret store root",
|
||||||
|
);
|
||||||
|
const workspaceSecretRuntimeRoot = absoluteRegistryPath(
|
||||||
|
env.THT_WORKSPACE_SECRET_RUNTIME_ROOT ?? "/tmp/thothii-workspace-secrets",
|
||||||
|
"secret runtime root",
|
||||||
|
);
|
||||||
const settingsFile = env.SETTINGS_FILE ?? "data/settings.json";
|
const settingsFile = env.SETTINGS_FILE ?? "data/settings.json";
|
||||||
const internalQdrantUrl = internalServiceUrl(
|
const internalQdrantUrl = internalServiceUrl(
|
||||||
env.THT_INTERNAL_QDRANT_URL,
|
env.THT_INTERNAL_QDRANT_URL,
|
||||||
@@ -284,6 +294,8 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
|||||||
legacyWorkspaceMode: legacyWorkspaceMode === "local",
|
legacyWorkspaceMode: legacyWorkspaceMode === "local",
|
||||||
workspaceDiagnosticTimeoutMs: diagnosticTimeout(env.THT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS),
|
workspaceDiagnosticTimeoutMs: diagnosticTimeout(env.THT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS),
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
|
workspaceSecretStoreRoot,
|
||||||
|
workspaceSecretRuntimeRoot,
|
||||||
internalQdrantUrl,
|
internalQdrantUrl,
|
||||||
internalEmbeddingUrl,
|
internalEmbeddingUrl,
|
||||||
internalEmbeddingModel: env.THT_INTERNAL_EMBEDDING_MODEL ?? "qwen3-embedding:0.6b",
|
internalEmbeddingModel: env.THT_INTERNAL_EMBEDDING_MODEL ?? "qwen3-embedding:0.6b",
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { z } from "zod";
|
|||||||
import type { WorkspaceRegistryConfig } from "../workspaces/types.js";
|
import type { WorkspaceRegistryConfig } from "../workspaces/types.js";
|
||||||
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
|
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
|
||||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||||
import { resolveRuntimeBindings } from "../workspaces/bindings.js";
|
|
||||||
import { buildInstallationContract } from "../workspaces/contracts.js";
|
import { buildInstallationContract } from "../workspaces/contracts.js";
|
||||||
|
import {
|
||||||
|
discoverWorkspaceSecretRequirements,
|
||||||
|
resolveRuntimeBindingsWithWorkspaceSecrets,
|
||||||
|
} from "../workspaces/secret-requirements.js";
|
||||||
|
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
||||||
import {
|
import {
|
||||||
validateOperationalWorkspace,
|
validateOperationalWorkspace,
|
||||||
validateWorkspaceDescriptor,
|
validateWorkspaceDescriptor,
|
||||||
@@ -24,10 +28,17 @@ interface WorkspaceRoutesDeps {
|
|||||||
registry: WorkspaceRegistry;
|
registry: WorkspaceRegistry;
|
||||||
config: WorkspaceRegistryConfig;
|
config: WorkspaceRegistryConfig;
|
||||||
diagnose: WorkspaceDiagnoser;
|
diagnose: WorkspaceDiagnoser;
|
||||||
|
secretStore: WorkspaceSecretStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
||||||
const workspacePayload = z.object({ workspace: z.unknown() }).strict();
|
const workspacePayload = z.object({ workspace: z.unknown() }).strict();
|
||||||
|
const secretRequirementId = z.string().regex(/^[a-z0-9][a-z0-9._-]{1,127}$/);
|
||||||
|
const secretValuesPayload = z.object({
|
||||||
|
values: z.record(secretRequirementId, z.string()).refine(
|
||||||
|
(values) => Object.keys(values).length > 0 && Object.keys(values).length <= 16,
|
||||||
|
),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
const SAFE_MESSAGES = {
|
const SAFE_MESSAGES = {
|
||||||
workspace_invalid: "Workspace request is invalid.",
|
workspace_invalid: "Workspace request is invalid.",
|
||||||
@@ -57,6 +68,29 @@ function errorReply(reply: FastifyReply, error: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps): void {
|
export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps): void {
|
||||||
|
const runtimeConfiguration = async (id: string) => {
|
||||||
|
const { workspace, revision } = await deps.registry.read(id);
|
||||||
|
const operational = validateOperationalWorkspace(workspace);
|
||||||
|
const requirements = discoverWorkspaceSecretRequirements(operational, process.env)
|
||||||
|
.map((requirement) => ({
|
||||||
|
id: requirement.id,
|
||||||
|
connector: requirement.connector,
|
||||||
|
label: requirement.label,
|
||||||
|
description: requirement.description,
|
||||||
|
input: requirement.input,
|
||||||
|
required: requirement.required,
|
||||||
|
configured: deps.secretStore.has(id, requirement.id),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
workspaceId: id,
|
||||||
|
revision,
|
||||||
|
configurationState: requirements.some(({ required, configured }) => required && !configured)
|
||||||
|
? "configuration_required" as const
|
||||||
|
: "ready" as const,
|
||||||
|
requirements,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
app.get("/workspace-registry/status", async (_request, reply) => {
|
app.get("/workspace-registry/status", async (_request, reply) => {
|
||||||
try {
|
try {
|
||||||
return await deps.registry.bootstrap();
|
return await deps.registry.bootstrap();
|
||||||
@@ -76,15 +110,18 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
|||||||
app.get("/workspaces", async (_request, reply) => {
|
app.get("/workspaces", async (_request, reply) => {
|
||||||
try {
|
try {
|
||||||
const records = await deps.registry.listCatalog();
|
const records = await deps.registry.listCatalog();
|
||||||
return records.map((record) => ({
|
return await Promise.all(records.map(async (record) => {
|
||||||
id: record.id,
|
const configuration = await runtimeConfiguration(record.id);
|
||||||
// Retain the metadata endpoint's selector field while adding catalog metadata.
|
return {
|
||||||
name: record.id,
|
id: record.id,
|
||||||
file: `${record.id}/workspace.yaml`,
|
// Retain the metadata endpoint's selector field while adding catalog metadata.
|
||||||
displayName: record.name,
|
name: record.id,
|
||||||
description: record.description,
|
file: `${record.id}/workspace.yaml`,
|
||||||
configurationState: record.configurationState,
|
displayName: record.name,
|
||||||
...(record.revision ? { revision: record.revision } : {}),
|
description: record.description,
|
||||||
|
configurationState: configuration.configurationState,
|
||||||
|
...(record.revision ? { revision: record.revision } : {}),
|
||||||
|
};
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorReply(reply, error);
|
return errorReply(reply, error);
|
||||||
@@ -110,6 +147,52 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/workspaces/:id/runtime-configuration", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
||||||
|
return await runtimeConfiguration(id);
|
||||||
|
} catch (error) {
|
||||||
|
return errorReply(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/workspaces/:id/secrets", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
||||||
|
const { values } = secretValuesPayload.parse(request.body);
|
||||||
|
const { workspace } = await deps.registry.read(id);
|
||||||
|
const declared = new Set(
|
||||||
|
discoverWorkspaceSecretRequirements(validateOperationalWorkspace(workspace), process.env)
|
||||||
|
.map(({ id: requirementId }) => requirementId),
|
||||||
|
);
|
||||||
|
if (Object.keys(values).some((requirementId) => !declared.has(requirementId))) {
|
||||||
|
throw new Error("undeclared workspace secret");
|
||||||
|
}
|
||||||
|
deps.secretStore.putMany(id, values);
|
||||||
|
return await runtimeConfiguration(id);
|
||||||
|
} catch (error) {
|
||||||
|
return errorReply(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/workspaces/:id/secrets/:requirementId", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { id, requirementId } = z.object({
|
||||||
|
id: workspaceId,
|
||||||
|
requirementId: secretRequirementId,
|
||||||
|
}).parse(request.params);
|
||||||
|
const { workspace } = await deps.registry.read(id);
|
||||||
|
const declared = discoverWorkspaceSecretRequirements(
|
||||||
|
validateOperationalWorkspace(workspace), process.env,
|
||||||
|
).some(({ id: candidate }) => candidate === requirementId);
|
||||||
|
if (!declared) throw new Error("undeclared workspace secret");
|
||||||
|
deps.secretStore.forget(id, requirementId);
|
||||||
|
return await runtimeConfiguration(id);
|
||||||
|
} catch (error) {
|
||||||
|
return errorReply(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/workspaces/:id/test", async (request, reply) => {
|
app.post("/workspaces/:id/test", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
||||||
@@ -122,8 +205,17 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
|||||||
"workspace_not_activatable", "Workspace requires explicit migration",
|
"workspace_not_activatable", "Workspace requires explicit migration",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const bindings = resolveRuntimeBindings(operational, process.env, deps.config.secretRoots);
|
const lease = resolveRuntimeBindingsWithWorkspaceSecrets(
|
||||||
return await deps.diagnose(operational, bindings, { writeProbe: false });
|
operational,
|
||||||
|
process.env,
|
||||||
|
deps.config.secretRoots,
|
||||||
|
deps.secretStore,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return await deps.diagnose(operational, lease.bindings, { writeProbe: false });
|
||||||
|
} finally {
|
||||||
|
lease.release();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorReply(reply, error);
|
return errorReply(reply, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "../workspaces/schema.js";
|
} from "../workspaces/schema.js";
|
||||||
import { reconcileCollection } from "../workspaces/qdrant-collection.js";
|
import { reconcileCollection } from "../workspaces/qdrant-collection.js";
|
||||||
|
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
||||||
|
|
||||||
export interface ThtConfig extends SecretBundleConfig {
|
export interface ThtConfig extends SecretBundleConfig {
|
||||||
thtBin: string;
|
thtBin: string;
|
||||||
@@ -33,6 +34,7 @@ export interface ThtConfig extends SecretBundleConfig {
|
|||||||
qdrantRequest?: typeof fetch;
|
qdrantRequest?: typeof fetch;
|
||||||
/** "self_heal" for session admission (create missing collections/indexes), default "require_existing". */
|
/** "self_heal" for session admission (create missing collections/indexes), default "require_existing". */
|
||||||
qdrantCollectionMode?: "self_heal" | "require_existing";
|
qdrantCollectionMode?: "self_heal" | "require_existing";
|
||||||
|
workspaceSecretStore?: WorkspaceSecretStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeConfigLease {
|
export interface RuntimeConfigLease {
|
||||||
@@ -221,8 +223,15 @@ export class ThtRunner {
|
|||||||
})(),
|
})(),
|
||||||
secretRoots: this.cfg.secretRoots ?? [],
|
secretRoots: this.cfg.secretRoots ?? [],
|
||||||
semanticRuntime: this.cfg.semanticRuntime ?? DEFAULT_SEMANTIC_RUNTIME,
|
semanticRuntime: this.cfg.semanticRuntime ?? DEFAULT_SEMANTIC_RUNTIME,
|
||||||
|
workspaceSecretStore: this.cfg.workspaceSecretStore,
|
||||||
});
|
});
|
||||||
const path = this.createRuntimeSnapshot(rendered.renderedConfig);
|
let path: string;
|
||||||
|
try {
|
||||||
|
path = this.createRuntimeSnapshot(rendered.renderedConfig);
|
||||||
|
} catch (error) {
|
||||||
|
rendered.releaseSecrets();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
let released = false;
|
let released = false;
|
||||||
return {
|
return {
|
||||||
path,
|
path,
|
||||||
@@ -232,6 +241,7 @@ export class ThtRunner {
|
|||||||
if (released) return;
|
if (released) return;
|
||||||
released = true;
|
released = true;
|
||||||
this.cleanupRuntimeSnapshot(path);
|
this.cleanupRuntimeSnapshot(path);
|
||||||
|
rendered.releaseSecrets();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
type CanonicalEffectiveConfig,
|
type CanonicalEffectiveConfig,
|
||||||
} from "./effective-config.js";
|
} from "./effective-config.js";
|
||||||
import { resolveRuntimeBindings, type RuntimeBindings } from "./bindings.js";
|
import { resolveRuntimeBindings, type RuntimeBindings } from "./bindings.js";
|
||||||
|
import { resolveRuntimeBindingsWithWorkspaceSecrets } from "./secret-requirements.js";
|
||||||
|
import type { WorkspaceSecretStore } from "./secret-store.js";
|
||||||
import { GitWorkspaceRepository } from "./git-repository.js";
|
import { GitWorkspaceRepository } from "./git-repository.js";
|
||||||
import { WorkspaceRegistry } from "./registry.js";
|
import { WorkspaceRegistry } from "./registry.js";
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +60,7 @@ export interface RenderedWorkspaceRuntime {
|
|||||||
bindingDigest: string;
|
bindingDigest: string;
|
||||||
renderedConfig: string;
|
renderedConfig: string;
|
||||||
semanticQdrantUrl: string;
|
semanticQdrantUrl: string;
|
||||||
|
releaseSecrets(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveRenderedWorkspaceRuntime extends RenderedWorkspaceRuntime {
|
export interface ActiveRenderedWorkspaceRuntime extends RenderedWorkspaceRuntime {
|
||||||
@@ -298,33 +301,49 @@ function renderWorkspaceRuntimeFromWorkspace(options: {
|
|||||||
dataRoot: string;
|
dataRoot: string;
|
||||||
secretRoots: readonly string[];
|
secretRoots: readonly string[];
|
||||||
semanticRuntime: SemanticRuntimeConfig;
|
semanticRuntime: SemanticRuntimeConfig;
|
||||||
|
workspaceSecretStore?: WorkspaceSecretStore;
|
||||||
}): RenderedWorkspaceRuntime {
|
}): RenderedWorkspaceRuntime {
|
||||||
const bindings = resolveRuntimeBindings(options.workspace, process.env, options.secretRoots);
|
const secretLease = options.workspaceSecretStore === undefined
|
||||||
|
? undefined
|
||||||
|
: resolveRuntimeBindingsWithWorkspaceSecrets(
|
||||||
|
options.workspace,
|
||||||
|
process.env,
|
||||||
|
options.secretRoots,
|
||||||
|
options.workspaceSecretStore,
|
||||||
|
);
|
||||||
|
const bindings = secretLease?.bindings
|
||||||
|
?? resolveRuntimeBindings(options.workspace, process.env, options.secretRoots);
|
||||||
const overlay = installationOverlay(options.harnessDir, options.configPath);
|
const overlay = installationOverlay(options.harnessDir, options.configPath);
|
||||||
const context: RuntimeRenderContext = {
|
const context: RuntimeRenderContext = {
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
workspaceRevision: options.workspaceRevision,
|
workspaceRevision: options.workspaceRevision,
|
||||||
revisionContentRoot: options.revisionContentRoot,
|
revisionContentRoot: options.revisionContentRoot,
|
||||||
};
|
};
|
||||||
return {
|
try {
|
||||||
workspace: options.workspace,
|
return {
|
||||||
workspaceId: options.workspaceId,
|
workspace: options.workspace,
|
||||||
workspaceRevision: options.workspaceRevision,
|
workspaceId: options.workspaceId,
|
||||||
revisionContentRoot: options.revisionContentRoot,
|
workspaceRevision: options.workspaceRevision,
|
||||||
runtimePaths: runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision),
|
revisionContentRoot: options.revisionContentRoot,
|
||||||
installationOverlay: overlay,
|
runtimePaths: runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision),
|
||||||
bindings,
|
installationOverlay: overlay,
|
||||||
bindingDigest: stableBindingDigest(bindings),
|
|
||||||
semanticQdrantUrl: options.semanticRuntime.internalQdrantUrl,
|
|
||||||
renderedConfig: renderRuntimeConfig(
|
|
||||||
options.workspace,
|
|
||||||
bindings,
|
bindings,
|
||||||
runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision),
|
bindingDigest: stableBindingDigest(bindings),
|
||||||
context,
|
semanticQdrantUrl: options.semanticRuntime.internalQdrantUrl,
|
||||||
overlay,
|
releaseSecrets: () => secretLease?.release(),
|
||||||
options.semanticRuntime,
|
renderedConfig: renderRuntimeConfig(
|
||||||
),
|
options.workspace,
|
||||||
};
|
bindings,
|
||||||
|
runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision),
|
||||||
|
context,
|
||||||
|
overlay,
|
||||||
|
options.semanticRuntime,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
secretLease?.release();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderWorkspaceRuntimeFromSnapshotPath(options: {
|
export function renderWorkspaceRuntimeFromSnapshotPath(options: {
|
||||||
@@ -334,6 +353,7 @@ export function renderWorkspaceRuntimeFromSnapshotPath(options: {
|
|||||||
dataRoot: string;
|
dataRoot: string;
|
||||||
secretRoots: readonly string[];
|
secretRoots: readonly string[];
|
||||||
semanticRuntime: SemanticRuntimeConfig;
|
semanticRuntime: SemanticRuntimeConfig;
|
||||||
|
workspaceSecretStore?: WorkspaceSecretStore;
|
||||||
}): RenderedWorkspaceRuntime {
|
}): RenderedWorkspaceRuntime {
|
||||||
const snapshot = readSnapshotWorkspace(options.snapshotPath);
|
const snapshot = readSnapshotWorkspace(options.snapshotPath);
|
||||||
return renderWorkspaceRuntimeFromWorkspace({
|
return renderWorkspaceRuntimeFromWorkspace({
|
||||||
@@ -346,6 +366,7 @@ export function renderWorkspaceRuntimeFromSnapshotPath(options: {
|
|||||||
dataRoot: options.dataRoot,
|
dataRoot: options.dataRoot,
|
||||||
secretRoots: options.secretRoots,
|
secretRoots: options.secretRoots,
|
||||||
semanticRuntime: options.semanticRuntime,
|
semanticRuntime: options.semanticRuntime,
|
||||||
|
workspaceSecretStore: options.workspaceSecretStore,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +379,7 @@ export async function renderActiveWorkspaceRuntime(options: {
|
|||||||
dataRoot: string;
|
dataRoot: string;
|
||||||
secretRoots: readonly string[];
|
secretRoots: readonly string[];
|
||||||
semanticRuntime: SemanticRuntimeConfig;
|
semanticRuntime: SemanticRuntimeConfig;
|
||||||
|
workspaceSecretStore?: WorkspaceSecretStore;
|
||||||
}): Promise<ActiveRenderedWorkspaceRuntime> {
|
}): Promise<ActiveRenderedWorkspaceRuntime> {
|
||||||
// The persisted active state may reference host-side snapshot paths (written by another
|
// The persisted active state may reference host-side snapshot paths (written by another
|
||||||
// process or installation). Read the active state directly and resolve the immutable snapshot
|
// process or installation). Read the active state directly and resolve the immutable snapshot
|
||||||
@@ -387,6 +409,7 @@ export async function renderActiveWorkspaceRuntime(options: {
|
|||||||
dataRoot: options.dataRoot,
|
dataRoot: options.dataRoot,
|
||||||
secretRoots: options.secretRoots,
|
secretRoots: options.secretRoots,
|
||||||
semanticRuntime: options.semanticRuntime,
|
semanticRuntime: options.semanticRuntime,
|
||||||
|
workspaceSecretStore: options.workspaceSecretStore,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...rendered,
|
...rendered,
|
||||||
@@ -584,4 +607,4 @@ export async function publishDeterministicRuntimeConfigLease(options: {
|
|||||||
inputFingerprint: inputFingerprintValue,
|
inputFingerprint: inputFingerprintValue,
|
||||||
release: () => undefined,
|
release: () => undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
@@ -9,6 +9,7 @@ import { loadConfig } from "../src/config.js";
|
|||||||
import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js";
|
import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js";
|
||||||
import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js";
|
import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js";
|
||||||
import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
|
import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
|
||||||
|
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
|
||||||
|
|
||||||
const workspace: CanonicalWorkspace = {
|
const workspace: CanonicalWorkspace = {
|
||||||
workspace: {
|
workspace: {
|
||||||
@@ -72,6 +73,7 @@ function registryFake(overrides: Partial<RegistryFake> = {}): RegistryFake {
|
|||||||
function appFor(
|
function appFor(
|
||||||
registry: RegistryFake,
|
registry: RegistryFake,
|
||||||
diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
||||||
|
secretStore = testSecretStore(),
|
||||||
) {
|
) {
|
||||||
return buildApp(loadConfig({
|
return buildApp(loadConfig({
|
||||||
THT_HARNESS_DIR: "/missing-harness",
|
THT_HARNESS_DIR: "/missing-harness",
|
||||||
@@ -80,9 +82,19 @@ function appFor(
|
|||||||
thtRunner: {} as any,
|
thtRunner: {} as any,
|
||||||
workspaceRegistry: registry as WorkspaceRegistry,
|
workspaceRegistry: registry as WorkspaceRegistry,
|
||||||
workspaceDiagnoser: diagnose,
|
workspaceDiagnoser: diagnose,
|
||||||
|
workspaceSecretStore: secretStore,
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const secretStoreRoots: string[] = [];
|
||||||
|
|
||||||
|
function testSecretStore(): WorkspaceSecretStore {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "thoth-route-secret-store-"));
|
||||||
|
const runtimeRoot = mkdtempSync(join(tmpdir(), "thoth-route-secret-runtime-"));
|
||||||
|
secretStoreRoots.push(root, runtimeRoot);
|
||||||
|
return new WorkspaceSecretStore({ root, runtimeRoot, installationId: "route-test" });
|
||||||
|
}
|
||||||
|
|
||||||
test("returns a redacted registry status and pulls without Git credential details", async () => {
|
test("returns a redacted registry status and pulls without Git credential details", async () => {
|
||||||
const registry = registryFake({
|
const registry = registryFake({
|
||||||
bootstrap: vi.fn(async () => ({
|
bootstrap: vi.fn(async () => ({
|
||||||
@@ -126,7 +138,7 @@ test("lists workspace summaries and reads a validated immutable workspace", asyn
|
|||||||
expect(list.json()).toEqual([expect.objectContaining({
|
expect(list.json()).toEqual([expect.objectContaining({
|
||||||
id: "psd-clinical",
|
id: "psd-clinical",
|
||||||
displayName: "Policlinico San Donato",
|
displayName: "Policlinico San Donato",
|
||||||
configurationState: "ready",
|
configurationState: "configuration_required",
|
||||||
revision,
|
revision,
|
||||||
})]);
|
})]);
|
||||||
expect(read.statusCode).toBe(200);
|
expect(read.statusCode).toBe(200);
|
||||||
@@ -174,6 +186,89 @@ test("runs diagnostics for a schema v3 workspace", async () => {
|
|||||||
}, { writeProbe: false });
|
}, { writeProbe: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reports runtime secret requirements without returning stored values", async () => {
|
||||||
|
const secretStore = testSecretStore();
|
||||||
|
const app = appFor(registryFake(), undefined, secretStore);
|
||||||
|
|
||||||
|
const missing = await app.inject({
|
||||||
|
method: "GET", url: "/workspaces/psd-clinical/runtime-configuration",
|
||||||
|
});
|
||||||
|
expect(missing.statusCode).toBe(200);
|
||||||
|
expect(missing.json()).toMatchObject({
|
||||||
|
workspaceId: "psd-clinical",
|
||||||
|
revision,
|
||||||
|
configurationState: "configuration_required",
|
||||||
|
requirements: [{
|
||||||
|
id: "dwh.password",
|
||||||
|
connector: "dwh",
|
||||||
|
label: "Data warehouse password",
|
||||||
|
required: true,
|
||||||
|
configured: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const secret = "never-return-this-password";
|
||||||
|
const save = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/workspaces/psd-clinical/secrets",
|
||||||
|
payload: { values: { "dwh.password": secret } },
|
||||||
|
});
|
||||||
|
expect(save.statusCode).toBe(200);
|
||||||
|
expect(save.body).not.toContain(secret);
|
||||||
|
expect(save.json()).toMatchObject({
|
||||||
|
configurationState: "ready",
|
||||||
|
requirements: [{ id: "dwh.password", configured: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const configured = await app.inject({
|
||||||
|
method: "GET", url: "/workspaces/psd-clinical/runtime-configuration",
|
||||||
|
});
|
||||||
|
expect(configured.body).not.toContain(secret);
|
||||||
|
expect(configured.json()).toMatchObject({ configurationState: "ready" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects undeclared secret identifiers and supports forgetting a configured secret", async () => {
|
||||||
|
const secretStore = testSecretStore();
|
||||||
|
const app = appFor(registryFake(), undefined, secretStore);
|
||||||
|
|
||||||
|
const unknown = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/workspaces/psd-clinical/secrets",
|
||||||
|
payload: { values: { "evidence.secret_key": "not-applicable" } },
|
||||||
|
});
|
||||||
|
expect(unknown.statusCode).toBe(400);
|
||||||
|
expect(secretStore.configured("psd-clinical")).toEqual([]);
|
||||||
|
|
||||||
|
secretStore.put("psd-clinical", "dwh.password", "temporary-password");
|
||||||
|
const forget = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: "/workspaces/psd-clinical/secrets/dwh.password",
|
||||||
|
});
|
||||||
|
expect(forget.statusCode).toBe(200);
|
||||||
|
expect(forget.json()).toMatchObject({ configurationState: "configuration_required" });
|
||||||
|
expect(secretStore.has("psd-clinical", "dwh.password")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("materializes stored secrets only for the diagnostic lease", async () => {
|
||||||
|
const secretStore = testSecretStore();
|
||||||
|
secretStore.put("psd-clinical", "dwh.password", "diagnostic-password");
|
||||||
|
let materializedPath = "";
|
||||||
|
const diagnose = vi.fn(async (_workspace, bindings) => {
|
||||||
|
materializedPath = bindings.dwh.values.THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE;
|
||||||
|
expect(readFileSync(materializedPath, "utf8")).toBe("diagnostic-password");
|
||||||
|
return { activatable: true, diagnostics: [] };
|
||||||
|
});
|
||||||
|
const app = appFor(registryFake(), diagnose, secretStore);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST", url: "/workspaces/psd-clinical/test", payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(materializedPath).not.toBe("");
|
||||||
|
expect(existsSync(materializedPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("reports a missing Evidence credential without changing the registry revision", async () => {
|
test("reports a missing Evidence credential without changing the registry revision", async () => {
|
||||||
const evidenceWorkspace: CanonicalWorkspace = {
|
const evidenceWorkspace: CanonicalWorkspace = {
|
||||||
...workspace,
|
...workspace,
|
||||||
@@ -282,6 +377,7 @@ async function createRealRouteFixture() {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
realRouteRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }));
|
realRouteRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }));
|
||||||
|
secretStoreRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a failed candidate pull keeps the last valid active workspace", async () => {
|
test("a failed candidate pull keeps the last valid active workspace", async () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { buildApp } from "../src/app.js";
|
|||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
import { ThtRunner } from "../src/tht/tht-runner.js";
|
import { ThtRunner } from "../src/tht/tht-runner.js";
|
||||||
import { WorkspaceRegistry } from "../src/workspaces/registry.js";
|
import { WorkspaceRegistry } from "../src/workspaces/registry.js";
|
||||||
|
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
|
||||||
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
||||||
|
|
||||||
const runFile = promisify(execFile);
|
const runFile = promisify(execFile);
|
||||||
@@ -166,6 +167,36 @@ test("real schema-v3 registry revision loads through ThtRunner and the harness c
|
|||||||
expect(readdirSync(join(f.registryConfig.root, "snapshots", "runtime"))).toEqual([]);
|
expect(readdirSync(join(f.registryConfig.root, "snapshots", "runtime"))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ThtRunner uses a vault secret only for the lifetime of its runtime lease", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE", "");
|
||||||
|
const vaultRoot = join(f.root, "workspace-secrets");
|
||||||
|
const runtimeRoot = join(f.root, "workspace-secret-runtime");
|
||||||
|
const secretStore = new WorkspaceSecretStore({
|
||||||
|
root: vaultRoot,
|
||||||
|
runtimeRoot,
|
||||||
|
installationId: "test",
|
||||||
|
});
|
||||||
|
secretStore.put("psd-clinical", "dwh.password", "vault-runtime-password");
|
||||||
|
const runner = new ThtRunner({
|
||||||
|
thtBin,
|
||||||
|
harnessDir,
|
||||||
|
configPath: "config/tht.yaml",
|
||||||
|
dataRoot: f.dataRoot,
|
||||||
|
runtimeSnapshotRoot: join(f.registryConfig.root, "snapshots", "runtime"),
|
||||||
|
secretRoots: f.registryConfig.secretRoots,
|
||||||
|
workspaceSecretStore: secretStore,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||||
|
const rendered = parse(readFileSync(lease.path, "utf8")) as {
|
||||||
|
database: { password_file: string };
|
||||||
|
};
|
||||||
|
expect(readFileSync(rendered.database.password_file, "utf8")).toBe("vault-runtime-password");
|
||||||
|
lease.release();
|
||||||
|
expect(existsSync(rendered.database.password_file)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("separate runtime leases hand off byte-identical revision Evidence configs accepted by tht", async () => {
|
test("separate runtime leases hand off byte-identical revision Evidence configs accepted by tht", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
const runner = runnerFor(f);
|
const runner = runnerFor(f);
|
||||||
|
|||||||
Reference in New Issue
Block a user