From 3931c90d301d2b2480d3771b8dad060b24c3a335 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 23 Mar 2023 20:16:50 +0400 Subject: [PATCH] Support using Eviction API where applicable (#7372) * Support using Eviction API when deleting Pods and Deployments, fix #5602 Signed-off-by: Roman * fix: `KubeStatus` message/status/info full explanation Signed-off-by: Roman * added some tests for `PodApi.evict(resourceDescriptor)` Signed-off-by: Roman * revert props.editable & props.removable for `KubeObjectMenu` Signed-off-by: Roman * revert props.editable & props.removable for `KubeObjectMenu` -- missing parts Signed-off-by: Roman --------- Signed-off-by: Roman --- .../common/k8s-api/__tests__/kube-api.test.ts | 77 ++++++++++++++++--- .../src/common/k8s-api/endpoints/pod.api.ts | 49 +++++++++++- packages/core/src/common/k8s-api/kube-api.ts | 9 +++ .../src/common/k8s-api/kube-object.store.ts | 4 +- .../core/src/common/k8s-api/kube-object.ts | 76 +++++++++++++++--- 5 files changed, 190 insertions(+), 25 deletions(-) diff --git a/packages/core/src/common/k8s-api/__tests__/kube-api.test.ts b/packages/core/src/common/k8s-api/__tests__/kube-api.test.ts index eaa355d0e0..e7240895cf 100644 --- a/packages/core/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/packages/core/src/common/k8s-api/__tests__/kube-api.test.ts @@ -11,20 +11,31 @@ import { Deployment, Pod, PodApi } from "../endpoints"; import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; 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 { + 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 "@k8slens/test-utils"; import createKubeJsonApiInjectable from "../create-kube-json-api.injectable"; import type { IKubeWatchEvent } from "../kube-watch-event"; -import type { KubeJsonApiDataFor } from "../kube-object"; -import setupAutoRegistrationInjectable from "../../../renderer/before-frame-starts/runnables/setup-auto-registration.injectable"; -import { createMockResponseFromStream, createMockResponseFromString } from "../../../test-utils/mock-responses"; -import storesAndApisCanBeCreatedInjectable from "../../../renderer/stores-apis-can-be-created.injectable"; -import directoryForUserDataInjectable from "../../app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import hostedClusterInjectable from "../../../renderer/cluster-frame-context/hosted-cluster.injectable"; -import directoryForKubeConfigsInjectable from "../../app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import type { KubeJsonApiDataFor, KubeStatusData } from "../kube-object"; +import setupAutoRegistrationInjectable + from "../../../renderer/before-frame-starts/runnables/setup-auto-registration.injectable"; +import { + createMockResponseFromStream, + createMockResponseFromString, +} from "../../../test-utils/mock-responses"; +import storesAndApisCanBeCreatedInjectable + from "../../../renderer/stores-apis-can-be-created.injectable"; +import directoryForUserDataInjectable + from "../../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import hostedClusterInjectable + from "../../../renderer/cluster-frame-context/hosted-cluster.injectable"; +import directoryForKubeConfigsInjectable + from "../../app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; import apiKubeInjectable from "../../../renderer/k8s/api-kube.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; import deploymentApiInjectable from "../endpoints/deployment.api.injectable"; @@ -450,6 +461,54 @@ describe("KubeApi", () => { }); }); }); + + describe("eviction-api as better replacement for pod.delete() request", () => { + let evictRequest: Promise; + + beforeEach(async () => { + evictRequest = api.evict({ name: "foo", namespace: "test" }); + }); + + it("requests evicting a pod in given namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo/eviction", + { + headers: { + "content-type": "application/json", + }, + method: "post", + }, + ]); + }); + + it("should resolve the call with >=200 <300 http code", async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo/eviction"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo/eviction", JSON.stringify({ + apiVersion: "policy/v1", + kind: "Status", + code: 201, + status: "all good", + } as KubeStatusData)), + ); + + expect(await evictRequest).toBe("201: all good"); + }); + + it("should throw in case of error", async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo/eviction"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo/eviction", JSON.stringify({ + apiVersion: "policy/v1", + kind: "Status", + code: 500, + status: "something went wrong", + } as KubeStatusData)), + ); + + expect(async () => evictRequest).rejects.toBe("500: something went wrong"); + }); + }); }); describe("deleting namespaces (cluser scoped resource)", () => { diff --git a/packages/core/src/common/k8s-api/endpoints/pod.api.ts b/packages/core/src/common/k8s-api/endpoints/pod.api.ts index 1db41957b2..a06389c20a 100644 --- a/packages/core/src/common/k8s-api/endpoints/pod.api.ts +++ b/packages/core/src/common/k8s-api/endpoints/pod.api.ts @@ -3,13 +3,24 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { DerivedKubeApiOptions, KubeApiDependencies, ResourceDescriptor } from "../kube-api"; +import type { + DeleteResourceDescriptor, + DerivedKubeApiOptions, + KubeApiDependencies, + ResourceDescriptor, +} from "../kube-api"; import { KubeApi } from "../kube-api"; import type { RequireExactlyOne } from "type-fest"; -import type { KubeObjectMetadata, LocalObjectReference, Affinity, Toleration, NamespaceScopedMetadata } from "../kube-object"; +import type { + Affinity, + KubeObjectMetadata, KubeStatusData, + LocalObjectReference, + NamespaceScopedMetadata, + Toleration, +} from "../kube-object"; +import { isKubeStatusData, KubeObject, KubeStatus } from "../kube-object"; import type { SecretReference } from "./secret.api"; import type { PersistentVolumeClaimSpec } from "./persistent-volume-claim.api"; -import { KubeObject } from "../kube-object"; import { isDefined } from "@k8slens/utilities"; import type { PodSecurityContext } from "./types/pod-security-context"; import type { Probe } from "./types/probe"; @@ -24,6 +35,38 @@ export class PodApi extends KubeApi { }); } + async evict(resource: DeleteResourceDescriptor) { + await this.checkPreferredVersion(); + const apiUrl = this.formatUrlForNotListing(resource); + let response: KubeStatusData; + + try { + response = await this.request.post(`${apiUrl}/eviction`, { + data: { + apiVersion: "policy/v1", + kind: "Eviction", + metadata: { + ...resource, + }, + }, + }); + } catch (err) { + response = err as KubeStatusData; + } + + if (isKubeStatusData(response)) { + const status = new KubeStatus(response); + + if (status.code >= 200 && status.code < 300) { + return status.getExplanation(); + } else { + throw status.getExplanation(); + } + } + + return response; + } + async getLogs(params: ResourceDescriptor, query?: PodLogsQuery): Promise { const path = `${this.getUrl(params)}/log`; diff --git a/packages/core/src/common/k8s-api/kube-api.ts b/packages/core/src/common/k8s-api/kube-api.ts index f34ef6bdbb..c5ed03abad 100644 --- a/packages/core/src/common/k8s-api/kube-api.ts +++ b/packages/core/src/common/k8s-api/kube-api.ts @@ -582,6 +582,15 @@ export class KubeApi< return parsed; } + /** + * Some k8s resources might implement special "delete" (e.g. pod.api) + * See also: https://kubernetes.io/docs/concepts/scheduling-eviction/api-eviction/ + * By default should work same as KubeObject.remove() + */ + async evict(desc: DeleteResourceDescriptor): Promise { + return this.delete(desc); + } + async delete({ propagationPolicy = "Background", ...desc }: DeleteResourceDescriptor) { await this.checkPreferredVersion(); const apiUrl = this.formatUrlForNotListing(desc); diff --git a/packages/core/src/common/k8s-api/kube-object.store.ts b/packages/core/src/common/k8s-api/kube-object.store.ts index fa17fd1f2c..9effdc8f57 100644 --- a/packages/core/src/common/k8s-api/kube-object.store.ts +++ b/packages/core/src/common/k8s-api/kube-object.store.ts @@ -387,7 +387,9 @@ export class KubeObjectStore< } async remove(item: K) { - await this.api.delete({ name: item.getName(), namespace: item.getNs() }); + // Some k8s apis might implement special more fine-grained "delete" request for resources (e.g. pod.api.ts) + // See also: https://kubernetes.io/docs/concepts/scheduling-eviction/api-eviction/ + await this.api.evict({ name: item.getName(), namespace: item.getNs() }); this.selectedItemsIds.delete(item.getId()); } diff --git a/packages/core/src/common/k8s-api/kube-object.ts b/packages/core/src/common/k8s-api/kube-object.ts index e700710788..1feb028aff 100644 --- a/packages/core/src/common/k8s-api/kube-object.ts +++ b/packages/core/src/common/k8s-api/kube-object.ts @@ -6,17 +6,35 @@ // Base class for all kubernetes objects import moment from "moment"; -import type { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata } from "./kube-json-api"; -import { formatDuration, hasOptionalTypedProperty, hasTypedProperty, isObject, isString, isNumber, bindPredicate, isTypedArray, isRecord } from "@k8slens/utilities"; +import type { + KubeJsonApiData, + KubeJsonApiDataList, + KubeJsonApiListMetadata, +} from "./kube-json-api"; +import { + formatDuration, + hasOptionalTypedProperty, + hasTypedProperty, + isObject, + isString, + isNumber, + bindPredicate, + isTypedArray, + isRecord, +} from "@k8slens/utilities"; import type { ItemObject } from "../item.store"; import type { Patch } from "rfc6902"; import assert from "assert"; import type { JsonObject } from "type-fest"; -import requestKubeObjectPatchInjectable from "./endpoints/resource-applier.api/request-patch.injectable"; +import requestKubeObjectPatchInjectable + from "./endpoints/resource-applier.api/request-patch.injectable"; import { apiKubeInjectionToken } from "./api-kube"; -import requestKubeObjectCreationInjectable from "./endpoints/resource-applier.api/request-update.injectable"; +import requestKubeObjectCreationInjectable + from "./endpoints/resource-applier.api/request-update.injectable"; import { dump } from "js-yaml"; -import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import { + getLegacyGlobalDiForExtensionApi, +} from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import autoBind from "auto-bind"; export type KubeJsonApiDataFor = K extends KubeObject @@ -203,13 +221,17 @@ export interface BaseKubeJsonApiObjectMetadata = BaseKubeJsonApiObjectMetadata & ( +export type KubeJsonApiObjectMetadata = + BaseKubeJsonApiObjectMetadata + & ( Namespaced extends KubeObjectScope.Namespace ? { readonly namespace: string } : {} -); + ); -export type KubeObjectMetadata = KubeJsonApiObjectMetadata & { +export type KubeObjectMetadata = + KubeJsonApiObjectMetadata + & { readonly selfLink: string; readonly uid: string; readonly name: string; @@ -225,6 +247,25 @@ export interface KubeStatusData { code: number; message?: string; reason?: string; + status?: string; +} + +export interface EvictionObject { + kind: "Eviction"; + apiVersion: string | "policy/v1"; + metadata: Partial; + deleteOptions?: { + kind?: string; + apiVersion?: string; + dryRun?: string[]; + gracePeriodSeconds?: number; + orphanDependents?: boolean; + propagationPolicy?: string; + preconditions?: { + resourceVersion: string; + uid: string; + }[]; + }; } export function isKubeStatusData(object: unknown): object is KubeStatusData { @@ -232,8 +273,11 @@ export function isKubeStatusData(object: unknown): object is KubeStatusData { && hasTypedProperty(object, "kind", isString) && hasTypedProperty(object, "apiVersion", isString) && hasTypedProperty(object, "code", isNumber) - && hasOptionalTypedProperty(object, "message", isString) - && hasOptionalTypedProperty(object, "reason", isString) + && ( + hasOptionalTypedProperty(object, "message", isString) + || hasOptionalTypedProperty(object, "reason", isString) + || hasOptionalTypedProperty(object, "status", isString) + ) && object.kind === "Status"; } @@ -241,14 +285,22 @@ export class KubeStatus { public readonly kind = "Status"; public readonly apiVersion: string; public readonly code: number; - public readonly message: string; - public readonly reason: string; + public readonly message?: string; + public readonly reason?: string; + public readonly status?: string; constructor(data: KubeStatusData) { this.apiVersion = data.apiVersion; this.code = data.code; this.message = data.message || ""; this.reason = data.reason || ""; + this.status = data.status || ""; + } + + getExplanation(): string { + const { code, message, reason, status } = this; + + return `${code}: ${message || reason || status}`; } }