diff --git a/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts b/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts new file mode 100644 index 0000000000..eb13464716 --- /dev/null +++ b/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts @@ -0,0 +1,719 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeJsonApi } from "../kube-json-api"; +import { PassThrough } from "stream"; +import type { ApiManager } from "../api-manager"; +import { Ingress, IngressApi } from "../endpoints"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import apiManagerInjectable from "../api-manager/manager.injectable"; +import autoRegistrationInjectable from "../api-manager/auto-registration.injectable"; +import type { Fetch } from "../../fetch/fetch.injectable"; +import fetchInjectable from "../../fetch/fetch.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { flushPromises } from "../../test-utils/flush-promises"; +import createKubeJsonApiInjectable from "../create-kube-json-api.injectable"; +import type { Response, Headers as NodeFetchHeaders } from "node-fetch"; + +const createMockResponseFromString = (url: string, data: string, statusCode = 200) => { + const res: jest.Mocked = { + buffer: jest.fn(async () => { throw new Error("buffer() is not supported"); }), + clone: jest.fn(() => res), + arrayBuffer: jest.fn(async () => { throw new Error("arrayBuffer() is not supported"); }), + blob: jest.fn(async () => { throw new Error("blob() is not supported"); }), + body: new PassThrough(), + bodyUsed: false, + headers: new Headers() as NodeFetchHeaders, + json: jest.fn(async () => JSON.parse(await res.text())), + ok: 200 <= statusCode && statusCode < 300, + redirected: 300 <= statusCode && statusCode < 400, + size: data.length, + status: statusCode, + statusText: "some-text", + text: jest.fn(async () => data), + type: "basic", + url, + formData: jest.fn(async () => { throw new Error("formData() is not supported"); }), + }; + + return res; +}; + +describe("KubeApi", () => { + let request: KubeJsonApi; + let registerApiSpy: jest.SpiedFunction; + let fetchMock: AsyncFnMock; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + fetchMock = asyncFn(); + di.override(fetchInjectable, () => fetchMock); + + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + + request = createKubeJsonApi({ + serverAddress: `http://127.0.0.1:9999`, + apiBase: "/api-kube", + }); + registerApiSpy = jest.spyOn(di.inject(apiManagerInjectable), "registerApi"); + + di.inject(autoRegistrationInjectable); + }); + + describe("on first call to IngressApi.get()", () => { + let ingressApi: IngressApi; + let getCall: Promise; + + beforeEach(async () => { + ingressApi = new IngressApi({ + request, + objectConstructor: Ingress, + apiBase: "/apis/networking.k8s.io/v1/ingresses", + fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], + checkPreferredVersion: true, + }); + getCall = ingressApi.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([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the version list from the api group resolves", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "networking.k8s.io", + versions: [ + { + groupVersion: "networking.k8s.io/v1", + version: "v1", + }, + { + groupVersion: "networking.k8s.io/v1beta1", + version: "v1beta1", + }, + ], + preferredVersion: { + groupVersion: "networking.k8s.io/v1", + version: "v1", + }, + })), + ); + }); + + it("requests resources from the versioned api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when resource request fufills with a resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1", JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + it("sets fields in the api instance", () => { + expect(ingressApi).toEqual(expect.objectContaining({ + apiVersionPreferred: "v1", + apiPrefix: "/apis", + apiGroup: "networking.k8s.io", + })); + }); + + it("registers the api with the changes info", () => { + expect(registerApiSpy).toBeCalledWith(ingressApi); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + + describe("when the request resolves with data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo", JSON.stringify({ + apiVersion: "v1", + kind: "Ingress", + metadata: { + name: "foo", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + })), + ); + result = await getCall; + }); + + it("results in the get call resolving to an instance", () => { + expect(result).toBeInstanceOf(Ingress); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + + describe("when resource request fufills with no resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1", JSON.stringify({ + resources: [], + })), + ); + }); + + it("requests resources from the second versioned api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + + + describe("when resource request fufills with a resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1", JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + it("sets fields in the api instance", () => { + expect(ingressApi).toEqual(expect.objectContaining({ + apiVersionPreferred: "v1beta1", + apiPrefix: "/apis", + apiGroup: "networking.k8s.io", + })); + }); + + it("registers the api with the changes info", () => { + expect(registerApiSpy).toBeCalledWith(ingressApi); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + + describe("when the request resolves with data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({ + apiVersion: "v1", + kind: "Ingress", + metadata: { + name: "foo", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + })), + ); + result = await getCall; + }); + + it("results in the get call resolving to an instance", () => { + expect(result).toBeInstanceOf(Ingress); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + }); + }); + + describe("when the version list from the api group resolves with no versions", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", JSON.stringify({ + "metadata": {}, + "status": "Failure", + "message": "the server could not find the requested resource", + "reason": "NotFound", + "details": { + "causes": [ + { + "reason": "UnexpectedServerResponse", + "message": "404 page not found", + }, + ], + }, + "code": 404, + }), 404), + ); + }); + + it("requests the resources from the base api url from the fallback api", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when resource request fufills with a resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "extensions", + versions: [ + { + groupVersion: "extensions/v1beta1", + version: "v1beta1", + }, + ], + preferredVersion: { + groupVersion: "extensions/v1beta1", + version: "v1beta1", + }, + })), + ); + }); + + it("requests resource versions from the versioned api group from the fallback apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the preferred version request resolves to v1beta1", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions", JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + it("sets fields in the api instance", () => { + expect(ingressApi).toEqual(expect.objectContaining({ + apiVersionPreferred: "v1beta1", + apiPrefix: "/apis", + apiGroup: "extensions", + })); + }); + + it("registers the api with the changes info", () => { + expect(registerApiSpy).toBeCalledWith(ingressApi); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + + describe("when the request resolves with data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({ + apiVersion: "v1beta1", + kind: "Ingress", + metadata: { + name: "foo", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + })), + ); + result = await getCall; + }); + + it("results in the get call resolving to an instance", () => { + expect(result).toBeInstanceOf(Ingress); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 05da1f89d5..9244d5e6ab 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -2,37 +2,97 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - -import { forRemoteCluster, KubeApi } from "../kube-api"; -import { KubeJsonApi } from "../kube-json-api"; -import { KubeObject } from "../kube-object"; -import { delay } from "../../utils/delay"; +import type { KubeApiWatchCallback } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApi, KubeJsonApiData } from "../kube-json-api"; import { PassThrough } from "stream"; -import { ApiManager } from "../api-manager"; -import type { FetchMock } from "jest-fetch-mock/types"; -import { DeploymentApi, Ingress, IngressApi, Pod, PodApi } from "../endpoints"; +import { Deployment, DeploymentApi, NamespaceApi, Pod, PodApi } from "../endpoints"; import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; -import apiManagerInjectable from "../api-manager/manager.injectable"; import autoRegistrationInjectable from "../api-manager/auto-registration.injectable"; +import type { Fetch } from "../../fetch/fetch.injectable"; +import fetchInjectable from "../../fetch/fetch.injectable"; +import type { CreateKubeApiForRemoteCluster } from "../create-kube-api-for-remote-cluster.injectable"; +import createKubeApiForRemoteClusterInjectable from "../create-kube-api-for-remote-cluster.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { flushPromises } from "../../test-utils/flush-promises"; +import createKubeJsonApiInjectable from "../create-kube-json-api.injectable"; +import type { IKubeWatchEvent } from "../kube-watch-event"; +import type { KubeJsonApiDataFor } from "../kube-object"; +import type { Response, Headers as NodeFetchHeaders } from "node-fetch"; import AbortController from "abort-controller"; -jest.mock("../api-manager"); +const createMockResponseFromString = (url: string, data: string, statusCode = 200) => { + const res: jest.Mocked = { + buffer: jest.fn(async () => { throw new Error("buffer() is not supported"); }), + clone: jest.fn(() => res), + arrayBuffer: jest.fn(async () => { throw new Error("arrayBuffer() is not supported"); }), + blob: jest.fn(async () => { throw new Error("blob() is not supported"); }), + body: new PassThrough(), + bodyUsed: false, + headers: new Headers() as NodeFetchHeaders, + json: jest.fn(async () => JSON.parse(await res.text())), + ok: 200 <= statusCode && statusCode < 300, + redirected: 300 <= statusCode && statusCode < 400, + size: data.length, + status: statusCode, + statusText: "some-text", + text: jest.fn(async () => data), + type: "basic", + url, + formData: jest.fn(async () => { throw new Error("formData() is not supported"); }), + }; -const mockFetch = fetch as FetchMock; + return res; +}; -describe("forRemoteCluster", () => { - let apiManager: jest.Mocked; +const createMockResponseFromStream = (url: string, stream: NodeJS.ReadableStream, statusCode = 200) => { + const res: jest.Mocked = { + buffer: jest.fn(async () => { throw new Error("buffer() is not supported"); }), + clone: jest.fn(() => res), + arrayBuffer: jest.fn(async () => { throw new Error("arrayBuffer() is not supported"); }), + blob: jest.fn(async () => { throw new Error("blob() is not supported"); }), + body: stream, + bodyUsed: false, + headers: new Headers() as NodeFetchHeaders, + json: jest.fn(async () => JSON.parse(await res.text())), + ok: 200 <= statusCode && statusCode < 300, + redirected: 300 <= statusCode && statusCode < 400, + size: 10, + status: statusCode, + statusText: "some-text", + text: jest.fn(() => { + const chunks: Buffer[] = []; - beforeEach(() => { + return new Promise((resolve, reject) => { + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stream.on("error", (err) => reject(err)); + stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); + }), + type: "basic", + url, + formData: jest.fn(async () => { throw new Error("formData() is not supported"); }), + }; + + return res; +}; + +describe("createKubeApiForRemoteCluster", () => { + let createKubeApiForRemoteCluster: CreateKubeApiForRemoteCluster; + let fetchMock: AsyncFnMock; + + beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - apiManager = new ApiManager() as jest.Mocked; + fetchMock = asyncFn(); + di.override(fetchInjectable, () => fetchMock); - di.override(apiManagerInjectable, () => apiManager); + createKubeApiForRemoteCluster = di.inject(createKubeApiForRemoteClusterInjectable); }); it("builds api client for KubeObject", async () => { - const api = forRemoteCluster({ + const api = createKubeApiForRemoteCluster({ cluster: { server: "https://127.0.0.1:6443", }, @@ -44,232 +104,90 @@ describe("forRemoteCluster", () => { expect(api).toBeInstanceOf(KubeApi); }); - it("builds api client for given KubeApi", async () => { - const api = forRemoteCluster({ - cluster: { - server: "https://127.0.0.1:6443", - }, - user: { - token: "daa", - }, - }, Pod, PodApi); + describe("when building for remote cluster with specific constructor", () => { + let api: PodApi; - expect(api).toBeInstanceOf(PodApi); - }); - - it("calls right api endpoint", async () => { - const api = forRemoteCluster({ - cluster: { - server: "https://127.0.0.1:6443", - }, - user: { - token: "daa", - }, - }, Pod); - - mockFetch.mockResponse(async (request: any) => { - expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); - - return { - body: "hello", - }; + beforeEach(() => { + api = createKubeApiForRemoteCluster({ + cluster: { + server: "https://127.0.0.1:6443", + }, + user: { + token: "daa", + }, + }, Pod, PodApi); }); - expect.hasAssertions(); + it("uses the constructor", () => { + expect(api).toBeInstanceOf(PodApi); + }); - await api.list(); + describe("when calling list without namespace", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list(); + + // This is required because of how JS promises work + await flushPromises(); + }); + + it("should request pods from default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "https://127.0.0.1:6443/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["https://127.0.0.1:6443/api/v1/pods"], + createMockResponseFromString("https://127.0.0.1:6443/api/v1/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata:{ + resourceVersion: "452899", + }, + items: [], + })), + ); + }); + + it("resolves the list call", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); }); }); describe("KubeApi", () => { let request: KubeJsonApi; - let apiManager: jest.Mocked; + let fetchMock: AsyncFnMock; - beforeEach(() => { + beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - request = new KubeJsonApi({ + fetchMock = asyncFn(); + di.override(fetchInjectable, () => fetchMock); + + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + + request = createKubeJsonApi({ serverAddress: `http://127.0.0.1:9999`, apiBase: "/api-kube", }); - apiManager = new ApiManager() as jest.Mocked; - di.override(apiManagerInjectable, () => apiManager); di.inject(autoRegistrationInjectable); }); - it("uses url from apiBase if apiBase contains the resource", async () => { - mockFetch.mockResponse(async (request: any) => { - if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { - return { - body: JSON.stringify({ - resources: [{ - name: "ingresses", - }], - }), - }; - } else if (request.url === "http://127.0.0.1:9999/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", - }], - }), - }; - } else { - return { - body: JSON.stringify({ - resources: [], - }), - }; - } - }); - - const apiBase = "/apis/networking.k8s.io/v1/ingresses"; - const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; - const kubeApi = new IngressApi({ - request, - objectConstructor: Ingress, - apiBase, - fallbackApiBases: [fallbackApiBase], - checkPreferredVersion: true, - }); - - await kubeApi.get({ - name: "foo", - namespace: "default", - }); - expect(kubeApi.apiPrefix).toEqual("/apis"); - expect(kubeApi.apiGroup).toEqual("networking.k8s.io"); - }); - - it("uses url from fallbackApiBases if apiBase lacks the resource", async () => { - mockFetch.mockResponse(async (request: any) => { - if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { - return { - body: JSON.stringify({ - resources: [], - }), - }; - } else if (request.url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { - return { - body: JSON.stringify({ - resources: [{ - name: "ingresses", - }], - }), - }; - } else { - return { - body: JSON.stringify({ - resources: [], - }), - }; - } - }); - - const apiBase = "apis/networking.k8s.io/v1/ingresses"; - const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; - const kubeApi = new IngressApi({ - request, - objectConstructor: Object.assign(KubeObject, { apiBase }), - kind: "Ingress", - fallbackApiBases: [fallbackApiBase], - checkPreferredVersion: true, - }); - - await kubeApi.get({ - name: "foo", - namespace: "default", - }); - expect(kubeApi.apiPrefix).toEqual("/apis"); - expect(kubeApi.apiGroup).toEqual("extensions"); - }); - - describe("checkPreferredVersion", () => { - it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred", async () => { - expect.hasAssertions(); - - const api = new IngressApi({ - objectConstructor: Ingress, - checkPreferredVersion: true, - fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], - request: { - get: jest.fn() - .mockImplementation((path: string) => { - switch (path) { - case "/apis/networking.k8s.io/v1": - throw new Error("no"); - case "/apis/extensions/v1beta1": - return { - resources: [ - { - name: "ingresses", - }, - ], - }; - case "/apis/extensions": - return { - preferredVersion: { - version: "v1beta1", - }, - }; - default: - throw new Error("unknown path"); - } - }), - } as Partial as KubeJsonApi, - }); - - await (api as any).checkPreferredVersion(); - - expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(apiManager.registerApi).toBeCalledWith(api); - }); - - it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred with non-grouped apis", async () => { - expect.hasAssertions(); - - const api = new PodApi({ - objectConstructor: Pod, - checkPreferredVersion: true, - fallbackApiBases: ["/api/v1beta1/pods"], - request: { - get: jest.fn() - .mockImplementation((path: string) => { - switch (path) { - case "/api/v1": - throw new Error("no"); - case "/api/v1beta1": - return { - resources: [ - { - name: "pods", - }, - ], - }; - case "/api": - return { - preferredVersion: { - version: "v1beta1", - }, - }; - default: - throw new Error("unknown path"); - } - }), - } as Partial as KubeJsonApi, - }); - - await (api as any).checkPreferredVersion(); - - expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(apiManager.registerApi).toBeCalledWith(api); - }); - }); - - describe("patch", () => { + describe("patching deployments", () => { let api: DeploymentApi; beforeEach(() => { @@ -278,140 +196,377 @@ describe("KubeApi", () => { }); }); - it("sends strategic patch by default", async () => { - expect.hasAssertions(); + describe("when patching a resource without providing a strategy", () => { + let patchRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PATCH"); - expect(request.headers.get("content-type")).toMatch("strategic-merge-patch"); - expect(request.body?.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + beforeEach(async () => { + patchRequest = api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }); - return {}; + // This is needed because of how JS promises work + await flushPromises(); }); - await api.patch({ name: "test", namespace: "default" }, { - spec: { replicas: 2 }, + it("requests a patch using strategic merge", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + method: "patch", + body: JSON.stringify({ spec: { replicas: 2 }}), + }, + ]); + }); + + describe("when the patch request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", JSON.stringify({ + apiVersion: "v1", + kind: "Deployment", + metadata: { + name: "test", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + spec: { + replicas: 2, + }, + })), + ); + }); + + it("resolves the patch call", async () => { + expect(await patchRequest).toBeInstanceOf(Deployment); + }); }); }); - it("allows to use merge patch", async () => { - expect.hasAssertions(); + describe("when patching a resource using json patch", () => { + let patchRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PATCH"); - expect(request.headers.get("content-type")).toMatch("merge-patch"); - expect(request.body?.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + beforeEach(async () => { + patchRequest = api.patch({ name: "test", namespace: "default" }, [ + { op: "replace", path: "/spec/replicas", value: 2 }, + ], "json"); - return {}; + // This is needed because of how JS promises work + await flushPromises(); }); - await api.patch({ name: "test", namespace: "default" }, { - spec: { replicas: 2 }, - }, "merge"); + it("requests a patch using json merge", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", + { + headers: { + "content-type": "application/json-patch+json", + }, + method: "patch", + body: JSON.stringify([ + { op: "replace", path: "/spec/replicas", value: 2 }, + ]), + }, + ]); + }); + + describe("when the patch request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", JSON.stringify({ + apiVersion: "v1", + kind: "Deployment", + metadata: { + name: "test", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + spec: { + replicas: 2, + }, + })), + ); + }); + + it("resolves the patch call", async () => { + expect(await patchRequest).toBeInstanceOf(Deployment); + }); + }); }); - it("allows to use json patch", async () => { - expect.hasAssertions(); + describe("when patching a resource using merge patch", () => { + let patchRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PATCH"); - expect(request.headers.get("content-type")).toMatch("json-patch"); - expect(request.body?.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }])); + beforeEach(async () => { + patchRequest = api.patch( + { name: "test", namespace: "default" }, + { metadata: { annotations: { provisioned: "True" }}}, + "merge", + ); - return {}; + // This is needed because of how JS promises work + await flushPromises(); }); - await api.patch({ name: "test", namespace: "default" }, [ - { op: "replace", path: "/spec/replicas", value: 2 }, - ], "json"); - }); - - it("allows deep partial patch", async () => { - expect.hasAssertions(); - - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PATCH"); - expect(request.headers.get("content-type")).toMatch("merge-patch"); - expect(request.body?.toString()).toEqual(JSON.stringify({ metadata: { annotations: { provisioned: "true" }}})); - - return {}; + it("requests a patch using json merge", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", + { + headers: { + "content-type": "application/merge-patch+json", + }, + method: "patch", + body: JSON.stringify({ metadata: { annotations: { provisioned: "True" }}}), + }, + ]); }); - await api.patch( - { name: "test", namespace: "default" }, - { metadata: { annotations: { provisioned: "true" }}}, - "merge", - ); + describe("when the patch request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", JSON.stringify({ + apiVersion: "v1", + kind: "Deployment", + metadata: { + name: "test", + namespace: "default", + resourceVersion: "1", + uid: "12345", + annotations: { + provisioned: "True", + }, + }, + })), + ); + }); + + it("resolves the patch call", async () => { + expect(await patchRequest).toBeInstanceOf(Deployment); + }); + }); }); }); - describe("delete", () => { + describe("deleting pods (namespace scoped resource)", () => { let api: PodApi; beforeEach(() => { api = new PodApi({ request, - objectConstructor: Pod, }); }); - it("sends correct request with empty namespace", async () => { - expect.hasAssertions(); - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("DELETE"); - expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/pods/foo?propagationPolicy=Background"); + describe("when deleting by just name", () => { + let deleteRequest: Promise; - return {}; + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo" }); + + // This is required for how JS promises work + await flushPromises(); }); - await api.delete({ name: "foo", namespace: "" }); + it("requests deleting pod in default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); }); - it("sends correct request without namespace", async () => { - expect.hasAssertions(); - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("DELETE"); - expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"); + describe("when deleting by name and empty namespace", () => { + let deleteRequest: Promise; - return {}; + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo", namespace: "" }); + + // This is required for how JS promises work + await flushPromises(); }); - await api.delete({ name: "foo" }); + it("requests deleting pod in default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); }); - it("sends correct request with namespace", async () => { - expect.hasAssertions(); - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("DELETE"); - expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods/foo?propagationPolicy=Background"); + describe("when deleting by name and namespace", () => { + let deleteRequest: Promise; - return {}; + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo", namespace: "test" }); + + // This is required for how JS promises work + await flushPromises(); }); - await api.delete({ name: "foo", namespace: "kube-system" }); - }); - - it("allows to change propagationPolicy", async () => { - expect.hasAssertions(); - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("DELETE"); - expect(request.url).toMatch("propagationPolicy=Orphan"); - - return {}; + it("requests deleting pod in given namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); }); - await api.delete({ name: "foo", namespace: "default", propagationPolicy: "Orphan" }); + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); }); }); - describe("watch", () => { + describe("deleting namespaces (cluser scoped resource)", () => { + let api: NamespaceApi; + + beforeEach(() => { + api = new NamespaceApi({ + request, + }); + }); + + describe("when deleting by just name", () => { + let deleteRequest: Promise; + + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo" }); + + // This is required for how JS promises work + await flushPromises(); + }); + + it("requests deleting Namespace without namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); + }); + + describe("when deleting by name and empty namespace", () => { + let deleteRequest: Promise; + + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo", namespace: "" }); + + // This is required for how JS promises work + await flushPromises(); + }); + + it("requests deleting Namespace without namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); + }); + + describe("when deleting by name and namespace", () => { + it("rejects request", () => { + expect(api.delete({ name: "foo", namespace: "test" })).rejects.toBeDefined(); + }); + }); + }); + + describe("watching pods", () => { let api: PodApi; let stream: PassThrough; beforeEach(() => { api = new PodApi({ request, - objectConstructor: Pod, }); stream = new PassThrough(); }); @@ -421,184 +576,341 @@ describe("KubeApi", () => { stream.destroy(); }); - it("sends a valid watch request", () => { - const spy = jest.spyOn(request, "getResponse"); + describe("when watching in a namespace", () => { + let stopWatch: () => void; + let callback: jest.MockedFunction; - mockFetch.mockResponse(async () => { - return { - // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 - body: stream as unknown as string, - }; + beforeEach(async () => { + callback = jest.fn(); + stopWatch = api.watch({ + namespace: "kube-system", + callback, + }); + + await flushPromises(); }); - api.watch({ namespace: "kube-system" }); - expect(spy).toHaveBeenCalledWith("/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", expect.anything(), expect.anything()); + it("requests the watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with a stream", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ([url, init]) => { + const isMatch = url === "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion="; + + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } + + return isMatch; + }, + createMockResponseFromStream("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", stream), + ); + }); + + describe("when some data comes back on the stream", () => { + beforeEach(() => { + stream.emit("data", `${JSON.stringify({ + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + uid: "123456", + }, + }, + } as IKubeWatchEvent>)}\n`); + }); + + it("calls the callback with the data", () => { + expect(callback).toBeCalledWith( + { + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + selfLink: "/api/v1/namespaces/kube-system/pods/foobar", + uid: "123456", + }, + }, + }, + null, + ); + }); + + describe("when stopping the watch", () => { + beforeEach(() => { + stopWatch(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + }); + }); }); - it("sends timeout as a query parameter", async () => { - const spy = jest.spyOn(request, "getResponse"); + describe("when watching in a namespace with an abort controller provided", () => { + let callback: jest.MockedFunction; + let abortController: AbortController; - mockFetch.mockResponse(async () => { - return { - // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 - body: stream as unknown as string, - }; + beforeEach(async () => { + callback = jest.fn(); + abortController = new AbortController(); + api.watch({ + namespace: "kube-system", + callback, + abortController, + }); + + await flushPromises(); }); - api.watch({ namespace: "kube-system", timeout: 60 }); - expect(spy).toHaveBeenCalledWith("/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", { query: { timeoutSeconds: 60 }}, expect.anything()); + it("requests the watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with a stream", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ([url, init]) => { + const isMatch = url === "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion="; + + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } + + return isMatch; + }, + createMockResponseFromStream("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", stream), + ); + }); + + describe("when some data comes back on the stream", () => { + beforeEach(() => { + stream.emit("data", `${JSON.stringify({ + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + uid: "123456", + }, + }, + } as IKubeWatchEvent>)}\n`); + }); + + it("calls the callback with the data", () => { + expect(callback).toBeCalledWith( + { + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + selfLink: "/api/v1/namespaces/kube-system/pods/foobar", + uid: "123456", + }, + }, + }, + null, + ); + }); + + describe("when stopping the watch via the controller", () => { + beforeEach(() => { + abortController.abort(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + }); + }); }); - it("aborts watch using abortController", (done) => { - const spy = jest.spyOn(request, "getResponse"); + describe("when watching in a namespace with a timeout", () => { + let stopWatch: () => void; + let callback: jest.MockedFunction; - mockFetch.mockResponse(async request => { - request.signal.addEventListener("abort", () => { - done(); - }); - - return { - // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 - body: stream as unknown as string, - }; - }); - - const abortController = new AbortController(); - - api.watch({ - namespace: "kube-system", - timeout: 60, - abortController, - }); - - expect(spy).toHaveBeenCalledWith("/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", { query: { timeoutSeconds: 60 }}, expect.anything()); - delay(100).then(() => abortController.abort()); - }); - - describe("retries", () => { - it("if request ended", (done) => { - const spy = jest.spyOn(request, "getResponse"); - - jest.spyOn(stream, "on").mockImplementation((event: string | symbol, callback: Function) => { - // End the request in 100ms. - if (event === "end") { - setTimeout(() => { - callback(); - }, 100); - } - - return stream; - }); - - // we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely - jest.spyOn(global, "fetch").mockImplementation(async () => { - return { - ok: true, - body: stream as never, - } as Partial as Response; - }); - - api.watch({ + beforeEach(async () => { + callback = jest.fn(); + stopWatch = api.watch({ namespace: "kube-system", + callback, + timeout: 60, }); - expect(spy).toHaveBeenCalledTimes(1); - - setTimeout(() => { - expect(spy).toHaveBeenCalledTimes(2); - done(); - }, 2000); + await flushPromises(); }); - it("if request not closed after timeout", (done) => { - const spy = jest.spyOn(request, "getResponse"); - - mockFetch.mockResponse(async () => { - return { - // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 - body: stream as unknown as string, - }; - }); - - const timeoutSeconds = 1; - - api.watch({ - namespace: "kube-system", - timeout: timeoutSeconds, - }); - - expect(spy).toHaveBeenCalledTimes(1); - - setTimeout(() => { - expect(spy).toHaveBeenCalledTimes(2); - done(); - }, timeoutSeconds * 1000 * 1.2); + it("requests the watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); }); - it("retries only once if request ends and timeout is set", (done) => { - const spy = jest.spyOn(request, "getResponse"); + describe("when the request resolves with a stream", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ([url, init]) => { + const isMatch = url === "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60"; - jest.spyOn(stream, "on").mockImplementation((event: string | symbol, callback: Function) => { - // End the request in 100ms. - if (event === "end") { - setTimeout(() => { - callback(); - }, 100); - } + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } - return stream; + return isMatch; + }, + createMockResponseFromStream("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60", stream), + ); }); - // we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely - jest.spyOn(global, "fetch").mockImplementation(async () => { - return { - ok: true, - body: stream as never, - } as Partial as Response; + describe("when some data comes back on the stream", () => { + beforeEach(() => { + stream.emit("data", `${JSON.stringify({ + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + uid: "123456", + }, + }, + } as IKubeWatchEvent>)}\n`); + }); + + it("calls the callback with the data", () => { + expect(callback).toBeCalledWith( + { + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + selfLink: "/api/v1/namespaces/kube-system/pods/foobar", + uid: "123456", + }, + }, + }, + null, + ); + }); + + describe("when stopping the watch", () => { + beforeEach(() => { + stopWatch(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + + describe("when the watch ends", () => { + beforeEach(() => { + stream.end(); + }); + + it("requests a new watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when stopping the watch", () => { + beforeEach(() => { + stopWatch(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + }); }); - - const timeoutSeconds = 0.5; - - api.watch({ - namespace: "kube-system", - timeout: timeoutSeconds, - }); - - expect(spy).toHaveBeenCalledTimes(1); - - setTimeout(() => { - expect(spy).toHaveBeenCalledTimes(2); - done(); - }, 2000); - }); - - afterEach(() => { - jest.clearAllMocks(); }); }); }); - describe("create", () => { + describe("creating pods", () => { let api: PodApi; beforeEach(() => { api = new PodApi({ request, - objectConstructor: Pod, }); }); - it("should add kind and apiVersion", async () => { - expect.hasAssertions(); + describe("when creating a pod", () => { + let createRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("POST"); - expect(JSON.parse(String(request.body))).toEqual({ - kind: "Pod", - apiVersion: "v1", + beforeEach(async () => { + createRequest = api.create({ + name: "foobar", + namespace: "default", + }, { metadata: { - name: "foobar", - namespace: "default", + labels: { + foo: "bar", + }, }, spec: { containers: [ @@ -617,101 +929,324 @@ describe("KubeApi", () => { }, }); - return {}; + // This is required because of how JS promises work + await flushPromises(); }); - await api.create({ - name: "foobar", - namespace: "default", - }, { - spec: { - containers: [ - { - name: "web", - image: "nginx", - ports: [ - { - name: "web", - containerPort: 80, - protocol: "TCP", + it("should request to create a pod with full descriptor", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", + { + headers: { + "content-type": "application/json", + }, + method: "post", + body: JSON.stringify({ + metadata: { + labels: { + foo: "bar", }, - ], - }, - ], - }, - }); - }); - - it("doesn't override metadata.labels", async () => { - expect.hasAssertions(); - - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("POST"); - expect(JSON.parse(String(request.body))).toEqual({ - kind: "Pod", - apiVersion: "v1", - metadata: { - name: "foobar", - namespace: "default", - labels: { - foo: "bar", - }, + name: "foobar", + namespace: "default", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + kind: "Pod", + apiVersion: "v1", + }), }, + ]); + }); + + describe("when request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", JSON.stringify({ + kind: "Pod", + apiVersion: "v1", + metadata: { + name: "foobar", + namespace: "default", + labels: { + foo: "bar", + }, + resourceVersion: "1", + uid: "123456798", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + })), + ); }); - return {}; - }); - - await api.create({ - name: "foobar", - namespace: "default", - }, { - metadata: { - labels: { - foo: "bar", - }, - }, + it("call should resolve in a Pod instance", async () => { + expect(await createRequest).toBeInstanceOf(Pod); + }); }); }); }); - describe("update", () => { + describe("updating pods", () => { let api: PodApi; beforeEach(() => { api = new PodApi({ request, - objectConstructor: Pod, }); }); - it("doesn't override metadata.labels", async () => { - expect.hasAssertions(); + describe("when updating a pod", () => { + let updateRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PUT"); - expect(JSON.parse(String(request.body))).toEqual({ + beforeEach(async () => { + updateRequest = api.update({ + name: "foobar", + namespace: "default", + }, { + kind: "Pod", + apiVersion: "v1", metadata: { - name: "foobar", - namespace: "default", labels: { foo: "bar", }, }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, }); - return {}; + await flushPromises(); }); - await api.update({ - name: "foobar", - namespace: "default", - }, { - metadata: { - labels: { - foo: "bar", + it("should request that the pod is updated", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foobar", + { + headers: { + "content-type": "application/json", + }, + method: "put", + body: JSON.stringify({ + kind: "Pod", + apiVersion: "v1", + metadata: { + labels: { + foo: "bar", + }, + name: "foobar", + namespace: "default", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + }), }, - }, + ]); + }); + + describe("when the request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foobar"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foobar", JSON.stringify({ + kind: "Pod", + apiVersion: "v1", + metadata: { + name: "foobar", + namespace: "default", + labels: { + foo: "bar", + }, + resourceVersion: "1", + uid: "123456798", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + })), + ); + }); + + it("the call should resolve to a Pod", async () => { + expect(await updateRequest).toBeInstanceOf(Pod); + }); + }); + }); + }); + + describe("listing pods", () => { + let api: PodApi; + + beforeEach(() => { + api = new PodApi({ + request, + }); + }); + + describe("when listing pods with no descriptor", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list(); + + await flushPromises(); + }); + + it("should request that the pods from all namespaces", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + + describe("when listing pods with descriptor with namespace=''", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list({ + namespace: "", + }); + + await flushPromises(); + }); + + it("should request that the pods from all namespaces", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + + describe("when listing pods with descriptor with namespace='default'", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list({ + namespace: "default", + }); + + await flushPromises(); + }); + + it("should request that the pods from just the default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); }); }); }); diff --git a/src/common/k8s-api/endpoints/cron-job.api.injectable.ts b/src/common/k8s-api/endpoints/cron-job.api.injectable.ts index 252dcd624e..e2230ee2db 100644 --- a/src/common/k8s-api/endpoints/cron-job.api.injectable.ts +++ b/src/common/k8s-api/endpoints/cron-job.api.injectable.ts @@ -14,9 +14,6 @@ const cronJobApiInjectable = getInjectable({ assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "cronJobApi is only available in certain environments"); return new CronJobApi({ - fallbackApiBases: [ - "/apis/batch/v1beta1/cronjobs", - ], checkPreferredVersion: true, }); }, diff --git a/src/common/k8s-api/endpoints/job.api.injectable.ts b/src/common/k8s-api/endpoints/job.api.injectable.ts index a9c4252e59..fc25c8c61f 100644 --- a/src/common/k8s-api/endpoints/job.api.injectable.ts +++ b/src/common/k8s-api/endpoints/job.api.injectable.ts @@ -13,7 +13,9 @@ const jobApiInjectable = getInjectable({ instantiate: (di) => { assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "jobApi is only available in certain environments"); - return new JobApi(); + return new JobApi({ + checkPreferredVersion: true, + }); }, injectionToken: kubeApiInjectionToken, diff --git a/src/common/k8s-api/kube-api-parse.ts b/src/common/k8s-api/kube-api-parse.ts index 509f1c28e9..dcb8b18636 100644 --- a/src/common/k8s-api/kube-api-parse.ts +++ b/src/common/k8s-api/kube-api-parse.ts @@ -17,6 +17,7 @@ export interface IKubeApiLinkRef { export interface IKubeApiParsed extends IKubeApiLinkRef { apiBase: string; + apiPrefix: string; apiGroup: string; apiVersionWithGroup: string; } diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index ddc2456668..814658c99f 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -24,10 +24,13 @@ import { Agent } from "https"; import type { Patch } from "rfc6902"; import assert from "assert"; import type { PartialDeep } from "type-fest"; -import logger from "../logger"; +import type { Logger } from "../logger"; import { Environments, getEnvironmentSpecificLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import autoRegistrationEmitterInjectable from "./api-manager/auto-registration-emitter.injectable"; import type AbortController from "abort-controller"; +import loggerInjectable from "../logger.injectable"; +import { matches } from "lodash/fp"; +import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; /** * The options used for creating a `KubeApi` @@ -151,6 +154,26 @@ export interface ILocalKubeApiConfig { }; } +export interface KubeApiResourceVersion { + groupVersion: string; + version: string; +} + +export interface KubeApiResourceVersionList { + apiVersion: string; + kind: string; + name: string; + preferredVersion: KubeApiResourceVersion; + versions: KubeApiResourceVersion[]; +} + +const not = (fn: (val: T) => boolean) => (val: T) => !(fn(val)); + +const getOrderedVersions = (list: KubeApiResourceVersionList): KubeApiResourceVersion[] => [ + list.preferredVersion, + ...list.versions.filter(not(matches(list.preferredVersion))), +]; + export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Background"; /** @@ -343,6 +366,10 @@ function legacyRegisterApi(api: KubeApi): void { } } +export interface KubeApiDependencies { + readonly logger: Logger; +} + export class KubeApi< Object extends KubeObject = KubeObject, Data extends KubeJsonApiDataFor = KubeJsonApiDataFor, @@ -365,6 +392,8 @@ export class KubeApi< protected readonly fullApiPathname: string; protected readonly fallbackApiBases: string[] | undefined; + protected readonly dependencies: KubeApiDependencies; + constructor(opts: KubeApiOptions) { const { objectConstructor, @@ -397,6 +426,10 @@ export class KubeApi< this.request = request; this.objectConstructor = objectConstructor; legacyRegisterApi(this); + + this.dependencies = { + logger: asLegacyGlobalForExtensionApi(loggerInjectable), + }; } get apiVersionWithGroup() { @@ -419,15 +452,20 @@ export class KubeApi< } try { - // Split e.g. "/apis/extensions/v1beta1/ingresses" to parts - const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = parseKubeApi(apiUrl); + const { apiPrefix, apiGroup, resource } = parseKubeApi(apiUrl); + const list = await this.request.get(`${apiPrefix}/${apiGroup}`) as KubeApiResourceVersionList; + const resourceVersions = getOrderedVersions(list); - // Request available resources - const { resources } = (await this.request.get(`${apiPrefix}/${apiVersionWithGroup}`)) as unknown as KubeApiResourceList; + for (const resourceVersion of resourceVersions) { + const { resources } = await this.request.get(`${apiPrefix}/${resourceVersion.groupVersion}`) as KubeApiResourceList; - // If the resource is found in the group, use this apiUrl - if (resources.find(({ name }) => name === resource)) { - return { apiPrefix, apiGroup }; + if (resources.some(({ name }) => name === resource)) { + return { + apiPrefix, + apiGroup, + apiVersionPreferred: resourceVersion.version, + }; + } } } catch (error) { // Exception is ignored as we can try the next url @@ -437,48 +475,19 @@ export class KubeApi< 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.fallbackApiBases) { - try { - return await this.getLatestApiPrefixGroup(); - } catch (error) { - // If valid API wasn't found, log the error and return defaults below - logger.error(`[KUBE-API]: ${error}`); - } - } - - return { - apiPrefix: this.apiPrefix, - apiGroup: this.apiGroup, - }; - } - 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.apiVersionPreferred === undefined) { - const { apiPrefix, apiGroup } = await this.getPreferredVersionPrefixGroup(); + const { apiPrefix, apiGroup, apiVersionPreferred } = await this.getLatestApiPrefixGroup(); - assert(apiPrefix); - - // The apiPrefix and apiGroup might change due to fallbackApiBases, so we must override them this.apiPrefix = apiPrefix; this.apiGroup = apiGroup; - - const url = [apiPrefix, apiGroup].filter(Boolean).join("/"); - const res = await this.request.get(url) as IKubePreferredVersion; - - this.apiVersionPreferred = res?.preferredVersion?.version; - - if (this.apiVersionPreferred) { - this.apiBase = this.computeApiBase(); - legacyRegisterApi(this); - } + this.apiVersionPreferred = apiVersionPreferred; + this.apiBase = this.computeApiBase(); + legacyRegisterApi(this); } } @@ -710,7 +719,7 @@ export class KubeApi< const abortController = new WrappedAbortController(opts.abortController); abortController.signal.addEventListener("abort", () => { - logger.info(`[KUBE-API] watch (${watchId}) aborted ${watchUrl}`); + this.dependencies.logger.info(`[KUBE-API] watch (${watchId}) aborted ${watchUrl}`); clearTimeout(timedRetry); }); @@ -721,7 +730,7 @@ export class KubeApi< timeout: 600_000, }); - logger.info(`[KUBE-API] watch (${watchId}) ${retry === true ? "retried" : "started"} ${watchUrl}`); + this.dependencies.logger.info(`[KUBE-API] watch (${watchId}) ${retry === true ? "retried" : "started"} ${watchUrl}`); responsePromise .then(response => { @@ -729,7 +738,7 @@ export class KubeApi< let requestRetried = false; if (!response.ok) { - logger.warn(`[KUBE-API] watch (${watchId}) error response ${watchUrl}`, { status: response.status }); + this.dependencies.logger.warn(`[KUBE-API] watch (${watchId}) error response ${watchUrl}`, { status: response.status }); return callback(null, response); } @@ -746,7 +755,7 @@ export class KubeApi< // Close current request abortController.abort(); - logger.info(`[KUBE-API] Watch timeout set, but not retried, retrying now`); + this.dependencies.logger.info(`[KUBE-API] Watch timeout set, but not retried, retrying now`); requestRetried = true; @@ -765,7 +774,7 @@ export class KubeApi< return; } - logger.info(`[KUBE-API] watch (${watchId}) ${eventName} ${watchUrl}`); + this.dependencies.logger.info(`[KUBE-API] watch (${watchId}) ${eventName} ${watchUrl}`); requestRetried = true; @@ -794,8 +803,9 @@ export class KubeApi< }); }) .catch(error => { - logger.error(`[KUBE-API] watch (${watchId}) throwed ${watchUrl}`, error); - + if (!abortController.signal.aborted) { + this.dependencies.logger.error(`[KUBE-API] watch (${watchId}) threw ${watchUrl}`, error); + } callback(null, error); }); diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index 32a762a74c..196facd079 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -221,7 +221,7 @@ export abstract class KubeObjectStore< try { return await res ?? []; } catch (error) { - onLoadFailure(String(error)); + onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: error })); // reset the store because we are loading all, so that nothing is displayed this.items.clear(); @@ -249,7 +249,7 @@ export abstract class KubeObjectStore< case "rejected": if (onLoadFailure) { - onLoadFailure(result.reason.message || result.reason); + onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: result.reason })); } else { // if onLoadFailure is not provided then preserve old behaviour throw result.reason; diff --git a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx index 03ffef3199..707351c231 100644 --- a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx @@ -49,6 +49,18 @@ interface Dependencies { toggleKubeDetailsPane: ToggleKubeDetailsPane; } +const getLoadErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + if (error.cause) { + return `${error.message}: ${getLoadErrorMessage(error.cause)}`; + } + + return error.message; + } + + return `${error}`; +}; + @observer class NonInjectedKubeObjectListLayout< K extends KubeObject, @@ -59,7 +71,7 @@ class NonInjectedKubeObjectListLayout< subscribeStores: true, }; - private loadErrors = observable.array(); + private readonly loadErrors = observable.array(); @computed get selectedItem() { return this.props.store.getByPath(this.props.kubeSelectedUrlParam.get()); @@ -78,7 +90,9 @@ class NonInjectedKubeObjectListLayout< if (subscribeStores) { reactions.push( this.props.subscribeToStores(stores, { - onLoadFailure: error => this.loadErrors.push(String(error)), + onLoadFailure: error => { + this.loadErrors.push(getLoadErrorMessage(error)); + }, }), ); }