diff --git a/src/renderer/api/__tests__/kube-api.test.ts b/src/renderer/api/__tests__/kube-api.test.ts new file mode 100644 index 0000000000..41078e77a3 --- /dev/null +++ b/src/renderer/api/__tests__/kube-api.test.ts @@ -0,0 +1,82 @@ +import { KubeApi } from "../kube-api"; + +describe("KubeApi", () => { + it("uses url from apiBase if apiBase contains the resource", async () => { + (fetch as any).mockResponse(async (request: any) => { + if (request.url === "/api-kube/apis/networking.k8s.io/v1") { + return { + body: JSON.stringify({ + resources: [{ + name: "ingresses" + }] as any [] + }) + }; + } else if (request.url === "/api-kube/apis/extensions/v1beta1") { + // Even if the old API contains ingresses, KubeApi should prefer the apiBase url + return { + body: JSON.stringify({ + resources: [{ + name: "ingresses" + }] as any [] + }) + }; + } else { + return { + body: JSON.stringify({ + resources: [] as any [] + }) + }; + } + }); + + const apiBase = "/apis/networking.k8s.io/v1/ingresses"; + const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; + const kubeApi = new KubeApi({ + apiBase, + fallbackApiBases: [fallbackApiBase], + checkPreferredVersion: true, + }); + + await kubeApi.get(); + expect(kubeApi.apiPrefix).toEqual("/apis"); + expect(kubeApi.apiGroup).toEqual("networking.k8s.io"); + }); + + it("uses url from fallbackApiBases if apiBase lacks the resource", async () => { + (fetch as any).mockResponse(async (request: any) => { + if (request.url === "/api-kube/apis/networking.k8s.io/v1") { + return { + body: JSON.stringify({ + resources: [] as any [] + }) + }; + } else if (request.url === "/api-kube/apis/extensions/v1beta1") { + return { + body: JSON.stringify({ + resources: [{ + name: "ingresses" + }] as any [] + }) + }; + } else { + return { + body: JSON.stringify({ + resources: [] as any [] + }) + }; + } + }); + + const apiBase = "apis/networking.k8s.io/v1/ingresses"; + const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; + const kubeApi = new KubeApi({ + apiBase, + fallbackApiBases: [fallbackApiBase], + checkPreferredVersion: true, + }); + + await kubeApi.get(); + expect(kubeApi.apiPrefix).toEqual("/apis"); + expect(kubeApi.apiGroup).toEqual("extensions"); + }); +}); \ No newline at end of file diff --git a/src/renderer/api/endpoints/ingress.api.ts b/src/renderer/api/endpoints/ingress.api.ts index 0594e3446e..a476b29efa 100644 --- a/src/renderer/api/endpoints/ingress.api.ts +++ b/src/renderer/api/endpoints/ingress.api.ts @@ -29,11 +29,42 @@ export interface ILoadBalancerIngress { hostname?: string; ip?: string; } + +// extensions/v1beta1 +interface IExtensionsBackend { + serviceName: string; + servicePort: number; +} + +// networking.k8s.io/v1 +interface INetworkingBackend { + service: IIngressService; +} + +export type IIngressBackend = IExtensionsBackend | INetworkingBackend; + +export interface IIngressService { + name: string; + port: { + name?: string; + number?: number; + } +} + +export const getBackendServiceNamePort = (backend: IIngressBackend) => { + // .service is available with networking.k8s.io/v1, otherwise using extensions/v1beta1 interface + const serviceName = "service" in backend ? backend.service.name : backend.serviceName; + // Port is specified either with a number or name + const servicePort = "service" in backend ? backend.service.port.number ?? backend.service.port.name : backend.servicePort; + + return { serviceName, servicePort }; +}; + @autobind() export class Ingress extends KubeObject { static kind = "Ingress"; static namespaced = true; - static apiBase = "/apis/extensions/v1beta1/ingresses"; + static apiBase = "/apis/networking.k8s.io/v1/ingresses"; spec: { tls: { @@ -44,17 +75,20 @@ export class Ingress extends KubeObject { http: { paths: { path?: string; - backend: { - serviceName: string; - servicePort: number; - }; + backend: IIngressBackend; }[]; }; }[]; - backend?: { - serviceName: string; - servicePort: number; - }; + // extensions/v1beta1 + backend?: IExtensionsBackend; + // networking.k8s.io/v1 + defaultBackend?: INetworkingBackend & { + resource: { + apiGroup: string; + kind: string; + name: string; + } + } }; status: { loadBalancer: { @@ -75,7 +109,9 @@ export class Ingress extends KubeObject { const host = rule.host ? rule.host : "*"; if (rule.http && rule.http.paths) { rule.http.paths.forEach(path => { - routes.push(protocol + "://" + host + (path.path || "/") + " ⇢ " + path.backend.serviceName + ":" + path.backend.servicePort); + const { serviceName, servicePort } = getBackendServiceNamePort(path.backend); + + routes.push(protocol + "://" + host + (path.path || "/") + " ⇢ " + serviceName + ":" + servicePort); }); } }); @@ -83,6 +119,17 @@ export class Ingress extends KubeObject { return routes; } + getServiceNamePort() { + const { spec } = this; + const serviceName = spec?.defaultBackend?.service.name ?? spec?.backend?.serviceName; + const servicePort = spec?.defaultBackend?.service.port.number ?? spec?.defaultBackend?.service.port.name ?? spec?.backend?.servicePort; + + return { + serviceName, + servicePort + }; + } + getHosts() { const { spec: { rules } } = this; if (!rules) return []; @@ -91,22 +138,24 @@ export class Ingress extends KubeObject { getPorts() { const ports: number[] = []; - const { spec: { tls, rules, backend } } = this; + const { spec: { tls, rules, backend, defaultBackend } } = this; const httpPort = 80; const tlsPort = 443; + + // Note: not using the port name (string) + const servicePort = defaultBackend?.service.port.number ?? backend?.servicePort; + if (rules && rules.length > 0) { if (rules.some(rule => rule.hasOwnProperty("http"))) { ports.push(httpPort); } - } - else { - if (backend && backend.servicePort) { - ports.push(backend.servicePort); - } + } else if (servicePort !== undefined) { + ports.push(Number(servicePort)); } if (tls && tls.length > 0) { ports.push(tlsPort); } + return ports.join(", "); } @@ -121,4 +170,8 @@ export class Ingress extends KubeObject { export const ingressApi = new IngressApi({ objectConstructor: Ingress, -}); + // Add fallback for Kubernetes <1.19 + checkPreferredVersion: true, + fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], + logStuff: true +} as any); diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index d4840d3620..0a0d153e18 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -8,10 +8,22 @@ import { apiKube } from "./index"; import { kubeWatchApi } from "./kube-watch-api"; import { apiManager } from "./api-manager"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; -import { apiKubePrefix, isDevelopment } from "../../common/vars"; +import { apiKubePrefix, isDevelopment, isTestEnv } from "../../common/vars"; export interface IKubeApiOptions { - apiBase?: string; // base api-path for listing all resources, e.g. "/api/v1/pods" + /** + * base api-path for listing all resources, e.g. "/api/v1/pods" + */ + apiBase?: string; + + /** + * If the API uses a different API endpoint (e.g. apiBase) depending on the cluster version, + * fallback API bases can be listed individually. + * The first (existing) API base is used in the requests, if apiBase is not found. + * This option only has effect if checkPreferredVersion is true. + */ + fallbackApiBases?: string[]; + objectConstructor?: IKubeObjectConstructor; request?: KubeJsonApi; isNamespaced?: boolean; @@ -35,6 +47,17 @@ export interface IKubePreferredVersion { } } +export interface IKubeResourceList { + resources: { + kind: string; + name: string; + namespaced: boolean; + singularName: string; + storageVersionHash: string; + verbs: string[]; + }[]; +} + export interface IKubeApiCluster { id: string; } @@ -85,7 +108,7 @@ export class KubeApi { if (!options.apiBase) { options.apiBase = objectConstructor.apiBase; } - const { apiBase, apiPrefix, apiGroup, apiVersion, apiVersionWithGroup, resource } = KubeApi.parseApi(options.apiBase); + const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = KubeApi.parseApi(options.apiBase); this.kind = kind; this.isNamespaced = isNamespaced; @@ -108,8 +131,73 @@ export class KubeApi { .join("/"); } + /** + * Returns the latest API prefix/group that contains the required resource. + * First tries options.apiBase, then urls in order from options.fallbackApiBases. + */ + private async getLatestApiPrefixGroup() { + // Note that this.options.apiBase is the "full" url, whereas this.apiBase is parsed + const apiBases = [this.options.apiBase, ...this.options.fallbackApiBases]; + + for (const apiUrl of apiBases) { + // Split e.g. "/apis/extensions/v1beta1/ingresses" to parts + const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = KubeApi.parseApi(apiUrl); + + // Request available resources + try { + const response = await this.request.get(`${apiPrefix}/${apiVersionWithGroup}`); + + // If the resource is found in the group, use this apiUrl + if (response.resources?.find(kubeResource => kubeResource.name === resource)) { + return { apiPrefix, apiGroup }; + } + } catch (error) { + // Exception is ignored as we can try the next url + console.error(error); + } + } + + // Avoid throwing in tests + if (isTestEnv) { + return { + apiPrefix: this.apiPrefix, + apiGroup: this.apiGroup + }; + } + + throw new Error(`Can't find working API for the Kubernetes resource ${this.apiResource}`); + } + + /** + * Get the apiPrefix and apiGroup to be used for fetching the preferred version. + */ + private async getPreferredVersionPrefixGroup() { + if (this.options.fallbackApiBases) { + return this.getLatestApiPrefixGroup(); + } else { + return { + apiPrefix: this.apiPrefix, + apiGroup: this.apiGroup + }; + } + } + protected async checkPreferredVersion() { + if (this.options.fallbackApiBases && !this.options.checkPreferredVersion) { + throw new Error("checkPreferredVersion must be enabled if fallbackApiBases is set in KubeApi"); + } + if (this.options.checkPreferredVersion && this.apiVersionPreferred === undefined) { + const { apiPrefix, apiGroup } = await this.getPreferredVersionPrefixGroup(); + + // The apiPrefix and apiGroup might change due to fallbackApiBases, so we must override them + Object.defineProperty(this, "apiPrefix", { + value: apiPrefix + }); + Object.defineProperty(this, "apiGroup", { + value: apiGroup + }); + const res = await this.request.get(`${this.apiPrefix}/${this.apiGroup}`); Object.defineProperty(this, "apiVersionPreferred", { value: res?.preferredVersion?.version ?? null, diff --git a/src/renderer/components/+network-ingresses/ingress-details.tsx b/src/renderer/components/+network-ingresses/ingress-details.tsx index a258294abe..e2d80da651 100644 --- a/src/renderer/components/+network-ingresses/ingress-details.tsx +++ b/src/renderer/components/+network-ingresses/ingress-details.tsx @@ -14,6 +14,7 @@ import { KubeObjectDetailsProps } from "../kube-object"; import { IngressCharts } from "./ingress-charts"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import { getBackendServiceNamePort } from "../../api/endpoints/ingress.api"; interface Props extends KubeObjectDetailsProps { } @@ -48,7 +49,9 @@ export class IngressDetails extends React.Component { { rule.http.paths.map((path, index) => { - const backend = `${path.backend.serviceName}:${path.backend.servicePort}`; + const { serviceName, servicePort } = getBackendServiceNamePort(path.backend); + const backend =`${serviceName}:${servicePort}`; + return ( {path.path || ""} @@ -100,6 +103,9 @@ export class IngressDetails extends React.Component { Network, Duration, ]; + + const { serviceName, servicePort } = ingress.getServiceNamePort(); + return (
{ {spec.tls.map((tls, index) =>

{tls.secretName}

)} } - {spec.backend && spec.backend.serviceName && spec.backend.servicePort && + {serviceName && servicePort && Service}> - {spec.backend.serviceName}:{spec.backend.servicePort} + {serviceName}:{servicePort} } Rules}/> @@ -134,14 +140,14 @@ export class IngressDetails extends React.Component { kubeObjectDetailRegistry.add({ kind: "Ingress", - apiVersions: ["extensions/v1beta1"], + apiVersions: ["networking.k8s.io/v1", "extensions/v1beta1"], components: { Details: (props) => } }); kubeObjectDetailRegistry.add({ kind: "Ingress", - apiVersions: ["extensions/v1beta1"], + apiVersions: ["networking.k8s.io/v1", "extensions/v1beta1"], priority: 5, components: { Details: (props) =>