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

Merge branch 'master' into feature/catalog-entity-context-menu-visibility

This commit is contained in:
Jari Kolehmainen 2021-04-13 06:37:20 +03:00
commit 4819ccf8cd
20 changed files with 1075 additions and 583 deletions

View File

@ -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
@ -70,6 +52,8 @@ jobs:
WIN_CSC_LINK: $(WIN_CSC_LINK)
WIN_CSC_KEY_PASSWORD: $(WIN_CSC_KEY_PASSWORD)
GH_TOKEN: $(GH_TOKEN)
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
- job: macOS
pool:
vmImage: macOS-10.14
@ -102,14 +86,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
@ -119,6 +95,8 @@ jobs:
CSC_LINK: $(CSC_LINK)
CSC_KEY_PASSWORD: $(CSC_KEY_PASSWORD)
GH_TOKEN: $(GH_TOKEN)
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
- job: Linux
pool:
vmImage: ubuntu-16.04
@ -151,21 +129,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
@ -181,6 +144,8 @@ jobs:
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
env:
GH_TOKEN: $(GH_TOKEN)
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
- script: make publish-npm
displayName: Publish npm package
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"

71
.github/workflows/test.yml vendored Normal file
View File

@ -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'

View File

@ -1,3 +1,3 @@
disturl "https://atom.io/download/electron"
target "9.1.0"
target "9.4.4"
runtime "electron"

View File

@ -2,7 +2,7 @@
"name": "kontena-lens",
"productName": "Lens",
"description": "Lens - The Kubernetes IDE",
"version": "5.0.0-alpha.0",
"version": "5.0.0-alpha.1",
"main": "static/build/main.js",
"copyright": "© 2021, Mirantis, Inc.",
"license": "MIT",
@ -69,6 +69,7 @@
]
},
"build": {
"generateUpdatesFilesForAllChannels": true,
"files": [
"static/build/main.js"
],
@ -165,9 +166,9 @@
},
"publish": [
{
"provider": "github",
"repo": "lens",
"owner": "lensapp"
"provider": "s3",
"bucket": "lens-binaries",
"path": "/ide"
}
],
"protocols": {
@ -319,8 +320,8 @@
"css-loader": "^3.5.3",
"deepdash": "^5.3.5",
"dompurify": "^2.0.11",
"electron": "^9.4.0",
"electron-builder": "^22.7.0",
"electron": "^9.4.4",
"electron-builder": "^22.10.5",
"electron-notarize": "^0.3.0",
"eslint": "^7.7.0",
"eslint-plugin-react": "^7.21.5",

View File

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

View File

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

View File

@ -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<string, CatalogEntity[]>([], { deep: true });
protected sources = observable.map<string, IObservableArray<CatalogEntity>>([], { deep: true });
@action addSource(id: string, source: CatalogEntity[]) {
@action addSource(id: string, source: IObservableArray<CatalogEntity>) {
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<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {

View File

@ -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<CatalogEntity>) {
catalogEntityRegistry.addSource(`${this.name}:${id}`, source);
}

View File

@ -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<CatalogEntity>([]);
constructor(public readonly port: number) {
super();

View File

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

View File

@ -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<boolean>(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<ShellSession> {
let shell: ShellSession;
if (nodeName) {
shell = new NodeShellSession(socket, cluster, nodeName);
} else {
shell = new ShellSession(socket, cluster);
}
shell.open();
return shell;
}

View File

@ -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<string, any> = 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<Array<string>> {
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")}`);
}
}

View File

@ -0,0 +1,2 @@
export * from "./node-shell-session";
export * from "./local-shell-session";

View File

@ -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<string[]> {
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 [];
}
}
}

View File

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

View File

@ -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<string, Record<string, any>> = new Map();
protected kubectl: Kubectl;
protected running = false;
protected shellProcess: pty.IPty;
protected kubectlBinDirP: Promise<string>;
protected kubeconfigPathP: Promise<string>;
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<string, any>): 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")}`);
}
}

View File

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

View File

@ -114,10 +114,12 @@ export class Catalog extends React.Component {
renderNavigation() {
return (
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<div className="header">Catalog</div>
{ this.categories.map((category, index) => {
return <Tab value={category.getId()} key={index} label={category.metadata.name} data-testid={`${category.getId()}-tab`} />;
})}
<div className="sidebarHeader">Catalog</div>
<div className="sidebarTabs">
{ this.categories.map((category, index) => {
return <Tab value={category.getId()} key={index} label={category.metadata.name} data-testid={`${category.getId()}-tab`} />;
})}
</div>
</Tabs>
);
}

View File

@ -2,7 +2,21 @@
Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights!
## 4.2.0-rc.3 (current version)
## 5.0.0-alpha.1 (current version)
- Workspaces are replaced by Catalog & Hotbar
## 4.2.1
- User is now notified if helm list fails
- Sorting order is now saved when switching views
- Fix: Node shells failing to open
- Fix: Tray icon is now reactive to changes
- Fix: Whole window is used for displaying workspace overview
- Fix: Workspace overview is now reactive to cluster changes
- Fix: Exported ClusterStore now enforces more invariants
## 4.2.0
- Add lens:// protocol handling with a routing mechanism
- Add common app routes to the protocol renderer router from the documentation
@ -38,6 +52,12 @@ Here you can find description of changes we've built into each release. While we
- Fix: Set initial cursor position for the editor to beginning
- Fix: Highlight sidebar's active section
## 4.1.5
- Proxy should listen only on loopback device
- Fix extension command palette loading
- Fix Lens not clearing other KUBECONFIG env vars
## 4.1.4
- Ignore clusters with invalid kubeconfig

633
yarn.lock

File diff suppressed because it is too large Load Diff