feat: configure workspace runtime secrets through API
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user