Committed by Hermes autonomous QA sprint 2026-08-13. These files were modified during the Aug 12 sprint but never committed.
137 lines
5.7 KiB
JavaScript
137 lines
5.7 KiB
JavaScript
/**
|
|
* 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;
|
|
};
|