feat(workspaces): modularize hub api, improve security, and optimize docker image

This commit is contained in:
mkorwel
2026-03-18 23:52:50 -07:00
parent 2ae8ffc16b
commit 14317a52a4
13 changed files with 885 additions and 142 deletions
+12 -109
View File
@@ -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();
}
}