Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
483 changes: 483 additions & 0 deletions docs/per_user_workspace_isolation_v1.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions workspace-manager/Dockerfile.control-plane
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM node:20-bookworm-slim
WORKDIR /app
COPY docker-control.js control-plane.js ./
USER node
HEALTHCHECK --interval=5s --timeout=2s --retries=12 CMD node -e "fetch('http://127.0.0.1:5192/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
ENTRYPOINT ["node", "control-plane.js"]
37 changes: 37 additions & 0 deletions workspace-manager/control-plane-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict';

const crypto = require('node:crypto');

const URL_BASE = process.env.CONTROL_PLANE_URL;
const SERVICE_ID = process.env.CONTROL_PLANE_SERVICE_ID || 'workspace-manager';
const SERVICE_SECRET = process.env.CONTROL_PLANE_SERVICE_SECRET;
const SERVICE_KEY_ID = process.env.CONTROL_PLANE_SERVICE_KEY_ID || 'active';

function clientRequest(operation, record, extra = {}) {
if (!URL_BASE || !SERVICE_SECRET) throw new Error('control-plane service authentication is not configured');
const payload = JSON.stringify({ record, ...extra });
const timestamp = String(Date.now());
const nonce = crypto.randomBytes(16).toString('hex');
const signature = crypto.createHmac('sha256', SERVICE_SECRET).update(`${timestamp}.${nonce}.${payload}`).digest('hex');
const correlationId = crypto.randomUUID();
return fetch(`${URL_BASE}/v1/workspaces/${encodeURIComponent(record.id)}/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-service-id': SERVICE_ID, 'x-service-key-id': SERVICE_KEY_ID, 'x-request-timestamp': timestamp, 'x-request-nonce': nonce, 'x-request-signature': signature, 'x-correlation-id': correlationId },
body: payload,
signal: AbortSignal.timeout(30000),
}).then(async response => ({ status: response.status, body: await response.json().catch(() => ({})) }));
}

class ControlPlaneClient {
imageArchitecture() { return clientRequest('image-architecture', { id: '00000000-0000-0000-0000-000000000000' }).then(result => result.body.architecture || null); }
inspectContainer(record) { return clientRequest('inspect', record); }
createVolume(record) { return clientRequest('create-volume', record).then(result => { if (result.status < 200 || result.status >= 300) throw new Error(result.body.error || 'workspace volume creation failed'); return result.body; }); }
createNetwork(record) { return clientRequest('create-network', record).then(result => { if (result.status < 200 || result.status >= 300) throw new Error(result.body.error || 'workspace network creation failed'); return result.body; }); }
createContainer(record, extra) { return clientRequest('create-container', record, extra).then(result => { if (result.status < 200 || result.status >= 300 || !result.body.Id) throw new Error(result.body.error || 'workspace container creation failed'); record.runtime_id = result.body.Id; record.runtime_name = `obws-${record.id}`; record.volume_name = `obws-volume-${record.id}`; record.network_name = `obws-network-${record.id}`; record.host_port = extra.hostPort; return result.body; }); }
start(record) { return clientRequest('start', record); }
stop(record) { return clientRequest('stop', record); }
update(record, allocation) { return clientRequest('update', record, { allocation }); }
remove(record, options = {}) { return clientRequest('remove', record, options); }
}

module.exports = { ControlPlaneClient };
67 changes: 67 additions & 0 deletions workspace-manager/control-plane.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use strict';

const http = require('node:http');
const crypto = require('node:crypto');
const { DockerControlPlane, IMAGE } = require('./docker-control');

const HOST = process.env.CONTROL_PLANE_HOST || '127.0.0.1';
const PORT = Number(process.env.CONTROL_PLANE_PORT || 5192);
const SERVICE_ID = process.env.CONTROL_PLANE_SERVICE_ID || 'workspace-manager';
const SERVICE_SECRET = process.env.CONTROL_PLANE_SERVICE_SECRET;
const SERVICE_KEY_ID = process.env.CONTROL_PLANE_SERVICE_KEY_ID || 'active';
const PREVIOUS_SERVICE_SECRET = process.env.CONTROL_PLANE_PREVIOUS_SERVICE_SECRET;
const PREVIOUS_SERVICE_KEY_ID = process.env.CONTROL_PLANE_PREVIOUS_SERVICE_KEY_ID || 'previous';
const AUDIT_PATH = process.env.CONTROL_PLANE_AUDIT_PATH || '/var/lib/omnibioai-workspace-control/audit.jsonl';
const MAX_CLOCK_SKEW_MS = 30_000;
const seenNonces = new Map();
let auditChain = Promise.resolve();
const control = new DockerControlPlane();

function json(res, status, value) { const data = JSON.stringify(value); res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) }); res.end(data); }
function fail(res, status, error) { return json(res, status, { error }); }
async function body(req) { let raw = ''; for await (const chunk of req) { raw += chunk; if (raw.length > 128 * 1024) throw new Error('request too large'); } return raw ? JSON.parse(raw) : {}; }
function validWorkspace(value) { return value && /^[0-9a-f-]{36}$/.test(value.id) && Number.isInteger(Number(value.owner_user_id)) && Number.isInteger(Number(value.organization_id)); }
function authenticate(req, raw) {
if ((!SERVICE_SECRET && !PREVIOUS_SERVICE_SECRET) || req.headers['x-service-id'] !== SERVICE_ID) return false;
const timestamp = Number(req.headers['x-request-timestamp']); const nonce = req.headers['x-request-nonce']; const signature = req.headers['x-request-signature'];
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > MAX_CLOCK_SKEW_MS || !/^[a-f0-9]{32}$/.test(nonce) || !/^[a-f0-9]{64}$/.test(signature)) return false;
if (seenNonces.has(nonce)) return false;
const keyId = req.headers['x-service-key-id']; const secret = keyId === SERVICE_KEY_ID ? SERVICE_SECRET : keyId === PREVIOUS_SERVICE_KEY_ID ? PREVIOUS_SERVICE_SECRET : null;
if (!secret) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${nonce}.${raw}`).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) return false;
seenNonces.set(nonce, Date.now() + MAX_CLOCK_SKEW_MS); return true;
}
function audit(event) {
const line = `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`;
auditChain = auditChain.then(async () => { const fs = require('node:fs'); await fs.promises.mkdir(require('node:path').dirname(AUDIT_PATH), { recursive: true, mode: 0o700 }); await fs.promises.appendFile(AUDIT_PATH, line, { mode: 0o600 }); });
return auditChain;
}
setInterval(() => { const now = Date.now(); for (const [nonce, expiry] of seenNonces) if (expiry < now) seenNonces.delete(nonce); }, MAX_CLOCK_SKEW_MS).unref();

