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 <cursoragent@cursor.com>
This commit is contained in:
2026-02-19 14:01:02 -08:00
parent a22d5a0f18
commit 106d9c5ff1
2 changed files with 173 additions and 3 deletions

170
lib/cloud-run-workspace.ts Normal file
View File

@@ -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<string> {
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<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}` },
});
}