From 4afd0c7b21c56e86ba46de8d6b7ab39fca1301e1 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Fri, 22 May 2026 16:57:33 -0400 Subject: [PATCH] feat: add /cluster/containers endpoint via Docker socket Lists all containers on the local host; supports POST /containers/:id/restart. Falls back to [] gracefully if Docker socket is unavailable. --- services/mam-api/src/routes/cluster.js | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index 58e8cca..4d5e8a6 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -1,4 +1,5 @@ import express from 'express'; +import http from 'http'; import pool from '../db/pool.js'; import { requireAuth } from '../middleware/auth.js'; @@ -18,6 +19,30 @@ function pickIp(reportedIp, reqIp) { return reportedIp; } +function dockerRequest(path, method = 'GET', body = null) { + return new Promise((resolve, reject) => { + const opts = { + socketPath: '/var/run/docker.sock', + path: `/v1.41${path}`, + method, + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + }; + const req = http.request(opts, (res) => { + let data = ''; + res.on('data', d => { data += d; }); + res.on('end', () => { + if (!data.trim()) return resolve(null); + try { resolve(JSON.parse(data)); } + catch (e) { resolve(null); } + }); + }); + req.on('error', reject); + req.setTimeout(5000, () => { req.destroy(); reject(new Error('Docker socket timeout')); }); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + // GET / – list all registered cluster nodes with online status router.get('/', async (req, res, next) => { try { @@ -34,6 +59,45 @@ router.get('/', async (req, res, next) => { } catch (err) { next(err); } }); +// GET /containers – list all containers on the local Docker host +router.get('/containers', async (req, res, next) => { + try { + const containers = await dockerRequest('/containers/json?all=true'); + if (!Array.isArray(containers)) return res.json([]); + const out = containers.map(c => { + const rawName = (c.Names[0] || '').replace(/^\//, ''); + const name = rawName.replace(/^wild-dragon-/, '').replace(/-\d+$/, ''); + const ports = (c.Ports || []) + .filter(p => p.PublicPort) + .map(p => `${p.PublicPort}→${p.PrivatePort}`) + .join(', '); + return { + id: c.Id.slice(0, 12), + name, + image: (c.Image || '').replace(/^sha256:/, '').slice(0, 40), + state: c.State, + uptime: (c.Status || '').replace(/\s*\(.*\)/, '').trim(), + healthy: (c.Status || '').includes('healthy'), + ports, + cpu: 0, + mem: 0, + }; + }); + res.json(out); + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'EACCES') return res.json([]); + next(err); + } +}); + +// POST /containers/:nameOrId/restart +router.post('/containers/:nameOrId/restart', async (req, res, next) => { + try { + await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST'); + res.json({ ok: true }); + } catch (err) { next(err); } +}); + // POST /heartbeat – upsert this node's registration (includes hardware capabilities) router.post('/heartbeat', async (req, res, next) => { try {