const operations = {
'image-architecture': async () => ({ architecture: (await control.imageArchitecture()) || null }),
inspect: async ({ record }) => control.inspectContainer(record),
'create-volume': async ({ record }) => control.createVolume(record),
'create-network': async ({ record }) => control.createNetwork(record),
'create-container': async ({ record, token, hostPort }) => { if (!Number.isInteger(hostPort) || hostPort < 1024 || hostPort > 65535 || typeof token !== 'string' || token.length < 32) throw new Error('invalid runtime parameters'); return control.createContainer(record, { token, hostPort }); },
start: async ({ record }) => control.start(record),
stop: async ({ record }) => control.stop(record),
update: async ({ record, allocation }) => control.update(record, allocation),
remove: async ({ record, purgeData }) => control.remove(record, { purgeData: purgeData === true }),
};

const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/healthz') return json(res, 200, { ok: true, service: SERVICE_ID });
if (req.method !== 'POST') return fail(res, 404, 'not found');
const match = /^\/v1\/workspaces\/([0-9a-f-]{36})\/([a-z-]+)$/.exec(req.url);
if (!match || !operations[match[2]]) return fail(res, 404, 'not found');
let raw; try { raw = await (async () => { let value = ''; for await (const chunk of req) { value += chunk; if (value.length > 128 * 1024) throw new Error('request too large'); } return value; })(); } catch { return fail(res, 400, 'invalid request'); }
if (!authenticate(req, raw)) { const event = { actor_service: req.headers['x-service-id'] || 'unknown', operation: match[2], correlation_id: req.headers['x-correlation-id'] || null, outcome: 'denied', reason: 'service authentication required' }; console.log(JSON.stringify(event)); audit(event).catch(() => {}); return fail(res, 401, 'service authentication required'); }
let input; try { input = JSON.parse(raw); } catch { return fail(res, 400, 'invalid JSON'); }
if (match[2] !== 'image-architecture' && (input.record?.id !== match[1] || !validWorkspace(input.record))) return fail(res, 403, 'invalid managed workspace');
try { const event = { actor_service: SERVICE_ID, owner_user_id: input.record?.owner_user_id || null, organization_id: input.record?.organization_id || null, workspace_id: input.record?.id || null, operation: match[2], correlation_id: req.headers['x-correlation-id'] || null, outcome: 'success' }; const result = await operations[match[2]](input); console.log(JSON.stringify(event)); audit(event).catch(() => {}); return json(res, result?.status ?? 200, result?.body ?? result); } catch (error) { const event = { actor_service: SERVICE_ID, owner_user_id: input.record?.owner_user_id || null, organization_id: input.record?.organization_id || null, workspace_id: input.record?.id || null, operation: match[2], correlation_id: req.headers['x-correlation-id'] || null, outcome: 'failure', reason: error.message === 'managed Docker resource ownership mismatch' ? error.message : 'control-plane operation failed' }; console.log(JSON.stringify(event)); audit(event).catch(() => {}); return fail(res, 409, event.reason); }
});

