From d66326b41d8598156b9a015edbd9d529678eae48 Mon Sep 17 00:00:00 2001 From: Ben Phelps Date: Fri, 9 Sep 2022 21:53:05 +0300 Subject: [PATCH] implement docker service discovery via labels --- package.json | 1 + pnpm-lock.yaml | 6 + src/pages/api/docker/stats/[...service].js | 2 +- src/pages/api/docker/status/[...service].js | 2 +- src/pages/api/services/index.js | 48 +++----- src/utils/docker.js | 6 +- src/utils/i18n.js | 2 +- src/utils/service-helpers.js | 117 +++++++++++++++++++- 8 files changed, 145 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index a6856c1cb..5846f48fc 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react-i18next": "^11.18.5", "react-icons": "^4.4.0", "rutorrent-promise": "^2.0.0", + "shvl": "^3.0.0", "swr": "^1.3.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bad1e81f..55ce7d76a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,7 @@ specifiers: react-i18next: ^11.18.5 react-icons: ^4.4.0 rutorrent-promise: ^2.0.0 + shvl: ^3.0.0 swr: ^1.3.0 tailwindcss: ^3.1.8 typescript: ^4.8.2 @@ -56,6 +57,7 @@ dependencies: react-i18next: 11.18.5_4sidbwfhen5r7txudrvpua6nty react-icons: 4.4.0_react@18.2.0 rutorrent-promise: 2.0.0 + shvl: 3.0.0 swr: 1.3.0_react@18.2.0 devDependencies: @@ -2354,6 +2356,10 @@ packages: engines: {node: '>=8'} dev: true + /shvl/3.0.0: + resolution: {integrity: sha512-5IomAM3ykE/g9K9L6lhODc+TpCuN03rrhlboegeKyyfh66DDdpRD5JN37DYhNHH+RaYjiIDx64K/Ms/xQYOR5w==} + dev: false + /side-channel/1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: diff --git a/src/pages/api/docker/stats/[...service].js b/src/pages/api/docker/stats/[...service].js index 382a8924a..f8f4e5f04 100644 --- a/src/pages/api/docker/stats/[...service].js +++ b/src/pages/api/docker/stats/[...service].js @@ -14,7 +14,7 @@ export default async function handler(req, res) { } try { - const docker = new Docker(await getDockerArguments(containerServer)); + const docker = new Docker(getDockerArguments(containerServer)); const containers = await docker.listContainers({ all: true, }); diff --git a/src/pages/api/docker/status/[...service].js b/src/pages/api/docker/status/[...service].js index 2aafe687b..8a14041de 100644 --- a/src/pages/api/docker/status/[...service].js +++ b/src/pages/api/docker/status/[...service].js @@ -13,7 +13,7 @@ export default async function handler(req, res) { } try { - const docker = new Docker(await getDockerArguments(containerServer)); + const docker = new Docker(getDockerArguments(containerServer)); const containers = await docker.listContainers({ all: true, }); diff --git a/src/pages/api/services/index.js b/src/pages/api/services/index.js index 40f1ea697..a8dd08b79 100644 --- a/src/pages/api/services/index.js +++ b/src/pages/api/services/index.js @@ -1,40 +1,26 @@ -import { promises as fs } from "fs"; -import path from "path"; - -import yaml from "js-yaml"; - -import checkAndCopyConfig from "utils/config"; +import { servicesFromConfig, servicesFromDocker, cleanServiceGroups } from "utils/service-helpers"; export default async function handler(req, res) { - checkAndCopyConfig("services.yaml"); + const discoveredServices = cleanServiceGroups(await servicesFromDocker()); + const configuredServices = cleanServiceGroups(await servicesFromConfig()); - const servicesYaml = path.join(process.cwd(), "config", "services.yaml"); - const fileContents = await fs.readFile(servicesYaml, "utf8"); - const services = yaml.load(fileContents); + const mergedGroupsNames = [ + ...new Set([discoveredServices.map((group) => group.name), configuredServices.map((group) => group.name)].flat()), + ]; - // map easy to write YAML objects into easy to consume JS arrays - const servicesArray = services.map((group) => ({ - name: Object.keys(group)[0], - services: group[Object.keys(group)[0]].map((entries) => { - const { widget, ...service } = entries[Object.keys(entries)[0]]; - const result = { - name: Object.keys(entries)[0], - ...service, - }; + const mergedGroups = []; - if (widget) { - const { type } = widget; + mergedGroupsNames.forEach((groupName) => { + const discoveredGroup = discoveredServices.find((group) => group.name === groupName) || { services: [] }; + const configuredGroup = configuredServices.find((group) => group.name === groupName) || { services: [] }; - result.widget = { - type, - service_group: Object.keys(group)[0], - service_name: Object.keys(entries)[0], - }; - } + const mergedGroup = { + name: groupName, + services: [...discoveredGroup.services, ...configuredGroup.services].filter((service) => service), + }; - return result; - }), - })); + mergedGroups.push(mergedGroup); + }); - res.send(servicesArray); + res.send(mergedGroups); } diff --git a/src/utils/docker.js b/src/utils/docker.js index 0bc6b843c..14fb5c8d6 100644 --- a/src/utils/docker.js +++ b/src/utils/docker.js @@ -1,15 +1,15 @@ import path from "path"; -import { promises as fs } from "fs"; +import { readFileSync } from "fs"; import yaml from "js-yaml"; import checkAndCopyConfig from "utils/config"; -export default async function getDockerArguments(server) { +export default function getDockerArguments(server) { checkAndCopyConfig("docker.yaml"); const configFile = path.join(process.cwd(), "config", "docker.yaml"); - const configData = await fs.readFile(configFile, "utf8"); + const configData = readFileSync(configFile, "utf8"); const servers = yaml.load(configData); if (!server) { diff --git a/src/utils/i18n.js b/src/utils/i18n.js index 808196529..98ff1c123 100644 --- a/src/utils/i18n.js +++ b/src/utils/i18n.js @@ -11,7 +11,7 @@ i18n .init({ fallbackLng: "en", ns: ["common"], - debug: process.env.NODE_ENV === "development", + // debug: process.env.NODE_ENV === "development", defaultNS: "common", nonExplicitSupportedLngs: true, interpolation: { diff --git a/src/utils/service-helpers.js b/src/utils/service-helpers.js index f17434f4d..82fd1c432 100644 --- a/src/utils/service-helpers.js +++ b/src/utils/service-helpers.js @@ -2,8 +2,15 @@ import { promises as fs } from "fs"; import path from "path"; import yaml from "js-yaml"; +import Docker from "dockerode"; +import * as shvl from "shvl"; + +import checkAndCopyConfig from "utils/config"; +import getDockerArguments from "utils/docker"; + +export async function servicesFromConfig() { + checkAndCopyConfig("services.yaml"); -export default async function getServiceWidget(group, service) { const servicesYaml = path.join(process.cwd(), "config", "services.yaml"); const fileContents = await fs.readFile(servicesYaml, "utf8"); const services = yaml.load(fileContents); @@ -17,7 +24,102 @@ export default async function getServiceWidget(group, service) { })), })); - const serviceGroup = servicesArray.find((g) => g.name === group); + return servicesArray; +} + +export async function servicesFromDocker() { + checkAndCopyConfig("docker.yaml"); + + const dockerYaml = path.join(process.cwd(), "config", "docker.yaml"); + const dockerFileContents = await fs.readFile(dockerYaml, "utf8"); + const servers = yaml.load(dockerFileContents); + + const serviceServers = await Promise.all( + Object.keys(servers).map(async (serverName) => { + const docker = new Docker(getDockerArguments(serverName)); + const containers = await docker.listContainers({ + all: true, + }); + + // bad docker connections can result in a object? + // in any case, this ensures the result is the expected array + if (!Array.isArray(containers)) { + return []; + } + + const discovered = containers.map((container) => { + let constructedService = null; + + Object.keys(container.Labels).forEach((label) => { + if (label.startsWith("homepage")) { + if (!constructedService) { + constructedService = { + container: container.Names[0].replace(/^\//, ""), + server: serverName, + }; + } + shvl.set(constructedService, label.replace("homepage.", ""), container.Labels[label]); + } + }); + + return constructedService; + }); + + return { server: serverName, services: discovered.filter((filteredService) => filteredService) }; + }) + ); + + const mappedServiceGroups = []; + + serviceServers.forEach((server) => { + server.services.forEach((serverService) => { + let serverGroup = mappedServiceGroups.find((searchedGroup) => searchedGroup.name === serverService.group); + if (!serverGroup) { + mappedServiceGroups.push({ + name: serverService.group, + services: [], + }); + serverGroup = mappedServiceGroups[mappedServiceGroups.length - 1]; + } + + const { name: serviceName, group: serverServiceGroup, ...pushedService } = serverService; + const result = { + name: serviceName, + ...pushedService, + }; + + serverGroup.services.push(result); + }); + }); + + return mappedServiceGroups; +} + +export function cleanServiceGroups(groups) { + return groups.map((serviceGroup) => ({ + name: serviceGroup.name, + services: serviceGroup.services.map((service) => { + const cleanedService = { ...service }; + + if (cleanedService.widget) { + const { type } = cleanedService.widget; + + cleanedService.widget = { + type, + service_name: service.name, + service_group: serviceGroup.name, + }; + } + + return cleanedService; + }), + })); +} + +export default async function getServiceWidget(group, service) { + const configuredServices = await servicesFromConfig(); + + const serviceGroup = configuredServices.find((g) => g.name === group); if (serviceGroup) { const serviceEntry = serviceGroup.services.find((s) => s.name === service); if (serviceEntry) { @@ -26,5 +128,16 @@ export default async function getServiceWidget(group, service) { } } + const discoveredServices = await servicesFromDocker(); + + const dockerServiceGroup = discoveredServices.find((g) => g.name === group); + if (dockerServiceGroup) { + const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service); + if (dockerServiceEntry) { + const { widget } = dockerServiceEntry; + return widget; + } + } + return false; }