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:
@@ -4,7 +4,7 @@ import { authOptions } from '@/lib/auth/authOptions';
|
|||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { createRepo, createWebhook, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
|
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';
|
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
|
||||||
|
|
||||||
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT;
|
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT;
|
||||||
@@ -99,8 +99,8 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const workspace = await provisionTheiaWorkspace(slug, projectId, giteaRepo);
|
const workspace = await provisionTheiaWorkspace(slug, projectId, giteaRepo);
|
||||||
theiaWorkspaceUrl = workspace.workspaceUrl;
|
theiaWorkspaceUrl = workspace.serviceUrl;
|
||||||
theiaAppUuid = workspace.appUuid;
|
theiaAppUuid = workspace.serviceName;
|
||||||
console.log(`[API] Theia workspace provisioned: ${theiaWorkspaceUrl}`);
|
console.log(`[API] Theia workspace provisioned: ${theiaWorkspaceUrl}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
theiaError = err instanceof Error ? err.message : String(err);
|
theiaError = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
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