diff --git a/package.json b/package.json index 172c8fa1db..ad6a728b76 100644 --- a/package.json +++ b/package.json @@ -347,6 +347,7 @@ "jest-canvas-mock": "^2.3.1", "jest-fetch-mock": "^3.0.3", "jest-mock-extended": "^1.0.16", + "json-to-pretty-yaml": "^1.2.2", "make-plural": "^6.2.2", "mini-css-extract-plugin": "^1.6.0", "node-gyp": "7.1.2", diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 14a584ee8c..b2ff284069 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -21,13 +21,12 @@ import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { CatalogEntity, CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { clusterActivateHandler, clusterDeleteHandler, clusterDisconnectHandler } from "../cluster-ipc"; +import { clusterActivateHandler, clusterDisconnectHandler } from "../cluster-ipc"; import { ClusterStore } from "../cluster-store"; import { requestMain } from "../ipc"; import { CatalogCategory, CatalogCategorySpec } from "../catalog"; import { app } from "electron"; import type { CatalogEntitySpec } from "../catalog/catalog-entity"; -import { HotbarStore } from "../hotbar-store"; export interface KubernetesClusterPrometheusMetrics { address?: { @@ -102,25 +101,11 @@ export class KubernetesCluster extends CatalogEntity context.navigate(`/entity/${this.metadata.uid}/settings`) - }, - { - title: "Delete", - icon: "delete", - onClick: () => { - HotbarStore.getInstance().removeAllHotbarItems(this.getId()); - requestMain(clusterDeleteHandler, this.metadata.uid); - }, - confirm: { - // TODO: change this to be a

tag with better formatting once this code can accept it. - message: `Delete the "${this.metadata.name}" context from "${this.spec.kubeconfigPath}"?` - } - }, - ); + context.menuItems.push({ + title: "Settings", + icon: "edit", + onClick: () => context.navigate(`/entity/${this.metadata.uid}/settings`) + }); } switch (this.status.phase) { diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 9191d93048..37ceaa12e2 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -25,5 +25,7 @@ export const clusterVisibilityHandler = "cluster:visibility"; export const clusterRefreshHandler = "cluster:refresh"; export const clusterDisconnectHandler = "cluster:disconnect"; export const clusterDeleteHandler = "cluster:delete"; +export const clusterSetDeletingHandler = "cluster:deleting:set"; +export const clusterClearDeletingHandler = "cluster:deleting:clear"; export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; export const clusterKubectlDeleteAllHandler = "cluster:kubectl-delete-all"; diff --git a/src/main/cluster.ts b/src/main/cluster.ts index c3673e8a91..fd8142aa57 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -32,9 +32,9 @@ import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; import { DetectorRegistry } from "./cluster-detectors/detector-registry"; import plimit from "p-limit"; -import { toJS } from "../common/utils"; import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types"; +import { storedKubeConfigFolder, toJS } from "../common/utils"; /** * Cluster @@ -739,4 +739,8 @@ export class Cluster implements ClusterModel, ClusterState { get imagePullSecret(): string | undefined { return this.preferences?.imagePullSecret; } + + isInLocalKubeconfig() { + return this.kubeConfigPath.startsWith(storedKubeConfigFolder()); + } } diff --git a/src/main/initializers/ipc.ts b/src/main/initializers/ipc.ts index 53d6b5b055..0eb36b4f3a 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -22,17 +22,14 @@ import { BrowserWindow, dialog, IpcMainInvokeEvent } from "electron"; import { KubernetesCluster } from "../../common/catalog-entities"; import { clusterFrameMap } from "../../common/cluster-frames"; -import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler } from "../../common/cluster-ipc"; -import { ClusterStore } from "../../common/cluster-store"; +import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc"; import type { ClusterId } from "../../common/cluster-types"; +import { ClusterStore } from "../../common/cluster-store"; import { appEventBus } from "../../common/event-bus"; import { dialogShowOpenDialogHandler, ipcMainHandle } from "../../common/ipc"; import { catalogEntityRegistry } from "../catalog"; import { pushCatalogToRenderer } from "../catalog-pusher"; import { ClusterManager } from "../cluster-manager"; -import { bundledKubectlPath } from "../kubectl"; -import logger from "../logger"; -import { promiseExecFile } from "../promise-exec"; import { ResourceApplier } from "../resource-applier"; import { WindowManager } from "../window-manager"; @@ -82,7 +79,7 @@ export function initIpcMainHandlers() { } }); - ipcMainHandle(clusterDeleteHandler, async (event, clusterId: ClusterId) => { + ipcMainHandle(clusterDeleteHandler, (event, clusterId: ClusterId) => { appEventBus.emit({ name: "cluster", action: "remove" }); const cluster = ClusterStore.getInstance().getById(clusterId); @@ -90,19 +87,16 @@ export function initIpcMainHandlers() { return; } - ClusterManager.getInstance().deleting.add(clusterId); cluster.disconnect(); clusterFrameMap.delete(cluster.id); - const kubectlPath = bundledKubectlPath(); - const args = ["config", "delete-context", cluster.contextName, "--kubeconfig", cluster.kubeConfigPath]; + }); - try { - await promiseExecFile(kubectlPath, args); - } catch ({ stderr }) { - logger.error(`[CLUSTER-REMOVE]: failed to remove cluster: ${stderr}`, { clusterId, context: cluster.contextName }); + ipcMainHandle(clusterSetDeletingHandler, (event, clusterId: string) => { + ClusterManager.getInstance().deleting.add(clusterId); + }); - throw `Failed to remove cluster: ${stderr}`; - } + ipcMainHandle(clusterClearDeletingHandler, (event, clusterId: string) => { + ClusterManager.getInstance().deleting.delete(clusterId); }); ipcMainHandle(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => { diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 3b6cf1a434..687490f94c 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -35,6 +35,7 @@ import { HotbarMenu } from "../hotbar/hotbar-menu"; import { EntitySettings } from "../+entity-settings"; import { Welcome } from "../+welcome"; import * as routes from "../../../common/routes"; +import { DeleteClusterDialog } from "../delete-cluster-dialog"; import { reaction } from "mobx"; import { navigation } from "../../navigation"; import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync"; @@ -71,6 +72,7 @@ export class ClusterManager extends React.Component { + ); } diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.scss b/src/renderer/components/confirm-dialog/confirm-dialog.scss index d9df634fd0..7eeef6738f 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.scss +++ b/src/renderer/components/confirm-dialog/confirm-dialog.scss @@ -33,7 +33,8 @@ max-width: 50vw; min-width: 45 * $unit; background-color: white; - outline: $unit solid rgba(255, 255, 255, .15); + border-radius: $radius; + line-height: 1.5; } .confirm-content { @@ -44,7 +45,7 @@ > .Icon { margin-left: inherit; - margin-right: $margin; + margin-right: $margin * 2; color: $colorSoftError; } @@ -60,9 +61,11 @@ .confirm-buttons { background: #f4f4f4; - padding: $spacing; + padding: $padding * 2.5; display: flex; justify-content: flex-end; + border-bottom-left-radius: $radius; + border-bottom-right-radius: $radius; > * { margin-left: $margin diff --git a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx new file mode 100644 index 0000000000..7d861a71c7 --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx @@ -0,0 +1,249 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import "@testing-library/jest-dom/extend-expect"; +import { KubeConfig } from "@kubernetes/client-node"; +import { fireEvent, render } from "@testing-library/react"; +import mockFs from "mock-fs"; +import React from "react"; +import selectEvent from "react-select-event"; + +import { Cluster } from "../../../../main/cluster"; +import { DeleteClusterDialog } from "../delete-cluster-dialog"; + +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + }, +})); + +const kubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://localhost + name: test +- cluster: + server: http://localhost + name: other-cluster +contexts: +- context: + cluster: test + user: test + name: test +- context: + cluster: test + user: test + name: test2 +- context: + cluster: other-cluster + user: test + name: other-context +current-context: other-context +kind: Config +preferences: {} +users: +- name: test + user: + token: kubeconfig-user-q4lm4:xxxyyyy +`; + +const singleClusterConfig = ` +apiVersion: v1 +clusters: +- cluster: + server: http://localhost + name: other-cluster +contexts: +- context: + cluster: other-cluster + user: test + name: other-context +current-context: other-context +kind: Config +preferences: {} +users: +- name: test + user: + token: kubeconfig-user-q4lm4:xxxyyyy +`; + +let config: KubeConfig; + +describe("", () => { + describe("Kubeconfig with different clusters", () => { + beforeEach(async () => { + const mockOpts = { + "temp-kube-config": kubeconfig, + }; + + mockFs(mockOpts); + + config = new KubeConfig(); + config.loadFromString(kubeconfig); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("shows warning when deleting non-current-context cluster", () => { + const cluster = new Cluster({ + id: "test", + contextName: "test", + preferences: { + clusterName: "minikube" + }, + kubeConfigPath: "./temp-kube-config", + }); + + DeleteClusterDialog.open({ cluster, config }); + const { getByText } = render(); + + const message = "The contents of kubeconfig file will be changed!"; + + expect(getByText(message)).toBeInstanceOf(HTMLElement); + }); + + it("shows warning when deleting current-context cluster", () => { + const cluster = new Cluster({ + id: "other-cluster", + contextName: "other-context", + preferences: { + clusterName: "other-cluster" + }, + kubeConfigPath: "./temp-kube-config", + }); + + DeleteClusterDialog.open({ cluster, config }); + + const { getByTestId } = render(); + + expect(getByTestId("current-context-warning")).toBeInstanceOf(HTMLElement); + }); + + it("shows context switcher when deleting current cluster", async () => { + const cluster = new Cluster({ + id: "other-cluster", + contextName: "other-context", + preferences: { + clusterName: "other-cluster" + }, + kubeConfigPath: "./temp-kube-config", + }); + + DeleteClusterDialog.open({ cluster, config }); + + const { getByText } = render(); + + expect(getByText("Select...")).toBeInTheDocument(); + selectEvent.openMenu(getByText("Select...")); + + expect(getByText("test")).toBeInTheDocument(); + expect(getByText("test2")).toBeInTheDocument(); + }); + + it("shows context switcher after checkbox click", async () => { + const cluster = new Cluster({ + id: "some-cluster", + contextName: "test", + preferences: { + clusterName: "test" + }, + kubeConfigPath: "./temp-kube-config", + }); + + DeleteClusterDialog.open({ cluster, config }); + + const { getByText, getByTestId } = render(); + const link = getByTestId("context-switch"); + + expect(link).toBeInstanceOf(HTMLElement); + fireEvent.click(link); + + expect(getByText("Select...")).toBeInTheDocument(); + selectEvent.openMenu(getByText("Select...")); + + expect(getByText("test")).toBeInTheDocument(); + expect(getByText("test2")).toBeInTheDocument(); + }); + + it("shows warning for internal kubeconfig cluster", () => { + const cluster = new Cluster({ + id: "some-cluster", + contextName: "test", + preferences: { + clusterName: "test" + }, + kubeConfigPath: "./temp-kube-config", + }); + + const spy = jest.spyOn(cluster, "isInLocalKubeconfig").mockImplementation(() => true); + + DeleteClusterDialog.open({ cluster, config }); + + const { getByTestId } = render(); + + expect(getByTestId("internal-kubeconfig-warning")).toBeInstanceOf(HTMLElement); + + spy.mockRestore(); + }); + }); + + describe("Kubeconfig with single cluster", () => { + beforeEach(async () => { + const mockOpts = { + "temp-kube-config": singleClusterConfig, + }; + + mockFs(mockOpts); + + config = new KubeConfig(); + config.loadFromString(singleClusterConfig); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it("shows warning if no other contexts left", () => { + const cluster = new Cluster({ + id: "other-cluster", + contextName: "other-context", + preferences: { + clusterName: "other-cluster" + }, + kubeConfigPath: "./temp-kube-config", + }); + + DeleteClusterDialog.open({ cluster, config }); + + const { getByTestId } = render(); + + expect(getByTestId("no-more-contexts-warning")).toBeInstanceOf(HTMLElement); + }); + }); +}); diff --git a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.module.css b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.module.css new file mode 100644 index 0000000000..0c95c3d2a6 --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.module.css @@ -0,0 +1,41 @@ +.warning { + @apply mt-4 flex py-4 px-6 rounded-md items-center; + background: #fad8d7; + color: #797979; +} + +.warningIcon { + @apply mr-5; + font-size: 26px; +} + +.dialog { + > div { + @apply rounded-md bg-white; + max-width: 600px; + min-width: calc(45 * var(--unit)); + } + + b { + word-break: break-all; + } +} + +.dialogContent { + @apply p-9 leading-9; +} + +.dialogButtons { + @apply flex justify-end p-7 rounded-md; + background: #f4f4f4; + + > * { + margin-left: var(--margin) + } +} + +.hr { + @apply mt-7; + height: 1px; + background: #dfdfdf80; +} \ No newline at end of file diff --git a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx new file mode 100644 index 0000000000..01cf35eec8 --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx @@ -0,0 +1,279 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import styles from "./delete-cluster-dialog.module.css"; + +import { computed, makeObservable, observable } from "mobx"; +import { observer } from "mobx-react"; +import React from "react"; + +import { Button } from "../button"; +import type { KubeConfig } from "@kubernetes/client-node"; +import type { Cluster } from "../../../main/cluster"; +import { saveKubeconfig } from "./save-config"; +import { requestMain } from "../../../common/ipc"; +import { clusterClearDeletingHandler, clusterDeleteHandler, clusterSetDeletingHandler } from "../../../common/cluster-ipc"; +import { Notifications } from "../notifications"; +import { HotbarStore } from "../../../common/hotbar-store"; +import { boundMethod } from "autobind-decorator"; +import { Dialog } from "../dialog"; +import { Icon } from "../icon"; +import { Select } from "../select"; +import { Checkbox } from "../checkbox"; + +type DialogState = { + isOpen: boolean, + config?: KubeConfig, + cluster?: Cluster +}; + +const dialogState: DialogState = observable({ + isOpen: false +}); + +type Props = {}; + +@observer +export class DeleteClusterDialog extends React.Component { + showContextSwitch = false; + newCurrentContext = ""; + + constructor(props: Props) { + super(props); + makeObservable(this, { + showContextSwitch: observable, + newCurrentContext: observable + }); + } + + static open({ config, cluster }: Partial) { + dialogState.isOpen = true; + dialogState.config = config; + dialogState.cluster = cluster; + } + + static close() { + dialogState.isOpen = false; + dialogState.cluster = null; + dialogState.config = null; + } + + @boundMethod + onOpen() { + this.newCurrentContext = ""; + + if (this.isCurrentContext()) { + this.showContextSwitch = true; + } + } + + @boundMethod + onClose() { + DeleteClusterDialog.close(); + this.showContextSwitch = false; + } + + removeContext() { + dialogState.config.contexts = dialogState.config.contexts.filter(item => + item.name !== dialogState.cluster.contextName + ); + } + + changeCurrentContext() { + if (this.newCurrentContext && this.showContextSwitch) { + dialogState.config.currentContext = this.newCurrentContext; + } + } + + @boundMethod + async onDelete() { + const { cluster, config } = dialogState; + + await requestMain(clusterSetDeletingHandler, cluster.id); + this.removeContext(); + this.changeCurrentContext(); + + try { + await saveKubeconfig(config, cluster.kubeConfigPath); + HotbarStore.getInstance().removeAllHotbarItems(cluster.id); + await requestMain(clusterDeleteHandler, cluster.id); + } catch(error) { + Notifications.error(`Cannot remove cluster, failed to process config file. ${error}`); + await requestMain(clusterClearDeletingHandler, cluster.id); + } + + this.onClose(); + } + + @computed get disableDelete() { + const { cluster, config } = dialogState; + const noContextsAvailable = config.contexts.filter(context => context.name !== cluster.contextName).length == 0; + const newContextNotSelected = this.newCurrentContext === ""; + + if (noContextsAvailable) { + return false; + } + + return this.showContextSwitch && newContextNotSelected; + } + + isCurrentContext() { + return dialogState.config.currentContext == dialogState.cluster.contextName; + } + + renderCurrentContextSwitch() { + if (!this.showContextSwitch) return null; + const { cluster, config } = dialogState; + const contexts = config.contexts.filter(context => context.name !== cluster.contextName); + + const options = [ + ...contexts.map(context => ({ + label: context.name, + value: context.name, + })), + ]; + + return ( +

+