From 23d5d40d624ebebe9963503c2fd67bef058aec88 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 12 Nov 2021 11:15:33 +0200 Subject: [PATCH] Implement KubeApi.patch (#4325) * implement KubeApi.patch Signed-off-by: Jari Kolehmainen * cleanup tests Signed-off-by: Jari Kolehmainen * keep it backward compatible Signed-off-by: Jari Kolehmainen --- src/common/k8s-api/__tests__/kube-api.test.ts | 77 +++++++++++++++++-- src/common/k8s-api/kube-api.ts | 27 +++++++ src/common/k8s-api/kube-object.store.ts | 22 +++++- src/common/k8s-api/kube-object.ts | 8 ++ 4 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 510cfe280c..ffc03a4fe7 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -19,11 +19,19 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { Pod, PodsApi } from "../endpoints/pods.api"; +import type { Request } from "node-fetch"; import { forRemoteCluster, KubeApi } from "../kube-api"; import { KubeJsonApi } from "../kube-json-api"; import { KubeObject } from "../kube-object"; +class TestKubeObject extends KubeObject { + static kind = "Pod"; + static namespaced = true; + static apiBase = "/api/v1/pods"; +} + +class TestKubeApi extends KubeApi {} + describe("forRemoteCluster", () => { it("builds api client for KubeObject", async () => { const api = forRemoteCluster({ @@ -33,7 +41,7 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, Pod); + }, TestKubeObject); expect(api).toBeInstanceOf(KubeApi); }); @@ -46,9 +54,9 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, Pod, PodsApi); + }, TestKubeObject, TestKubeApi); - expect(api).toBeInstanceOf(PodsApi); + expect(api).toBeInstanceOf(TestKubeApi); }); it("calls right api endpoint", async () => { @@ -59,7 +67,7 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, Pod); + }, TestKubeObject); (fetch as any).mockResponse(async (request: any) => { expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); @@ -167,4 +175,63 @@ describe("KubeApi", () => { expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiGroup).toEqual("extensions"); }); + + describe("patch", () => { + let api: TestKubeApi; + + beforeEach(() => { + api = new TestKubeApi({ + request, + objectConstructor: TestKubeObject, + }); + }); + + it("sends strategic patch by default", async () => { + expect.hasAssertions(); + + (fetch as any).mockResponse(async (request: 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 }})); + + return {}; + }); + + await api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }); + }); + + it("allows to use merge patch", async () => { + expect.hasAssertions(); + + (fetch as any).mockResponse(async (request: 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 }})); + + return {}; + }); + + await api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }, "merge"); + }); + + it("allows to use json patch", async () => { + expect.hasAssertions(); + + (fetch as any).mockResponse(async (request: 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 }])); + + return {}; + }); + + await api.patch({ name: "test", namespace: "default" }, [ + { op: "replace", path: "/spec/replicas", value: 2 }, + ], "json"); + }); + }); }); diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 779656855d..c9930a148c 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -37,6 +37,7 @@ import { noop } from "../utils"; import type { RequestInit } from "node-fetch"; import AbortController from "abort-controller"; import { Agent, AgentOptions } from "https"; +import type { Patch } from "rfc6902"; export interface IKubeApiOptions { /** @@ -207,6 +208,14 @@ export type KubeApiWatchOptions = { retry?: boolean; }; +export type KubeApiPatchType = "merge" | "json" | "strategic"; + +const patchTypeHeaders: Record = { + "merge": "application/merge-patch+json", + "json": "application/json-patch+json", + "strategic": "application/strategic-merge-patch+json", +}; + export class KubeApi { readonly kind: string; readonly apiBase: string; @@ -475,6 +484,24 @@ export class KubeApi { return parsed; } + async patch({ name = "", namespace = "default" } = {}, data?: Partial | Patch, strategy: KubeApiPatchType = "strategic"): Promise { + await this.checkPreferredVersion(); + const apiUrl = this.getUrl({ namespace, name }); + + const res = await this.request.patch(apiUrl, { data }, { + headers: { + "content-type": patchTypeHeaders[strategy], + }, + }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`PATCH request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; + } + async delete({ name = "", namespace = "default" }) { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index f4e0542f66..885d4b9347 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -295,16 +295,30 @@ export abstract class KubeObjectStore extends ItemStore } async patch(item: T, patch: Patch): Promise { - return this.postUpdate(await item.patch(patch)); + return this.postUpdate( + await this.api.patch( + { + name: item.getName(), namespace: item.getNs(), + }, + patch, + "json", + ), + ); } async update(item: T, data: Partial): Promise { - return this.postUpdate(await item.update(data)); + return this.postUpdate( + await this.api.update( + { + name: item.getName(), namespace: item.getNs(), + }, + data, + ), + ); } async remove(item: T) { - await item.delete(); - this.items.remove(item); + await this.api.delete({ name: item.getName(), namespace: item.getNs() }); this.selectedItemsIds.delete(item.getId()); } diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index 0cb4ac15f6..ffed59b3df 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -306,6 +306,9 @@ export class KubeObject { for (const op of patch) { if (KubeObject.nonEditablePaths.has(op.path)) { @@ -328,6 +331,8 @@ export class KubeObject): Promise { // use unified resource-applier api for updating all k8s objects @@ -337,6 +342,9 @@ export class KubeObject