From 931dedb79b83d408dab1a6d8f831695b5d4d65d5 Mon Sep 17 00:00:00 2001 From: Antonio De Lucreziis Date: Fri, 20 Sep 2024 01:36:54 +0200 Subject: [PATCH] file support --- .gitignore | 2 + package-lock.json | 3 ++ package.json | 1 + server/docker.js | 89 ++++++++++++++++++++++++++++++++++++++++ server/server.js | 100 ++++++++++++++++++++++++++++++++++----------- server/utils.js | 40 ++++++++++++++++++ src/client/term.js | 29 ++++++++++--- 7 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 server/docker.js create mode 100644 server/utils.js diff --git a/.gitignore b/.gitignore index 16d54bb..da01e83 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ pnpm-debug.log* # jetbrains setting folder .idea/ + +*.local* diff --git a/package-lock.json b/package-lock.json index 96a6381..eafefad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "express": "^4.21.0", "node-pty": "^1.0.0", "preact": "^10.24.0", + "signal-exit": "^4.1.0", "ws": "^8.18.0", "xterm-theme": "^1.1.0" }, @@ -5855,6 +5856,8 @@ }, "node_modules/signal-exit": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", "engines": { "node": ">=14" diff --git a/package.json b/package.json index a889385..addb029 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "express": "^4.21.0", "node-pty": "^1.0.0", "preact": "^10.24.0", + "signal-exit": "^4.1.0", "ws": "^8.18.0", "xterm-theme": "^1.1.0" }, diff --git a/server/docker.js b/server/docker.js new file mode 100644 index 0000000..59df4b7 --- /dev/null +++ b/server/docker.js @@ -0,0 +1,89 @@ +import * as pty from 'node-pty' +import { getContainerPath, runCommand } from './utils.js' + +import fs from 'fs/promises' + +import { randomUUID } from 'crypto' + +const stat = path => + new Promise(resolve => + fs + .access(path) + .then(() => resolve(true)) + .catch(() => resolve(false)) + ) + +const containerIds = new Set() + +await cleanContainers() + +async function storeContainers() { + await fs.writeFile('containers-db.local.json', JSON.stringify([...containerIds])) +} + +async function cleanContainers() { + if (!(await stat('containers-db.local.json'))) { + storeContainers() + } + + const raw = JSON.parse(await fs.readFile('containers-db.local.json', 'utf8')) + await Promise.all(raw.map(id => destroyContainer(id))) +} + +async function createContainer(tag) { + const id = randomUUID() + + await fs.mkdir(getContainerPath('phc', id), { recursive: true }) + + await runCommand('docker', [ + 'run', + '-d', + '--name', + id, + '--volume', + `${getContainerPath('phc', id)}:/project`, + tag, + 'sleep', + 'infinity', + ]) + + containerIds.add(id) + await storeContainers() + + return id +} + +export async function createContainerPty( + tag, + options = { + onExit, + onData, + + shellCommand: ['/bin/sh'], + } +) { + const shellCommand = options.shellCommand ?? ['/bin/sh'] + const { onData, onExit } = options + + const id = await createContainer(tag) + + const container = pty.spawn('docker', ['exec', '-it', id, ...shellCommand]) + + container.onExit(async e => { + onExit?.(e) + await destroyContainer(id) + }) + + container.onData(data => { + onData?.(data) + }) + + return { id, pty: container } +} + +async function destroyContainer(id) { + await runCommand('docker', ['rm', '-f', id]) + + containerIds.delete(id) + await storeContainers() +} diff --git a/server/server.js b/server/server.js index e582570..dfbde6a 100644 --- a/server/server.js +++ b/server/server.js @@ -1,18 +1,56 @@ import express from 'express' import { WebSocketServer } from 'ws' - -import * as pty from 'node-pty' +import { createContainerPty } from './docker.js' +import { runCommand } from './utils.js' const app = express() app.use(express.json()) -app.get('/api/foo', c => c.json({ foo: 42 })) +app.get('/api/status', c => c.json(42)) -app.get('/api/docker/:tag', (req, res) => { - wss.handleUpgrade(req, res.socket, req.headers, ws => { - wss.emit('connection', ws, req) - }) +app.get('/api/container/:uuid([a-zA-Z0-9]+)/exec/ls', async (req, res) => { + const uuid = req.params.uuid + + try { + const files = await runCommand('/bin/ls', ['exec', uuid, '/bin/ls']) + + res.json(files.trim().split('\n')) + } catch (e) { + res.json({ error: e.toString() }) + } +}) + +app.get('/api/container/:uuid([a-zA-Z0-9]+)/fs/*', async (req, res) => { + const uuid = req.params.uuid + const path = req.params[0] + + console.log('Getting', path) + + try { + const content = await runCommand('docker', ['exec', uuid, '/bin/cat', '/' + path]) + + res.json(content) + } catch (e) { + res.json({ error: e.toString() }) + } +}) + +app.put('/api/container/:uuid([a-zA-Z0-9]+)/fs/*', async (req, res) => { + const path = req.params[0] + + const content = req.body + console.log(content) + + try { + await runCommand('docker', ['exec', uuid, '/bin/sh', '-c', `cat - > "${path}"`], { + stdin: content, + }) + + res.json('ok') + } catch (e) { + res.json({ error: e.toString() }) + } }) const server = app.listen(5432, () => { @@ -21,30 +59,44 @@ const server = app.listen(5432, () => { const wss = new WebSocketServer({ server }) -wss.on('connection', (ws, req) => { - const url = new URL(req.url, `http://${req.headers.host}`) - const tag = url.pathname.split('/')[3] +wss.on('connection', async (ws, req) => { + const tag = req.url.split('/').at(-2) - console.log(`Client connected, initializing "${tag}"...`) + console.log(`Client connected to "${tag}"`) - const docker = pty.spawn('docker', ['run', '--init', '-it', '--rm', tag, '/bin/sh']) - - docker.onExit(e => { - ws.send(JSON.stringify(e)) - ws.close() - console.log('Client requested stop') + const container = await createContainerPty(tag, { + onData(data) { + ws.send( + JSON.stringify({ + type: 'pty', + data, + }) + ) + }, + onExit(e) { + ws.send( + JSON.stringify({ + type: 'destroyed', + ...e, + }) + ) + ws.close() + }, }) - docker.onData(data => { - ws.send(data) - }) + ws.send( + JSON.stringify({ + type: 'created', + id: container.id, + }) + ) - ws.on('message', message => { - docker.write(message) + ws.on('message', data => { + container.pty.write(data) }) ws.on('close', () => { - docker.kill() - console.log('Client disconnected') + container.pty.kill() + console.log(`Client disconnected`) }) }) diff --git a/server/utils.js b/server/utils.js new file mode 100644 index 0000000..5247e4d --- /dev/null +++ b/server/utils.js @@ -0,0 +1,40 @@ +import { spawn } from 'child_process' + +export const CONTAINERS_ROOT = '/tmp/phc-run/containers' + +export const getContainerPath = (ns, uuid) => `${CONTAINERS_ROOT}/${ns}/${uuid}` + +export function runCommand(command, args, options = { stdin: null, capture: ['stdout', 'stderr'] }) { + console.log(`Running: ${command} ${JSON.stringify(args)}`) + + return new Promise((resolve, reject) => { + const child = spawn(command, args) + + let output = '' + + if (options.capture.includes('stdout')) { + child.stdout.on('data', data => { + output += data.toString() + }) + } + + if (options.capture.includes('stderr')) { + child.stderr.on('data', data => { + output += data.toString() + }) + } + + if (options.stdin) { + child.stdin.write(options.stdin) + } + + child.on('close', code => { + if (code === 0) { + resolve(output) + } else { + reject(new Error(`Command failed with code ${code}: ${output}`)) + console.error('Command error:', output) + } + }) + }) +} diff --git a/src/client/term.js b/src/client/term.js index 535d04d..ddc3f43 100644 --- a/src/client/term.js +++ b/src/client/term.js @@ -16,20 +16,37 @@ term.open(document.getElementById('terminal')) fitAddon.fit() -const socket = new WebSocket(`ws://${location.host}/api/docker/alpine:latest`) +const ws = new WebSocket(`ws://${location.host}/api/docker/alpine:latest/pty`) term.onData(data => { - socket.send(data) + ws.send(data) }) -socket.addEventListener('message', event => { - term.write(event.data) +let id + +ws.addEventListener('message', e => { + const event = JSON.parse(e.data) + + if (event.type === 'pty') { + term.write(event.data) + } else if (event.type === 'created') { + id = event.id + } else { + console.log(event) + } }) -socket.addEventListener('open', () => { +ws.addEventListener('open', () => { term.write('Connected to backend container\r\n') }) -socket.addEventListener('close', () => { +ws.addEventListener('close', () => { term.write('\r\nDisconnected from backend container') }) + +setInterval(async () => { + const req = await fetch(`/api/container/${id}/ls`) + const files = await req.json() + + console.log(files) +}, 1000)