Add context menu entry for deleting local clusters (#2923)

This commit is contained in:
Sebastian Malton authored and GitHub committed 2021-06-17 12:19:22 -04:00
1 parent e47d26e1ce
commit eb45f45a7a
30 files changed
+348 -377

No files matched your search

+2 -2
View File
@@ -120,8 +120,8 @@ describe("create clusters", () => {
protected bindEvents() {
return;
}
protected async ensureKubectl() {
return Promise.resolve(true);
async ensureKubectl() {
return Promise.resolve(null);
}
}({
id: "foo",
+1 -1
View File
@@ -30,7 +30,7 @@ import type { CatalogEntity } from "../common/catalog";
const broadcaster = debounce((items: CatalogEntity[]) => {
broadcastMessage("catalog:items", items);
}, 1_000, { trailing: true });
}, 1_000, { leading: true, trailing: true });
export function pushCatalogToRenderer(catalog: CatalogEntityRegistry) {
return reaction(() => toJS(catalog.items), (items) => {
@@ -27,6 +27,7 @@ import { computeDiff, configToModels } from "../kubeconfig-sync";
import mockFs from "mock-fs";
import fs from "fs";
import { ClusterStore } from "../../../common/cluster-store";
import { ClusterManager } from "../../cluster-manager";
jest.mock("electron", () => ({
app: {
@@ -38,10 +39,13 @@ describe("kubeconfig-sync.source tests", () => {
beforeEach(() => {
mockFs();
ClusterStore.createInstance();
ClusterManager.createInstance();
});
afterEach(() => {
mockFs.restore();
ClusterStore.resetInstance();
ClusterManager.resetInstance();
});
describe("configsToModels", () => {
+4 -1
View File
@@ -31,7 +31,7 @@ import logger from "../logger";
import type { KubeConfig } from "@kubernetes/client-node";
import { loadConfigFromString, splitConfig } from "../../common/kube-helpers";
import { Cluster } from "../cluster";
import { catalogEntityFromCluster } from "../cluster-manager";
import { catalogEntityFromCluster, ClusterManager } from "../cluster-manager";
import { UserStore } from "../../common/user-store";
import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store";
import { createHash } from "crypto";
@@ -170,6 +170,9 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri
// remove and disconnect clusters that were removed from the config
if (!model) {
// remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting
ClusterManager.getInstance().deleting.delete(value[0].id);
value[0].disconnect();
source.delete(contextName);
logger.debug(`${logPrefix} Removed old cluster from sync`, { filePath, contextName });
+21 -7
View File
@@ -21,8 +21,8 @@
import "../common/cluster-ipc";
import type http from "http";
import { action, autorun, makeObservable, reaction, toJS } from "mobx";
import { ClusterStore, getClusterIdFromHost } from "../common/cluster-store";
import { action, autorun, makeObservable, observable, observe, reaction, toJS } from "mobx";
import { ClusterId, ClusterStore, getClusterIdFromHost } from "../common/cluster-store";
import type { Cluster } from "./cluster";
import logger from "./logger";
import { apiKubePrefix } from "../common/vars";
@@ -30,17 +30,18 @@ import { Singleton } from "../common/utils";
import { catalogEntityRegistry } from "./catalog";
import { KubernetesCluster, KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster";
import { ipcMainOn } from "../common/ipc";
import { once } from "lodash";
export class ClusterManager extends Singleton {
private store = ClusterStore.getInstance();
deleting = observable.set<ClusterId>();
constructor() {
super();
makeObservable(this);
this.bindEvents();
}
private bindEvents() {
init = once(() => {
// reacting to every cluster's state change and total amount of items
reaction(
() => this.store.clustersList.map(c => c.getState()),
@@ -59,6 +60,12 @@ export class ClusterManager extends Singleton {
this.syncClustersFromCatalog(entities);
});
observe(this.deleting, change => {
if (change.type === "add") {
this.updateEntityStatus(catalogEntityRegistry.getById(change.newValue));
}
});
// auto-stop removed clusters
autorun(() => {
const removedClusters = Array.from(this.store.removedClusters.values());
@@ -76,7 +83,7 @@ export class ClusterManager extends Singleton {
ipcMainOn("network:offline", this.onNetworkOffline);
ipcMainOn("network:online", this.onNetworkOnline);
}
});
@action
protected updateCatalog(clusters: Cluster[]) {
@@ -115,8 +122,15 @@ export class ClusterManager extends Singleton {
catalogEntityRegistry.items.splice(index, 1, entity);
}
protected updateEntityStatus(entity: KubernetesCluster, cluster: Cluster) {
entity.status.phase = cluster.accessible ? "connected" : "disconnected";
@action
protected updateEntityStatus(entity: KubernetesCluster, cluster?: Cluster) {
if (this.deleting.has(entity.getId())) {
entity.status.phase = "deleting";
entity.status.enabled = false;
} else {
entity.status.phase = cluster?.accessible ? "connected" : "disconnected";
entity.status.enabled = true;
}
}
@action syncClustersFromCatalog(entities: KubernetesCluster[]) {
+8 -11
View File
@@ -88,12 +88,7 @@ export interface ClusterState {
export class Cluster implements ClusterModel, ClusterState {
/** Unique id for a cluster */
public readonly id: ClusterId;
/**
* Kubectl
*
* @internal
*/
public kubeCtl: Kubectl;
private kubeCtl: Kubectl;
/**
* Context handler
*
@@ -363,7 +358,7 @@ export class Cluster implements ClusterModel, ClusterState {
if (this.accessible) {
await this.refreshAccessibility();
this.ensureKubectl();
this.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard
}
this.activated = true;
@@ -373,10 +368,12 @@ export class Cluster implements ClusterModel, ClusterState {
/**
* @internal
*/
protected async ensureKubectl() {
this.kubeCtl = new Kubectl(this.version);
async ensureKubectl() {
this.kubeCtl ??= new Kubectl(this.version);
return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard
await this.kubeCtl.ensureKubectl();
return this.kubeCtl;
}
/**
@@ -650,7 +647,7 @@ export class Cluster implements ClusterModel, ClusterState {
const api = (await this.getProxyKubeconfig()).makeApiClient(CoreV1Api);
try {
const { body: { items }} = await api.listNamespace();
const { body: { items } } = await api.listNamespace();
const namespaces = items.map(ns => ns.metadata.name);
this.getAllowedNamespacesErrorCount = 0; // reset on success
+3 -2
View File
@@ -169,9 +169,10 @@ export async function rollback(name: string, namespace: string, revision: number
async function getResources(name: string, namespace: string, cluster: Cluster) {
try {
const helm = await helmCli.binaryPath();
const kubectl = await cluster.kubeCtl.getPath();
const kubectl = await cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
const pathToKubeconfig = await cluster.getProxyKubeconfigPath();
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`);
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectlPath}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`);
return JSON.parse(stdout).items;
} catch {
+6 -3
View File
@@ -146,9 +146,12 @@ app.on("ready", async () => {
filesystemStore.load(),
]);
const lensProxy = LensProxy.createInstance(handleWsUpgrade);
ClusterManager.createInstance();
const lensProxy = LensProxy.createInstance(
handleWsUpgrade,
req => ClusterManager.getInstance().getClusterForRequest(req),
);
ClusterManager.createInstance().init();
KubeconfigSyncManager.createInstance();
try {
+28 -1
View File
@@ -22,11 +22,15 @@
import type { IpcMainInvokeEvent } from "electron";
import type { KubernetesCluster } from "../../common/catalog-entities";
import { clusterFrameMap } from "../../common/cluster-frames";
import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler } from "../../common/cluster-ipc";
import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler } from "../../common/cluster-ipc";
import { ClusterId, ClusterStore } from "../../common/cluster-store";
import { appEventBus } from "../../common/event-bus";
import { ipcMainHandle } from "../../common/ipc";
import { catalogEntityRegistry } from "../catalog";
import { ClusterManager } from "../cluster-manager";
import { bundledKubectlPath } from "../kubectl";
import logger from "../logger";
import { promiseExecFile } from "../promise-exec";
import { ResourceApplier } from "../resource-applier";
export function initIpcMainHandlers() {
@@ -73,6 +77,29 @@ export function initIpcMainHandlers() {
}
});
ipcMainHandle(clusterDeleteHandler, async (event, clusterId: ClusterId) => {
appEventBus.emit({ name: "cluster", action: "remove" });
const cluster = ClusterStore.getInstance().getById(clusterId);
if (!cluster) {
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 });
throw `Failed to remove cluster: ${stderr}`;
}
});
ipcMainHandle(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => {
appEventBus.emit({ name: "cluster", action: "kubectl-apply-all" });
const cluster = ClusterStore.getInstance().getById(clusterId);
+12 -6
View File
@@ -31,6 +31,7 @@ import { UserStore } from "../common/user-store";
import { customRequest } from "../common/request";
import { getBundledKubectlVersion } from "../common/utils/app-version";
import { isDevelopment, isWindows, isTestEnv } from "../common/vars";
import { SemVer } from "semver";
const bundledVersion = getBundledKubectlVersion();
const kubectlMap: Map<string, string> = new Map([
@@ -92,14 +93,19 @@ export class Kubectl {
// Returns the single bundled Kubectl instance
public static bundled() {
if (!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion);
return Kubectl.bundledInstance;
return Kubectl.bundledInstance ??= new Kubectl(Kubectl.bundledKubectlVersion);
}
constructor(clusterVersion: string) {
const versionParts = /^v?(\d+\.\d+)(.*)/.exec(clusterVersion);
const minorVersion = versionParts[1];
let version: SemVer;
try {
version = new SemVer(clusterVersion, { includePrerelease: false });
} catch {
version = new SemVer(Kubectl.bundledKubectlVersion);
}
const minorVersion = `${version.major}.${version.minor}`;
/* minorVersion is the first two digits of kube server version
if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */
@@ -107,7 +113,7 @@ export class Kubectl {
this.kubectlVersion = kubectlMap.get(minorVersion);
logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using version map`);
} else {
this.kubectlVersion = versionParts[1] + versionParts[2];
this.kubectlVersion = version.format();
logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using fallback`);
}
+2 -1
View File
@@ -20,6 +20,7 @@
*/
import * as util from "util";
import { exec } from "child_process";
import { exec, execFile } from "child_process";
export const promiseExec = util.promisify(exec);
export const promiseExecFile = util.promisify(execFile);
+4 -4
View File
@@ -29,7 +29,7 @@ import { Router } from "../router";
import type { ContextHandler } from "../context-handler";
import logger from "../logger";
import { Singleton } from "../../common/utils";
import { ClusterManager } from "../cluster-manager";
import type { Cluster } from "../cluster";
type WSUpgradeHandler = (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => void;
@@ -42,7 +42,7 @@ export class LensProxy extends Singleton {
public port: number;
constructor(handleWsUpgrade: WSUpgradeHandler) {
constructor(handleWsUpgrade: WSUpgradeHandler, protected getClusterForRequest: (req: http.IncomingMessage) => Cluster | undefined) {
super();
const proxy = this.createProxy();
@@ -104,7 +104,7 @@ export class LensProxy extends Singleton {
}
protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
const cluster = ClusterManager.getInstance().getClusterForRequest(req);
const cluster = this.getClusterForRequest(req);
if (cluster) {
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "");
@@ -220,7 +220,7 @@ export class LensProxy extends Singleton {
}
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) {
const cluster = ClusterManager.getInstance().getClusterForRequest(req);
const cluster = this.getClusterForRequest(req);
if (cluster) {
const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler);
+7 -7
View File
@@ -36,15 +36,15 @@ export class ResourceApplier {
async apply(resource: KubernetesObject | any): Promise<string> {
resource = this.sanitizeObject(resource);
appEventBus.emit({name: "resource", action: "apply"});
appEventBus.emit({ name: "resource", action: "apply" });
return await this.kubectlApply(yaml.safeDump(resource));
}
protected async kubectlApply(content: string): Promise<string> {
const { kubeCtl } = this.cluster;
const kubectlPath = await kubeCtl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
const kubectl = await this.cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
return new Promise<string>((resolve, reject) => {
const fileName = tempy.file({ name: "resource.yaml" });
@@ -82,9 +82,9 @@ export class ResourceApplier {
}
protected async kubectlCmdAll(subCmd: string, resources: string[], args: string[] = []): Promise<string> {
const { kubeCtl } = this.cluster;
const kubectlPath = await kubeCtl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
const kubectl = await this.cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
return new Promise((resolve, reject) => {
const tmpDir = tempy.directory();