1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Fix kubeconfig-sync issues (#2692)

- Removed `getFreePort` as its use is always a race condition. Change
  all uses of it to retrive the port after listening

- Added `getPortFrom` as a helper function to read a port from a stream

- Remove `Cluster.ownerRef` as it is outdated and no longer needed for 5.0

- Remove `Cluster.enabled`, no longer needed because of above

- Removed `Cluster.init`, moved its contents into `Cluster.constructor`
  as nothing in that function is asyncronous. Currently only being run
  on `main` as a stop gap until `renderer` gets its own version of
  `Cluster`

- Refactored `LensProxy` so as to prevent `pty.node` (a NodeJS
  extension) being included in `webpack.extension.ts`'s run

- Removed the passing around of the proxy port as that can now be
  accessed from an instance of `LensProxy`

- purge ContextHandler's cache on disconnect

Co-authored-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Jari Kolehmainen 2021-05-04 22:21:15 +03:00 committed by GitHub
parent bf96e6ef4c
commit 8fff064e0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 563 additions and 893 deletions

View File

@ -6,7 +6,7 @@
*/ */
import { Application } from "spectron"; import { Application } from "spectron";
import * as utils from "../helpers/utils"; 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 { exec } from "child_process";
import * as util from "util"; import * as util from "util";
@ -25,7 +25,6 @@ describe("Lens cluster pages", () => {
let clusterAdded = false; let clusterAdded = false;
const addCluster = async () => { const addCluster = async () => {
await app.client.waitUntilTextExists("div", "Catalog"); await app.client.waitUntilTextExists("div", "Catalog");
await addMinikubeCluster(app);
await waitForMinikubeDashboard(app); await waitForMinikubeDashboard(app);
await app.client.click('a[href="/nodes"]'); await app.client.click('a[href="/nodes"]');
await app.client.waitUntilTextExists("div.TableCell", "Ready"); await app.client.waitUntilTextExists("div.TableCell", "Ready");

View File

@ -38,28 +38,12 @@ export function minikubeReady(testNamespace: string): boolean {
return true; return true;
} }
export async function addMinikubeCluster(app: Application) { export async function waitForMinikubeDashboard(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
await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.waitUntilTextExists("div.TableCell", "minikube");
await app.client.waitForExist(".Input.SearchInput input"); await app.client.waitForExist(".Input.SearchInput input");
await app.client.setValue(".Input.SearchInput input", "minikube"); await app.client.setValue(".Input.SearchInput input", "minikube");
await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.waitUntilTextExists("div.TableCell", "minikube");
await app.client.click("div.TableRow"); 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.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`); await app.client.waitForExist(`iframe[name="minikube"]`);
await app.client.frame("minikube"); await app.client.frame("minikube");

View File

@ -92,7 +92,6 @@ describe("empty config", () => {
expect(storedCluster.id).toBe("foo"); expect(storedCluster.id).toBe("foo");
expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); expect(storedCluster.preferences.terminalCWD).toBe("/tmp");
expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5"); expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5");
expect(storedCluster.enabled).toBe(true);
}); });
it("removes cluster from store", async () => { it("removes cluster from store", async () => {
@ -215,13 +214,6 @@ describe("config with existing clusters", () => {
expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2"); expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2");
expect(storedClusters[2].id).toBe("cluster3"); 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", () => { describe("config with invalid cluster kubeconfig", () => {
@ -288,18 +280,35 @@ users:
it("does not enable clusters with invalid kubeconfig", () => { it("does not enable clusters with invalid kubeconfig", () => {
const storedClusters = ClusterStore.getInstance().clustersList; const storedClusters = ClusterStore.getInstance().clustersList;
expect(storedClusters.length).toBe(2); expect(storedClusters.length).toBe(1);
expect(storedClusters[0].enabled).toBeFalsy;
expect(storedClusters[1].id).toBe("cluster2");
expect(storedClusters[1].enabled).toBeTruthy;
}); });
}); });
const minimalValidKubeConfig = JSON.stringify({ const minimalValidKubeConfig = JSON.stringify({
apiVersion: "v1", apiVersion: "v1",
clusters: [], clusters: [{
users: [], name: "minikube",
contexts: [], 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", () => { 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 () => { it("migrates to modern format with kubeconfig in a file", async () => {
const config = ClusterStore.getInstance().clustersList[0].kubeConfigPath; 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 config = fs.readFileSync(file, "utf8");
const kc = yaml.safeLoad(config); 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["access-token"]).toBe("should be string");
expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string"); expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string");
}); });

View File

@ -39,7 +39,10 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
onlyVisibleForSource: "local", onlyVisibleForSource: "local",
onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`) onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`)
}, },
{ ];
if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) {
context.menuItems.push({
icon: "delete", icon: "delete",
title: "Delete", title: "Delete",
onlyVisibleForSource: "local", onlyVisibleForSource: "local",
@ -47,8 +50,8 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
confirm: { confirm: {
message: `Remove Kubernetes Cluster "${this.metadata.name} from ${productName}?` message: `Remove Kubernetes Cluster "${this.metadata.name} from ${productName}?`
} }
}, });
]; }
if (this.status.phase == "connected") { if (this.status.phase == "connected") {
context.menuItems.unshift({ context.menuItems.unshift({

View File

@ -64,11 +64,6 @@ export interface ClusterModel {
/** Metadata */ /** Metadata */
metadata?: ClusterMetadata; metadata?: ClusterMetadata;
/**
* If extension sets ownerRef it has to explicitly mark a cluster as enabled during onActive (or when cluster is saved)
*/
ownerRef?: string;
/** List of accessible namespaces */ /** List of accessible namespaces */
accessibleNamespaces?: string[]; accessibleNamespaces?: string[];
@ -172,9 +167,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
protected pushStateToViewsAutomatically() { protected pushStateToViewsAutomatically() {
if (ipcMain) { if (ipcMain) {
this.disposer.push( this.disposer.push(
reaction(() => this.enabledClustersList, () => {
this.pushState();
}),
reaction(() => this.connectedClustersList, () => { reaction(() => this.connectedClustersList, () => {
this.pushState(); this.pushState();
}), }),
@ -210,10 +202,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return Array.from(this.clusters.values()); return Array.from(this.clusters.values());
} }
@computed get enabledClustersList(): Cluster[] {
return this.clustersList.filter((c) => c.enabled);
}
@computed get active(): Cluster | null { @computed get active(): Cluster | null {
return this.getById(this.activeCluster); return this.getById(this.activeCluster);
} }
@ -232,13 +220,9 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@action @action
setActive(clusterId: ClusterId) { setActive(clusterId: ClusterId) {
const cluster = this.clusters.get(clusterId); this.activeCluster = this.clusters.has(clusterId)
? clusterId
if (!cluster?.enabled) { : null;
clusterId = null;
}
this.activeCluster = clusterId;
} }
deactivate(id: ClusterId) { deactivate(id: ClusterId) {
@ -274,10 +258,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
? clusterOrModel ? clusterOrModel
: new Cluster(clusterOrModel); : new Cluster(clusterOrModel);
if (!cluster.isManaged) {
cluster.enabled = true;
}
this.clusters.set(cluster.id, cluster); this.clusters.set(cluster.id, cluster);
return cluster; return cluster;
@ -314,18 +294,18 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
// update new clusters // update new clusters
for (const clusterModel of clusters) { for (const clusterModel of clusters) {
try {
let cluster = currentClusters.get(clusterModel.id); let cluster = currentClusters.get(clusterModel.id);
if (cluster) { if (cluster) {
cluster.updateModel(clusterModel); cluster.updateModel(clusterModel);
} else { } else {
cluster = new Cluster(clusterModel); cluster = new Cluster(clusterModel);
if (!cluster.isManaged && cluster.apiUrl) {
cluster.enabled = true;
}
} }
newClusters.set(clusterModel.id, cluster); newClusters.set(clusterModel.id, cluster);
} catch {
// ignore
}
} }
// update removed clusters // update removed clusters
@ -335,7 +315,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
}); });
this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null; this.setActive(activeCluster);
this.clusters.replace(newClusters); this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters); this.removedClusters.replace(removedClusters);
} }

View File

@ -1,6 +1,6 @@
// Lens-extensions api developer's kit // Lens-extensions api developer's kit
export * from "../lens-main-extension"; export { LensMainExtension } from "../lens-main-extension";
export * from "../lens-renderer-extension"; export { LensRendererExtension } from "../lens-renderer-extension";
// APIs // APIs
import * as App from "./app"; import * as App from "./app";

View File

@ -31,15 +31,8 @@ jest.mock("request-promise-native");
import { Console } from "console"; import { Console } from "console";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import { Cluster } from "../cluster"; 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"; import { Kubectl } from "../kubectl";
const mockedRequest = request as jest.MockedFunction<typeof request>;
console = new Console(process.stdout, process.stderr); // fix mockFS console = new Console(process.stdout, process.stderr); // fix mockFS
describe("create clusters", () => { describe("create clusters", () => {
@ -99,54 +92,7 @@ describe("create clusters", () => {
expect(() => c.disconnect()).not.toThrowError(); 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 () => { 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<boolean> => {
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 { const c = new class extends Cluster {
// only way to mock protected methods, without these we leak promises // only way to mock protected methods, without these we leak promises
@ -162,14 +108,20 @@ describe("create clusters", () => {
kubeConfigPath: "minikube-config.yml" 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(); await c.activate();
expect(ContextHandler.prototype.ensureServer).toBeCalled(); expect(c.reconnect).toBeCalled();
expect(mockedRequest).toBeCalled(); expect(c.refreshConnectionStatus).toBeCalled();
expect(c.accessible).toBe(true);
expect(c.allowedNamespaces.length).toBe(1);
expect(c.allowedResources.length).toBe(apiResources.length);
c.disconnect(); c.disconnect();
jest.resetAllMocks(); jest.resetAllMocks();
}); });

View File

@ -26,10 +26,10 @@ jest.mock("winston", () => ({
jest.mock("../../common/ipc"); jest.mock("../../common/ipc");
jest.mock("child_process"); jest.mock("child_process");
jest.mock("tcp-port-used"); jest.mock("tcp-port-used");
//jest.mock("../utils/get-port");
import { Cluster } from "../cluster"; import { Cluster } from "../cluster";
import { KubeAuthProxy } from "../kube-auth-proxy"; import { KubeAuthProxy } from "../kube-auth-proxy";
import { getFreePort } from "../port";
import { broadcastMessage } from "../../common/ipc"; import { broadcastMessage } from "../../common/ipc";
import { ChildProcess, spawn } from "child_process"; import { ChildProcess, spawn } from "child_process";
import { bundledKubectlPath, Kubectl } from "../kubectl"; import { bundledKubectlPath, Kubectl } from "../kubectl";
@ -39,6 +39,7 @@ import { Readable } from "stream";
import { UserStore } from "../../common/user-store"; import { UserStore } from "../../common/user-store";
import { Console } from "console"; import { Console } from "console";
import { stdout, stderr } from "process"; import { stdout, stderr } from "process";
import mockFs from "mock-fs";
console = new Console(stdout, stderr); console = new Console(stdout, stderr);
@ -51,11 +52,41 @@ describe("kube auth proxy tests", () => {
jest.clearAllMocks(); jest.clearAllMocks();
UserStore.resetInstance(); UserStore.resetInstance();
UserStore.createInstance(); 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 () => { it("calling exit multiple times shouldn't throw", async () => {
const port = await getFreePort(); const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "minikube-config.yml", contextName: "minikube" }), {});
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {});
kap.exit(); kap.exit();
kap.exit(); kap.exit();
@ -63,13 +94,11 @@ describe("kube auth proxy tests", () => {
}); });
describe("spawn tests", () => { describe("spawn tests", () => {
let port: number;
let mockedCP: MockProxy<ChildProcess>; let mockedCP: MockProxy<ChildProcess>;
let listeners: Record<string, (...args: any[]) => void>; let listeners: Record<string, (...args: any[]) => void>;
let proxy: KubeAuthProxy; let proxy: KubeAuthProxy;
beforeEach(async () => { beforeEach(async () => {
port = await getFreePort();
mockedCP = mock<ChildProcess>(); mockedCP = mock<ChildProcess>();
listeners = {}; listeners = {};
@ -89,6 +118,7 @@ describe("kube auth proxy tests", () => {
mockedCP.stdout = mock<Readable>(); mockedCP.stdout = mock<Readable>();
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners[`stdout/${event}`] = listener; listeners[`stdout/${event}`] = listener;
listeners[`stdout/${event}`]("Starting to serve on 127.0.0.1:9191");
return mockedCP.stdout; return mockedCP.stdout;
}); });
@ -98,10 +128,10 @@ describe("kube auth proxy tests", () => {
return mockedCP; return mockedCP;
}); });
mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve()); mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve());
const cluster = new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" });
jest.spyOn(cluster, "apiUrl", "get").mockReturnValue("https://fake.k8s.internal"); const cluster = new Cluster({ id: "foobar", kubeConfigPath: "minikube-config.yml", contextName: "minikube" });
proxy = new KubeAuthProxy(cluster, port, {});
proxy = new KubeAuthProxy(cluster, {});
}); });
it("should call spawn and broadcast errors", async () => { 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 () => { it("should call spawn and broadcast stdout serving info", async () => {
await proxy.run(); await proxy.run();
listeners["stdout/data"]("Starting to serve on");
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "Authentication proxy started\n" }); expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "Authentication proxy started\n" });
}); });

View File

@ -27,7 +27,6 @@ import { KubeconfigManager } from "../kubeconfig-manager";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import { Cluster } from "../cluster"; import { Cluster } from "../cluster";
import { ContextHandler } from "../context-handler"; import { ContextHandler } from "../context-handler";
import { getFreePort } from "../port";
import fse from "fs-extra"; import fse from "fs-extra";
import { loadYaml } from "@kubernetes/client-node"; import { loadYaml } from "@kubernetes/client-node";
import { Console } from "console"; import { Console } from "console";
@ -36,6 +35,9 @@ import * as path from "path";
console = new Console(process.stdout, process.stderr); // fix mockFS console = new Console(process.stdout, process.stderr); // fix mockFS
describe("kubeconfig manager tests", () => { describe("kubeconfig manager tests", () => {
let cluster: Cluster;
let contextHandler: ContextHandler;
beforeEach(() => { beforeEach(() => {
const mockOpts = { const mockOpts = {
"minikube-config.yml": JSON.stringify({ "minikube-config.yml": JSON.stringify({
@ -62,6 +64,14 @@ describe("kubeconfig manager tests", () => {
}; };
mockFs(mockOpts); 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(() => { afterEach(() => {
@ -69,14 +79,7 @@ describe("kubeconfig manager tests", () => {
}); });
it("should create 'temp' kube config with proxy", async () => { it("should create 'temp' kube config with proxy", async () => {
const cluster = new Cluster({ const kubeConfManager = new KubeconfigManager(cluster, contextHandler);
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
});
const contextHandler = new ContextHandler(cluster);
const port = await getFreePort();
const kubeConfManager = new KubeconfigManager(cluster, contextHandler, port);
expect(logger.error).not.toBeCalled(); expect(logger.error).not.toBeCalled();
expect(await kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`); expect(await kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`);
@ -86,19 +89,12 @@ describe("kubeconfig manager tests", () => {
const yml = loadYaml<any>(file.toString()); const yml = loadYaml<any>(file.toString());
expect(yml["current-context"]).toBe("minikube"); 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"); expect(yml["users"][0]["name"]).toBe("proxy");
}); });
it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => { it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => {
const cluster = new Cluster({ const kubeConfManager = new KubeconfigManager(cluster, contextHandler);
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 configPath = await kubeConfManager.getPath(); const configPath = await kubeConfManager.getPath();
expect(await fse.pathExists(configPath)).toBe(true); expect(await fse.pathExists(configPath)).toBe(true);

View File

@ -14,8 +14,8 @@ export class CatalogPusher {
init() { init() {
const disposers: Disposer[] = []; const disposers: Disposer[] = [];
disposers.push(reaction(() => this.catalog.items, (items) => { disposers.push(reaction(() => toJS(this.catalog.items, { recurseEverything: true }), (items) => {
broadcastMessage("catalog:items", toJS(items, { recurseEverything: true })); broadcastMessage("catalog:items", items);
}, { }, {
fireImmediately: true, fireImmediately: true,
})); }));

View File

@ -5,10 +5,12 @@ import { Cluster } from "../../cluster";
import { computeDiff, configToModels } from "../kubeconfig-sync"; import { computeDiff, configToModels } from "../kubeconfig-sync";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import fs from "fs"; import fs from "fs";
import { ClusterStore } from "../../../common/cluster-store";
describe("kubeconfig-sync.source tests", () => { describe("kubeconfig-sync.source tests", () => {
beforeEach(() => { beforeEach(() => {
mockFs(); mockFs();
ClusterStore.createInstance();
}); });
afterEach(() => { afterEach(() => {
@ -57,10 +59,9 @@ describe("kubeconfig-sync.source tests", () => {
it("should leave an empty source empty if there are no entries", () => { it("should leave an empty source empty if there are no entries", () => {
const contents = ""; const contents = "";
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>(); const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const port = 0;
const filePath = "/bar"; const filePath = "/bar";
computeDiff(contents, rootSource, port, filePath); computeDiff(contents, rootSource, filePath);
expect(rootSource.size).toBe(0); expect(rootSource.size).toBe(0);
}); });
@ -93,12 +94,11 @@ describe("kubeconfig-sync.source tests", () => {
currentContext: "foobar" currentContext: "foobar"
}); });
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>(); const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const port = 0;
const filePath = "/bar"; const filePath = "/bar";
fs.writeFileSync(filePath, contents); fs.writeFileSync(filePath, contents);
computeDiff(contents, rootSource, port, filePath); computeDiff(contents, rootSource, filePath);
expect(rootSource.size).toBe(1); expect(rootSource.size).toBe(1);
@ -137,12 +137,11 @@ describe("kubeconfig-sync.source tests", () => {
currentContext: "foobar" currentContext: "foobar"
}); });
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>(); const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const port = 0;
const filePath = "/bar"; const filePath = "/bar";
fs.writeFileSync(filePath, contents); fs.writeFileSync(filePath, contents);
computeDiff(contents, rootSource, port, filePath); computeDiff(contents, rootSource, filePath);
expect(rootSource.size).toBe(1); expect(rootSource.size).toBe(1);
@ -151,7 +150,7 @@ describe("kubeconfig-sync.source tests", () => {
expect(c.kubeConfigPath).toBe("/bar"); expect(c.kubeConfigPath).toBe("/bar");
expect(c.contextName).toBe("context-name"); expect(c.contextName).toBe("context-name");
computeDiff("{}", rootSource, port, filePath); computeDiff("{}", rootSource, filePath);
expect(rootSource.size).toBe(0); expect(rootSource.size).toBe(0);
}); });
@ -192,12 +191,11 @@ describe("kubeconfig-sync.source tests", () => {
currentContext: "foobar" currentContext: "foobar"
}); });
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>(); const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const port = 0;
const filePath = "/bar"; const filePath = "/bar";
fs.writeFileSync(filePath, contents); fs.writeFileSync(filePath, contents);
computeDiff(contents, rootSource, port, filePath); computeDiff(contents, rootSource, filePath);
expect(rootSource.size).toBe(2); expect(rootSource.size).toBe(2);
@ -237,7 +235,7 @@ describe("kubeconfig-sync.source tests", () => {
currentContext: "foobar" currentContext: "foobar"
}); });
computeDiff(newContents, rootSource, port, filePath); computeDiff(newContents, rootSource, filePath);
expect(rootSource.size).toBe(1); expect(rootSource.size).toBe(1);

View File

@ -3,7 +3,6 @@ import { CatalogEntity, catalogEntityRegistry } from "../../common/catalog";
import { watch } from "chokidar"; import { watch } from "chokidar";
import fs from "fs"; import fs from "fs";
import fse from "fs-extra"; import fse from "fs-extra";
import * as uuid from "uuid";
import stream from "stream"; import stream from "stream";
import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils";
import logger from "../logger"; import logger from "../logger";
@ -13,6 +12,7 @@ import { Cluster } from "../cluster";
import { catalogEntityFromCluster } from "../cluster-manager"; import { catalogEntityFromCluster } from "../cluster-manager";
import { UserStore } from "../../common/user-store"; import { UserStore } from "../../common/user-store";
import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store"; import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store";
import { createHash } from "crypto";
const logPrefix = "[KUBECONFIG-SYNC]:"; const logPrefix = "[KUBECONFIG-SYNC]:";
@ -24,7 +24,7 @@ export class KubeconfigSyncManager extends Singleton {
protected static readonly syncName = "lens:kube-sync"; protected static readonly syncName = "lens:kube-sync";
@action @action
startSync(port: number): void { startSync(): void {
if (this.syncing) { if (this.syncing) {
return; return;
} }
@ -41,16 +41,16 @@ export class KubeconfigSyncManager extends Singleton {
))); )));
// This must be done so that c&p-ed clusters are visible // 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()) { for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) {
this.startNewSync(filePath, port); this.startNewSync(filePath);
} }
this.syncListDisposer = UserStore.getInstance().syncKubeconfigEntries.observe(change => { this.syncListDisposer = UserStore.getInstance().syncKubeconfigEntries.observe(change => {
switch (change.type) { switch (change.type) {
case "add": case "add":
this.startNewSync(change.name, port); this.startNewSync(change.name);
break; break;
case "delete": case "delete":
this.stopOldSync(change.name); this.stopOldSync(change.name);
@ -72,14 +72,14 @@ export class KubeconfigSyncManager extends Singleton {
} }
@action @action
protected async startNewSync(filePath: string, port: number): Promise<void> { protected async startNewSync(filePath: string): Promise<void> {
if (this.sources.has(filePath)) { if (this.sources.has(filePath)) {
// don't start a new sync if we already have one // don't start a new sync if we already have one
return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath }); return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath });
} }
try { 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.info(`${logPrefix} starting sync of file/folder`, { filePath });
logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); 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<string, RootSourceValue>; type RootSource = ObservableMap<string, RootSourceValue>;
// exported for testing // 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(() => { runInAction(() => {
try { try {
const rawModels = configToModels(loadConfigFromString(contents), filePath); 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) { for (const [contextName, model] of models) {
// add new clusters to the source // add new clusters to the source
try { 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) { if (!cluster.apiUrl) {
throw new Error("Cluster constructor failed, see above error"); throw new Error("Cluster constructor failed, see above error");
} }
cluster.init(port);
const entity = catalogEntityFromCluster(cluster); const entity = catalogEntityFromCluster(cluster);
entity.metadata.labels.file = filePath; 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 }); logger.debug(`${logPrefix} file changed`, { filePath });
// TODO: replace with an AbortController with fs.readFile when we upgrade to Node 16 (after it comes out) // 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", () => { .on("end", () => {
if (!closed) { if (!closed) {
computeDiff(Buffer.concat(bufs).toString("utf-8"), source, port, filePath); computeDiff(Buffer.concat(bufs).toString("utf-8"), source, filePath);
} }
}); });
return cleanup; return cleanup;
} }
async function watchFileChanges(filePath: string, port: number): Promise<[IComputedValue<CatalogEntity[]>, Disposer]> { async function watchFileChanges(filePath: string): Promise<[IComputedValue<CatalogEntity[]>, Disposer]> {
const stat = await fse.stat(filePath); // traverses symlinks, is a race condition const stat = await fse.stat(filePath); // traverses symlinks, is a race condition
const watcher = watch(filePath, { const watcher = watch(filePath, {
followSymlinks: true, followSymlinks: true,
@ -235,10 +234,10 @@ async function watchFileChanges(filePath: string, port: number): Promise<[ICompu
watcher watcher
.on("change", (childFilePath) => { .on("change", (childFilePath) => {
stoppers.get(childFilePath)(); stoppers.get(childFilePath)();
stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port)); stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath)));
}) })
.on("add", (childFilePath) => { .on("add", (childFilePath) => {
stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port)); stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath)));
}) })
.on("unlink", (childFilePath) => { .on("unlink", (childFilePath) => {
stoppers.get(childFilePath)(); stoppers.get(childFilePath)();

View File

@ -1,5 +1,6 @@
import request, { RequestPromiseOptions } from "request-promise-native"; import { RequestPromiseOptions } from "request-promise-native";
import { Cluster } from "../cluster"; import { Cluster } from "../cluster";
import { k8sRequest } from "../k8s-request";
export type ClusterDetectionResult = { export type ClusterDetectionResult = {
value: string | number | boolean value: string | number | boolean
@ -7,11 +8,9 @@ export type ClusterDetectionResult = {
}; };
export class BaseClusterDetector { export class BaseClusterDetector {
cluster: Cluster;
key: string; key: string;
constructor(cluster: Cluster) { constructor(public cluster: Cluster) {
this.cluster = cluster;
} }
detect(): Promise<ClusterDetectionResult> { detect(): Promise<ClusterDetectionResult> {
@ -19,16 +18,6 @@ export class BaseClusterDetector {
} }
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> { protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
const apiUrl = this.cluster.kubeProxyUrl + path; return k8sRequest(this.cluster, path, options);
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 || {}),
},
});
} }
} }

View File

@ -1,37 +1,21 @@
import "../common/cluster-ipc"; import "../common/cluster-ipc";
import type http from "http"; import type http from "http";
import { ipcMain } from "electron"; 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 { ClusterStore, getClusterIdFromHost } from "../common/cluster-store";
import { Cluster } from "./cluster"; import { Cluster } from "./cluster";
import logger from "./logger"; import logger from "./logger";
import { apiKubePrefix } from "../common/vars"; import { apiKubePrefix } from "../common/vars";
import { Singleton } from "../common/utils"; import { Singleton } from "../common/utils";
import { CatalogEntity, catalogEntityRegistry } from "../common/catalog"; import { catalogEntityRegistry } from "../common/catalog";
import { KubernetesCluster } from "../common/catalog-entities/kubernetes-cluster"; import { KubernetesCluster } from "../common/catalog-entities/kubernetes-cluster";
const clusterOwnerRef = "ClusterManager";
export class ClusterManager extends Singleton { export class ClusterManager extends Singleton {
catalogSource = observable.array<CatalogEntity>([]); constructor() {
constructor(public readonly port: number) {
super(); super();
catalogEntityRegistry.addObservableSource("lens:kubernetes-clusters", this.catalogSource); reaction(() => toJS(ClusterStore.getInstance().clustersList, { recurseEverything: true }), () => {
// auto-init clusters this.updateCatalog(ClusterStore.getInstance().clustersList);
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);
}, { fireImmediately: true }); }, { fireImmediately: true });
reaction(() => catalogEntityRegistry.getItemsForApiKind<KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { reaction(() => catalogEntityRegistry.getItemsForApiKind<KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => {
@ -58,31 +42,20 @@ export class ClusterManager extends Singleton {
ipcMain.on("network:online", () => { this.onNetworkOnline(); }); ipcMain.on("network:online", () => { this.onNetworkOnline(); });
} }
@action protected updateCatalogSource(clusters: Cluster[]) { @action protected updateCatalog(clusters: Cluster[]) {
this.catalogSource.replace(this.catalogSource.filter(entity => (
clusters.find((cluster) => entity.metadata.uid === cluster.id)
)));
for (const cluster of clusters) { for (const cluster of clusters) {
if (cluster.ownerRef) { const index = catalogEntityRegistry.items.findIndex((entity) => entity.metadata.uid === cluster.id);
continue;
if (index !== -1) {
const entity = catalogEntityRegistry.items[index];
entity.status.phase = cluster.disconnected ? "disconnected" : "connected";
entity.status.active = !cluster.disconnected;
if (cluster.preferences?.clusterName) {
entity.metadata.name = cluster.preferences.clusterName;
} }
catalogEntityRegistry.items.splice(index, 1, entity);
const entityIndex = this.catalogSource.findIndex((entity) => entity.metadata.uid === cluster.id);
const newEntity = catalogEntityFromCluster(cluster);
if (entityIndex === -1) {
this.catalogSource.push(newEntity);
} else {
const oldEntity = this.catalogSource[entityIndex];
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);
} }
} }
} }
@ -98,8 +71,6 @@ export class ClusterManager extends Singleton {
if (!cluster) { if (!cluster) {
ClusterStore.getInstance().addCluster({ ClusterStore.getInstance().addCluster({
id: entity.metadata.uid, id: entity.metadata.uid,
enabled: true,
ownerRef: clusterOwnerRef,
preferences: { preferences: {
clusterName: entity.metadata.name clusterName: entity.metadata.name
}, },
@ -107,9 +78,6 @@ export class ClusterManager extends Singleton {
contextName: entity.spec.kubeconfigContext contextName: entity.spec.kubeconfigContext
}); });
} else { } else {
cluster.enabled = true;
cluster.ownerRef ||= clusterOwnerRef;
cluster.preferences.clusterName = entity.metadata.name;
cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.kubeConfigPath = entity.spec.kubeconfigPath;
cluster.contextName = entity.spec.kubeconfigContext; cluster.contextName = entity.spec.kubeconfigContext;
@ -123,7 +91,7 @@ export class ClusterManager extends Singleton {
protected onNetworkOffline() { protected onNetworkOffline() {
logger.info("[CLUSTER-MANAGER]: network is offline"); logger.info("[CLUSTER-MANAGER]: network is offline");
ClusterStore.getInstance().enabledClustersList.forEach((cluster) => { ClusterStore.getInstance().clustersList.forEach((cluster) => {
if (!cluster.disconnected) { if (!cluster.disconnected) {
cluster.online = false; cluster.online = false;
cluster.accessible = false; cluster.accessible = false;
@ -134,7 +102,7 @@ export class ClusterManager extends Singleton {
protected onNetworkOnline() { protected onNetworkOnline() {
logger.info("[CLUSTER-MANAGER]: network is online"); logger.info("[CLUSTER-MANAGER]: network is online");
ClusterStore.getInstance().enabledClustersList.forEach((cluster) => { ClusterStore.getInstance().clustersList.forEach((cluster) => {
if (!cluster.disconnected) { if (!cluster.disconnected) {
cluster.refreshConnectionStatus().catch((e) => e); cluster.refreshConnectionStatus().catch((e) => e);
} }

View File

@ -1,15 +1,12 @@
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-store"; 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 { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars"; import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../common/ipc";
import { broadcastMessage, InvalidKubeconfigChannel, ClusterListNamespaceForbiddenChannel } from "../common/ipc";
import { ContextHandler } from "./context-handler"; import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { Kubectl } from "./kubectl"; import { Kubectl } from "./kubectl";
import { KubeconfigManager } from "./kubeconfig-manager"; import { KubeconfigManager } from "./kubeconfig-manager";
import { loadConfig, validateKubeConfig } from "../common/kube-helpers"; import { loadConfig, validateKubeConfig } from "../common/kube-helpers";
import request, { RequestPromiseOptions } from "request-promise-native";
import { apiResources, KubeApiResource } from "../common/rbac"; import { apiResources, KubeApiResource } from "../common/rbac";
import logger from "./logger"; import logger from "./logger";
import { VersionDetector } from "./cluster-detectors/version-detector"; import { VersionDetector } from "./cluster-detectors/version-detector";
@ -36,8 +33,6 @@ export type ClusterRefreshOptions = {
}; };
export interface ClusterState { export interface ClusterState {
initialized: boolean;
enabled: boolean;
apiUrl: string; apiUrl: string;
online: boolean; online: boolean;
disconnected: boolean; disconnected: boolean;
@ -70,33 +65,13 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal * @internal
*/ */
public contextHandler: ContextHandler; 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 kubeconfigManager: KubeconfigManager;
protected eventDisposers: Function[] = []; protected eventDisposers: Function[] = [];
protected activated = false; protected activated = false;
private resourceAccessStatuses: Map<KubeApiResource, boolean> = new Map(); private resourceAccessStatuses: Map<KubeApiResource, boolean> = new Map();
whenInitialized = when(() => this.initialized);
whenReady = when(() => this.ready); 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 * Kubeconfig context name
* *
@ -119,19 +94,6 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable * @observable
*/ */
@observable apiUrl: string; // cluster server url @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 * Is cluster online
* *
@ -260,7 +222,6 @@ export class Cluster implements ClusterModel, ClusterState {
this.id = model.id; this.id = model.id;
this.updateModel(model); this.updateModel(model);
try {
const kubeconfig = this.getKubeconfig(); const kubeconfig = this.getKubeconfig();
const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
@ -269,18 +230,18 @@ export class Cluster implements ClusterModel, ClusterState {
} }
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; 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 (ipcMain) {
* Is cluster managed by an extension // for the time being, until renderer gets its own cluster type
*/ this.contextHandler = new ContextHandler(this);
get isManaged(): boolean { this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler);
return !!this.ownerRef;
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; this.metadata = model.metadata;
} }
if (model.ownerRef) {
this.ownerRef = model.ownerRef;
}
if (model.accessibleNamespaces) { if (model.accessibleNamespaces) {
this.accessibleNamespaces = 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 * @internal
*/ */
@ -385,8 +313,8 @@ export class Cluster implements ClusterModel, ClusterState {
if (this.activated && !force) { if (this.activated && !force) {
return this.pushState(); return this.pushState();
} }
logger.info(`[CLUSTER]: activate`, this.getMeta()); logger.info(`[CLUSTER]: activate`, this.getMeta());
await this.whenInitialized;
if (!this.eventDisposers.length) { if (!this.eventDisposers.length) {
this.bindEvents(); this.bindEvents();
@ -403,7 +331,7 @@ export class Cluster implements ClusterModel, ClusterState {
} }
this.activated = true; this.activated = true;
return this.pushState(); this.pushState();
} }
/** /**
@ -450,7 +378,6 @@ export class Cluster implements ClusterModel, ClusterState {
@action @action
async refresh(opts: ClusterRefreshOptions = {}) { async refresh(opts: ClusterRefreshOptions = {}) {
logger.info(`[CLUSTER]: refresh`, this.getMeta()); logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.whenInitialized;
await this.refreshConnectionStatus(); await this.refreshConnectionStatus();
if (this.accessible) { if (this.accessible) {
@ -527,34 +454,6 @@ export class Cluster implements ClusterModel, ClusterState {
return this.kubeconfigManager.getPath(); return this.kubeconfigManager.getPath();
} }
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
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<ClusterStatus> { protected async getConnectionStatus(): Promise<ClusterStatus> {
try { try {
const versionDetector = new VersionDetector(this); const versionDetector = new VersionDetector(this);
@ -647,7 +546,6 @@ export class Cluster implements ClusterModel, ClusterState {
workspace: this.workspace, workspace: this.workspace,
preferences: this.preferences, preferences: this.preferences,
metadata: this.metadata, metadata: this.metadata,
ownerRef: this.ownerRef,
accessibleNamespaces: this.accessibleNamespaces, accessibleNamespaces: this.accessibleNamespaces,
}; };
@ -661,8 +559,6 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
getState(): ClusterState { getState(): ClusterState {
const state: ClusterState = { const state: ClusterState = {
initialized: this.initialized,
enabled: this.enabled,
apiUrl: this.apiUrl, apiUrl: this.apiUrl,
online: this.online, online: this.online,
ready: this.ready, ready: this.ready,
@ -702,7 +598,6 @@ export class Cluster implements ClusterModel, ClusterState {
return { return {
id: this.id, id: this.id,
name: this.contextName, name: this.contextName,
initialized: this.initialized,
ready: this.ready, ready: this.ready,
online: this.online, online: this.online,
accessible: this.accessible, accessible: this.accessible,

View File

@ -6,16 +6,14 @@ import url, { UrlWithStringQuery } from "url";
import { CoreV1Api } from "@kubernetes/client-node"; import { CoreV1Api } from "@kubernetes/client-node";
import { prometheusProviders } from "../common/prometheus-providers"; import { prometheusProviders } from "../common/prometheus-providers";
import logger from "./logger"; import logger from "./logger";
import { getFreePort } from "./port";
import { KubeAuthProxy } from "./kube-auth-proxy"; import { KubeAuthProxy } from "./kube-auth-proxy";
export class ContextHandler { export class ContextHandler {
public proxyPort: number;
public clusterUrl: UrlWithStringQuery; public clusterUrl: UrlWithStringQuery;
protected kubeAuthProxy: KubeAuthProxy; protected kubeAuthProxy?: KubeAuthProxy;
protected apiTarget: httpProxy.ServerOptions; protected apiTarget?: httpProxy.ServerOptions;
protected prometheusProvider: string; protected prometheusProvider: string;
protected prometheusPath: string; protected prometheusPath: string | null;
constructor(protected cluster: Cluster) { constructor(protected cluster: Cluster) {
this.clusterUrl = url.parse(cluster.apiUrl); this.clusterUrl = url.parse(cluster.apiUrl);
@ -77,31 +75,25 @@ export class ContextHandler {
} }
async resolveAuthProxyUrl() { async resolveAuthProxyUrl() {
const proxyPort = await this.ensurePort(); await this.ensureServer();
const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; 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<httpProxy.ServerOptions> { async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> {
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 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) { if (isWatchRequest) {
this.apiTarget = apiTarget; return this.newApiTarget(timeout);
} }
return apiTarget; return this.apiTarget ??= await this.newApiTarget(timeout);
} }
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> { protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
const proxyUrl = await this.resolveAuthProxyUrl();
return { return {
target: proxyUrl, target: await this.resolveAuthProxyUrl(),
changeOrigin: true, changeOrigin: true,
timeout, timeout,
headers: { headers: {
@ -110,32 +102,22 @@ export class ContextHandler {
}; };
} }
async ensurePort(): Promise<number> {
if (!this.proxyPort) {
this.proxyPort = await getFreePort();
}
return this.proxyPort;
}
async ensureServer() { async ensureServer() {
if (!this.kubeAuthProxy) { if (!this.kubeAuthProxy) {
await this.ensurePort();
const proxyEnv = Object.assign({}, process.env); const proxyEnv = Object.assign({}, process.env);
if (this.cluster.preferences.httpsProxy) { if (this.cluster.preferences.httpsProxy) {
proxyEnv.HTTPS_PROXY = 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(); await this.kubeAuthProxy.run();
} }
} }
stopServer() { stopServer() {
if (this.kubeAuthProxy) { this.kubeAuthProxy?.exit();
this.kubeAuthProxy.exit(); this.kubeAuthProxy = undefined;
this.kubeAuthProxy = null; this.apiTarget = undefined;
}
} }
get proxyLastError(): string { get proxyLastError(): string {

View File

@ -7,11 +7,10 @@ import * as LensExtensions from "../extensions/core-api";
import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron"; import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron";
import { appName, isMac, productName } from "../common/vars"; import { appName, isMac, productName } from "../common/vars";
import path from "path"; import path from "path";
import { LensProxy } from "./lens-proxy"; import { LensProxy } from "./proxy/lens-proxy";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { ClusterManager } from "./cluster-manager"; import { ClusterManager } from "./cluster-manager";
import { shellSync } from "./shell-sync"; import { shellSync } from "./shell-sync";
import { getFreePort } from "./port";
import { mangleProxyEnv } from "./proxy-env"; import { mangleProxyEnv } from "./proxy-env";
import { registerFileProtocol } from "../common/register-protocol"; import { registerFileProtocol } from "../common/register-protocol";
import logger from "./logger"; import logger from "./logger";
@ -34,6 +33,7 @@ import { catalogEntityRegistry } from "../common/catalog";
import { HotbarStore } from "../common/hotbar-store"; import { HotbarStore } from "../common/hotbar-store";
import { HelmRepoManager } from "./helm/helm-repo-manager"; import { HelmRepoManager } from "./helm/helm-repo-manager";
import { KubeconfigSyncManager } from "./catalog-sources"; import { KubeconfigSyncManager } from "./catalog-sources";
import { handleWsUpgrade } from "./proxy/ws-upgrade";
const workingDir = path.join(app.getPath("appData"), appName); const workingDir = path.join(app.getPath("appData"), appName);
@ -118,45 +118,33 @@ app.on("ready", async () => {
filesystemStore.load(), filesystemStore.load(),
]); ]);
try { const lensProxy = LensProxy.createInstance(handleWsUpgrade);
logger.info("🔑 Getting free port for LensProxy server");
const proxyPort = await getFreePort();
// create cluster manager ClusterManager.createInstance();
ClusterManager.createInstance(proxyPort); KubeconfigSyncManager.createInstance().startSync();
} catch (error) {
logger.error(error);
dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy");
app.exit();
}
const clusterManager = ClusterManager.getInstance();
// create kubeconfig sync manager
KubeconfigSyncManager.createInstance().startSync(clusterManager.port);
// run proxy
try { try {
logger.info("🔌 Starting LensProxy"); logger.info("🔌 Starting LensProxy");
// eslint-disable-next-line unused-imports/no-unused-vars-ts await lensProxy.listen();
LensProxy.createInstance(clusterManager.port).listen();
} catch (error) { } catch (error) {
logger.error(`Could not start proxy (127.0.0:${clusterManager.port}): ${error?.message}`); dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`);
dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${clusterManager.port}): ${error?.message || "unknown error"}`);
app.exit(); app.exit();
} }
// test proxy connection // test proxy connection
try { try {
logger.info("🔎 Testing LensProxy connection ..."); logger.info("🔎 Testing LensProxy connection ...");
const versionFromProxy = await getAppVersionFromProxyServer(clusterManager.port); const versionFromProxy = await getAppVersionFromProxyServer(lensProxy.port);
if (getAppVersion() !== versionFromProxy) { 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) { } catch (error) {
logger.error("Checking proxy server connection failed", error); logger.error(`🛑 LensProxy: failed connection test: ${error}`);
app.exit();
} }
const extensionDiscovery = ExtensionDiscovery.createInstance(); const extensionDiscovery = ExtensionDiscovery.createInstance();
@ -169,7 +157,7 @@ app.on("ready", async () => {
const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden); const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden);
logger.info("🖥️ Starting WindowManager"); logger.info("🖥️ Starting WindowManager");
const windowManager = WindowManager.createInstance(clusterManager.port); const windowManager = WindowManager.createInstance();
installDeveloperTools(); installDeveloperTools();

29
src/main/k8s-request.ts Normal file
View File

@ -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<T = any>(cluster: Cluster, path: string, options: RequestPromiseOptions = {}): Promise<T> {
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<any> {
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,
});
}

View File

@ -5,24 +5,30 @@ import type { Cluster } from "./cluster";
import { Kubectl } from "./kubectl"; import { Kubectl } from "./kubectl";
import logger from "./logger"; import logger from "./logger";
import * as url from "url"; import * as url from "url";
import { getPortFrom } from "./utils/get-port";
export interface KubeAuthProxyLog { export interface KubeAuthProxyLog {
data: string; data: string;
error?: boolean; // stream=stderr error?: boolean; // stream=stderr
} }
const startingServeRegex = /^starting to serve on (?<address>.+)/i;
export class KubeAuthProxy { export class KubeAuthProxy {
public lastError: string; public lastError: string;
public get port(): number {
return this._port;
}
protected _port: number;
protected cluster: Cluster; protected cluster: Cluster;
protected env: NodeJS.ProcessEnv = null; protected env: NodeJS.ProcessEnv = null;
protected proxyProcess: ChildProcess; protected proxyProcess: ChildProcess;
protected port: number;
protected kubectl: Kubectl; protected kubectl: Kubectl;
constructor(cluster: Cluster, port: number, env: NodeJS.ProcessEnv) { constructor(cluster: Cluster, env: NodeJS.ProcessEnv) {
this.env = env; this.env = env;
this.port = port;
this.cluster = cluster; this.cluster = cluster;
this.kubectl = Kubectl.bundled(); this.kubectl = Kubectl.bundled();
} }
@ -39,7 +45,7 @@ export class KubeAuthProxy {
const proxyBin = await this.kubectl.getPath(); const proxyBin = await this.kubectl.getPath();
const args = [ const args = [
"proxy", "proxy",
"-p", `${this.port}`, "-p", "0",
"--kubeconfig", `${this.cluster.kubeConfigPath}`, "--kubeconfig", `${this.cluster.kubeConfigPath}`,
"--context", `${this.cluster.contextName}`, "--context", `${this.cluster.contextName}`,
"--accept-hosts", this.acceptHosts, "--accept-hosts", this.acceptHosts,
@ -50,6 +56,7 @@ export class KubeAuthProxy {
args.push("-v", "9"); args.push("-v", "9");
} }
logger.debug(`spawning kubectl proxy with args: ${args}`); logger.debug(`spawning kubectl proxy with args: ${args}`);
this.proxyProcess = spawn(proxyBin, args, { env: this.env, }); this.proxyProcess = spawn(proxyBin, args, { env: this.env, });
this.proxyProcess.on("error", (error) => { this.proxyProcess.on("error", (error) => {
this.sendIpcLogMessage({ data: error.message, error: true }); this.sendIpcLogMessage({ data: error.message, error: true });
@ -61,20 +68,20 @@ export class KubeAuthProxy {
this.exit(); 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.proxyProcess.stderr.on("data", (data) => {
this.lastError = this.parseError(data.toString()); this.lastError = this.parseError(data.toString());
this.sendIpcLogMessage({ data: data.toString(), error: true }); 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); return waitUntilUsed(this.port, 500, 10000);
} }
@ -96,7 +103,7 @@ export class KubeAuthProxy {
return errorMsg; return errorMsg;
} }
protected async sendIpcLogMessage(res: KubeAuthProxyLog) { protected sendIpcLogMessage(res: KubeAuthProxyLog) {
const channel = `kube-auth:${this.cluster.id}`; const channel = `kube-auth:${this.cluster.id}`;
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() }); logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });

View File

@ -6,12 +6,13 @@ import path from "path";
import fs from "fs-extra"; import fs from "fs-extra";
import { dumpConfigYaml, loadConfig } from "../common/kube-helpers"; import { dumpConfigYaml, loadConfig } from "../common/kube-helpers";
import logger from "./logger"; import logger from "./logger";
import { LensProxy } from "./proxy/lens-proxy";
export class KubeconfigManager { export class KubeconfigManager {
protected configDir = app.getPath("temp"); protected configDir = app.getPath("temp");
protected tempFile: string = null; protected tempFile: string = null;
constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) { } constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) { }
async getPath(): Promise<string> { async getPath(): Promise<string> {
if (this.tempFile === undefined) { if (this.tempFile === undefined) {
@ -46,15 +47,15 @@ export class KubeconfigManager {
protected async init() { protected async init() {
try { try {
await this.contextHandler.ensurePort(); await this.contextHandler.ensureServer();
this.tempFile = await this.createProxyKubeconfig(); this.tempFile = await this.createProxyKubeconfig();
} catch (err) { } catch (err) {
logger.error(`Failed to created temp config for auth-proxy`, { err }); logger.error(`Failed to created temp config for auth-proxy`, { err });
} }
} }
protected resolveProxyUrl() { get resolveProxyUrl() {
return `http://127.0.0.1:${this.port}/${this.cluster.id}`; return `http://127.0.0.1:${LensProxy.getInstance().port}/${this.cluster.id}`;
} }
/** /**
@ -71,7 +72,7 @@ export class KubeconfigManager {
clusters: [ clusters: [
{ {
name: contextName, name: contextName,
server: this.resolveProxyUrl(), server: this.resolveProxyUrl,
skipTLSVerify: undefined, skipTLSVerify: undefined,
} }
], ],

View File

@ -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<number> {
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 });
});
}

View File

@ -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);
});
});

2
src/main/proxy/index.ts Normal file
View File

@ -0,0 +1,2 @@
// Don't export the contents here
// It will break the extension webpack

View File

@ -3,45 +3,30 @@ import http from "http";
import spdy from "spdy"; import spdy from "spdy";
import httpProxy from "http-proxy"; import httpProxy from "http-proxy";
import url from "url"; import url from "url";
import * as WebSocket from "ws"; import { apiPrefix, apiKubePrefix } from "../../common/vars";
import { apiPrefix, apiKubePrefix } from "../common/vars"; import { Router } from "../router";
import { Router } from "./router"; import { ContextHandler } from "../context-handler";
import { ContextHandler } from "./context-handler"; import logger from "../logger";
import logger from "./logger"; import { Singleton } from "../../common/utils";
import { NodeShellSession, LocalShellSession } from "./shell-session"; import { ClusterManager } from "../cluster-manager";
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 { export class LensProxy extends Singleton {
protected origin: string; protected origin: string;
protected proxyServer: http.Server; protected proxyServer: http.Server;
protected router: Router; protected router = new Router();
protected closed = false; protected closed = false;
protected retryCounters = new Map<string, number>(); protected retryCounters = new Map<string, number>();
constructor(protected port: number) { public port: number;
constructor(handleWsUpgrade: WSUpgradeHandler) {
super(); 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 proxy = this.createProxy();
const spdyProxy = spdy.createServer({
this.proxyServer = spdy.createServer({
spdy: { spdy: {
plain: true, plain: true,
protocols: ["http/1.1", "spdy/3.1"] protocols: ["http/1.1", "spdy/3.1"]
@ -50,18 +35,51 @@ export class LensProxy extends Singleton {
this.handleRequest(proxy, req, res); this.handleRequest(proxy, req, res);
}); });
spdyProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { this.proxyServer
.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
if (req.url.startsWith(`${apiPrefix}?`)) { if (req.url.startsWith(`${apiPrefix}?`)) {
this.handleWsUpgrade(req, socket, head); handleWsUpgrade(req, socket, head);
} else { } else {
this.handleProxyUpgrade(proxy, req, socket, head); this.handleProxyUpgrade(proxy, req, socket, head);
} }
}); });
spdyProxy.on("error", (err) => { }
logger.error("proxy error", err);
/**
* Starts the lens proxy.
* @resolves After the server is listening
* @rejects if there is an error before that happens
*/
listen(): Promise<void> {
return new Promise<void>((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}`);
}); });
return spdyProxy; 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) { protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
@ -166,21 +184,6 @@ export class LensProxy extends Singleton {
return proxy; 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<httpProxy.ServerOptions> { protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions> {
if (req.url.startsWith(apiKubePrefix)) { if (req.url.startsWith(apiKubePrefix)) {
delete req.headers.authorization; delete req.headers.authorization;
@ -211,12 +214,4 @@ export class LensProxy extends Singleton {
} }
this.router.route(cluster, req, res); 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);
});
}
} }

View File

@ -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);
});
}

View File

@ -4,6 +4,7 @@ import { LensApi } from "../lens-api";
import { Cluster, ClusterMetadataKey } from "../cluster"; import { Cluster, ClusterMetadataKey } from "../cluster";
import { ClusterPrometheusMetadata } from "../../common/cluster-store"; import { ClusterPrometheusMetadata } from "../../common/cluster-store";
import logger from "../logger"; import logger from "../logger";
import { getMetrics } from "../k8s-request";
export type IMetricsQuery = string | string[] | { export type IMetricsQuery = string | string[] | {
[metricName: string]: string; [metricName: string]: string;
@ -22,7 +23,7 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa
async function loadMetricHelper(): Promise<any> { async function loadMetricHelper(): Promise<any> {
for (const [attempt, lastAttempt] of ATTEMPTS.entries()) { // retry for (const [attempt, lastAttempt] of ATTEMPTS.entries()) { // retry
try { try {
return await cluster.getMetrics(prometheusPath, { query, ...queryParams }); return await getMetrics(cluster, prometheusPath, { query, ...queryParams });
} catch (error) { } catch (error) {
if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) {
logger.error("[Metrics]: metrics not available", { error }); logger.error("[Metrics]: metrics not available", { error });

View File

@ -2,48 +2,58 @@ import { LensApiRequest } from "../router";
import { LensApi } from "../lens-api"; import { LensApi } from "../lens-api";
import { spawn, ChildProcessWithoutNullStreams } from "child_process"; import { spawn, ChildProcessWithoutNullStreams } from "child_process";
import { Kubectl } from "../kubectl"; import { Kubectl } from "../kubectl";
import { getFreePort } from "../port";
import { shell } from "electron"; import { shell } from "electron";
import * as tcpPortUsed from "tcp-port-used"; import * as tcpPortUsed from "tcp-port-used";
import logger from "../logger"; 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 (?<address>.+) ->/i;
class PortForward { class PortForward {
public static portForwards: PortForward[] = []; public static portForwards: PortForward[] = [];
static getPortforward(forward: {clusterId: string; kind: string; name: string; namespace: string; port: string}) { static getPortforward(forward: PortForwardArgs) {
return PortForward.portForwards.find((pf) => { return PortForward.portForwards.find((pf) => (
return (
pf.clusterId == forward.clusterId && pf.clusterId == forward.clusterId &&
pf.kind == forward.kind && pf.kind == forward.kind &&
pf.name == forward.name && pf.name == forward.name &&
pf.namespace == forward.namespace && pf.namespace == forward.namespace &&
pf.port == forward.port pf.port == forward.port
); ));
});
} }
public clusterId: string;
public process: ChildProcessWithoutNullStreams; public process: ChildProcessWithoutNullStreams;
public kubeConfig: string; public clusterId: string;
public kind: string; public kind: string;
public namespace: string; public namespace: string;
public name: string; public name: string;
public port: string; public port: string;
public localPort: number; public internalPort?: number;
constructor(obj: any) { constructor(public kubeConfig: string, args: PortForwardArgs) {
Object.assign(this, obj); this.clusterId = args.clusterId;
this.kind = args.kind;
this.namespace = args.namespace;
this.name = args.name;
this.port = args.port;
} }
public async start() { public async start() {
this.localPort = await getFreePort();
const kubectlBin = await Kubectl.bundled().getPath(); const kubectlBin = await Kubectl.bundled().getPath();
const args = [ const args = [
"--kubeconfig", this.kubeConfig, "--kubeconfig", this.kubeConfig,
"port-forward", "port-forward",
"-n", this.namespace, "-n", this.namespace,
`${this.kind}/${this.name}`, `${this.kind}/${this.name}`,
`${this.localPort}:${this.port}` `:${this.port}`
]; ];
this.process = spawn(kubectlBin, args, { this.process = spawn(kubectlBin, args, {
@ -58,8 +68,12 @@ class PortForward {
} }
}); });
this.internalPort = await getPortFrom(this.process.stdout, {
lineRegex: internalPortRegex,
});
try { try {
await tcpPortUsed.waitUntilUsed(this.localPort, 500, 15000); await tcpPortUsed.waitUntilUsed(this.internalPort, 500, 15000);
return true; return true;
} catch (error) { } catch (error) {
@ -70,7 +84,14 @@ class PortForward {
} }
public open() { 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) { if (!portForward) {
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`); logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`);
portForward = new PortForward({ portForward = new PortForward(await cluster.getProxyKubeconfigPath(), {
clusterId: cluster.id, clusterId: cluster.id,
kind: resourceType, kind: resourceType,
namespace, namespace,
name: resourceName, name: resourceName,
port, port,
kubeConfig: await cluster.getProxyKubeconfigPath()
}); });
const started = await portForward.start(); const started = await portForward.start();

View File

@ -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<number> {
return new Promise<number>((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);
});
}

View File

@ -11,6 +11,7 @@ import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames";
import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events";
import logger from "./logger"; import logger from "./logger";
import { productName } from "../common/vars"; import { productName } from "../common/vars";
import { LensProxy } from "./proxy/lens-proxy";
export class WindowManager extends Singleton { export class WindowManager extends Singleton {
protected mainWindow: BrowserWindow; protected mainWindow: BrowserWindow;
@ -20,7 +21,7 @@ export class WindowManager extends Singleton {
@observable activeClusterId: ClusterId; @observable activeClusterId: ClusterId;
constructor(protected proxyPort: number) { constructor() {
super(); super();
this.bindEvents(); this.bindEvents();
this.initMenu(); this.initMenu();
@ -28,7 +29,7 @@ export class WindowManager extends Singleton {
} }
get mainUrl() { get mainUrl() {
return `http://localhost:${this.proxyPort}`; return `http://localhost:${LensProxy.getInstance().port}`;
} }
async initMainWindow(showSplash = true) { async initMainWindow(showSplash = true) {

View File

@ -9,7 +9,7 @@ export default migration({
run(store) { run(store) {
const hotbars: Hotbar[] = []; const hotbars: Hotbar[] = [];
ClusterStore.getInstance().enabledClustersList.forEach((cluster: any) => { ClusterStore.getInstance().clustersList.forEach((cluster: any) => {
const name = cluster.workspace; const name = cluster.workspace;
if (!name) return; if (!name) return;

View File

@ -3,8 +3,8 @@
$spacing: $padding * 2; $spacing: $padding * 2;
.AceEditor { .AceEditor {
min-height: 200px; min-height: 600px;
max-height: 400px; max-height: 600px;
border: 1px solid var(--colorVague); border: 1px solid var(--colorVague);
border-radius: $radius; 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 { code {
color: $pink-400; color: $pink-400;
} }

View File

@ -1,51 +1,35 @@
import "./add-cluster.scss"; import "./add-cluster.scss";
import os from "os";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { action, observable, runInAction } from "mobx"; import { action, observable, runInAction } from "mobx";
import { remote } from "electron";
import { KubeConfig } from "@kubernetes/client-node"; import { KubeConfig } from "@kubernetes/client-node";
import { Select, SelectOption } from "../select";
import { DropFileInput, Input } from "../input";
import { AceEditor } from "../ace-editor"; import { AceEditor } from "../ace-editor";
import { Button } from "../button"; import { Button } from "../button";
import { Icon } from "../icon"; import { loadConfig, splitConfig, validateKubeConfig } from "../../../common/kube-helpers";
import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers";
import { ClusterStore } from "../../../common/cluster-store"; import { ClusterStore } from "../../../common/cluster-store";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { cssNames } from "../../utils";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Tab, Tabs } from "../tabs";
import { ExecValidationNotFoundError } from "../../../common/custom-errors"; import { ExecValidationNotFoundError } from "../../../common/custom-errors";
import { appEventBus } from "../../../common/event-bus"; import { appEventBus } from "../../../common/event-bus";
import { PageLayout } from "../layout/page-layout"; import { PageLayout } from "../layout/page-layout";
import { docsUrl } from "../../../common/vars"; import { docsUrl } from "../../../common/vars";
import { catalogURL } from "../+catalog"; import { catalogURL } from "../+catalog";
import { preferencesURL } from "../+preferences";
enum KubeConfigSourceTab { import { Input } from "../input";
FILE = "file",
TEXT = "text"
}
@observer @observer
export class AddCluster extends React.Component { export class AddCluster extends React.Component {
@observable.ref kubeConfigLocal: KubeConfig; @observable.ref kubeConfigLocal: KubeConfig;
@observable.ref error: React.ReactNode; @observable.ref error: React.ReactNode;
@observable kubeContexts = observable.map<string, KubeConfig>(); // available contexts from kubeconfig-file or user-input
@observable selectedContexts = observable.array<string>();
@observable sourceTab = KubeConfigSourceTab.FILE;
@observable kubeConfigPath = "";
@observable customConfig = ""; @observable customConfig = "";
@observable proxyServer = ""; @observable proxyServer = "";
@observable isWaiting = false; @observable isWaiting = false;
@observable showSettings = false; @observable showSettings = false;
kubeContexts = observable.map<string, KubeConfig>();
componentDidMount() { componentDidMount() {
ClusterStore.getInstance().setActive(null);
this.setKubeConfig(UserStore.getInstance().kubeConfigPath);
appEventBus.emit({ name: "cluster-add", action: "start" }); appEventBus.emit({ name: "cluster-add", action: "start" });
} }
@ -53,53 +37,20 @@ export class AddCluster extends React.Component {
UserStore.getInstance().markNewContextsAsSeen(); 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(
<div>Can&apos;t setup <code>{filePath}</code> as kubeconfig: {String(err)}</div>
);
}
if (throwError) {
throw err;
}
}
}
@action @action
refreshContexts() { refreshContexts() {
this.selectedContexts.clear();
this.kubeContexts.clear(); this.kubeContexts.clear();
switch (this.sourceTab) {
case KubeConfigSourceTab.FILE:
const contexts = this.getContexts(this.kubeConfigLocal);
this.kubeContexts.replace(contexts);
break;
case KubeConfigSourceTab.TEXT:
try { try {
this.error = ""; this.error = "";
const contexts = this.getContexts(loadConfig(this.customConfig || "{}")); const contexts = this.getContexts(loadConfig(this.customConfig || "{}"));
console.log(contexts);
this.kubeContexts.replace(contexts); this.kubeContexts.replace(contexts);
} catch (err) { } catch (err) {
this.error = String(err); this.error = String(err);
} }
break;
}
if (this.kubeContexts.size === 1) {
this.selectedContexts.push(this.kubeContexts.keys().next().value);
}
} }
getContexts(config: KubeConfig): Map<string, KubeConfig> { getContexts(config: KubeConfig): Map<string, KubeConfig> {
@ -112,36 +63,14 @@ export class AddCluster extends React.Component {
return contexts; 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 @action
addClusters = (): void => { addClusters = (): void => {
try { try {
if (!this.selectedContexts.length) {
return void (this.error = "Please select at least one cluster context");
}
this.error = ""; this.error = "";
this.isWaiting = true; this.isWaiting = true;
appEventBus.emit({ name: "cluster-add", action: "click" }); 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 kubeConfig = this.kubeContexts.get(context);
const error = validateKubeConfig(kubeConfig, context); const error = validateKubeConfig(kubeConfig, context);
@ -157,9 +86,7 @@ export class AddCluster extends React.Component {
}).map(context => { }).map(context => {
const clusterId = uuid(); const clusterId = uuid();
const kubeConfig = this.kubeContexts.get(context); const kubeConfig = this.kubeContexts.get(context);
const kubeConfigPath = this.sourceTab === KubeConfigSourceTab.FILE const kubeConfigPath = ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder
? this.kubeConfigPath // save link to original kubeconfig in file-system
: ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder
return { return {
id: clusterId, id: clusterId,
@ -193,9 +120,8 @@ export class AddCluster extends React.Component {
renderInfo() { renderInfo() {
return ( return (
<p> <p>
Add clusters by clicking the <span className="text-primary">Add Cluster</span> button. Paste kubeconfig as a text from the clipboard to the textarea below.
You&apos;ll need to obtain a working kubeconfig for the cluster you want to add. If you want to add clusters from kubeconfigs that exists on filesystem, please add those files (or folders) to kubeconfig sync via <a onClick={() => navigate(preferencesURL())}>Preferences</a>.
You can either browse it from the file system or paste it as a text from the clipboard.
Read more about adding clusters <a href={`${docsUrl}/clusters/adding-clusters/`} rel="noreferrer" target="_blank">here</a>. Read more about adding clusters <a href={`${docsUrl}/clusters/adding-clusters/`} rel="noreferrer" target="_blank">here</a>.
</p> </p>
); );
@ -204,144 +130,31 @@ export class AddCluster extends React.Component {
renderKubeConfigSource() { renderKubeConfigSource() {
return ( return (
<> <>
<Tabs onChange={this.onKubeConfigTabChange}>
<Tab
value={KubeConfigSourceTab.FILE}
label="Select kubeconfig file"
active={this.sourceTab == KubeConfigSourceTab.FILE}/>
<Tab
value={KubeConfigSourceTab.TEXT}
label="Paste as text"
active={this.sourceTab == KubeConfigSourceTab.TEXT}
/>
</Tabs>
{this.sourceTab === KubeConfigSourceTab.FILE && (
<div>
<div className="kube-config-select flex gaps align-center">
<Input
theme="round-black"
className="kube-config-path box grow"
value={this.kubeConfigPath}
onChange={v => this.kubeConfigPath = v}
onBlur={this.onKubeConfigInputBlur}
/>
{this.kubeConfigPath !== kubeConfigDefaultPath && (
<Icon
material="settings_backup_restore"
onClick={() => this.setKubeConfig(kubeConfigDefaultPath)}
tooltip="Reset"
/>
)}
<Icon
material="folder"
onClick={this.selectKubeConfigDialog}
tooltip="Browse"
/>
</div>
<small className="hint">
Pro-Tip: you can also drag-n-drop kubeconfig file to this area
</small>
</div>
)}
{this.sourceTab === KubeConfigSourceTab.TEXT && (
<div className="flex column"> <div className="flex column">
<AceEditor <AceEditor
autoFocus autoFocus
showGutter={false} showGutter={false}
mode="yaml" mode="yaml"
value={this.customConfig} value={this.customConfig}
wrap={true}
onChange={value => { onChange={value => {
this.customConfig = value; this.customConfig = value;
this.refreshContexts(); this.refreshContexts();
}} }}
/> />
<small className="hint">
Pro-Tip: paste kubeconfig to get available contexts
</small>
</div> </div>
)}
</> </>
); );
} }
renderContextSelector() {
const allContexts = Array.from(this.kubeContexts.keys());
const placeholder = this.selectedContexts.length > 0
? <>Selected contexts: <b>{this.selectedContexts.length}</b></>
: "Select contexts";
return (
<div>
<Select
id="kubecontext-select" // todo: provide better mapping for integration tests (e.g. data-test-id="..")
placeholder={placeholder}
controlShouldRenderValue={false}
closeMenuOnSelect={false}
isOptionSelected={() => false}
options={allContexts}
formatOptionLabel={this.formatContextLabel}
noOptionsMessage={() => `No contexts available or they have been added already`}
onChange={({ value: ctx }: SelectOption<string>) => {
if (this.selectedContexts.includes(ctx)) {
this.selectedContexts.remove(ctx);
} else {
this.selectedContexts.push(ctx);
}
}}
/>
{this.selectedContexts.length > 0 && (
<small className="hint">
<span>Applying contexts:</span>{" "}
<code>{this.selectedContexts.join(", ")}</code>
</small>
)}
</div>
);
}
onKubeConfigInputBlur = () => {
const isChanged = this.kubeConfigPath !== UserStore.getInstance().kubeConfigPath;
if (isChanged) {
this.kubeConfigPath = this.kubeConfigPath.replace("~", os.homedir());
try {
this.setKubeConfig(this.kubeConfigPath, { throwError: true });
} catch (err) {
this.setKubeConfig(UserStore.getInstance().kubeConfigPath); // revert to previous valid path
}
}
};
onKubeConfigTabChange = (tabId: KubeConfigSourceTab) => {
this.sourceTab = tabId;
this.error = "";
this.refreshContexts();
};
protected formatContextLabel = ({ value: context }: SelectOption<string>) => {
const isNew = UserStore.getInstance().newContexts.has(context);
const isSelected = this.selectedContexts.includes(context);
return (
<div className={cssNames("kube-context flex gaps align-center", context)}>
<span>{context}</span>
{isNew && <Icon small material="fiber_new"/>}
{isSelected && <Icon small material="check" className="box right"/>}
</div>
);
};
render() { render() {
const submitDisabled = this.selectedContexts.length === 0; const submitDisabled = this.kubeContexts.size === 0;
return ( return (
<DropFileInput onDropFiles={this.onDropKubeConfig}>
<PageLayout className="AddClusters" showOnTop={true}> <PageLayout className="AddClusters" showOnTop={true}>
<h2>Add Clusters from Kubeconfig</h2> <h2>Add Clusters from Kubeconfig</h2>
{this.renderInfo()} {this.renderInfo()}
{this.renderKubeConfigSource()} {this.renderKubeConfigSource()}
{this.renderContextSelector()}
<div className="cluster-settings"> <div className="cluster-settings">
<a href="#" onClick={() => this.showSettings = !this.showSettings}> <a href="#" onClick={() => this.showSettings = !this.showSettings}>
Proxy settings Proxy settings
@ -369,15 +182,14 @@ export class AddCluster extends React.Component {
<Button <Button
primary primary
disabled={submitDisabled} disabled={submitDisabled}
label={this.selectedContexts.length < 2 ? "Add cluster" : "Add clusters"} label={this.kubeContexts.keys.length < 2 ? "Add cluster" : "Add clusters"}
onClick={this.addClusters} onClick={this.addClusters}
waiting={this.isWaiting} waiting={this.isWaiting}
tooltip={submitDisabled ? "Select at least one cluster to add." : undefined} tooltip={submitDisabled ? "Paste a valid kubeconfig." : undefined}
tooltipOverrideDisabled tooltipOverrideDisabled
/> />
</div> </div>
</PageLayout> </PageLayout>
</DropFileInput>
); );
} }
} }

View File

@ -13,13 +13,7 @@ import { CatalogEntity } from "../../api/catalog-entity";
function getClusterForEntity(entity: CatalogEntity) { function getClusterForEntity(entity: CatalogEntity) {
const cluster = ClusterStore.getInstance().getById(entity.metadata.uid); return ClusterStore.getInstance().getById(entity.metadata.uid);
if (!cluster?.enabled) {
return null;
}
return cluster;
} }
entitySettingRegistry.add([ entitySettingRegistry.add([

View File

@ -27,10 +27,8 @@ export class RemoveClusterButton extends React.Component<Props> {
} }
render() { render() {
const { cluster } = this.props;
return ( return (
<Button accent onClick={this.confirmRemoveCluster} className="button-area" disabled={cluster.isManaged}> <Button accent onClick={this.confirmRemoveCluster} className="button-area">
Remove Cluster Remove Cluster
</Button> </Button>
); );

View File

@ -7,20 +7,50 @@ import "@testing-library/jest-dom/extend-expect";
import { MainLayoutHeader } from "../main-layout-header"; import { MainLayoutHeader } from "../main-layout-header";
import { Cluster } from "../../../../main/cluster"; import { Cluster } from "../../../../main/cluster";
import { ClusterStore } from "../../../../common/cluster-store"; import { ClusterStore } from "../../../../common/cluster-store";
import mockFs from "mock-fs";
const cluster: Cluster = new Cluster({ describe("<MainLayoutHeader />", () => {
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", id: "foo",
contextName: "minikube", contextName: "minikube",
kubeConfigPath: "minikube-config.yml", kubeConfigPath: "minikube-config.yml",
}); });
describe("<MainLayoutHeader />", () => {
beforeEach(() => {
ClusterStore.createInstance();
}); });
afterEach(() => { afterEach(() => {
ClusterStore.resetInstance(); ClusterStore.resetInstance();
mockFs.restore();
}); });
it("renders w/o errors", () => { it("renders w/o errors", () => {