diff --git a/.github/workflows/buid-docker.yml b/.github/workflows/buid-docker.yml index 5aff918..5d13818 100644 --- a/.github/workflows/buid-docker.yml +++ b/.github/workflows/buid-docker.yml @@ -10,7 +10,7 @@ env: IMAGE_NAME: cprtsoftware/container-launcher jobs: - gstreamer-build: + build: runs-on: ubuntu-latest steps: - name: Checkout @@ -20,13 +20,25 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Docker login + if: github.event_name == 'push' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build & push gstreamer - id: gstreamer_build + - name: Build (no push on PR) + if: github.event_name == 'pull_request' + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64, linux/arm64 + push: false + provenance: false + + - name: Build & push (only on merge to main) + if: github.event_name == 'push' + id: build uses: docker/build-push-action@v6 with: context: . @@ -38,4 +50,4 @@ jobs: ${{ env.IMAGE_NAME }}:${{ github.sha }} cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:cache cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:cache,mode=max - provenance: false \ No newline at end of file + provenance: false diff --git a/src/config/launchOptions.ts b/src/config/launchOptions.ts index 1544ab1..54bce80 100644 --- a/src/config/launchOptions.ts +++ b/src/config/launchOptions.ts @@ -4,7 +4,7 @@ export interface OptionConfig { command?: string[]; } -const image = "cprtsoftware/cprt_rover_24:jetson"; +const image = "cprtsoftware/rover:arm64"; export const launchOptions: Record = { core: { @@ -23,16 +23,8 @@ export const launchOptions: Record = { image: image, command: ["ros2", "launch", "bringup", "arm.launch.py"], }, - localization: { + joy: { image: image, - command: ["ros2", "launch", "bringup", "localization.launch.py"], - }, - navigation: { - image: image, - command: ["ros2", "launch", "bringup", "navigation.launch.py"], - }, - science: { - image: image, - command: ["ros2", "launch", "bringup", "science.launch.py"], + command: ["ros2", "run", "joy", "joy_node"], }, }; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 80d9c10..31415d1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,5 @@ - import express, { Request, Response } from 'express'; -import http, { get } from 'http'; +import http from 'http'; import { WebSocketServer, WebSocket } from 'ws'; import Docker, { Container } from 'dockerode'; import url from 'url'; @@ -9,58 +8,23 @@ import type { IncomingMessage } from 'http'; import { launchOptions, OptionConfig } from './config/launchOptions'; import cors from 'cors'; - -type WebSocketWithStream = WebSocket & { stream?: Readable }; +type LogClients = { + stream: Readable, + clients: Set +}; +const logStreams = new Map(); const sharedConfig: Partial = { Tty: true, HostConfig: { Privileged: true, NetworkMode: 'host', - AutoRemove: true, + AutoRemove: false, + Runtime: 'nvidia', + IpcMode: 'host' } }; -/** - * Retrieves a Docker container by its name. - * @param name - The name of the container to retrieve. - * @returns A promise that resolves to the Docker container or null if not found. - */ -async function getContainerByName(name: string): Promise { - try { - const docker = new Docker({ socketPath: '/var/run/docker.sock' }); - const containers = await docker.listContainers({ all: true }); - const containerInfo = containers.find(c => c.Names.includes(`/${name}`)); - if (containerInfo) { - return docker.getContainer(containerInfo.Id); - } - return null; - } catch (err) { - console.error('Error retrieving container by name:', err); - return null; - } -} - -async function getContainerById(id: string): Promise { - try { - const docker = new Docker({ socketPath: '/var/run/docker.sock' }); - return docker.getContainer(id); - } catch (err) { - console.error('Error retrieving container by ID:', err); - return null; - } -} - -async function getContainerId(container: Container): Promise { - try { - const info = await container.inspect(); - return info.Id; - } catch (err) { - console.error('Error retrieving container ID:', err); - return null; - } -} - const docker = new Docker({ socketPath: '/var/run/docker.sock' }); const app = express(); app.use(cors({ @@ -72,23 +36,44 @@ app.use(express.json()); const server = http.createServer(app); const wss = new WebSocketServer({ server }); - const clients = new Map(); +const sseClients: Response[] = []; + +async function getContainerByName(name: string): Promise { + try { + const containers = await docker.listContainers({ all: true }); + const containerInfo = containers.find(c => c.Names.includes(`/${name}`)); + if (containerInfo) { + return docker.getContainer(containerInfo.Id); + } + return null; + } catch (err) { + console.error('Error retrieving container by name:', err); + return null; + } +} -// SSE clients per option key -const sseClientsById: Record = {}; -Object.keys(launchOptions).forEach(opt => sseClientsById[opt] = []); +async function getContainerId(container: Container): Promise { + try { + const info = await container.inspect(); + return info.Id; + } catch (err) { + console.error('Error retrieving container ID:', err); + return null; + } +} -// GET /options - return available launch option keys app.get('/options', async (req: Request, res: Response) => { const options = await Promise.all(Object.entries(launchOptions).map(async ([key, config]) => { const container = await getContainerByName(`${key}-instance`); - const id = container ? await getContainerId(container) : null; - const status = container ? 'running' : 'stopped'; - const startTime = container ? (await container.inspect()).State.StartedAt : null; + const info = container ? await container.inspect() : null; + const isRunning = info?.State?.Running ?? false; + const status = isRunning ? 'running' : 'stopped'; + const startTime = info?.State?.StartedAt ?? null; + const id = info?.Id ?? null; const protocol = req.protocol === 'https' ? 'wss' : 'ws'; const host = req.headers.host; - const logsWsUrl = container ? `${protocol}://${host}/logs?id=${id}` : null; + const logsWsUrl = isRunning ? `${protocol}://${host}/logs?id=${id}` : null; const cmd = config.command ? config.command.join(' ') : ''; return { @@ -106,61 +91,103 @@ app.get('/options', async (req: Request, res: Response) => { }); // GET /events/:id - SSE stream for a specific option -app.get('/events/:id', (req: Request, res: Response) => { - const id = req.params.id; - if (!sseClientsById[id]) { - return res.status(400).json({ error: 'Invalid option ID' }); - } - +app.get('/events', (req: Request, res: Response) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); - sseClientsById[id].push(res); + sseClients.push(res); req.on('close', () => { - const index = sseClientsById[id].indexOf(res); - if (index !== -1) sseClientsById[id].splice(index, 1); + const index = sseClients.indexOf(res); + if (index !== -1) sseClients.splice(index, 1); }); }); -wss.on('connection', (ws: WebSocketWithStream, req: IncomingMessage) => { +wss.on('connection', async (ws: WebSocket, req: IncomingMessage) => { const parsed = url.parse(req.url || '', true); if (!parsed.pathname || parsed.pathname !== '/logs') { ws.close(1008, 'Invalid WebSocket path'); return; } - const containerId = parsed.query.id as string | undefined; + const containerId = parsed.query.id as string | undefined; if (!containerId) { ws.send('Missing container ID'); ws.close(); return; } - const container = docker.getContainer(containerId); - container.logs({ follow: true, stdout: true, stderr: true }, (err, stream) => { - if (err || !stream) { - ws.close(); - return; - } - - const nodeStream = stream as Readable; + let logClients = logStreams.get(containerId); + if (!logClients) { - ws.stream = nodeStream; - clients.set(ws, nodeStream); + try { + const container = docker.getContainer(containerId); + const info = await container.inspect(); + if (!info.State.Running) { + ws.send(`Container ${containerId} is not running. Logs may be incomplete.`); + } - nodeStream.on('data', chunk => { - if (ws.readyState === ws.OPEN) { - ws.send(chunk.toString()); + container.logs({ follow: true, stdout: true, stderr: true }, (err, stream) => { + if (err || !stream) { + ws.send(`Error retrieving logs: ${err?.message || 'Unknown error'}`); + ws.close(); + return; } - }); - ws.on('close', () => { - nodeStream.destroy(); - clients.delete(ws); - }); + const nodeStream = stream as Readable; + const clientsSet = new Set(); + clientsSet.add(ws); + + logStreams.set(containerId, { stream: nodeStream, clients: clientsSet }); + + nodeStream.on('data', chunk => { + for (const client of clientsSet) { + if (client.readyState === client.OPEN) { + client.send(chunk.toString()); + } + } + }); + + nodeStream.on('error', err => { + console.error('Log stream error:', err); + for (const client of clientsSet) { + if (client.readyState === client.OPEN) { + client.send(`Log stream error: ${err.message}`); + client.close(); + } + } + logStreams.delete(containerId); + }); + + nodeStream.on('end', () => { + for (const client of clientsSet) { + if (client.readyState === client.OPEN) { + client.close(); + } + } + logStreams.delete(containerId); + }); + }); + + } catch (err) { + ws.send(`Container not found`); + ws.close(); + return; + } + } else { + logClients.clients.add(ws); + } + + ws.on('close', () => { + const logClients = logStreams.get(containerId); + if (!logClients) return; + logClients.clients.delete(ws); + if (logClients.clients.size === 0) { + logClients.stream.destroy(); + logStreams.delete(containerId); + } }); }); @@ -173,35 +200,81 @@ app.post('/start/:option', async (req, res) => { return res.status(400).json({ error: 'Invalid option' }); } - if (await getContainerByName(containerName)) { - return res.status(409).json({ error: 'Container already running for this option' }); - } - try { + const existing = await getContainerByName(containerName); + if (existing) { + const info = await existing.inspect(); + + if (info.State.Running) { + return res.status(409).json({ error: 'Container already running for this option' }); + } + + console.log(`Removing existing container: ${containerName}`); + await existing.remove(); + } const containerConfig: Docker.ContainerCreateOptions = { ...sharedConfig, Image: optionConfig.image, Cmd: optionConfig.command, - name: `${option}-instance`, + name: containerName, }; const container = await docker.createContainer(containerConfig); await container.start(); res.json({ status: 'started', id: container.id }); + + // Alert all other clients: + const message = { + event: 'starting', + container: option, + timestamp: new Date().toISOString() + }; + const data = `data: ${JSON.stringify(message)}\n\n`; + sseClients.forEach(client => client.write(data)); + } catch (err: any) { + console.log("Error starting container:", err.message); res.status(500).json({ error: err.message }); } }); -app.post('/stop/:id', async (req: Request, res: Response) => { +app.post('/stop/:option', async (req: Request, res: Response) => { try { - const container = await getContainerById(req.params.id); - if (!container) { + const option = req.params.option; + const container = await getContainerByName(`${option}-instance`); + if (!option || !container) { return res.status(404).json({ error: 'Container not found' }); } - await container.stop(); - res.json({ status: 'stopped' }); + + const message = { + event: 'stopping', + container: option, + timestamp: new Date().toISOString() + }; + console.log(`Stopping container: ${option}`); + + const data = `data: ${JSON.stringify(message)}\n\n`; + sseClients.forEach(client => client.write(data)); + + // Send SIGINT to trigger ros2 graceful shutdown + await container.kill({ signal: 'SIGINT' }); + + // After 10s, check if still running → force kill + setTimeout(async () => { + try { + const inspect = await container.inspect(); + if (inspect.State.Running) { + console.log(`Container ${option} still running, sending SIGKILL`); + await container.kill({ signal: 'SIGKILL' }); + } + } catch (e: any) { + console.log(`Container ${option} may already be stopped:`, e.message); + } + }, 10000); + + res.json({ status: 'SIGINT sent, will SIGKILL after 10s if needed' }); } catch (err: any) { + console.log("Error stopping container:", err.message); res.status(500).json({ error: err.message }); } }); @@ -209,21 +282,33 @@ app.post('/stop/:id', async (req: Request, res: Response) => { (async () => { const eventStream = await docker.getEvents(); eventStream.on('data', buffer => { - const event = JSON.parse(buffer.toString()); - if (event.status === 'die') { - const message = { - event: 'exit', - container: event.Actor.Attributes.name, - code: event.Actor.Attributes.exitCode, - timestamp: new Date(event.time * 1000).toISOString() - }; - const containerName = event.Actor?.Attributes?.name ?? 'unknown'; - console.log(`Container ${message.container} exited with code ${message.code}`); - const id = Object.entries(launchOptions).find(([key]) => `${key}-instance` === containerName)?.[0]; - if (id) { - const data = `data: ${JSON.stringify(message)}\n\n`; - sseClientsById[id].forEach(client => client.write(data)); + try { + const event = JSON.parse(buffer.toString()); + + if (event.status === 'die') { + const containerName = event.Actor?.Attributes?.name ?? 'unknown'; + const exitCode = event.Actor?.Attributes?.exitCode ?? 'unknown'; + + console.log(`Container ${containerName} exited with code ${exitCode}`); + + const id = Object.entries(launchOptions) + .find(([key]) => `${key}-instance` === containerName)?.[0]; + + if (id) { + const message = { + event: 'exit', + container: containerName, + code: exitCode, + timestamp: new Date(event.time * 1000).toISOString() + }; + const data = `data: ${JSON.stringify(message)}\n\n`; + sseClients.forEach(client => client.write(data)); + } else { + console.warn(`Unknown container for name: ${containerName}`); + } } + } catch (err) { + console.error('Error handling Docker event:', err); } }); })(); diff --git a/start.sh b/start.sh index cb3b905..f9b33b7 100755 --- a/start.sh +++ b/start.sh @@ -4,4 +4,5 @@ echo "Starting the container launcher..." docker run -it --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ + -p 8080:8080 \ cprtsoftware/container-launcher:latest \ No newline at end of file