feat(workspaces): implement workspace container image and initial hub api

This commit is contained in:
mkorwel
2026-03-18 23:40:05 -07:00
parent ecb364c495
commit 2ae8ffc16b
5 changed files with 233 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
# Copyright 2026 Google LLC
# SPDX-License-Identifier: Apache-2.0
FROM node:20-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
rsync \
vim \
tmux \
procps \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install gh -y
# Install Rust (to install shpool)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
# Install shpool
RUN cargo install shpool \
&& mv /root/.cargo/bin/shpool /usr/local/bin/shpool
# Install global dev tools
RUN npm install -g tsx eslint vitest typescript prettier @google/gemini-cli@nightly
# Create workspace directory
WORKDIR /home/node/workspace
RUN chown -R node:node /home/node/workspace
# Entrypoint script
COPY --chown=node:node entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER node
# Environment variables
ENV GEMINI_CLI_WORKSPACE=1
ENV PATH=$PATH:/usr/local/bin
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/bin/bash"]

View File

@@ -0,0 +1,21 @@
#!/bin/bash
# Copyright 2026 Google LLC
# SPDX-License-Identifier: Apache-2.0
# Ensure GH_TOKEN is set from memory-only mount if available
if [ -f /dev/shm/.gh_token ]; then
export GH_TOKEN=$(cat /dev/shm/.gh_token)
echo "GitHub token injected from memory."
fi
# Start shpool daemon in the background
/usr/local/bin/shpool daemon &
# Restore ~/.gemini settings if they are provided in a mount or PD
# (Assuming PD is mounted at /home/node/persistent_home for now)
if [ -d /home/node/persistent_home/.gemini ]; then
rsync -a /home/node/persistent_home/.gemini/ /home/node/.gemini/
fi
# Execute the CMD passed to docker
exec "$@"

View File

@@ -0,0 +1,27 @@
{
"name": "@google/gemini-cli-workspace-manager",
"version": "0.1.0",
"description": "Workspace Hub for Gemini CLI",
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"test": "vitest run",
"build": "tsc"
},
"dependencies": {
"@google-cloud/compute": "^4.10.0",
"@google-cloud/firestore": "^7.11.0",
"express": "^4.21.2",
"uuid": "^13.0.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"typescript": "^5.7.0",
"vitest": "^3.2.0"
}
}

View File

@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import express from 'express';
import type { Request, Response, RequestHandler } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Firestore } from '@google-cloud/firestore';
interface WorkspaceData {
owner_id: string;
name: string;
instance_name: string;
status: string;
machine_type: string;
created_at: string;
}
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) => {
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();
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}`);
});

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}