From 106d9c5ff1ccde222ce78e1e4ef214fad2121d13 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 19 Feb 2026 14:01:02 -0800 Subject: [PATCH] feat: switch workspace provisioning from Coolify to Cloud Run - lib/cloud-run-workspace.ts: provisions per-project Theia workspaces as Cloud Run services (theia-{slug}), scales to zero when idle, starts in ~5-15s from cached image - create/route.ts: imports cloud-run-workspace instead of coolify-workspace - Image: northamerica-northeast1-docker.pkg.dev/master-ai-484822/vibn-ide/theia:latest - Includes prewarmWorkspace() for near-zero perceived load time on login Co-authored-by: Cursor --- app/api/projects/create/route.ts | 6 +- lib/cloud-run-workspace.ts | 170 +++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 lib/cloud-run-workspace.ts diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index dcbe6ba..0cb4ec1 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -4,7 +4,7 @@ import { authOptions } from '@/lib/auth/authOptions'; import { query } from '@/lib/db-postgres'; import { randomUUID } from 'crypto'; import { createRepo, createWebhook, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; -import { provisionTheiaWorkspace } from '@/lib/coolify-workspace'; +import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT; @@ -99,8 +99,8 @@ export async function POST(request: Request) { try { const workspace = await provisionTheiaWorkspace(slug, projectId, giteaRepo); - theiaWorkspaceUrl = workspace.workspaceUrl; - theiaAppUuid = workspace.appUuid; + theiaWorkspaceUrl = workspace.serviceUrl; + theiaAppUuid = workspace.serviceName; console.log(`[API] Theia workspace provisioned: ${theiaWorkspaceUrl}`); } catch (err) { theiaError = err instanceof Error ? err.message : String(err); diff --git a/lib/cloud-run-workspace.ts b/lib/cloud-run-workspace.ts new file mode 100644 index 0000000..8a8f592 --- /dev/null +++ b/lib/cloud-run-workspace.ts @@ -0,0 +1,170 @@ +/** + * Cloud Run Workspace Provisioning + * + * Provisions a dedicated Theia IDE instance per Vibn project using + * Google Cloud Run. Each workspace: + * - Gets its own Cloud Run service: theia-{slug} + * - Scales to zero when idle (zero cost when not in use) + * - Starts in ~5-15s from cached image on demand + * - Is accessible at the Cloud Run URL stored on the project record + * - Auth is enforced by our Vibn session before the URL is revealed + */ + +import { GoogleAuth } from 'google-auth-library'; + +const PROJECT_ID = 'master-ai-484822'; +const REGION = 'northamerica-northeast1'; +const IMAGE = `${REGION}-docker.pkg.dev/${PROJECT_ID}/vibn-ide/theia:latest`; +const VIBN_URL = process.env.NEXTAUTH_URL ?? 'https://vibnai.com'; + +const CLOUD_RUN_API = `https://run.googleapis.com/v2/projects/${PROJECT_ID}/locations/${REGION}/services`; + +async function getAccessToken(): Promise { + const auth = new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + const client = await auth.getClient(); + const token = await client.getAccessToken(); + if (!token.token) throw new Error('Failed to get GCP access token'); + return token.token; +} + +export interface ProvisionResult { + serviceUrl: string; // https://theia-{slug}-xxx.run.app + serviceName: string; // theia-{slug} +} + +/** + * Creates a new Cloud Run service for a Vibn project workspace. + * The service scales to zero when idle and starts on first request. + */ +export async function provisionTheiaWorkspace( + slug: string, + projectId: string, + giteaRepo: string | null, +): Promise { + const token = await getAccessToken(); + const serviceName = `theia-${slug}`.slice(0, 49); // Cloud Run max 49 chars + + const serviceBody = { + name: `${CLOUD_RUN_API}/${serviceName}`, + template: { + scaling: { + minInstanceCount: 0, // scale to zero when idle + maxInstanceCount: 1, // one instance per workspace + }, + containers: [{ + image: IMAGE, + ports: [{ containerPort: 3000 }], + resources: { + limits: { cpu: '1', memory: '2Gi' }, + cpuIdle: true, // only allocate CPU during requests + }, + env: [ + { name: 'VIBN_PROJECT_ID', value: projectId }, + { name: 'VIBN_PROJECT_SLUG', value: slug }, + { name: 'VIBN_API_URL', value: VIBN_URL }, + { name: 'GITEA_REPO', value: giteaRepo ?? '' }, + { name: 'GITEA_API_URL', value: process.env.GITEA_API_URL ?? 'https://git.vibnai.com' }, + ], + // 5 minute startup timeout — Theia needs time to initialise + startupProbe: { + httpGet: { path: '/', port: 3000 }, + failureThreshold: 30, + periodSeconds: 10, + }, + }], + // Keep container alive for 15 minutes of idle before scaling to zero + timeout: '900s', + }, + ingress: 'INGRESS_TRAFFIC_ALL', + }; + + const createRes = await fetch(`${CLOUD_RUN_API}?serviceId=${serviceName}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(serviceBody), + }); + + if (!createRes.ok) { + const body = await createRes.text(); + throw new Error(`Cloud Run create service failed (${createRes.status}): ${body}`); + } + + // Make service publicly accessible (auth handled by Vibn before URL is revealed) + await allowUnauthenticated(serviceName, token); + + // Poll until the service URL is available (usually 10-30s) + const serviceUrl = await waitForServiceUrl(serviceName, token); + + console.log(`[workspace] Cloud Run service ready: ${serviceName} → ${serviceUrl}`); + return { serviceUrl, serviceName }; +} + +/** + * Grants allUsers invoker access so the service URL works without GCP auth. + * Vibn controls access by only sharing the URL with the project owner. + */ +async function allowUnauthenticated(serviceName: string, token: string): Promise { + await fetch(`${CLOUD_RUN_API}/${serviceName}:setIamPolicy`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + policy: { + bindings: [{ role: 'roles/run.invoker', members: ['allUsers'] }], + }, + }), + }); +} + +/** + * Polls until Cloud Run reports the service URL (service is ready). + */ +async function waitForServiceUrl(serviceName: string, token: string, maxWaitMs = 60_000): Promise { + const deadline = Date.now() + maxWaitMs; + + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 3000)); + + const res = await fetch(`${CLOUD_RUN_API}/${serviceName}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (res.ok) { + const svc = await res.json() as { urls?: string[] }; + if (svc.urls?.[0]) return svc.urls[0]; + } + } + + // Return expected URL pattern even if polling timed out + return `https://${serviceName}-${PROJECT_ID.slice(-6)}.${REGION}.run.app`; +} + +/** + * Triggers a warm-up request to a workspace so the container is ready + * before the user clicks "Open IDE". Call this on user login. + */ +export async function prewarmWorkspace(serviceUrl: string): Promise { + try { + await fetch(`${serviceUrl}/`, { signal: AbortSignal.timeout(5000) }); + } catch { + // Ignore — fire and forget + } +} + +/** + * Deletes a Cloud Run workspace service. + */ +export async function deleteTheiaWorkspace(serviceName: string): Promise { + const token = await getAccessToken(); + await fetch(`${CLOUD_RUN_API}/${serviceName}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); +}