21 KiB
- Generate Control Plane API scaffold Folder layout backend/control-plane/ package.json tsconfig.json src/ index.ts config.ts auth.ts registry.ts types.ts storage/ firestore.ts gcs.ts routes/ tools.ts runs.ts health.ts .env.example
backend/control-plane/package.json { "name": "@productos/control-plane", "version": "0.1.0", "private": true, "type": "module", "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "lint": "eslint ." }, "dependencies": { "@google-cloud/firestore": "^7.11.0", "@google-cloud/storage": "^7.14.0", "@fastify/cors": "^9.0.1", "@fastify/helmet": "^12.0.0", "@fastify/rate-limit": "^9.1.0", "fastify": "^4.28.1", "zod": "^3.23.8", "nanoid": "^5.0.7" }, "devDependencies": { "@types/node": "^22.0.0", "tsx": "^4.19.0", "typescript": "^5.5.4", "eslint": "^9.8.0" } }
backend/control-plane/tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "Bundler", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["node"] } }
backend/control-plane/.env.example PORT=8080 GCP_PROJECT_ID=your-project-id GCS_BUCKET_ARTIFACTS=productos-artifacts-dev FIRESTORE_COLLECTION_RUNS=runs FIRESTORE_COLLECTION_TOOLS=tools
If you put behind IAP / OAuth later, validate ID tokens here:
AUTH_MODE=dev # dev | oauth
backend/control-plane/src/config.ts export const config = { port: Number(process.env.PORT ?? 8080), projectId: process.env.GCP_PROJECT_ID ?? "", artifactsBucket: process.env.GCS_BUCKET_ARTIFACTS ?? "", runsCollection: process.env.FIRESTORE_COLLECTION_RUNS ?? "runs", toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools", authMode: process.env.AUTH_MODE ?? "dev" };
backend/control-plane/src/types.ts export type ToolRisk = "low" | "medium" | "high";
export type ToolDef = { name: string; description: string; risk: ToolRisk; executor: { kind: "http"; url: string; // executor base url path: string; // executor endpoint path }; inputSchema: unknown; // JSON Schema object outputSchema?: unknown; // JSON Schema object };
export type ToolInvokeRequest = { tool: string; tenant_id: string; workspace_id?: string; input: unknown; dry_run?: boolean; };
export type RunStatus = "queued" | "running" | "succeeded" | "failed";
export type RunRecord = { run_id: string; tenant_id: string; tool: string; status: RunStatus; created_at: string; updated_at: string; input: unknown; output?: unknown; error?: { message: string; details?: unknown }; artifacts?: { bucket: string; prefix: string }; };
backend/control-plane/src/auth.ts import { FastifyRequest } from "fastify"; import { config } from "./config.js";
/**
- V1: dev mode = trust caller (or a shared API key later).
- V2: validate Google OAuth/IAP identity token and map to tenant/org. */ export async function requireAuth(req: FastifyRequest) { if (config.authMode === "dev") return;
// Placeholder for OAuth/IAP verification: // - read Authorization: Bearer <id_token> // - verify token (Google JWKS) // - attach req.user throw new Error("AUTH_MODE oauth not yet implemented"); }
backend/control-plane/src/storage/firestore.ts import { Firestore } from "@google-cloud/firestore"; import { config } from "../config.js"; import type { RunRecord, ToolDef } from "../types.js";
const db = new Firestore({ projectId: config.projectId });
export async function saveRun(run: RunRecord): Promise { await db.collection(config.runsCollection).doc(run.run_id).set(run, { merge: true }); }
export async function getRun(runId: string): Promise<RunRecord | null> { const snap = await db.collection(config.runsCollection).doc(runId).get(); return snap.exists ? (snap.data() as RunRecord) : null; }
export async function saveTool(tool: ToolDef): Promise { await db.collection(config.toolsCollection).doc(tool.name).set(tool, { merge: true }); }
export async function listTools(): Promise<ToolDef[]> { const snap = await db.collection(config.toolsCollection).get(); return snap.docs.map(d => d.data() as ToolDef); }
backend/control-plane/src/storage/gcs.ts import { Storage } from "@google-cloud/storage"; import { config } from "../config.js";
const storage = new Storage({ projectId: config.projectId });
export async function writeArtifactText(prefix: string, filename: string, content: string) {
const bucket = storage.bucket(config.artifactsBucket);
const file = bucket.file(${prefix}/${filename});
await file.save(content, { contentType: "text/plain" });
return { bucket: config.artifactsBucket, path: ${prefix}/${filename} };
}
backend/control-plane/src/registry.ts import type { ToolDef } from "./types.js"; import { listTools } from "./storage/firestore.js";
/**
- Simple registry. V2: cache + versioning + per-tenant overrides. */ export async function getRegistry(): Promise<Record<string, ToolDef>> { const tools = await listTools(); return Object.fromEntries(tools.map(t => [t.name, t])); }
backend/control-plane/src/routes/health.ts import type { FastifyInstance } from "fastify";
export async function healthRoutes(app: FastifyInstance) { app.get("/healthz", async () => ({ ok: true })); }
backend/control-plane/src/routes/tools.ts import type { FastifyInstance } from "fastify"; import { nanoid } from "nanoid"; import { requireAuth } from "../auth.js"; import { getRegistry } from "../registry.js"; import { saveRun } from "../storage/firestore.js"; import { writeArtifactText } from "../storage/gcs.js"; import type { RunRecord, ToolInvokeRequest } from "../types.js";
async function postJson(url: string, body: unknown) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!res.ok) {
const txt = await res.text();
throw new Error(Executor error ${res.status}: ${txt});
}
return res.json() as Promise;
}
export async function toolRoutes(app: FastifyInstance) { app.get("/tools", async (req) => { await requireAuth(req); const registry = await getRegistry(); return { tools: Object.values(registry) }; });
app.post<{ Body: ToolInvokeRequest }>("/tools/invoke", async (req) => { await requireAuth(req);
const body = req.body;
const registry = await getRegistry();
const tool = registry[body.tool];
if (!tool) return app.httpErrors.notFound(`Unknown tool: ${body.tool}`);
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
const run: RunRecord = {
run_id: runId,
tenant_id: body.tenant_id,
tool: body.tool,
status: "queued",
created_at: now,
updated_at: now,
input: body.input,
artifacts: { bucket: process.env.GCS_BUCKET_ARTIFACTS ?? "", prefix: `runs/${runId}` }
};
await saveRun(run);
// record input artifact
await writeArtifactText(`runs/${runId}`, "input.json", JSON.stringify(body, null, 2));
// execute (sync for v1; v2: push to Cloud Tasks / Workflows)
try {
run.status = "running";
run.updated_at = new Date().toISOString();
await saveRun(run);
if (body.dry_run) {
run.status = "succeeded";
run.output = { dry_run: true };
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(run.output, null, 2));
return { run_id: runId, status: run.status };
}
const execUrl = `${tool.executor.url}${tool.executor.path}`;
const output = await postJson(execUrl, {
run_id: runId,
tenant_id: body.tenant_id,
workspace_id: body.workspace_id,
input: body.input
});
run.status = "succeeded";
run.output = output;
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(output, null, 2));
return { run_id: runId, status: run.status };
} catch (e: any) {
run.status = "failed";
run.error = { message: e?.message ?? "Unknown error" };
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "error.json", JSON.stringify(run.error, null, 2));
return { run_id: runId, status: run.status };
}
}); }
backend/control-plane/src/routes/runs.ts import type { FastifyInstance } from "fastify"; import { requireAuth } from "../auth.js"; import { getRun } from "../storage/firestore.js";
export async function runRoutes(app: FastifyInstance) { app.get("/runs/:run_id", async (req) => { await requireAuth(req); // @ts-expect-error fastify param typing const runId = req.params.run_id as string; const run = await getRun(runId); if (!run) return app.httpErrors.notFound("Run not found"); return run; });
// V1: logs are stored as artifacts in GCS; IDE can fetch by signed URL later app.get("/runs/:run_id/logs", async (req) => { await requireAuth(req); // stub return { note: "V1: logs are in GCS artifacts under runs/<run_id>/" }; }); }
backend/control-plane/src/index.ts import Fastify from "fastify"; import cors from "@fastify/cors"; import helmet from "@fastify/helmet"; import rateLimit from "@fastify/rate-limit"; import { config } from "./config.js"; import { healthRoutes } from "./routes/health.js"; import { toolRoutes } from "./routes/tools.js"; import { runRoutes } from "./routes/runs.js";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true }); await app.register(helmet); await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(healthRoutes); await app.register(toolRoutes); await app.register(runRoutes);
app.listen({ port: config.port, host: "0.0.0.0" }).catch((err) => { app.log.error(err); process.exit(1); });
- Generate Tool Registry schema
You want two things:
A human-editable YAML (source of truth)
A JSON Schema to validate tool definitions
contracts/tool-registry.yaml (example) version: 1 tools: cloudrun.deploy_service: description: Deploy a Cloud Run service via Cloud Build. risk: medium executor: kind: http url: https://deploy-executor-xxxxx.a.run.app path: /execute/deploy inputSchema: type: object required: [service_name, repo, ref, env] properties: service_name: { type: string } repo: { type: string, description: "Git repo URL" } ref: { type: string, description: "Branch, tag, or commit SHA" } env: { type: string, enum: ["dev", "staging", "prod"] } outputSchema: type: object properties: service_url: { type: string } revision: { type: string }
analytics.get_funnel_summary: description: Return funnel metrics for a tenant and time window. risk: low executor: kind: http url: https://analytics-executor-xxxxx.a.run.app path: /execute/funnel inputSchema: type: object required: [range_days] properties: range_days: { type: integer, minimum: 1, maximum: 365 } segment: { type: object }
contracts/tool-registry.schema.json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://productos.dev/schemas/tool-registry.schema.json", "type": "object", "required": ["version", "tools"], "properties": { "version": { "type": "integer", "minimum": 1 }, "tools": { "type": "object", "additionalProperties": { "$ref": "#/$defs/ToolDef" } } }, "$defs": { "ToolDef": { "type": "object", "required": ["description", "risk", "executor", "inputSchema"], "properties": { "description": { "type": "string" }, "risk": { "type": "string", "enum": ["low", "medium", "high"] }, "executor": { "$ref": "#/$defs/Executor" }, "inputSchema": { "type": "object" }, "outputSchema": { "type": "object" } }, "additionalProperties": false }, "Executor": { "type": "object", "required": ["kind", "url", "path"], "properties": { "kind": { "type": "string", "enum": ["http"] }, "url": { "type": "string" }, "path": { "type": "string" } }, "additionalProperties": false } } }
- Generate VSCodium extension skeleton Folder layout client-ide/extensions/gcp-productos/ package.json tsconfig.json src/extension.ts src/api.ts src/ui.ts media/icon.png (optional)
client-ide/extensions/gcp-productos/package.json { "name": "gcp-productos", "displayName": "GCP Product OS", "description": "Product-centric panels (Code, Marketing, Analytics, Growth...) and backend tool invocation.", "version": "0.0.1", "publisher": "productos", "engines": { "vscode": "^1.90.0" }, "categories": ["Other"], "activationEvents": ["onStartupFinished"], "main": "./dist/extension.js", "contributes": { "commands": [ { "command": "productos.configure", "title": "Product OS: Configure Backend" }, { "command": "productos.tools.list", "title": "Product OS: List Tools" }, { "command": "productos.tools.invoke", "title": "Product OS: Invoke Tool" }, { "command": "productos.runs.open", "title": "Product OS: Open Run" } ], "configuration": { "title": "Product OS", "properties": { "productos.backendUrl": { "type": "string", "default": "http://localhost:8080", "description": "Control Plane API base URL" }, "productos.tenantId": { "type": "string", "default": "t_dev", "description": "Tenant ID for tool calls" } } } }, "scripts": { "build": "tsc -p tsconfig.json", "watch": "tsc -w -p tsconfig.json" }, "devDependencies": { "@types/node": "^22.0.0", "@types/vscode": "^1.90.0", "typescript": "^5.5.4" } }
client-ide/extensions/gcp-productos/tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "CommonJS", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true } }
client-ide/extensions/gcp-productos/src/api.ts import * as vscode from "vscode";
function cfg(key: string): T { return vscode.workspace.getConfiguration("productos").get(key)!; }
export function backendUrl() { return cfg("backendUrl"); }
export function tenantId() { return cfg("tenantId"); }
export async function listTools(): Promise<any[]> {
const res = await fetch(${backendUrl()}/tools);
if (!res.ok) throw new Error(await res.text());
const json = await res.json();
return json.tools ?? [];
}
export async function invokeTool(tool: string, input: any) {
const res = await fetch(${backendUrl()}/tools/invoke, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tool,
tenant_id: tenantId(),
input
})
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function getRun(runId: string) {
const res = await fetch(${backendUrl()}/runs/${runId});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
client-ide/extensions/gcp-productos/src/ui.ts import * as vscode from "vscode"; import { getRun } from "./api";
export async function showJson(title: string, obj: any) { const doc = await vscode.workspace.openTextDocument({ content: JSON.stringify(obj, null, 2), language: "json" }); await vscode.window.showTextDocument(doc, { preview: false }); vscode.window.setStatusBarMessage(title, 3000); }
export async function openRun(runId: string) {
const run = await getRun(runId);
await showJson(Run ${runId}, run);
}
client-ide/extensions/gcp-productos/src/extension.ts import * as vscode from "vscode"; import { invokeTool, listTools } from "./api"; import { openRun, showJson } from "./ui";
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("productos.configure", async () => {
const backendUrl = await vscode.window.showInputBox({ prompt: "Control Plane backend URL" });
if (!backendUrl) return;
await vscode.workspace.getConfiguration("productos").update("backendUrl", backendUrl, vscode.ConfigurationTarget.Global);
vscode.window.showInformationMessage(Product OS backend set: ${backendUrl});
})
);
context.subscriptions.push( vscode.commands.registerCommand("productos.tools.list", async () => { const tools = await listTools(); await showJson("Tools", tools); }) );
context.subscriptions.push( vscode.commands.registerCommand("productos.tools.invoke", async () => { const tools = await listTools(); const pick = await vscode.window.showQuickPick( tools.map((t: any) => ({ label: t.name, description: t.description })), { placeHolder: "Select a tool to invoke" } ); if (!pick) return;
const inputText = await vscode.window.showInputBox({
prompt: "Tool input JSON",
value: "{}"
});
if (!inputText) return;
const input = JSON.parse(inputText);
const result = await invokeTool(pick.label, input);
await showJson("Invoke Result", result);
if (result?.run_id) {
const open = await vscode.window.showInformationMessage(`Run started: ${result.run_id}`, "Open Run");
if (open === "Open Run") await openRun(result.run_id);
}
})
);
context.subscriptions.push( vscode.commands.registerCommand("productos.runs.open", async () => { const runId = await vscode.window.showInputBox({ prompt: "Run ID" }); if (!runId) return; await openRun(runId); }) ); }
export function deactivate() {}
- Generate Terraform base
This is a minimal hub-style baseline:
GCS bucket for artifacts
Firestore (Native) for runs/tools
Cloud Run service for Control Plane
Service accounts + IAM
Placeholders for executor services
Folder layout infra/terraform/ providers.tf variables.tf outputs.tf main.tf iam.tf
infra/terraform/providers.tf terraform { required_version = ">= 1.5.0" required_providers { google = { source = "hashicorp/google" version = "~> 5.30" } } }
provider "google" { project = var.project_id region = var.region }
infra/terraform/variables.tf variable "project_id" { type = string } variable "region" { type = string default = "us-central1" } variable "artifact_bucket_name" { type = string } variable "control_plane_image" { type = string description = "Container image URI for control-plane (Artifact Registry)." }
infra/terraform/main.tf resource "google_storage_bucket" "artifacts" { name = var.artifact_bucket_name location = var.region uniform_bucket_level_access = true versioning { enabled = true } }
Firestore (Native mode) – requires enabling in console once per project (or via API depending on org policy).
resource "google_firestore_database" "default" { name = "(default)" location_id = var.region type = "FIRESTORE_NATIVE" }
resource "google_service_account" "control_plane_sa" { account_id = "sa-control-plane" display_name = "Product OS Control Plane" }
resource "google_cloud_run_v2_service" "control_plane" { name = "control-plane" location = var.region
template { service_account = google_service_account.control_plane_sa.email
containers {
image = var.control_plane_image
env {
name = "GCP_PROJECT_ID"
value = var.project_id
}
env {
name = "GCS_BUCKET_ARTIFACTS"
value = google_storage_bucket.artifacts.name
}
env {
name = "AUTH_MODE"
value = "dev"
}
}
} }
Public access optional; prefer IAM auth in production.
resource "google_cloud_run_v2_service_iam_member" "control_plane_public" { name = google_cloud_run_v2_service.control_plane.name location = var.region role = "roles/run.invoker" member = "allUsers" }
infra/terraform/iam.tf
Allow control-plane to write artifacts in GCS
resource "google_storage_bucket_iam_member" "control_plane_bucket_writer" { bucket = google_storage_bucket.artifacts.name role = "roles/storage.objectAdmin" member = "serviceAccount:${google_service_account.control_plane_sa.email}" }
Firestore access for run/tool metadata
resource "google_project_iam_member" "control_plane_firestore" { project = var.project_id role = "roles/datastore.user" member = "serviceAccount:${google_service_account.control_plane_sa.email}" }
Placeholder: executor services will each have their own service accounts.
Control-plane should be granted roles/run.invoker on each executor service once created.
infra/terraform/outputs.tf output "control_plane_url" { value = google_cloud_run_v2_service.control_plane.uri }
output "artifact_bucket" { value = google_storage_bucket.artifacts.name }