[grade=B] DC-100: Service discovery — auto-detect running containers
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.
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -61,6 +61,7 @@ const updatesRoutes = require('../routes/updates');
|
|||||||
const authRoutes = require('../routes/auth');
|
const authRoutes = require('../routes/auth');
|
||||||
const shareRoutes = require('../routes/share');
|
const shareRoutes = require('../routes/share');
|
||||||
const i18nRoutes = require('../routes/i18n');
|
const i18nRoutes = require('../routes/i18n');
|
||||||
|
const discoverRoutes = require('../routes/discover');
|
||||||
const configRoutes = require('../routes/config');
|
const configRoutes = require('../routes/config');
|
||||||
const dnsRoutes = require('../routes/dns');
|
const dnsRoutes = require('../routes/dns');
|
||||||
const notificationRoutes = require('../routes/notifications');
|
const notificationRoutes = require('../routes/notifications');
|
||||||
@@ -599,6 +600,13 @@ async function createApp() {
|
|||||||
|
|
||||||
// DC-077: i18n — language metadata and translations (public, no auth needed)
|
// DC-077: i18n — language metadata and translations (public, no auth needed)
|
||||||
apiRouter.use(i18nRoutes());
|
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({
|
apiRouter.use(updatesRoutes({
|
||||||
updateManager: ctx.updateManager,
|
updateManager: ctx.updateManager,
|
||||||
selfUpdater: ctx.selfUpdater,
|
selfUpdater: ctx.selfUpdater,
|
||||||
|
|||||||
Reference in New Issue
Block a user