From ec28d884ae41ec39e11b96a65b62d2fd4f11416d Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 12 Apr 2021 09:32:07 +0300 Subject: [PATCH 1/5] Styling catalog sidebar (#2496) Signed-off-by: Alex Andreev --- src/renderer/components/+catalog/catalog.scss | 39 +++++++++++++++++-- src/renderer/components/+catalog/catalog.tsx | 10 +++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/+catalog/catalog.scss b/src/renderer/components/+catalog/catalog.scss index 302175d1f8..1d321ac232 100644 --- a/src/renderer/components/+catalog/catalog.scss +++ b/src/renderer/components/+catalog/catalog.scss @@ -1,15 +1,48 @@ .CatalogPage { --width: 100%; --height: 100%; - --nav-column-width: 230px; - text-align: left; + --nav-column-width: 200px; .sidebarRegion { justify-content: flex-start; + background-color: var(--sidebarBackground); + + .sidebarHeader { + background: var(--sidebarLogoBackground); + height: var(--main-layout-header); + padding: 4px; + color: var(--textColorAccent); + font-weight: bold; + font-size: 14px; + display: flex; + align-items: center; + padding-left: 10px; + } + + > .sidebar { + width: 100%; + + .sidebarTabs { + margin-top: 5px; + + .Tab { + padding: 7px 10px; + font-weight: normal; + font-size: 14px; + border-radius: 0; + height: 36px; + + &.active { + background-color: var(--blue); + color: white; + } + } + } + } } .contentRegion { - .content { + > .content { padding: 20px 20px; } } diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 1a1d02f587..ebd17fd676 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -114,10 +114,12 @@ export class Catalog extends React.Component { renderNavigation() { return ( -
Catalog
- { this.categories.map((category, index) => { - return ; - })} +
Catalog
+
+ { this.categories.map((category, index) => { + return ; + })} +
); } From 9f065a209f136a20eb4ebd19861f4263729cf9e6 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 12 Apr 2021 09:48:57 +0300 Subject: [PATCH 2/5] Electron 9.4.4 (#2472) Signed-off-by: Jari Kolehmainen --- .yarnrc | 2 +- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.yarnrc b/.yarnrc index 5119ded102..f11de8a2a9 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1,3 +1,3 @@ disturl "https://atom.io/download/electron" -target "9.1.0" +target "9.4.4" runtime "electron" diff --git a/package.json b/package.json index b70fa7dbfd..7508acf1db 100644 --- a/package.json +++ b/package.json @@ -319,7 +319,7 @@ "css-loader": "^3.5.3", "deepdash": "^5.3.5", "dompurify": "^2.0.11", - "electron": "^9.4.0", + "electron": "^9.4.4", "electron-builder": "^22.7.0", "electron-notarize": "^0.3.0", "eslint": "^7.7.0", diff --git a/yarn.lock b/yarn.lock index bb7c0206d3..2feb2df31e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4861,10 +4861,10 @@ electron@*: "@types/node" "^12.0.12" extract-zip "^1.0.3" -electron@^9.4.0: - version "9.4.0" - resolved "https://registry.yarnpkg.com/electron/-/electron-9.4.0.tgz#c3c607e3598226ddbaaff8babcdffa8bb2210936" - integrity sha512-hOC4q0jkb+UDYZRy8vrZ1IANnq+jznZnbkD62OEo06nU+hIbp2IrwDRBNuSLmQ3cwZMVir0WSIA1qEVK0PkzGA== +electron@^9.4.4: + version "9.4.4" + resolved "https://registry.yarnpkg.com/electron/-/electron-9.4.4.tgz#2a74a0655a74bd326216672c5ae6ed3a44451446" + integrity sha512-dcPlTrMWQu5xuSm6sYV42KK/BRIqh3erM8v/WtZqaDmG7pkCeJpvw26Dgbqhdt78XmqqGiN96giEe6A3S9vpAQ== dependencies: "@electron/get" "^1.0.1" "@types/node" "^12.0.12" From 4504a76e6a140cdad0131363b4b72ea16ad21b4b Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 12 Apr 2021 09:50:01 +0300 Subject: [PATCH 3/5] Improve CatalogEntityRegistry types & add tests (#2495) * improve CatalogEntityRegistry types & add tests Signed-off-by: Jari Kolehmainen * fix Signed-off-by: Jari Kolehmainen * fix Signed-off-by: Jari Kolehmainen --- .../__tests__/catalog-entity-registry.test.ts | 63 +++++++++++++++++++ src/common/catalog-entities/web-link.ts | 8 ++- src/common/catalog-entity-registry.ts | 16 ++--- src/extensions/lens-main-extension.ts | 3 +- src/main/cluster-manager.ts | 2 +- 5 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 src/common/__tests__/catalog-entity-registry.test.ts diff --git a/src/common/__tests__/catalog-entity-registry.test.ts b/src/common/__tests__/catalog-entity-registry.test.ts new file mode 100644 index 0000000000..da85af3e3d --- /dev/null +++ b/src/common/__tests__/catalog-entity-registry.test.ts @@ -0,0 +1,63 @@ +import { observable, reaction } from "mobx"; +import { WebLink } from "../catalog-entities"; +import { CatalogEntityRegistry } from "../catalog-entity-registry"; + +describe("CatalogEntityRegistry", () => { + let registry: CatalogEntityRegistry; + const entity = new WebLink({ + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "WebLink", + metadata: { + uid: "test", + name: "test-link", + source: "test", + labels: {} + }, + spec: { + url: "https://k8slens.dev" + }, + status: { + phase: "ok" + } + }); + + beforeEach(() => { + registry = new CatalogEntityRegistry(); + }); + + describe("addSource", () => { + it ("allows to add an observable source", () => { + const source = observable.array([]); + + registry.addSource("test", source); + expect(registry.items.length).toEqual(0); + + source.push(entity); + + expect(registry.items.length).toEqual(1); + }); + + it ("added source change triggers reaction", (done) => { + const source = observable.array([]); + + registry.addSource("test", source); + reaction(() => registry.items, () => { + done(); + }); + + source.push(entity); + }); + }); + + describe("removeSource", () => { + it ("removes source", () => { + const source = observable.array([]); + + registry.addSource("test", source); + source.push(entity); + registry.removeSource("test"); + + expect(registry.items.length).toEqual(0); + }); + }); +}); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index a8523408a3..db52fe0eb6 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -1,5 +1,5 @@ import { observable } from "mobx"; -import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity"; +import { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity"; import { catalogCategoryRegistry } from "../catalog-category-registry"; export interface WebLinkStatus extends CatalogEntityStatus { @@ -17,6 +17,12 @@ export class WebLink implements CatalogEntity { @observable public status: WebLinkStatus; @observable public spec: WebLinkSpec; + constructor(data: CatalogEntityData) { + this.metadata = data.metadata; + this.status = data.status as WebLinkStatus; + this.spec = data.spec as WebLinkSpec; + } + getId() { return this.metadata.uid; } diff --git a/src/common/catalog-entity-registry.ts b/src/common/catalog-entity-registry.ts index e800fa88f9..28afa55bac 100644 --- a/src/common/catalog-entity-registry.ts +++ b/src/common/catalog-entity-registry.ts @@ -1,10 +1,10 @@ -import { action, computed, observable } from "mobx"; +import { action, computed, observable, IObservableArray } from "mobx"; import { CatalogEntity } from "./catalog-entity"; export class CatalogEntityRegistry { - protected sources = observable.map([], { deep: true }); + protected sources = observable.map>([], { deep: true }); - @action addSource(id: string, source: CatalogEntity[]) { + @action addSource(id: string, source: IObservableArray) { this.sources.set(id, source); } @@ -12,14 +12,8 @@ export class CatalogEntityRegistry { this.sources.delete(id); } - @computed get items() { - const catalogItems: CatalogEntity[] = []; - - for (const items of this.sources.values()) { - items.forEach((item) => catalogItems.push(item)); - } - - return catalogItems; + @computed get items(): CatalogEntity[] { + return Array.from(this.sources.values()).flat(); } getItemsForApiKind(apiVersion: string, kind: string): T[] { diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index 1cfd3105b0..0ebb3d90f4 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -4,6 +4,7 @@ import { WindowManager } from "../main/window-manager"; import { getExtensionPageUrl } from "./registries/page-registry"; import { catalogEntityRegistry } from "../common/catalog-entity-registry"; import { CatalogEntity } from "../common/catalog-entity"; +import { IObservableArray } from "mobx"; export class LensMainExtension extends LensExtension { appMenus: MenuRegistration[] = []; @@ -19,7 +20,7 @@ export class LensMainExtension extends LensExtension { await windowManager.navigate(pageUrl, frameId); } - addCatalogSource(id: string, source: CatalogEntity[]) { + addCatalogSource(id: string, source: IObservableArray) { catalogEntityRegistry.addSource(`${this.name}:${id}`, source); } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index c0ba24cc5d..047fb0ca95 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -14,7 +14,7 @@ import { catalogEntityRegistry } from "../common/catalog-entity-registry"; const clusterOwnerRef = "ClusterManager"; export class ClusterManager extends Singleton { - @observable.deep catalogSource: CatalogEntity[] = []; + catalogSource = observable.array([]); constructor(public readonly port: number) { super(); From 743597e2f545b15415316118ac8cdd55c09b8dfc Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 12 Apr 2021 02:51:25 -0400 Subject: [PATCH 4/5] Cleanup shell sessions (#2469) Signed-off-by: Sebastian Malton --- src/main/lens-proxy.ts | 8 +- src/main/node-shell-session.ts | 159 ------------ src/main/shell-session.ts | 231 ------------------ src/main/shell-session/index.ts | 2 + src/main/shell-session/local-shell-session.ts | 40 +++ src/main/shell-session/node-shell-session.ts | 108 ++++++++ src/main/shell-session/shell-session.ts | 179 ++++++++++++++ 7 files changed, 335 insertions(+), 392 deletions(-) delete mode 100644 src/main/node-shell-session.ts delete mode 100644 src/main/shell-session.ts create mode 100644 src/main/shell-session/index.ts create mode 100644 src/main/shell-session/local-shell-session.ts create mode 100644 src/main/shell-session/node-shell-session.ts create mode 100644 src/main/shell-session/shell-session.ts diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 0bc3528a33..98bdd97f54 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -5,11 +5,11 @@ import httpProxy from "http-proxy"; import url from "url"; import * as WebSocket from "ws"; import { apiPrefix, apiKubePrefix } from "../common/vars"; -import { openShell } from "./node-shell-session"; import { Router } from "./router"; import { ClusterManager } from "./cluster-manager"; import { ContextHandler } from "./context-handler"; import logger from "./logger"; +import { NodeShellSession, LocalShellSession } from "./shell-session"; export class LensProxy { protected origin: string; @@ -173,8 +173,12 @@ export class LensProxy { return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { const cluster = this.clusterManager.getClusterForRequest(req); const nodeParam = url.parse(req.url, true).query["node"]?.toString(); + const shell = nodeParam + ? new NodeShellSession(socket, cluster, nodeParam) + : new LocalShellSession(socket, cluster); - openShell(socket, cluster, nodeParam); + shell.open() + .catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error })); })); } diff --git a/src/main/node-shell-session.ts b/src/main/node-shell-session.ts deleted file mode 100644 index a0dc0dc792..0000000000 --- a/src/main/node-shell-session.ts +++ /dev/null @@ -1,159 +0,0 @@ -import * as WebSocket from "ws"; -import * as pty from "node-pty"; -import { ShellSession } from "./shell-session"; -import { v4 as uuid } from "uuid"; -import * as k8s from "@kubernetes/client-node"; -import { KubeConfig } from "@kubernetes/client-node"; -import { Cluster } from "./cluster"; -import logger from "./logger"; -import { appEventBus } from "../common/event-bus"; - -export class NodeShellSession extends ShellSession { - protected nodeName: string; - protected podId: string; - protected kc: KubeConfig; - - constructor(socket: WebSocket, cluster: Cluster, nodeName: string) { - super(socket, cluster); - this.nodeName = nodeName; - this.podId = `node-shell-${uuid()}`; - } - - public async open() { - // these are needed by the ShellSession getCachedShellEnv() method - this.kubeconfigPath = await this.cluster.getProxyKubeconfigPath(); - this.kubectlBinDir = await this.kubectl.binDir(); - - this.kc = await this.cluster.getProxyKubeconfig(); - const shell = await this.kubectl.getPath(); - let args = []; - - if (this.createNodeShellPod(this.podId, this.nodeName)) { - await this.waitForRunningPod(this.podId).catch(() => { - this.exitAndClean(1001); - }); - } - args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]; - - const shellEnv = await this.getCachedShellEnv(); - - this.shellProcess = pty.spawn(shell, args, { - cols: 80, - cwd: this.cwd() || shellEnv["HOME"], - env: shellEnv, - name: "xterm-256color", - rows: 30, - }); - this.running = true; - this.pipeStdout(); - this.pipeStdin(); - this.closeWebsocketOnProcessExit(); - this.exitProcessOnWebsocketClose(); - - appEventBus.emit({name: "node-shell", action: "open"}); - } - - protected exitAndClean(code = 1000) { - if (this.podId) { - this.deleteNodeShellPod(); - } - - if (code != 1000) { - this.sendResponse("Error occurred. "); - } - } - - protected async createNodeShellPod(podId: string, nodeName: string) { - const kc = this.getKubeConfig(); - const k8sApi = kc.makeApiClient(k8s.CoreV1Api); - const pod = { - metadata: { - name: podId, - namespace: "kube-system" - }, - spec: { - nodeName, - restartPolicy: "Never", - terminationGracePeriodSeconds: 0, - hostPID: true, - hostIPC: true, - hostNetwork: true, - tolerations: [{ - operator: "Exists" - }], - containers: [{ - name: "shell", - image: "docker.io/alpine:3.12", - securityContext: { - privileged: true, - }, - command: ["nsenter"], - args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"] - }], - } - } as k8s.V1Pod; - - await k8sApi.createNamespacedPod("kube-system", pod).catch((error) => { - logger.error(error); - - return false; - }); - - return true; - } - - protected getKubeConfig() { - if (this.kc) { - return this.kc; - } - this.kc = new k8s.KubeConfig(); - this.kc.loadFromFile(this.kubeconfigPath); - - return this.kc; - } - - protected waitForRunningPod(podId: string) { - return new Promise(async (resolve, reject) => { - const kc = this.getKubeConfig(); - const watch = new k8s.Watch(kc); - const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {}, - // callback is called for each received object. - (type, obj) => { - if (obj.metadata.name == podId && obj.status.phase === "Running") { - resolve(true); - } - }, - // done callback is called if the watch terminates normally - (err) => { - logger.error(err); - reject(false); - } - ); - - setTimeout(() => { - req.abort(); - reject(false); - }, 120 * 1000); - }); - } - - protected deleteNodeShellPod() { - const kc = this.getKubeConfig(); - const k8sApi = kc.makeApiClient(k8s.CoreV1Api); - - k8sApi.deleteNamespacedPod(this.podId, "kube-system"); - } -} - -export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise { - let shell: ShellSession; - - if (nodeName) { - shell = new NodeShellSession(socket, cluster, nodeName); - } else { - shell = new ShellSession(socket, cluster); - } - shell.open(); - - return shell; -} diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts deleted file mode 100644 index 1582ede43e..0000000000 --- a/src/main/shell-session.ts +++ /dev/null @@ -1,231 +0,0 @@ -import * as pty from "node-pty"; -import * as WebSocket from "ws"; -import { EventEmitter } from "events"; -import path from "path"; -import shellEnv from "shell-env"; -import { app } from "electron"; -import { Kubectl } from "./kubectl"; -import { Cluster } from "./cluster"; -import { helmCli } from "./helm/helm-cli"; -import { isWindows } from "../common/vars"; -import { appEventBus } from "../common/event-bus"; -import { userStore } from "../common/user-store"; -import { clearKubeconfigEnvVars } from "./utils/clear-kube-env-vars"; - -export class ShellSession extends EventEmitter { - static shellEnvs: Map = new Map(); - - protected websocket: WebSocket; - protected shellProcess: pty.IPty; - protected kubeconfigPath: string; - protected nodeShellPod: string; - protected kubectl: Kubectl; - protected kubectlBinDir: string; - protected kubectlPathDir: string; - protected helmBinDir: string; - protected running = false; - protected cluster: Cluster; - - constructor(socket: WebSocket, cluster: Cluster) { - super(); - this.websocket = socket; - this.kubectl = new Kubectl(cluster.version); - this.cluster = cluster; - } - - public async open() { - this.kubeconfigPath = await this.cluster.getProxyKubeconfigPath(); - this.kubectlBinDir = await this.kubectl.binDir(); - const pathFromPreferences = userStore.preferences.kubectlBinariesPath || this.kubectl.getBundledPath(); - - this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? this.kubectlBinDir : path.dirname(pathFromPreferences); - this.helmBinDir = helmCli.getBinaryDir(); - const env = await this.getCachedShellEnv(); - const shell = env.PTYSHELL; - const args = await this.getShellArgs(shell); - - this.shellProcess = pty.spawn(shell, args, { - cols: 80, - cwd: this.cwd() || env.HOME, - env, - name: "xterm-256color", - rows: 30, - }); - this.running = true; - - this.pipeStdout(); - this.pipeStdin(); - this.closeWebsocketOnProcessExit(); - this.exitProcessOnWebsocketClose(); - - appEventBus.emit({name: "shell", action: "open"}); - } - - protected cwd(): string { - const { preferences } = this.cluster; - - if(!preferences || !preferences.terminalCWD || preferences.terminalCWD === "") { - return null; - } - - return preferences.terminalCWD; - } - - protected async getShellArgs(shell: string): Promise> { - switch(path.basename(shell)) { - case "powershell.exe": - return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${this.helmBinDir};${this.kubectlPathDir};$Env:PATH"}`]; - case "bash": - return ["--init-file", path.join(this.kubectlBinDir, ".bash_set_path")]; - case "fish": - return ["--login", "--init-command", `export PATH="${this.helmBinDir}:${this.kubectlPathDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPath}"`]; - case "zsh": - return ["--login"]; - default: - return []; - } - } - - protected async getCachedShellEnv() { - const { id: clusterId } = this.cluster; - - let env = ShellSession.shellEnvs.get(clusterId); - - if (!env) { - env = await this.getShellEnv(); - ShellSession.shellEnvs.set(clusterId, env); - } else { - // refresh env in the background - this.getShellEnv().then((shellEnv: any) => { - ShellSession.shellEnvs.set(clusterId, shellEnv); - }); - } - - return env; - } - - protected async getShellEnv() { - const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); - const pathStr = [this.kubectlBinDir, this.helmBinDir, process.env.PATH].join(path.delimiter); - const shell = userStore.preferences.shell || process.env.SHELL || process.env.PTYSHELL; - const { preferences } = this.cluster; - - if(isWindows) { - env["SystemRoot"] = process.env.SystemRoot; - env["PTYSHELL"] = shell || "powershell.exe"; - env["PATH"] = pathStr; - env["LENS_SESSION"] = "true"; - const lensWslEnv = "KUBECONFIG/up:LENS_SESSION/u"; - - if (process.env.WSLENV != undefined) { - env["WSLENV"] = `${process.env["WSLENV"]}:${lensWslEnv}`; - } else { - env["WSLENV"] = lensWslEnv; - } - } else if(shell !== undefined) { - env["PTYSHELL"] = shell; - env["PATH"] = pathStr; - } else { - env["PTYSHELL"] = ""; // blank runs the system default shell - } - - if(path.basename(env["PTYSHELL"]) === "zsh") { - env["OLD_ZDOTDIR"] = env.ZDOTDIR || env.HOME; - env["ZDOTDIR"] = this.kubectlBinDir; - env["DISABLE_AUTO_UPDATE"] = "true"; - } - - env["PTYPID"] = process.pid.toString(); - env["KUBECONFIG"] = this.kubeconfigPath; - env["TERM_PROGRAM"] = app.getName(); - env["TERM_PROGRAM_VERSION"] = app.getVersion(); - - if (preferences.httpsProxy) { - env["HTTPS_PROXY"] = preferences.httpsProxy; - } - const no_proxy = ["localhost", "127.0.0.1", env["NO_PROXY"]]; - - env["NO_PROXY"] = no_proxy.filter(address => !!address).join(); - - if (env.DEBUG) { // do not pass debug option to bash - delete env["DEBUG"]; - } - - return(env); - } - - protected pipeStdout() { - // send shell output to websocket - this.shellProcess.onData(((data: string) => { - this.sendResponse(data); - })); - } - - protected pipeStdin() { - // write websocket messages to shellProcess - this.websocket.on("message", (data: string) => { - if (!this.running) { return; } - - const message = Buffer.from(data.slice(1, data.length), "base64").toString(); - - switch (data[0]) { - case "0": - this.shellProcess.write(message); - break; - case "4": - const resizeMsgObj = JSON.parse(message); - - this.shellProcess.resize(resizeMsgObj["Width"], resizeMsgObj["Height"]); - break; - case "9": - this.emit("newToken", message); - break; - } - }); - } - - protected exit(code = 1000) { - if (this.websocket.readyState == this.websocket.OPEN) this.websocket.close(code); - this.emit("exit"); - } - - protected closeWebsocketOnProcessExit() { - this.shellProcess.onExit(({ exitCode }) => { - this.running = false; - let timeout = 0; - - if (exitCode > 0) { - this.sendResponse("Terminal will auto-close in 15 seconds ..."); - timeout = 15*1000; - } - setTimeout(() => { - this.exit(); - }, timeout); - }); - } - - protected exitProcessOnWebsocketClose() { - this.websocket.on("close", () => { - this.killShellProcess(); - }); - } - - protected killShellProcess(){ - if(this.running) { - // On Windows we need to kill the shell process by pid, since Lens won't respond after a while if using `this.shellProcess.kill()` - if (isWindows) { - try { - process.kill(this.shellProcess.pid); - } catch(e) { - return; - } - } else { - this.shellProcess.kill(); - } - } - } - - protected sendResponse(msg: string) { - this.websocket.send(`1${Buffer.from(msg).toString("base64")}`); - } -} diff --git a/src/main/shell-session/index.ts b/src/main/shell-session/index.ts new file mode 100644 index 0000000000..96743adcc6 --- /dev/null +++ b/src/main/shell-session/index.ts @@ -0,0 +1,2 @@ +export * from "./node-shell-session"; +export * from "./local-shell-session"; diff --git a/src/main/shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session.ts new file mode 100644 index 0000000000..c131ea651c --- /dev/null +++ b/src/main/shell-session/local-shell-session.ts @@ -0,0 +1,40 @@ +import path from "path"; +import { helmCli } from "../helm/helm-cli"; +import { userStore } from "../../common/user-store"; +import { ShellSession } from "./shell-session"; + +export class LocalShellSession extends ShellSession { + ShellType = "shell"; + + protected getPathEntries(): string[] { + return [helmCli.getBinaryDir()]; + } + + public async open() { + + const env = await this.getCachedShellEnv(); + const shell = env.PTYSHELL; + const args = await this.getShellArgs(shell); + + super.open(env.PTYSHELL, args, env); + } + + protected async getShellArgs(shell: string): Promise { + const helmpath = helmCli.getBinaryDir(); + const pathFromPreferences = userStore.preferences.kubectlBinariesPath || this.kubectl.getBundledPath(); + const kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? await this.kubectlBinDirP : path.dirname(pathFromPreferences); + + switch(path.basename(shell)) { + case "powershell.exe": + return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${helmpath};${kubectlPathDir};$Env:PATH"}`]; + case "bash": + return ["--init-file", path.join(await this.kubectlBinDirP, ".bash_set_path")]; + case "fish": + return ["--login", "--init-command", `export PATH="${helmpath}:${kubectlPathDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPathP}"`]; + case "zsh": + return ["--login"]; + default: + return []; + } + } +} diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts new file mode 100644 index 0000000000..16d467138d --- /dev/null +++ b/src/main/shell-session/node-shell-session.ts @@ -0,0 +1,108 @@ +import * as WebSocket from "ws"; +import { v4 as uuid } from "uuid"; +import * as k8s from "@kubernetes/client-node"; +import { KubeConfig } from "@kubernetes/client-node"; +import { Cluster } from "../cluster"; +import { ShellOpenError, ShellSession } from "./shell-session"; + +export class NodeShellSession extends ShellSession { + ShellType = "node-shell"; + + protected podId = `node-shell-${uuid()}`; + protected kc: KubeConfig; + + constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) { + super(socket, cluster); + } + + public async open() { + this.kc = await this.cluster.getProxyKubeconfig(); + const shell = await this.kubectl.getPath(); + + try { + await this.createNodeShellPod(); + await this.waitForRunningPod(); + } catch (error) { + this.deleteNodeShellPod(); + this.sendResponse("Error occurred. "); + + throw new ShellOpenError("failed to create node pod", error); + } + + const args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]; + const env = await this.getCachedShellEnv(); + + super.open(shell, args, env); + } + + protected createNodeShellPod() { + return this + .kc + .makeApiClient(k8s.CoreV1Api) + .createNamespacedPod("kube-system", { + metadata: { + name: this.podId, + namespace: "kube-system" + }, + spec: { + nodeName: this.nodeName, + restartPolicy: "Never", + terminationGracePeriodSeconds: 0, + hostPID: true, + hostIPC: true, + hostNetwork: true, + tolerations: [{ + operator: "Exists" + }], + containers: [{ + name: "shell", + image: "docker.io/alpine:3.12", + securityContext: { + privileged: true, + }, + command: ["nsenter"], + args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"] + }], + } + }); + } + + protected waitForRunningPod(): Promise { + return new Promise((resolve, reject) => { + const watch = new k8s.Watch(this.kc); + + watch + .watch(`/api/v1/namespaces/kube-system/pods`, + {}, + // callback is called for each received object. + (type, obj) => { + if (obj.metadata.name == this.podId && obj.status.phase === "Running") { + resolve(); + } + }, + // done callback is called if the watch terminates normally + (err) => { + console.log(err); + reject(err); + } + ) + .then(req => { + setTimeout(() => { + console.log("aborting"); + req.abort(); + }, 2 * 60 * 1000); + }) + .catch(err => { + console.log("watch failed"); + reject(err); + }); + }); + } + + protected deleteNodeShellPod() { + this + .kc + .makeApiClient(k8s.CoreV1Api) + .deleteNamespacedPod(this.podId, "kube-system"); + } +} diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts new file mode 100644 index 0000000000..eebaed0605 --- /dev/null +++ b/src/main/shell-session/shell-session.ts @@ -0,0 +1,179 @@ +import { Cluster } from "../cluster"; +import { Kubectl } from "../kubectl"; +import * as WebSocket from "ws"; +import shellEnv from "shell-env"; +import { app } from "electron"; +import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; +import path from "path"; +import { isWindows } from "../../common/vars"; +import { userStore } from "../../common/user-store"; +import * as pty from "node-pty"; +import { appEventBus } from "../../common/event-bus"; + +export class ShellOpenError extends Error { + constructor(message: string, public cause: Error) { + super(`${message}: ${cause}`); + this.name = this.constructor.name; + Error.captureStackTrace(this); + } +} + +export abstract class ShellSession { + abstract ShellType: string; + + static shellEnvs: Map> = new Map(); + + protected kubectl: Kubectl; + protected running = false; + protected shellProcess: pty.IPty; + protected kubectlBinDirP: Promise; + protected kubeconfigPathP: Promise; + + protected get cwd(): string | undefined { + return this.cluster.preferences?.terminalCWD; + } + + constructor(protected websocket: WebSocket, protected cluster: Cluster) { + this.kubectl = new Kubectl(cluster.version); + this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); + this.kubectlBinDirP = this.kubectl.binDir(); + } + + open(shell: string, args: string[], env: Record): void { + this.shellProcess = pty.spawn(shell, args, { + cols: 80, + cwd: this.cwd || env.HOME, + env, + name: "xterm-256color", + rows: 30, + }); + this.running = true; + + this.shellProcess.onData(data => this.sendResponse(data)); + this.shellProcess.onExit(({ exitCode }) => { + this.running = false; + + if (exitCode > 0) { + this.sendResponse("Terminal will auto-close in 15 seconds ..."); + setTimeout(() => this.exit(), 15 * 1000); + } else { + this.exit(); + } + }); + + this.websocket + .on("message", (data: string) => { + if (!this.running) { + return; + } + + const message = Buffer.from(data.slice(1, data.length), "base64").toString(); + + switch (data[0]) { + case "0": + this.shellProcess.write(message); + break; + case "4": + const { Width, Height } = JSON.parse(message); + + this.shellProcess.resize(Width, Height); + break; + } + }) + .on("close", () => { + if (this.running) { + try { + process.kill(this.shellProcess.pid); + } catch (e) { + } + } + + this.running = false; + }); + + appEventBus.emit({ name: this.ShellType, action: "open" }); + } + + protected getPathEntries(): string[] { + return []; + } + + protected async getCachedShellEnv() { + const { id: clusterId } = this.cluster; + + let env = ShellSession.shellEnvs.get(clusterId); + + if (!env) { + env = await this.getShellEnv(); + ShellSession.shellEnvs.set(clusterId, env); + } else { + // refresh env in the background + this.getShellEnv().then((shellEnv: any) => { + ShellSession.shellEnvs.set(clusterId, shellEnv); + }); + } + + return env; + } + + protected async getShellEnv() { + const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); + const pathStr = [...this.getPathEntries(), await this.kubectlBinDirP, process.env.PATH].join(path.delimiter); + const shell = userStore.preferences.shell || process.env.SHELL || process.env.PTYSHELL; + + delete env.DEBUG; // don't pass DEBUG into shells + + if (isWindows) { + env.SystemRoot = process.env.SystemRoot; + env.PTYSHELL = shell || "powershell.exe"; + env.PATH = pathStr; + env.LENS_SESSION = "true"; + env.WSLENV = [ + process.env.WSLENV, + "KUBECONFIG/up:LENS_SESSION/u" + ] + .filter(Boolean) + .join(":"); + } else if (shell !== undefined) { + env.PTYSHELL = shell; + env.PATH = pathStr; + } else { + env.PTYSHELL = ""; // blank runs the system default shell + } + + if (path.basename(env.PTYSHELL) === "zsh") { + env.OLD_ZDOTDIR = env.ZDOTDIR || env.HOME; + env.ZDOTDIR = this.kubectlBinDirP; + env.DISABLE_AUTO_UPDATE = "true"; + } + + env.PTYPID = process.pid.toString(); + env.KUBECONFIG = await this.kubeconfigPathP; + env.TERM_PROGRAM = app.getName(); + env.TERM_PROGRAM_VERSION = app.getVersion(); + + if (this.cluster.preferences.httpsProxy) { + env.HTTPS_PROXY = this.cluster.preferences.httpsProxy; + } + + env.NO_PROXY = [ + "localhost", + "127.0.0.1", + env.NO_PROXY + ] + .filter(Boolean) + .join(); + + return env; + } + + protected exit(code = 1000) { + if (this.websocket.readyState == this.websocket.OPEN) { + this.websocket.close(code); + } + } + + protected sendResponse(msg: string) { + this.websocket.send(`1${Buffer.from(msg).toString("base64")}`); + } +} From ed8d26fe6892e142e325171abb38a1ab1e8fcd37 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 12 Apr 2021 15:43:45 +0300 Subject: [PATCH 5/5] Test using GH Actions (#2509) * test on gh actions Signed-off-by: Jari Kolehmainen * don't run integration test on release pipeline Signed-off-by: Jari Kolehmainen --- .azure-pipelines.yml | 43 +---------------------- .github/workflows/test.yml | 71 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index d7ed058fa3..fe886488e1 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -1,19 +1,7 @@ variables: YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn -pr: - branches: - include: - - master - - releases/* - paths: - exclude: - - .github/* - - docs/* - - mkdocs/* +pr: none trigger: - branches: - include: - - '*' tags: include: - "*" @@ -57,12 +45,6 @@ jobs: displayName: Run tests - script: make test-extensions displayName: Run In-tree Extension tests - - bash: | - set -e - rm -rf extensions/telemetry - make integration-win - git checkout extensions/telemetry - displayName: Run integration tests - script: make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" displayName: Build @@ -102,14 +84,6 @@ jobs: displayName: Run tests - script: make test-extensions displayName: Run In-tree Extension tests - - bash: | - set -e - rm -rf extensions/telemetry - make integration-mac - git checkout extensions/telemetry - displayName: Run integration tests - - script: make test-extensions - displayName: Run In-tree Extension tests - script: make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" displayName: Build @@ -151,21 +125,6 @@ jobs: displayName: Run tests - script: make test-extensions displayName: Run In-tree Extension tests - - bash: | - sudo apt-get update - sudo apt-get install libgconf-2-4 conntrack -y - curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 - sudo install minikube-linux-amd64 /usr/local/bin/minikube - sudo minikube start --driver=none - # Although the kube and minikube config files are in placed $HOME they are owned by root - sudo chown -R $USER $HOME/.kube $HOME/.minikube - displayName: Install integration test dependencies - - bash: | - set -e - rm -rf extensions/telemetry - xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux - git checkout extensions/telemetry - displayName: Run integration tests - bash: | sudo chown root:root / sudo apt-get update && sudo apt-get install -y snapd diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..e6dbf72784 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,71 @@ +name: Test +on: + - pull_request +jobs: + build: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-16.04, macos-10.15, windows-2019] + node-version: [12.x] + steps: + - name: Checkout Release from lens + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Using Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - run: make node_modules + name: Install dependencies + + - run: make build-npm + name: Generate npm package + + - run: make -j2 build-extensions + name: Build bundled extensions + + - run: make test + name: Run tests + + - run: make test-extensions + name: Run In-tree Extension tests + + - run: | + sudo apt-get update + sudo apt-get install libgconf-2-4 conntrack -y + curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 + sudo install minikube-linux-amd64 /usr/local/bin/minikube + sudo minikube start --driver=none + # Although the kube and minikube config files are in placed $HOME they are owned by root + sudo chown -R $USER $HOME/.kube $HOME/.minikube + name: Install integration test dependencies + if: runner.os == 'Linux' + - run: | + set -e + rm -rf extensions/telemetry + xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux + git checkout extensions/telemetry + name: Run Linux integration tests + if: runner.os == 'Linux' + + - run: | + set -e + rm -rf extensions/telemetry + make integration-win + git checkout extensions/telemetry + name: Run Windows integration tests + shell: bash + if: runner.os == 'Windows' + + - run: | + set -e + rm -rf extensions/telemetry + make integration-mac + git checkout extensions/telemetry + name: Run macOS integration tests + if: runner.os == 'macOS'