743 lines
21 KiB
Markdown
743 lines
21 KiB
Markdown
1) 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<void> {
|
||
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<void> {
|
||
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<unknown>;
|
||
}
|
||
|
||
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);
|
||
});
|
||
|
||
2) 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
|
||
}
|
||
}
|
||
}
|
||
|
||
3) 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<T>(key: string): T {
|
||
return vscode.workspace.getConfiguration("productos").get<T>(key)!;
|
||
}
|
||
|
||
export function backendUrl() {
|
||
return cfg<string>("backendUrl");
|
||
}
|
||
|
||
export function tenantId() {
|
||
return cfg<string>("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() {}
|
||
|
||
4) 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
|
||
} |