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 { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.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 { WorkspaceSecretStore } from "./workspaces/secret-store.js";
|
||||
|
||||
export interface BuildAppDeps {
|
||||
thtRunner?: ThtRunner;
|
||||
@@ -34,6 +36,7 @@ export interface BuildAppDeps {
|
||||
hub?: SseHub;
|
||||
workspaceRegistry?: WorkspaceRegistry;
|
||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||
workspaceSecretStore?: WorkspaceSecretStore;
|
||||
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||
maintenanceBarrier?: MaintenanceBarrier;
|
||||
piManagement?: PiManagementService;
|
||||
@@ -41,6 +44,11 @@ export interface BuildAppDeps {
|
||||
|
||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||
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.
|
||||
app.register(cors, {
|
||||
@@ -58,6 +66,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
secretRoots: config.workspaceRegistry.secretRoots,
|
||||
secretsFile: config.secretsFile,
|
||||
secretFiles: config.secretFiles,
|
||||
workspaceSecretStore,
|
||||
semanticRuntime: {
|
||||
internalQdrantUrl: config.internalQdrantUrl,
|
||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||
@@ -75,13 +84,19 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
internalEmbeddingModel: config.internalEmbeddingModel,
|
||||
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
||||
});
|
||||
const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => (
|
||||
supportsSessionRuntime(resolveRuntimeBindings(
|
||||
const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => {
|
||||
const lease = resolveRuntimeBindingsWithWorkspaceSecrets(
|
||||
workspace,
|
||||
process.env,
|
||||
config.workspaceRegistry.secretRoots,
|
||||
))
|
||||
));
|
||||
workspaceSecretStore,
|
||||
);
|
||||
try {
|
||||
return supportsSessionRuntime(lease.bindings);
|
||||
} finally {
|
||||
lease.release();
|
||||
}
|
||||
});
|
||||
const readiness = deps?.readiness ?? new ReadinessManager(
|
||||
tht as ThtRunner,
|
||||
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 });
|
||||
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 });
|
||||
piManagementRoutes(app, { config, service: piManagement });
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface AppConfig {
|
||||
legacyWorkspaceMode: boolean;
|
||||
workspaceDiagnosticTimeoutMs: number;
|
||||
workspaceRegistry: WorkspaceRegistryConfig;
|
||||
workspaceSecretStoreRoot: string;
|
||||
workspaceSecretRuntimeRoot: string;
|
||||
internalQdrantUrl: string;
|
||||
internalEmbeddingUrl: 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),
|
||||
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 internalQdrantUrl = internalServiceUrl(
|
||||
env.THT_INTERNAL_QDRANT_URL,
|
||||
@@ -284,6 +294,8 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
legacyWorkspaceMode: legacyWorkspaceMode === "local",
|
||||
workspaceDiagnosticTimeoutMs: diagnosticTimeout(env.THT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS),
|
||||
workspaceRegistry,
|
||||
workspaceSecretStoreRoot,
|
||||
workspaceSecretRuntimeRoot,
|
||||
internalQdrantUrl,
|
||||
internalEmbeddingUrl,
|
||||
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 { WorkspaceRegistryError } from "../workspaces/git-repository.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import { resolveRuntimeBindings } from "../workspaces/bindings.js";
|
||||
import { buildInstallationContract } from "../workspaces/contracts.js";
|
||||
import {
|
||||
discoverWorkspaceSecretRequirements,
|
||||
resolveRuntimeBindingsWithWorkspaceSecrets,
|
||||
} from "../workspaces/secret-requirements.js";
|
||||
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
||||
import {
|
||||
validateOperationalWorkspace,
|
||||
validateWorkspaceDescriptor,
|
||||
@@ -24,10 +28,17 @@ interface WorkspaceRoutesDeps {
|
||||
registry: WorkspaceRegistry;
|
||||
config: WorkspaceRegistryConfig;
|
||||
diagnose: WorkspaceDiagnoser;
|
||||
secretStore: WorkspaceSecretStore;
|
||||
}
|
||||
|
||||
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
||||
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 = {
|
||||
workspace_invalid: "Workspace request is invalid.",
|
||||
@@ -57,6 +68,29 @@ function errorReply(reply: FastifyReply, error: unknown) {
|
||||
}
|
||||
|
||||
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) => {
|
||||
try {
|
||||
return await deps.registry.bootstrap();
|
||||
@@ -76,15 +110,18 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
||||
app.get("/workspaces", async (_request, reply) => {
|
||||
try {
|
||||
const records = await deps.registry.listCatalog();
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
// Retain the metadata endpoint's selector field while adding catalog metadata.
|
||||
name: record.id,
|
||||
file: `${record.id}/workspace.yaml`,
|
||||
displayName: record.name,
|
||||
description: record.description,
|
||||
configurationState: record.configurationState,
|
||||
...(record.revision ? { revision: record.revision } : {}),
|
||||
return await Promise.all(records.map(async (record) => {
|
||||
const configuration = await runtimeConfiguration(record.id);
|
||||
return {
|
||||
id: record.id,
|
||||
// Retain the metadata endpoint's selector field while adding catalog metadata.
|
||||
name: record.id,
|
||||
file: `${record.id}/workspace.yaml`,
|
||||
displayName: record.name,
|
||||
description: record.description,
|
||||
configurationState: configuration.configurationState,
|
||||
...(record.revision ? { revision: record.revision } : {}),
|
||||
};
|
||||
}));
|
||||
} catch (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) => {
|
||||
try {
|
||||
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",
|
||||
);
|
||||
}
|
||||
const bindings = resolveRuntimeBindings(operational, process.env, deps.config.secretRoots);
|
||||
return await deps.diagnose(operational, bindings, { writeProbe: false });
|
||||
const lease = resolveRuntimeBindingsWithWorkspaceSecrets(
|
||||
operational,
|
||||
process.env,
|
||||
deps.config.secretRoots,
|
||||
deps.secretStore,
|
||||
);
|
||||
try {
|
||||
return await deps.diagnose(operational, lease.bindings, { writeProbe: false });
|
||||
} finally {
|
||||
lease.release();
|
||||
}
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type WorkspaceDescriptor,
|
||||
} from "../workspaces/schema.js";
|
||||
import { reconcileCollection } from "../workspaces/qdrant-collection.js";
|
||||
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
||||
|
||||
export interface ThtConfig extends SecretBundleConfig {
|
||||
thtBin: string;
|
||||
@@ -33,6 +34,7 @@ export interface ThtConfig extends SecretBundleConfig {
|
||||
qdrantRequest?: typeof fetch;
|
||||
/** "self_heal" for session admission (create missing collections/indexes), default "require_existing". */
|
||||
qdrantCollectionMode?: "self_heal" | "require_existing";
|
||||
workspaceSecretStore?: WorkspaceSecretStore;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigLease {
|
||||
@@ -221,8 +223,15 @@ export class ThtRunner {
|
||||
})(),
|
||||
secretRoots: this.cfg.secretRoots ?? [],
|
||||
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;
|
||||
return {
|
||||
path,
|
||||
@@ -232,6 +241,7 @@ export class ThtRunner {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.cleanupRuntimeSnapshot(path);
|
||||
rendered.releaseSecrets();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
type CanonicalEffectiveConfig,
|
||||
} from "./effective-config.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 { WorkspaceRegistry } from "./registry.js";
|
||||
import {
|
||||
@@ -58,6 +60,7 @@ export interface RenderedWorkspaceRuntime {
|
||||
bindingDigest: string;
|
||||
renderedConfig: string;
|
||||
semanticQdrantUrl: string;
|
||||
releaseSecrets(): void;
|
||||
}
|
||||
|
||||
export interface ActiveRenderedWorkspaceRuntime extends RenderedWorkspaceRuntime {
|
||||
@@ -298,33 +301,49 @@ function renderWorkspaceRuntimeFromWorkspace(options: {
|
||||
dataRoot: string;
|
||||
secretRoots: readonly string[];
|
||||
semanticRuntime: SemanticRuntimeConfig;
|
||||
workspaceSecretStore?: WorkspaceSecretStore;
|
||||
}): 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 context: RuntimeRenderContext = {
|
||||
workspaceId: options.workspaceId,
|
||||
workspaceRevision: options.workspaceRevision,
|
||||
revisionContentRoot: options.revisionContentRoot,
|
||||
};
|
||||
return {
|
||||
workspace: options.workspace,
|
||||
workspaceId: options.workspaceId,
|
||||
workspaceRevision: options.workspaceRevision,
|
||||
revisionContentRoot: options.revisionContentRoot,
|
||||
runtimePaths: runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision),
|
||||
installationOverlay: overlay,
|
||||
bindings,
|
||||
bindingDigest: stableBindingDigest(bindings),
|
||||
semanticQdrantUrl: options.semanticRuntime.internalQdrantUrl,
|
||||
renderedConfig: renderRuntimeConfig(
|
||||
options.workspace,
|
||||
try {
|
||||
return {
|
||||
workspace: options.workspace,
|
||||
workspaceId: options.workspaceId,
|
||||
workspaceRevision: options.workspaceRevision,
|
||||
revisionContentRoot: options.revisionContentRoot,
|
||||
runtimePaths: runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision),
|
||||
installationOverlay: overlay,
|
||||
bindings,
|
||||
runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision),
|
||||
context,
|
||||
overlay,
|
||||
options.semanticRuntime,
|
||||
),
|
||||
};
|
||||
bindingDigest: stableBindingDigest(bindings),
|
||||
semanticQdrantUrl: options.semanticRuntime.internalQdrantUrl,
|
||||
releaseSecrets: () => secretLease?.release(),
|
||||
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: {
|
||||
@@ -334,6 +353,7 @@ export function renderWorkspaceRuntimeFromSnapshotPath(options: {
|
||||
dataRoot: string;
|
||||
secretRoots: readonly string[];
|
||||
semanticRuntime: SemanticRuntimeConfig;
|
||||
workspaceSecretStore?: WorkspaceSecretStore;
|
||||
}): RenderedWorkspaceRuntime {
|
||||
const snapshot = readSnapshotWorkspace(options.snapshotPath);
|
||||
return renderWorkspaceRuntimeFromWorkspace({
|
||||
@@ -346,6 +366,7 @@ export function renderWorkspaceRuntimeFromSnapshotPath(options: {
|
||||
dataRoot: options.dataRoot,
|
||||
secretRoots: options.secretRoots,
|
||||
semanticRuntime: options.semanticRuntime,
|
||||
workspaceSecretStore: options.workspaceSecretStore,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -358,6 +379,7 @@ export async function renderActiveWorkspaceRuntime(options: {
|
||||
dataRoot: string;
|
||||
secretRoots: readonly string[];
|
||||
semanticRuntime: SemanticRuntimeConfig;
|
||||
workspaceSecretStore?: WorkspaceSecretStore;
|
||||
}): Promise<ActiveRenderedWorkspaceRuntime> {
|
||||
// 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
|
||||
@@ -387,6 +409,7 @@ export async function renderActiveWorkspaceRuntime(options: {
|
||||
dataRoot: options.dataRoot,
|
||||
secretRoots: options.secretRoots,
|
||||
semanticRuntime: options.semanticRuntime,
|
||||
workspaceSecretStore: options.workspaceSecretStore,
|
||||
});
|
||||
return {
|
||||
...rendered,
|
||||
@@ -584,4 +607,4 @@ export async function publishDeterministicRuntimeConfigLease(options: {
|
||||
inputFingerprint: inputFingerprintValue,
|
||||
release: () => undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -9,6 +9,7 @@ import { loadConfig } from "../src/config.js";
|
||||
import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js";
|
||||
import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js";
|
||||
import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
|
||||
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
|
||||
|
||||
const workspace: CanonicalWorkspace = {
|
||||
workspace: {
|
||||
@@ -72,6 +73,7 @@ function registryFake(overrides: Partial<RegistryFake> = {}): RegistryFake {
|
||||
function appFor(
|
||||
registry: RegistryFake,
|
||||
diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
||||
secretStore = testSecretStore(),
|
||||
) {
|
||||
return buildApp(loadConfig({
|
||||
THT_HARNESS_DIR: "/missing-harness",
|
||||
@@ -80,9 +82,19 @@ function appFor(
|
||||
thtRunner: {} as any,
|
||||
workspaceRegistry: registry as WorkspaceRegistry,
|
||||
workspaceDiagnoser: diagnose,
|
||||
workspaceSecretStore: secretStore,
|
||||
} 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 () => {
|
||||
const registry = registryFake({
|
||||
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({
|
||||
id: "psd-clinical",
|
||||
displayName: "Policlinico San Donato",
|
||||
configurationState: "ready",
|
||||
configurationState: "configuration_required",
|
||||
revision,
|
||||
})]);
|
||||
expect(read.statusCode).toBe(200);
|
||||
@@ -174,6 +186,89 @@ test("runs diagnostics for a schema v3 workspace", async () => {
|
||||
}, { 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 () => {
|
||||
const evidenceWorkspace: CanonicalWorkspace = {
|
||||
...workspace,
|
||||
@@ -282,6 +377,7 @@ async function createRealRouteFixture() {
|
||||
|
||||
afterEach(() => {
|
||||
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 () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { ThtRunner } from "../src/tht/tht-runner.js";
|
||||
import { WorkspaceRegistry } from "../src/workspaces/registry.js";
|
||||
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
|
||||
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
||||
|
||||
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([]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const f = await fixture();
|
||||
const runner = runnerFor(f);
|
||||
|
||||
Reference in New Issue
Block a user