diff --git a/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts b/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts index cd08ef4d5c..dd65f58762 100644 --- a/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts +++ b/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts @@ -4,7 +4,7 @@ */ import type { ApiManager } from "../api-manager"; import type { IngressApi } from "../endpoints"; -import { Ingress } from "../endpoints"; +import { Ingress, HorizontalPodAutoscalerApi } from "../endpoints"; import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; import type { Fetch } from "../../fetch/fetch.injectable"; import fetchInjectable from "../../fetch/fetch.injectable"; @@ -21,6 +21,8 @@ import directoryForKubeConfigsInjectable from "../../app-paths/directory-for-kub import apiManagerInjectable from "../api-manager/manager.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; import ingressApiInjectable from "../endpoints/ingress.api.injectable"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; describe("KubeApi", () => { let fetchMock: AsyncFnMock; @@ -705,4 +707,125 @@ describe("KubeApi", () => { }); }); }); + + describe("on first call to HorizontalPodAutoscalerApi.get()", () => { + let horizontalPodAutoscalerApi: HorizontalPodAutoscalerApi; + + beforeEach(async () => { + horizontalPodAutoscalerApi = new HorizontalPodAutoscalerApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }, { + allowedUsableVersions: { + autoscaling: [ + "v2", + "v2beta2", + "v2beta1", + "v1", + ], + }, + }); + horizontalPodAutoscalerApi.get({ + name: "foo", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("requests version list from the api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "https://127.0.0.1:12345/api-kube/apis/autoscaling", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the version list from the api group resolves with preferredVersion in allowed version", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["https://127.0.0.1:12345/api-kube/apis/autoscaling"], + createMockResponseFromString("https://127.0.0.1:12345/api-kube/apis/autoscaling", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "autoscaling", + versions: [ + { + groupVersion: "autoscaling/v1", + version: "v1", + }, + { + groupVersion: "autoscaling/v1beta1", + version: "v2beta1", + }, + ], + preferredVersion: { + groupVersion: "autoscaling/v1", + version: "v1", + }, + })), + ); + }); + + it("requests resources from the preferred version api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "https://127.0.0.1:12345/api-kube/apis/autoscaling/v1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + }); + + describe("when the version list from the api group resolves with preferredVersion not allowed version", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["https://127.0.0.1:12345/api-kube/apis/autoscaling"], + createMockResponseFromString("https://127.0.0.1:12345/api-kube/apis/autoscaling", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "autoscaling", + versions: [ + { + groupVersion: "autoscaling/v2", + version: "v2", + }, + { + groupVersion: "autoscaling/v2beta1", + version: "v2beta1", + }, + { + groupVersion: "autoscaling/v3", + version: "v3", + }, + ], + preferredVersion: { + groupVersion: "autoscaling/v3", + version: "v3", + }, + })), + ); + }); + + it("requests resources from the non preferred version from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "https://127.0.0.1:12345/api-kube/apis/autoscaling/v2", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + }); + }); }); diff --git a/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts b/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts index ba5483da2c..12ef92165c 100644 --- a/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts +++ b/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts @@ -371,6 +371,14 @@ export class HorizontalPodAutoscaler extends KubeObject< export class HorizontalPodAutoscalerApi extends KubeApi { constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { super(deps, { + allowedUsableVersions: { + autoscaling: [ + "v2", + "v2beta2", + "v2beta1", + "v1", + ], + }, ...opts ?? {}, objectConstructor: HorizontalPodAutoscaler, checkPreferredVersion: true, diff --git a/packages/core/src/common/k8s-api/kube-api.ts b/packages/core/src/common/k8s-api/kube-api.ts index 8e6e86d7da..4d02b43a49 100644 --- a/packages/core/src/common/k8s-api/kube-api.ts +++ b/packages/core/src/common/k8s-api/kube-api.ts @@ -58,14 +58,32 @@ export interface DerivedKubeApiOptions { /** * 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. + * + * This option only has effect if {@link DerivedKubeApiOptions.checkPreferredVersion} is `true`. */ fallbackApiBases?: string[]; + /** + * This option is useful for protecting against newer versions on the same apiBase from being + * used. So that if a certain type only supports `v1`, or `v2` of some kind and then the `v3` + * version becomes the `preferredVersion` on the server but still has `v2` then the `v2` version + * will be used instead. + * + * This can help to prevent crashes in the future if the shape of a kind sufficently changes. + * + * The order is important. It should be sorted and the first entry should be the most preferable. + * + * This option only has effect if {@link DerivedKubeApiOptions.checkPreferredVersion} is `true` + */ + allowedUsableVersions?: Partial>; + /** * If `true` then will check all declared apiBases against the kube api server * for the first accepted one. + * + * @default false */ checkPreferredVersion?: boolean; @@ -133,10 +151,10 @@ export interface KubeApiResourceVersionList { const not = (fn: (val: T) => boolean) => (val: T) => !(fn(val)); -const getOrderedVersions = (list: KubeApiResourceVersionList): KubeApiResourceVersion[] => [ +const getOrderedVersions = (list: KubeApiResourceVersionList, allowedUsableVersions: string[] | undefined): KubeApiResourceVersion[] => [ list.preferredVersion, ...list.versions.filter(not(matches(list.preferredVersion))), -]; +].filter(({ version }) => !allowedUsableVersions || allowedUsableVersions.includes(version)); export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Background"; @@ -233,6 +251,7 @@ export class KubeApi< protected readonly doCheckPreferredVersion: boolean; protected readonly fullApiPathname: string; protected readonly fallbackApiBases: string[] | undefined; + protected readonly allowedUsableVersions: Partial> | undefined; constructor(protected readonly dependencies: KubeApiDependencies, opts: KubeApiOptions) { const { @@ -243,6 +262,7 @@ export class KubeApi< apiBase: fullApiPathname = objectConstructor.apiBase, checkPreferredVersion: doCheckPreferredVersion = false, fallbackApiBases, + allowedUsableVersions, } = opts; assert(fullApiPathname, "apiBase MUST be provied either via KubeApiOptions.apiBase or KubeApiOptions.objectConstructor.apiBase"); @@ -255,6 +275,7 @@ export class KubeApi< this.doCheckPreferredVersion = doCheckPreferredVersion; this.fallbackApiBases = fallbackApiBases; + this.allowedUsableVersions = allowedUsableVersions; this.fullApiPathname = fullApiPathname; this.kind = kind; this.isNamespaced = isNamespaced ?? objectConstructor.namespaced ?? false; @@ -291,7 +312,7 @@ export class KubeApi< try { const { apiPrefix, apiGroup, resource } = parseKubeApi(apiUrl); const list = await this.request.get(`${apiPrefix}/${apiGroup}`) as KubeApiResourceVersionList; - const resourceVersions = getOrderedVersions(list); + const resourceVersions = getOrderedVersions(list, this.allowedUsableVersions?.[apiGroup]); for (const resourceVersion of resourceVersions) { const { resources } = await this.request.get(`${apiPrefix}/${resourceVersion.groupVersion}`) as KubeApiResourceList; @@ -313,8 +334,8 @@ export class KubeApi< } protected async checkPreferredVersion() { - if (this.fallbackApiBases && !this.doCheckPreferredVersion) { - throw new Error("checkPreferredVersion must be enabled if fallbackApiBases is set in KubeApi"); + if (!this.doCheckPreferredVersion && (this.fallbackApiBases || this.allowedUsableVersions)) { + throw new Error("checkPreferredVersion must be enabled if either fallbackApiBases or allowedUsableVersions are set in KubeApi"); } if (this.doCheckPreferredVersion && this.apiVersionPreferred === undefined) {