diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 7e88c77608..e2e9d14e2a 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -6,7 +6,7 @@ */ import { Application } from "spectron"; import * as utils from "../helpers/utils"; -import { addMinikubeCluster, minikubeReady, waitForMinikubeDashboard } from "../helpers/minikube"; +import { minikubeReady, waitForMinikubeDashboard } from "../helpers/minikube"; import { exec } from "child_process"; import * as util from "util"; @@ -25,7 +25,6 @@ describe("Lens cluster pages", () => { let clusterAdded = false; const addCluster = async () => { await app.client.waitUntilTextExists("div", "Catalog"); - await addMinikubeCluster(app); await waitForMinikubeDashboard(app); await app.client.click('a[href="/nodes"]'); await app.client.waitUntilTextExists("div.TableCell", "Ready"); diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index edbe3127d1..df2110ba92 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -38,28 +38,12 @@ export function minikubeReady(testNamespace: string): boolean { return true; } -export async function addMinikubeCluster(app: Application) { - await app.client.waitForVisible("button.MuiSpeedDial-fab"); - await app.client.moveToObject("button.MuiSpeedDial-fab"); - await app.client.waitForVisible(`button[title="Add from kubeconfig"]`); - await app.client.click(`button[title="Add from kubeconfig"]`); - await app.client.waitUntilTextExists("div", "Select kubeconfig file"); - await app.client.click("div.Select__control"); // show the context drop-down list - await app.client.waitUntilTextExists("div", "minikube"); - - if (!await app.client.$("button.primary").isEnabled()) { - await app.client.click("div.minikube"); // select minikube context - } // else the only context, which must be 'minikube', is automatically selected - await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button) - await app.client.click("button.primary"); // add minikube cluster +export async function waitForMinikubeDashboard(app: Application) { await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.waitForExist(".Input.SearchInput input"); await app.client.setValue(".Input.SearchInput input", "minikube"); await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.click("div.TableRow"); -} - -export async function waitForMinikubeDashboard(app: Application) { await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); await app.client.waitForExist(`iframe[name="minikube"]`); await app.client.frame("minikube"); diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 6fb5f40659..739979c5cf 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -92,7 +92,6 @@ describe("empty config", () => { expect(storedCluster.id).toBe("foo"); expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5"); - expect(storedCluster.enabled).toBe(true); }); it("removes cluster from store", async () => { @@ -215,13 +214,6 @@ describe("config with existing clusters", () => { expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2"); expect(storedClusters[2].id).toBe("cluster3"); }); - - it("marks owned cluster disabled by default", () => { - const storedClusters = ClusterStore.getInstance().clustersList; - - expect(storedClusters[0].enabled).toBe(true); - expect(storedClusters[2].enabled).toBe(false); - }); }); describe("config with invalid cluster kubeconfig", () => { @@ -288,18 +280,35 @@ users: it("does not enable clusters with invalid kubeconfig", () => { const storedClusters = ClusterStore.getInstance().clustersList; - expect(storedClusters.length).toBe(2); - expect(storedClusters[0].enabled).toBeFalsy; - expect(storedClusters[1].id).toBe("cluster2"); - expect(storedClusters[1].enabled).toBeTruthy; + expect(storedClusters.length).toBe(1); }); }); const minimalValidKubeConfig = JSON.stringify({ apiVersion: "v1", - clusters: [], - users: [], - contexts: [], + clusters: [{ + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }], + "current-context": "minikube", + contexts: [{ + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }], + users: [{ + name: "minikube", + user: { + "client-certificate": "/Users/foo/.minikube/client.crt", + "client-key": "/Users/foo/.minikube/client.key", + } + }], + kind: "Config", + preferences: {}, }); describe("pre 2.0 config with an existing cluster", () => { @@ -330,7 +339,7 @@ describe("pre 2.0 config with an existing cluster", () => { it("migrates to modern format with kubeconfig in a file", async () => { const config = ClusterStore.getInstance().clustersList[0].kubeConfigPath; - expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[]`); + expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[`); }); }); @@ -402,8 +411,6 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => const config = fs.readFileSync(file, "utf8"); const kc = yaml.safeLoad(config); - console.log(kc); - expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe("should be string"); expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string"); }); diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index dc5159e509..5f2737eba2 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -39,7 +39,10 @@ export class KubernetesCluster extends CatalogEntity context.navigate(`/entity/${this.metadata.uid}/settings`) }, - { + ]; + + if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) { + context.menuItems.push({ icon: "delete", title: "Delete", onlyVisibleForSource: "local", @@ -47,8 +50,8 @@ export class KubernetesCluster extends CatalogEntity { protected pushStateToViewsAutomatically() { if (ipcMain) { this.disposer.push( - reaction(() => this.enabledClustersList, () => { - this.pushState(); - }), reaction(() => this.connectedClustersList, () => { this.pushState(); }), @@ -210,10 +202,6 @@ export class ClusterStore extends BaseStore { return Array.from(this.clusters.values()); } - @computed get enabledClustersList(): Cluster[] { - return this.clustersList.filter((c) => c.enabled); - } - @computed get active(): Cluster | null { return this.getById(this.activeCluster); } @@ -232,13 +220,9 @@ export class ClusterStore extends BaseStore { @action setActive(clusterId: ClusterId) { - const cluster = this.clusters.get(clusterId); - - if (!cluster?.enabled) { - clusterId = null; - } - - this.activeCluster = clusterId; + this.activeCluster = this.clusters.has(clusterId) + ? clusterId + : null; } deactivate(id: ClusterId) { @@ -274,10 +258,6 @@ export class ClusterStore extends BaseStore { ? clusterOrModel : new Cluster(clusterOrModel); - if (!cluster.isManaged) { - cluster.enabled = true; - } - this.clusters.set(cluster.id, cluster); return cluster; @@ -314,18 +294,18 @@ export class ClusterStore extends BaseStore { // update new clusters for (const clusterModel of clusters) { - let cluster = currentClusters.get(clusterModel.id); + try { + let cluster = currentClusters.get(clusterModel.id); - if (cluster) { - cluster.updateModel(clusterModel); - } else { - cluster = new Cluster(clusterModel); - - if (!cluster.isManaged && cluster.apiUrl) { - cluster.enabled = true; + if (cluster) { + cluster.updateModel(clusterModel); + } else { + cluster = new Cluster(clusterModel); } + newClusters.set(clusterModel.id, cluster); + } catch { + // ignore } - newClusters.set(clusterModel.id, cluster); } // update removed clusters @@ -335,7 +315,7 @@ export class ClusterStore extends BaseStore { } }); - this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null; + this.setActive(activeCluster); this.clusters.replace(newClusters); this.removedClusters.replace(removedClusters); } diff --git a/src/extensions/core-api/index.ts b/src/extensions/core-api/index.ts index 9b075f049f..f0989fc5bd 100644 --- a/src/extensions/core-api/index.ts +++ b/src/extensions/core-api/index.ts @@ -1,6 +1,6 @@ // Lens-extensions api developer's kit -export * from "../lens-main-extension"; -export * from "../lens-renderer-extension"; +export { LensMainExtension } from "../lens-main-extension"; +export { LensRendererExtension } from "../lens-renderer-extension"; // APIs import * as App from "./app"; diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index 40050e10d1..9227280941 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -31,15 +31,8 @@ jest.mock("request-promise-native"); import { Console } from "console"; import mockFs from "mock-fs"; import { Cluster } from "../cluster"; -import { ContextHandler } from "../context-handler"; -import { getFreePort } from "../port"; -import { V1ResourceAttributes } from "@kubernetes/client-node"; -import { apiResources } from "../../common/rbac"; -import request from "request-promise-native"; import { Kubectl } from "../kubectl"; -const mockedRequest = request as jest.MockedFunction; - console = new Console(process.stdout, process.stderr); // fix mockFS describe("create clusters", () => { @@ -99,54 +92,7 @@ describe("create clusters", () => { expect(() => c.disconnect()).not.toThrowError(); }); - it("init should not throw if everything is in order", async () => { - await c.init(await getFreePort()); - expect(logger.info).toBeCalledWith(expect.stringContaining("init success"), { - id: "foo", - apiUrl: "https://192.168.64.3:8443", - context: "minikube", - }); - }); - it("activating cluster should try to connect to cluster and do a refresh", async () => { - const port = await getFreePort(); - - jest.spyOn(ContextHandler.prototype, "ensureServer"); - - const mockListNSs = jest.fn(); - const mockKC = { - makeApiClient() { - return { - listNamespace: mockListNSs, - }; - } - }; - - jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true)); - jest.spyOn(Cluster.prototype, "canUseWatchApi").mockReturnValue(Promise.resolve(true)); - jest.spyOn(Cluster.prototype, "canI") - .mockImplementation((attr: V1ResourceAttributes): Promise => { - expect(attr.namespace).toBe("default"); - expect(attr.verb).toBe("list"); - - return Promise.resolve(true); - }); - jest.spyOn(Cluster.prototype, "getProxyKubeconfig").mockReturnValue(mockKC as any); - mockListNSs.mockImplementationOnce(() => ({ - body: { - items: [{ - metadata: { - name: "default", - } - }] - } - })); - - mockedRequest.mockImplementationOnce(((uri: any) => { - expect(uri).toBe(`http://localhost:${port}/api-kube/version`); - - return Promise.resolve({ gitVersion: "1.2.3" }); - }) as any); const c = new class extends Cluster { // only way to mock protected methods, without these we leak promises @@ -162,14 +108,20 @@ describe("create clusters", () => { kubeConfigPath: "minikube-config.yml" }); - await c.init(port); + c.contextHandler = { + ensureServer: jest.fn(), + stopServer: jest.fn() + } as any; + + jest.spyOn(c, "reconnect"); + jest.spyOn(c, "canI"); + jest.spyOn(c, "refreshConnectionStatus"); + await c.activate(); - expect(ContextHandler.prototype.ensureServer).toBeCalled(); - expect(mockedRequest).toBeCalled(); - expect(c.accessible).toBe(true); - expect(c.allowedNamespaces.length).toBe(1); - expect(c.allowedResources.length).toBe(apiResources.length); + expect(c.reconnect).toBeCalled(); + expect(c.refreshConnectionStatus).toBeCalled(); + c.disconnect(); jest.resetAllMocks(); }); diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index 612db10a01..a9f6d4522e 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -26,10 +26,10 @@ jest.mock("winston", () => ({ jest.mock("../../common/ipc"); jest.mock("child_process"); jest.mock("tcp-port-used"); +//jest.mock("../utils/get-port"); import { Cluster } from "../cluster"; import { KubeAuthProxy } from "../kube-auth-proxy"; -import { getFreePort } from "../port"; import { broadcastMessage } from "../../common/ipc"; import { ChildProcess, spawn } from "child_process"; import { bundledKubectlPath, Kubectl } from "../kubectl"; @@ -39,6 +39,7 @@ import { Readable } from "stream"; import { UserStore } from "../../common/user-store"; import { Console } from "console"; import { stdout, stderr } from "process"; +import mockFs from "mock-fs"; console = new Console(stdout, stderr); @@ -51,11 +52,41 @@ describe("kube auth proxy tests", () => { jest.clearAllMocks(); UserStore.resetInstance(); UserStore.createInstance(); + + const mockMinikubeConfig = { + "minikube-config.yml": JSON.stringify({ + apiVersion: "v1", + clusters: [{ + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }], + "current-context": "minikube", + contexts: [{ + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }], + users: [{ + name: "minikube", + }], + kind: "Config", + preferences: {}, + }) + }; + + mockFs(mockMinikubeConfig); + }); + + afterEach(() => { + mockFs.restore(); }); it("calling exit multiple times shouldn't throw", async () => { - const port = await getFreePort(); - const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}); + const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "minikube-config.yml", contextName: "minikube" }), {}); kap.exit(); kap.exit(); @@ -63,13 +94,11 @@ describe("kube auth proxy tests", () => { }); describe("spawn tests", () => { - let port: number; let mockedCP: MockProxy; let listeners: Record void>; let proxy: KubeAuthProxy; beforeEach(async () => { - port = await getFreePort(); mockedCP = mock(); listeners = {}; @@ -89,6 +118,7 @@ describe("kube auth proxy tests", () => { mockedCP.stdout = mock(); mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { listeners[`stdout/${event}`] = listener; + listeners[`stdout/${event}`]("Starting to serve on 127.0.0.1:9191"); return mockedCP.stdout; }); @@ -98,10 +128,10 @@ describe("kube auth proxy tests", () => { return mockedCP; }); mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve()); - const cluster = new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }); - jest.spyOn(cluster, "apiUrl", "get").mockReturnValue("https://fake.k8s.internal"); - proxy = new KubeAuthProxy(cluster, port, {}); + const cluster = new Cluster({ id: "foobar", kubeConfigPath: "minikube-config.yml", contextName: "minikube" }); + + proxy = new KubeAuthProxy(cluster, {}); }); it("should call spawn and broadcast errors", async () => { @@ -127,7 +157,6 @@ describe("kube auth proxy tests", () => { it("should call spawn and broadcast stdout serving info", async () => { await proxy.run(); - listeners["stdout/data"]("Starting to serve on"); expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "Authentication proxy started\n" }); }); diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index cf93f71957..d12fbf88b7 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -27,7 +27,6 @@ import { KubeconfigManager } from "../kubeconfig-manager"; import mockFs from "mock-fs"; import { Cluster } from "../cluster"; import { ContextHandler } from "../context-handler"; -import { getFreePort } from "../port"; import fse from "fs-extra"; import { loadYaml } from "@kubernetes/client-node"; import { Console } from "console"; @@ -36,6 +35,9 @@ import * as path from "path"; console = new Console(process.stdout, process.stderr); // fix mockFS describe("kubeconfig manager tests", () => { + let cluster: Cluster; + let contextHandler: ContextHandler; + beforeEach(() => { const mockOpts = { "minikube-config.yml": JSON.stringify({ @@ -62,6 +64,14 @@ describe("kubeconfig manager tests", () => { }; mockFs(mockOpts); + + cluster = new Cluster({ + id: "foo", + contextName: "minikube", + kubeConfigPath: "minikube-config.yml", + }); + contextHandler = jest.fn() as any; + jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo"); }); afterEach(() => { @@ -69,14 +79,7 @@ describe("kubeconfig manager tests", () => { }); it("should create 'temp' kube config with proxy", async () => { - const cluster = new Cluster({ - id: "foo", - contextName: "minikube", - kubeConfigPath: "minikube-config.yml", - }); - const contextHandler = new ContextHandler(cluster); - const port = await getFreePort(); - const kubeConfManager = new KubeconfigManager(cluster, contextHandler, port); + const kubeConfManager = new KubeconfigManager(cluster, contextHandler); expect(logger.error).not.toBeCalled(); expect(await kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`); @@ -86,19 +89,12 @@ describe("kubeconfig manager tests", () => { const yml = loadYaml(file.toString()); expect(yml["current-context"]).toBe("minikube"); - expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`); + expect(yml["clusters"][0]["cluster"]["server"].endsWith("/foo")).toBe(true); expect(yml["users"][0]["name"]).toBe("proxy"); }); it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => { - const cluster = new Cluster({ - id: "foo", - contextName: "minikube", - kubeConfigPath: "minikube-config.yml", - }); - const contextHandler = new ContextHandler(cluster); - const port = await getFreePort(); - const kubeConfManager = new KubeconfigManager(cluster, contextHandler, port); + const kubeConfManager = new KubeconfigManager(cluster, contextHandler); const configPath = await kubeConfManager.getPath(); expect(await fse.pathExists(configPath)).toBe(true); diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts index 8bc028cbd7..532faf37e3 100644 --- a/src/main/catalog-pusher.ts +++ b/src/main/catalog-pusher.ts @@ -14,8 +14,8 @@ export class CatalogPusher { init() { const disposers: Disposer[] = []; - disposers.push(reaction(() => this.catalog.items, (items) => { - broadcastMessage("catalog:items", toJS(items, { recurseEverything: true })); + disposers.push(reaction(() => toJS(this.catalog.items, { recurseEverything: true }), (items) => { + broadcastMessage("catalog:items", items); }, { fireImmediately: true, })); diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index beda3ca890..63a17a648f 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -5,10 +5,12 @@ import { Cluster } from "../../cluster"; import { computeDiff, configToModels } from "../kubeconfig-sync"; import mockFs from "mock-fs"; import fs from "fs"; +import { ClusterStore } from "../../../common/cluster-store"; describe("kubeconfig-sync.source tests", () => { beforeEach(() => { mockFs(); + ClusterStore.createInstance(); }); afterEach(() => { @@ -57,10 +59,9 @@ describe("kubeconfig-sync.source tests", () => { it("should leave an empty source empty if there are no entries", () => { const contents = ""; const rootSource = new ObservableMap(); - const port = 0; const filePath = "/bar"; - computeDiff(contents, rootSource, port, filePath); + computeDiff(contents, rootSource, filePath); expect(rootSource.size).toBe(0); }); @@ -93,12 +94,11 @@ describe("kubeconfig-sync.source tests", () => { currentContext: "foobar" }); const rootSource = new ObservableMap(); - const port = 0; const filePath = "/bar"; fs.writeFileSync(filePath, contents); - computeDiff(contents, rootSource, port, filePath); + computeDiff(contents, rootSource, filePath); expect(rootSource.size).toBe(1); @@ -137,12 +137,11 @@ describe("kubeconfig-sync.source tests", () => { currentContext: "foobar" }); const rootSource = new ObservableMap(); - const port = 0; const filePath = "/bar"; fs.writeFileSync(filePath, contents); - computeDiff(contents, rootSource, port, filePath); + computeDiff(contents, rootSource, filePath); expect(rootSource.size).toBe(1); @@ -151,7 +150,7 @@ describe("kubeconfig-sync.source tests", () => { expect(c.kubeConfigPath).toBe("/bar"); expect(c.contextName).toBe("context-name"); - computeDiff("{}", rootSource, port, filePath); + computeDiff("{}", rootSource, filePath); expect(rootSource.size).toBe(0); }); @@ -192,12 +191,11 @@ describe("kubeconfig-sync.source tests", () => { currentContext: "foobar" }); const rootSource = new ObservableMap(); - const port = 0; const filePath = "/bar"; fs.writeFileSync(filePath, contents); - computeDiff(contents, rootSource, port, filePath); + computeDiff(contents, rootSource, filePath); expect(rootSource.size).toBe(2); @@ -237,7 +235,7 @@ describe("kubeconfig-sync.source tests", () => { currentContext: "foobar" }); - computeDiff(newContents, rootSource, port, filePath); + computeDiff(newContents, rootSource, filePath); expect(rootSource.size).toBe(1); diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts index 9cf2bf57d4..f90fb85caf 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -3,7 +3,6 @@ import { CatalogEntity, catalogEntityRegistry } from "../../common/catalog"; import { watch } from "chokidar"; import fs from "fs"; import fse from "fs-extra"; -import * as uuid from "uuid"; import stream from "stream"; import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; import logger from "../logger"; @@ -13,6 +12,7 @@ import { Cluster } from "../cluster"; import { catalogEntityFromCluster } from "../cluster-manager"; import { UserStore } from "../../common/user-store"; import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store"; +import { createHash } from "crypto"; const logPrefix = "[KUBECONFIG-SYNC]:"; @@ -24,7 +24,7 @@ export class KubeconfigSyncManager extends Singleton { protected static readonly syncName = "lens:kube-sync"; @action - startSync(port: number): void { + startSync(): void { if (this.syncing) { return; } @@ -41,16 +41,16 @@ export class KubeconfigSyncManager extends Singleton { ))); // This must be done so that c&p-ed clusters are visible - this.startNewSync(ClusterStore.storedKubeConfigFolder, port); + this.startNewSync(ClusterStore.storedKubeConfigFolder); for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) { - this.startNewSync(filePath, port); + this.startNewSync(filePath); } this.syncListDisposer = UserStore.getInstance().syncKubeconfigEntries.observe(change => { switch (change.type) { case "add": - this.startNewSync(change.name, port); + this.startNewSync(change.name); break; case "delete": this.stopOldSync(change.name); @@ -72,14 +72,14 @@ export class KubeconfigSyncManager extends Singleton { } @action - protected async startNewSync(filePath: string, port: number): Promise { + protected async startNewSync(filePath: string): Promise { if (this.sources.has(filePath)) { // don't start a new sync if we already have one return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath }); } try { - this.sources.set(filePath, await watchFileChanges(filePath, port)); + this.sources.set(filePath, await watchFileChanges(filePath)); logger.info(`${logPrefix} starting sync of file/folder`, { filePath }); logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); @@ -124,7 +124,7 @@ type RootSourceValue = [Cluster, CatalogEntity]; type RootSource = ObservableMap; // exported for testing -export function computeDiff(contents: string, source: RootSource, port: number, filePath: string): void { +export function computeDiff(contents: string, source: RootSource, filePath: string): void { runInAction(() => { try { const rawModels = configToModels(loadConfigFromString(contents), filePath); @@ -156,14 +156,13 @@ export function computeDiff(contents: string, source: RootSource, port: number, for (const [contextName, model] of models) { // add new clusters to the source try { - const cluster = new Cluster({ ...model, id: uuid.v4() }); + const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex"); + const cluster = ClusterStore.getInstance().getById(clusterId) || new Cluster({ ...model, id: clusterId}); if (!cluster.apiUrl) { throw new Error("Cluster constructor failed, see above error"); } - cluster.init(port); - const entity = catalogEntityFromCluster(cluster); entity.metadata.labels.file = filePath; @@ -181,7 +180,7 @@ export function computeDiff(contents: string, source: RootSource, port: number, }); } -function diffChangedConfig(filePath: string, source: RootSource, port: number): Disposer { +function diffChangedConfig(filePath: string, source: RootSource): Disposer { logger.debug(`${logPrefix} file changed`, { filePath }); // TODO: replace with an AbortController with fs.readFile when we upgrade to Node 16 (after it comes out) @@ -214,14 +213,14 @@ function diffChangedConfig(filePath: string, source: RootSource, port: number): }) .on("end", () => { if (!closed) { - computeDiff(Buffer.concat(bufs).toString("utf-8"), source, port, filePath); + computeDiff(Buffer.concat(bufs).toString("utf-8"), source, filePath); } }); return cleanup; } -async function watchFileChanges(filePath: string, port: number): Promise<[IComputedValue, Disposer]> { +async function watchFileChanges(filePath: string): Promise<[IComputedValue, Disposer]> { const stat = await fse.stat(filePath); // traverses symlinks, is a race condition const watcher = watch(filePath, { followSymlinks: true, @@ -235,10 +234,10 @@ async function watchFileChanges(filePath: string, port: number): Promise<[ICompu watcher .on("change", (childFilePath) => { stoppers.get(childFilePath)(); - stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port)); + stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath))); }) .on("add", (childFilePath) => { - stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port)); + stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath))); }) .on("unlink", (childFilePath) => { stoppers.get(childFilePath)(); diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts index 885f96c33e..6ae05324dd 100644 --- a/src/main/cluster-detectors/base-cluster-detector.ts +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -1,5 +1,6 @@ -import request, { RequestPromiseOptions } from "request-promise-native"; +import { RequestPromiseOptions } from "request-promise-native"; import { Cluster } from "../cluster"; +import { k8sRequest } from "../k8s-request"; export type ClusterDetectionResult = { value: string | number | boolean @@ -7,11 +8,9 @@ export type ClusterDetectionResult = { }; export class BaseClusterDetector { - cluster: Cluster; key: string; - constructor(cluster: Cluster) { - this.cluster = cluster; + constructor(public cluster: Cluster) { } detect(): Promise { @@ -19,16 +18,6 @@ export class BaseClusterDetector { } protected async k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { - const apiUrl = this.cluster.kubeProxyUrl + path; - - return request(apiUrl, { - json: true, - timeout: 30000, - ...options, - headers: { - Host: `${this.cluster.id}.${new URL(this.cluster.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest() - ...(options.headers || {}), - }, - }); + return k8sRequest(this.cluster, path, options); } } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 369c4f61b9..3c4bcd0015 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -1,37 +1,21 @@ import "../common/cluster-ipc"; import type http from "http"; import { ipcMain } from "electron"; -import { action, autorun, observable, reaction, toJS } from "mobx"; +import { action, autorun, reaction, toJS } from "mobx"; import { ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; import { Cluster } from "./cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; import { Singleton } from "../common/utils"; -import { CatalogEntity, catalogEntityRegistry } from "../common/catalog"; +import { catalogEntityRegistry } from "../common/catalog"; import { KubernetesCluster } from "../common/catalog-entities/kubernetes-cluster"; -const clusterOwnerRef = "ClusterManager"; - export class ClusterManager extends Singleton { - catalogSource = observable.array([]); - - constructor(public readonly port: number) { + constructor() { super(); - catalogEntityRegistry.addObservableSource("lens:kubernetes-clusters", this.catalogSource); - // auto-init clusters - reaction(() => ClusterStore.getInstance().enabledClustersList, (clusters) => { - clusters.forEach((cluster) => { - if (!cluster.initialized && !cluster.initializing) { - logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta()); - cluster.init(port); - } - }); - - }, { fireImmediately: true }); - - reaction(() => toJS(ClusterStore.getInstance().enabledClustersList, { recurseEverything: true }), () => { - this.updateCatalogSource(ClusterStore.getInstance().enabledClustersList); + reaction(() => toJS(ClusterStore.getInstance().clustersList, { recurseEverything: true }), () => { + this.updateCatalog(ClusterStore.getInstance().clustersList); }, { fireImmediately: true }); reaction(() => catalogEntityRegistry.getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { @@ -58,31 +42,20 @@ export class ClusterManager extends Singleton { ipcMain.on("network:online", () => { this.onNetworkOnline(); }); } - @action protected updateCatalogSource(clusters: Cluster[]) { - this.catalogSource.replace(this.catalogSource.filter(entity => ( - clusters.find((cluster) => entity.metadata.uid === cluster.id) - ))); - + @action protected updateCatalog(clusters: Cluster[]) { for (const cluster of clusters) { - if (cluster.ownerRef) { - continue; - } + const index = catalogEntityRegistry.items.findIndex((entity) => entity.metadata.uid === cluster.id); - const entityIndex = this.catalogSource.findIndex((entity) => entity.metadata.uid === cluster.id); - const newEntity = catalogEntityFromCluster(cluster); + if (index !== -1) { + const entity = catalogEntityRegistry.items[index]; - if (entityIndex === -1) { - this.catalogSource.push(newEntity); - } else { - const oldEntity = this.catalogSource[entityIndex]; + entity.status.phase = cluster.disconnected ? "disconnected" : "connected"; + entity.status.active = !cluster.disconnected; - newEntity.status.phase = cluster.disconnected ? "disconnected" : "connected"; - newEntity.status.active = !cluster.disconnected; - newEntity.metadata.labels = { - ...newEntity.metadata.labels, - ...oldEntity.metadata.labels - }; - this.catalogSource.splice(entityIndex, 1, newEntity); + if (cluster.preferences?.clusterName) { + entity.metadata.name = cluster.preferences.clusterName; + } + catalogEntityRegistry.items.splice(index, 1, entity); } } } @@ -98,8 +71,6 @@ export class ClusterManager extends Singleton { if (!cluster) { ClusterStore.getInstance().addCluster({ id: entity.metadata.uid, - enabled: true, - ownerRef: clusterOwnerRef, preferences: { clusterName: entity.metadata.name }, @@ -107,9 +78,6 @@ export class ClusterManager extends Singleton { contextName: entity.spec.kubeconfigContext }); } else { - cluster.enabled = true; - cluster.ownerRef ||= clusterOwnerRef; - cluster.preferences.clusterName = entity.metadata.name; cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.contextName = entity.spec.kubeconfigContext; @@ -123,7 +91,7 @@ export class ClusterManager extends Singleton { protected onNetworkOffline() { logger.info("[CLUSTER-MANAGER]: network is offline"); - ClusterStore.getInstance().enabledClustersList.forEach((cluster) => { + ClusterStore.getInstance().clustersList.forEach((cluster) => { if (!cluster.disconnected) { cluster.online = false; cluster.accessible = false; @@ -134,7 +102,7 @@ export class ClusterManager extends Singleton { protected onNetworkOnline() { logger.info("[CLUSTER-MANAGER]: network is online"); - ClusterStore.getInstance().enabledClustersList.forEach((cluster) => { + ClusterStore.getInstance().clustersList.forEach((cluster) => { if (!cluster.disconnected) { cluster.refreshConnectionStatus().catch((e) => e); } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index d55a243f99..1b02e79a02 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -1,15 +1,12 @@ import { ipcMain } from "electron"; import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-store"; -import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; import { action, comparer, computed, observable, reaction, toJS, when } from "mobx"; -import { apiKubePrefix } from "../common/vars"; -import { broadcastMessage, InvalidKubeconfigChannel, ClusterListNamespaceForbiddenChannel } from "../common/ipc"; +import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../common/ipc"; import { ContextHandler } from "./context-handler"; import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { Kubectl } from "./kubectl"; import { KubeconfigManager } from "./kubeconfig-manager"; import { loadConfig, validateKubeConfig } from "../common/kube-helpers"; -import request, { RequestPromiseOptions } from "request-promise-native"; import { apiResources, KubeApiResource } from "../common/rbac"; import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; @@ -36,8 +33,6 @@ export type ClusterRefreshOptions = { }; export interface ClusterState { - initialized: boolean; - enabled: boolean; apiUrl: string; online: boolean; disconnected: boolean; @@ -70,33 +65,13 @@ export class Cluster implements ClusterModel, ClusterState { * @internal */ public contextHandler: ContextHandler; - /** - * Owner reference - * - * If extension sets this it needs to also mark cluster as enabled on activate (or when added to a store) - */ - public ownerRef: string; protected kubeconfigManager: KubeconfigManager; protected eventDisposers: Function[] = []; protected activated = false; private resourceAccessStatuses: Map = new Map(); - whenInitialized = when(() => this.initialized); whenReady = when(() => this.ready); - /** - * Is cluster object initializing on-going - * - * @observable - */ - @observable initializing = false; - - /** - * Is cluster object initialized - * - * @observable - */ - @observable initialized = false; /** * Kubeconfig context name * @@ -119,19 +94,6 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable apiUrl: string; // cluster server url - /** - * Internal authentication proxy URL - * - * @observable - * @internal - */ - @observable kubeProxyUrl: string; // lens-proxy to kube-api url - /** - * Is cluster instance enabled (disabled clusters are currently hidden) - * - * @observable - */ - @observable enabled = false; // only enabled clusters are visible to users /** * Is cluster online * @@ -260,27 +222,26 @@ export class Cluster implements ClusterModel, ClusterState { this.id = model.id; this.updateModel(model); - try { - const kubeconfig = this.getKubeconfig(); - const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); + const kubeconfig = this.getKubeconfig(); + const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); - if (error) { - throw error; - } - - this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; - } catch(err) { - logger.error(err); - logger.error(`[CLUSTER] Failed to load kubeconfig for the cluster '${this.name ||Β this.contextName}' (context: ${this.contextName}, kubeconfig: ${this.kubeConfigPath}).`); - broadcastMessage(InvalidKubeconfigChannel, model.id); + if (error) { + throw error; } - } - /** - * Is cluster managed by an extension - */ - get isManaged(): boolean { - return !!this.ownerRef; + this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; + + if (ipcMain) { + // for the time being, until renderer gets its own cluster type + this.contextHandler = new ContextHandler(this); + this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler); + + logger.debug(`[CLUSTER]: Cluster init success`, { + id: this.id, + context: this.contextName, + apiUrl: this.apiUrl + }); + } } /** @@ -309,44 +270,11 @@ export class Cluster implements ClusterModel, ClusterState { this.metadata = model.metadata; } - if (model.ownerRef) { - this.ownerRef = model.ownerRef; - } - if (model.accessibleNamespaces) { this.accessibleNamespaces = model.accessibleNamespaces; } } - /** - * Initialize a cluster (can be done only in main process) - * - * @param port port where internal auth proxy is listening - * @internal - */ - @action - async init(port: number) { - try { - this.initializing = true; - this.contextHandler = new ContextHandler(this); - this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler, port); - this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`; - this.initialized = true; - logger.info(`[CLUSTER]: "${this.contextName}" init success`, { - id: this.id, - context: this.contextName, - apiUrl: this.apiUrl - }); - } catch (err) { - logger.error(`[CLUSTER]: init failed: ${err}`, { - id: this.id, - error: err, - }); - } finally { - this.initializing = false; - } - } - /** * @internal */ @@ -385,8 +313,8 @@ export class Cluster implements ClusterModel, ClusterState { if (this.activated && !force) { return this.pushState(); } + logger.info(`[CLUSTER]: activate`, this.getMeta()); - await this.whenInitialized; if (!this.eventDisposers.length) { this.bindEvents(); @@ -403,7 +331,7 @@ export class Cluster implements ClusterModel, ClusterState { } this.activated = true; - return this.pushState(); + this.pushState(); } /** @@ -450,7 +378,6 @@ export class Cluster implements ClusterModel, ClusterState { @action async refresh(opts: ClusterRefreshOptions = {}) { logger.info(`[CLUSTER]: refresh`, this.getMeta()); - await this.whenInitialized; await this.refreshConnectionStatus(); if (this.accessible) { @@ -527,34 +454,6 @@ export class Cluster implements ClusterModel, ClusterState { return this.kubeconfigManager.getPath(); } - protected async k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { - options.headers ??= {}; - options.json ??= true; - options.timeout ??= 30000; - options.headers.Host = `${this.id}.${new URL(this.kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest() - - return request(this.kubeProxyUrl + path, options); - } - - /** - * - * @param prometheusPath path to prometheus service - * @param queryParams query parameters - * @internal - */ - getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) { - const prometheusPrefix = this.preferences.prometheus?.prefix || ""; - const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; - - return this.k8sRequest(metricsPath, { - timeout: 0, - resolveWithFullResponse: false, - json: true, - method: "POST", - form: queryParams, - }); - } - protected async getConnectionStatus(): Promise { try { const versionDetector = new VersionDetector(this); @@ -647,7 +546,6 @@ export class Cluster implements ClusterModel, ClusterState { workspace: this.workspace, preferences: this.preferences, metadata: this.metadata, - ownerRef: this.ownerRef, accessibleNamespaces: this.accessibleNamespaces, }; @@ -661,8 +559,6 @@ export class Cluster implements ClusterModel, ClusterState { */ getState(): ClusterState { const state: ClusterState = { - initialized: this.initialized, - enabled: this.enabled, apiUrl: this.apiUrl, online: this.online, ready: this.ready, @@ -702,7 +598,6 @@ export class Cluster implements ClusterModel, ClusterState { return { id: this.id, name: this.contextName, - initialized: this.initialized, ready: this.ready, online: this.online, accessible: this.accessible, diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index e94520b9be..45e081753e 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -6,16 +6,14 @@ import url, { UrlWithStringQuery } from "url"; import { CoreV1Api } from "@kubernetes/client-node"; import { prometheusProviders } from "../common/prometheus-providers"; import logger from "./logger"; -import { getFreePort } from "./port"; import { KubeAuthProxy } from "./kube-auth-proxy"; export class ContextHandler { - public proxyPort: number; public clusterUrl: UrlWithStringQuery; - protected kubeAuthProxy: KubeAuthProxy; - protected apiTarget: httpProxy.ServerOptions; + protected kubeAuthProxy?: KubeAuthProxy; + protected apiTarget?: httpProxy.ServerOptions; protected prometheusProvider: string; - protected prometheusPath: string; + protected prometheusPath: string | null; constructor(protected cluster: Cluster) { this.clusterUrl = url.parse(cluster.apiUrl); @@ -77,31 +75,25 @@ export class ContextHandler { } async resolveAuthProxyUrl() { - const proxyPort = await this.ensurePort(); + await this.ensureServer(); const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; - return `http://127.0.0.1:${proxyPort}${path}`; + return `http://127.0.0.1:${this.kubeAuthProxy.port}${path}`; } async getApiTarget(isWatchRequest = false): Promise { - if (this.apiTarget && !isWatchRequest) { - return this.apiTarget; - } const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest - const apiTarget = await this.newApiTarget(timeout); - if (!isWatchRequest) { - this.apiTarget = apiTarget; + if (isWatchRequest) { + return this.newApiTarget(timeout); } - return apiTarget; + return this.apiTarget ??= await this.newApiTarget(timeout); } protected async newApiTarget(timeout: number): Promise { - const proxyUrl = await this.resolveAuthProxyUrl(); - return { - target: proxyUrl, + target: await this.resolveAuthProxyUrl(), changeOrigin: true, timeout, headers: { @@ -110,32 +102,22 @@ export class ContextHandler { }; } - async ensurePort(): Promise { - if (!this.proxyPort) { - this.proxyPort = await getFreePort(); - } - - return this.proxyPort; - } - async ensureServer() { if (!this.kubeAuthProxy) { - await this.ensurePort(); const proxyEnv = Object.assign({}, process.env); if (this.cluster.preferences.httpsProxy) { proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy; } - this.kubeAuthProxy = new KubeAuthProxy(this.cluster, this.proxyPort, proxyEnv); + this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv); await this.kubeAuthProxy.run(); } } stopServer() { - if (this.kubeAuthProxy) { - this.kubeAuthProxy.exit(); - this.kubeAuthProxy = null; - } + this.kubeAuthProxy?.exit(); + this.kubeAuthProxy = undefined; + this.apiTarget = undefined; } get proxyLastError(): string { diff --git a/src/main/index.ts b/src/main/index.ts index 2b4a7ae911..eec7c5006e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,11 +7,10 @@ import * as LensExtensions from "../extensions/core-api"; import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron"; import { appName, isMac, productName } from "../common/vars"; import path from "path"; -import { LensProxy } from "./lens-proxy"; +import { LensProxy } from "./proxy/lens-proxy"; import { WindowManager } from "./window-manager"; import { ClusterManager } from "./cluster-manager"; import { shellSync } from "./shell-sync"; -import { getFreePort } from "./port"; import { mangleProxyEnv } from "./proxy-env"; import { registerFileProtocol } from "../common/register-protocol"; import logger from "./logger"; @@ -34,6 +33,7 @@ import { catalogEntityRegistry } from "../common/catalog"; import { HotbarStore } from "../common/hotbar-store"; import { HelmRepoManager } from "./helm/helm-repo-manager"; import { KubeconfigSyncManager } from "./catalog-sources"; +import { handleWsUpgrade } from "./proxy/ws-upgrade"; const workingDir = path.join(app.getPath("appData"), appName); @@ -118,45 +118,33 @@ app.on("ready", async () => { filesystemStore.load(), ]); - try { - logger.info("πŸ”‘ Getting free port for LensProxy server"); - const proxyPort = await getFreePort(); + const lensProxy = LensProxy.createInstance(handleWsUpgrade); - // create cluster manager - ClusterManager.createInstance(proxyPort); - } catch (error) { - logger.error(error); - dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy"); - app.exit(); - } + ClusterManager.createInstance(); + KubeconfigSyncManager.createInstance().startSync(); - const clusterManager = ClusterManager.getInstance(); - - // create kubeconfig sync manager - KubeconfigSyncManager.createInstance().startSync(clusterManager.port); - - // run proxy try { logger.info("πŸ”Œ Starting LensProxy"); - // eslint-disable-next-line unused-imports/no-unused-vars-ts - LensProxy.createInstance(clusterManager.port).listen(); + await lensProxy.listen(); } catch (error) { - logger.error(`Could not start proxy (127.0.0:${clusterManager.port}): ${error?.message}`); - dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${clusterManager.port}): ${error?.message || "unknown error"}`); + dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`); app.exit(); } // test proxy connection try { logger.info("πŸ”Ž Testing LensProxy connection ..."); - const versionFromProxy = await getAppVersionFromProxyServer(clusterManager.port); + const versionFromProxy = await getAppVersionFromProxyServer(lensProxy.port); if (getAppVersion() !== versionFromProxy) { - logger.error(`Proxy server responded with invalid response`); + logger.error("Proxy server responded with invalid response"); + app.exit(); + } else { + logger.info("⚑ LensProxy connection OK"); } - logger.info("⚑ LensProxy connection OK"); } catch (error) { - logger.error("Checking proxy server connection failed", error); + logger.error(`πŸ›‘ LensProxy: failed connection test: ${error}`); + app.exit(); } const extensionDiscovery = ExtensionDiscovery.createInstance(); @@ -169,7 +157,7 @@ app.on("ready", async () => { const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden); logger.info("πŸ–₯️ Starting WindowManager"); - const windowManager = WindowManager.createInstance(clusterManager.port); + const windowManager = WindowManager.createInstance(); installDeveloperTools(); diff --git a/src/main/k8s-request.ts b/src/main/k8s-request.ts new file mode 100644 index 0000000000..2f9def789c --- /dev/null +++ b/src/main/k8s-request.ts @@ -0,0 +1,29 @@ +import request, { RequestPromiseOptions } from "request-promise-native"; +import { apiKubePrefix } from "../common/vars"; +import { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; +import { LensProxy } from "./proxy/lens-proxy"; +import { Cluster } from "./cluster"; + +export async function k8sRequest(cluster: Cluster, path: string, options: RequestPromiseOptions = {}): Promise { + const kubeProxyUrl = `http://localhost:${LensProxy.getInstance().port}${apiKubePrefix}`; + + options.headers ??= {}; + options.json ??= true; + options.timeout ??= 30000; + options.headers.Host = `${cluster.id}.${new URL(kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest() + + return request(kubeProxyUrl + path, options); +} + +export async function getMetrics(cluster: Cluster, prometheusPath: string, queryParams: IMetricsReqParams & { query: string }): Promise { + const prometheusPrefix = cluster.preferences.prometheus?.prefix || ""; + const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; + + return k8sRequest(cluster, metricsPath, { + timeout: 0, + resolveWithFullResponse: false, + json: true, + method: "POST", + form: queryParams, + }); +} diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index 589fe8fa16..e67ae68903 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -5,24 +5,30 @@ import type { Cluster } from "./cluster"; import { Kubectl } from "./kubectl"; import logger from "./logger"; import * as url from "url"; +import { getPortFrom } from "./utils/get-port"; export interface KubeAuthProxyLog { data: string; error?: boolean; // stream=stderr } +const startingServeRegex = /^starting to serve on (?
.+)/i; + export class KubeAuthProxy { public lastError: string; + public get port(): number { + return this._port; + } + + protected _port: number; protected cluster: Cluster; protected env: NodeJS.ProcessEnv = null; protected proxyProcess: ChildProcess; - protected port: number; protected kubectl: Kubectl; - constructor(cluster: Cluster, port: number, env: NodeJS.ProcessEnv) { + constructor(cluster: Cluster, env: NodeJS.ProcessEnv) { this.env = env; - this.port = port; this.cluster = cluster; this.kubectl = Kubectl.bundled(); } @@ -39,7 +45,7 @@ export class KubeAuthProxy { const proxyBin = await this.kubectl.getPath(); const args = [ "proxy", - "-p", `${this.port}`, + "-p", "0", "--kubeconfig", `${this.cluster.kubeConfigPath}`, "--context", `${this.cluster.contextName}`, "--accept-hosts", this.acceptHosts, @@ -50,6 +56,7 @@ export class KubeAuthProxy { args.push("-v", "9"); } logger.debug(`spawning kubectl proxy with args: ${args}`); + this.proxyProcess = spawn(proxyBin, args, { env: this.env, }); this.proxyProcess.on("error", (error) => { this.sendIpcLogMessage({ data: error.message, error: true }); @@ -61,20 +68,20 @@ export class KubeAuthProxy { this.exit(); }); - this.proxyProcess.stdout.on("data", (data) => { - let logItem = data.toString(); - - if (logItem.startsWith("Starting to serve on")) { - logItem = "Authentication proxy started\n"; - } - this.sendIpcLogMessage({ data: logItem }); - }); - this.proxyProcess.stderr.on("data", (data) => { this.lastError = this.parseError(data.toString()); this.sendIpcLogMessage({ data: data.toString(), error: true }); }); + this._port = await getPortFrom(this.proxyProcess.stdout, { + lineRegex: startingServeRegex, + onFind: () => this.sendIpcLogMessage({ data: "Authentication proxy started\n" }), + }); + + this.proxyProcess.stdout.on("data", (data: any) => { + this.sendIpcLogMessage({ data: data.toString() }); + }); + return waitUntilUsed(this.port, 500, 10000); } @@ -96,7 +103,7 @@ export class KubeAuthProxy { return errorMsg; } - protected async sendIpcLogMessage(res: KubeAuthProxyLog) { + protected sendIpcLogMessage(res: KubeAuthProxyLog) { const channel = `kube-auth:${this.cluster.id}`; logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() }); diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index 6fe5cddf39..d7753bf742 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -6,12 +6,13 @@ import path from "path"; import fs from "fs-extra"; import { dumpConfigYaml, loadConfig } from "../common/kube-helpers"; import logger from "./logger"; +import { LensProxy } from "./proxy/lens-proxy"; export class KubeconfigManager { protected configDir = app.getPath("temp"); protected tempFile: string = null; - constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) { } + constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) { } async getPath(): Promise { if (this.tempFile === undefined) { @@ -46,15 +47,15 @@ export class KubeconfigManager { protected async init() { try { - await this.contextHandler.ensurePort(); + await this.contextHandler.ensureServer(); this.tempFile = await this.createProxyKubeconfig(); } catch (err) { logger.error(`Failed to created temp config for auth-proxy`, { err }); } } - protected resolveProxyUrl() { - return `http://127.0.0.1:${this.port}/${this.cluster.id}`; + get resolveProxyUrl() { + return `http://127.0.0.1:${LensProxy.getInstance().port}/${this.cluster.id}`; } /** @@ -71,7 +72,7 @@ export class KubeconfigManager { clusters: [ { name: contextName, - server: this.resolveProxyUrl(), + server: this.resolveProxyUrl, skipTLSVerify: undefined, } ], diff --git a/src/main/port.ts b/src/main/port.ts deleted file mode 100644 index cd4c5701e8..0000000000 --- a/src/main/port.ts +++ /dev/null @@ -1,25 +0,0 @@ -import net, { AddressInfo } from "net"; -import logger from "./logger"; - -// todo: check https://github.com/http-party/node-portfinder ? - -export async function getFreePort(): Promise { - logger.debug("Lookup new free port.."); - - return new Promise((resolve, reject) => { - const server = net.createServer(); - - server.unref(); - server.on("listening", () => { - const port = (server.address() as AddressInfo).port; - - server.close(() => resolve(port)); - logger.debug(`New port found: ${port}`); - }); - server.on("error", error => { - logger.error(`Can't resolve new port: "${error}"`); - reject(error); - }); - server.listen({ host: "127.0.0.1", port: 0 }); - }); -} diff --git a/src/main/port_spec.ts b/src/main/port_spec.ts deleted file mode 100644 index 43c2326b92..0000000000 --- a/src/main/port_spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { EventEmitter } from "events"; -import { getFreePort } from "./port"; - -let newPort = 0; - -jest.mock("net", () => { - return { - createServer() { - return new class MockServer extends EventEmitter { - listen = jest.fn(() => { - this.emit("listening"); - - return this; - }); - address = () => { - newPort = Math.round(Math.random() * 10000); - - return { - port: newPort - }; - }; - unref = jest.fn(); - close = jest.fn(cb => cb()); - }; - }, - }; -}); - -describe("getFreePort", () => { - it("finds the next free port", async () => { - return expect(getFreePort()).resolves.toEqual(newPort); - }); -}); diff --git a/src/main/proxy/index.ts b/src/main/proxy/index.ts new file mode 100644 index 0000000000..64e2cfdca7 --- /dev/null +++ b/src/main/proxy/index.ts @@ -0,0 +1,2 @@ +// Don't export the contents here +// It will break the extension webpack diff --git a/src/main/lens-proxy.ts b/src/main/proxy/lens-proxy.ts similarity index 70% rename from src/main/lens-proxy.ts rename to src/main/proxy/lens-proxy.ts index aa92f4b90e..b0ac867e39 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/proxy/lens-proxy.ts @@ -3,45 +3,30 @@ import http from "http"; import spdy from "spdy"; import httpProxy from "http-proxy"; import url from "url"; -import * as WebSocket from "ws"; -import { apiPrefix, apiKubePrefix } from "../common/vars"; -import { Router } from "./router"; -import { ContextHandler } from "./context-handler"; -import logger from "./logger"; -import { NodeShellSession, LocalShellSession } from "./shell-session"; -import { Singleton } from "../common/utils"; -import { ClusterManager } from "./cluster-manager"; +import { apiPrefix, apiKubePrefix } from "../../common/vars"; +import { Router } from "../router"; +import { ContextHandler } from "../context-handler"; +import logger from "../logger"; +import { Singleton } from "../../common/utils"; +import { ClusterManager } from "../cluster-manager"; + +type WSUpgradeHandler = (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => void; export class LensProxy extends Singleton { protected origin: string; protected proxyServer: http.Server; - protected router: Router; + protected router = new Router(); protected closed = false; protected retryCounters = new Map(); - constructor(protected port: number) { + public port: number; + + constructor(handleWsUpgrade: WSUpgradeHandler) { super(); - this.origin = `http://localhost:${port}`; - this.router = new Router(); - } - - listen(port = this.port): this { - this.proxyServer = this.buildCustomProxy().listen(port, "127.0.0.1"); - logger.info(`[LENS-PROXY]: Proxy server has started at ${this.origin}`); - - return this; - } - - close() { - logger.info("Closing proxy server"); - this.proxyServer.close(); - this.closed = true; - } - - protected buildCustomProxy(): http.Server { const proxy = this.createProxy(); - const spdyProxy = spdy.createServer({ + + this.proxyServer = spdy.createServer({ spdy: { plain: true, protocols: ["http/1.1", "spdy/3.1"] @@ -50,18 +35,51 @@ export class LensProxy extends Singleton { this.handleRequest(proxy, req, res); }); - spdyProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { - if (req.url.startsWith(`${apiPrefix}?`)) { - this.handleWsUpgrade(req, socket, head); - } else { - this.handleProxyUpgrade(proxy, req, socket, head); - } - }); - spdyProxy.on("error", (err) => { - logger.error("proxy error", err); - }); + this.proxyServer + .on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { + if (req.url.startsWith(`${apiPrefix}?`)) { + handleWsUpgrade(req, socket, head); + } else { + this.handleProxyUpgrade(proxy, req, socket, head); + } + }); + } - return spdyProxy; + /** + * Starts the lens proxy. + * @resolves After the server is listening + * @rejects if there is an error before that happens + */ + listen(): Promise { + return new Promise((resolve, reject) => { + this.proxyServer.listen(0, "127.0.0.1"); + + this.proxyServer + .once("listening", () => { + this.proxyServer.removeAllListeners("error"); // don't reject the promise + + const { address, port } = this.proxyServer.address() as net.AddressInfo; + + logger.info(`[LENS-PROXY]: Proxy server has started at ${address}:${port}`); + + this.proxyServer.on("error", (error) => { + logger.info(`[LENS-PROXY]: Subsequent error: ${error}`); + }); + + this.port = port; + resolve(); + }) + .once("error", (error) => { + logger.info(`[LENS-PROXY]: Proxy server failed to start: ${error}`); + reject(error); + }); + }); + } + + close() { + logger.info("Closing proxy server"); + this.proxyServer.close(); + this.closed = true; } protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) { @@ -166,21 +184,6 @@ export class LensProxy extends Singleton { return proxy; } - protected createWsListener(): WebSocket.Server { - const ws = new WebSocket.Server({ noServer: true }); - - return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); - const nodeParam = url.parse(req.url, true).query["node"]?.toString(); - const shell = nodeParam - ? new NodeShellSession(socket, cluster, nodeParam) - : new LocalShellSession(socket, cluster); - - shell.open() - .catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error })); - })); - } - protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise { if (req.url.startsWith(apiKubePrefix)) { delete req.headers.authorization; @@ -211,12 +214,4 @@ export class LensProxy extends Singleton { } this.router.route(cluster, req, res); } - - protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) { - const wsServer = this.createWsListener(); - - wsServer.handleUpgrade(req, socket, head, (con) => { - wsServer.emit("connection", con, req); - }); - } } diff --git a/src/main/proxy/ws-upgrade.ts b/src/main/proxy/ws-upgrade.ts new file mode 100644 index 0000000000..9fc2d03d94 --- /dev/null +++ b/src/main/proxy/ws-upgrade.ts @@ -0,0 +1,36 @@ +/** + * This file is here so that the "../shell-session" import can be injected into + * LensProxy at creation time. So that the `pty.node` extension isn't loaded + * into Lens Extension webpack bundle. + */ + +import * as WebSocket from "ws"; +import http from "http"; +import net from "net"; +import url from "url"; +import { NodeShellSession, LocalShellSession } from "../shell-session"; +import { ClusterManager } from "../cluster-manager"; +import logger from "../logger"; + +function createWsListener(): WebSocket.Server { + const ws = new WebSocket.Server({ noServer: true }); + + return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { + const cluster = ClusterManager.getInstance().getClusterForRequest(req); + const nodeParam = url.parse(req.url, true).query["node"]?.toString(); + const shell = nodeParam + ? new NodeShellSession(socket, cluster, nodeParam) + : new LocalShellSession(socket, cluster); + + shell.open() + .catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error })); + })); +} + +export async function handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) { + const wsServer = createWsListener(); + + wsServer.handleUpgrade(req, socket, head, (con) => { + wsServer.emit("connection", con, req); + }); +} diff --git a/src/main/routes/metrics-route.ts b/src/main/routes/metrics-route.ts index d132f7b0ae..b6fbdadb61 100644 --- a/src/main/routes/metrics-route.ts +++ b/src/main/routes/metrics-route.ts @@ -4,6 +4,7 @@ import { LensApi } from "../lens-api"; import { Cluster, ClusterMetadataKey } from "../cluster"; import { ClusterPrometheusMetadata } from "../../common/cluster-store"; import logger from "../logger"; +import { getMetrics } from "../k8s-request"; export type IMetricsQuery = string | string[] | { [metricName: string]: string; @@ -22,7 +23,7 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa async function loadMetricHelper(): Promise { for (const [attempt, lastAttempt] of ATTEMPTS.entries()) { // retry try { - return await cluster.getMetrics(prometheusPath, { query, ...queryParams }); + return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); } catch (error) { if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { logger.error("[Metrics]: metrics not available", { error }); diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts index 0b5954948d..6edd81e537 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -2,48 +2,58 @@ import { LensApiRequest } from "../router"; import { LensApi } from "../lens-api"; import { spawn, ChildProcessWithoutNullStreams } from "child_process"; import { Kubectl } from "../kubectl"; -import { getFreePort } from "../port"; import { shell } from "electron"; import * as tcpPortUsed from "tcp-port-used"; import logger from "../logger"; +import { getPortFrom } from "../utils/get-port"; + +interface PortForwardArgs { + clusterId: string; + kind: string; + namespace: string; + name: string; + port: string; +} + +const internalPortRegex = /^forwarding from (?
.+) ->/i; class PortForward { public static portForwards: PortForward[] = []; - static getPortforward(forward: {clusterId: string; kind: string; name: string; namespace: string; port: string}) { - return PortForward.portForwards.find((pf) => { - return ( - pf.clusterId == forward.clusterId && - pf.kind == forward.kind && - pf.name == forward.name && - pf.namespace == forward.namespace && - pf.port == forward.port - ); - }); + static getPortforward(forward: PortForwardArgs) { + return PortForward.portForwards.find((pf) => ( + pf.clusterId == forward.clusterId && + pf.kind == forward.kind && + pf.name == forward.name && + pf.namespace == forward.namespace && + pf.port == forward.port + )); } - public clusterId: string; public process: ChildProcessWithoutNullStreams; - public kubeConfig: string; + public clusterId: string; public kind: string; public namespace: string; public name: string; public port: string; - public localPort: number; + public internalPort?: number; - constructor(obj: any) { - Object.assign(this, obj); + constructor(public kubeConfig: string, args: PortForwardArgs) { + this.clusterId = args.clusterId; + this.kind = args.kind; + this.namespace = args.namespace; + this.name = args.name; + this.port = args.port; } public async start() { - this.localPort = await getFreePort(); const kubectlBin = await Kubectl.bundled().getPath(); const args = [ "--kubeconfig", this.kubeConfig, "port-forward", "-n", this.namespace, `${this.kind}/${this.name}`, - `${this.localPort}:${this.port}` + `:${this.port}` ]; this.process = spawn(kubectlBin, args, { @@ -58,8 +68,12 @@ class PortForward { } }); + this.internalPort = await getPortFrom(this.process.stdout, { + lineRegex: internalPortRegex, + }); + try { - await tcpPortUsed.waitUntilUsed(this.localPort, 500, 15000); + await tcpPortUsed.waitUntilUsed(this.internalPort, 500, 15000); return true; } catch (error) { @@ -70,7 +84,14 @@ class PortForward { } public open() { - shell.openExternal(`http://localhost:${this.localPort}`); + shell.openExternal(`http://localhost:${this.internalPort}`) + .catch(error => logger.error(`[PORT-FORWARD]: failed to open external shell: ${error}`, { + clusterId: this.clusterId, + port: this.port, + kind: this.kind, + namespace: this.namespace, + name: this.name, + })); } } @@ -86,13 +107,12 @@ class PortForwardRoute extends LensApi { if (!portForward) { logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`); - portForward = new PortForward({ + portForward = new PortForward(await cluster.getProxyKubeconfigPath(), { clusterId: cluster.id, kind: resourceType, namespace, name: resourceName, port, - kubeConfig: await cluster.getProxyKubeconfigPath() }); const started = await portForward.start(); diff --git a/src/main/utils/get-port.ts b/src/main/utils/get-port.ts new file mode 100644 index 0000000000..9af1191002 --- /dev/null +++ b/src/main/utils/get-port.ts @@ -0,0 +1,51 @@ +import { Readable } from "stream"; +import URLParse from "url-parse"; + +interface GetPortArgs { + /** + * Should be case insensitive + * Must have a named matching group called `address` + */ + lineRegex: RegExp; + /** + * Called when the port is found + */ + onFind?: () => void; + /** + * Timeout for how long to wait for the port. + * Default: 5s + */ + timeout?: number; +} + +/** + * Parse lines from `stream` (assumes data comes in lines) to find the port + * which the source of the stream is watching on. + * @param stream A readable stream to match lines against + * @param args The args concerning the stream + * @returns A Promise for port number + */ +export function getPortFrom(stream: Readable, args: GetPortArgs): Promise { + return new Promise((resolve, reject) => { + const handler = (data: any) => { + const logItem: string = data.toString(); + const match = logItem.match(args.lineRegex); + + if (match) { + // use unknown protocol so that there is no default port + const addr = new URLParse(`s://${match.groups.address.trim()}`); + + args.onFind?.(); + stream.off("data", handler); + clearTimeout(timeoutID); + resolve(+addr.port); + } + }; + const timeoutID = setTimeout(() => { + stream.off("data", handler); + reject(new Error("failed to retrieve port from stream")); + }, args.timeout ?? 5000); + + stream.on("data", handler); + }); +} diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 0a8627b324..7324a3df1a 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -11,6 +11,7 @@ import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import logger from "./logger"; import { productName } from "../common/vars"; +import { LensProxy } from "./proxy/lens-proxy"; export class WindowManager extends Singleton { protected mainWindow: BrowserWindow; @@ -20,7 +21,7 @@ export class WindowManager extends Singleton { @observable activeClusterId: ClusterId; - constructor(protected proxyPort: number) { + constructor() { super(); this.bindEvents(); this.initMenu(); @@ -28,7 +29,7 @@ export class WindowManager extends Singleton { } get mainUrl() { - return `http://localhost:${this.proxyPort}`; + return `http://localhost:${LensProxy.getInstance().port}`; } async initMainWindow(showSplash = true) { diff --git a/src/migrations/hotbar-store/5.0.0-alpha.0.ts b/src/migrations/hotbar-store/5.0.0-alpha.0.ts index 08d3e9785c..58d7073761 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.0.ts +++ b/src/migrations/hotbar-store/5.0.0-alpha.0.ts @@ -9,7 +9,7 @@ export default migration({ run(store) { const hotbars: Hotbar[] = []; - ClusterStore.getInstance().enabledClustersList.forEach((cluster: any) => { + ClusterStore.getInstance().clustersList.forEach((cluster: any) => { const name = cluster.workspace; if (!name) return; diff --git a/src/renderer/components/+add-cluster/add-cluster.scss b/src/renderer/components/+add-cluster/add-cluster.scss index 9583ec17b9..1185fcd761 100644 --- a/src/renderer/components/+add-cluster/add-cluster.scss +++ b/src/renderer/components/+add-cluster/add-cluster.scss @@ -3,8 +3,8 @@ $spacing: $padding * 2; .AceEditor { - min-height: 200px; - max-height: 400px; + min-height: 600px; + max-height: 600px; border: 1px solid var(--colorVague); border-radius: $radius; @@ -17,24 +17,6 @@ } } - .Select { - .kube-context { - --flex-gap: #{$padding}; - } - - // todo: extract to component, merge with namespace-select.scss - &__placeholder { - width: 100%; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - &__control { - box-shadow: 0 0 0 1px $borderFaintColor; - } - } - code { color: $pink-400; } diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 4c65104c7e..b6d24f46e5 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -1,51 +1,35 @@ import "./add-cluster.scss"; -import os from "os"; import React from "react"; import { observer } from "mobx-react"; import { action, observable, runInAction } from "mobx"; -import { remote } from "electron"; import { KubeConfig } from "@kubernetes/client-node"; -import { Select, SelectOption } from "../select"; -import { DropFileInput, Input } from "../input"; import { AceEditor } from "../ace-editor"; import { Button } from "../button"; -import { Icon } from "../icon"; -import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers"; +import { loadConfig, splitConfig, validateKubeConfig } from "../../../common/kube-helpers"; import { ClusterStore } from "../../../common/cluster-store"; import { v4 as uuid } from "uuid"; import { navigate } from "../../navigation"; import { UserStore } from "../../../common/user-store"; -import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; -import { Tab, Tabs } from "../tabs"; import { ExecValidationNotFoundError } from "../../../common/custom-errors"; import { appEventBus } from "../../../common/event-bus"; import { PageLayout } from "../layout/page-layout"; import { docsUrl } from "../../../common/vars"; import { catalogURL } from "../+catalog"; - -enum KubeConfigSourceTab { - FILE = "file", - TEXT = "text" -} - +import { preferencesURL } from "../+preferences"; +import { Input } from "../input"; @observer export class AddCluster extends React.Component { @observable.ref kubeConfigLocal: KubeConfig; @observable.ref error: React.ReactNode; - - @observable kubeContexts = observable.map(); // available contexts from kubeconfig-file or user-input - @observable selectedContexts = observable.array(); - @observable sourceTab = KubeConfigSourceTab.FILE; - @observable kubeConfigPath = ""; @observable customConfig = ""; @observable proxyServer = ""; @observable isWaiting = false; @observable showSettings = false; + kubeContexts = observable.map(); + componentDidMount() { - ClusterStore.getInstance().setActive(null); - this.setKubeConfig(UserStore.getInstance().kubeConfigPath); appEventBus.emit({ name: "cluster-add", action: "start" }); } @@ -53,52 +37,19 @@ export class AddCluster extends React.Component { UserStore.getInstance().markNewContextsAsSeen(); } - @action - setKubeConfig(filePath: string, { throwError = false } = {}) { - try { - this.kubeConfigLocal = loadConfig(filePath); - validateConfig(this.kubeConfigLocal); - this.refreshContexts(); - this.kubeConfigPath = filePath; - UserStore.getInstance().kubeConfigPath = filePath; // save to store - } catch (err) { - if (!UserStore.getInstance().isDefaultKubeConfigPath) { - Notifications.error( -
Can't setup {filePath} as kubeconfig: {String(err)}
- ); - } - - if (throwError) { - throw err; - } - } - } - @action refreshContexts() { - this.selectedContexts.clear(); this.kubeContexts.clear(); - switch (this.sourceTab) { - case KubeConfigSourceTab.FILE: - const contexts = this.getContexts(this.kubeConfigLocal); + try { + this.error = ""; + const contexts = this.getContexts(loadConfig(this.customConfig || "{}")); - this.kubeContexts.replace(contexts); - break; - case KubeConfigSourceTab.TEXT: - try { - this.error = ""; - const contexts = this.getContexts(loadConfig(this.customConfig || "{}")); + console.log(contexts); - this.kubeContexts.replace(contexts); - } catch (err) { - this.error = String(err); - } - break; - } - - if (this.kubeContexts.size === 1) { - this.selectedContexts.push(this.kubeContexts.keys().next().value); + this.kubeContexts.replace(contexts); + } catch (err) { + this.error = String(err); } } @@ -112,36 +63,14 @@ export class AddCluster extends React.Component { return contexts; } - selectKubeConfigDialog = async () => { - const { dialog, BrowserWindow } = remote; - const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { - defaultPath: this.kubeConfigPath, - properties: ["openFile", "showHiddenFiles"], - message: `Select custom kubeconfig file`, - buttonLabel: `Use configuration`, - }); - - if (!canceled && filePaths.length) { - this.setKubeConfig(filePaths[0]); - } - }; - - onDropKubeConfig = (files: File[]) => { - this.sourceTab = KubeConfigSourceTab.FILE; - this.setKubeConfig(files[0].path); - }; - @action addClusters = (): void => { try { - if (!this.selectedContexts.length) { - return void (this.error = "Please select at least one cluster context"); - } this.error = ""; this.isWaiting = true; appEventBus.emit({ name: "cluster-add", action: "click" }); - const newClusters = this.selectedContexts.filter(context => { + const newClusters = Array.from(this.kubeContexts.keys()).filter(context => { const kubeConfig = this.kubeContexts.get(context); const error = validateKubeConfig(kubeConfig, context); @@ -157,9 +86,7 @@ export class AddCluster extends React.Component { }).map(context => { const clusterId = uuid(); const kubeConfig = this.kubeContexts.get(context); - const kubeConfigPath = this.sourceTab === KubeConfigSourceTab.FILE - ? this.kubeConfigPath // save link to original kubeconfig in file-system - : ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder + const kubeConfigPath = ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder return { id: clusterId, @@ -193,9 +120,8 @@ export class AddCluster extends React.Component { renderInfo() { return (

- Add clusters by clicking the Add Cluster button. - You'll need to obtain a working kubeconfig for the cluster you want to add. - You can either browse it from the file system or paste it as a text from the clipboard. + Paste kubeconfig as a text from the clipboard to the textarea below. + If you want to add clusters from kubeconfigs that exists on filesystem, please add those files (or folders) to kubeconfig sync via navigate(preferencesURL())}>Preferences. Read more about adding clusters here.

); @@ -204,180 +130,66 @@ export class AddCluster extends React.Component { renderKubeConfigSource() { return ( <> - - - + { + this.customConfig = value; + this.refreshContexts(); + }} /> - - {this.sourceTab === KubeConfigSourceTab.FILE && ( -
-
- this.kubeConfigPath = v} - onBlur={this.onKubeConfigInputBlur} - /> - {this.kubeConfigPath !== kubeConfigDefaultPath && ( - this.setKubeConfig(kubeConfigDefaultPath)} - tooltip="Reset" - /> - )} - -
- - Pro-Tip: you can also drag-n-drop kubeconfig file to this area - -
- )} - {this.sourceTab === KubeConfigSourceTab.TEXT && ( -
- { - this.customConfig = value; - this.refreshContexts(); - }} - /> - - Pro-Tip: paste kubeconfig to get available contexts - -
- )} + ); } - renderContextSelector() { - const allContexts = Array.from(this.kubeContexts.keys()); - const placeholder = this.selectedContexts.length > 0 - ? <>Selected contexts: {this.selectedContexts.length} - : "Select contexts"; - - return ( -
- this.proxyServer = value} - theme="round-black" - /> - - {"A HTTP proxy server URL (format: http://
:)."} - -
- )} - {this.error && ( -
{this.error}
- )} - -
-
+ ); } } diff --git a/src/renderer/components/cluster-settings/cluster-settings.tsx b/src/renderer/components/cluster-settings/cluster-settings.tsx index 5ebde4caa8..ba1aff0d9f 100644 --- a/src/renderer/components/cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/cluster-settings/cluster-settings.tsx @@ -13,13 +13,7 @@ import { CatalogEntity } from "../../api/catalog-entity"; function getClusterForEntity(entity: CatalogEntity) { - const cluster = ClusterStore.getInstance().getById(entity.metadata.uid); - - if (!cluster?.enabled) { - return null; - } - - return cluster; + return ClusterStore.getInstance().getById(entity.metadata.uid); } entitySettingRegistry.add([ diff --git a/src/renderer/components/cluster-settings/components/remove-cluster-button.tsx b/src/renderer/components/cluster-settings/components/remove-cluster-button.tsx index 20c1b2b62c..b41aa4d9aa 100644 --- a/src/renderer/components/cluster-settings/components/remove-cluster-button.tsx +++ b/src/renderer/components/cluster-settings/components/remove-cluster-button.tsx @@ -27,10 +27,8 @@ export class RemoveClusterButton extends React.Component { } render() { - const { cluster } = this.props; - return ( - ); diff --git a/src/renderer/components/layout/__test__/main-layout-header.test.tsx b/src/renderer/components/layout/__test__/main-layout-header.test.tsx index 11985e9b42..a92f87f266 100644 --- a/src/renderer/components/layout/__test__/main-layout-header.test.tsx +++ b/src/renderer/components/layout/__test__/main-layout-header.test.tsx @@ -7,20 +7,50 @@ import "@testing-library/jest-dom/extend-expect"; import { MainLayoutHeader } from "../main-layout-header"; import { Cluster } from "../../../../main/cluster"; import { ClusterStore } from "../../../../common/cluster-store"; - -const cluster: Cluster = new Cluster({ - id: "foo", - contextName: "minikube", - kubeConfigPath: "minikube-config.yml", -}); +import mockFs from "mock-fs"; describe("", () => { + let cluster: Cluster; + beforeEach(() => { + const mockOpts = { + "minikube-config.yml": JSON.stringify({ + apiVersion: "v1", + clusters: [{ + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }], + contexts: [{ + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }], + users: [{ + name: "minikube", + }], + kind: "Config", + preferences: {}, + }) + }; + + mockFs(mockOpts); + ClusterStore.createInstance(); + + cluster = new Cluster({ + id: "foo", + contextName: "minikube", + kubeConfigPath: "minikube-config.yml", + }); }); afterEach(() => { ClusterStore.resetInstance(); + mockFs.restore(); }); it("renders w/o errors", () => {