diff --git a/docs/configs/kubernetes.md b/docs/configs/kubernetes.md index 06685f3a..8d105ec0 100644 --- a/docs/configs/kubernetes.md +++ b/docs/configs/kubernetes.md @@ -8,6 +8,7 @@ The Kubernetes connectivity has the following requirements: - Kubernetes 1.19+ - Metrics Service - An Ingress controller + - Optionally: Gateway-API The Kubernetes connection is configured in the `kubernetes.yaml` file. There are 3 modes to choose from: @@ -19,6 +20,12 @@ The Kubernetes connection is configured in the `kubernetes.yaml` file. There are mode: default ``` +To enable Kubernetes gateway-api compatibility, add the following setting: + +```yaml +route: gateway +``` + ## Services Once the Kubernetes connection is configured, individual services can be configured to pull statistics. Only CPU and Memory are currently supported. @@ -100,6 +107,8 @@ If you are using multiple instances of homepage, an `instance` annotation can be If you have a single service that needs to be shown on multiple specific instances of homepage (but not on all of them), the service can be annotated by multiple `instance.name` annotations, where `name` can be the names of your specific multiple homepage instances. For example, a service that is annotated with `gethomepage.dev/instance.public: ""` and `gethomepage.dev/instance.internal: ""` will be shown on `public` and `internal` homepage instances. +Use the `gethomepage.dev/pod-selector` selector to specify the pod used for the health check. For example, a service that is annotated with `gethomepage.dev/pod-selector: app.kubernetes.io/name=deployment` would link to a pod with the label `app.kubernetes.io/name: deployment`. + ### Traefik IngressRoute support Homepage can also read ingresses defined using the Traefik IngressRoute custom resource definition. Due to the complex nature of Traefik routing rules, it is required for the `gethomepage.dev/href` annotation to be set: @@ -140,6 +149,10 @@ spec: If the `href` attribute is not present, Homepage will ignore the specific IngressRoute. +### Gateway API HttpRoute support + +Homepage also features automatic service discovery for gateway-api. Service definitions are read by annotating the HttpRoute custom resource definition and are indentical to the Ingress example as defined in [Automatic Service Discovery](#automatic-service-discovery). + ## Caveats Similarly to Docker service discovery, there currently is no rigid ordering to discovered services and discovered services will be displayed above those specified in the `services.yaml`. diff --git a/docs/installation/k8s.md b/docs/installation/k8s.md index 6805139b..7faebda9 100644 --- a/docs/installation/k8s.md +++ b/docs/installation/k8s.md @@ -215,6 +215,15 @@ rules: verbs: - get - list + # if using gateway api add the following: + # - apiGroups: + # - gateway.networking.k8s.io + # resources: + # - httproutes + # - gateways + # verbs: + # - get + # - list - apiGroups: - metrics.k8s.io resources: @@ -370,7 +379,7 @@ prevent unnecessary re-renders on page loads and window / tab focusing. The procedure for enabling sticky sessions depends on your Ingress controller. Below is an example using Traefik as the Ingress controller. -``` +```yaml apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: diff --git a/src/pages/api/kubernetes/stats/[...service].js b/src/pages/api/kubernetes/stats/[...service].js index b1bf8345..89554eec 100644 --- a/src/pages/api/kubernetes/stats/[...service].js +++ b/src/pages/api/kubernetes/stats/[...service].js @@ -1,6 +1,6 @@ import { CoreV1Api, Metrics } from "@kubernetes/client-node"; -import getKubeConfig from "../../../../utils/config/kubernetes"; +import getKubeArguments from "../../../../utils/config/kubernetes"; import { parseCpu, parseMemory } from "../../../../utils/kubernetes/kubernetes-utils"; import createLogger from "../../../../utils/logger"; @@ -20,7 +20,7 @@ export default async function handler(req, res) { const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`; try { - const kc = getKubeConfig(); + const kc = getKubeArguments().config; if (!kc) { res.status(500).send({ error: "No kubernetes configuration", diff --git a/src/pages/api/kubernetes/status/[...service].js b/src/pages/api/kubernetes/status/[...service].js index e50d726c..bae2e98f 100644 --- a/src/pages/api/kubernetes/status/[...service].js +++ b/src/pages/api/kubernetes/status/[...service].js @@ -1,6 +1,6 @@ import { CoreV1Api } from "@kubernetes/client-node"; -import getKubeConfig from "../../../../utils/config/kubernetes"; +import getKubeArguments from "../../../../utils/config/kubernetes"; import createLogger from "../../../../utils/logger"; const logger = createLogger("kubernetesStatusService"); @@ -18,7 +18,7 @@ export default async function handler(req, res) { } const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`; try { - const kc = getKubeConfig(); + const kc = getKubeArguments().config; if (!kc) { res.status(500).send({ error: "No kubernetes configuration", diff --git a/src/pages/api/widgets/kubernetes.js b/src/pages/api/widgets/kubernetes.js index 778a6aa1..eadb77cc 100644 --- a/src/pages/api/widgets/kubernetes.js +++ b/src/pages/api/widgets/kubernetes.js @@ -1,6 +1,6 @@ import { CoreV1Api, Metrics } from "@kubernetes/client-node"; -import getKubeConfig from "../../../utils/config/kubernetes"; +import getKubeArguments from "../../../utils/config/kubernetes"; import { parseCpu, parseMemory } from "../../../utils/kubernetes/kubernetes-utils"; import createLogger from "../../../utils/logger"; @@ -8,7 +8,7 @@ const logger = createLogger("kubernetes-widget"); export default async function handler(req, res) { try { - const kc = getKubeConfig(); + const kc = getKubeArguments().config; if (!kc) { return res.status(500).send({ error: "No kubernetes configuration", diff --git a/src/utils/config/kubernetes.js b/src/utils/config/kubernetes.js index 6693a98d..2dfda9a6 100644 --- a/src/utils/config/kubernetes.js +++ b/src/utils/config/kubernetes.js @@ -6,26 +6,50 @@ import { KubeConfig } from "@kubernetes/client-node"; import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config"; -export default function getKubeConfig() { +const extractKubeData = (config) => { + // kubeconfig + const kc = new KubeConfig(); + kc.loadFromCluster(); + + // route + let route = "ingress"; + if (config?.route === "gateway") { + route = "gateway"; + } + + // traefik + let traefik = true; + if (config?.traefik === "disable") { + traefik = false; + } + + return { + config: kc, + route, + traefik, + }; +}; + +export default function getKubeArguments() { checkAndCopyConfig("kubernetes.yaml"); const configFile = path.join(CONF_DIR, "kubernetes.yaml"); const rawConfigData = readFileSync(configFile, "utf8"); const configData = substituteEnvironmentVars(rawConfigData); const config = yaml.load(configData); - const kc = new KubeConfig(); + let kubeData; switch (config?.mode) { case "cluster": - kc.loadFromCluster(); + kubeData = extractKubeData(config); break; case "default": - kc.loadFromDefault(); + kubeData = extractKubeData(config); break; case "disabled": default: - return null; + kubeData = { config: null }; } - return kc; + return kubeData; } diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 1566a135..b44b7503 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -3,16 +3,51 @@ import path from "path"; import yaml from "js-yaml"; import Docker from "dockerode"; -import { CustomObjectsApi, NetworkingV1Api, ApiextensionsV1Api } from "@kubernetes/client-node"; import createLogger from "utils/logger"; import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config"; import getDockerArguments from "utils/config/docker"; -import getKubeConfig from "utils/config/kubernetes"; +import { getUrlSchema, getRouteList } from "utils/kubernetes/kubernetes-routes"; import * as shvl from "utils/config/shvl"; const logger = createLogger("service-helpers"); +function parseServicesToGroups(services) { + if (!services) { + return []; + } + + // map easy to write YAML objects into easy to consume JS arrays + return services.map((serviceGroup) => { + const name = Object.keys(serviceGroup)[0]; + let groups = []; + const serviceGroupServices = []; + serviceGroup[name].forEach((entries) => { + const entryName = Object.keys(entries)[0]; + if (!entries[entryName]) { + logger.warn(`Error parsing service "${entryName}" from config. Ensure required fields are present.`); + return; + } + if (Array.isArray(entries[entryName])) { + groups = groups.concat(parseServicesToGroups([{ [entryName]: entries[entryName] }])); + } else { + serviceGroupServices.push({ + name: entryName, + ...entries[entryName], + weight: entries[entryName].weight || serviceGroupServices.length * 100, // default weight + type: "service", + }); + } + }); + return { + name, + type: "group", + services: serviceGroupServices, + groups, + }; + }); +} + export async function servicesFromConfig() { checkAndCopyConfig("services.yaml"); @@ -20,31 +55,7 @@ export async function servicesFromConfig() { const rawFileContents = await fs.readFile(servicesYaml, "utf8"); const fileContents = substituteEnvironmentVars(rawFileContents); const services = yaml.load(fileContents); - - if (!services) { - return []; - } - - // map easy to write YAML objects into easy to consume JS arrays - const servicesArray = services.map((servicesGroup) => ({ - name: Object.keys(servicesGroup)[0], - services: servicesGroup[Object.keys(servicesGroup)[0]].map((entries) => ({ - name: Object.keys(entries)[0], - ...entries[Object.keys(entries)[0]], - type: "service", - })), - })); - - // add default weight to services based on their position in the configuration - servicesArray.forEach((group, groupIndex) => { - group.services.forEach((service, serviceIndex) => { - if (service.weight === undefined) { - servicesArray[groupIndex].services[serviceIndex].weight = (serviceIndex + 1) * 100; - } - }); - }); - - return servicesArray; + return parseServicesToGroups(services); } export async function servicesFromDocker() { @@ -98,7 +109,11 @@ export async function servicesFromDocker() { type: "service", }; } - shvl.set(constructedService, value, substituteEnvironmentVars(containerLabels[label])); + let substitutedVal = substituteEnvironmentVars(containerLabels[label]); + if (value === "widget.version") { + substitutedVal = parseInt(substitutedVal, 10); + } + shvl.set(constructedService, value, substitutedVal); } }); @@ -151,33 +166,6 @@ export async function servicesFromDocker() { return mappedServiceGroups; } -function getUrlFromIngress(ingress) { - const urlHost = ingress.spec.rules[0].host; - const urlPath = ingress.spec.rules[0].http.paths[0].path; - const urlSchema = ingress.spec.tls ? "https" : "http"; - return `${urlSchema}://${urlHost}${urlPath}`; -} - -export async function checkCRD(kc, name) { - const apiExtensions = kc.makeApiClient(ApiextensionsV1Api); - const exist = await apiExtensions - .readCustomResourceDefinitionStatus(name) - .then(() => true) - .catch(async (error) => { - if (error.statusCode === 403) { - logger.error( - "Error checking if CRD %s exists. Make sure to add the following permission to your RBAC: %d %s %s", - name, - error.statusCode, - error.body.message, - ); - } - return false; - }); - - return exist; -} - export async function servicesFromKubernetes() { const ANNOTATION_BASE = "gethomepage.dev"; const ANNOTATION_WIDGET_BASE = `${ANNOTATION_BASE}/widget.`; @@ -186,128 +174,70 @@ export async function servicesFromKubernetes() { checkAndCopyConfig("kubernetes.yaml"); try { - const kc = getKubeConfig(); - if (!kc) { + const routeList = await getRouteList(ANNOTATION_BASE); + + if (!routeList) { return []; } - const networking = kc.makeApiClient(NetworkingV1Api); - const crd = kc.makeApiClient(CustomObjectsApi); - const ingressList = await networking - .listIngressForAllNamespaces(null, null, null, null) - .then((response) => response.body) - .catch((error) => { - logger.error("Error getting ingresses: %d %s %s", error.statusCode, error.body, error.response); - logger.debug(error); - return null; - }); - - const traefikContainoExists = await checkCRD(kc, "ingressroutes.traefik.containo.us"); - const traefikExists = await checkCRD(kc, "ingressroutes.traefik.io"); - - const traefikIngressListContaino = await crd - .listClusterCustomObject("traefik.containo.us", "v1alpha1", "ingressroutes") - .then((response) => response.body) - .catch(async (error) => { - if (traefikContainoExists) { - logger.error( - "Error getting traefik ingresses from traefik.containo.us: %d %s %s", - error.statusCode, - error.body, - error.response, - ); - logger.debug(error); - } - - return []; - }); - - const traefikIngressListIo = await crd - .listClusterCustomObject("traefik.io", "v1alpha1", "ingressroutes") - .then((response) => response.body) - .catch(async (error) => { - if (traefikExists) { - logger.error( - "Error getting traefik ingresses from traefik.io: %d %s %s", - error.statusCode, - error.body, - error.response, - ); - logger.debug(error); - } - - return []; - }); - - const traefikIngressList = [...(traefikIngressListContaino?.items ?? []), ...(traefikIngressListIo?.items ?? [])]; - - if (traefikIngressList.length > 0) { - const traefikServices = traefikIngressList.filter( - (ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`], - ); - ingressList.items.push(...traefikServices); - } - - if (!ingressList) { - return []; - } - const services = ingressList.items - .filter( - (ingress) => - ingress.metadata.annotations && - ingress.metadata.annotations[`${ANNOTATION_BASE}/enabled`] === "true" && - (!ingress.metadata.annotations[`${ANNOTATION_BASE}/instance`] || - ingress.metadata.annotations[`${ANNOTATION_BASE}/instance`] === instanceName || - `${ANNOTATION_BASE}/instance.${instanceName}` in ingress.metadata.annotations), - ) - .map((ingress) => { - let constructedService = { - app: ingress.metadata.annotations[`${ANNOTATION_BASE}/app`] || ingress.metadata.name, - namespace: ingress.metadata.namespace, - href: ingress.metadata.annotations[`${ANNOTATION_BASE}/href`] || getUrlFromIngress(ingress), - name: ingress.metadata.annotations[`${ANNOTATION_BASE}/name`] || ingress.metadata.name, - group: ingress.metadata.annotations[`${ANNOTATION_BASE}/group`] || "Kubernetes", - weight: ingress.metadata.annotations[`${ANNOTATION_BASE}/weight`] || "0", - icon: ingress.metadata.annotations[`${ANNOTATION_BASE}/icon`] || "", - description: ingress.metadata.annotations[`${ANNOTATION_BASE}/description`] || "", - external: false, - type: "service", - }; - if (ingress.metadata.annotations[`${ANNOTATION_BASE}/external`]) { - constructedService.external = - String(ingress.metadata.annotations[`${ANNOTATION_BASE}/external`]).toLowerCase() === "true"; - } - if (ingress.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`] !== undefined) { - constructedService.podSelector = ingress.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`]; - } - if (ingress.metadata.annotations[`${ANNOTATION_BASE}/ping`]) { - constructedService.ping = ingress.metadata.annotations[`${ANNOTATION_BASE}/ping`]; - } - if (ingress.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`]) { - constructedService.siteMonitor = ingress.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`]; - } - if (ingress.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`]) { - constructedService.statusStyle = ingress.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`]; - } - Object.keys(ingress.metadata.annotations).forEach((annotation) => { - if (annotation.startsWith(ANNOTATION_WIDGET_BASE)) { - shvl.set( - constructedService, - annotation.replace(`${ANNOTATION_BASE}/`, ""), - ingress.metadata.annotations[annotation], - ); + const services = await Promise.all( + routeList + .filter( + (route) => + route.metadata.annotations && + route.metadata.annotations[`${ANNOTATION_BASE}/enabled`] === "true" && + (!route.metadata.annotations[`${ANNOTATION_BASE}/instance`] || + route.metadata.annotations[`${ANNOTATION_BASE}/instance`] === instanceName || + `${ANNOTATION_BASE}/instance.${instanceName}` in route.metadata.annotations), + ) + .map(async (route) => { + let constructedService = { + app: route.metadata.annotations[`${ANNOTATION_BASE}/app`] || route.metadata.name, + namespace: route.metadata.namespace, + href: route.metadata.annotations[`${ANNOTATION_BASE}/href`] || (await getUrlSchema(route)), + name: route.metadata.annotations[`${ANNOTATION_BASE}/name`] || route.metadata.name, + group: route.metadata.annotations[`${ANNOTATION_BASE}/group`] || "Kubernetes", + weight: route.metadata.annotations[`${ANNOTATION_BASE}/weight`] || "0", + icon: route.metadata.annotations[`${ANNOTATION_BASE}/icon`] || "", + description: route.metadata.annotations[`${ANNOTATION_BASE}/description`] || "", + external: false, + type: "service", + }; + if (route.metadata.annotations[`${ANNOTATION_BASE}/external`]) { + constructedService.external = + String(route.metadata.annotations[`${ANNOTATION_BASE}/external`]).toLowerCase() === "true"; } - }); + if (route.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`] !== undefined) { + constructedService.podSelector = route.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`]; + } + if (route.metadata.annotations[`${ANNOTATION_BASE}/ping`]) { + constructedService.ping = route.metadata.annotations[`${ANNOTATION_BASE}/ping`]; + } + if (route.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`]) { + constructedService.siteMonitor = route.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`]; + } + if (route.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`]) { + constructedService.statusStyle = route.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`]; + } + Object.keys(route.metadata.annotations).forEach((annotation) => { + if (annotation.startsWith(ANNOTATION_WIDGET_BASE)) { + shvl.set( + constructedService, + annotation.replace(`${ANNOTATION_BASE}/`, ""), + route.metadata.annotations[annotation], + ); + } + }); - try { - constructedService = JSON.parse(substituteEnvironmentVars(JSON.stringify(constructedService))); - } catch (e) { - logger.error("Error attempting k8s environment variable substitution."); - logger.debug(e); - } - - return constructedService; - }); + try { + constructedService = JSON.parse(substituteEnvironmentVars(JSON.stringify(constructedService))); + } catch (e) { + logger.error("Error attempting k8s environment variable substitution."); + logger.debug(e); + } + return constructedService; + }), + ); const mappedServiceGroups = []; @@ -354,8 +284,12 @@ export function cleanServiceGroups(groups) { if (typeof cleanedService.weight !== "number") { cleanedService.weight = 0; } - + if (!cleanedService.widgets) cleanedService.widgets = []; if (cleanedService.widget) { + cleanedService.widgets.push(cleanedService.widget); + delete cleanedService.widget; + } + cleanedService.widgets = cleanedService.widgets.map((widgetData, index) => { // whitelisted set of keys to pass to the frontend // alphabetical, grouped by widget(s) const { @@ -390,6 +324,9 @@ export function cleanServiceGroups(groups) { mappings, display, + // deluge, qbittorrent + enableLeechProgress, + // diskstation volume, @@ -409,7 +346,7 @@ export function cleanServiceGroups(groups) { // frigate enableRecentEvents, - // glances, immich, mealie, pihole, pfsense + // beszel, glances, immich, mealie, pihole, pfsense version, // glances @@ -492,7 +429,10 @@ export function cleanServiceGroups(groups) { // technitium range, - } = cleanedService.widget; + + // spoolman + spoolIds, + } = widgetData; let fieldsList = fields; if (typeof fields === "string") { @@ -504,166 +444,191 @@ export function cleanServiceGroups(groups) { } } - cleanedService.widget = { + const widget = { type, fields: fieldsList || null, hide_errors: hideErrors || false, service_name: service.name, service_group: serviceGroup.name, + index, }; if (type === "azuredevops") { - if (userEmail) cleanedService.widget.userEmail = userEmail; - if (repositoryId) cleanedService.widget.repositoryId = repositoryId; + if (userEmail) widget.userEmail = userEmail; + if (repositoryId) widget.repositoryId = repositoryId; } if (type === "beszel") { - if (systemId) cleanedService.widget.systemId = systemId; + if (systemId) widget.systemId = systemId; } if (type === "coinmarketcap") { - if (currency) cleanedService.widget.currency = currency; - if (symbols) cleanedService.widget.symbols = symbols; - if (slugs) cleanedService.widget.slugs = slugs; - if (defaultinterval) cleanedService.widget.defaultinterval = defaultinterval; + if (currency) widget.currency = currency; + if (symbols) widget.symbols = symbols; + if (slugs) widget.slugs = slugs; + if (defaultinterval) widget.defaultinterval = defaultinterval; } if (type === "docker") { - if (server) cleanedService.widget.server = server; - if (container) cleanedService.widget.container = container; + if (server) widget.server = server; + if (container) widget.container = container; } if (type === "unifi") { - if (site) cleanedService.widget.site = site; + if (site) widget.site = site; } if (type === "proxmox") { - if (node) cleanedService.widget.node = node; + if (node) widget.node = node; } if (type === "kubernetes") { - if (namespace) cleanedService.widget.namespace = namespace; - if (app) cleanedService.widget.app = app; - if (podSelector) cleanedService.widget.podSelector = podSelector; + if (namespace) widget.namespace = namespace; + if (app) widget.app = app; + if (podSelector) widget.podSelector = podSelector; } if (type === "iframe") { - if (src) cleanedService.widget.src = src; - if (classes) cleanedService.widget.classes = classes; - if (referrerPolicy) cleanedService.widget.referrerPolicy = referrerPolicy; - if (allowPolicy) cleanedService.widget.allowPolicy = allowPolicy; - if (allowFullscreen) cleanedService.widget.allowFullscreen = allowFullscreen; - if (loadingStrategy) cleanedService.widget.loadingStrategy = loadingStrategy; - if (allowScrolling) cleanedService.widget.allowScrolling = allowScrolling; - if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; + if (src) widget.src = src; + if (classes) widget.classes = classes; + if (referrerPolicy) widget.referrerPolicy = referrerPolicy; + if (allowPolicy) widget.allowPolicy = allowPolicy; + if (allowFullscreen) widget.allowFullscreen = allowFullscreen; + if (loadingStrategy) widget.loadingStrategy = loadingStrategy; + if (allowScrolling) widget.allowScrolling = allowScrolling; + if (refreshInterval) widget.refreshInterval = refreshInterval; + } + if (["deluge", "qbittorrent"].includes(type)) { + if (enableLeechProgress !== undefined) widget.enableLeechProgress = JSON.parse(enableLeechProgress); } if (["opnsense", "pfsense"].includes(type)) { - if (wan) cleanedService.widget.wan = wan; + if (wan) widget.wan = wan; } if (["emby", "jellyfin"].includes(type)) { - if (enableBlocks !== undefined) cleanedService.widget.enableBlocks = JSON.parse(enableBlocks); - if (enableNowPlaying !== undefined) cleanedService.widget.enableNowPlaying = JSON.parse(enableNowPlaying); + if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks); + if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying); } if (["emby", "jellyfin", "tautulli"].includes(type)) { if (expandOneStreamToTwoRows !== undefined) - cleanedService.widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows); - if (showEpisodeNumber !== undefined) - cleanedService.widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber); - if (enableUser !== undefined) cleanedService.widget.enableUser = !!JSON.parse(enableUser); + widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows); + if (showEpisodeNumber !== undefined) widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber); + if (enableUser !== undefined) widget.enableUser = !!JSON.parse(enableUser); } if (["sonarr", "radarr"].includes(type)) { - if (enableQueue !== undefined) cleanedService.widget.enableQueue = JSON.parse(enableQueue); + if (enableQueue !== undefined) widget.enableQueue = JSON.parse(enableQueue); } if (type === "truenas") { - if (enablePools !== undefined) cleanedService.widget.enablePools = JSON.parse(enablePools); - if (nasType !== undefined) cleanedService.widget.nasType = nasType; + if (enablePools !== undefined) widget.enablePools = JSON.parse(enablePools); + if (nasType !== undefined) widget.nasType = nasType; } if (["diskstation", "qnap"].includes(type)) { - if (volume) cleanedService.widget.volume = volume; + if (volume) widget.volume = volume; } if (type === "kopia") { - if (snapshotHost) cleanedService.widget.snapshotHost = snapshotHost; - if (snapshotPath) cleanedService.widget.snapshotPath = snapshotPath; + if (snapshotHost) widget.snapshotHost = snapshotHost; + if (snapshotPath) widget.snapshotPath = snapshotPath; } - if (["glances", "immich", "mealie", "pfsense", "pihole"].includes(type)) { - if (version) cleanedService.widget.version = parseInt(version, 10); + if (["beszel", "glances", "immich", "mealie", "pfsense", "pihole"].includes(type)) { + if (version) widget.version = parseInt(version, 10); } if (type === "glances") { - if (metric) cleanedService.widget.metric = metric; + if (metric) widget.metric = metric; if (chart !== undefined) { - cleanedService.widget.chart = chart; + widget.chart = chart; } else { - cleanedService.widget.chart = true; + widget.chart = true; } - if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; - if (pointsLimit) cleanedService.widget.pointsLimit = pointsLimit; - if (diskUnits) cleanedService.widget.diskUnits = diskUnits; + if (refreshInterval) widget.refreshInterval = refreshInterval; + if (pointsLimit) widget.pointsLimit = pointsLimit; + if (diskUnits) widget.diskUnits = diskUnits; } if (type === "mjpeg") { - if (stream) cleanedService.widget.stream = stream; - if (fit) cleanedService.widget.fit = fit; + if (stream) widget.stream = stream; + if (fit) widget.fit = fit; } if (type === "openmediavault") { - if (method) cleanedService.widget.method = method; + if (method) widget.method = method; } if (type === "openwrt") { - if (interfaceName) cleanedService.widget.interfaceName = interfaceName; + if (interfaceName) widget.interfaceName = interfaceName; } if (type === "customapi") { - if (mappings) cleanedService.widget.mappings = mappings; - if (display) cleanedService.widget.display = display; - if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; + if (mappings) widget.mappings = mappings; + if (display) widget.display = display; + if (refreshInterval) widget.refreshInterval = refreshInterval; } if (type === "calendar") { - if (integrations) cleanedService.widget.integrations = integrations; - if (firstDayInWeek) cleanedService.widget.firstDayInWeek = firstDayInWeek; - if (view) cleanedService.widget.view = view; - if (maxEvents) cleanedService.widget.maxEvents = maxEvents; - if (previousDays) cleanedService.widget.previousDays = previousDays; - if (showTime) cleanedService.widget.showTime = showTime; - if (timezone) cleanedService.widget.timezone = timezone; + if (integrations) widget.integrations = integrations; + if (firstDayInWeek) widget.firstDayInWeek = firstDayInWeek; + if (view) widget.view = view; + if (maxEvents) widget.maxEvents = maxEvents; + if (previousDays) widget.previousDays = previousDays; + if (showTime) widget.showTime = showTime; + if (timezone) widget.timezone = timezone; } if (type === "hdhomerun") { - if (tuner !== undefined) cleanedService.widget.tuner = tuner; + if (tuner !== undefined) widget.tuner = tuner; } if (type === "healthchecks") { - if (uuid !== undefined) cleanedService.widget.uuid = uuid; + if (uuid !== undefined) widget.uuid = uuid; } if (type === "speedtest") { if (bitratePrecision !== undefined) { - cleanedService.widget.bitratePrecision = parseInt(bitratePrecision, 10); + widget.bitratePrecision = parseInt(bitratePrecision, 10); } } if (type === "stocks") { - if (watchlist) cleanedService.widget.watchlist = watchlist; - if (showUSMarketStatus) cleanedService.widget.showUSMarketStatus = showUSMarketStatus; + if (watchlist) widget.watchlist = watchlist; + if (showUSMarketStatus) widget.showUSMarketStatus = showUSMarketStatus; } if (type === "wgeasy") { - if (threshold !== undefined) cleanedService.widget.threshold = parseInt(threshold, 10); + if (threshold !== undefined) widget.threshold = parseInt(threshold, 10); } if (type === "frigate") { - if (enableRecentEvents !== undefined) cleanedService.widget.enableRecentEvents = enableRecentEvents; + if (enableRecentEvents !== undefined) widget.enableRecentEvents = enableRecentEvents; } if (type === "technitium") { - if (range !== undefined) cleanedService.widget.range = range; + if (range !== undefined) widget.range = range; } if (type === "lubelogger") { - if (vehicleID !== undefined) cleanedService.widget.vehicleID = parseInt(vehicleID, 10); + if (vehicleID !== undefined) widget.vehicleID = parseInt(vehicleID, 10); } if (type === "vikunja") { - if (enableTaskList !== undefined) cleanedService.widget.enableTaskList = !!enableTaskList; + if (enableTaskList !== undefined) widget.enableTaskList = !!enableTaskList; } if (type === "prometheusmetric") { - if (metrics) cleanedService.widget.metrics = metrics; - if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; + if (metrics) widget.metrics = metrics; + if (refreshInterval) widget.refreshInterval = refreshInterval; } - } - + if (type === "spoolman") { + if (spoolIds !== undefined) widget.spoolIds = spoolIds; + } + return widget; + }); return cleanedService; }), + type: serviceGroup.type || "group", + groups: serviceGroup.groups ? cleanServiceGroups(serviceGroup.groups) : [], })); } +export function findGroupByName(groups, name) { + // Deep search for a group by name. Using for loop allows for early return + for (let i = 0; i < groups.length; i += 1) { + const group = groups[i]; + if (group.name === name) { + return group; + } else if (group.groups) { + const foundGroup = findGroupByName(group.groups, name); + if (foundGroup) { + foundGroup.parent = group; + return foundGroup; + } + } + } + return null; +} + export async function getServiceItem(group, service) { const configuredServices = await servicesFromConfig(); - const serviceGroup = configuredServices.find((g) => g.name === group); + const serviceGroup = findGroupByName(configuredServices, group); if (serviceGroup) { const serviceEntry = serviceGroup.services.find((s) => s.name === service); if (serviceEntry) return serviceEntry; @@ -671,14 +636,14 @@ export async function getServiceItem(group, service) { const discoveredServices = await servicesFromDocker(); - const dockerServiceGroup = discoveredServices.find((g) => g.name === group); + const dockerServiceGroup = findGroupByName(discoveredServices, group); if (dockerServiceGroup) { const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service); if (dockerServiceEntry) return dockerServiceEntry; } const kubernetesServices = await servicesFromKubernetes(); - const kubernetesServiceGroup = kubernetesServices.find((g) => g.name === group); + const kubernetesServiceGroup = findGroupByName(kubernetesServices, group); if (kubernetesServiceGroup) { const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service); if (kubernetesServiceEntry) return kubernetesServiceEntry; @@ -687,12 +652,11 @@ export async function getServiceItem(group, service) { return false; } -export default async function getServiceWidget(group, service) { +export default async function getServiceWidget(group, service, index) { const serviceItem = await getServiceItem(group, service); if (serviceItem) { - const { widget } = serviceItem; - return widget; + const { widget, widgets } = serviceItem; + return index > -1 && widgets ? widgets[index] : widget; } - return false; } diff --git a/src/utils/kubernetes/kubernetes-routes.js b/src/utils/kubernetes/kubernetes-routes.js new file mode 100644 index 00000000..d030015f --- /dev/null +++ b/src/utils/kubernetes/kubernetes-routes.js @@ -0,0 +1,211 @@ +import { CustomObjectsApi, NetworkingV1Api, CoreV1Api, ApiextensionsV1Api } from "@kubernetes/client-node"; + +import getKubeArguments from "utils/config/kubernetes"; +import createLogger from "utils/logger"; + +const logger = createLogger("service-helpers"); + +const kubeArguments = getKubeArguments(); +const kc = kubeArguments.config; + +const apiGroup = "gateway.networking.k8s.io"; +const version = "v1"; + +let crd; +let core; +let networking; +let routingType; +let traefik; + +export async function checkCRD(name) { + const apiExtensions = kc.makeApiClient(ApiextensionsV1Api); + const exist = await apiExtensions + .readCustomResourceDefinitionStatus(name) + .then(() => true) + .catch(async (error) => { + if (error.statusCode === 403) { + logger.error( + "Error checking if CRD %s exists. Make sure to add the following permission to your RBAC: %d %s %s", + name, + error.statusCode, + error.body.message, + ); + } + return false; + }); + + return exist; +} + +const getSchemaFromGateway = async (gatewayRef) => { + const schema = await crd + .getNamespacedCustomObject(apiGroup, version, gatewayRef.namespace, "gateways", gatewayRef.name) + .then((response) => { + const listner = response.body.spec.listeners.filter((listener) => listener.name === gatewayRef.sectionName)[0]; + return listner.protocol.toLowerCase(); + }) + .catch((error) => { + logger.error("Error getting gateways: %d %s %s", error.statusCode, error.body, error.response); + logger.debug(error); + return ""; + }); + return schema; +}; + +async function getUrlFromHttpRoute(ingress) { + const urlHost = ingress.spec.hostnames[0]; + const urlPath = ingress.spec.rules[0].matches[0].path.value; + const urlSchema = (await getSchemaFromGateway(ingress.spec.parentRefs[0])) ? "https" : "http"; + return `${urlSchema}://${urlHost}${urlPath}`; +} + +function getUrlFromIngress(ingress) { + const urlHost = ingress.spec.rules[0].host; + const urlPath = ingress.spec.rules[0].http.paths[0].path; + const urlSchema = ingress.spec.tls ? "https" : "http"; + return `${urlSchema}://${urlHost}${urlPath}`; +} + +async function getHttpRouteList() { + // httproutes + const getHttpRoute = async (namespace) => + crd + .listNamespacedCustomObject(apiGroup, version, namespace, "httproutes") + .then((response) => { + const [httpRoute] = response.body.items; + return httpRoute; + }) + .catch((error) => { + logger.error("Error getting httproutes: %d %s %s", error.statusCode, error.body, error.response); + logger.debug(error); + return null; + }); + + // namespaces + const namespaces = await core + .listNamespace() + .then((response) => response.body.items.map((ns) => ns.metadata.name)) + .catch((error) => { + logger.error("Error getting namespaces: %d %s %s", error.statusCode, error.body, error.response); + logger.debug(error); + return null; + }); + + let httpRouteList = []; + if (namespaces) { + const httpRouteListUnfiltered = await Promise.all( + namespaces.map(async (namespace) => { + const httpRoute = await getHttpRoute(namespace); + return httpRoute; + }), + ); + + httpRouteList = httpRouteListUnfiltered.filter((httpRoute) => httpRoute !== undefined); + } + return httpRouteList; +} + +async function getIngressList(ANNOTATION_BASE) { + const ingressList = await networking + .listIngressForAllNamespaces(null, null, null, null) + .then((response) => response.body) + .catch((error) => { + logger.error("Error getting ingresses: %d %s %s", error.statusCode, error.body, error.response); + logger.debug(error); + return null; + }); + + if (traefik) { + const traefikContainoExists = await checkCRD("ingressroutes.traefik.containo.us"); + const traefikExists = await checkCRD("ingressroutes.traefik.io"); + + const traefikIngressListContaino = await crd + .listClusterCustomObject("traefik.containo.us", "v1alpha1", "ingressroutes") + .then((response) => response.body) + .catch(async (error) => { + if (traefikContainoExists) { + logger.error( + "Error getting traefik ingresses from traefik.containo.us: %d %s %s", + error.statusCode, + error.body, + error.response, + ); + logger.debug(error); + } + + return []; + }); + + const traefikIngressListIo = await crd + .listClusterCustomObject("traefik.io", "v1alpha1", "ingressroutes") + .then((response) => response.body) + .catch(async (error) => { + if (traefikExists) { + logger.error( + "Error getting traefik ingresses from traefik.io: %d %s %s", + error.statusCode, + error.body, + error.response, + ); + logger.debug(error); + } + + return []; + }); + + const traefikIngressList = [...(traefikIngressListContaino?.items ?? []), ...(traefikIngressListIo?.items ?? [])]; + + if (traefikIngressList.length > 0) { + const traefikServices = traefikIngressList.filter( + (ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`], + ); + ingressList.items.push(...traefikServices); + } + } + + return ingressList.items; +} + +export async function getRouteList(ANNOTATION_BASE) { + let routeList = []; + + if (!kc) { + return []; + } + + crd = kc.makeApiClient(CustomObjectsApi); + core = kc.makeApiClient(CoreV1Api); + networking = kc.makeApiClient(NetworkingV1Api); + + routingType = kubeArguments.route; + traefik = kubeArguments.traefik; + + switch (routingType) { + case "ingress": + routeList = await getIngressList(ANNOTATION_BASE); + break; + case "gateway": + routeList = await getHttpRouteList(); + break; + default: + routeList = await getIngressList(ANNOTATION_BASE); + } + + return routeList; +} + +export async function getUrlSchema(route) { + let urlSchema; + + switch (routingType) { + case "ingress": + urlSchema = getUrlFromIngress(route); + break; + case "gateway": + urlSchema = await getUrlFromHttpRoute(route); + break; + default: + urlSchema = getUrlFromIngress(route); + } + return urlSchema; +}