From 735cc8e7af56d290d3b65340e77187ca41b49e22 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 15 Aug 2022 14:41:43 -0400 Subject: [PATCH] Convert all KubeApi related tests to use asyncFn Signed-off-by: Sebastian Malton --- src/common/k8s-api/__tests__/kube-api.test.ts | 1715 ++++++++++++----- src/common/k8s-api/kube-api.ts | 71 +- 2 files changed, 1299 insertions(+), 487 deletions(-) diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 9292fab51a..d47bcce231 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -2,33 +2,36 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeApiWatchCallback } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { KubeJsonApi } from "../kube-json-api"; -import { KubeObject } from "../kube-object"; -import { delay } from "../../utils/delay"; +import type { KubeJsonApi, KubeJsonApiData } from "../kube-json-api"; import { PassThrough } from "stream"; import type { ApiManager } from "../api-manager"; -import { DeploymentApi, Ingress, IngressApi, Pod, PodApi } from "../endpoints"; +import { Deployment, DeploymentApi, Ingress, IngressApi, 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 { JsonApiDependencies } from "../json-api"; -import loggerInjectable from "../../logger.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 { Headers, Response } from "node-fetch"; +import { Response } from "node-fetch"; +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 AbortController from "abort-controller"; describe("createKubeApiForRemoteCluster", () => { let createKubeApiForRemoteCluster: CreateKubeApiForRemoteCluster; - let fetchMock: jest.MockedFunction; + let fetchMock: AsyncFnMock; beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - fetchMock = jest.fn(); + fetchMock = asyncFn(); di.override(fetchInjectable, () => fetchMock); createKubeApiForRemoteCluster = di.inject(createKubeApiForRemoteClusterInjectable); @@ -47,56 +50,83 @@ describe("createKubeApiForRemoteCluster", () => { expect(api).toBeInstanceOf(KubeApi); }); - it("builds api client for given KubeApi", async () => { - const api = createKubeApiForRemoteCluster({ - 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 = createKubeApiForRemoteCluster({ - cluster: { - server: "https://127.0.0.1:6443", - }, - user: { - token: "daa", - }, - }, Pod); - - fetchMock.mockImplementation(async (url) => { - expect(url).toBe("https://127.0.0.1:6443/api/v1/pods"); - - return new Response("hello"); + beforeEach(() => { + api = createKubeApiForRemoteCluster({ + cluster: { + server: "https://127.0.0.1:6443", + }, + user: { + token: "daa", + }, + }, Pod, PodApi); }); - expect(await api.list()).toBeNull(); + it("uses the constructor", () => { + expect(api).toBeInstanceOf(PodApi); + }); + + 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/namespaces/default/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/namespaces/default/pods"], + new Response(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 registerApiSpy: jest.SpiedFunction; - let fetchMock: jest.MockedFunction; + let fetchMock: AsyncFnMock; beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - fetchMock = jest.fn(); + fetchMock = asyncFn(); di.override(fetchInjectable, () => fetchMock); - const dependencies: JsonApiDependencies = { - logger: di.inject(loggerInjectable), - fetch: di.inject(fetchInjectable), - }; + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); - request = new KubeJsonApi(dependencies, { + request = createKubeJsonApi({ serverAddress: `http://127.0.0.1:9999`, apiBase: "/api-kube", }); @@ -105,187 +135,434 @@ describe("KubeApi", () => { di.inject(autoRegistrationInjectable); }); - it("uses url from apiBase if apiBase contains the resource", async () => { - 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, - }); + describe("on first call to IngressApi.get()", () => { + let ingressApi: IngressApi; + let getCall: Promise; - fetchMock.mockImplementation(async (url) => { - if (url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { - return new Response(JSON.stringify({ - resources: [{ - name: "ingresses", - }], - })); - } - - if (url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { - return new Response(JSON.stringify({ - resources: [{ - name: "ingresses", - }], - })); - } - - return new Response(JSON.stringify({ resources: [] })); - }); - - fetchMock.mockImplementation(async (url) => { - if (url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { - return new Response(JSON.stringify({ - resources: [{ - name: "ingresses", - }], - })); - } - - if (url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { - return new Response(JSON.stringify({ - resources: [{ - name: "ingresses", - }], - })); - } - - return new Response(JSON.stringify({ resources: [] })); - }); - - 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 () => { - 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, - }); - - - fetchMock.mockImplementation(async (url) => { - if (url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { - return new Response(JSON.stringify({ - resources: [], - })); - } - - if (url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { - return new Response(JSON.stringify({ - resources: [{ - name: "ingresses", - }], - })); - } - - return new Response(JSON.stringify({ resources: [] })); - }); - - 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({ + beforeEach(async () => { + ingressApi = new IngressApi({ + request, objectConstructor: Ingress, - checkPreferredVersion: true, + apiBase: "/apis/networking.k8s.io/v1/ingresses", 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, + checkPreferredVersion: true, + }); + getCall = ingressApi.get({ + name: "foo", + namespace: "default", }); - await (api as any).checkPreferredVersion(); - - expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(registerApiSpy).toBeCalledWith(api); + // This is needed because of how JS promises work + await flushPromises(); }); - it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred with non-grouped apis", async () => { - expect.hasAssertions(); + 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", + }, + ]); + }); - 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, + 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"], + new Response(JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); }); - await (api as any).checkPreferredVersion(); + it("requests the perferred version of that api group", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); - expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(registerApiSpy).toBeCalledWith(api); + describe("when the preferred version resolves with v1", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io"], + new Response(JSON.stringify({ + preferredVersion: { + version: "v1", + }, + })), + ); + }); + + 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.apiVersionPreferred).toBe("v1"); + expect(ingressApi.apiPrefix).toBe("/apis"); + expect(ingressApi.apiGroup).toBe("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"], + new Response(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"], + new Response(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"], + new Response(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"], + new Response(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"], + new Response(JSON.stringify({ + resources: [], + })), + ); + }); + + 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/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/extensions/v1beta1"], + new Response(JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("requests the preferred version for that api group", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions", + { + 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"], + new Response(JSON.stringify({ + preferredVersion: { + version: "v1beta1", + }, + })), + ); + }); + + 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.apiVersionPreferred).toBe("v1beta1"); + expect(ingressApi.apiPrefix).toBe("/apis"); + expect(ingressApi.apiGroup).toBe("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"], + new Response(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"], + new Response(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"], + new Response(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"], + new Response(JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + }); }); }); - describe("patch", () => { + describe("patching deployments", () => { let api: DeploymentApi; beforeEach(() => { @@ -294,140 +571,377 @@ describe("KubeApi", () => { }); }); - it("sends strategic patch by default", async () => { - expect.hasAssertions(); + describe("when patching a resource without providing a strategy", () => { + let patchRequest: Promise; - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("patch"); - expect(new Headers(init?.headers).get("content-type")).toMatch("strategic-merge-patch"); - expect(init?.body?.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + beforeEach(async () => { + patchRequest = api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }); - return new Response(); + // 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"], + new Response(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; - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("patch"); - expect(new Headers(init?.headers).get("content-type")).toMatch("merge-patch"); - expect(init?.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 new Response(); + // 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"], + new Response(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; - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("patch"); - expect(new Headers(init?.headers).get("content-type")).toMatch("json-patch"); - expect(init?.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 new Response(); + // 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(); - - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("patch"); - expect(new Headers(init?.headers).get("content-type")).toMatch("merge-patch"); - expect(init?.body?.toString()).toEqual(JSON.stringify({ metadata: { annotations: { provisioned: "true" }}})); - - return new Response(); + 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"], + new Response(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(); - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("delete"); - expect(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 new Response(); + 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"], + new Response("{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); }); - it("sends correct request without namespace", async () => { - expect.hasAssertions(); - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("delete"); - expect(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 new Response(); + 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"], + new Response("{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); }); - it("sends correct request with namespace", async () => { - expect.hasAssertions(); - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("delete"); - expect(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 new Response(); + 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(); - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("delete"); - expect(url).toMatch("propagationPolicy=Orphan"); - - return new Response(); + 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"], + new Response("{}"), + ); + }); + + 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"], + new Response("{}"), + ); + }); + + 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"], + new Response("{}"), + ); + }); + + 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(); }); @@ -437,169 +951,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; - fetchMock.mockImplementation(async () => { - return new Response(stream); - }); - - api.watch({ namespace: "kube-system" }); - expect(spy).toHaveBeenCalledWith("/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", expect.anything(), expect.anything()); - }); - - it("sends timeout as a query parameter", async () => { - const spy = jest.spyOn(request, "getResponse"); - - fetchMock.mockImplementation(async () => { - return new Response(stream); - }); - - 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("aborts watch using abortController", (done) => { - const spy = jest.spyOn(request, "getResponse"); - - fetchMock.mockImplementation(async (url, init) => { - init?.signal?.addEventListener("abort", () => { - done(); + beforeEach(async () => { + callback = jest.fn(); + stopWatch = api.watch({ + namespace: "kube-system", + callback, }); - return new Response(stream); + await flushPromises(); }); - const abortController = new AbortController(); - - api.watch({ - namespace: "kube-system", - timeout: 60, - abortController, + 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", + }, + ]); }); - expect(spy).toHaveBeenCalledWith("/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", { query: { timeoutSeconds: 60 }}, expect.anything()); - delay(100).then(() => abortController.abort()); - }); + 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="; - describe("retries", () => { - it("if request ended", (done) => { - const spy = jest.spyOn(request, "getResponse"); + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } - jest.spyOn(stream, "on").mockImplementation((event: string | symbol, callback: Function) => { - // End the request in 100ms. - if (event === "end") { - setTimeout(() => { - callback(); - }, 100); - } - - return stream; + return isMatch; + }, + new Response(stream), + ); }); - fetchMock.mockImplementation(async () => { - return new Response(stream, { - status: 200, + 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 watching in a namespace with an abort controller provided", () => { + let callback: jest.MockedFunction; + let abortController: AbortController; + + beforeEach(async () => { + callback = jest.fn(); + abortController = new AbortController(); api.watch({ namespace: "kube-system", + callback, + abortController, }); - 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"); - - fetchMock.mockImplementation(async () => { - return new Response(stream); - }); - - 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=", + { + 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="; - 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; + }, + new Response(stream), + ); }); - // we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely - fetchMock.mockImplementation(async () => { - return new Response(stream, { - status: 200, + 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); + }); }); }); + }); + }); - const timeoutSeconds = 0.5; + describe("when watching in a namespace with a timeout", () => { + let stopWatch: () => void; + let callback: jest.MockedFunction; - api.watch({ + beforeEach(async () => { + callback = jest.fn(); + stopWatch = api.watch({ namespace: "kube-system", - timeout: timeoutSeconds, + callback, + timeout: 60, }); - expect(spy).toHaveBeenCalledTimes(1); - - setTimeout(() => { - expect(spy).toHaveBeenCalledTimes(2); - done(); - }, 2000); + await flushPromises(); }); - afterEach(() => { - jest.clearAllMocks(); + 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", + }, + ]); + }); + + 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"; + + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } + + return isMatch; + }, + new Response(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); + }); + }); + + 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); + }); + }); + }); + }); }); }); }); - 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; - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("post"); - expect(JSON.parse(String(init?.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: [ @@ -618,101 +1304,190 @@ describe("KubeApi", () => { }, }); - return new Response(); + // 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(); - - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("post"); - expect(JSON.parse(String(init?.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"], + new Response(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 new Response(); - }); - - 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; - fetchMock.mockImplementation(async (url, init) => { - expect(init?.method).toEqual("put"); - expect(JSON.parse(String(init?.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 new Response(); + 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"], + new Response(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); + }); }); }); }); diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 0ac834148e..3df8f3216f 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -15,7 +15,7 @@ import type { IKubeWatchEvent } from "./kube-watch-event"; import type { KubeJsonApiData, KubeJsonApi } from "./kube-json-api"; import type { Disposer } from "../utils"; import { isDefined, noop, WrappedAbortController } from "../utils"; -import type { RequestInit } from "node-fetch"; +import type { RequestInit, Response } from "node-fetch"; import type { Patch } from "rfc6902"; import assert from "assert"; import type { PartialDeep } from "type-fest"; @@ -144,16 +144,37 @@ export interface KubeApiResourceList { export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Background"; -export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void; +export type KubeApiWatchCallback = (data: IKubeWatchEvent | null, error: KubeStatus | Response | null | any) => void; export interface KubeApiWatchOptions> { - namespace: string; + /** + * If the resource is namespaced then the default is `"default"` + */ + namespace?: string; + + /** + * This will be called when either an error occurs or some data is received + */ callback?: KubeApiWatchCallback; + + /** + * This is a way of aborting the request + */ abortController?: AbortController; + + /** + * The ID used for tracking within logs + */ watchId?: string; + + /** + * @default false + */ retry?: boolean; - // timeout in seconds + /** + * timeout in seconds + */ timeout?: number; } @@ -280,13 +301,14 @@ export class KubeApi< */ private async getLatestApiPrefixGroup() { // Note that this.fullApiPathname is the "full" url, whereas this.apiBase is parsed - const apiBases = [this.fullApiPathname, this.objectConstructor.apiBase, ...this.fallbackApiBases ?? []]; + const rawApiBases = [ + this.fullApiPathname, + this.objectConstructor.apiBase, + ...this.fallbackApiBases ?? [], + ].filter(isDefined); + const apiBases = new Set(rawApiBases); for (const apiUrl of apiBases) { - if (!apiUrl) { - continue; - } - try { // Split e.g. "/apis/extensions/v1beta1/ingresses" to parts const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = parseKubeApi(apiUrl); @@ -372,11 +394,17 @@ export class KubeApi< } getUrl({ name, namespace }: Partial = {}, query?: Partial) { + if (!this.isNamespaced && namespace) { + throw new Error("Tried to delete cluster scoped resource in a namespace"); + } + const resourcePath = createKubeApiURL({ apiPrefix: this.apiPrefix, apiVersion: this.apiVersionWithGroup, resource: this.apiResource, - namespace: this.isNamespaced ? namespace ?? "default" : undefined, + namespace: this.isNamespaced + ? namespace || "default" + : undefined, name, }); @@ -457,7 +485,7 @@ export class KubeApi< }); } - async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: KubeApiQueryParams): Promise { + async list({ namespace, reqInit }: KubeApiListOptions = {}, query?: KubeApiQueryParams): Promise { await this.checkPreferredVersion(); const url = this.getUrl({ namespace }); @@ -532,6 +560,10 @@ export class KubeApi< return parsed; } + async patch(desc: ResourceDescriptor, data: PartialDeep): Promise; + async patch(desc: ResourceDescriptor, data: PartialDeep, strategy: "strategic" | "merge"): Promise; + async patch(desc: ResourceDescriptor, data: Patch, strategy: "json"): Promise; + async patch(desc: ResourceDescriptor, data: PartialDeep | Patch, strategy: KubeApiPatchType): Promise; async patch(desc: ResourceDescriptor, data: PartialDeep | Patch, strategy: KubeApiPatchType = "strategic"): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl(desc); @@ -561,7 +593,7 @@ export class KubeApi< }); } - getWatchUrl(namespace = "", query: KubeApiQueryParams = {}) { + getWatchUrl(namespace?: string, query: KubeApiQueryParams = {}) { return this.getUrl({ namespace }, { watch: 1, resourceVersion: this.getResourceVersion(namespace), @@ -569,14 +601,19 @@ export class KubeApi< }); } - watch(opts: KubeApiWatchOptions = { namespace: "", retry: false }): () => void { + watch(opts?: KubeApiWatchOptions): () => void { let errorReceived = false; let timedRetry: NodeJS.Timeout; - const { namespace, callback = noop, retry, timeout } = opts; - const { watchId = `${this.kind.toLowerCase()}-${this.watchId++}` } = opts; + const { + namespace, + callback = noop as KubeApiWatchCallback, + retry = false, + timeout, + watchId = `${this.kind.toLowerCase()}-${this.watchId++}`, + } = opts ?? {}; // Create AbortController for this request - const abortController = new WrappedAbortController(opts.abortController); + const abortController = new WrappedAbortController(opts?.abortController); abortController.signal.addEventListener("abort", () => { logger.info(`[KUBE-API] watch (${watchId}) aborted ${watchUrl}`); @@ -647,7 +684,7 @@ export class KubeApi< byline(response.body).on("data", (line) => { try { - const event = JSON.parse(line) as IKubeWatchEvent>; + const event = JSON.parse(line) as IKubeWatchEvent; if (event.type === "ERROR" && isKubeStatusData(event.object)) { errorReceived = true;