if (require.main === module) server.listen(PORT, HOST, () => console.log(`control plane listening on ${HOST}:${PORT}`));
module.exports = { server, authenticate, operations, IMAGE };
42 changes: 42 additions & 0 deletions workspace-manager/docker-compose.control-plane.test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
services:
workspace-control-plane:
build:
context: .
dockerfile: Dockerfile.control-plane
command: ["node", "control-plane.js"]
user: "0:0"
environment:
CONTROL_PLANE_HOST: 0.0.0.0
CONTROL_PLANE_PORT: 5192
CONTROL_PLANE_SERVICE_ID: workspace-manager
CONTROL_PLANE_SERVICE_KEY_ID: active
CONTROL_PLANE_SERVICE_SECRET: ${CONTROL_PLANE_SERVICE_SECRET:?set a disposable secret}
CONTROL_PLANE_AUDIT_PATH: /state/audit.jsonl
DOCKER_SOCKET_PATH: /var/run/docker.sock
networks: [workspace-control-plane]
expose: ["5192"]
group_add: ["${DOCKER_SOCKET_GID:-0}"]
read_only: true
tmpfs: [/tmp]
cpus: 1
mem_limit: 512m
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5192/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 5s
timeout: 2s
retries: 12
volumes:
- type: volume
source: workspace-control-plane-state
target: /state
- type: bind
source: ${DOCKER_SOCKET_PATH:?set a host Docker socket path}
target: /var/run/docker.sock
read_only: true

networks:
workspace-control-plane:
internal: true

volumes:
workspace-control-plane-state:
99 changes: 99 additions & 0 deletions workspace-manager/docker-control.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use strict';

const http = require('node:http');

const SOCKET_PATH = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock';
const IMAGE = 'ghcr.io/omnibioai/omnibioai-jupyter:1.0';
const UUID = /^[0-9a-f-]{36}$/;

function names(workspace) {
if (!workspace || !UUID.test(workspace.id)) throw new Error('invalid managed workspace id');
return { container: `obws-${workspace.id}`, volume: `obws-volume-${workspace.id}`, network: `obws-network-${workspace.id}` };
}

function labels(workspace) {
return {
'com.omnibioai.managed': 'workspace-manager-v1',
'com.omnibioai.workspace.id': workspace.id,
'com.omnibioai.workspace.owner_user_id': String(workspace.owner_user_id),
'com.omnibioai.workspace.organization_id': String(workspace.organization_id),
};
}

function request(method, endpoint, requestBody) {
return new Promise((resolve, reject) => {
const encoded = requestBody === undefined ? undefined : JSON.stringify(requestBody);
const req = http.request({ socketPath: SOCKET_PATH, path: endpoint, method,
headers: encoded ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(encoded) } : {} }, res => {
let raw = ''; res.setEncoding('utf8'); res.on('data', chunk => { raw += chunk; });
res.on('end', () => { let parsed; try { parsed = raw ? JSON.parse(raw) : {}; } catch { parsed = raw; } resolve({ status: res.statusCode, body: parsed }); });
});
req.on('error', reject); req.end(encoded);
});
}

