mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 11:04:42 -07:00
feat(workspaces): modularize hub api, improve security, and optimize docker image
This commit is contained in:
@@ -5,121 +5,24 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import type { Request, Response, RequestHandler } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
import { workspaceRouter } from './routes/workspaceRoutes.js';
|
||||
|
||||
interface WorkspaceData {
|
||||
owner_id: string;
|
||||
name: string;
|
||||
instance_name: string;
|
||||
status: string;
|
||||
machine_type: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
export const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize Firestore
|
||||
const firestore = new Firestore();
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
app.get('/health', (_req, res) => {
|
||||
res.send({ status: 'ok' });
|
||||
});
|
||||
|
||||
/**
|
||||
* List all workspaces for the authenticated user
|
||||
*/
|
||||
const listWorkspaces: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const ownerId = 'default-user'; // TODO: Get from OAuth/IAP headers
|
||||
const snapshot = await firestore
|
||||
.collection('workspaces')
|
||||
.where('owner_id', '==', ownerId)
|
||||
.get();
|
||||
// Register Workspace Routes
|
||||
app.use('/workspaces', workspaceRouter);
|
||||
|
||||
const workspaces = snapshot.docs.map((doc) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = doc.data() as WorkspaceData;
|
||||
return {
|
||||
id: doc.id,
|
||||
...data,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(workspaces);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/workspaces', listWorkspaces);
|
||||
|
||||
/**
|
||||
* Create a new workspace (GCE VM)
|
||||
*/
|
||||
const createWorkspace: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const name = typeof body['name'] === 'string' ? body['name'] : 'unnamed';
|
||||
const machineType =
|
||||
typeof body['machineType'] === 'string'
|
||||
? body['machineType']
|
||||
: 'e2-standard-4';
|
||||
|
||||
const ownerId = 'default-user'; // TODO: Get from OAuth/IAP headers
|
||||
const workspaceId = uuidv4();
|
||||
const instanceName = `workspace-${workspaceId.slice(0, 8)}`;
|
||||
|
||||
const workspaceData: WorkspaceData = {
|
||||
owner_id: ownerId,
|
||||
name,
|
||||
instance_name: instanceName,
|
||||
status: 'PROVISIONING',
|
||||
machine_type: machineType,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await firestore
|
||||
.collection('workspaces')
|
||||
.doc(workspaceId)
|
||||
.set(workspaceData);
|
||||
|
||||
res.status(201).json({ id: workspaceId, ...workspaceData });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
app.post('/workspaces', createWorkspace);
|
||||
|
||||
/**
|
||||
* Delete a workspace
|
||||
*/
|
||||
const deleteWorkspace: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
res.status(400).json({ error: 'Workspace ID is required' });
|
||||
return;
|
||||
}
|
||||
await firestore.collection('workspaces').doc(id).delete();
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
app.delete('/workspaces/:id', deleteWorkspace);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Workspace Hub listening on port ${PORT}`);
|
||||
});
|
||||
// Only listen if not in test mode
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.listen(PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Workspace Hub listening on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { app } from '../index.js';
|
||||
|
||||
// Mock the services
|
||||
vi.mock('../services/workspaceService.js', () => ({
|
||||
WorkspaceService: vi.fn().mockImplementation(() => ({
|
||||
listWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
getWorkspace: vi.fn().mockResolvedValue(null),
|
||||
createWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
deleteWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../services/computeService.js', () => ({
|
||||
ComputeService: vi.fn().mockImplementation(() => ({
|
||||
createWorkspaceInstance: vi.fn().mockResolvedValue(undefined),
|
||||
deleteWorkspaceInstance: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Workspace Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /workspaces', () => {
|
||||
it('should return an empty list of workspaces', async () => {
|
||||
const response = await request(app).get('/workspaces');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /workspaces', () => {
|
||||
it('should create a new workspace', async () => {
|
||||
const payload = { name: 'test-workspace' };
|
||||
const response = await request(app).post('/workspaces').send(payload);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe('test-workspace');
|
||||
expect(response.body.owner_id).toBe('default-user');
|
||||
expect(response.body.status).toBe('PROVISIONING');
|
||||
});
|
||||
|
||||
it('should fail if name is missing', async () => {
|
||||
const response = await request(app).post('/workspaces').send({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /workspaces/:id', () => {
|
||||
it('should return 404 if workspace not found', async () => {
|
||||
const response = await request(app).delete('/workspaces/non-existent');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { z } from 'zod';
|
||||
import { WorkspaceService } from '../services/workspaceService.js';
|
||||
import { ComputeService } from '../services/computeService.js';
|
||||
|
||||
const router = Router();
|
||||
const workspaceService = new WorkspaceService();
|
||||
const computeService = new ComputeService();
|
||||
|
||||
const CreateWorkspaceSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
machineType: z.string().optional().default('e2-standard-4'),
|
||||
imageTag: z.string().optional().default('latest'),
|
||||
zone: z.string().optional().default('us-west1-a'),
|
||||
});
|
||||
|
||||
const DEFAULT_OWNER = 'default-user'; // TODO: Replace with IAP identity middleware
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const workspaces = await workspaceService.listWorkspaces(DEFAULT_OWNER);
|
||||
res.json(workspaces);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const validation = CreateWorkspaceSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error.format() });
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, machineType, imageTag, zone } = validation.data;
|
||||
const workspaceId = uuidv4();
|
||||
const instanceName = `workspace-${workspaceId.slice(0, 8)}`;
|
||||
|
||||
const workspaceData = {
|
||||
owner_id: DEFAULT_OWNER,
|
||||
name,
|
||||
instance_name: instanceName,
|
||||
status: 'PROVISIONING',
|
||||
machine_type: machineType,
|
||||
zone,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 1. Save to state store
|
||||
await workspaceService.createWorkspace(workspaceId, workspaceData);
|
||||
|
||||
// 2. Trigger GCE provisioning (Async)
|
||||
computeService
|
||||
.createWorkspaceInstance({
|
||||
instanceName,
|
||||
machineType,
|
||||
imageTag,
|
||||
zone,
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to provision GCE instance ${instanceName}:`, err);
|
||||
});
|
||||
|
||||
res.status(201).json({ id: workspaceId, ...workspaceData });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const workspace = await workspaceService.getWorkspace(id);
|
||||
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: 'Workspace not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY: Ownership Check
|
||||
if (workspace.owner_id !== DEFAULT_OWNER) {
|
||||
res.status(403).json({ error: 'Unauthorized to delete this workspace' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Delete GCE instance
|
||||
await computeService.deleteWorkspaceInstance(
|
||||
workspace.instance_name,
|
||||
workspace.zone,
|
||||
);
|
||||
|
||||
// 2. Delete from state store
|
||||
await workspaceService.deleteWorkspace(id);
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
export const workspaceRouter = router;
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { InstancesClient } from '@google-cloud/compute';
|
||||
|
||||
export interface ProvisionOptions {
|
||||
instanceName: string;
|
||||
machineType: string;
|
||||
imageTag: string;
|
||||
zone: string;
|
||||
}
|
||||
|
||||
export class ComputeService {
|
||||
private client: InstancesClient;
|
||||
|
||||
constructor() {
|
||||
this.client = new InstancesClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a new GCE VM with the Workspace Container
|
||||
*/
|
||||
async createWorkspaceInstance(options: ProvisionOptions): Promise<void> {
|
||||
// TODO: Implement actual instancesClient.insert call
|
||||
// For now, we just log and return
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[ComputeService] Mocking creation of ${options.instanceName} in ${options.zone}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a GCE VM
|
||||
*/
|
||||
async deleteWorkspaceInstance(
|
||||
instanceName: string,
|
||||
zone: string,
|
||||
): Promise<void> {
|
||||
// TODO: Implement actual instancesClient.delete call
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[ComputeService] Mocking deletion of ${instanceName} in ${zone}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
|
||||
export interface WorkspaceData {
|
||||
owner_id: string;
|
||||
name: string;
|
||||
instance_name: string;
|
||||
status: string;
|
||||
machine_type: string;
|
||||
zone: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceRecord extends WorkspaceData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class WorkspaceService {
|
||||
private firestore: Firestore;
|
||||
|
||||
constructor() {
|
||||
this.firestore = new Firestore();
|
||||
}
|
||||
|
||||
async listWorkspaces(ownerId: string): Promise<WorkspaceRecord[]> {
|
||||
const snapshot = await this.firestore
|
||||
.collection('workspaces')
|
||||
.where('owner_id', '==', ownerId)
|
||||
.get();
|
||||
|
||||
return snapshot.docs.map((doc) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = doc.data() as WorkspaceData;
|
||||
return {
|
||||
id: doc.id,
|
||||
...data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspace(id: string): Promise<WorkspaceRecord | null> {
|
||||
const doc = await this.firestore.collection('workspaces').doc(id).get();
|
||||
if (!doc.exists) return null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return { id: doc.id, ...(doc.data() as WorkspaceData) };
|
||||
}
|
||||
|
||||
async createWorkspace(id: string, data: WorkspaceData): Promise<void> {
|
||||
await this.firestore.collection('workspaces').doc(id).set(data);
|
||||
}
|
||||
|
||||
async deleteWorkspace(id: string): Promise<void> {
|
||||
await this.firestore.collection('workspaces').doc(id).delete();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user