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:
170
lib/cloud-run-workspace.ts
Normal file
170
lib/cloud-run-workspace.ts
Normal 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}` },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user