function assertManaged(record, resource, expectedName) {
const actualName = resource.Name?.replace(/^\//, '') || resource.Name;
if (actualName !== expectedName) throw new Error('managed Docker resource name mismatch');
const actual = resource.Config?.Labels || resource.Labels || {};
const expected = labels(record);
for (const key of Object.keys(expected)) if (actual[key] !== expected[key]) throw new Error('managed Docker resource ownership mismatch');
}

class DockerControlPlane {
async imageArchitecture() {
const result = await request('GET', `/images/${encodeURIComponent(IMAGE)}/json`);
return result.status === 200 ? result.body.Architecture : null;
}

async inspectContainer(record) {
const result = await request('GET', `/containers/${encodeURIComponent(record.runtime_id)}/json`);
if (result.status === 404) return result;
if (result.status !== 200 || !result.body || typeof result.body !== 'object') throw new Error('managed workspace container inspection failed');
assertManaged(record, result.body, names(record).container);
return result;
}

async createVolume(record) {
const result = await request('POST', '/volumes/create', { Name: names(record).volume, Driver: 'local', Labels: labels(record) });
if (result.status < 200 || result.status >= 300) throw new Error('workspace volume creation failed');
const inspected = await request('GET', `/volumes/${encodeURIComponent(names(record).volume)}`);
if (inspected.status !== 200) throw new Error('workspace volume inspection failed');
assertManaged(record, inspected.body, names(record).volume); return inspected.body;
}

async createNetwork(record) {
const result = await request('POST', '/networks/create', { Name: names(record).network, Driver: 'bridge', Labels: labels(record) });
if (result.status < 200 || result.status >= 300) throw new Error('workspace network creation failed');
const inspected = await request('GET', `/networks/${encodeURIComponent(names(record).network)}`);
if (inspected.status !== 200) throw new Error('workspace network inspection failed');
assertManaged(record, inspected.body, names(record).network); return inspected.body;
}

async createContainer(record, { token, hostPort }) {
const n = names(record); const architecture = await this.imageArchitecture();
if (architecture !== 'arm64') throw new Error('workspace image is not available for arm64');
const result = await request('POST', `/containers/create?name=${n.container}`, {
Image: IMAGE, User: '1000:100', Cmd: ['start-notebook.py', `--NotebookApp.token=${token}`, `--ServerApp.token=${token}`, '--ip=0.0.0.0', '--no-browser'],
Env: ['JUPYTER_ENABLE_LAB=yes', `JUPYTER_TOKEN=${token}`], ExposedPorts: { '8888/tcp': {} }, Labels: labels(record),
HostConfig: { NanoCpus: Math.round(record.allocation.cpu * 1e9), Memory: record.allocation.memory_bytes, DeviceRequests: [],
NetworkMode: n.network, PortBindings: { '8888/tcp': [{ HostIp: '127.0.0.1', HostPort: String(hostPort) }] },
SecurityOpt: ['no-new-privileges:true'], Privileged: false, Binds: [`${n.volume}:/home/jovyan/work:rw`] },
});
if (result.status < 200 || result.status >= 300 || !result.body.Id) throw new Error('workspace container creation failed');
record.runtime_id = result.body.Id; record.runtime_name = n.container; record.volume_name = n.volume; record.network_name = n.network; record.host_port = hostPort;
const inspected = await this.inspectContainer(record); assertManaged(record, inspected.body, n.container); return result.body;
}

async start(record) { const result = await this.inspectContainer(record); if (result.status === 404) throw new Error('managed workspace container not found'); return request('POST', `/containers/${encodeURIComponent(record.runtime_id)}/start`); }
async stop(record) { const result = await this.inspectContainer(record); if (result.status === 404) return result; return request('POST', `/containers/${encodeURIComponent(record.runtime_id)}/stop`, { t: 5 }); }
async update(record, allocation) { const result = await this.inspectContainer(record); if (result.status === 404) throw new Error('managed workspace container not found'); return request('POST', `/containers/${encodeURIComponent(record.runtime_id)}/update`, { NanoCpus: Math.round(allocation.cpu * 1e9), Memory: allocation.memory_bytes, DeviceRequests: [] }); }

async remove(record, { purgeData = false } = {}) {
if (record.runtime_id) { const inspected = await this.inspectContainer(record); if (inspected.status !== 404) await request('DELETE', `/containers/${encodeURIComponent(record.runtime_id)}?force=true`); }
const n = names(record); const volume = await request('GET', `/volumes/${encodeURIComponent(n.volume)}`); if (purgeData && volume.status === 200) { assertManaged(record, volume.body, n.volume); await request('DELETE', `/volumes/${encodeURIComponent(n.volume)}`); }
const network = await request('GET', `/networks/${encodeURIComponent(n.network)}`); if (network.status === 200) { assertManaged(record, network.body, n.network); await request('DELETE', `/networks/${encodeURIComponent(n.network)}`); }
}
}

module.exports = { DockerControlPlane, IMAGE, labels, names, request };
10 changes: 10 additions & 0 deletions workspace-manager/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "omnibioai-workspace-manager-local",
"private": true,
"version": "0.1.0",
"description": "Disposable local-Docker per-user workspace manager vertical slice",
"scripts": {
"test": "node --test test/workspace-manager.test.js test/control-plane.test.js",
"test:transport": "node --test test/transport-container.test.js"
}
}
Loading
Loading