Support for Kubernetes gateway API (#4)

Added support for Kubernetes Gateway-API
This commit is contained in:
djeinstine 2024-11-13 11:51:05 +01:00 committed by GitHub
parent d82fbc3026
commit 5289765125
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 333 additions and 158 deletions

View File

@ -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.
@ -140,6 +147,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`.

View File

@ -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:

View File

@ -23,6 +23,12 @@ Set the `mode` in the `kubernetes.yaml` to `cluster`.
mode: default
```
To enable Kubernetes gateway-api compatibility, set `route` to `gateway`.
```yaml
route: gateway
```
## Widgets
The Kubernetes widget can show a high-level overview of the cluster,

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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;
}

View File

@ -3,12 +3,11 @@ 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");
@ -151,33 +150,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 +158,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 = [];

View File

View File

@ -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;
}