Files
vibn-frontend/lib/cloud-run-workspace.ts
Mark Henderson 68f844ce52 fix: use service account key for Cloud Run workspace provisioning
GCE metadata tokens lack the cloud-platform OAuth scope, causing 403
PERMISSION_DENIED when creating Cloud Run services. Use an explicit JWT
from GOOGLE_SERVICE_ACCOUNT_KEY env var when present, with ADC as fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 15:45:51 -08:00

188 lines
6.2 KiB
TypeScript

/**
* 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<string> {
// Prefer an explicit service account key (avoids GCE metadata scope limitations)
const keyJson = process.env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (keyJson) {
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;
}
// 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<ProvisionResult> {
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<void> {
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<string> {
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<void> {
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<void> {
const token = await getAccessToken();
await fetch(`${CLOUD_RUN_API}/${serviceName}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
}