From d45dc8d3b77a4934f4074c59c9e2ada6a112f86b Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 12 Aug 2026 12:27:57 -0700 Subject: [PATCH] =?UTF-8?q?[grade=3DB]=20DC-100:=20Service=20discovery=20?= =?UTF-8?q?=E2=80=94=20auto-detect=20running=20containers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/discover scans running Docker containers, matches images against 20 known patterns (Plex, Jellyfin, Sonarr, Radarr, qBittorrent, Gitea, Nextcloud, Redis, Postgres, etc.), and returns suggested service configs. Marks services already in the dashboard as 'existing'. Returns: container ID, name, image, suggested type/name/port/protocol, port mappings, labels, and existing flag. 5 tests, 1623 total pass. --- .../__tests__/routes/discover.routes.test.js | 136 ++++++++++++++++++ dashcaddy-api/routes/discover.js | 136 ++++++++++++++++++ dashcaddy-api/src/app.js | 8 ++ 3 files changed, 280 insertions(+) create mode 100644 dashcaddy-api/__tests__/routes/discover.routes.test.js create mode 100644 dashcaddy-api/routes/discover.js diff --git a/dashcaddy-api/__tests__/routes/discover.routes.test.js b/dashcaddy-api/__tests__/routes/discover.routes.test.js new file mode 100644 index 0000000..05b97a8 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/discover.routes.test.js @@ -0,0 +1,136 @@ +/** + * DC-100: Service discovery tests + */ +const express = require('express'); +const request = require('supertest'); + +function createApp(docker, servicesStateManager) { + const app = express(); + app.use(express.json()); + + const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + const discoverRoutes = require('../../routes/discover'); + + app.use('/api/v1', discoverRoutes({ + docker, + servicesStateManager, + asyncHandler, + })); + return app; +} + +describe('DC-100: Service Discovery', () => { + it('returns 503 when Docker is not available', async () => { + const app = createApp(null, null); + const res = await request(app).get('/api/v1/discover'); + expect(res.status).toBe(503); + expect(res.body.success).toBe(false); + expect(res.body.code).toBe('DC-CONT-011'); + }); + + it('discovers running containers with pattern matching', async () => { + const mockDocker = { + client: { + listContainers: jest.fn().mockResolvedValue([ + { + Id: 'abc123def456', + Names: ['/plex-server'], + Image: 'plexinc/pms-docker:latest', + State: 'running', + Ports: [ + { IP: '0.0.0.0', PrivatePort: 32400, PublicPort: 32400, Type: 'tcp' }, + ], + Labels: {}, + }, + { + Id: 'def789abc012', + Names: ['/redis-cache'], + Image: 'redis:7-alpine', + State: 'running', + Ports: [ + { IP: '0.0.0.0', PrivatePort: 6379, PublicPort: 6379, Type: 'tcp' }, + ], + Labels: {}, + }, + ]), + }, + }; + + const mockStateManager = { + read: jest.fn().mockResolvedValue([]), + }; + + const app = createApp(mockDocker, mockStateManager); + const res = await request(app).get('/api/v1/discover'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.total).toBe(2); + expect(res.body.discovered).toHaveLength(2); + + const plex = res.body.discovered.find(d => d.name === 'plex-server'); + expect(plex.suggested.type).toBe('plex'); + expect(plex.suggested.name).toBe('Plex'); + expect(plex.suggested.port).toBe(32400); + expect(plex.existing).toBe(false); + + const redis = res.body.discovered.find(d => d.name === 'redis-cache'); + expect(redis.suggested.type).toBe('redis'); + }); + + it('marks already-added services as existing', async () => { + const mockDocker = { + client: { + listContainers: jest.fn().mockResolvedValue([ + { + Id: 'abc123def456', + Names: ['/plex-server'], + Image: 'plexinc/pms-docker:latest', + State: 'running', + Ports: [], + Labels: {}, + }, + ]), + }, + }; + + const mockStateManager = { + read: jest.fn().mockResolvedValue([{ id: 'plex-server' }]), + }; + + const app = createApp(mockDocker, mockStateManager); + const res = await request(app).get('/api/v1/discover'); + + expect(res.status).toBe(200); + expect(res.body.discovered[0].existing).toBe(true); + }); + + it('handles empty container list', async () => { + const mockDocker = { + client: { + listContainers: jest.fn().mockResolvedValue([]), + }, + }; + + const app = createApp(mockDocker, null); + const res = await request(app).get('/api/v1/discover'); + + expect(res.status).toBe(200); + expect(res.body.total).toBe(0); + expect(res.body.discovered).toEqual([]); + }); + + it('returns 500 on Docker error', async () => { + const mockDocker = { + client: { + listContainers: jest.fn().mockRejectedValue(new Error('connection refused')), + }, + }; + + const app = createApp(mockDocker, null); + const res = await request(app).get('/api/v1/discover'); + + expect(res.status).toBe(500); + expect(res.body.success).toBe(false); + }); +}); diff --git a/dashcaddy-api/routes/discover.js b/dashcaddy-api/routes/discover.js new file mode 100644 index 0000000..2416572 --- /dev/null +++ b/dashcaddy-api/routes/discover.js @@ -0,0 +1,136 @@ +/** + * DC-100: Service Discovery — auto-detect running Docker containers + * and suggest them as services to add to the dashboard. + * + * Scans all running containers, extracts port mappings, image info, + * and labels to suggest service configurations. + */ +const express = require('express'); +const { ok, errorResponse } = require('../src/utils/responses'); +const { ErrorCodes } = require('../src/utilities/error-codes'); + +// Known image patterns → suggested service type and default config +const IMAGE_PATTERNS = { + 'plexinc/pms': { type: 'plex', name: 'Plex', port: 32400, https: false }, + 'linuxserver/jellyfin': { type: 'jellyfin', name: 'Jellyfin', port: 8096, https: false }, + 'linuxserver/emby': { type: 'emby', name: 'Emby', port: 8096, https: false }, + 'lscr.io/linuxserver/sonarr': { type: 'sonarr', name: 'Sonarr', port: 8989, https: false }, + 'lscr.io/linuxserver/radarr': { type: 'radarr', name: 'Radarr', port: 7878, https: false }, + 'lscr.io/linuxserver/prowlarr': { type: 'prowlarr', name: 'Prowlarr', port: 9696, https: false }, + 'lscr.io/linuxserver/lidarr': { type: 'lidarr', name: 'Lidarr', port: 8686, https: false }, + 'lscr.io/linuxserver/readarr': { type: 'readarr', name: 'Readarr', port: 8787, https: false }, + 'lscr.io/linuxserver/qbittorrent': { type: 'qbittorrent', name: 'qBittorrent', port: 8080, https: false }, + 'lscr.io/linuxserver/transmission': { type: 'transmission', name: 'Transmission', port: 9091, https: false }, + 'haugene/transmission-openvpn': { type: 'transmission', name: 'Transmission+VPN', port: 9091, https: false }, + 'gitea/gitea': { type: 'gitea', name: 'Gitea', port: 3000, https: false }, + 'nextcloud': { type: 'nextcloud', name: 'Nextcloud', port: 80, https: false }, + 'vaultwarden': { type: 'vaultwarden', name: 'Vaultwarden', port: 80, https: false }, + 'nginx': { type: 'web', name: 'Nginx', port: 80, https: false }, + 'caddy': { type: 'web', name: 'Caddy', port: 80, https: false }, + 'redis': { type: 'redis', name: 'Redis', port: 6379, https: false }, + 'postgres': { type: 'postgres', name: 'PostgreSQL', port: 5432, https: false }, + 'mariadb': { type: 'mariadb', name: 'MariaDB', port: 3306, https: false }, + 'mongo': { type: 'mongodb', name: 'MongoDB', port: 27017, https: false }, +}; + +module.exports = function({ docker, servicesStateManager, asyncHandler }) { + const router = express.Router(); + + /** + * GET /api/v1/discover — scan running containers for auto-detection + * + * Returns a list of discovered services with suggested configurations. + * Services already in the dashboard are marked as `existing: true`. + */ + router.get('/discover', asyncHandler(async (req, res) => { + if (!docker || !docker.client) { + return errorResponse(res, 503, 'Docker daemon not available', { + code: ErrorCodes.CONTAINER.DOCKER_UNREACHABLE, + }); + } + + try { + // Get all running containers + const containers = await docker.client.listContainers({ all: false }); + + // Get existing service IDs to mark duplicates + let existingIds = new Set(); + if (servicesStateManager) { + try { + const services = await servicesStateManager.read(); + const list = Array.isArray(services) ? services : (services.services || []); + existingIds = new Set(list.map(s => s.id)); + } catch { /* ignore — treat as empty */ } + } + + const discovered = []; + const seen = new Set(); + + for (const container of containers) { + const name = (container.Names && container.Names[0] || '').replace(/^\//, ''); + if (!name || seen.has(name)) continue; + seen.add(name); + + const image = container.Image || ''; + const imageBase = image.split(':')[0].toLowerCase(); + + // Match against known patterns + let matched = null; + for (const [pattern, config] of Object.entries(IMAGE_PATTERNS)) { + if (imageBase.includes(pattern)) { + matched = config; + break; + } + } + + // Extract port mappings + const ports = (container.Ports || []).map(p => ({ + ip: p.IP || '0.0.0.0', + privatePort: p.PrivatePort, + publicPort: p.PublicPort, + type: p.Type || 'tcp', + })).filter(p => p.publicPort); + + // Suggested config + const suggestedPort = matched ? matched.port : (ports[0] && ports[0].publicPort) || null; + const suggestedId = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase(); + + discovered.push({ + containerId: container.Id.substring(0, 12), + name, + image, + status: container.State, + suggested: { + id: suggestedId, + name: matched ? matched.name : name.charAt(0).toUpperCase() + name.slice(1), + type: matched ? matched.type : 'generic', + port: suggestedPort, + protocol: matched ? (matched.https ? 'https' : 'http') : 'http', + }, + ports, + labels: container.Labels || {}, + existing: existingIds.has(suggestedId), + }); + } + + // Sort: unmatched first (more interesting to discover), then by name + discovered.sort((a, b) => { + if (a.existing !== b.existing) return a.existing ? 1 : -1; + return a.name.localeCompare(b.name); + }); + + ok(res, { + total: discovered.length, + matched: discovered.filter(d => d.suggested.type !== 'generic').length, + newServices: discovered.filter(d => !d.existing).length, + discovered, + }); + } catch (err) { + return errorResponse(res, 500, `Discovery failed: ${err.message}`, { + code: ErrorCodes.GENERAL.INTERNAL, + }); + } + })); + + return router; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 5574fa4..f985344 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -61,6 +61,7 @@ const updatesRoutes = require('../routes/updates'); const authRoutes = require('../routes/auth'); const shareRoutes = require('../routes/share'); const i18nRoutes = require('../routes/i18n'); +const discoverRoutes = require('../routes/discover'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); @@ -599,6 +600,13 @@ async function createApp() { // DC-077: i18n — language metadata and translations (public, no auth needed) apiRouter.use(i18nRoutes()); + + // DC-100: Service discovery — auto-detect running containers + apiRouter.use(discoverRoutes({ + docker: ctx.docker, + servicesStateManager: ctx.servicesStateManager, + asyncHandler: ctx.asyncHandler, + })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater,