/** * 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, JWT } 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`; const SCOPES = ['https://www.googleapis.com/auth/cloud-platform']; async function getAccessToken(): Promise { // Prefer an explicit service account key (avoids GCE metadata scope limitations). // Stored as base64 to survive Docker ARG/ENV special-character handling. const keyB64 = process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64; if (keyB64) { const keyJson = Buffer.from(keyB64, 'base64').toString('utf-8'); const key = JSON.parse(keyJson) as { client_email: string; private_key: string; }; const jwt = new JWT({ email: key.client_email, key: key.private_key, scopes: SCOPES, }); const token = await jwt.getAccessToken(); if (!token.token) throw new Error('Failed to get GCP access token from service account key'); return token.token as string; } // Fall back to ADC (works locally or on GCE with cloud-platform scope) const auth = new GoogleAuth({ scopes: SCOPES }); 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 // Cloud Run v2: name must be empty in the body — it's passed via ?serviceId= in the URL const serviceBody = { 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' }, // Token lets the startup script clone and push to the project's repo { name: 'GITEA_TOKEN', value: process.env.GITEA_API_TOKEN ?? '' }, ], // 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(); // 409 = service already exists — fetch its URL instead of failing if (createRes.status === 409) { console.log(`[workspace] Cloud Run service already exists: ${serviceName} — fetching existing URL`); const serviceUrl = await waitForServiceUrl(serviceName, token); await allowUnauthenticated(serviceName, token); console.log(`[workspace] Linked to existing service: ${serviceName} → ${serviceUrl}`); return { serviceUrl, serviceName }; } 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}` }, }); }