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

Migrating Vue components to React and stores refactoring (#585)

Signed-off-by: Roman <ixrock@gmail.com>

Co-authored-by: Sebastian Malton <sebastian@malton.name>
Co-authored-by: Sebastian Malton <smalton@mirantis.com>
Co-authored-by: Lauri Nevala <lauri.nevala@gmail.com>
Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
Roman 2020-08-20 08:53:07 +03:00 committed by GitHub
parent 905bbe9d3f
commit 5670312c47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
297 changed files with 8187 additions and 9104 deletions

View File

@ -4,11 +4,9 @@ module.exports = {
files: [ files: [
"src/renderer/**/*.js", "src/renderer/**/*.js",
"build/**/*.js", "build/**/*.js",
"src/renderer/**/*.vue"
], ],
extends: [ extends: [
'eslint:recommended', 'eslint:recommended',
'plugin:vue/recommended'
], ],
env: { env: {
node: true node: true
@ -20,9 +18,6 @@ module.exports = {
rules: { rules: {
"indent": ["error", 2], "indent": ["error", 2],
"no-unused-vars": "off", "no-unused-vars": "off",
"vue/order-in-components": "off",
"vue/attributes-order": "off",
"vue/max-attributes-per-line": "off"
} }
}, },
{ {

2
.gitignore vendored
View File

@ -5,7 +5,7 @@ node_modules/
yarn-error.log yarn-error.log
coverage/ coverage/
tmp/ tmp/
static/build/client/ static/build/**
binaries/client/ binaries/client/
binaries/server/ binaries/server/
locales/**/**.js locales/**/**.js

View File

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

View File

@ -40,12 +40,17 @@ brew cask install lens
Allows faster separately re-run some of involved processes: Allows faster separately re-run some of involved processes:
1. `yarn dev:main` compiles electron's main process and watch files 1. `yarn dev:main` compiles electron's main process part and start watching files
1. `yarn dev:renderer:vue` compiles electron's renderer vue-part 1. `yarn dev:renderer` compiles electron's renderer part and start watching files
1. `yarn dev:renderer:react` compiles electron's renderer react-part
1. `yarn dev-run` runs app in dev-mode and restarts when electron's main process file has changed 1. `yarn dev-run` runs app in dev-mode and restarts when electron's main process file has changed
Alternatively to compile both render parts in single command use `yarn dev:renderer` ## Developer's ~~RTFM~~ recommended list:
- [TypeScript](https://www.typescriptlang.org/docs/home.html) (front-end/back-end)
- [ReactJS](https://reactjs.org/docs/getting-started.html) (front-end, ui)
- [MobX](https://mobx.js.org/) (app-state-management, back-end/front-end)
- [ElectronJS](https://www.electronjs.org/docs) (chrome/node)
- [NodeJS](https://nodejs.org/dist/latest-v12.x/docs/api/) (api docs)
## Contributing ## Contributing

View File

@ -3,8 +3,10 @@ module.exports = {
match: jest.fn(), match: jest.fn(),
app: { app: {
getVersion: jest.fn().mockReturnValue("3.0.0"), getVersion: jest.fn().mockReturnValue("3.0.0"),
getPath: jest.fn().mockReturnValue("tmp"),
getLocale: jest.fn().mockRejectedValue("en"), getLocale: jest.fn().mockRejectedValue("en"),
getPath: jest.fn((name: string) => {
return "tmp"
}),
}, },
remote: { remote: {
app: { app: {

1
__mocks__/styleMock.ts Normal file
View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -1,3 +1,3 @@
import { helmCli } from "../src/main/helm-cli" import { helmCli } from "../src/main/helm/helm-cli"
helmCli.ensureBinary() helmCli.ensureBinary()

View File

@ -79,7 +79,9 @@ class KubectlDownloader {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
file.on("close", () => { file.on("close", () => {
console.log("kubectl binary download closed") console.log("kubectl binary download closed")
fs.chmod(this.path, 0o755, () => {}) fs.chmod(this.path, 0o755, (err) => {
if (err) reject(err);
})
resolve() resolve()
}) })
stream.pipe(file) stream.pipe(file)

View File

@ -3,13 +3,13 @@ import { Application } from "spectron";
let appPath = "" let appPath = ""
switch(process.platform) { switch(process.platform) {
case "win32": case "win32":
appPath = "./dist/win-unpacked/LensDev.exe" appPath = "./dist/win-unpacked/Lens.exe"
break break
case "linux": case "linux":
appPath = "./dist/linux-unpacked/kontena-lens" appPath = "./dist/linux-unpacked/kontena-lens"
break break
case "darwin": case "darwin":
appPath = "./dist/mac/LensDev.app/Contents/MacOS/LensDev" appPath = "./dist/mac/Lens.app/Contents/MacOS/Lens"
break break
} }
@ -20,6 +20,10 @@ export function setup(): Application {
path: appPath, path: appPath,
startTimeout: 30000, startTimeout: 30000,
waitTimeout: 30000, waitTimeout: 30000,
chromeDriverArgs: ['remote-debugging-port=9222'],
env: {
CICD: "true"
}
}) })
} }

View File

@ -1,7 +1,6 @@
import { Application } from "spectron" import { Application } from "spectron"
import * as util from "../helpers/utils" import * as util from "../helpers/utils"
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import { stat } from "fs"
jest.setTimeout(20000) jest.setTimeout(20000)
@ -11,19 +10,21 @@ describe("app start", () => {
let app: Application let app: Application
const clickWhatsNew = async (app: Application) => { const clickWhatsNew = async (app: Application) => {
await app.client.waitUntilTextExists("h1", "What's new") await app.client.waitUntilTextExists("h1", "What's new")
await app.client.click("button.btn-primary") await app.client.click("button.primary")
await app.client.waitUntilTextExists("h1", "Welcome") await app.client.waitUntilTextExists("h1", "Welcome")
} }
const addMinikubeCluster = async (app: Application) => { const addMinikubeCluster = async (app: Application) => {
await app.client.click("a#add-cluster") await app.client.click("div.add-cluster")
await app.client.waitUntilTextExists("legend", "Choose config:") await app.client.waitUntilTextExists("p", "Choose config")
await app.client.selectByVisibleText("select#kubecontext-select", "minikube (new)") await app.client.click("div#kubecontext-select")
await app.client.click("button.btn-primary") await app.client.waitUntilTextExists("div", "minikube")
await app.client.click("div.minikube")
await app.client.click("button.primary")
} }
const waitForMinikubeDashboard = async (app: Application) => { const waitForMinikubeDashboard = async (app: Application) => {
await app.client.waitUntilTextExists("pre.auth-output", "Authentication proxy started") await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started")
let windowCount = await app.client.getWindowCount() let windowCount = await app.client.getWindowCount()
// wait for webview to appear on window count // wait for webview to appear on window count
while (windowCount == 1) { while (windowCount == 1) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "3.6.0-dev", "version": "3.6.0-dev",
"main": "out/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2020, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
"author": { "author": {
@ -12,31 +12,28 @@
}, },
"scripts": { "scripts": {
"dev": "concurrently -k \"yarn dev-run -C\" \"yarn dev:main\" \"yarn dev:renderer\"", "dev": "concurrently -k \"yarn dev-run -C\" \"yarn dev:main\" \"yarn dev:renderer\"",
"dev-run": "nodemon --watch out/main.* --exec \"electron --inspect .\" $@", "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\" $@",
"dev-test": "yarn test --watch",
"dev:main": "env DEBUG=true yarn compile:main --watch $@", "dev:main": "env DEBUG=true yarn compile:main --watch $@",
"dev:renderer": "env DEBUG=true yarn compile:renderer --watch $@", "dev:renderer": "env DEBUG=true yarn compile:renderer --watch $@",
"dev:renderer:react": "yarn dev:renderer --config-name react $@", "compile": "env NODE_ENV=production concurrently yarn:compile:*",
"dev:renderer:vue": "yarn dev:renderer --config-name vue $@", "compile:main": "webpack --config webpack.main.ts",
"compile": "concurrently \"yarn i18n:compile\" \"yarn compile:main -p\" \"yarn compile:renderer -p\"", "compile:renderer": "webpack --config webpack.renderer.ts",
"compile:main": "webpack --progress --config webpack.main.ts", "compile:i18n": "lingui compile",
"compile:renderer": "webpack --progress --config webpack.renderer.ts", "build:linux": "yarn compile && electron-builder --linux --dir -c.productName=Lens",
"compile:dll": "webpack --config webpack.dll.ts", "build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens",
"build:linux": "yarn compile && electron-builder --linux --dir -c.productName=LensDev", "build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens",
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=LensDev",
"build:win": "yarn compile && electron-builder --win --dir -c.productName=LensDev",
"test": "jest --env=jsdom src $@", "test": "jest --env=jsdom src $@",
"integration": "jest --coverage integration $@", "integration": "jest --coverage integration $@",
"dist": "yarn compile && electron-builder -p onTag", "dist": "yarn compile && electron-builder --publish onTag",
"dist:win": "yarn compile && electron-builder -p onTag --x64 --ia32", "dist:win": "yarn compile && electron-builder --publish onTag --x64 --ia32",
"dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null", "dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null",
"postinstall": "patch-package", "postinstall": "patch-package",
"i18n:extract": "lingui extract", "i18n:extract": "lingui extract",
"i18n:compile": "lingui compile",
"download-bins": "concurrently yarn:download:*", "download-bins": "concurrently yarn:download:*",
"download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:kubectl": "yarn run ts-node build/download_kubectl.ts",
"download:helm": "yarn run ts-node build/download_helm.ts", "download:helm": "yarn run ts-node build/download_helm.ts",
"lint": "eslint $@ --ext js,ts,tsx,vue --max-warnings=0 src/" "lint": "eslint $@ --ext js,ts,tsx --max-warnings=0 src/",
"rebuild-pty": "yarn run electron-rebuild -f -w node-pty"
}, },
"config": { "config": {
"bundledKubectlVersion": "1.17.4", "bundledKubectlVersion": "1.17.4",
@ -69,6 +66,9 @@
"testEnvironment": "node", "testEnvironment": "node",
"transform": { "transform": {
"^.+\\.tsx?$": "ts-jest" "^.+\\.tsx?$": "ts-jest"
},
"moduleNameMapper": {
"\\.(css|scss)$": "<rootDir>/__mocks__/styleMock.ts"
} }
}, },
"build": { "build": {
@ -87,7 +87,7 @@
{ {
"from": "static/", "from": "static/",
"to": "static/", "to": "static/",
"filter": "**/*" "filter": "!**/main.js"
}, },
"LICENSE" "LICENSE"
], ],
@ -170,29 +170,35 @@
"@types/node": "^12.12.45", "@types/node": "^12.12.45",
"@types/proper-lockfile": "^4.1.1", "@types/proper-lockfile": "^4.1.1",
"@types/tar": "^4.0.3", "@types/tar": "^4.0.3",
"chalk": "^4.1.0",
"conf": "^7.0.1",
"crypto-js": "^4.0.0", "crypto-js": "^4.0.0",
"electron-promise-ipc": "^2.1.0",
"electron-store": "^5.2.0",
"electron-updater": "^4.3.1", "electron-updater": "^4.3.1",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"filenamify": "^4.1.0", "filenamify": "^4.1.0",
"fs-extra": "^9.0.1", "fs-extra": "^9.0.1",
"handlebars": "^4.7.6", "handlebars": "^4.7.6",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"immer": "^7.0.5",
"js-yaml": "^3.14.0", "js-yaml": "^3.14.0",
"jsonpath": "^1.0.2", "jsonpath": "^1.0.2",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"mac-ca": "^1.0.4", "mac-ca": "^1.0.4",
"marked": "^1.1.0", "marked": "^1.1.0",
"md5-file": "^5.0.0", "md5-file": "^5.0.0",
"mobx": "^5.15.5",
"mobx-observable-history": "^1.0.3",
"mock-fs": "^4.12.0", "mock-fs": "^4.12.0",
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"node-pty": "^0.9.0", "node-pty": "^0.9.0",
"openid-client": "^3.15.2", "openid-client": "^3.15.2",
"path-to-regexp": "^6.1.0",
"proper-lockfile": "^4.1.1", "proper-lockfile": "^4.1.1",
"react-router": "^5.2.0",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.8", "request-promise-native": "^1.0.8",
"semver": "^7.3.2", "semver": "^7.3.2",
"serializr": "^2.0.3",
"shell-env": "^3.0.0", "shell-env": "^3.0.0",
"tar": "^6.0.2", "tar": "^6.0.2",
"tcp-port-used": "^1.0.1", "tcp-port-used": "^1.0.1",
@ -201,6 +207,7 @@
"uuid": "^8.1.0", "uuid": "^8.1.0",
"win-ca": "^3.2.0", "win-ca": "^3.2.0",
"winston": "^3.2.1", "winston": "^3.2.1",
"winston-transport-browserconsole": "^1.0.5",
"ws": "^7.3.0" "ws": "^7.3.0"
}, },
"devDependencies": { "devDependencies": {
@ -210,6 +217,7 @@
"@babel/preset-env": "^7.10.2", "@babel/preset-env": "^7.10.2",
"@babel/preset-react": "^7.10.1", "@babel/preset-react": "^7.10.1",
"@babel/preset-typescript": "^7.10.1", "@babel/preset-typescript": "^7.10.1",
"@emeraldpay/hashicon-react": "^0.4.0",
"@lingui/babel-preset-react": "^2.9.1", "@lingui/babel-preset-react": "^2.9.1",
"@lingui/cli": "^3.0.0-13", "@lingui/cli": "^3.0.0-13",
"@lingui/loader": "^3.0.0-13", "@lingui/loader": "^3.0.0-13",
@ -228,7 +236,6 @@
"@types/md5-file": "^4.0.2", "@types/md5-file": "^4.0.2",
"@types/mini-css-extract-plugin": "^0.9.1", "@types/mini-css-extract-plugin": "^0.9.1",
"@types/react": "^16.9.35", "@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.5", "@types/react-router-dom": "^5.1.5",
"@types/react-select": "^3.0.13", "@types/react-select": "^3.0.13",
"@types/react-window": "^1.8.2", "@types/react-window": "^1.8.2",
@ -253,8 +260,6 @@
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"babel-plugin-macros": "^2.8.0", "babel-plugin-macros": "^2.8.0",
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"bootstrap": "^4.5.0",
"bootstrap-vue": "^2.15.0",
"chart.js": "^2.9.3", "chart.js": "^2.9.3",
"circular-dependency-plugin": "^5.2.0", "circular-dependency-plugin": "^5.2.0",
"color": "^3.1.2", "color": "^3.1.2",
@ -262,15 +267,14 @@
"css-element-queries": "^1.2.3", "css-element-queries": "^1.2.3",
"css-loader": "^3.5.3", "css-loader": "^3.5.3",
"dompurify": "^2.0.11", "dompurify": "^2.0.11",
"electron": "^6.1.12", "electron": "^9.1.2",
"electron-builder": "^22.7.0", "electron-builder": "^22.7.0",
"electron-notarize": "^0.3.0", "electron-notarize": "^0.3.0",
"electron-rebuild": "^1.11.0",
"eslint": "^7.3.1", "eslint": "^7.3.1",
"eslint-plugin-vue": "^6.2.2",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"flex.box": "^3.4.4", "flex.box": "^3.4.4",
"fork-ts-checker-webpack-plugin": "^5.0.0", "fork-ts-checker-webpack-plugin": "^5.0.0",
"hashicon": "^0.3.0",
"hoist-non-react-statics": "^3.3.2", "hoist-non-react-statics": "^3.3.2",
"html-webpack-plugin": "^4.3.0", "html-webpack-plugin": "^4.3.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
@ -279,17 +283,13 @@
"make-plural": "^6.2.1", "make-plural": "^6.2.1",
"material-design-icons": "^3.0.1", "material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"mobx": "^5.15.4",
"mobx-observable-history": "^1.0.3",
"mobx-react": "^6.2.2", "mobx-react": "^6.2.2",
"moment": "^2.26.0", "moment": "^2.26.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"patch-package": "^6.2.2", "patch-package": "^6.2.2",
"path-to-regexp": "^6.1.0",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"prismjs": "^1.20.0",
"raw-loader": "^4.0.1", "raw-loader": "^4.0.1",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
@ -297,7 +297,7 @@
"react-select": "^3.1.0", "react-select": "^3.1.0",
"react-window": "^1.8.5", "react-window": "^1.8.5",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"spectron": "^8.0.0", "spectron": "11.0.0",
"style-loader": "^1.2.1", "style-loader": "^1.2.1",
"terser-webpack-plugin": "^3.0.3", "terser-webpack-plugin": "^3.0.3",
"ts-jest": "^26.1.0", "ts-jest": "^26.1.0",
@ -306,15 +306,6 @@
"typeface-roboto": "^0.0.75", "typeface-roboto": "^0.0.75",
"typescript": "^3.9.5", "typescript": "^3.9.5",
"url-loader": "^4.1.0", "url-loader": "^4.1.0",
"vue": "^2.6.11",
"vue-electron": "^1.0.6",
"vue-loader": "^15.9.2",
"vue-prism-editor": "^0.6.1",
"vue-router": "^3.3.2",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.6.11",
"vuedraggable": "^2.23.2",
"vuex": "^3.4.0",
"webpack": "^4.43.0", "webpack": "^4.43.0",
"webpack-cli": "^3.3.11", "webpack-cli": "^3.3.11",
"webpack-node-externals": "^1.7.2", "webpack-node-externals": "^1.7.2",

138
src/common/base-store.ts Normal file
View File

@ -0,0 +1,138 @@
import path from "path"
import Config from "conf"
import { Options as ConfOptions } from "conf/dist/source/types"
import { app, ipcMain, IpcMainEvent, ipcRenderer, IpcRendererEvent, remote } from "electron"
import { action, observable, reaction, runInAction, toJS, when } from "mobx";
import Singleton from "./utils/singleton";
import { getAppVersion } from "./utils/app-version";
import logger from "../main/logger";
import { broadcastIpc } from "./ipc";
import isEqual from "lodash/isEqual";
export interface BaseStoreParams<T = any> extends ConfOptions<T> {
autoLoad?: boolean;
syncEnabled?: boolean;
}
export class BaseStore<T = any> extends Singleton {
protected storeConfig: Config<T>;
protected syncDisposers: Function[] = [];
whenLoaded = when(() => this.isLoaded);
@observable isLoaded = false;
@observable protected data: T;
protected constructor(protected params: BaseStoreParams) {
super();
this.params = {
autoLoad: false,
syncEnabled: true,
...params,
}
this.init();
}
get name() {
return path.basename(this.storeConfig.path);
}
get syncChannel() {
return `store-sync:${this.name}`
}
protected async init() {
if (this.params.autoLoad) {
await this.load();
}
if (this.params.syncEnabled) {
await this.whenLoaded;
this.enableSync();
}
}
async load() {
const { autoLoad, syncEnabled, ...confOptions } = this.params;
this.storeConfig = new Config({
...confOptions,
projectName: "lens",
projectVersion: getAppVersion(),
cwd: (app || remote.app).getPath("userData"),
});
logger.info(`[STORE]: LOADED from ${this.storeConfig.path}`);
this.fromStore(this.storeConfig.store);
this.isLoaded = true;
}
protected async save(model: T) {
logger.info(`[STORE]: SAVING ${this.name}`);
// todo: update when fixed https://github.com/sindresorhus/conf/issues/114
Object.entries(model).forEach(([key, value]) => {
this.storeConfig.set(key, value);
});
}
enableSync() {
this.syncDisposers.push(
reaction(() => this.toJSON(), model => this.onModelChange(model)),
);
if (ipcMain) {
const callback = (event: IpcMainEvent, model: T) => {
logger.debug(`[STORE]: SYNC ${this.name} from renderer`, { model });
this.onSync(model);
};
ipcMain.on(this.syncChannel, callback);
this.syncDisposers.push(() => ipcMain.off(this.syncChannel, callback));
}
if (ipcRenderer) {
const callback = (event: IpcRendererEvent, model: T) => {
logger.debug(`[STORE]: SYNC ${this.name} from main`, { model });
this.onSync(model);
};
ipcRenderer.on(this.syncChannel, callback);
this.syncDisposers.push(() => ipcRenderer.off(this.syncChannel, callback));
}
}
disableSync() {
this.syncDisposers.forEach(dispose => dispose());
this.syncDisposers.length = 0;
}
protected applyWithoutSync(callback: () => void) {
this.disableSync();
runInAction(callback);
if (this.params.syncEnabled) {
this.enableSync();
}
}
protected onSync(model: T) {
// todo: use "resourceVersion" if merge required (to avoid equality checks => better performance)
if (!isEqual(this.toJSON(), model)) {
this.fromStore(model);
}
}
protected async onModelChange(model: T) {
if (ipcMain) {
this.save(model); // save config file
broadcastIpc({ channel: this.syncChannel, args: [model] }); // broadcast to renderer views
}
// send "update-request" to main-process
if (ipcRenderer) {
ipcRenderer.send(this.syncChannel, model);
}
}
@action
protected fromStore(data: T) {
this.data = data;
}
// todo: use "serializr" ?
toJSON(): T {
return toJS(this.data, {
recurseEverything: true,
})
}
}

59
src/common/cluster-ipc.ts Normal file
View File

@ -0,0 +1,59 @@
import { createIpcChannel } from "./ipc";
import { ClusterId, clusterStore } from "./cluster-store";
import { tracker } from "./tracker";
export const clusterIpc = {
init: createIpcChannel({
channel: "cluster:init",
handle: async (clusterId: ClusterId, frameId: number) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) {
cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates
return cluster.pushState();
}
},
}),
activate: createIpcChannel({
channel: "cluster:activate",
handle: (clusterId: ClusterId) => {
return clusterStore.getById(clusterId)?.activate();
},
}),
disconnect: createIpcChannel({
channel: "cluster:disconnect",
handle: (clusterId: ClusterId) => {
tracker.event("cluster", "stop");
return clusterStore.getById(clusterId)?.disconnect();
},
}),
installFeature: createIpcChannel({
channel: "cluster:install-feature",
handle: async (clusterId: ClusterId, feature: string, config?: any) => {
tracker.event("cluster", "install", feature);
const cluster = clusterStore.getById(clusterId);
if (cluster) {
await cluster.installFeature(feature, config)
} else {
throw `${clusterId} is not a valid cluster id`;
}
}
}),
uninstallFeature: createIpcChannel({
channel: "cluster:uninstall-feature",
handle: (clusterId: ClusterId, feature: string) => {
tracker.event("cluster", "uninstall", feature);
return clusterStore.getById(clusterId)?.uninstallFeature(feature)
}
}),
upgradeFeature: createIpcChannel({
channel: "cluster:upgrade-feature",
handle: (clusterId: ClusterId, feature: string, config?: any) => {
tracker.event("cluster", "upgrade", feature);
return clusterStore.getById(clusterId)?.upgradeFeature(feature, config)
}
}),
}

View File

@ -1,120 +1,189 @@
import ElectronStore from "electron-store" import type { WorkspaceId } from "./workspace-store";
import { Cluster, ClusterBaseInfo } from "../main/cluster"; import path from "path";
import * as version200Beta2 from "../migrations/cluster-store/2.0.0-beta.2" import { app, ipcRenderer, remote } from "electron";
import * as version241 from "../migrations/cluster-store/2.4.1" import { unlink } from "fs-extra";
import * as version260Beta2 from "../migrations/cluster-store/2.6.0-beta.2" import { action, computed, observable, toJS } from "mobx";
import * as version260Beta3 from "../migrations/cluster-store/2.6.0-beta.3" import { BaseStore } from "./base-store";
import * as version270Beta0 from "../migrations/cluster-store/2.7.0-beta.0" import { Cluster, ClusterState } from "../main/cluster";
import * as version270Beta1 from "../migrations/cluster-store/2.7.0-beta.1" import migrations from "../migrations/cluster-store"
import * as version360Beta1 from "../migrations/cluster-store/3.6.0-beta.1" import logger from "../main/logger";
import { getAppVersion } from "./utils/app-version"; import { tracker } from "./tracker";
export class ClusterStore { export interface ClusterIconUpload {
private static instance: ClusterStore; clusterId: string;
public store: ElectronStore; name: string;
path: string;
}
export interface ClusterStoreModel {
activeCluster?: ClusterId; // last opened cluster
clusters?: ClusterModel[]
}
export type ClusterId = string;
export interface ClusterModel {
id: ClusterId;
workspace?: WorkspaceId;
contextName?: string;
preferences?: ClusterPreferences;
kubeConfigPath: string;
/** @deprecated */
kubeConfig?: string; // yaml
}
export interface ClusterPreferences {
terminalCWD?: string;
clusterName?: string;
prometheus?: {
namespace: string;
service: string;
port: number;
prefix: string;
};
prometheusProvider?: {
type: string;
};
icon?: string;
httpsProxy?: string;
}
export class ClusterStore extends BaseStore<ClusterStoreModel> {
static get iconsDir() {
// TODO: remove remote cheat
return path.join((app || remote.app).getPath("userData"), "icons");
}
private constructor() { private constructor() {
this.store = new ElectronStore({ super({
// @ts-ignore configName: "lens-cluster-store",
// fixme: tests are failed without "projectVersion"
projectVersion: getAppVersion(),
name: "lens-cluster-store",
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
migrations: { migrations: migrations,
"2.0.0-beta.2": version200Beta2.migration, });
"2.4.1": version241.migration, if (ipcRenderer) {
"2.6.0-beta.2": version260Beta2.migration, ipcRenderer.on("cluster:state", (event, model: ClusterState) => {
"2.6.0-beta.3": version260Beta3.migration, this.applyWithoutSync(() => {
"2.7.0-beta.0": version270Beta0.migration, logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model);
"2.7.0-beta.1": version270Beta1.migration, this.getById(model.id)?.updateModel(model);
"3.6.0-beta.1": version360Beta1.migration })
} })
})
}
public getAllClusterObjects(): Array<Cluster> {
return this.store.get("clusters", []).map((clusterInfo: ClusterBaseInfo) => {
return new Cluster(clusterInfo)
})
}
public getAllClusters(): Array<ClusterBaseInfo> {
return this.store.get("clusters", [])
}
public removeCluster(id: string): void {
this.store.delete(id);
const clusterBaseInfos = this.getAllClusters()
const index = clusterBaseInfos.findIndex((cbi) => cbi.id === id)
if (index !== -1) {
clusterBaseInfos.splice(index, 1)
this.store.set("clusters", clusterBaseInfos)
} }
} }
public removeClustersByWorkspace(workspace: string) { @observable activeClusterId: ClusterId;
this.getAllClusters().forEach((cluster) => { @observable removedClusters = observable.map<ClusterId, Cluster>();
if (cluster.workspace === workspace) { @observable clusters = observable.map<ClusterId, Cluster>();
this.removeCluster(cluster.id)
} @computed get activeCluster(): Cluster | null {
}) return this.getById(this.activeClusterId);
} }
public getCluster(id: string): Cluster { @computed get clustersList(): Cluster[] {
const cluster = this.getAllClusterObjects().find((cluster) => cluster.id === id) return Array.from(this.clusters.values());
}
isActive(id: ClusterId) {
return this.activeClusterId === id;
}
setActive(id: ClusterId) {
this.activeClusterId = id;
}
hasClusters() {
return this.clusters.size > 0;
}
hasContext(name: string) {
return this.clustersList.some(cluster => cluster.contextName === name);
}
getById(id: ClusterId): Cluster {
return this.clusters.get(id);
}
getByWorkspaceId(workspaceId: string): Cluster[] {
return this.clustersList.filter(cluster => cluster.workspace === workspaceId)
}
@action
async addCluster(model: ClusterModel, activate = true): Promise<Cluster> {
tracker.event("cluster", "add");
const cluster = new Cluster(model);
this.clusters.set(model.id, cluster);
if (activate) this.activeClusterId = model.id;
return cluster;
}
@action
async removeById(clusterId: ClusterId) {
tracker.event("cluster", "remove");
const cluster = this.getById(clusterId);
if (cluster) { if (cluster) {
return cluster this.clusters.delete(clusterId);
if (this.activeClusterId === clusterId) {
this.activeClusterId = null;
}
unlink(cluster.kubeConfigPath).catch(() => null);
} }
return null
} }
public storeCluster(cluster: ClusterBaseInfo) { @action
const clusters = this.getAllClusters(); removeByWorkspaceId(workspaceId: string) {
const index = clusters.findIndex((cl) => cl.id === cluster.id) this.getByWorkspaceId(workspaceId).forEach(cluster => {
const storable = { this.removeById(cluster.id)
id: cluster.id,
kubeConfigPath: cluster.kubeConfigPath,
contextName: cluster.contextName,
preferences: cluster.preferences,
workspace: cluster.workspace
}
if (index === -1) {
clusters.push(storable)
}
else {
clusters[index] = storable
}
this.store.set("clusters", clusters)
}
public storeClusters(clusters: ClusterBaseInfo[]) {
clusters.forEach((cluster: ClusterBaseInfo) => {
this.removeCluster(cluster.id)
this.storeCluster(cluster)
}) })
} }
public reloadCluster(cluster: ClusterBaseInfo): void { @action
const storedCluster = this.getCluster(cluster.id); protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) {
if (storedCluster) { const currentClusters = this.clusters.toJS();
cluster.kubeConfigPath = storedCluster.kubeConfigPath const newClusters = new Map<ClusterId, Cluster>();
cluster.contextName = storedCluster.contextName const removedClusters = new Map<ClusterId, Cluster>();
cluster.preferences = storedCluster.preferences
cluster.workspace = storedCluster.workspace // update new clusters
} clusters.forEach(clusterModel => {
let cluster = currentClusters.get(clusterModel.id);
if (cluster) {
cluster.updateModel(clusterModel);
} else {
cluster = new Cluster(clusterModel);
}
newClusters.set(clusterModel.id, cluster);
});
// update removed clusters
currentClusters.forEach(cluster => {
if (!newClusters.has(cluster.id)) {
removedClusters.set(cluster.id, cluster);
}
});
this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null;
this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters);
} }
static getInstance(): ClusterStore { toJSON(): ClusterStoreModel {
if (!ClusterStore.instance) { return toJS({
ClusterStore.instance = new ClusterStore(); activeCluster: this.activeClusterId,
} clusters: this.clustersList.map(cluster => cluster.toJSON()),
return ClusterStore.instance; }, {
} recurseEverything: true
})
static resetInstance() {
ClusterStore.instance = null
} }
} }
export const clusterStore: ClusterStore = ClusterStore.getInstance(); export const clusterStore = ClusterStore.getInstance<ClusterStore>();
export function getHostedClusterId(): ClusterId {
const clusterHost = location.hostname.match(/^(.*?)\.localhost/);
if (clusterHost) {
return clusterHost[1]
}
}
export function getHostedCluster(): Cluster {
return clusterStore.getById(getHostedClusterId());
}

View File

@ -1,349 +0,0 @@
import mockFs from "mock-fs"
import yaml from "js-yaml"
import * as fs from "fs"
import { ClusterStore } from "./cluster-store";
import { Cluster } from "../main/cluster";
jest.mock("electron", () => {
return {
app: {
getVersion: () => '99.99.99',
getPath: () => 'tmp',
getLocale: () => 'en'
}
}
})
// Console.log needs to be called before fs-mocks, see https://github.com/tschaub/mock-fs/issues/234
console.log("");
describe("for an empty config", () => {
beforeEach(() => {
ClusterStore.resetInstance()
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({})
}
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("allows to store and retrieve a cluster", async () => {
const cluster = new Cluster({
id: 'foo',
kubeConfigPath: 'kubeconfig',
contextName: "foo",
preferences: {
terminalCWD: '/tmp',
icon: 'path to icon'
}
})
const clusterStore = ClusterStore.getInstance()
clusterStore.storeCluster(cluster);
const storedCluster = clusterStore.getCluster(cluster.id);
expect(storedCluster.kubeConfigPath).toBe(cluster.kubeConfigPath)
expect(storedCluster.contextName).toBe(cluster.contextName)
expect(storedCluster.preferences.icon).toBe(cluster.preferences.icon)
expect(storedCluster.preferences.terminalCWD).toBe(cluster.preferences.terminalCWD)
expect(storedCluster.id).toBe(cluster.id)
})
it("allows to delete a cluster", async () => {
const cluster = new Cluster({
id: 'foofoo',
kubeConfigPath: 'kubeconfig',
contextName: "foo",
preferences: {
terminalCWD: '/tmp'
}
})
const clusterStore = ClusterStore.getInstance()
clusterStore.storeCluster(cluster);
const storedCluster = clusterStore.getCluster(cluster.id);
expect(storedCluster.id).toBe(cluster.id)
clusterStore.removeCluster(cluster.id);
expect(clusterStore.getCluster(cluster.id)).toBe(null)
})
})
describe("for a config with existing clusters", () => {
beforeEach(() => {
ClusterStore.resetInstance()
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "99.99.99"
}
},
clusters: [
{
id: 'cluster1',
kubeConfigPath: 'foo',
preferences: { terminalCWD: '/foo' }
},
{
id: 'cluster2',
kubeConfigPath: 'foo2',
preferences: { terminalCWD: '/foo2' }
}
]
})
}
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("allows to retrieve a cluster", async () => {
const clusterStore = ClusterStore.getInstance()
const storedCluster = clusterStore.getCluster('cluster1')
expect(storedCluster.kubeConfigPath).toBe('foo')
expect(storedCluster.preferences.terminalCWD).toBe('/foo')
expect(storedCluster.id).toBe('cluster1')
const storedCluster2 = clusterStore.getCluster('cluster2')
expect(storedCluster2.kubeConfigPath).toBe('foo2')
expect(storedCluster2.preferences.terminalCWD).toBe('/foo2')
expect(storedCluster2.id).toBe('cluster2')
})
it("allows to delete a cluster", async () => {
const clusterStore = ClusterStore.getInstance()
clusterStore.removeCluster('cluster2')
// Verify the other cluster still exists:
const storedCluster = clusterStore.getCluster('cluster1')
expect(storedCluster.id).toBe('cluster1')
const storedCluster2 = clusterStore.getCluster('cluster2')
expect(storedCluster2).toBe(null)
})
it("allows to reload a cluster in-place", async () => {
const cluster = new Cluster({
id: 'cluster1',
kubeConfigPath: 'kubeconfig string',
contextName: "foo",
preferences: {
terminalCWD: '/tmp'
}
})
const clusterStore = ClusterStore.getInstance()
clusterStore.reloadCluster(cluster)
expect(cluster.kubeConfigPath).toBe('foo')
expect(cluster.preferences.terminalCWD).toBe('/foo')
expect(cluster.id).toBe('cluster1')
})
it("allows getting all the clusters", async () => {
const clusterStore = ClusterStore.getInstance()
const storedClusters = clusterStore.getAllClusters()
expect(storedClusters[0].id).toBe('cluster1')
expect(storedClusters[0].preferences.terminalCWD).toBe('/foo')
expect(storedClusters[0].kubeConfigPath).toBe('foo')
expect(storedClusters[1].id).toBe('cluster2')
expect(storedClusters[1].preferences.terminalCWD).toBe('/foo2')
expect(storedClusters[1].kubeConfigPath).toBe('foo2')
})
it("allows storing the clusters in a different order", async () => {
const clusterStore = ClusterStore.getInstance()
const storedClusters = clusterStore.getAllClusters()
const reorderedClusters = [storedClusters[1], storedClusters[0]]
clusterStore.storeClusters(reorderedClusters)
const storedClusters2 = clusterStore.getAllClusters()
expect(storedClusters2[0].id).toBe('cluster2')
expect(storedClusters2[1].id).toBe('cluster1')
})
})
describe("for a pre 2.0 config with an existing cluster", () => {
beforeEach(() => {
ClusterStore.resetInstance()
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "1.0.0"
}
},
cluster1: 'kubeconfig content'
})
}
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("migrates to modern format with kubeconfig under a key", async () => {
const clusterStore = ClusterStore.getInstance()
const storedCluster = clusterStore.store.get('clusters')[0]
expect(storedCluster.kubeConfigPath).toBe(`tmp/kubeconfigs/${storedCluster.id}`)
})
})
describe("for a pre 2.4.1 config with an existing cluster", () => {
beforeEach(() => {
ClusterStore.resetInstance()
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "2.0.0-beta.2"
}
},
cluster1: {
kubeConfig: 'foo',
online: true,
accessible: false,
failureReason: 'user error'
},
})
}
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("migrates to modern format throwing out the state related data", async () => {
const clusterStore = ClusterStore.getInstance()
const storedClusterData = clusterStore.store.get('clusters')[0]
expect(storedClusterData.hasOwnProperty('online')).toBe(false)
expect(storedClusterData.hasOwnProperty('accessible')).toBe(false)
expect(storedClusterData.hasOwnProperty('failureReason')).toBe(false)
})
})
describe("for a pre 2.6.0 config with a cluster that has arrays in auth config", () => {
beforeEach(() => {
ClusterStore.resetInstance()
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "2.4.1"
}
},
cluster1: {
kubeConfig: "apiVersion: v1\nclusters:\n- cluster:\n server: https://10.211.55.6:8443\n name: minikube\ncontexts:\n- context:\n cluster: minikube\n user: minikube\n name: minikube\ncurrent-context: minikube\nkind: Config\npreferences: {}\nusers:\n- name: minikube\n user:\n client-certificate: /Users/kimmo/.minikube/client.crt\n client-key: /Users/kimmo/.minikube/client.key\n auth-provider:\n config:\n access-token:\n - should be string\n expiry:\n - should be string\n"
},
})
}
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("replaces array format access token and expiry into string", async () => {
const clusterStore = ClusterStore.getInstance()
const storedClusterData = clusterStore.store.get('clusters')[0]
const kc = yaml.safeLoad(fs.readFileSync(storedClusterData.kubeConfigPath).toString())
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(storedClusterData.contextName).toBe("minikube")
})
})
describe("for a pre 2.6.0 config with a cluster icon", () => {
beforeEach(() => {
ClusterStore.resetInstance()
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "2.4.1"
}
},
cluster1: {
kubeConfig: "foo",
icon: "icon path",
preferences: {
terminalCWD: "/tmp"
}
},
})
}
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("moves the icon into preferences", async () => {
const clusterStore = ClusterStore.getInstance()
const storedClusterData = clusterStore.store.get('clusters')[0]
expect(storedClusterData.hasOwnProperty('icon')).toBe(false)
expect(storedClusterData.preferences.hasOwnProperty('icon')).toBe(true)
expect(storedClusterData.preferences.icon).toBe("icon path")
})
})
describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
beforeEach(() => {
ClusterStore.resetInstance()
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "2.6.6"
}
},
cluster1: {
kubeConfig: "foo",
icon: "icon path",
preferences: {
terminalCWD: "/tmp"
}
},
})
}
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("adds cluster to default workspace", async () => {
const clusterStore = ClusterStore.getInstance()
const storedClusterData = clusterStore.store.get("clusters")[0]
expect(storedClusterData.workspace).toBe('default')
})
})

View File

@ -0,0 +1,333 @@
import fs from "fs";
import mockFs from "mock-fs";
import yaml from "js-yaml";
import { Cluster } from "../main/cluster";
import { ClusterStore } from "./cluster-store";
import { workspaceStore } from "./workspace-store";
import { saveConfigToAppFiles } from "./kube-helpers";
let clusterStore: ClusterStore;
describe("empty config", () => {
beforeAll(() => {
ClusterStore.resetInstance();
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({})
}
}
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
})
afterAll(() => {
mockFs.restore();
})
it("adds new cluster to store", async () => {
const cluster = new Cluster({
id: "foo",
preferences: {
terminalCWD: "/tmp",
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube"
},
kubeConfigPath: saveConfigToAppFiles("foo", "fancy foo config"),
workspace: workspaceStore.currentWorkspaceId
});
clusterStore.addCluster(cluster);
const storedCluster = clusterStore.getById(cluster.id);
expect(storedCluster.id).toBe(cluster.id);
expect(storedCluster.preferences.terminalCWD).toBe(cluster.preferences.terminalCWD);
expect(storedCluster.preferences.icon).toBe(cluster.preferences.icon);
})
it("adds cluster to default workspace", () => {
const storedCluster = clusterStore.getById("foo");
expect(storedCluster.workspace).toBe("default");
})
it("check if store can contain multiple clusters", () => {
const prodCluster = new Cluster({
id: "prod",
preferences: {
clusterName: "prod"
},
kubeConfigPath: saveConfigToAppFiles("prod", "fancy config"),
workspace: "workstation"
});
const devCluster = new Cluster({
id: "dev",
preferences: {
clusterName: "dev"
},
kubeConfigPath: saveConfigToAppFiles("dev", "fancy config"),
workspace: "workstation"
});
clusterStore.addCluster(prodCluster);
clusterStore.addCluster(devCluster);
expect(clusterStore.hasClusters()).toBeTruthy();
expect(clusterStore.clusters.size).toBe(3);
});
it("gets clusters by workspaces", () => {
const wsClusters = clusterStore.getByWorkspaceId("workstation");
const defaultClusters = clusterStore.getByWorkspaceId("default");
expect(defaultClusters.length).toBe(1);
expect(wsClusters.length).toBe(2);
expect(wsClusters[0].id).toBe("prod");
expect(wsClusters[1].id).toBe("dev");
})
it("checks if last added cluster becomes active", () => {
expect(clusterStore.activeCluster.id).toBe("dev");
})
it("sets active cluster", () => {
clusterStore.setActive("foo");
expect(clusterStore.activeCluster.id).toBe("foo");
})
it("check if cluster's kubeconfig file saved", () => {
const file = saveConfigToAppFiles("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
})
it("removes cluster from store", async () => {
await clusterStore.removeById("foo");
expect(clusterStore.getById("foo")).toBeUndefined();
})
})
describe("config with existing clusters", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "99.99.99"
}
},
clusters: [
{
id: 'cluster1',
kubeConfig: 'foo',
preferences: { terminalCWD: '/foo' }
},
{
id: 'cluster2',
kubeConfig: 'foo2',
preferences: { terminalCWD: '/foo2' }
}
]
})
}
}
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
})
afterEach(() => {
mockFs.restore();
})
it("allows to retrieve a cluster", () => {
const storedCluster = clusterStore.getById('cluster1');
expect(storedCluster.id).toBe('cluster1');
expect(storedCluster.preferences.terminalCWD).toBe('/foo');
})
it("allows to delete a cluster", () => {
clusterStore.removeById('cluster2');
const storedCluster = clusterStore.getById('cluster1');
expect(storedCluster).toBeTruthy();
const storedCluster2 = clusterStore.getById('cluster2');
expect(storedCluster2).toBeUndefined();
})
it("allows getting all of the clusters", async () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters[0].id).toBe('cluster1')
expect(storedClusters[0].preferences.terminalCWD).toBe('/foo')
expect(storedClusters[1].id).toBe('cluster2')
expect(storedClusters[1].preferences.terminalCWD).toBe('/foo2')
})
})
describe("pre 2.0 config with an existing cluster", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "1.0.0"
}
},
cluster1: 'kubeconfig content'
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
})
afterEach(() => {
mockFs.restore();
})
it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath;
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
})
})
describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "2.4.1"
}
},
cluster1: {
kubeConfig: "apiVersion: v1\nclusters:\n- cluster:\n server: https://10.211.55.6:8443\n name: minikube\ncontexts:\n- context:\n cluster: minikube\n user: minikube\n name: minikube\ncurrent-context: minikube\nkind: Config\npreferences: {}\nusers:\n- name: minikube\n user:\n client-certificate: /Users/kimmo/.minikube/client.crt\n client-key: /Users/kimmo/.minikube/client.key\n auth-provider:\n config:\n access-token:\n - should be string\n expiry:\n - should be string\n"
},
})
}
}
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
})
afterEach(() => {
mockFs.restore();
})
it("replaces array format access token and expiry into string", async () => {
const file = clusterStore.clustersList[0].kubeConfigPath;
const config = fs.readFileSync(file, "utf8");
const kc = yaml.safeLoad(config);
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");
})
})
describe("pre 2.6.0 config with a cluster icon", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "2.4.1"
}
},
cluster1: {
kubeConfig: "foo",
icon: "icon path",
preferences: {
terminalCWD: "/tmp"
}
},
})
}
}
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
})
afterEach(() => {
mockFs.restore();
})
it("moves the icon into preferences", async () => {
const storedClusterData = clusterStore.clustersList[0];
expect(storedClusterData.hasOwnProperty('icon')).toBe(false);
expect(storedClusterData.preferences.hasOwnProperty('icon')).toBe(true);
expect(storedClusterData.preferences.icon).toBe("icon path");
})
})
describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "2.6.6"
}
},
cluster1: {
kubeConfig: "foo",
icon: "icon path",
preferences: {
terminalCWD: "/tmp"
}
},
})
}
}
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
})
afterEach(() => {
mockFs.restore();
})
it("adds cluster to default workspace", async () => {
const storedClusterData = clusterStore.clustersList[0];
expect(storedClusterData.workspace).toBe('default');
})
})
describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
'tmp': {
'lens-cluster-store.json': JSON.stringify({
__internal__: {
migrations: {
version: "2.7.0"
}
},
clusters: [
{
id: 'cluster1',
kubeConfig: 'kubeconfig content'
}
]
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
})
afterEach(() => {
mockFs.restore();
})
it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath;
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
})
})

76
src/common/ipc.ts Normal file
View File

@ -0,0 +1,76 @@
// Inter-protocol communications (main <-> renderer)
// https://www.electronjs.org/docs/api/ipc-main
// https://www.electronjs.org/docs/api/ipc-renderer
import { ipcMain, ipcRenderer, WebContents, webContents } from "electron"
import logger from "../main/logger";
export type IpcChannel = string;
export interface IpcChannelOptions {
channel: IpcChannel; // main <-> renderer communication channel name
handle?: (...args: any[]) => Promise<any> | any; // message handler
autoBind?: boolean; // auto-bind message handler in main-process, default: true
timeout?: number; // timeout for waiting response from the sender
once?: boolean; // one-time event
}
export function createIpcChannel({ autoBind = true, once, timeout = 0, handle, channel }: IpcChannelOptions) {
const ipcChannel = {
channel: channel,
handleInMain: () => {
logger.info(`[IPC]: setup channel "${channel}"`);
const ipcHandler = once ? ipcMain.handleOnce : ipcMain.handle;
ipcHandler(channel, async (event, ...args) => {
let timerId: any;
try {
if (timeout > 0) {
timerId = setTimeout(() => {
throw new Error(`[IPC]: response timeout in ${timeout}ms`)
}, timeout);
}
return await handle(...args); // todo: maybe exec in separate thread/worker
} catch (error) {
throw error
} finally {
clearTimeout(timerId);
}
})
},
removeHandler() {
ipcMain.removeHandler(channel);
},
invokeFromRenderer: async <T>(...args: any[]): Promise<T> => {
return ipcRenderer.invoke(channel, ...args);
},
}
if (autoBind && ipcMain) {
ipcChannel.handleInMain();
}
return ipcChannel;
}
export interface IpcBroadcastParams<A extends any[] = any> {
channel: IpcChannel
webContentId?: number; // send to single webContents view
frameId?: number; // send to inner frame of webContents
filter?: (webContent: WebContents) => boolean
timeout?: number; // todo: add support
args?: A;
}
export function broadcastIpc({ channel, frameId, webContentId, filter, args = [] }: IpcBroadcastParams) {
const singleView = webContentId ? webContents.fromId(webContentId) : null;
let views = singleView ? [singleView] : webContents.getAllWebContents();
if (filter) {
views = views.filter(filter);
}
views.forEach(webContent => {
const type = webContent.getType();
logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
webContent.send(channel, ...args);
if (frameId) {
webContent.sendToFrame(frameId, channel, ...args)
}
})
}

167
src/common/kube-helpers.ts Normal file
View File

@ -0,0 +1,167 @@
import { app, remote } from "electron";
import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node"
import fse, { ensureDirSync, readFile, writeFileSync } from "fs-extra";
import path from "path"
import os from "os"
import yaml from "js-yaml"
import logger from "../main/logger";
function resolveTilde(filePath: string) {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
return filePath.replace("~", os.homedir());
}
return filePath;
}
export function loadConfig(pathOrContent?: string): KubeConfig {
const kc = new KubeConfig();
if (fse.pathExistsSync(pathOrContent)) {
kc.loadFromFile(path.resolve(resolveTilde(pathOrContent)));
} else {
kc.loadFromString(pathOrContent);
}
return kc
}
/**
* KubeConfig is valid when there's at least one of each defined:
* - User
* - Cluster
* - Context
* @param config KubeConfig to check
*/
export function validateConfig(config: KubeConfig | string): KubeConfig {
if (typeof config == "string") {
config = loadConfig(config);
}
logger.debug(`validating kube config: ${JSON.stringify(config)}`)
if (!config.users || config.users.length == 0) {
throw new Error("No users provided in config")
}
if (!config.clusters || config.clusters.length == 0) {
throw new Error("No clusters provided in config")
}
if (!config.contexts || config.contexts.length == 0) {
throw new Error("No contexts provided in config")
}
return config
}
/**
* Breaks kube config into several configs. Each context as it own KubeConfig object
*/
export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] {
const configs: KubeConfig[] = []
if (!kubeConfig.contexts) {
return configs;
}
kubeConfig.contexts.forEach(ctx => {
const kc = new KubeConfig();
kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n);
kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n)
kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n)
kc.setCurrentContext(ctx.name);
configs.push(kc);
});
return configs;
}
export function dumpConfigYaml(kubeConfig: Partial<KubeConfig>): string {
const config = {
apiVersion: "v1",
kind: "Config",
preferences: {},
'current-context': kubeConfig.currentContext,
clusters: kubeConfig.clusters.map(cluster => {
return {
name: cluster.name,
cluster: {
'certificate-authority-data': cluster.caData,
'certificate-authority': cluster.caFile,
server: cluster.server,
'insecure-skip-tls-verify': cluster.skipTLSVerify
}
}
}),
contexts: kubeConfig.contexts.map(context => {
return {
name: context.name,
context: {
cluster: context.cluster,
user: context.user,
namespace: context.namespace
}
}
}),
users: kubeConfig.users.map(user => {
return {
name: user.name,
user: {
'client-certificate-data': user.certData,
'client-certificate': user.certFile,
'client-key-data': user.keyData,
'client-key': user.keyFile,
'auth-provider': user.authProvider,
exec: user.exec,
token: user.token,
username: user.username,
password: user.password
}
}
})
}
logger.debug("Dumping KubeConfig:", config);
// skipInvalid: true makes dump ignore undefined values
return yaml.safeDump(config, { skipInvalid: true });
}
export function podHasIssues(pod: V1Pod) {
// Logic adapted from dashboard
const notReady = !!pod.status.conditions.find(condition => {
return condition.type == "Ready" && condition.status !== "True"
});
return (
notReady ||
pod.status.phase !== "Running" ||
pod.spec.priority > 500000 // We're interested in high prio pods events regardless of their running status
)
}
export function getNodeWarningConditions(node: V1Node) {
return node.status.conditions.filter(c =>
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"
)
}
// Write kubeconfigs to "embedded" store, i.e. "/Users/ixrock/Library/Application Support/Lens/kubeconfigs"
export function saveConfigToAppFiles(clusterId: string, kubeConfig: KubeConfig | string): string {
const userData = (app || remote.app).getPath("userData");
const kubeConfigFile = path.join(userData, `kubeconfigs/${clusterId}`)
const kubeConfigContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig);
ensureDirSync(path.dirname(kubeConfigFile));
writeFileSync(kubeConfigFile, kubeConfigContents);
return kubeConfigFile;
}
export async function getKubeConfigLocal(): Promise<string> {
try {
const configFile = path.join(os.homedir(), '.kube', 'config');
const file = await readFile(configFile, "utf8");
const obj = yaml.safeLoad(file);
if (obj.contexts) {
obj.contexts = obj.contexts.filter((ctx: any) => ctx?.context?.cluster && ctx?.name)
}
return yaml.safeDump(obj);
} catch (err) {
logger.debug(`Cannot read local kube-config: ${err}`)
return "";
}
}

50
src/common/rbac.ts Normal file
View File

@ -0,0 +1,50 @@
import { getHostedCluster } from "./cluster-store";
export type KubeResource =
"namespaces" | "nodes" | "events" | "resourcequotas" |
"services" | "secrets" | "configmaps" | "ingresses" | "networkpolicies" | "persistentvolumes" | "storageclasses" |
"pods" | "daemonsets" | "deployments" | "statefulsets" | "replicasets" | "jobs" | "cronjobs" |
"endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies"
export interface KubeApiResource {
resource: KubeResource; // valid resource name
group?: string; // api-group
}
// TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7)
export const apiResources: KubeApiResource[] = [
{ resource: "configmaps" },
{ resource: "cronjobs", group: "batch" },
{ resource: "customresourcedefinitions", group: "apiextensions.k8s.io" },
{ resource: "daemonsets", group: "apps" },
{ resource: "deployments", group: "apps" },
{ resource: "endpoints" },
{ resource: "events" },
{ resource: "horizontalpodautoscalers" },
{ resource: "ingresses", group: "networking.k8s.io" },
{ resource: "jobs", group: "batch" },
{ resource: "namespaces" },
{ resource: "networkpolicies", group: "networking.k8s.io" },
{ resource: "nodes" },
{ resource: "persistentvolumes" },
{ resource: "pods" },
{ resource: "podsecuritypolicies" },
{ resource: "resourcequotas" },
{ resource: "secrets" },
{ resource: "services" },
{ resource: "statefulsets", group: "apps" },
{ resource: "storageclasses", group: "storage.k8s.io" },
];
export function isAllowedResource(resources: KubeResource | KubeResource[]) {
if (!Array.isArray(resources)) {
resources = [resources];
}
const { allowedResources = [] } = getHostedCluster() || {};
for (const resource of resources) {
if (!allowedResources.includes(resource)) {
return false;
}
}
return true;
}

View File

@ -0,0 +1,12 @@
// Register custom protocols
import { protocol } from "electron"
import path from "path";
export function registerFileProtocol(name: string, basePath: string) {
protocol.registerFileProtocol(name, (request, callback) => {
const filePath = request.url.replace(name + "://", "");
const absPath = path.resolve(basePath, filePath);
callback({ path: absPath });
})
}

View File

@ -1,25 +0,0 @@
// Setup static folder for common assets
import path from "path";
import { protocol } from "electron"
import logger from "../main/logger";
import { staticDir, staticProto, outDir } from "./vars";
export function registerStaticProtocol(rootFolder = staticDir) {
const scheme = staticProto.replace("://", "");
protocol.registerFileProtocol(scheme, (request, callback) => {
const relativePath = request.url.replace(staticProto, "");
const absPath = path.resolve(rootFolder, relativePath);
callback(absPath);
}, (error) => {
logger.debug(`Failed to register protocol "${scheme}"`, error);
})
}
export function getStaticUrl(filePath: string) {
return staticProto + filePath;
}
export function getStaticPath(filePath: string) {
return path.resolve(staticDir, filePath);
}

View File

@ -1,12 +1,28 @@
import request from "request" import request from "request"
import requestPromise from "request-promise-native"
import { userStore } from "./user-store" import { userStore } from "./user-store"
export function globalRequestOpts(requestOpts: request.Options ) { // todo: get rid of "request" (deprecated)
const userPrefs = userStore.getPreferences() // https://github.com/lensapp/lens/issues/459
if (userPrefs.httpsProxy) {
requestOpts.proxy = userPrefs.httpsProxy
}
requestOpts.rejectUnauthorized = !userPrefs.allowUntrustedCAs;
return requestOpts function getDefaultRequestOpts(): Partial<request.Options> {
const { httpsProxy, allowUntrustedCAs } = userStore.preferences
return {
proxy: httpsProxy || undefined,
rejectUnauthorized: !allowUntrustedCAs,
}
}
/**
* @deprecated
*/
export function customRequest(opts: request.Options) {
return request.defaults(getDefaultRequestOpts())(opts)
}
/**
* @deprecated
*/
export function customRequestPromise(opts: requestPromise.Options) {
return requestPromise.defaults(getDefaultRequestOpts())(opts)
} }

View File

@ -1,10 +1,13 @@
import { app, App, remote } from "electron"
import ua from "universal-analytics" import ua from "universal-analytics"
import { machineIdSync } from "node-machine-id" import { machineIdSync } from "node-machine-id"
import Singleton from "./utils/singleton";
import { userStore } from "./user-store" import { userStore } from "./user-store"
import logger from "../main/logger";
const GA_ID = "UA-159377374-1" export class Tracker extends Singleton {
static readonly GA_ID = "UA-159377374-1"
export class Tracker {
protected visitor: ua.Visitor protected visitor: ua.Visitor
protected machineId: string = null; protected machineId: string = null;
protected ip: string = null; protected ip: string = null;
@ -12,31 +15,35 @@ export class Tracker {
protected locale: string; protected locale: string;
protected electronUA: string; protected electronUA: string;
constructor(app: Electron.App) { private constructor(app: App) {
super();
try { try {
this.visitor = ua(GA_ID, machineIdSync(), {strictCidFormat: false}) this.visitor = ua(Tracker.GA_ID, machineIdSync(), { strictCidFormat: false })
} catch (error) { } catch (error) {
this.visitor = ua(GA_ID) this.visitor = ua(Tracker.GA_ID)
} }
this.visitor.set("dl", "https://telemetry.k8slens.dev") this.visitor.set("dl", "https://telemetry.k8slens.dev")
} }
public async event(eventCategory: string, eventAction: string) { protected async isTelemetryAllowed(): Promise<boolean> {
return new Promise(async (resolve, reject) => { return userStore.preferences.allowTelemetry;
if (!this.telemetryAllowed()) { }
resolve()
return async event(eventCategory: string, eventAction: string, otherParams = {}) {
try {
const allowed = await this.isTelemetryAllowed();
if (!allowed) {
return;
} }
this.visitor.event({ this.visitor.event({
ec: eventCategory, ec: eventCategory,
ea: eventAction ea: eventAction,
...otherParams,
}).send() }).send()
resolve() } catch (err) {
}) logger.error(`Failed to track "${eventCategory}:${eventAction}"`, err)
} }
protected telemetryAllowed() {
const userPrefs = userStore.getPreferences()
return !!userPrefs.allowTelemetry
} }
} }
export const tracker = Tracker.getInstance<Tracker>(app || remote.app);

View File

@ -1,9 +1,16 @@
import ElectronStore from "electron-store" import type { ThemeId } from "../renderer/theme.store";
import * as version210Beta4 from "../migrations/user-store/2.1.0-beta.4" import semver from "semver"
import { action, observable, reaction, toJS } from "mobx";
import { BaseStore } from "./base-store";
import migrations from "../migrations/user-store"
import { getAppVersion } from "./utils/app-version"; import { getAppVersion } from "./utils/app-version";
import { getKubeConfigLocal, loadConfig } from "./kube-helpers";
import { tracker } from "./tracker";
export interface User { export interface UserStoreModel {
id?: string; lastSeenAppVersion: string;
seenContexts: string[];
preferences: UserPreferences;
} }
export interface UserPreferences { export interface UserPreferences {
@ -11,76 +18,93 @@ export interface UserPreferences {
colorTheme?: string; colorTheme?: string;
allowUntrustedCAs?: boolean; allowUntrustedCAs?: boolean;
allowTelemetry?: boolean; allowTelemetry?: boolean;
downloadMirror?: string; downloadMirror?: string | "default";
} }
export class UserStore { export class UserStore extends BaseStore<UserStoreModel> {
private static instance: UserStore; static readonly defaultTheme: ThemeId = "kontena-dark"
public store: ElectronStore;
private constructor() { private constructor() {
this.store = new ElectronStore({ super({
// @ts-ignore // configName: "lens-user-store", // todo: migrate from default "config.json"
// fixme: tests are failed without "projectVersion" migrations: migrations,
projectVersion: getAppVersion(),
migrations: {
"2.1.0-beta.4": version210Beta4.migration,
}
}); });
// track telemetry availability
reaction(() => this.preferences.allowTelemetry, allowed => {
tracker.event("telemetry", allowed ? "enabled" : "disabled");
});
// refresh new contexts
this.whenLoaded.then(this.refreshNewContexts);
reaction(() => this.seenContexts.size, this.refreshNewContexts);
} }
public lastSeenAppVersion() { @observable lastSeenAppVersion = "0.0.0"
return this.store.get('lastSeenAppVersion', "0.0.0") @observable seenContexts = observable.set<string>();
@observable newContexts = observable.set<string>();
@observable preferences: UserPreferences = {
allowTelemetry: true,
allowUntrustedCAs: false,
colorTheme: UserStore.defaultTheme,
downloadMirror: "default",
};
get isNewVersion() {
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
} }
public setLastSeenAppVersion(version: string) { @action
this.store.set('lastSeenAppVersion', version) resetTheme() {
this.preferences.colorTheme = UserStore.defaultTheme;
} }
public getSeenContexts(): Array<string> { @action
return this.store.get("seenContexts", []) saveLastSeenAppVersion() {
tracker.event("app", "whats-new-seen")
this.lastSeenAppVersion = getAppVersion();
} }
public storeSeenContext(newContexts: string[]) { protected refreshNewContexts = async () => {
const seenContexts = this.getSeenContexts().concat(newContexts) const kubeConfig = await getKubeConfigLocal();
// store unique contexts by casting array to set first if (kubeConfig) {
const newContextSet = new Set(seenContexts) this.newContexts.clear();
const allContexts = [...newContextSet] const localContexts = loadConfig(kubeConfig).getContexts();
this.store.set("seenContexts", allContexts) localContexts
return allContexts .filter(ctx => ctx.cluster)
} .filter(ctx => !this.seenContexts.has(ctx.name))
.forEach(ctx => this.newContexts.add(ctx.name));
public setPreferences(preferences: UserPreferences) {
this.store.set('preferences', preferences)
}
public getPreferences(): UserPreferences {
const prefs = this.store.get("preferences", {})
if (!prefs.colorTheme) {
prefs.colorTheme = "dark"
} }
if (!prefs.downloadMirror) {
prefs.downloadMirror = "default"
}
if (prefs.allowTelemetry === undefined) {
prefs.allowTelemetry = true
}
return prefs
} }
static getInstance(): UserStore { @action
if (!UserStore.instance) { markNewContextsAsSeen() {
UserStore.instance = new UserStore(); const { seenContexts, newContexts } = this;
} this.seenContexts.replace([...seenContexts, ...newContexts]);
return UserStore.instance; this.newContexts.clear();
} }
static resetInstance() { @action
UserStore.instance = null protected fromStore(data: Partial<UserStoreModel> = {}) {
const { lastSeenAppVersion, seenContexts = [], preferences } = data
if (lastSeenAppVersion) {
this.lastSeenAppVersion = lastSeenAppVersion;
}
this.seenContexts.replace(seenContexts);
Object.assign(this.preferences, preferences);
}
toJSON(): UserStoreModel {
const model: UserStoreModel = {
lastSeenAppVersion: this.lastSeenAppVersion,
seenContexts: Array.from(this.seenContexts),
preferences: this.preferences,
}
return toJS(model, {
recurseEverything: true,
})
} }
} }
const userStore: UserStore = UserStore.getInstance(); export const userStore = UserStore.getInstance<UserStore>();
export { userStore };

View File

@ -1,72 +0,0 @@
import mockFs from "mock-fs"
import { userStore, UserStore } from "./user-store"
// Console.log needs to be called before fs-mocks, see https://github.com/tschaub/mock-fs/issues/234
console.log("");
describe("for an empty config", () => {
beforeEach(() => {
UserStore.resetInstance()
const mockOpts = {
'tmp': {
'config.json': JSON.stringify({})
}
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("allows setting and retrieving lastSeenAppVersion", async () => {
userStore.setLastSeenAppVersion("1.2.3");
expect(userStore.lastSeenAppVersion()).toBe("1.2.3");
})
it("allows adding and listing seen contexts", async () => {
userStore.storeSeenContext(['foo'])
expect(userStore.getSeenContexts().length).toBe(1)
userStore.storeSeenContext(['foo', 'bar'])
const seenContexts = userStore.getSeenContexts()
expect(seenContexts.length).toBe(2) // check 'foo' isn't added twice
expect(seenContexts[0]).toBe('foo')
expect(seenContexts[1]).toBe('bar')
})
it("allows setting and getting preferences", async () => {
userStore.setPreferences({
httpsProxy: 'abcd://defg',
})
const storedPreferences = userStore.getPreferences()
expect(storedPreferences.httpsProxy).toBe('abcd://defg')
expect(storedPreferences.colorTheme).toBe('dark') // defaults to dark
userStore.setPreferences({
colorTheme: 'light'
})
expect(userStore.getPreferences().colorTheme).toBe('light')
})
})
describe("migrations", () => {
beforeEach(() => {
UserStore.resetInstance()
const mockOpts = {
'tmp': {
'config.json': JSON.stringify({
user: { username: 'foobar' },
preferences: { colorTheme: 'light' },
})
}
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("sets last seen app version to 0.0.0", async () => {
expect(userStore.lastSeenAppVersion()).toBe('0.0.0')
})
})

View File

@ -0,0 +1,102 @@
import mockFs from "mock-fs"
jest.mock("electron", () => {
return {
app: {
getVersion: () => '99.99.99',
getPath: () => 'tmp',
getLocale: () => 'en'
}
}
})
import { UserStore } from "./user-store"
import { SemVer } from "semver"
import electron from "electron"
describe("user store tests", () => {
describe("for an empty config", () => {
beforeEach(() => {
UserStore.resetInstance()
mockFs({ tmp: { 'config.json': "{}" } })
})
afterEach(() => {
mockFs.restore()
})
it("allows setting and retrieving lastSeenAppVersion", () => {
const us = UserStore.getInstance<UserStore>();
us.lastSeenAppVersion = "1.2.3";
expect(us.lastSeenAppVersion).toBe("1.2.3");
})
it("allows adding and listing seen contexts", () => {
const us = UserStore.getInstance<UserStore>();
us.seenContexts.add('foo')
expect(us.seenContexts.size).toBe(1)
us.seenContexts.add('foo')
us.seenContexts.add('bar')
expect(us.seenContexts.size).toBe(2) // check 'foo' isn't added twice
expect(us.seenContexts.has('foo')).toBe(true)
expect(us.seenContexts.has('bar')).toBe(true)
})
it("allows setting and getting preferences", () => {
const us = UserStore.getInstance<UserStore>();
us.preferences.httpsProxy = 'abcd://defg';
expect(us.preferences.httpsProxy).toBe('abcd://defg')
expect(us.preferences.colorTheme).toBe(UserStore.defaultTheme)
us.preferences.colorTheme = "light";
expect(us.preferences.colorTheme).toBe('light')
})
it("correctly resets theme to default value", () => {
const us = UserStore.getInstance<UserStore>();
us.preferences.colorTheme = "some other theme";
us.resetTheme();
expect(us.preferences.colorTheme).toBe(UserStore.defaultTheme);
})
it("correctly calculates if the last seen version is an old release", () => {
const us = UserStore.getInstance<UserStore>();
expect(us.isNewVersion).toBe(true);
us.lastSeenAppVersion = (new SemVer(electron.app.getVersion())).inc("major").format();
expect(us.isNewVersion).toBe(false);
})
})
describe("migrations", () => {
beforeEach(() => {
UserStore.resetInstance()
mockFs({
'tmp': {
'config.json': JSON.stringify({
user: { username: 'foobar' },
preferences: { colorTheme: 'light' },
lastSeenAppVersion: '1.2.3'
})
}
})
})
afterEach(() => {
mockFs.restore()
})
it("sets last seen app version to 0.0.0", () => {
const us = UserStore.getInstance<UserStore>();
expect(us.lastSeenAppVersion).toBe('0.0.0')
})
})
})

View File

@ -0,0 +1,5 @@
// Clone json-serializable object
export function cloneJsonObject<T = object>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}

View File

@ -0,0 +1,12 @@
// Setup variable in global scope (top-level object)
// Global type definition must be added separately to `mocks.d.ts` in form:
// declare const __globalName: any;
export function defineGlobal(propName: string, descriptor: PropertyDescriptor) {
const scope = typeof global !== "undefined" ? global : window;
if (scope.hasOwnProperty(propName)) {
console.info(`Global variable "${propName}" already exists. Skipping.`)
return;
}
Object.defineProperty(scope, propName, descriptor);
}

View File

@ -0,0 +1,6 @@
// Create random system name
export function getRandId({ prefix = "", suffix = "", sep = "_" } = {}) {
const randId = () => Math.random().toString(16).substr(2);
return [prefix, randId(), suffix].filter(s => s).join(sep);
}

View File

@ -3,3 +3,5 @@
export * from "./base64" export * from "./base64"
export * from "./camelCase" export * from "./camelCase"
export * from "./splitArray" export * from "./splitArray"
export * from "./getRandId"
export * from "./cloneJson"

View File

@ -1,16 +0,0 @@
import { app, remote } from "electron"
import { ensureDirSync, writeFileSync } from "fs-extra"
import * as path from "path"
// Writes kubeconfigs to "embedded" store, i.e. .../Lens/kubeconfigs/
export function writeEmbeddedKubeConfig(clusterId: string, kubeConfig: string): string {
// This can be called from main & renderer
const a = (app || remote.app)
const kubeConfigBase = path.join(a.getPath("userData"), "kubeconfigs")
ensureDirSync(kubeConfigBase)
const kubeConfigFile = path.join(kubeConfigBase, clusterId)
writeFileSync(kubeConfigFile, kubeConfig)
return kubeConfigFile
}

View File

@ -0,0 +1,28 @@
/**
* Narrowing class instances to the one.
* Use "private" or "protected" modifier for constructor (when overriding) to disallow "new" usage.
*
* @example
* const usersStore: UsersStore = UsersStore.getInstance();
*/
type Constructor<T = {}> = new (...args: any[]) => T;
class Singleton {
private static instances = new WeakMap<object, Singleton>();
// todo: improve types inferring
static getInstance<T>(...args: ConstructorParameters<Constructor<T>>): T {
if (!Singleton.instances.has(this)) {
Singleton.instances.set(this, Reflect.construct(this, args));
}
return Singleton.instances.get(this) as T;
}
static resetInstance() {
Singleton.instances.delete(this);
}
}
export { Singleton }
export default Singleton;

View File

@ -1,37 +1,39 @@
// App's common configuration for any process (main, renderer, build pipeline, etc.) // App's common configuration for any process (main, renderer, build pipeline, etc.)
import path from "path"; import path from "path";
import packageInfo from "../../package.json"
import { defineGlobal } from "./utils/defineGlobal";
// Temp
export const reactAppName = "app_react"
export const vueAppName = "app_vue"
// Flags
export const isMac = process.platform === "darwin" export const isMac = process.platform === "darwin"
export const isWindows = process.platform === "win32" export const isWindows = process.platform === "win32"
export const isDebugging = process.env.DEBUG === "true"; export const isDebugging = process.env.DEBUG === "true";
export const isProduction = process.env.NODE_ENV === "production" export const isProduction = process.env.NODE_ENV === "production"
export const isDevelopment = isDebugging || !isProduction; export const isDevelopment = isDebugging || !isProduction;
export const buildVersion = process.env.BUILD_VERSION;
export const isTestEnv = !!process.env.JEST_WORKER_ID; export const isTestEnv = !!process.env.JEST_WORKER_ID;
// Paths export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`
export const publicPath = "/build/"
// Webpack build paths
export const contextDir = process.cwd(); export const contextDir = process.cwd();
export const staticDir = path.join(contextDir, "static"); export const buildDir = path.join(contextDir, "static", publicPath);
export const outDir = path.join(contextDir, "out");
export const mainDir = path.join(contextDir, "src/main"); export const mainDir = path.join(contextDir, "src/main");
export const rendererDir = path.join(contextDir, "src/renderer"); export const rendererDir = path.join(contextDir, "src/renderer");
export const htmlTemplate = path.resolve(rendererDir, "template.html"); export const htmlTemplate = path.resolve(rendererDir, "template.html");
export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss"); export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
// Apis // Special runtime paths
export const staticProto = "static://" defineGlobal("__static", {
get() {
if (isDevelopment) {
return path.resolve(contextDir, "static");
}
return path.resolve(process.resourcesPath, "static")
}
})
export const apiPrefix = { // Apis
BASE: '/api', export const apiPrefix = "/api" // local router apis
KUBE_BASE: '/api-kube', // kubernetes cluster api export const apiKubePrefix = "/api-kube" // k8s cluster apis
KUBE_HELM: '/api-helm', // helm charts api
KUBE_RESOURCE_APPLIER: "/api-resource",
};
// Links // Links
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues"

View File

@ -1,78 +1,109 @@
import ElectronStore from "electron-store" import { action, computed, observable, toJS } from "mobx";
import { BaseStore } from "./base-store";
import { clusterStore } from "./cluster-store" import { clusterStore } from "./cluster-store"
export interface WorkspaceData { export type WorkspaceId = string;
id: string;
export interface WorkspaceStoreModel {
currentWorkspace?: WorkspaceId;
workspaces: Workspace[]
}
export interface Workspace {
id: WorkspaceId;
name: string; name: string;
description?: string; description?: string;
} }
export class Workspace implements WorkspaceData { export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
public id: string static readonly defaultId: WorkspaceId = "default"
public name: string
public description?: string
public constructor(data: WorkspaceData) {
Object.assign(this, data)
}
}
export class WorkspaceStore {
public static defaultId = "default"
private static instance: WorkspaceStore;
public store: ElectronStore;
private constructor() { private constructor() {
this.store = new ElectronStore({ super({
name: "lens-workspace-store" configName: "lens-workspace-store",
});
}
@observable currentWorkspaceId = WorkspaceStore.defaultId;
@observable workspaces = observable.map<WorkspaceId, Workspace>({
[WorkspaceStore.defaultId]: {
id: WorkspaceStore.defaultId,
name: "default"
}
});
@computed get currentWorkspace(): Workspace {
return this.getById(this.currentWorkspaceId);
}
@computed get workspacesList() {
return Array.from(this.workspaces.values());
}
isDefault(id: WorkspaceId) {
return id === WorkspaceStore.defaultId;
}
getById(id: WorkspaceId): Workspace {
return this.workspaces.get(id);
}
@action
setActive(id = WorkspaceStore.defaultId) {
if (!this.getById(id)) {
throw new Error(`workspace ${id} doesn't exist`);
}
this.currentWorkspaceId = id;
}
@action
saveWorkspace(workspace: Workspace) {
const id = workspace.id;
const existingWorkspace = this.getById(id);
if (existingWorkspace) {
Object.assign(existingWorkspace, workspace);
} else {
this.workspaces.set(id, workspace);
}
}
@action
removeWorkspace(id: WorkspaceId) {
const workspace = this.getById(id);
if (!workspace) return;
if (this.isDefault(id)) {
throw new Error("Cannot remove default workspace");
}
if (this.currentWorkspaceId === id) {
this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default
}
this.workspaces.delete(id);
clusterStore.removeByWorkspaceId(id)
}
@action
protected fromStore({ currentWorkspace, workspaces = [] }: WorkspaceStoreModel) {
if (currentWorkspace) {
this.currentWorkspaceId = currentWorkspace
}
if (workspaces.length) {
this.workspaces.clear();
workspaces.forEach(workspace => {
this.workspaces.set(workspace.id, workspace)
})
}
}
toJSON(): WorkspaceStoreModel {
return toJS({
currentWorkspace: this.currentWorkspaceId,
workspaces: this.workspacesList,
}, {
recurseEverything: true
}) })
} }
public storeWorkspace(workspace: WorkspaceData) {
const workspaces = this.getAllWorkspaces()
const index = workspaces.findIndex((w) => w.id === workspace.id)
if (index !== -1) {
workspaces[index] = workspace
} else {
workspaces.push(workspace)
}
this.store.set("workspaces", workspaces)
}
public removeWorkspace(workspace: Workspace) {
if (workspace.id === WorkspaceStore.defaultId) {
throw new Error("Cannot remove default workspace")
}
const workspaces = this.getAllWorkspaces()
const index = workspaces.findIndex((w) => w.id === workspace.id)
if (index !== -1) {
clusterStore.removeClustersByWorkspace(workspace.id)
workspaces.splice(index, 1)
this.store.set("workspaces", workspaces)
}
}
public getAllWorkspaces(): Array<Workspace> {
const workspacesData: WorkspaceData[] = this.store.get("workspaces", [])
return workspacesData.map((wsd) => new Workspace(wsd))
}
static getInstance(): WorkspaceStore {
if (!WorkspaceStore.instance) {
WorkspaceStore.instance = new WorkspaceStore()
}
return WorkspaceStore.instance
}
} }
const workspaceStore: WorkspaceStore = WorkspaceStore.getInstance() export const workspaceStore = WorkspaceStore.getInstance<WorkspaceStore>()
if (!workspaceStore.getAllWorkspaces().find( ws => ws.id === WorkspaceStore.defaultId)) {
workspaceStore.storeWorkspace({
id: WorkspaceStore.defaultId,
name: "default"
})
}
export { workspaceStore }

View File

@ -0,0 +1,128 @@
import mockFs from "mock-fs"
jest.mock("electron", () => {
return {
app: {
getVersion: () => '99.99.99',
getPath: () => 'tmp',
getLocale: () => 'en'
}
}
})
import { WorkspaceStore } from "./workspace-store"
describe("workspace store tests", () => {
describe("for an empty config", () => {
beforeEach(async () => {
WorkspaceStore.resetInstance()
mockFs({ tmp: { 'lens-workspace-store.json': "{}" } })
await WorkspaceStore.getInstance<WorkspaceStore>().load();
})
afterEach(() => {
mockFs.restore()
})
it("default workspace should always exist", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(ws.workspaces.size).toBe(1);
expect(ws.getById(WorkspaceStore.defaultId)).not.toBe(null);
})
it("cannot remove the default workspace", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(() => ws.removeWorkspace(WorkspaceStore.defaultId)).toThrowError("Cannot remove");
})
it("can update default workspace name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({
id: WorkspaceStore.defaultId,
name: "foobar",
});
expect(ws.currentWorkspace.name).toBe("foobar");
})
it("can add workspaces", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({
id: "123",
name: "foobar",
});
expect(ws.getById("123").name).toBe("foobar");
})
it("cannot set a non-existent workspace to be active", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(() => ws.setActive("abc")).toThrow("doesn't exist");
})
it("can set a existent workspace to be active", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({
id: "abc",
name: "foobar",
});
expect(() => ws.setActive("abc")).not.toThrowError();
})
it("can remove a workspace", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({
id: "123",
name: "foobar",
});
ws.saveWorkspace({
id: "1234",
name: "foobar 1",
});
ws.removeWorkspace("123");
expect(ws.workspaces.size).toBe(2);
})
})
describe("for a non-empty config", () => {
beforeEach(async () => {
WorkspaceStore.resetInstance()
mockFs({
tmp: {
'lens-workspace-store.json': JSON.stringify({
currentWorkspace: "abc",
workspaces: [{
id: "abc",
name: "test"
}, {
id: "default",
name: "default"
}]
})
}
})
await WorkspaceStore.getInstance<WorkspaceStore>().load();
})
afterEach(() => {
mockFs.restore()
})
it("doesn't revert to default workspace", async () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(ws.currentWorkspaceId).toBe("abc");
})
})
})

View File

@ -27,7 +27,8 @@ export interface MetricsConfiguration {
} }
export class MetricsFeature extends Feature { export class MetricsFeature extends Feature {
name = 'metrics'; static id = 'metrics'
name = MetricsFeature.id;
latestVersion = "v2.17.2-lens1" latestVersion = "v2.17.2-lens1"
config: MetricsConfiguration = { config: MetricsConfiguration = {
@ -51,58 +52,49 @@ export class MetricsFeature extends Feature {
storageClass: null, storageClass: null,
}; };
async install(cluster: Cluster): Promise<boolean> { async install(cluster: Cluster): Promise<void> {
// Check if there are storageclasses // Check if there are storageclasses
const storageClient = cluster.proxyKubeconfig().makeApiClient(k8s.StorageV1Api) const storageClient = cluster.getProxyKubeconfig().makeApiClient(k8s.StorageV1Api)
const scs = await storageClient.listStorageClass(); const scs = await storageClient.listStorageClass();
scs.body.items.forEach(sc => {
if(sc.metadata.annotations && this.config.persistence.enabled = scs.body.items.some(sc => (
(sc.metadata.annotations['storageclass.kubernetes.io/is-default-class'] === 'true' || sc.metadata.annotations['storageclass.beta.kubernetes.io/is-default-class'] === 'true')) { sc.metadata?.annotations?.['storageclass.kubernetes.io/is-default-class'] === 'true' ||
this.config.persistence.enabled = true; sc.metadata?.annotations?.['storageclass.beta.kubernetes.io/is-default-class'] === 'true'
} ));
});
return super.install(cluster) return super.install(cluster)
} }
async upgrade(cluster: Cluster): Promise<boolean> { async upgrade(cluster: Cluster): Promise<void> {
return this.install(cluster) return this.install(cluster)
} }
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> { async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
return new Promise<FeatureStatus>( async (resolve, reject) => { const client = kc.makeApiClient(AppsV1Api)
const client = kc.makeApiClient(AppsV1Api) const status: FeatureStatus = {
const status: FeatureStatus = { currentVersion: null,
currentVersion: null, installed: false,
installed: false, latestVersion: this.latestVersion,
latestVersion: this.latestVersion, canUpgrade: false, // Dunno yet
canUpgrade: false, // Dunno yet };
};
try { try {
const prometheus = (await client.readNamespacedStatefulSet('prometheus', 'lens-metrics')).body;
const prometheus = (await client.readNamespacedStatefulSet('prometheus', 'lens-metrics')).body; status.installed = true;
status.installed = true; status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1];
status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; status.canUpgrade = semver.lt(status.currentVersion, this.latestVersion, true);
status.canUpgrade = semver.lt(status.currentVersion, this.latestVersion, true); } catch {
resolve(status) // ignore error
} catch(error) { }
resolve(status)
} return status;
});
} }
async uninstall(cluster: Cluster): Promise<boolean> { async uninstall(cluster: Cluster): Promise<void> {
return new Promise<boolean>(async (resolve, reject) => { const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api)
const rbacClient = cluster.proxyKubeconfig().makeApiClient(RbacAuthorizationV1Api)
try { await this.deleteNamespace(cluster.getProxyKubeconfig(), "lens-metrics")
await this.deleteNamespace(cluster.proxyKubeconfig(), "lens-metrics") await rbacClient.deleteClusterRole("lens-prometheus");
await rbacClient.deleteClusterRole("lens-prometheus"); await rbacClient.deleteClusterRoleBinding("lens-prometheus");
await rbacClient.deleteClusterRoleBinding("lens-prometheus");
resolve(true);
} catch(error) {
reject(error);
}
});
} }
} }

View File

@ -3,48 +3,42 @@ import {KubeConfig, RbacAuthorizationV1Api} from "@kubernetes/client-node"
import { Cluster } from "../main/cluster" import { Cluster } from "../main/cluster"
export class UserModeFeature extends Feature { export class UserModeFeature extends Feature {
name = 'user-mode'; static id = 'user-mode'
name = UserModeFeature.id;
latestVersion = "v2.0.0" latestVersion = "v2.0.0"
async install(cluster: Cluster): Promise<boolean> { async install(cluster: Cluster): Promise<void> {
return super.install(cluster) return super.install(cluster)
} }
async upgrade(cluster: Cluster): Promise<boolean> { async upgrade(cluster: Cluster): Promise<void> {
return true return;
} }
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> { async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
return new Promise<FeatureStatus>( async (resolve, reject) => { const client = kc.makeApiClient(RbacAuthorizationV1Api)
const client = kc.makeApiClient(RbacAuthorizationV1Api) const status: FeatureStatus = {
const status: FeatureStatus = { currentVersion: null,
currentVersion: null, installed: false,
installed: false, latestVersion: this.latestVersion,
latestVersion: this.latestVersion, canUpgrade: false, // Dunno yet
canUpgrade: false, // Dunno yet };
};
try { try {
await client.readClusterRoleBinding("lens-user") await client.readClusterRoleBinding("lens-user")
status.installed = true; status.installed = true;
status.currentVersion = this.latestVersion status.currentVersion = this.latestVersion;
status.canUpgrade = false status.canUpgrade = false;
resolve(status) } catch {
} catch(error) { // ignore error
resolve(status) }
}
}); return status;
} }
async uninstall(cluster: Cluster): Promise<boolean> { async uninstall(cluster: Cluster): Promise<void> {
return new Promise<boolean>(async (resolve, reject) => { const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api)
const rbacClient = cluster.proxyKubeconfig().makeApiClient(RbacAuthorizationV1Api) await rbacClient.deleteClusterRole("lens-user");
try { await rbacClient.deleteClusterRoleBinding("lens-user");
await rbacClient.deleteClusterRole("lens-user");
await rbacClient.deleteClusterRoleBinding("lens-user");
resolve(true);
} catch(error) {
reject(error);
}
});
} }
} }

View File

@ -1,281 +1,65 @@
import { KubeConfig } from "@kubernetes/client-node" import "../common/cluster-ipc";
import { PromiseIpc } from "electron-promise-ipc" import type http from "http"
import http from "http" import { autorun } from "mobx";
import { Cluster, ClusterBaseInfo } from "./cluster" import { ClusterId, clusterStore } from "../common/cluster-store"
import { clusterStore } from "../common/cluster-store" import { Cluster } from "./cluster"
import * as k8s from "./k8s" import logger from "./logger";
import logger from "./logger" import { apiKubePrefix } from "../common/vars";
import { LensProxy } from "./proxy"
import { app } from "electron"
import path from "path"
import { promises } from "fs"
import { ensureDir } from "fs-extra"
import filenamify from "filenamify"
import { v4 as uuid } from "uuid"
import { apiPrefix } from "../common/vars";
export type FeatureInstallRequest = {
name: string;
clusterId: string;
config: any;
}
export type FeatureInstallResponse = {
success: boolean;
message: string;
}
export type ClusterIconUpload = {
path: string;
name: string;
clusterId: string;
}
export class ClusterManager { export class ClusterManager {
public static readonly clusterIconDir = path.join(app.getPath("userData"), "icons") constructor(public readonly port: number) {
protected promiseIpc: any // auto-init clusters
protected proxyServer: LensProxy autorun(() => {
protected port: number clusterStore.clusters.forEach(cluster => {
protected clusters: Map<string, Cluster>; if (!cluster.initialized) {
logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta());
constructor(clusters: Cluster[], port: number) { cluster.init(port);
this.promiseIpc = new PromiseIpc({ timeout: 2000 }) }
this.port = port });
this.clusters = new Map()
clusters.forEach((clusterInfo) => {
try {
const kc = this.loadKubeConfig(clusterInfo.kubeConfigPath)
const cluster = new Cluster({
id: clusterInfo.id,
port: this.port,
kubeConfigPath: clusterInfo.kubeConfigPath,
contextName: clusterInfo.contextName,
preferences: clusterInfo.preferences,
workspace: clusterInfo.workspace
})
cluster.init(kc)
logger.debug(`Created cluster[id: ${ cluster.id }] for context ${ cluster.contextName }`)
this.clusters.set(cluster.id, cluster)
} catch(error) {
logger.error(`Error while initializing ${clusterInfo.contextName}`)
}
}); });
logger.debug("clusters after constructor:" + this.clusters.size)
this.listenEvents()
}
public getClusters() { // auto-stop removed clusters
return this.clusters.values() autorun(() => {
} const removedClusters = Array.from(clusterStore.removedClusters.values());
if (removedClusters.length > 0) {
public getCluster(id: string) { const meta = removedClusters.map(cluster => cluster.getMeta());
return this.clusters.get(id) logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta);
} removedClusters.forEach(cluster => cluster.disconnect());
clusterStore.removedClusters.clear();
public stop() {
const clusters = Array.from(this.getClusters())
clusters.map(cluster => cluster.stopServer())
}
protected loadKubeConfig(configPath: string): KubeConfig {
const kc = new KubeConfig();
kc.loadFromFile(configPath)
return kc;
}
protected async addNewCluster(clusterData: ClusterBaseInfo): Promise<Cluster> {
return new Promise(async (resolve, reject) => {
try {
const kc = this.loadKubeConfig(clusterData.kubeConfigPath)
k8s.validateConfig(kc)
kc.setCurrentContext(clusterData.contextName)
const cluster = new Cluster({
id: uuid(),
port: this.port,
kubeConfigPath: clusterData.kubeConfigPath,
contextName: clusterData.contextName,
preferences: clusterData.preferences,
workspace: clusterData.workspace
})
cluster.init(kc)
cluster.save()
this.clusters.set(cluster.id, cluster)
resolve(cluster)
} catch(error) {
logger.error(error)
reject(error)
} }
}, {
delay: 250
}); });
} }
protected listenEvents() { stop() {
this.promiseIpc.on("addCluster", async (clusterData: ClusterBaseInfo) => { clusterStore.clusters.forEach((cluster: Cluster) => {
logger.debug(`IPC: addCluster`) cluster.disconnect();
const cluster = await this.addNewCluster(clusterData) })
return {
addedCluster: cluster.toClusterInfo(),
allClusters: Array.from(this.getClusters()).map((cluster: Cluster) => cluster.toClusterInfo())
}
});
this.promiseIpc.on("getClusters", async (workspaceId: string) => {
logger.debug(`IPC: getClusters, workspace ${workspaceId}`)
const workspaceClusters = Array.from(this.getClusters()).filter((cluster) => cluster.workspace === workspaceId)
return workspaceClusters.map((cluster: Cluster) => cluster.toClusterInfo())
});
this.promiseIpc.on("getCluster", async (id: string) => {
logger.debug(`IPC: getCluster`)
const cluster = this.getCluster(id)
if (cluster) {
await cluster.refreshCluster()
return cluster.toClusterInfo()
} else {
return null
}
});
this.promiseIpc.on("installFeature", async (installReq: FeatureInstallRequest) => {
logger.debug(`IPC: installFeature for ${installReq.name}`)
const cluster = this.clusters.get(installReq.clusterId)
try {
await cluster.installFeature(installReq.name, installReq.config)
return {success: true, message: ""}
} catch(error) {
return {success: false, message: error}
}
});
this.promiseIpc.on("upgradeFeature", async (installReq: FeatureInstallRequest) => {
logger.debug(`IPC: upgradeFeature for ${installReq.name}`)
const cluster = this.clusters.get(installReq.clusterId)
try {
await cluster.upgradeFeature(installReq.name, installReq.config)
return {success: true, message: ""}
} catch(error) {
return {success: false, message: error}
}
});
this.promiseIpc.on("uninstallFeature", async (installReq: FeatureInstallRequest) => {
logger.debug(`IPC: uninstallFeature for ${installReq.name}`)
const cluster = this.clusters.get(installReq.clusterId)
await cluster.uninstallFeature(installReq.name)
return {success: true, message: ""}
});
this.promiseIpc.on("saveClusterIcon", async (fileUpload: ClusterIconUpload) => {
logger.debug(`IPC: saveClusterIcon for ${fileUpload.clusterId}`)
const cluster = this.getCluster(fileUpload.clusterId)
if (!cluster) {
return {success: false, message: "Cluster not found"}
}
try {
const clusterIcon = await this.uploadClusterIcon(cluster, fileUpload.name, fileUpload.path)
clusterStore.reloadCluster(cluster);
if(!cluster.preferences) cluster.preferences = {};
cluster.preferences.icon = clusterIcon
clusterStore.storeCluster(cluster);
return {success: true, cluster: cluster.toClusterInfo(), message: ""}
} catch(error) {
return {success: false, message: error}
}
});
this.promiseIpc.on("resetClusterIcon", async (id: string) => {
logger.debug(`IPC: resetClusterIcon`)
const cluster = this.getCluster(id)
if (cluster && cluster.preferences) {
cluster.preferences.icon = null;
clusterStore.storeCluster(cluster)
return {success: true, cluster: cluster.toClusterInfo(), message: ""}
} else {
return {success: false, message: "Cluster not found"}
}
});
this.promiseIpc.on("refreshCluster", async (clusterId: string) => {
const cluster = this.clusters.get(clusterId)
await cluster.refreshCluster()
return cluster.toClusterInfo()
});
this.promiseIpc.on("stopCluster", (clusterId: string) => {
logger.debug(`IPC: stopCluster: ${clusterId}`)
const cluster = this.clusters.get(clusterId)
if (cluster) {
cluster.stopServer()
return true
}
return false
});
this.promiseIpc.on("removeCluster", (ctx: string) => {
logger.debug(`IPC: removeCluster: ${ctx}`)
return this.removeCluster(ctx).map((cluster: Cluster) => cluster.toClusterInfo())
});
this.promiseIpc.on("clusterStored", (clusterId: string) => {
logger.debug(`IPC: clusterStored: ${clusterId}`)
const cluster = this.clusters.get(clusterId)
if (cluster) {
clusterStore.reloadCluster(cluster);
cluster.stopServer()
}
});
this.promiseIpc.on("preferencesSaved", () => {
logger.debug(`IPC: preferencesSaved`)
this.clusters.forEach((cluster) => {
cluster.stopServer()
})
});
this.promiseIpc.on("getClusterEvents", async (clusterId: string) => {
const cluster = this.clusters.get(clusterId)
return cluster.getEventCount();
});
} }
public removeCluster(id: string): Cluster[] { protected getCluster(id: ClusterId) {
const cluster = this.clusters.get(id) return clusterStore.getById(id);
if (cluster) {
cluster.stopServer()
clusterStore.removeCluster(cluster.id);
this.clusters.delete(cluster.id)
}
return Array.from(this.clusters.values())
} }
public getClusterForRequest(req: http.IncomingMessage): Cluster { getClusterForRequest(req: http.IncomingMessage): Cluster {
let cluster: Cluster = null let cluster: Cluster = null
// lens-server is connecting to 127.0.0.1:<port>/<uid> // lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) { if (req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1] const clusterId = req.url.split("/")[1]
if (clusterId) { if (clusterId) {
cluster = this.clusters.get(clusterId) cluster = this.getCluster(clusterId)
if (cluster) { if (cluster) {
// we need to swap path prefix so that request is proxied to kube api // we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiPrefix.KUBE_BASE) req.url = req.url.replace(`/${clusterId}`, apiKubePrefix)
} }
} }
} else { } else {
const id = req.headers.host.split(".")[0] const id = req.headers.host.split(".")[0]
cluster = this.clusters.get(id) cluster = this.getCluster(id)
} }
return cluster; return cluster;
} }
protected async uploadClusterIcon(cluster: Cluster, fileName: string, src: string): Promise<string> {
await ensureDir(ClusterManager.clusterIconDir)
fileName = filenamify(cluster.contextName + "-" + fileName)
const dest = path.join(ClusterManager.clusterIconDir, fileName)
await promises.copyFile(src, dest)
return "store:///icons/" + fileName
}
} }

View File

@ -1,217 +1,267 @@
import type { ClusterId, ClusterModel, ClusterPreferences } from "../common/cluster-store"
import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store";
import type { FeatureStatusMap } from "./feature"
import { action, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars";
import { broadcastIpc } from "../common/ipc";
import { ContextHandler } from "./context-handler" import { ContextHandler } from "./context-handler"
import { FeatureStatusMap } from "./feature"
import * as k8s from "./k8s"
import { clusterStore } from "../common/cluster-store"
import logger from "./logger"
import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node" import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"
import * as fm from "./feature-manager";
import { Kubectl } from "./kubectl"; import { Kubectl } from "./kubectl";
import { KubeconfigManager } from "./kubeconfig-manager" import { KubeconfigManager } from "./kubeconfig-manager"
import { PromiseIpc } from "electron-promise-ipc" import { getNodeWarningConditions, loadConfig, podHasIssues } from "../common/kube-helpers"
import request from "request-promise-native" import { getFeatures, installFeature, uninstallFeature, upgradeFeature } from "./feature-manager";
import { apiPrefix } from "../common/vars"; import request, { RequestPromiseOptions } from "request-promise-native"
import { apiResources } from "../common/rbac";
import logger from "./logger"
enum ClusterStatus { export enum ClusterStatus {
AccessGranted = 2, AccessGranted = 2,
AccessDenied = 1, AccessDenied = 1,
Offline = 0 Offline = 0
} }
export interface ClusterBaseInfo { export interface ClusterState extends ClusterModel {
id: string; initialized: boolean;
kubeConfigPath: string;
contextName: string;
preferences?: ClusterPreferences;
port?: number;
workspace?: string;
}
export interface ClusterInfo extends ClusterBaseInfo {
url: string;
apiUrl: string; apiUrl: string;
online?: boolean; online: boolean;
accessible?: boolean; disconnected: boolean;
failureReason?: string; accessible: boolean;
nodes?: number; failureReason: string;
version?: string; nodes: number;
distribution?: string; eventCount: number;
isAdmin?: boolean; version: string;
features?: FeatureStatusMap; distribution: string;
kubeCtl?: Kubectl; isAdmin: boolean;
contextName: string; allowedNamespaces: string[]
allowedResources: string[]
features: FeatureStatusMap;
} }
export type ClusterPreferences = { export class Cluster implements ClusterModel {
terminalCWD?: string; public id: ClusterId;
clusterName?: string; public frameId: number;
prometheus?: {
namespace: string;
service: string;
port: number;
prefix: string;
};
prometheusProvider?: {
type: string;
};
icon?: string;
httpsProxy?: string;
}
export class Cluster implements ClusterInfo {
public id: string;
public workspace: string;
public contextHandler: ContextHandler;
public contextName: string;
public url: string;
public port: number;
public apiUrl: string;
public online: boolean;
public accessible: boolean;
public failureReason: string;
public nodes: number;
public version: string;
public distribution: string;
public isAdmin: boolean;
public features: FeatureStatusMap;
public kubeCtl: Kubectl public kubeCtl: Kubectl
public kubeConfigPath: string; public contextHandler: ContextHandler;
public eventCount: number;
public preferences: ClusterPreferences;
protected eventPoller: NodeJS.Timeout;
protected promiseIpc = new PromiseIpc({ timeout: 2000 })
protected kubeconfigManager: KubeconfigManager; protected kubeconfigManager: KubeconfigManager;
protected eventDisposers: Function[] = [];
constructor(clusterInfo: ClusterBaseInfo) { whenInitialized = when(() => this.initialized);
if (clusterInfo) Object.assign(this, clusterInfo)
if (!this.preferences) this.preferences = {} @observable initialized = false;
@observable contextName: string;
@observable workspace: WorkspaceId;
@observable kubeConfigPath: string;
@observable apiUrl: string; // cluster server url
@observable kubeProxyUrl: string; // lens-proxy to kube-api url
@observable online: boolean;
@observable accessible: boolean;
@observable disconnected: boolean;
@observable failureReason: string;
@observable nodes = 0;
@observable version: string;
@observable distribution = "unknown";
@observable isAdmin = false;
@observable eventCount = 0;
@observable preferences: ClusterPreferences = {};
@observable features: FeatureStatusMap = {};
@observable allowedNamespaces: string[] = [];
@observable allowedResources: string[] = [];
@computed get available() {
return this.accessible && !this.disconnected;
} }
public proxyKubeconfigPath() { constructor(model: ClusterModel) {
return this.kubeconfigManager.getPath() this.updateModel(model);
} }
public proxyKubeconfig() { @action
const kc = new KubeConfig() updateModel(model: ClusterModel) {
kc.loadFromFile(this.proxyKubeconfigPath()) Object.assign(this, model);
return kc this.apiUrl = this.getKubeconfig().getCurrentCluster()?.server;
this.contextName = this.contextName || this.preferences.clusterName;
} }
public async init(kc: KubeConfig) { @action
this.apiUrl = kc.getCurrentCluster().server async init(port: number) {
this.contextHandler = new ContextHandler(kc, this) try {
await this.contextHandler.init() // So we get the proxy port reserved this.contextHandler = new ContextHandler(this);
this.kubeconfigManager = new KubeconfigManager(this) this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler);
this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`;
this.url = this.contextHandler.url this.initialized = true;
} logger.info(`[CLUSTER]: "${this.contextName}" init success`, {
id: this.id,
public stopServer() { context: this.contextName,
this.contextHandler.stopServer() apiUrl: this.apiUrl
clearInterval(this.eventPoller); });
} } catch (err) {
logger.error(`[CLUSTER]: init failed: ${err}`, {
public async installFeature(name: string, config: any) { id: this.id,
await fm.installFeature(name, this, config) error: err,
return this.refreshCluster() });
}
public async upgradeFeature(name: string, config: any) {
await fm.upgradeFeature(name, this, config)
return this.refreshCluster()
}
public async uninstallFeature(name: string) {
await fm.uninstallFeature(name, this)
return this.refreshCluster()
}
public async refreshCluster() {
clusterStore.reloadCluster(this)
this.contextHandler.setClusterPreferences(this.preferences)
const connectionStatus = await this.getConnectionStatus()
this.accessible = connectionStatus == ClusterStatus.AccessGranted;
this.online = connectionStatus > ClusterStatus.Offline;
if (this.accessible) {
this.distribution = this.detectKubernetesDistribution(this.version)
this.features = await fm.getFeatures(this)
this.isAdmin = await this.isClusterAdmin()
this.nodes = await this.getNodeCount()
this.kubeCtl = new Kubectl(this.version)
this.kubeCtl.ensureKubectl()
} }
}
protected bindEvents() {
logger.info(`[CLUSTER]: bind events`, this.getMeta());
const refreshTimer = setInterval(() => this.online && this.refresh(), 30000); // every 30s
const refreshEventsTimer = setInterval(() => this.online && this.refreshEvents(), 3000); // every 3s
this.eventDisposers.push(
reaction(this.getState, this.pushState),
() => clearInterval(refreshTimer),
() => clearInterval(refreshEventsTimer),
);
}
protected unbindEvents() {
logger.info(`[CLUSTER]: unbind events`, this.getMeta());
this.eventDisposers.forEach(dispose => dispose());
this.eventDisposers.length = 0;
}
async activate() {
logger.info(`[CLUSTER]: activate`, this.getMeta());
await this.whenInitialized;
if (!this.eventDisposers.length) {
this.bindEvents();
}
if (this.disconnected) {
await this.reconnect();
}
await this.refresh();
return this.pushState();
}
async reconnect() {
logger.info(`[CLUSTER]: reconnect`, this.getMeta());
this.contextHandler.stopServer();
await this.contextHandler.ensureServer();
this.disconnected = false;
}
@action
disconnect() {
logger.info(`[CLUSTER]: disconnect`, this.getMeta());
this.unbindEvents();
this.contextHandler.stopServer();
this.disconnected = true;
this.online = false;
this.accessible = false;
this.pushState();
}
@action
async refresh() {
logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.refreshConnectionStatus(); // refresh "version", "online", etc.
if (this.accessible) {
this.kubeCtl = new Kubectl(this.version)
this.distribution = this.detectKubernetesDistribution(this.version)
const [features, isAdmin, nodesCount] = await Promise.all([
getFeatures(this),
this.isClusterAdmin(),
this.getNodeCount(),
this.kubeCtl.ensureKubectl()
]);
this.features = features;
this.isAdmin = isAdmin;
this.nodes = nodesCount;
await Promise.all([
this.refreshEvents(),
this.refreshAllowedResources(),
]);
}
}
@action
async refreshConnectionStatus() {
const connectionStatus = await this.getConnectionStatus();
this.online = connectionStatus > ClusterStatus.Offline;
this.accessible = connectionStatus == ClusterStatus.AccessGranted;
}
@action
async refreshAllowedResources() {
this.allowedNamespaces = await this.getAllowedNamespaces();
this.allowedResources = await this.getAllowedResources();
}
@action
async refreshEvents() {
this.eventCount = await this.getEventCount(); this.eventCount = await this.getEventCount();
} }
public getPrometheusApiPrefix() { protected getKubeconfig(): KubeConfig {
if (!this.preferences.prometheus?.prefix) { return loadConfig(this.kubeConfigPath);
return ""
}
return this.preferences.prometheus.prefix
} }
public save() { getProxyKubeconfig(): KubeConfig {
clusterStore.storeCluster(this) return loadConfig(this.getProxyKubeconfigPath());
} }
public toClusterInfo(): ClusterInfo { getProxyKubeconfigPath(): string {
return { return this.kubeconfigManager.getPath()
id: this.id,
workspace: this.workspace,
url: this.url,
contextName: this.contextName,
apiUrl: this.apiUrl,
online: this.online,
accessible: this.accessible,
failureReason: this.failureReason,
nodes: this.nodes,
version: this.version,
distribution: this.distribution,
isAdmin: this.isAdmin,
features: this.features,
kubeCtl: this.kubeCtl,
kubeConfigPath: this.kubeConfigPath,
preferences: this.preferences
}
} }
protected async k8sRequest(path: string, opts?: request.RequestPromiseOptions) { async installFeature(name: string, config: any) {
const options = Object.assign({ return installFeature(name, this, config)
}
async upgradeFeature(name: string, config: any) {
return upgradeFeature(name, this, config)
}
async uninstallFeature(name: string) {
return uninstallFeature(name, this)
}
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
const apiUrl = this.kubeProxyUrl + path;
return request(apiUrl, {
json: true, json: true,
timeout: 10000 timeout: 5000,
}, (opts || {})) ...options,
if (!options.headers) { options.headers = {} } headers: {
options.headers.host = `${this.id}.localhost:${this.port}` Host: `${this.id}.${new URL(this.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest()
return request(`http://127.0.0.1:${this.port}${apiPrefix.KUBE_BASE}${path}`, options) ...(options.headers || {}),
},
})
} }
protected async getConnectionStatus() { 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,
qs: queryParams,
})
}
protected async getConnectionStatus(): Promise<ClusterStatus> {
try { try {
const response = await this.k8sRequest("/version") const response = await this.k8sRequest("/version")
this.version = response.gitVersion this.version = response.gitVersion
this.failureReason = null this.failureReason = null
return ClusterStatus.AccessGranted; return ClusterStatus.AccessGranted;
} catch (error) { } catch (error) {
logger.error(`Failed to connect to cluster ${this.contextName}: ${JSON.stringify(error)}`) logger.error(`Failed to connect cluster "${this.contextName}": ${error}`)
if (error.statusCode) { if (error.statusCode) {
if (error.statusCode >= 400 && error.statusCode < 500) { if (error.statusCode >= 400 && error.statusCode < 500) {
this.failureReason = "Invalid credentials"; this.failureReason = "Invalid credentials";
return ClusterStatus.AccessDenied; return ClusterStatus.AccessDenied;
} } else {
else {
this.failureReason = error.error || error.message; this.failureReason = error.error || error.message;
return ClusterStatus.Offline; return ClusterStatus.Offline;
} }
} } else if (error.failed === true) {
else if (error.failed === true) {
if (error.timedOut === true) { if (error.timedOut === true) {
this.failureReason = "Connection timed out"; this.failureReason = "Connection timed out";
return ClusterStatus.Offline; return ClusterStatus.Offline;
} } else {
else {
this.failureReason = "Failed to fetch credentials"; this.failureReason = "Failed to fetch credentials";
return ClusterStatus.AccessDenied; return ClusterStatus.AccessDenied;
} }
@ -221,22 +271,22 @@ export class Cluster implements ClusterInfo {
} }
} }
public async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> { async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
const authApi = this.proxyKubeconfig().makeApiClient(AuthorizationV1Api) const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api)
try { try {
const accessReview = await authApi.createSelfSubjectAccessReview({ const accessReview = await authApi.createSelfSubjectAccessReview({
apiVersion: "authorization.k8s.io/v1", apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview", kind: "SelfSubjectAccessReview",
spec: { resourceAttributes } spec: { resourceAttributes }
}) })
return accessReview.body.status.allowed === true return accessReview.body.status.allowed
} catch (error) { } catch (error) {
logger.error(`failed to request selfSubjectAccessReview: ${error.message}`) logger.error(`failed to request selfSubjectAccessReview: ${error}`)
return false return false
} }
} }
protected async isClusterAdmin(): Promise<boolean> { async isClusterAdmin(): Promise<boolean> {
return this.canI({ return this.canI({
namespace: "kube-system", namespace: "kube-system",
resource: "*", resource: "*",
@ -245,32 +295,17 @@ export class Cluster implements ClusterInfo {
} }
protected detectKubernetesDistribution(kubernetesVersion: string): string { protected detectKubernetesDistribution(kubernetesVersion: string): string {
if (kubernetesVersion.includes("gke")) { if (kubernetesVersion.includes("gke")) return "gke"
return "gke" if (kubernetesVersion.includes("eks")) return "eks"
} if (kubernetesVersion.includes("IKS")) return "iks"
else if (kubernetesVersion.includes("eks")) { if (this.apiUrl.endsWith("azmk8s.io")) return "aks"
return "eks" if (this.apiUrl.endsWith("k8s.ondigitalocean.com")) return "digitalocean"
} if (this.contextName.startsWith("minikube")) return "minikube"
else if (kubernetesVersion.includes("IKS")) { if (kubernetesVersion.includes("+")) return "custom"
return "iks"
}
else if (this.apiUrl.endsWith("azmk8s.io")) {
return "aks"
}
else if (this.apiUrl.endsWith("k8s.ondigitalocean.com")) {
return "digitalocean"
}
else if (this.contextHandler.contextName.startsWith("minikube")) {
return "minikube"
}
else if (kubernetesVersion.includes("+")) {
return "custom"
}
return "vanilla" return "vanilla"
} }
protected async getNodeCount() { protected async getNodeCount(): Promise<number> {
try { try {
const response = await this.k8sRequest("/api/v1/nodes") const response = await this.k8sRequest("/api/v1/nodes")
return response.items.length return response.items.length
@ -280,11 +315,11 @@ export class Cluster implements ClusterInfo {
} }
} }
public async getEventCount(): Promise<number> { protected async getEventCount(): Promise<number> {
if (!this.isAdmin) { if (!this.isAdmin) {
return 0; return 0;
} }
const client = this.proxyKubeconfig().makeApiClient(CoreV1Api); const client = this.getProxyKubeconfig().makeApiClient(CoreV1Api);
try { try {
const response = await client.listEventForAllNamespaces(false, null, null, null, 1000); const response = await client.listEventForAllNamespaces(false, null, null, null, 1000);
const uniqEventSources = new Set(); const uniqEventSources = new Set();
@ -294,20 +329,19 @@ export class Cluster implements ClusterInfo {
try { try {
const pod = (await client.readNamespacedPod(w.involvedObject.name, w.involvedObject.namespace)).body; const pod = (await client.readNamespacedPod(w.involvedObject.name, w.involvedObject.namespace)).body;
logger.debug(`checking pod ${w.involvedObject.namespace}/${w.involvedObject.name}`) logger.debug(`checking pod ${w.involvedObject.namespace}/${w.involvedObject.name}`)
if (k8s.podHasIssues(pod)) { if (podHasIssues(pod)) {
uniqEventSources.add(w.involvedObject.uid); uniqEventSources.add(w.involvedObject.uid);
} }
} catch (err) { } catch (err) {
} }
} } else {
else {
uniqEventSources.add(w.involvedObject.uid); uniqEventSources.add(w.involvedObject.uid);
} }
} }
let nodeNotificationCount = 0; let nodeNotificationCount = 0;
const nodes = (await client.listNode()).body.items; const nodes = (await client.listNode()).body.items;
nodes.map(n => { nodes.map(n => {
nodeNotificationCount = nodeNotificationCount + k8s.getNodeWarningConditions(n).length nodeNotificationCount = nodeNotificationCount + getNodeWarningConditions(n).length
}); });
return uniqEventSources.size + nodeNotificationCount; return uniqEventSources.size + nodeNotificationCount;
} catch (error) { } catch (error) {
@ -315,4 +349,105 @@ export class Cluster implements ClusterInfo {
return 0; return 0;
} }
} }
toJSON(): ClusterModel {
const model: ClusterModel = {
id: this.id,
contextName: this.contextName,
kubeConfigPath: this.kubeConfigPath,
workspace: this.workspace,
preferences: this.preferences,
};
return toJS(model, {
recurseEverything: true
})
}
// serializable cluster-state used for sync btw main <-> renderer
getState = (): ClusterState => {
const state: ClusterState = {
...this.toJSON(),
initialized: this.initialized,
apiUrl: this.apiUrl,
online: this.online,
disconnected: this.disconnected,
accessible: this.accessible,
failureReason: this.failureReason,
nodes: this.nodes,
version: this.version,
distribution: this.distribution,
isAdmin: this.isAdmin,
features: this.features,
eventCount: this.eventCount,
allowedNamespaces: this.allowedNamespaces,
allowedResources: this.allowedResources,
};
return toJS(state, {
recurseEverything: true
})
}
pushState = (state = this.getState()): ClusterState => {
logger.debug(`[CLUSTER]: push-state`, state);
broadcastIpc({
channel: "cluster:state",
frameId: this.frameId,
args: [state],
});
return state;
}
// get cluster system meta, e.g. use in "logger"
getMeta() {
return {
id: this.id,
name: this.contextName,
initialized: this.initialized,
online: this.online,
accessible: this.accessible,
disconnected: this.disconnected,
}
}
protected async getAllowedNamespaces() {
const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api)
try {
const namespaceList = await api.listNamespace()
const nsAccessStatuses = await Promise.all(
namespaceList.body.items.map(ns => this.canI({
namespace: ns.metadata.name,
resource: "pods",
verb: "list",
}))
)
return namespaceList.body.items
.filter((ns, i) => nsAccessStatuses[i])
.map(ns => ns.metadata.name)
} catch (error) {
const ctx = this.getProxyKubeconfig().getContextObject(this.contextName)
if (ctx.namespace) return [ctx.namespace]
return []
}
}
protected async getAllowedResources() {
try {
if (!this.allowedNamespaces.length) {
return [];
}
const resourceAccessStatuses = await Promise.all(
apiResources.map(apiResource => this.canI({
resource: apiResource.resource,
group: apiResource.group,
verb: "list",
namespace: this.allowedNamespaces[0]
}))
)
return apiResources
.filter((resource, i) => resourceAccessStatuses[i])
.map(apiResource => apiResource.resource)
} catch (error) {
return []
}
}
} }

View File

@ -1,76 +1,42 @@
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node" import type { PrometheusProvider, PrometheusService } from "./prometheus/provider-registry"
import { ServerOptions } from "http-proxy" import type { ClusterPreferences } from "../common/cluster-store";
import * as url from "url" import type { Cluster } from "./cluster"
import type httpProxy from "http-proxy"
import url, { UrlWithStringQuery } from "url";
import { CoreV1Api } from "@kubernetes/client-node"
import { prometheusProviders } from "../common/prometheus-providers"
import logger from "./logger" import logger from "./logger"
import { getFreePort } from "./port" import { getFreePort } from "./port"
import { KubeAuthProxy } from "./kube-auth-proxy" import { KubeAuthProxy } from "./kube-auth-proxy"
import { Cluster, ClusterPreferences } from "./cluster"
import { prometheusProviders } from "../common/prometheus-providers"
import { PrometheusService, PrometheusProvider } from "./prometheus/provider-registry"
export class ContextHandler { export class ContextHandler {
public contextName: string public proxyPort: number;
public id: string public clusterUrl: UrlWithStringQuery;
public url: string protected kubeAuthProxy: KubeAuthProxy
public clusterUrl: url.UrlWithStringQuery protected apiTarget: httpProxy.ServerOptions
public proxyServer: KubeAuthProxy
public proxyPort: number
public certData: string
public authCertData: string
public cluster: Cluster
protected apiTarget: ServerOptions
protected proxyTarget: ServerOptions
protected clientCert: string
protected clientKey: string
protected secureApiConnection = true
protected defaultNamespace: string
protected kubernetesApi: string
protected prometheusProvider: string protected prometheusProvider: string
protected prometheusPath: string protected prometheusPath: string
protected clusterName: string
constructor(kc: KubeConfig, cluster: Cluster) { constructor(protected cluster: Cluster) {
this.id = cluster.id this.clusterUrl = url.parse(cluster.apiUrl);
this.setupPrometheus(cluster.preferences);
this.cluster = cluster
this.clusterUrl = url.parse(cluster.apiUrl)
this.contextName = cluster.contextName;
this.defaultNamespace = kc.getContextObject(cluster.contextName).namespace
this.url = `http://${this.id}.localhost:${cluster.port}/`
this.kubernetesApi = `http://127.0.0.1:${cluster.port}/${this.id}`
this.setClusterPreferences(cluster.preferences)
} }
public async init() { protected setupPrometheus(preferences: ClusterPreferences = {}) {
await this.resolveProxyPort() this.prometheusProvider = preferences.prometheusProvider?.type;
} this.prometheusPath = null;
if (preferences.prometheus) {
public setClusterPreferences(clusterPreferences?: ClusterPreferences) { const { namespace, service, port } = preferences.prometheus
this.prometheusProvider = clusterPreferences.prometheusProvider?.type this.prometheusPath = `${namespace}/services/${service}:${port}`
if (clusterPreferences && clusterPreferences.prometheus) {
const prom = clusterPreferences.prometheus
this.prometheusPath = `${prom.namespace}/services/${prom.service}:${prom.port}`
}
else {
this.prometheusPath = null
}
if (clusterPreferences && clusterPreferences.clusterName) {
this.clusterName = clusterPreferences.clusterName;
}
else {
this.clusterName = this.contextName;
} }
} }
protected async resolvePrometheusPath(): Promise<string> { protected async resolvePrometheusPath(): Promise<string> {
const {service, namespace, port} = await this.getPrometheusService() const { service, namespace, port } = await this.getPrometheusService()
return `${namespace}/services/${service}:${port}` return `${namespace}/services/${service}:${port}`
} }
public async getPrometheusProvider() { async getPrometheusProvider() {
if (!this.prometheusProvider) { if (!this.prometheusProvider) {
const service = await this.getPrometheusService() const service = await this.getPrometheusService()
logger.info(`using ${service.id} as prometheus provider`) logger.info(`using ${service.id} as prometheus provider`)
@ -79,36 +45,35 @@ export class ContextHandler {
return prometheusProviders.find(p => p.id === this.prometheusProvider) return prometheusProviders.find(p => p.id === this.prometheusProvider)
} }
public async getPrometheusService(): Promise<PrometheusService> { async getPrometheusService(): Promise<PrometheusService> {
const providers = this.prometheusProvider ? prometheusProviders.filter((p, _) => p.id == this.prometheusProvider) : prometheusProviders const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders;
const prometheusPromises: Promise<PrometheusService>[] = providers.map(async (provider: PrometheusProvider): Promise<PrometheusService> => { const prometheusPromises: Promise<PrometheusService>[] = providers.map(async (provider: PrometheusProvider): Promise<PrometheusService> => {
const apiClient = this.cluster.proxyKubeconfig().makeApiClient(CoreV1Api) const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api)
return await provider.getPrometheusService(apiClient) return await provider.getPrometheusService(apiClient)
}) })
const resolvedPrometheusServices = await Promise.all(prometheusPromises) const resolvedPrometheusServices = await Promise.all(prometheusPromises)
const service = resolvedPrometheusServices.filter(n => n)[0] const service = resolvedPrometheusServices.filter(n => n)[0];
if (service) { return service || {
return service id: "lens",
} namespace: "lens-metrics",
else { service: "prometheus",
return { port: 80
id: "lens",
namespace: "lens-metrics",
service: "prometheus",
port: 80
}
} }
} }
public async getPrometheusPath(): Promise<string> { async getPrometheusPath(): Promise<string> {
if (this.prometheusPath) return this.prometheusPath if (!this.prometheusPath) {
this.prometheusPath = await this.resolvePrometheusPath()
this.prometheusPath = await this.resolvePrometheusPath() }
return this.prometheusPath;
return this.prometheusPath
} }
public async getApiTarget(isWatchRequest = false): Promise<ServerOptions> { async resolveAuthProxyUrl() {
const proxyPort = await this.ensurePort();
return `http://127.0.0.1:${proxyPort}`;
}
async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> {
if (this.apiTarget && !isWatchRequest) { if (this.apiTarget && !isWatchRequest) {
return this.apiTarget return this.apiTarget
} }
@ -120,65 +85,45 @@ export class ContextHandler {
return apiTarget return apiTarget
} }
protected async newApiTarget(timeout: number): Promise<ServerOptions> { protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
const proxyUrl = await this.resolveAuthProxyUrl();
return { return {
target: proxyUrl + this.clusterUrl.path,
changeOrigin: true, changeOrigin: true,
timeout: timeout, timeout: timeout,
headers: { headers: {
"Host": this.clusterUrl.hostname "Host": this.clusterUrl.hostname,
},
target: {
port: await this.resolveProxyPort(),
protocol: "http://",
host: "localhost",
path: this.clusterUrl.path
}, },
} }
} }
protected async resolveProxyPort(): Promise<number> { async ensurePort(): Promise<number> {
if (this.proxyPort) return this.proxyPort if (!this.proxyPort) {
this.proxyPort = await getFreePort();
let serverPort: number = null
try {
serverPort = await getFreePort()
} catch (error) {
logger.error(error)
throw(error)
} }
this.proxyPort = serverPort return this.proxyPort
return serverPort
} }
public async withTemporaryKubeconfig(callback: (kubeconfig: string) => Promise<any>) { async ensureServer() {
try { if (!this.kubeAuthProxy) {
await callback(this.cluster.proxyKubeconfigPath()) await this.ensurePort();
} catch (error) {
throw(error)
}
}
public async ensureServer() {
if (!this.proxyServer) {
const proxyPort = await this.resolveProxyPort()
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.proxyServer = new KubeAuthProxy(this.cluster, proxyPort, proxyEnv) this.kubeAuthProxy = new KubeAuthProxy(this.cluster, this.proxyPort, proxyEnv)
await this.proxyServer.run() await this.kubeAuthProxy.run()
} }
} }
public stopServer() { stopServer() {
if (this.proxyServer) { if (this.kubeAuthProxy) {
this.proxyServer.exit() this.kubeAuthProxy.exit()
this.proxyServer = null this.kubeAuthProxy = null
} }
} }
public proxyServerError() { get proxyLastError(): string {
return this.proxyServer?.lastError || "" return this.kubeAuthProxy?.lastError || ""
} }
} }

View File

@ -1,55 +1,44 @@
import { KubeConfig } from "@kubernetes/client-node" import { KubeConfig } from "@kubernetes/client-node"
import logger from "./logger"; import logger from "./logger";
import { Cluster } from "./cluster"; import { Cluster } from "./cluster";
import { Feature, FeatureStatusMap } from "./feature" import { Feature, FeatureStatusMap, FeatureMap } from "./feature"
import { MetricsFeature } from "../features/metrics" import { MetricsFeature } from "../features/metrics"
import { UserModeFeature } from "../features/user-mode" import { UserModeFeature } from "../features/user-mode"
const ALL_FEATURES: any = { const ALL_FEATURES: Map<string, Feature> = new Map([
'metrics': new MetricsFeature(null), [MetricsFeature.id, new MetricsFeature(null)],
'user-mode': new UserModeFeature(null), [UserModeFeature.id, new UserModeFeature(null)],
} ]);
export async function getFeatures(cluster: Cluster): Promise<FeatureStatusMap> { export async function getFeatures(cluster: Cluster): Promise<FeatureStatusMap> {
return new Promise<FeatureStatusMap>(async (resolve, reject) => { const result: FeatureStatusMap = {};
const result: FeatureStatusMap = {}; logger.debug(`features for ${cluster.contextName}`);
logger.debug(`features for ${cluster.contextName}`);
for (const key in ALL_FEATURES) {
logger.debug(`feature ${key}`);
if (ALL_FEATURES.hasOwnProperty(key)) {
logger.debug("getting feature status...");
const feature = ALL_FEATURES[key] as Feature;
const kc = new KubeConfig()
kc.loadFromFile(cluster.proxyKubeconfigPath())
const status = await feature.featureStatus(kc);
result[feature.name] = status
} else { for (const [key, feature] of ALL_FEATURES) {
logger.error("ALL_FEATURES.hasOwnProperty(key) returned FALSE ?!?!?!?!") logger.debug(`feature ${key}`);
logger.debug("getting feature status...");
} const kc = new KubeConfig();
} kc.loadFromFile(cluster.getProxyKubeconfigPath());
logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`);
resolve(result); result[feature.name] = await feature.featureStatus(kc);
}); }
logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`);
return result;
} }
export async function installFeature(name: string, cluster: Cluster, config: any) { export async function installFeature(name: string, cluster: Cluster, config: any): Promise<void> {
const feature = ALL_FEATURES[name] as Feature
// TODO Figure out how to handle config stuff // TODO Figure out how to handle config stuff
await feature.install(cluster) return ALL_FEATURES.get(name).install(cluster)
} }
export async function upgradeFeature(name: string, cluster: Cluster, config: any) { export async function upgradeFeature(name: string, cluster: Cluster, config: any): Promise<void> {
const feature = ALL_FEATURES[name] as Feature
// TODO Figure out how to handle config stuff // TODO Figure out how to handle config stuff
await feature.upgrade(cluster) return ALL_FEATURES.get(name).upgrade(cluster)
} }
export async function uninstallFeature(name: string, cluster: Cluster) { export async function uninstallFeature(name: string, cluster: Cluster): Promise<void> {
const feature = ALL_FEATURES[name] as Feature return ALL_FEATURES.get(name).uninstall(cluster)
await feature.uninstall(cluster)
} }

View File

@ -1,96 +1,83 @@
import fs from "fs"; import fs from "fs";
import path from "path" import path from "path"
import * as hb from "handlebars" import hb from "handlebars"
import { ResourceApplier } from "./resource-applier" import { ResourceApplier } from "./resource-applier"
import { KubeConfig, CoreV1Api, Watch } from "@kubernetes/client-node" import { CoreV1Api, KubeConfig, Watch } from "@kubernetes/client-node"
import logger from "./logger";
import { Cluster } from "./cluster"; import { Cluster } from "./cluster";
import logger from "./logger";
export type FeatureStatus = { export type FeatureStatusMap = Record<string, FeatureStatus>
export type FeatureMap = Record<string, Feature>
export interface FeatureInstallRequest {
clusterId: string;
name: string;
config?: any;
}
export interface FeatureStatus {
currentVersion: string; currentVersion: string;
installed: boolean; installed: boolean;
latestVersion: string; latestVersion: string;
canUpgrade: boolean; canUpgrade: boolean;
// TODO We need bunch of other stuff too: upgradeable, latestVersion, ...
};
export type FeatureStatusMap = {
[name: string]: FeatureStatus;
} }
export abstract class Feature { export abstract class Feature {
name: string; name: string;
config: any;
latestVersion: string; latestVersion: string;
constructor(config: any) { abstract async upgrade(cluster: Cluster): Promise<void>;
if(config) {
this.config = config;
}
}
// TODO Return types for these? abstract async uninstall(cluster: Cluster): Promise<void>;
async install(cluster: Cluster): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
// Read and process yamls through handlebar
const resources = this.renderTemplates();
// Apply processed manifests
cluster.contextHandler.withTemporaryKubeconfig(async (kubeconfigPath) => {
const resourceApplier = new ResourceApplier(cluster, kubeconfigPath)
try {
await resourceApplier.kubectlApplyAll(resources)
resolve(true)
} catch(error) {
reject(error)
}
});
});
}
abstract async upgrade(cluster: Cluster): Promise<boolean>;
abstract async uninstall(cluster: Cluster): Promise<boolean>;
abstract async featureStatus(kc: KubeConfig): Promise<FeatureStatus>; abstract async featureStatus(kc: KubeConfig): Promise<FeatureStatus>;
constructor(public config: any) {
}
async install(cluster: Cluster): Promise<void> {
const resources = this.renderTemplates();
try {
await new ResourceApplier(cluster).kubectlApplyAll(resources);
} catch (err) {
logger.error("Installing feature error", { err, cluster });
throw err;
}
}
protected async deleteNamespace(kc: KubeConfig, name: string) { protected async deleteNamespace(kc: KubeConfig, name: string) {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const client = kc.makeApiClient(CoreV1Api) const client = kc.makeApiClient(CoreV1Api)
const result = await client.deleteNamespace("lens-metrics", 'false', undefined, undefined, undefined, "Foreground"); const result = await client.deleteNamespace("lens-metrics", 'false', undefined, undefined, undefined, "Foreground");
const nsVersion = result.body.metadata.resourceVersion; const nsVersion = result.body.metadata.resourceVersion;
const nsWatch = new Watch(kc); const nsWatch = new Watch(kc);
const req = await nsWatch.watch('/api/v1/namespaces', {resourceVersion: nsVersion, fieldSelector: "metadata.name=lens-metrics"}, const query: Record<string, string> = {
(type, obj) => { resourceVersion: nsVersion,
if(type === 'DELETED') { fieldSelector: "metadata.name=lens-metrics",
}
const req = await nsWatch.watch('/api/v1/namespaces', query,
(phase, obj) => {
if (phase === 'DELETED') {
logger.debug(`namespace ${name} finally gone`) logger.debug(`namespace ${name} finally gone`)
req.abort(); req.abort();
resolve() resolve()
} }
}, },
(err) => { (err?: any) => {
if(err) { if (err) reject(err);
reject(err)
}
}); });
}); });
} }
protected renderTemplates(): string[] { protected renderTemplates(): string[] {
console.log("starting to render resources...");
const resources: string[] = []; const resources: string[] = [];
fs.readdirSync(this.manifestPath()).forEach((f) => { fs.readdirSync(this.manifestPath()).forEach(filename => {
const file = path.join(this.manifestPath(), f); const file = path.join(this.manifestPath(), filename);
console.log("processing file:", file)
const raw = fs.readFileSync(file); const raw = fs.readFileSync(file);
console.log("raw file loaded"); if (filename.endsWith('.hb')) {
if(f.endsWith('.hb')) {
console.log("processing HB template");
const template = hb.compile(raw.toString()); const template = hb.compile(raw.toString());
resources.push(template(this.config)); resources.push(template(this.config));
console.log("HB template done");
} else { } else {
console.log("using as raw, no HB detected");
resources.push(raw.toString()); resources.push(raw.toString());
} }
}); });
@ -100,7 +87,7 @@ export abstract class Feature {
protected manifestPath() { protected manifestPath() {
const devPath = path.join(__dirname, "..", 'src/features', this.name); const devPath = path.join(__dirname, "..", 'src/features', this.name);
if(fs.existsSync(devPath)) { if (fs.existsSync(devPath)) {
return devPath; return devPath;
} }
return path.join(__dirname, "..", 'features', this.name); return path.join(__dirname, "..", 'features', this.name);

View File

@ -1,11 +0,0 @@
import fs from "fs"
export function ensureDir(dirname: string) {
if (!fs.existsSync(dirname)) {
fs.mkdirSync(dirname)
}
}
export function randomFileName(name: string) {
return `${Math.random().toString(36).substring(2, 15)}-${Math.random().toString(36).substring(2, 15)}-${name}`
}

View File

@ -1,155 +0,0 @@
import fs from "fs";
import logger from "./logger";
import * as yaml from "js-yaml";
import { promiseExec } from "./promise-exec";
import { helmCli } from "./helm-cli";
type HelmEnv = {
[key: string]: string | undefined;
}
export type HelmRepo = {
name: string;
url: string;
cacheFilePath?: string;
}
export class HelmRepoManager {
private static instance: HelmRepoManager;
public static cache = {}
protected helmEnv: HelmEnv
protected initialized: boolean
static getInstance(): HelmRepoManager {
if(!HelmRepoManager.instance) {
HelmRepoManager.instance = new HelmRepoManager()
}
return HelmRepoManager.instance;
}
private constructor() {
// use singleton getInstance()
}
public async init() {
const helm = await helmCli.binaryPath()
if (!this.initialized) {
this.helmEnv = await this.parseHelmEnv()
await this.update()
this.initialized = true
}
}
protected async parseHelmEnv() {
const helm = await helmCli.binaryPath()
const { stdout } = await promiseExec(`"${helm}" env`).catch((error) => { throw(error.stderr)})
const lines = stdout.split(/\r?\n/) // split by new line feed
const env: HelmEnv = {}
lines.forEach((line: string) => {
const [key, value] = line.split("=")
if (key && value) {
env[key] = value.replace(/"/g, "") // strip quotas
}
})
return env
}
public async repositories(): Promise<Array<HelmRepo>> {
if(!this.initialized) {
await this.init()
}
const repositoryFilePath = this.helmEnv.HELM_REPOSITORY_CONFIG
const repoFile = await fs.promises.readFile(repositoryFilePath, 'utf8').catch(async (error) => {
return null
})
if(!repoFile) {
await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" })
return await this.repositories()
}
try {
const repositories = yaml.safeLoad(repoFile)
const result = repositories['repositories'].map((repository: HelmRepo) => {
return {
name: repository.name,
url: repository.url,
cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repository['name']}-index.yaml`
}
});
if (result.length == 0) {
await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" })
return await this.repositories()
}
return result
} catch (error) {
logger.debug(error)
return []
}
}
public async repository(name: string) {
const repositories = await this.repositories()
return repositories.find((repo: HelmRepo) => {
return repo.name == name
})
}
public async update() {
const helm = await helmCli.binaryPath()
logger.debug(`${helm} repo update`)
const {stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => { return { stdout: error.stdout } })
return stdout
}
protected async addRepositories(repositories: HelmRepo[]){
const currentRepositories = await this.repositories()
repositories.forEach(async (repo: HelmRepo) => {
try {
const repoExists = currentRepositories.find((currentRepo: HelmRepo) => {
return currentRepo.url == repo.url
})
if(!repoExists) {
await this.addRepo(repo)
}
}
catch(error) {
logger.error(JSON.stringify(error))
}
});
}
protected async pruneRepositories(repositoriesToKeep: HelmRepo[]) {
const repositories = await this.repositories()
repositories.filter((repo: HelmRepo) => {
return repositoriesToKeep.find((repoToKeep: HelmRepo) => {
return repo.name == repoToKeep.name
}) === undefined
}).forEach(async (repo: HelmRepo) => {
try {
const output = await this.removeRepo(repo)
logger.debug(output)
} catch(error) {
logger.error(error)
}
})
}
public async addRepo(repository: HelmRepo) {
const helm = await helmCli.binaryPath()
logger.debug(`${helm} repo add ${repository.name} ${repository.url}`)
const {stdout } = await promiseExec(`"${helm}" repo add ${repository.name} ${repository.url}`).catch((error) => { throw(error.stderr)})
return stdout
}
public async removeRepo(repository: HelmRepo): Promise<string> {
const helm = await helmCli.binaryPath()
logger.debug(`${helm} repo remove ${repository.name} ${repository.url}`)
const { stdout, stderr } = await promiseExec(`"${helm}" repo remove ${repository.name}`).catch((error) => { throw(error.stderr)})
return stdout
}
}
export const repoManager = HelmRepoManager.getInstance()

View File

@ -1,17 +1,18 @@
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import { HelmRepo, HelmRepoManager } from "./helm-repo-manager" import { HelmRepo, HelmRepoManager } from "./helm-repo-manager"
import logger from "./logger"; import logger from "../logger";
import { promiseExec } from "./promise-exec" import { promiseExec } from "../promise-exec"
import { helmCli } from "./helm-cli" import { helmCli } from "./helm-cli"
type CachedYaml = { type CachedYaml = {
entries: any; entries: any; // todo: types
} }
export class HelmChartManager { export class HelmChartManager {
protected cache: any protected cache: any = {}
protected repo: HelmRepo protected repo: HelmRepo
constructor(repo: HelmRepo){ constructor(repo: HelmRepo){
this.cache = HelmRepoManager.cache this.cache = HelmRepoManager.cache
this.repo = repo this.repo = repo

View File

@ -1,7 +1,7 @@
import packageInfo from "../../package.json" import packageInfo from "../../../package.json"
import path from "path" import path from "path"
import { LensBinary, LensBinaryOpts } from "./lens-binary" import { LensBinary, LensBinaryOpts } from "../lens-binary"
import { isProduction } from "../common/vars"; import { isProduction } from "../../common/vars";
export class HelmCli extends LensBinary { export class HelmCli extends LensBinary {

View File

@ -1,10 +1,10 @@
import * as tempy from "tempy"; import * as tempy from "tempy";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import { promiseExec} from "./promise-exec" import { promiseExec} from "../promise-exec"
import { helmCli } from "./helm-cli"; import { helmCli } from "./helm-cli";
import { Cluster } from "./cluster"; import { Cluster } from "../cluster";
import { toCamelCase } from "../common/utils/camelCase"; import { toCamelCase } from "../../common/utils/camelCase";
export class HelmReleaseManager { export class HelmReleaseManager {
@ -54,7 +54,7 @@ export class HelmReleaseManager {
await fs.promises.writeFile(fileName, yaml.safeDump(values)) await fs.promises.writeFile(fileName, yaml.safeDump(values))
try { try {
const { stdout, stderr } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.proxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)}) const { stdout, stderr } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)})
return { return {
log: stdout, log: stdout,
release: this.getRelease(name, namespace, cluster) release: this.getRelease(name, namespace, cluster)
@ -66,7 +66,7 @@ export class HelmReleaseManager {
public async getRelease(name: string, namespace: string, cluster: Cluster) { public async getRelease(name: string, namespace: string, cluster: Cluster) {
const helm = await helmCli.binaryPath() const helm = await helmCli.binaryPath()
const {stdout, stderr} = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.proxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)}) const {stdout, stderr} = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)})
const release = JSON.parse(stdout) const release = JSON.parse(stdout)
release.resources = await this.getResources(name, namespace, cluster) release.resources = await this.getResources(name, namespace, cluster)
return release return release
@ -99,8 +99,8 @@ export class HelmReleaseManager {
protected async getResources(name: string, namespace: string, cluster: Cluster) { protected async getResources(name: string, namespace: string, cluster: Cluster) {
const helm = await helmCli.binaryPath() const helm = await helmCli.binaryPath()
const kubectl = await cluster.kubeCtl.kubectlPath() const kubectl = await cluster.kubeCtl.getPath()
const pathToKubeconfig = cluster.proxyKubeconfigPath() const pathToKubeconfig = cluster.getProxyKubeconfigPath()
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch((error) => { const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch((error) => {
return { stdout: JSON.stringify({items: []})} return { stdout: JSON.stringify({items: []})}
}) })

View File

@ -0,0 +1,130 @@
import yaml from "js-yaml";
import { readFile } from "fs-extra";
import { promiseExec } from "../promise-exec";
import { helmCli } from "./helm-cli";
import { Singleton } from "../../common/utils/singleton";
import { customRequestPromise } from "../../common/request";
import orderBy from "lodash/orderBy";
import logger from "../logger";
export type HelmEnv = Record<string, string> & {
HELM_REPOSITORY_CACHE?: string;
HELM_REPOSITORY_CONFIG?: string;
}
export interface HelmRepoConfig {
repositories: HelmRepo[]
}
export interface HelmRepo {
name: string;
url: string;
cacheFilePath?: string
caFile?: string,
certFile?: string,
insecure_skip_tls_verify?: boolean,
keyFile?: string,
username?: string,
password?: string,
}
export class HelmRepoManager extends Singleton {
static cache = {} // todo: remove implicit updates in helm-chart-manager.ts
protected repos: HelmRepo[];
protected helmEnv: HelmEnv
protected initialized: boolean
async loadAvailableRepos(): Promise<HelmRepo[]> {
const res = await customRequestPromise({
uri: "https://hub.helm.sh/assets/js/repos.json",
json: true,
resolveWithFullResponse: true,
timeout: 10000,
});
return orderBy<HelmRepo>(res.body.data, repo => repo.name);
}
async init() {
await helmCli.ensureBinary();
if (!this.initialized) {
this.helmEnv = await this.parseHelmEnv()
await this.update()
this.initialized = true
}
}
protected async parseHelmEnv() {
const helm = await helmCli.binaryPath()
const { stdout } = await promiseExec(`"${helm}" env`).catch((error) => {
throw(error.stderr)
})
const lines = stdout.split(/\r?\n/) // split by new line feed
const env: HelmEnv = {}
lines.forEach((line: string) => {
const [key, value] = line.split("=")
if (key && value) {
env[key] = value.replace(/"/g, "") // strip quotas
}
})
return env
}
public async repositories(): Promise<HelmRepo[]> {
if (!this.initialized) {
await this.init()
}
try {
const repoConfigFile = this.helmEnv.HELM_REPOSITORY_CONFIG;
const { repositories }: HelmRepoConfig = await readFile(repoConfigFile, 'utf8')
.then((yamlContent: string) => yaml.safeLoad(yamlContent))
.catch(() => ({
repositories: []
}));
if (!repositories.length) {
await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" });
return await this.repositories();
}
return repositories.map(repo => ({
...repo,
cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`
}));
} catch (error) {
logger.error(`[HELM]: repositories listing error "${error}"`)
return []
}
}
public async repository(name: string) {
const repositories = await this.repositories()
return repositories.find(repo => repo.name == name);
}
public async update() {
const helm = await helmCli.binaryPath()
const { stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => {
return { stdout: error.stdout }
})
return stdout
}
public async addRepo({ name, url }: HelmRepo) {
logger.info(`[HELM]: adding repo "${name}" from ${url}`);
const helm = await helmCli.binaryPath()
const { stdout } = await promiseExec(`"${helm}" repo add ${name} ${url}`).catch((error) => {
throw(error.stderr)
})
return stdout
}
public async removeRepo({ name, url }: HelmRepo): Promise<string> {
logger.info(`[HELM]: removing repo "${name}" from ${url}`);
const helm = await helmCli.binaryPath()
const { stdout, stderr } = await promiseExec(`"${helm}" repo remove ${name}`).catch((error) => {
throw(error.stderr)
})
return stdout
}
}
export const repoManager = HelmRepoManager.getInstance<HelmRepoManager>()

View File

@ -1,13 +1,12 @@
import { Cluster } from "./cluster"; import { Cluster } from "../cluster";
import logger from "./logger"; import logger from "../logger";
import { repoManager } from "./helm-repo-manager"; import { repoManager } from "./helm-repo-manager";
import { HelmChartManager } from "./helm-chart-manager"; import { HelmChartManager } from "./helm-chart-manager";
import { releaseManager } from "./helm-release-manager"; import { releaseManager } from "./helm-release-manager";
class HelmService { class HelmService {
public async installChart(cluster: Cluster, data: {chart: string; values: {}; name: string; namespace: string; version: string}) { public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) {
const installResult = await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, cluster.proxyKubeconfigPath()) return await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, cluster.getProxyKubeconfigPath())
return installResult
} }
public async listCharts() { public async listCharts() {
@ -19,7 +18,7 @@ class HelmService {
const manager = new HelmChartManager(repo) const manager = new HelmChartManager(repo)
let entries = await manager.charts() let entries = await manager.charts()
entries = this.excludeDeprecated(entries) entries = this.excludeDeprecated(entries)
for(const key in entries) { for (const key in entries) {
entries[key] = entries[key][0] entries[key] = entries[key][0]
} }
charts[repo.name] = entries charts[repo.name] = entries
@ -48,50 +47,44 @@ class HelmService {
public async listReleases(cluster: Cluster, namespace: string = null) { public async listReleases(cluster: Cluster, namespace: string = null) {
await repoManager.init() await repoManager.init()
const releases = await releaseManager.listReleases(cluster.proxyKubeconfigPath(), namespace) return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace)
return releases
} }
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) { public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release") logger.debug("Fetch release")
const release = await releaseManager.getRelease(releaseName, namespace, cluster) return await releaseManager.getRelease(releaseName, namespace, cluster)
return release
} }
public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) { public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release values") logger.debug("Fetch release values")
const values = await releaseManager.getValues(releaseName, namespace, cluster.proxyKubeconfigPath()) return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath())
return values
} }
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) { public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release history") logger.debug("Fetch release history")
const history = await releaseManager.getHistory(releaseName, namespace, cluster.proxyKubeconfigPath()) return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath())
return(history)
} }
public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) { public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Delete release") logger.debug("Delete release")
const release = await releaseManager.deleteRelease(releaseName, namespace, cluster.proxyKubeconfigPath()) return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath())
return release
} }
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: {chart: string; values: {}; version: string}) { public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) {
logger.debug("Upgrade release") logger.debug("Upgrade release")
const release = await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster) return await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster)
return release
} }
public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) { public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) {
logger.debug("Rollback release") logger.debug("Rollback release")
const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.proxyKubeconfigPath()) const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath())
return({ message: output }) return { message: output }
} }
protected excludeDeprecated(entries: any) { protected excludeDeprecated(entries: any) {
for(const key in entries) { for (const key in entries) {
entries[key] = entries[key].filter((entry: any) => { entries[key] = entries[key].filter((entry: any) => {
if(Array.isArray(entry)) { if (Array.isArray(entry)) {
return entry[0]['deprecated'] != true return entry[0]['deprecated'] != true
} }
return entry["deprecated"] != true return entry["deprecated"] != true

View File

@ -1,141 +1,86 @@
// Main process // Main process
import "../common/system-ca" import "../common/system-ca"
import { app, dialog, protocol } from "electron"
import { isMac, vueAppName, isDevelopment } from "../common/vars";
if (isDevelopment) {
const appName = 'LensDev';
app.setName(appName);
const appData = app.getPath('appData');
app.setPath('userData', path.join(appData, appName));
}
import "../common/prometheus-providers" import "../common/prometheus-providers"
import { PromiseIpc } from "electron-promise-ipc" import { app, dialog } from "electron"
import { appName } from "../common/vars";
import path from "path" import path from "path"
import { format as formatUrl } from "url" import { LensProxy } from "./lens-proxy"
import logger from "./logger"
import initMenu from "./menu"
import * as proxy from "./proxy"
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { clusterStore } from "../common/cluster-store"
import { tracker } from "./tracker"
import { ClusterManager } from "./cluster-manager"; import { ClusterManager } from "./cluster-manager";
import AppUpdater from "./app-updater" import AppUpdater from "./app-updater"
import { shellSync } from "./shell-sync" import { shellSync } from "./shell-sync"
import { getFreePort } from "./port" import { getFreePort } from "./port"
import { mangleProxyEnv } from "./proxy-env" import { mangleProxyEnv } from "./proxy-env"
import { findMainWebContents } from "./webcontents" import { registerFileProtocol } from "../common/register-protocol";
import { registerStaticProtocol } from "../common/register-static"; import { clusterStore } from "../common/cluster-store"
import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store";
import { tracker } from "../common/tracker";
import logger from "./logger"
const workingDir = path.join(app.getPath("appData"), appName);
app.setName(appName);
if(!process.env.CICD) {
app.setPath("userData", workingDir);
}
let windowManager: WindowManager;
let clusterManager: ClusterManager;
let proxyServer: LensProxy;
mangleProxyEnv() mangleProxyEnv()
if (app.commandLine.getSwitchValue("proxy-server") !== "") { if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server") process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server")
} }
const promiseIpc = new PromiseIpc({ timeout: 2000 })
let windowManager: WindowManager = null;
let clusterManager: ClusterManager = null;
let proxyServer: proxy.LensProxy = null;
const vmURL = formatUrl({
pathname: path.join(__dirname, `${vueAppName}.html`),
protocol: "file",
slashes: true,
})
async function main() { async function main() {
shellSync(app.getLocale()) await shellSync();
logger.info(`🚀 Starting Lens from "${workingDir}"`)
tracker.event("app", "start");
const updater = new AppUpdater() const updater = new AppUpdater()
updater.start(); updater.start();
tracker.event("app", "start"); registerFileProtocol("static", __static);
registerStaticProtocol();
protocol.registerFileProtocol('store', (request, callback) => {
const url = request.url.substr(8)
callback(path.normalize(`${app.getPath("userData")}/${url}`))
}, (error) => {
if (error) console.error('Failed to register protocol')
})
let port: number = null
// find free port // find free port
let proxyPort: number
try { try {
port = await getFreePort() proxyPort = await getFreePort()
} catch (error) { } catch (error) {
logger.error(error)
await dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy") await dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy")
app.quit(); app.quit();
} }
// preload configuration from stores
await Promise.all([
userStore.load(),
clusterStore.load(),
workspaceStore.load(),
]);
// create cluster manager // create cluster manager
clusterManager = new ClusterManager(clusterStore.getAllClusterObjects(), port) clusterManager = new ClusterManager(proxyPort);
// run proxy // run proxy
try { try {
proxyServer = proxy.listen(port, clusterManager) proxyServer = LensProxy.create(proxyPort, clusterManager);
} catch (error) { } catch (error) {
logger.error(`Could not start proxy (127.0.0:${port}): ${error.message}`) logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error.message}`)
await dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${port}): ${error.message || "unknown error"}`) await dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error.message || "unknown error"}`)
app.quit(); app.quit();
} }
// boot windowmanager // create window manager and open app
windowManager = new WindowManager(); windowManager = new WindowManager(proxyPort);
windowManager.showMain(vmURL)
initMenu({
logoutHook: async () => {
// IPC send needs webContents as we're sending it to renderer
promiseIpc.send('logout', findMainWebContents(), {}).then((data: any) => {
logger.debug("logout IPC sent");
})
},
showPreferencesHook: async () => {
// IPC send needs webContents as we're sending it to renderer
promiseIpc.send('navigate', findMainWebContents(), { name: 'preferences-page' }).then((data: any) => {
logger.debug("navigate: preferences IPC sent");
})
},
addClusterHook: async () => {
promiseIpc.send('navigate', findMainWebContents(), { name: "add-cluster-page" }).then((data: any) => {
logger.debug("navigate: add-cluster-page IPC sent");
})
},
showWhatsNewHook: async () => {
promiseIpc.send('navigate', findMainWebContents(), { name: "whats-new-page" }).then((data: any) => {
logger.debug("navigate: whats-new-page IPC sent");
})
},
clusterSettingsHook: async () => {
promiseIpc.send('navigate', findMainWebContents(), { name: "cluster-settings-page" }).then((data: any) => {
logger.debug("navigate: cluster-settings-page IPC sent");
})
},
}, promiseIpc)
} }
app.on("ready", main) app.on("ready", main);
app.on('window-all-closed', function () {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (!isMac) {
app.quit();
} else {
windowManager = null
if (clusterManager) clusterManager.stop()
}
})
app.on("activate", () => {
if (!windowManager) {
logger.debug("activate main window")
windowManager = new WindowManager({ showSplash: false })
windowManager.showMain(vmURL)
}
})
app.on("will-quit", async (event) => { app.on("will-quit", async (event) => {
event.preventDefault(); // To allow mixpanel sending to be executed event.preventDefault(); // To allow mixpanel sending to be executed
if (clusterManager) clusterManager.stop()
if (proxyServer) proxyServer.close() if (proxyServer) proxyServer.close()
app.exit(0); if (clusterManager) clusterManager.stop()
app.exit();
}) })

View File

@ -1,153 +0,0 @@
import * as k8s from "@kubernetes/client-node"
import * as os from "os"
import * as yaml from "js-yaml"
import logger from "./logger";
const kc = new k8s.KubeConfig()
function resolveTilde(filePath: string) {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
return filePath.replace("~", os.homedir());
}
return filePath;
}
export function loadConfig(kubeconfig: string): k8s.KubeConfig {
if (kubeconfig) {
kc.loadFromFile(resolveTilde(kubeconfig))
} else {
kc.loadFromDefault();
}
return kc
}
/**
* KubeConfig is valid when there's atleast one of each defined:
* - User
* - Cluster
* - Context
*
* @param config KubeConfig to check
*/
export function validateConfig(config: k8s.KubeConfig): boolean {
logger.debug(`validating kube config: ${JSON.stringify(config)}`)
if(!config.users || config.users.length == 0) {
throw new Error("No users provided in config")
}
if(!config.clusters || config.clusters.length == 0) {
throw new Error("No clusters provided in config")
}
if(!config.contexts || config.contexts.length == 0) {
throw new Error("No contexts provided in config")
}
return true
}
/**
* Breaks kube config into several configs. Each context as it own KubeConfig object
*
* @param configString yaml string of kube config
*/
export function splitConfig(kubeConfig: k8s.KubeConfig): k8s.KubeConfig[] {
const configs: k8s.KubeConfig[] = []
if(!kubeConfig.contexts) {
return configs;
}
kubeConfig.contexts.forEach(ctx => {
const kc = new k8s.KubeConfig();
kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n);
kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n)
kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n)
kc.setCurrentContext(ctx.name);
configs.push(kc);
});
return configs;
}
/**
* Loads KubeConfig from a yaml and breaks it into several configs. Each context per KubeConfig object
*
* @param configPath path to kube config yaml file
*/
export function loadAndSplitConfig(configPath: string): k8s.KubeConfig[] {
const allConfigs = new k8s.KubeConfig();
allConfigs.loadFromFile(configPath);
return splitConfig(allConfigs);
}
export function dumpConfigYaml(kc: k8s.KubeConfig): string {
const config = {
apiVersion: "v1",
kind: "Config",
preferences: {},
'current-context': kc.currentContext,
clusters: kc.clusters.map(c => {
return {
name: c.name,
cluster: {
'certificate-authority-data': c.caData,
'certificate-authority': c.caFile,
server: c.server,
'insecure-skip-tls-verify': c.skipTLSVerify
}
}
}),
contexts: kc.contexts.map(c => {
return {
name: c.name,
context: {
cluster: c.cluster,
user: c.user,
namespace: c.namespace
}
}
}),
users: kc.users.map(u => {
return {
name: u.name,
user: {
'client-certificate-data': u.certData,
'client-certificate': u.certFile,
'client-key-data': u.keyData,
'client-key': u.keyFile,
'auth-provider': u.authProvider,
exec: u.exec,
token: u.token,
username: u.username,
password: u.password
}
}
})
}
console.log("dumping kc:", config);
// skipInvalid: true makes dump ignore undefined values
return yaml.safeDump(config, {skipInvalid: true});
}
export function podHasIssues(pod: k8s.V1Pod) {
// Logic adapted from dashboard
const notReady = !!pod.status.conditions.find(condition => {
return condition.type == "Ready" && condition.status !== "True"
});
return (
notReady ||
pod.status.phase !== "Running" ||
pod.spec.priority > 500000 // We're interested in high prio pods events regardless of their running status
)
}
// Logic adapted from dashboard
// see: https://github.com/kontena/kontena-k8s-dashboard/blob/7d8f9cb678cc817a22dd1886c5e79415b212b9bf/client/api/endpoints/nodes.api.ts#L147
export function getNodeWarningConditions(node: k8s.V1Node) {
return node.status.conditions.filter(c =>
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"
)
}

View File

@ -1,10 +1,14 @@
import { spawn, ChildProcess } from "child_process" import { ChildProcess, spawn } from "child_process"
import { waitUntilUsed } from "tcp-port-used";
import { broadcastIpc } from "../common/ipc";
import type { Cluster } from "./cluster"
import { bundledKubectl, Kubectl } from "./kubectl"
import logger from "./logger" import logger from "./logger"
import * as tcpPortUsed from "tcp-port-used"
import { Kubectl, bundledKubectl } from "./kubectl" export interface KubeAuthProxyLog {
import { Cluster } from "./cluster" data: string;
import { PromiseIpc } from "electron-promise-ipc" error?: boolean; // stream=stderr
import { findMainWebContents } from "./webcontents" }
export class KubeAuthProxy { export class KubeAuthProxy {
public lastError: string public lastError: string
@ -14,58 +18,52 @@ export class KubeAuthProxy {
protected proxyProcess: ChildProcess protected proxyProcess: ChildProcess
protected port: number protected port: number
protected kubectl: Kubectl protected kubectl: Kubectl
protected promiseIpc: any
constructor(cluster: Cluster, port: number, env: NodeJS.ProcessEnv) { constructor(cluster: Cluster, port: number, env: NodeJS.ProcessEnv) {
this.env = env this.env = env
this.port = port this.port = port
this.cluster = cluster this.cluster = cluster
this.kubectl = bundledKubectl this.kubectl = bundledKubectl
this.promiseIpc = new PromiseIpc({ timeout: 2000 })
} }
public async run(): Promise<void> { public async run(): Promise<void> {
if (this.proxyProcess) { if (this.proxyProcess) {
return; return;
} }
const proxyBin = await this.kubectl.kubectlPath() const proxyBin = await this.kubectl.getPath()
let args = [ const args = [
"proxy", "proxy",
"-p", this.port.toString(), "-p", `${this.port}`,
"--kubeconfig", this.cluster.kubeConfigPath, "--kubeconfig", `${this.cluster.kubeConfigPath}`,
"--context", this.cluster.contextName, "--context", `${this.cluster.contextName}`,
"--accept-hosts", ".*", "--accept-hosts", ".*",
"--reject-paths", "^[^/]" "--reject-paths", "^[^/]"
] ]
if (process.env.DEBUG_PROXY === "true") { if (process.env.DEBUG_PROXY === "true") {
args = args.concat(["-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, { this.proxyProcess = spawn(proxyBin, args, { env: this.env, })
env: this.env
})
this.proxyProcess.on("exit", (code) => { this.proxyProcess.on("exit", (code) => {
logger.error(`proxy ${this.cluster.contextName} exited with code ${code}`) this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 })
this.sendIpcLogMessage( `proxy exited with code ${code}`, "stderr").catch((err: Error) => { this.exit();
logger.debug("failed to send IPC log message: " + err.message)
})
this.proxyProcess = null
}) })
this.proxyProcess.stdout.on('data', (data) => { this.proxyProcess.stdout.on('data', (data) => {
let logItem = data.toString() let logItem = data.toString()
if (logItem.startsWith("Starting to serve on")) { if (logItem.startsWith("Starting to serve on")) {
logItem = "Authentication proxy started\n" logItem = "Authentication proxy started\n"
} }
logger.debug(`proxy ${this.cluster.contextName} stdout: ${logItem}`) this.sendIpcLogMessage({ data: logItem })
this.sendIpcLogMessage(logItem, "stdout")
})
this.proxyProcess.stderr.on('data', (data) => {
this.lastError = this.parseError(data.toString())
logger.debug(`proxy ${this.cluster.contextName} stderr: ${data}`)
this.sendIpcLogMessage(data.toString(), "stderr")
}) })
return tcpPortUsed.waitUntilUsed(this.port, 500, 10000) this.proxyProcess.stderr.on('data', (data) => {
this.lastError = this.parseError(data.toString())
this.sendIpcLogMessage({ data: data.toString(), error: true })
})
return waitUntilUsed(this.port, 500, 10000)
} }
protected parseError(data: string) { protected parseError(data: string) {
@ -76,21 +74,30 @@ export class KubeAuthProxy {
try { try {
const parsedError = JSON.parse(jsonError) const parsedError = JSON.parse(jsonError)
errorMsg = parsedError.error_description || parsedError.error || jsonError errorMsg = parsedError.error_description || parsedError.error || jsonError
} catch(_) { } catch (_) {
errorMsg = jsonError.trim() errorMsg = jsonError.trim()
} }
} }
return errorMsg return errorMsg
} }
protected async sendIpcLogMessage(data: string, stream: string) { protected async sendIpcLogMessage(res: KubeAuthProxyLog) {
await this.promiseIpc.send(`kube-auth:${this.cluster.id}`, findMainWebContents(), { data, stream }) const channel = `kube-auth:${this.cluster.id}`
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });
broadcastIpc({
// webContentId: null, // todo: send a message only to single cluster's window
channel: channel,
args: [res],
});
} }
public exit() { public exit() {
if (this.proxyProcess) { if (!this.proxyProcess) return;
logger.debug(`Stopping local proxy: ${this.cluster.contextName}`) logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta())
this.proxyProcess.kill() this.proxyProcess.kill()
} this.proxyProcess.removeAllListeners();
this.proxyProcess.stderr.removeAllListeners();
this.proxyProcess.stdout.removeAllListeners();
this.proxyProcess = null;
} }
} }

View File

@ -1,62 +1,75 @@
import type { KubeConfig } from "@kubernetes/client-node";
import type { Cluster } from "./cluster"
import type { ContextHandler } from "./context-handler";
import { app } from "electron" import { app } from "electron"
import fs from "fs" import path from "path"
import { ensureDir, randomFileName} from "./file-helpers" import fs from "fs-extra"
import { dumpConfigYaml, loadConfig } from "../common/kube-helpers"
import logger from "./logger" import logger from "./logger"
import { Cluster } from "./cluster"
import * as k8s from "./k8s"
import { KubeConfig } from "@kubernetes/client-node"
export class KubeconfigManager { export class KubeconfigManager {
protected configDir = app.getPath("temp") protected configDir = app.getPath("temp")
protected tempFile: string protected tempFile: string;
protected cluster: Cluster
constructor(cluster: Cluster) { constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) {
this.cluster = cluster this.init();
this.tempFile = this.createTemporaryKubeconfig()
} }
public getPath() { protected async init() {
return this.tempFile try {
await this.contextHandler.ensurePort();
await this.createProxyKubeconfig();
} catch (err) {
logger.error(`Failed to created temp config for auth-proxy`, { err })
}
}
getPath() {
return this.tempFile;
} }
/** /**
* Creates new "temporary" kubeconfig that point to the kubectl-proxy. * Creates new "temporary" kubeconfig that point to the kubectl-proxy.
* This way any user of the config does not need to know anything about the auth etc. details. * This way any user of the config does not need to know anything about the auth etc. details.
*/ */
protected createTemporaryKubeconfig(): string { protected async createProxyKubeconfig(): Promise<string> {
ensureDir(this.configDir) const { configDir, cluster, contextHandler } = this;
const path = `${this.configDir}/${randomFileName("kubeconfig")}` const { contextName, kubeConfigPath, id } = cluster;
const originalKc = new KubeConfig() const tempFile = path.join(configDir, `kubeconfig-${id}`);
originalKc.loadFromFile(this.cluster.kubeConfigPath) const kubeConfig = loadConfig(kubeConfigPath);
const kc = { const proxyConfig: Partial<KubeConfig> = {
currentContext: contextName,
clusters: [ clusters: [
{ {
name: this.cluster.contextName, name: contextName,
server: `http://127.0.0.1:${this.cluster.contextHandler.proxyPort}` server: await contextHandler.resolveAuthProxyUrl(),
skipTLSVerify: undefined,
} }
], ],
users: [ users: [
{ { name: "proxy" },
name: "proxy"
}
], ],
contexts: [ contexts: [
{ {
name: this.cluster.contextName, user: "proxy",
cluster: this.cluster.contextName, name: contextName,
namespace: originalKc.getContextObject(this.cluster.contextName).namespace, cluster: contextName,
user: "proxy" namespace: kubeConfig.getContextObject(contextName).namespace,
} }
], ]
currentContext: this.cluster.contextName };
} as KubeConfig
fs.writeFileSync(path, k8s.dumpConfigYaml(kc)) // write
return path const configYaml = dumpConfigYaml(proxyConfig);
fs.ensureDir(path.dirname(tempFile));
fs.writeFileSync(tempFile, configYaml);
this.tempFile = tempFile;
logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
return tempFile;
} }
public unlink() { unlink() {
logger.debug('Deleting temporary kubeconfig: ' + this.tempFile) logger.info('Deleting temporary kubeconfig: ' + this.tempFile)
fs.unlinkSync(this.tempFile) fs.unlinkSync(this.tempFile)
} }
} }

View File

@ -1,15 +1,15 @@
import { app, remote } from "electron" import { app, remote } from "electron"
import path from "path" import path from "path"
import fs from "fs" import fs from "fs"
import request from "request" import { promiseExec } from "./promise-exec"
import { promiseExec} from "./promise-exec"
import logger from "./logger" import logger from "./logger"
import { ensureDir, pathExists } from "fs-extra" import { ensureDir, pathExists } from "fs-extra"
import { globalRequestOpts } from "../common/request"
import * as lockFile from "proper-lockfile" import * as lockFile from "proper-lockfile"
import { helmCli } from "./helm-cli" import { helmCli } from "./helm/helm-cli"
import { userStore } from "../common/user-store" import { userStore } from "../common/user-store"
import { getBundledKubectlVersion} from "../common/utils/app-version" import { customRequest } from "../common/request";
import { getBundledKubectlVersion } from "../common/utils/app-version"
import { isDevelopment, isWindows } from "../common/vars";
const bundledVersion = getBundledKubectlVersion() const bundledVersion = getBundledKubectlVersion()
const kubectlMap: Map<string, string> = new Map([ const kubectlMap: Map<string, string> = new Map([
@ -32,35 +32,37 @@ const packageMirrors: Map<string, string> = new Map([
["china", "https://mirror.azure.cn/kubernetes/kubectl"] ["china", "https://mirror.azure.cn/kubernetes/kubectl"]
]) ])
let bundledPath: string
const initScriptVersionString = "# lens-initscript v3\n" const initScriptVersionString = "# lens-initscript v3\n"
const isDevelopment = process.env.NODE_ENV !== "production" if (isDevelopment) {
let bundledPath: string = null
if(isDevelopment) {
bundledPath = path.join(process.cwd(), "binaries", "client", process.platform, process.arch, "kubectl") bundledPath = path.join(process.cwd(), "binaries", "client", process.platform, process.arch, "kubectl")
} else { } else {
bundledPath = path.join(process.resourcesPath, process.arch, "kubectl") bundledPath = path.join(process.resourcesPath, process.arch, "kubectl")
} }
if(process.platform === "win32") bundledPath = `${bundledPath}.exe` if (isWindows) {
bundledPath = `${bundledPath}.exe`
}
export class Kubectl { export class Kubectl {
public kubectlVersion: string public kubectlVersion: string
protected directory: string protected directory: string
protected url: string protected url: string
protected path: string protected path: string
protected dirname: string protected dirname: string
public static readonly kubectlDir = path.join((app || remote.app).getPath("userData"), "binaries", "kubectl") static get kubectlDir() {
return path.join((app || remote.app).getPath("userData"), "binaries", "kubectl")
}
public static readonly bundledKubectlPath = bundledPath public static readonly bundledKubectlPath = bundledPath
public static readonly bundledKubectlVersion: string = bundledVersion public static readonly bundledKubectlVersion: string = bundledVersion
private static bundledInstance: Kubectl; private static bundledInstance: Kubectl;
// Returns the single bundled Kubectl instance // Returns the single bundled Kubectl instance
public static bundled() { public static bundled() {
if(!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion) if (!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion)
return Kubectl.bundledInstance return Kubectl.bundledInstance
} }
@ -69,7 +71,7 @@ export class Kubectl {
const minorVersion = versionParts[1] const minorVersion = versionParts[1]
/* minorVersion is the first two digits of kube server version /* minorVersion is the first two digits of kube server version
if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */ if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */
if(kubectlMap.has(minorVersion)) { if (kubectlMap.has(minorVersion)) {
this.kubectlVersion = kubectlMap.get(minorVersion) this.kubectlVersion = kubectlMap.get(minorVersion)
logger.debug("Set kubectl version " + this.kubectlVersion + " for cluster version " + clusterVersion + " using version map") logger.debug("Set kubectl version " + this.kubectlVersion + " for cluster version " + clusterVersion + " using version map")
} else { } else {
@ -79,16 +81,16 @@ export class Kubectl {
let arch = null let arch = null
if(process.arch == "x64") { if (process.arch == "x64") {
arch = "amd64" arch = "amd64"
} else if(process.arch == "x86" || process.arch == "ia32") { } else if (process.arch == "x86" || process.arch == "ia32") {
arch = "386" arch = "386"
} else { } else {
arch = process.arch arch = process.arch
} }
const platformName = process.platform === "win32" ? "windows" : process.platform const platformName = isWindows ? "windows" : process.platform
const binaryName = process.platform === "win32" ? "kubectl.exe" : "kubectl" const binaryName = isWindows ? "kubectl.exe" : "kubectl"
this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${platformName}/${arch}/${binaryName}` this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${platformName}/${arch}/${binaryName}`
@ -96,11 +98,11 @@ export class Kubectl {
this.path = path.join(this.dirname, binaryName) this.path = path.join(this.dirname, binaryName)
} }
public async kubectlPath(): Promise<string> { public async getPath(): Promise<string> {
try { try {
await this.ensureKubectl() await this.ensureKubectl()
return this.path return this.path
} catch(err) { } catch (err) {
logger.error("Failed to ensure kubectl, fallback to the bundled version") logger.error("Failed to ensure kubectl, fallback to the bundled version")
logger.error(err) logger.error(err)
return Kubectl.bundledKubectlPath return Kubectl.bundledKubectlPath
@ -111,7 +113,7 @@ export class Kubectl {
try { try {
await this.ensureKubectl() await this.ensureKubectl()
return this.dirname return this.dirname
} catch(err) { } catch (err) {
logger.error(err) logger.error(err)
return "" return ""
} }
@ -136,8 +138,7 @@ export class Kubectl {
return true return true
} }
logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`) logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`)
} } catch (err) {
catch(err) {
logger.error(`Local kubectl failed to run properly (${err.message}), unlinking`) logger.error(`Local kubectl failed to run properly (${err.message}), unlinking`)
} }
await fs.promises.unlink(this.path) await fs.promises.unlink(this.path)
@ -146,7 +147,7 @@ export class Kubectl {
} }
protected async checkBundled(): Promise<boolean> { protected async checkBundled(): Promise<boolean> {
if(this.kubectlVersion === Kubectl.bundledKubectlVersion) { if (this.kubectlVersion === Kubectl.bundledKubectlVersion) {
try { try {
const exist = await pathExists(this.path) const exist = await pathExists(this.path)
if (!exist) { if (!exist) {
@ -154,7 +155,7 @@ export class Kubectl {
await fs.promises.chmod(this.path, 0o755) await fs.promises.chmod(this.path, 0o755)
} }
return true return true
} catch(err) { } catch (err) {
logger.error("Could not copy the bundled kubectl to app-data: " + err) logger.error("Could not copy the bundled kubectl to app-data: " + err)
return false return false
} }
@ -169,10 +170,15 @@ export class Kubectl {
logger.debug(`Acquired a lock for ${this.kubectlVersion}`) logger.debug(`Acquired a lock for ${this.kubectlVersion}`)
const bundled = await this.checkBundled() const bundled = await this.checkBundled()
const isValid = await this.checkBinary(!bundled) const isValid = await this.checkBinary(!bundled)
if(!isValid) { if (!isValid) {
await this.downloadKubectl().catch((error) => { logger.error(error) }); await this.downloadKubectl().catch((error) => {
logger.error(error)
});
} }
await this.writeInitScripts().catch((error) => { logger.error("Failed to write init scripts"); logger.error(error) }) await this.writeInitScripts().catch((error) => {
logger.error("Failed to write init scripts");
logger.error(error)
})
logger.debug(`Releasing lock for ${this.kubectlVersion}`) logger.debug(`Releasing lock for ${this.kubectlVersion}`)
release() release()
return true return true
@ -188,10 +194,10 @@ export class Kubectl {
logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`) logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const stream = request({ const stream = customRequest({
url: this.url,
gzip: true, gzip: true,
...this.getRequestOpts() });
})
const file = fs.createWriteStream(this.path) const file = fs.createWriteStream(this.path)
stream.on("complete", () => { stream.on("complete", () => {
logger.debug("kubectl binary download finished") logger.debug("kubectl binary download finished")
@ -199,12 +205,16 @@ export class Kubectl {
}) })
stream.on("error", (error) => { stream.on("error", (error) => {
logger.error(error) logger.error(error)
fs.unlink(this.path, null) fs.unlink(this.path, () => {
// do nothing
})
reject(error) reject(error)
}) })
file.on("close", () => { file.on("close", () => {
logger.debug("kubectl binary download closed") logger.debug("kubectl binary download closed")
fs.chmod(this.path, 0o755, null) fs.chmod(this.path, 0o755, (err) => {
if (err) reject(err);
})
resolve() resolve()
}) })
stream.pipe(file) stream.pipe(file)
@ -213,7 +223,7 @@ export class Kubectl {
protected async scriptIsLatest(scriptPath: string) { protected async scriptIsLatest(scriptPath: string) {
const scriptExists = await pathExists(scriptPath) const scriptExists = await pathExists(scriptPath)
if(!scriptExists) return false if (!scriptExists) return false
try { try {
const filehandle = await fs.promises.open(scriptPath, 'r') const filehandle = await fs.promises.open(scriptPath, 'r')
@ -232,7 +242,7 @@ export class Kubectl {
const fsPromises = fs.promises; const fsPromises = fs.promises;
const bashScriptPath = path.join(this.dirname, '.bash_set_path') const bashScriptPath = path.join(this.dirname, '.bash_set_path')
const bashScriptIsLatest = await this.scriptIsLatest(bashScriptPath) const bashScriptIsLatest = await this.scriptIsLatest(bashScriptPath)
if(!bashScriptIsLatest) { if (!bashScriptIsLatest) {
let bashScript = "" + initScriptVersionString let bashScript = "" + initScriptVersionString
bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n" bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n" bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"
@ -251,7 +261,7 @@ export class Kubectl {
const zshScriptPath = path.join(this.dirname, '.zlogin') const zshScriptPath = path.join(this.dirname, '.zlogin')
const zshScriptIsLatest = await this.scriptIsLatest(zshScriptPath) const zshScriptIsLatest = await this.scriptIsLatest(zshScriptPath)
if(!zshScriptIsLatest) { if (!zshScriptIsLatest) {
let zshScript = "" + initScriptVersionString let zshScript = "" + initScriptVersionString
zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n" zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
@ -278,14 +288,8 @@ export class Kubectl {
} }
} }
protected getRequestOpts() {
return globalRequestOpts({
url: this.url
})
}
protected getDownloadMirror() { protected getDownloadMirror() {
const mirror = packageMirrors.get(userStore.getPreferences().downloadMirror) const mirror = packageMirrors.get(userStore.preferences?.downloadMirror)
if (mirror) { if (mirror) {
return mirror return mirror
} }

View File

@ -1,19 +1,7 @@
import packageInfo from "../../package.json" import packageInfo from "../../package.json"
import { bundledKubectl, Kubectl } from "../../src/main/kubectl"; import { bundledKubectl, Kubectl } from "../../src/main/kubectl";
import { UserStore } from "../common/user-store";
jest.mock("../common/user-store", () => { jest.mock("../common/user-store");
const userStoreMock: Partial<UserStore> = {
getPreferences() {
return {
downloadMirror: "default"
}
}
}
return {
userStore: userStoreMock,
}
})
describe("kubectlVersion", () => { describe("kubectlVersion", () => {
it("returns bundled version if exactly same version used", async () => { it("returns bundled version if exactly same version used", async () => {

View File

@ -164,13 +164,17 @@ export class LensBinary {
stream.on("error", (error) => { stream.on("error", (error) => {
logger.error(error) logger.error(error)
fs.unlink(binaryPath, null) fs.unlink(binaryPath, () => {
// do nothing
})
throw(error) throw(error)
}) })
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
file.on("close", () => { file.on("close", () => {
logger.debug(`${this.originalBinaryName} binary download closed`) logger.debug(`${this.originalBinaryName} binary download closed`)
if (!this.tarPath) fs.chmod(binaryPath, 0o755, null) if (!this.tarPath) fs.chmod(binaryPath, 0o755, (err) => {
if (err) reject(err);
})
resolve() resolve()
}) })
stream.pipe(file) stream.pipe(file)

141
src/main/lens-proxy.ts Normal file
View File

@ -0,0 +1,141 @@
import net from "net";
import http from "http";
import httpProxy from "http-proxy";
import url from "url";
import * as WebSocket from "ws"
import { openShell } from "./node-shell-session";
import { Router } from "./router"
import { ClusterManager } from "./cluster-manager"
import { ContextHandler } from "./context-handler";
import { apiKubePrefix } from "../common/vars";
import logger from "./logger"
export class LensProxy {
protected origin: string
protected proxyServer: http.Server
protected router: Router
protected closed = false
protected retryCounters = new Map<string, number>()
static create(port: number, clusterManager: ClusterManager) {
return new LensProxy(port, clusterManager).listen();
}
private constructor(protected port: number, protected clusterManager: ClusterManager) {
this.origin = `http://localhost:${port}`
this.router = new Router();
}
listen(port = this.port): this {
this.proxyServer = this.buildCustomProxy().listen(port);
logger.info(`LensProxy 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 customProxy = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
this.handleRequest(proxy, req, res);
});
customProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
this.handleWsUpgrade(req, socket, head)
});
customProxy.on("error", (err) => {
logger.error("proxy error", err)
});
return customProxy;
}
protected createProxy(): httpProxy {
const proxy = httpProxy.createProxyServer();
proxy.on("proxyRes", (proxyRes, req, res) => {
if (req.method !== "GET") {
return;
}
if (proxyRes.statusCode === 502) {
const cluster = this.clusterManager.getClusterForRequest(req)
const proxyError = cluster?.contextHandler.proxyLastError;
if (proxyError) {
return res.writeHead(502).end(proxyError);
}
}
const reqId = this.getRequestId(req);
if (this.retryCounters.has(reqId)) {
logger.debug(`Resetting proxy retry cache for url: ${reqId}`);
this.retryCounters.delete(reqId)
}
})
proxy.on("error", (error, req, res, target) => {
if (this.closed) {
return;
}
if (target) {
logger.debug("Failed proxy to target: " + JSON.stringify(target, null, 2));
if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) {
const reqId = this.getRequestId(req);
const retryCount = this.retryCounters.get(reqId) || 0
const timeoutMs = retryCount * 250
if (retryCount < 20) {
logger.debug(`Retrying proxy request to url: ${reqId}`)
setTimeout(() => {
this.retryCounters.set(reqId, retryCount + 1)
this.handleRequest(proxy, req, res)
}, timeoutMs)
}
}
}
res.writeHead(500).end("Oops, something went wrong.")
})
return proxy;
}
protected createWsListener(): WebSocket.Server {
const ws = new WebSocket.Server({ noServer: true })
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();
openShell(socket, cluster, nodeParam);
}));
}
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions> {
if (req.url.startsWith(apiKubePrefix)) {
delete req.headers.authorization
req.url = req.url.replace(apiKubePrefix, "")
const isWatchRequest = req.url.includes("watch=")
return await contextHandler.getApiTarget(isWatchRequest)
}
}
protected getRequestId(req: http.IncomingMessage) {
return req.headers.host + req.url;
}
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) {
const cluster = this.clusterManager.getClusterForRequest(req)
if (cluster) {
await cluster.contextHandler.ensureServer();
const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler)
if (proxyTarget) {
// allow to fetch apis in "clusterId.localhost:port" from "localhost:port"
res.setHeader("Access-Control-Allow-Origin", this.origin);
return proxy.web(req, res, proxyTarget);
}
}
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

@ -1,60 +1,75 @@
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron" import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron"
import { isDevelopment, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars"; import { autorun } from "mobx";
import { WindowManager } from "./window-manager";
import { appName, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars";
import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route";
import logger from "./logger";
// todo: refactor + split menu sections to separated files, e.g. menus/file.menu.ts export function initMenu(windowManager: WindowManager) {
autorun(() => buildMenu(windowManager), {
export interface MenuOptions { delay: 100
logoutHook: any; });
addClusterHook: any;
clusterSettingsHook: any;
showWhatsNewHook: any;
showPreferencesHook: any;
// all the above are really () => void type functions
} }
function setClusterSettingsEnabled(enabled: boolean) { export function buildMenu(windowManager: WindowManager) {
const menuIndex = isMac ? 1 : 0 function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) {
Menu.getApplicationMenu().items[menuIndex].submenu.items[1].enabled = enabled if (isMac) return [];
} return menuItems;
}
function showAbout(_menuitem: MenuItem, browserWindow: BrowserWindow) {
const appDetails = [ function activeClusterOnly(menuItems: MenuItemConstructorOptions[]) {
`Version: ${app.getVersion()}`, if (!windowManager.activeClusterId) {
] menuItems.forEach(item => {
appDetails.push(`Copyright 2020 Mirantis, Inc.`) item.enabled = false
let title = "Lens" });
if (isWindows) { }
title = ` ${title}` return menuItems;
}
function navigate(url: string) {
logger.info(`[MENU]: navigating to ${url}`);
windowManager.navigate({
channel: "menu:navigate",
url: url,
})
}
function showAbout(browserWindow: BrowserWindow) {
const appInfo = [
`${appName}: ${app.getVersion()}`,
`Electron: ${process.versions.electron}`,
`Chrome: ${process.versions.chrome}`,
`Copyright 2020 Copyright 2020 Mirantis, Inc.`,
]
dialog.showMessageBoxSync(browserWindow, {
title: `${isWindows ? " ".repeat(2) : ""}${appName}`,
type: "info",
buttons: ["Close"],
message: `Lens`,
detail: appInfo.join("\r\n")
})
} }
dialog.showMessageBoxSync(browserWindow, {
title,
type: "info",
buttons: ["Close"],
message: `Lens`,
detail: appDetails.join("\r\n")
})
}
/**
* Constructs the menu based on the example at: https://electronjs.org/docs/api/menu#main-process
* Menu items are constructed piece-by-piece to have slightly better control on individual sub-menus
*
* @param ipc the main promiceIpc handle. Needed to be able to hook IPC sending into logout click handler.
*/
export default function initMenu(opts: MenuOptions, promiseIpc: any) {
const mt: MenuItemConstructorOptions[] = []; const mt: MenuItemConstructorOptions[] = [];
const macAppMenu: MenuItemConstructorOptions = { const macAppMenu: MenuItemConstructorOptions = {
label: app.getName(), label: app.getName(),
submenu: [ submenu: [
{ {
label: "About Lens", label: "About Lens",
click: showAbout click(menuItem: MenuItem, browserWindow: BrowserWindow) {
showAbout(browserWindow)
}
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Preferences', label: 'Preferences',
click: opts.showPreferencesHook, click() {
enabled: true navigate(preferencesURL())
}
}, },
{ type: 'separator' }, { type: 'separator' },
{ role: 'services' }, { role: 'services' },
@ -66,51 +81,46 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
{ role: 'quit' } { role: 'quit' }
] ]
}; };
if (isMac) { if (isMac) {
mt.push(macAppMenu); mt.push(macAppMenu);
} }
let fileMenu: MenuItemConstructorOptions; const fileMenu: MenuItemConstructorOptions = {
if (isMac) { label: "File",
fileMenu = { submenu: [
label: 'File',
submenu: [{
label: 'Add Cluster...',
click: opts.addClusterHook,
},
{ {
label: 'Cluster Settings', label: 'Add Cluster',
click: opts.clusterSettingsHook, click() {
enabled: false navigate(addClusterURL())
} }
] },
} ...activeClusterOnly([
}
else {
fileMenu = {
label: 'File',
submenu: [
{
label: 'Add Cluster...',
click: opts.addClusterHook,
},
{ {
label: 'Cluster Settings', label: 'Cluster Settings',
click: opts.clusterSettingsHook, click() {
enabled: false navigate(clusterSettingsURL({
}, params: {
clusterId: windowManager.activeClusterId
}
}))
}
}
]),
...ignoreOnMac([
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Preferences', label: 'Preferences',
click: opts.showPreferencesHook, click() {
enabled: true navigate(preferencesURL())
}
}, },
{ type: 'separator' }, { type: 'separator' },
{ role: 'quit' } { role: 'quit' }
] ])
} ]
} };
mt.push(fileMenu); mt.push(fileMenu)
const editMenu: MenuItemConstructorOptions = { const editMenu: MenuItemConstructorOptions = {
label: 'Edit', label: 'Edit',
@ -126,8 +136,7 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
{ role: 'selectAll' }, { role: 'selectAll' },
] ]
}; };
mt.push(editMenu); mt.push(editMenu)
const viewMenu: MenuItemConstructorOptions = { const viewMenu: MenuItemConstructorOptions = {
label: 'View', label: 'View',
submenu: [ submenu: [
@ -135,21 +144,21 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
label: 'Back', label: 'Back',
accelerator: 'CmdOrCtrl+[', accelerator: 'CmdOrCtrl+[',
click() { click() {
webContents.getFocusedWebContents().executeJavaScript('window.history.back()') webContents.getFocusedWebContents()?.goBack();
} }
}, },
{ {
label: 'Forward', label: 'Forward',
accelerator: 'CmdOrCtrl+]', accelerator: 'CmdOrCtrl+]',
click() { click() {
webContents.getFocusedWebContents().executeJavaScript('window.history.forward()') webContents.getFocusedWebContents()?.goForward();
} }
}, },
{ {
label: 'Reload', label: 'Reload',
accelerator: 'CmdOrCtrl+R', accelerator: 'CmdOrCtrl+R',
click() { click() {
webContents.getFocusedWebContents().reload() webContents.getFocusedWebContents()?.reload();
} }
}, },
{ role: 'toggleDevTools' }, { role: 'toggleDevTools' },
@ -161,19 +170,19 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
{ role: 'togglefullscreen' } { role: 'togglefullscreen' }
] ]
}; };
mt.push(viewMenu); mt.push(viewMenu)
const helpMenu: MenuItemConstructorOptions = { const helpMenu: MenuItemConstructorOptions = {
role: 'help', role: 'help',
submenu: [ submenu: [
{ {
label: 'License', label: "License",
click: async () => { click: async () => {
shell.openExternal('https://k8slens.dev/licenses/eula.md'); shell.openExternal('https://k8slens.dev/licenses/eula.md');
}, },
}, },
{ {
label: 'Community Slack', label: "Community Slack",
click: async () => { click: async () => {
shell.openExternal(slackUrl); shell.openExternal(slackUrl);
}, },
@ -186,24 +195,22 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
}, },
{ {
label: "What's new?", label: "What's new?",
click: opts.showWhatsNewHook, click() {
navigate(whatsNewURL())
},
}, },
...(!isMac ? [{ ...ignoreOnMac([
label: "About Lens", {
click: showAbout label: "About Lens",
} as MenuItemConstructorOptions] : []) click(menuItem: MenuItem, browserWindow: BrowserWindow) {
showAbout(browserWindow)
}
}
])
] ]
}; };
mt.push(helpMenu);
const menu = Menu.buildFromTemplate(mt); mt.push(helpMenu)
Menu.setApplicationMenu(menu);
promiseIpc.on("enableClusterSettingsMenuItem", (clusterId: string) => { Menu.setApplicationMenu(Menu.buildFromTemplate(mt));
setClusterSettingsEnabled(true)
});
promiseIpc.on("disableClusterSettingsMenuItem", () => {
setClusterSettingsEnabled(false)
});
} }

View File

@ -3,25 +3,25 @@ import * as pty from "node-pty"
import { ShellSession } from "./shell-session"; import { ShellSession } from "./shell-session";
import { v4 as uuid } from "uuid" import { v4 as uuid } from "uuid"
import * as k8s from "@kubernetes/client-node" import * as k8s from "@kubernetes/client-node"
import { KubeConfig } from "@kubernetes/client-node"
import { Cluster } from "./cluster"
import logger from "./logger"; import logger from "./logger";
import { KubeConfig, V1Pod } from "@kubernetes/client-node"; import { tracker } from "../common/tracker";
import { tracker } from "./tracker"
import { Cluster, ClusterPreferences } from "./cluster"
export class NodeShellSession extends ShellSession { export class NodeShellSession extends ShellSession {
protected nodeName: string; protected nodeName: string;
protected podId: string protected podId: string
protected kc: KubeConfig protected kc: KubeConfig
constructor(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster, nodeName: string) { constructor(socket: WebSocket, cluster: Cluster, nodeName: string) {
super(socket, pathToKubeconfig, cluster) super(socket, cluster)
this.nodeName = nodeName this.nodeName = nodeName
this.podId = `node-shell-${uuid()}` this.podId = `node-shell-${uuid()}`
this.kc = cluster.proxyKubeconfig() this.kc = cluster.getProxyKubeconfig()
} }
public async open() { public async open() {
const shell = await this.kubectl.kubectlPath() const shell = await this.kubectl.getPath()
let args = [] let args = []
if (this.createNodeShellPod(this.podId, this.nodeName)) { if (this.createNodeShellPod(this.podId, this.nodeName)) {
await this.waitForRunningPod(this.podId).catch((error) => { await this.waitForRunningPod(this.podId).catch((error) => {
@ -107,7 +107,7 @@ export class NodeShellSession extends ShellSession {
const watch = new k8s.Watch(kc); const watch = new k8s.Watch(kc);
const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {}, const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {},
// callback is called for each received object. // callback is called for each received object.
(_type, obj) => { (_type, obj) => {
if (obj.metadata.name == podId && obj.status.phase === "Running") { if (obj.metadata.name == podId && obj.status.phase === "Running") {
resolve(true) resolve(true)
@ -119,9 +119,13 @@ export class NodeShellSession extends ShellSession {
reject(false) reject(false)
} }
); );
setTimeout(() => { req.abort(); reject(false); }, 120 * 1000); setTimeout(() => {
req.abort();
reject(false);
}, 120 * 1000);
}) })
} }
protected deleteNodeShellPod() { protected deleteNodeShellPod() {
const kc = this.getKubeConfig(); const kc = this.getKubeConfig();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api); const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
@ -129,16 +133,13 @@ export class NodeShellSession extends ShellSession {
} }
} }
export async function open(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster, nodeName?: string): Promise<ShellSession> { export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise<ShellSession> {
return new Promise(async(resolve, reject) => { let shell: ShellSession;
let shell = null if (nodeName) {
if (nodeName) { shell = new NodeShellSession(socket, cluster, nodeName)
shell = new NodeShellSession(socket, pathToKubeconfig, cluster, nodeName) } else {
} shell = new ShellSession(socket, cluster);
else { }
shell = new ShellSession(socket, pathToKubeconfig, cluster) shell.open()
} return shell;
shell.open()
resolve(shell)
})
} }

View File

@ -1,29 +1,22 @@
import net, { AddressInfo } from "net"
import logger from "./logger" import logger from "./logger"
import { createServer, AddressInfo } from "net"
const getNextAvailablePort = () => { // todo: check https://github.com/http-party/node-portfinder ?
logger.debug("getNextAvailablePort() start")
const server = createServer()
server.unref()
return new Promise<number>((resolve, reject) =>
server
.on('error', (error: any) => reject(error))
.on('listening', () => {
logger.debug("*** server listening event ***")
const _port = (server.address() as AddressInfo).port
server.close(() => resolve(_port))
})
.listen({host: "127.0.0.1", port: 0}))
}
export const getFreePort = async () => { export async function getFreePort(): Promise<number> {
logger.debug("getFreePort() start") logger.debug("Lookup new free port..");
let freePort: number = null return new Promise((resolve, reject) => {
try { const server = net.createServer()
freePort = await getNextAvailablePort() server.unref()
logger.debug("got port : " + freePort) server.on("listening", () => {
} catch(error) { const port = (server.address() as AddressInfo).port
throw("getNextAvailablePort() threw: '" + error + "'") server.close(() => resolve(port));
} logger.debug(`New port found: ${port}`);
return freePort });
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,6 +1,8 @@
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { getFreePort } from "./port" import { getFreePort } from "./port"
let newPort = 0;
jest.mock("net", () => { jest.mock("net", () => {
return { return {
createServer() { createServer() {
@ -10,7 +12,10 @@ jest.mock("net", () => {
return this return this
}) })
address = () => { address = () => {
return { port: 12345 } newPort = Math.round(Math.random() * 10000)
return {
port: newPort
}
} }
unref = jest.fn() unref = jest.fn()
close = jest.fn(cb => cb()) close = jest.fn(cb => cb())
@ -21,6 +26,6 @@ jest.mock("net", () => {
describe("getFreePort", () => { describe("getFreePort", () => {
it("finds the next free port", async () => { it("finds the next free port", async () => {
return expect(getFreePort()).resolves.toEqual(expect.any(Number)) return expect(getFreePort()).resolves.toEqual(newPort);
}) })
}) })

View File

@ -1,167 +0,0 @@
import http from "http";
import httpProxy from "http-proxy";
import { Socket } from "net";
import * as url from "url";
import * as WebSocket from "ws"
import { ContextHandler } from "./context-handler";
import logger from "./logger"
import * as shell from "./node-shell-session"
import { ClusterManager } from "./cluster-manager"
import { Router } from "./router"
import { apiPrefix } from "../common/vars";
export class LensProxy {
public static readonly localShellSessions = true
public port: number;
protected clusterUrl: url.UrlWithStringQuery
protected clusterManager: ClusterManager
protected retryCounters: Map<string, number> = new Map()
protected router: Router
protected proxyServer: http.Server
protected closed = false
constructor(port: number, clusterManager: ClusterManager) {
this.port = port
this.clusterManager = clusterManager
this.router = new Router()
}
public run() {
const proxyServer = this.buildProxyServer();
proxyServer.listen(this.port, "127.0.0.1")
this.proxyServer = proxyServer
}
public close() {
logger.info("Closing proxy server")
this.proxyServer.close()
this.closed = true
}
protected buildProxyServer() {
const proxy = this.createProxy();
const proxyServer = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
this.handleRequest(proxy, req, res);
});
proxyServer.on("upgrade", (req: http.IncomingMessage, socket: Socket, head: Buffer) => {
this.handleWsUpgrade(req, socket, head)
});
proxyServer.on("error", (err) => {
logger.error(err)
});
return proxyServer;
}
protected createProxy() {
const proxy = httpProxy.createProxyServer();
proxy.on("proxyRes", (proxyRes, req, res) => {
if (proxyRes.statusCode === 502) {
const cluster = this.clusterManager.getClusterForRequest(req)
if (cluster && cluster.contextHandler.proxyServerError()) {
res.writeHead(proxyRes.statusCode, {
"Content-Type": "text/plain"
})
res.end(cluster.contextHandler.proxyServerError())
return
}
}
if (req.method !== "GET") {
return
}
const key = `${req.headers.host}${req.url}`
if (this.retryCounters.has(key)) {
logger.debug("Resetting proxy retry cache for url: " + key)
this.retryCounters.delete(key)
}
})
proxy.on("error", (error, req, res, target) => {
if(this.closed) {
return
}
if (target) {
logger.debug("Failed proxy to target: " + JSON.stringify(target))
if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) {
const retryCounterKey = `${req.headers.host}${req.url}`
const retryCount = this.retryCounters.get(retryCounterKey) || 0
if (retryCount < 20) {
logger.debug("Retrying proxy request to url: " + retryCounterKey)
setTimeout(() => {
this.retryCounters.set(retryCounterKey, retryCount + 1)
this.handleRequest(proxy, req, res)
}, (250 * retryCount))
}
}
}
res.writeHead(500, {
'Content-Type': 'text/plain'
})
res.end('Oops, something went wrong.')
})
return proxy;
}
protected createWsListener() {
const ws = new WebSocket.Server({ noServer: true })
ws.on("connection", ((con: WebSocket, req: http.IncomingMessage) => {
const cluster = this.clusterManager.getClusterForRequest(req)
const contextHandler = cluster.contextHandler
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
contextHandler.withTemporaryKubeconfig((kubeconfigPath) => {
return new Promise<boolean>(async (resolve, reject) => {
const shellSession = await shell.open(con, kubeconfigPath, cluster, nodeParam)
shellSession.on("exit", () => {
resolve(true)
})
})
})
}))
return ws
}
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions> {
const prefix = apiPrefix.KUBE_BASE;
if (req.url.startsWith(prefix)) {
delete req.headers.authorization
req.url = req.url.replace(prefix, "")
const isWatchRequest = req.url.includes("watch=")
return await contextHandler.getApiTarget(isWatchRequest)
}
}
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) {
const cluster = this.clusterManager.getClusterForRequest(req)
if (!cluster) {
logger.error("Got request to unknown cluster")
logger.debug(req.headers.host + req.url)
res.statusCode = 503
res.end()
return
}
const contextHandler = cluster.contextHandler
contextHandler.ensureServer().then(async () => {
const proxyTarget = await this.getProxyTarget(req, contextHandler)
if (proxyTarget) {
proxy.web(req, res, proxyTarget)
} else {
this.router.route(cluster, req, res)
}
})
}
protected async handleWsUpgrade(req: http.IncomingMessage, socket: Socket, head: Buffer) {
const wsServer = this.createWsListener();
wsServer.handleUpgrade(req, socket, head, (con) => {
wsServer.emit("connection", con, req);
});
}
}
export function listen(port: number, clusterManager: ClusterManager) {
const proxyServer = new LensProxy(port, clusterManager)
proxyServer.run();
return proxyServer;
}

View File

@ -1,17 +0,0 @@
import { LensApiRequest } from "./router"
import * as resourceApplier from "./resource-applier"
import { LensApi } from "./lens-api"
class ResourceApplierApi extends LensApi {
public async applyResource(request: LensApiRequest) {
const { response, cluster, payload } = request
try {
const resource = await resourceApplier.apply(cluster, cluster.proxyKubeconfigPath(), payload)
this.respondJson(response, [resource], 200)
} catch(error) {
this.respondText(response, error, 422)
}
}
}
export const resourceApplierApi = new ResourceApplierApi()

View File

@ -1,47 +1,31 @@
import type { Cluster } from "./cluster";
import { KubernetesObject } from "@kubernetes/client-node"
import { exec } from "child_process"; import { exec } from "child_process";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import path from "path"; import path from "path";
import * as tempy from "tempy"; import * as tempy from "tempy";
import logger from "./logger" import logger from "./logger"
import { Cluster } from "./cluster"; import { tracker } from "../common/tracker";
import { tracker } from "./tracker"; import { cloneJsonObject } from "../common/utils";
type KubeObject = {
status: {};
metadata?: {
resourceVersion: number;
annotations?: {
"kubectl.kubernetes.io/last-applied-configuration": string;
};
};
}
export class ResourceApplier { export class ResourceApplier {
protected kubeconfigPath: string; constructor(protected cluster: Cluster) {
protected cluster: Cluster
constructor(cluster: Cluster, pathToKubeconfig: string) {
this.kubeconfigPath = pathToKubeconfig
this.cluster = cluster
} }
public async apply(resource: any): Promise<string> { async apply(resource: KubernetesObject | any): Promise<string> {
this.sanitizeObject((resource as KubeObject)) resource = this.sanitizeObject(resource);
try { tracker.event("resource", "apply")
tracker.event("resource", "apply") return await this.kubectlApply(yaml.safeDump(resource));
return await this.kubectlApply(yaml.safeDump(resource))
} catch(error) {
throw (error)
}
} }
protected async kubectlApply(content: string): Promise<string> { protected async kubectlApply(content: string): Promise<string> {
const kubectl = await this.cluster.kubeCtl.kubectlPath() const { kubeCtl, kubeConfigPath } = this.cluster;
const kubectlPath = await kubeCtl.getPath()
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const fileName = tempy.file({name: "resource.yaml"}) const fileName = tempy.file({ name: "resource.yaml" })
fs.writeFileSync(fileName, content) fs.writeFileSync(fileName, content)
const cmd = `"${kubectl}" apply --kubeconfig ${this.kubeconfigPath} -o json -f ${fileName}` const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${fileName}"`
logger.debug("shooting manifests with: " + cmd); logger.debug("shooting manifests with: " + cmd);
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env) const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env)
const httpsProxy = this.cluster.preferences?.httpsProxy const httpsProxy = this.cluster.preferences?.httpsProxy
@ -62,17 +46,18 @@ export class ResourceApplier {
} }
public async kubectlApplyAll(resources: string[]): Promise<string> { public async kubectlApplyAll(resources: string[]): Promise<string> {
const kubectl = await this.cluster.kubeCtl.kubectlPath() const { kubeCtl, kubeConfigPath } = this.cluster;
return new Promise<string>((resolve, reject) => { const kubectlPath = await kubeCtl.getPath()
return new Promise((resolve, reject) => {
const tmpDir = tempy.directory() const tmpDir = tempy.directory()
// Dump each resource into tmpDir // Dump each resource into tmpDir
for (const i in resources) { resources.forEach((resource, index) => {
fs.writeFileSync(path.join(tmpDir, `${i}.yaml`), resources[i]) fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource);
} })
const cmd = `"${kubectl}" apply --kubeconfig ${this.kubeconfigPath} -o json -f ${tmpDir}` const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${tmpDir}"`
console.log("shooting manifests with:", cmd); console.log("shooting manifests with:", cmd);
exec(cmd, (error, stdout, stderr) => { exec(cmd, (error, stdout, stderr) => {
if(error) { if (error) {
reject("Error applying manifests:" + error); reject("Error applying manifests:" + error);
} }
if (stderr != "") { if (stderr != "") {
@ -84,18 +69,14 @@ export class ResourceApplier {
}) })
} }
protected sanitizeObject(resource: KubeObject) { protected sanitizeObject(resource: KubernetesObject | any) {
delete resource['status'] resource = cloneJsonObject(resource);
if (resource['metadata']) { delete resource.status;
if (resource['metadata']['annotations'] && resource['metadata']['annotations']['kubectl.kubernetes.io/last-applied-configuration']) { delete resource.metadata?.resourceVersion;
delete resource['metadata']['annotations']['kubectl.kubernetes.io/last-applied-configuration'] const annotations = resource.metadata?.annotations;
} if (annotations) {
delete resource['metadata']['resourceVersion'] delete annotations['kubectl.kubernetes.io/last-applied-configuration'];
} }
return resource;
} }
} }
export async function apply(cluster: Cluster, pathToKubeconfig: string, resource: any) {
const resourceApplier = new ResourceApplier(cluster, pathToKubeconfig)
return await resourceApplier.apply(resource)
}

View File

@ -4,71 +4,68 @@ import http from "http"
import path from "path" import path from "path"
import { readFile } from "fs-extra" import { readFile } from "fs-extra"
import { Cluster } from "./cluster" import { Cluster } from "./cluster"
import { configRoute } from "./routes/config" import { apiPrefix, appName, publicPath } from "../common/vars";
import { helmApi } from "./helm-api" import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes";
import { resourceApplierApi } from "./resource-applier-api"
import { kubeconfigRoute } from "./routes/kubeconfig"
import { metricsRoute } from "./routes/metrics"
import { watchRoute } from "./routes/watch"
import { portForwardRoute } from "./routes/port-forward"
import { apiPrefix, outDir, reactAppName } from "../common/vars";
const mimeTypes: Record<string, string> = { export interface RouterRequestOpts {
"html": "text/html", req: http.IncomingMessage;
"txt": "text/plain", res: http.ServerResponse;
"css": "text/css", cluster: Cluster;
"gif": "image/gif", params: RouteParams;
"jpg": "image/jpeg", url: URL;
"png": "image/png",
"svg": "image/svg+xml",
"js": "application/javascript",
"woff2": "font/woff2",
"ttf": "font/ttf"
};
interface RouteParams {
[key: string]: string | undefined;
} }
export type LensApiRequest = { export interface RouteParams extends Record<string, string> {
cluster: Cluster; path?: string; // *-route
payload: any; namespace?: string;
raw: { service?: string;
req: http.IncomingMessage; account?: string;
}; release?: string;
repo?: string;
chart?: string;
}
export interface LensApiRequest<P = any> {
path: string;
payload: P;
params: RouteParams; params: RouteParams;
cluster: Cluster;
response: http.ServerResponse; response: http.ServerResponse;
query: URLSearchParams; query: URLSearchParams;
path: string; raw: {
req: http.IncomingMessage;
}
} }
export class Router { export class Router {
protected router: any protected router: any;
public constructor() { public constructor() {
this.router = new Call.Router(); this.router = new Call.Router();
this.addRoutes() this.addRoutes()
} }
public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse) { public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise<boolean> {
const url = new URL(req.url, "http://localhost") const url = new URL(req.url, "http://localhost");
const path = url.pathname const path = url.pathname
const method = req.method.toLowerCase() const method = req.method.toLowerCase()
const matchingRoute = this.router.route(method, path) const matchingRoute = this.router.route(method, path);
const routeFound = !matchingRoute.isBoom;
if (matchingRoute.isBoom !== true) { // route() returns error if route not found -> object.isBoom === true if (routeFound) {
const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params }) const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params });
await matchingRoute.route(request) await matchingRoute.route(request)
return true return true
} else {
return false
} }
return false;
} }
protected async getRequest(opts: { req: http.IncomingMessage; res: http.ServerResponse; cluster: Cluster; url: URL; params: RouteParams }) { protected async getRequest(opts: RouterRequestOpts): Promise<LensApiRequest> {
const { req, res, url, cluster, params } = opts const { req, res, url, cluster, params } = opts
const { payload } = await Subtext.parse(req, null, { parse: true, output: 'data' }); const { payload } = await Subtext.parse(req, null, {
const request: LensApiRequest = { parse: true,
output: "data",
});
return {
cluster: cluster, cluster: cluster,
path: url.pathname, path: url.pathname,
raw: { raw: {
@ -79,67 +76,68 @@ export class Router {
payload: payload, payload: payload,
params: params params: params
} }
return request
} }
protected getMimeType(filename: string) { protected getMimeType(filename: string) {
const mimeTypes: Record<string, string> = {
html: "text/html",
txt: "text/plain",
css: "text/css",
gif: "image/gif",
jpg: "image/jpeg",
png: "image/png",
svg: "image/svg+xml",
js: "application/javascript",
woff2: "font/woff2",
ttf: "font/ttf"
};
return mimeTypes[path.extname(filename).slice(1)] || "text/plain" return mimeTypes[path.extname(filename).slice(1)] || "text/plain"
} }
protected async handleStaticFile(filePath: string, response: http.ServerResponse) { async handleStaticFile(filePath: string, res: http.ServerResponse) {
const asset = path.resolve(outDir, filePath); const asset = path.join(__static, filePath);
try { try {
const data = await readFile(asset); const data = await readFile(asset);
response.setHeader("Content-Type", this.getMimeType(asset)); res.setHeader("Content-Type", this.getMimeType(asset));
response.write(data) res.write(data)
response.end() res.end()
} catch (err) { } catch (err) {
// default to index.html so that react routes work when page is refreshed this.handleStaticFile(`${publicPath}/${appName}.html`, res);
this.handleStaticFile(`${reactAppName}.html`, response)
} }
} }
protected addRoutes() { protected addRoutes() {
const {
BASE: apiBase,
KUBE_HELM: apiHelm,
KUBE_RESOURCE_APPLIER: apiResource,
} = apiPrefix;
// Static assets // Static assets
this.router.add({ method: 'get', path: '/{path*}' }, (request: LensApiRequest) => { this.router.add({ method: 'get', path: '/{path*}' }, ({ params, response }: LensApiRequest) => {
const { response, params } = request this.handleStaticFile(params.path, response);
const file = params.path || "/index.html" });
this.handleStaticFile(file, response)
})
this.router.add({ method: "get", path: `${apiBase}/config` }, configRoute.routeConfig.bind(configRoute)) this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute))
this.router.add({ method: "get", path: `${apiBase}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute))
// Watch API // Watch API
this.router.add({ method: "get", path: `${apiBase}/watch` }, watchRoute.routeWatch.bind(watchRoute)) this.router.add({ method: "get", path: `${apiPrefix}/watch` }, watchRoute.routeWatch.bind(watchRoute))
// Metrics API // Metrics API
this.router.add({ method: "post", path: `${apiBase}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)) this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute))
// Port-forward API // Port-forward API
this.router.add({ method: "post", path: `${apiBase}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, portForwardRoute.routePortForward.bind(portForwardRoute)) this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, portForwardRoute.routePortForward.bind(portForwardRoute))
// Helm API // Helm API
this.router.add({ method: "get", path: `${apiHelm}/v2/charts` }, helmApi.listCharts.bind(helmApi)) this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute))
this.router.add({ method: "get", path: `${apiHelm}/v2/charts/{repo}/{chart}` }, helmApi.getChart.bind(helmApi)) this.router.add({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}` }, helmRoute.getChart.bind(helmRoute))
this.router.add({ method: "get", path: `${apiHelm}/v2/charts/{repo}/{chart}/values` }, helmApi.getChartValues.bind(helmApi)) this.router.add({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}/values` }, helmRoute.getChartValues.bind(helmRoute))
this.router.add({ method: "post", path: `${apiHelm}/v2/releases` }, helmApi.installChart.bind(helmApi)) this.router.add({ method: "post", path: `${apiPrefix}/v2/releases` }, helmRoute.installChart.bind(helmRoute))
this.router.add({ method: `put`, path: `${apiHelm}/v2/releases/{namespace}/{release}` }, helmApi.updateRelease.bind(helmApi)) this.router.add({ method: `put`, path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, helmRoute.updateRelease.bind(helmRoute))
this.router.add({ method: `put`, path: `${apiHelm}/v2/releases/{namespace}/{release}/rollback` }, helmApi.rollbackRelease.bind(helmApi)) this.router.add({ method: `put`, path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback` }, helmRoute.rollbackRelease.bind(helmRoute))
this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace?}` }, helmApi.listReleases.bind(helmApi)) this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace?}` }, helmRoute.listReleases.bind(helmRoute))
this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace}/{release}` }, helmApi.getRelease.bind(helmApi)) this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, helmRoute.getRelease.bind(helmRoute))
this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace}/{release}/values` }, helmApi.getReleaseValues.bind(helmApi)) this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/values` }, helmRoute.getReleaseValues.bind(helmRoute))
this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace}/{release}/history` }, helmApi.getReleaseHistory.bind(helmApi)) this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/history` }, helmRoute.getReleaseHistory.bind(helmRoute))
this.router.add({ method: "delete", path: `${apiHelm}/v2/releases/{namespace}/{release}` }, helmApi.deleteRelease.bind(helmApi)) this.router.add({ method: "delete", path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, helmRoute.deleteRelease.bind(helmRoute))
// Resource Applier API // Resource Applier API
this.router.add({ method: "post", path: `${apiResource}/stack` }, resourceApplierApi.applyResource.bind(resourceApplierApi)) this.router.add({ method: "post", path: `${apiPrefix}/stack` }, resourceApplierRoute.applyResource.bind(resourceApplierRoute))
} }
} }

View File

@ -1,109 +0,0 @@
import { app } from "electron"
import { CoreV1Api } from "@kubernetes/client-node"
import { LensApiRequest } from "../router"
import { LensApi } from "../lens-api"
import { userStore } from "../../common/user-store"
import { Cluster } from "../cluster"
export interface IConfigRoutePayload {
kubeVersion?: string;
clusterName?: string;
lensVersion?: string;
lensTheme?: string;
username?: string;
token?: string;
allowedNamespaces?: string[];
allowedResources?: string[];
isClusterAdmin?: boolean;
chartsEnabled: boolean;
kubectlAccess?: boolean; // User accessed via kubectl-lens plugin
}
// TODO: auto-populate all resources dynamically
const apiResources = [
{ resource: "configmaps" },
{ resource: "cronjobs", group: "batch" },
{ resource: "customresourcedefinitions", group: "apiextensions.k8s.io" },
{ resource: "daemonsets", group: "apps" },
{ resource: "deployments", group: "apps" },
{ resource: "endpoints" },
{ resource: "events" },
{ resource: "horizontalpodautoscalers" },
{ resource: "ingresses", group: "networking.k8s.io" },
{ resource: "jobs", group: "batch" },
{ resource: "namespaces" },
{ resource: "networkpolicies", group: "networking.k8s.io" },
{ resource: "nodes" },
{ resource: "persistentvolumes" },
{ resource: "pods" },
{ resource: "podsecuritypolicies" },
{ resource: "resourcequotas" },
{ resource: "secrets" },
{ resource: "services" },
{ resource: "statefulsets", group: "apps" },
{ resource: "storageclasses", group: "storage.k8s.io" },
]
async function getAllowedNamespaces(cluster: Cluster) {
const api = cluster.proxyKubeconfig().makeApiClient(CoreV1Api)
try {
const namespaceList = await api.listNamespace()
const nsAccessStatuses = await Promise.all(
namespaceList.body.items.map(ns => cluster.canI({
namespace: ns.metadata.name,
resource: "pods",
verb: "list",
}))
)
return namespaceList.body.items
.filter((ns, i) => nsAccessStatuses[i])
.map(ns => ns.metadata.name)
} catch(error) {
const ctx = cluster.proxyKubeconfig().getContextObject(cluster.contextName)
if (ctx.namespace) {
return [ctx.namespace]
}
else {
return []
}
}
}
async function getAllowedResources(cluster: Cluster, namespaces: string[]) {
try {
const resourceAccessStatuses = await Promise.all(
apiResources.map(apiResource => cluster.canI({
resource: apiResource.resource,
group: apiResource.group,
verb: "list",
namespace: namespaces[0]
}))
)
return apiResources
.filter((resource, i) => resourceAccessStatuses[i]).map(apiResource => apiResource.resource)
} catch (error) {
return []
}
}
class ConfigRoute extends LensApi {
public async routeConfig(request: LensApiRequest) {
const { params, response, cluster } = request
const namespaces = await getAllowedNamespaces(cluster)
const data: IConfigRoutePayload = {
clusterName: cluster.contextName,
lensVersion: app.getVersion(),
lensTheme: `kontena-${userStore.getPreferences().colorTheme}`,
kubeVersion: cluster.version,
chartsEnabled: true,
isClusterAdmin: cluster.isAdmin,
allowedResources: await getAllowedResources(cluster, namespaces),
allowedNamespaces: namespaces
};
this.respondJson(response, data)
}
}
export const configRoute = new ConfigRoute()

View File

@ -1,9 +1,9 @@
import { LensApiRequest } from "./router" import { LensApiRequest } from "../router"
import { helmService } from "./helm-service" import { helmService } from "../helm/helm-service"
import { LensApi } from "./lens-api" import { LensApi } from "../lens-api"
import logger from "./logger" import logger from "../logger"
class HelmApi extends LensApi { class HelmApiRoute extends LensApi {
public async listCharts(request: LensApiRequest) { public async listCharts(request: LensApiRequest) {
const { response } = request const { response } = request
const charts = await helmService.listCharts() const charts = await helmService.listCharts()
@ -111,4 +111,4 @@ class HelmApi extends LensApi {
} }
} }
export const helmApi = new HelmApi() export const helmRoute = new HelmApiRoute()

6
src/main/routes/index.ts Normal file
View File

@ -0,0 +1,6 @@
export * from "./kubeconfig-route"
export * from "./metrics-route"
export * from "./port-forward-route"
export * from "./watch-route"
export * from "./helm-route"
export * from "./resource-applier-route"

View File

@ -4,7 +4,7 @@ import { Cluster } from "../cluster"
import { CoreV1Api, V1Secret } from "@kubernetes/client-node" import { CoreV1Api, V1Secret } from "@kubernetes/client-node"
function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) {
const tokenData = new Buffer(secret.data["token"], "base64") const tokenData = Buffer.from(secret.data["token"], "base64")
return { return {
'apiVersion': 'v1', 'apiVersion': 'v1',
'kind': 'Config', 'kind': 'Config',
@ -44,7 +44,7 @@ class KubeconfigRoute extends LensApi {
public async routeServiceAccountRoute(request: LensApiRequest) { public async routeServiceAccountRoute(request: LensApiRequest) {
const { params, response, cluster} = request const { params, response, cluster} = request
const client = cluster.proxyKubeconfig().makeApiClient(CoreV1Api); const client = cluster.getProxyKubeconfig().makeApiClient(CoreV1Api);
const secretList = await client.listNamespacedSecret(params.namespace) const secretList = await client.listNamespacedSecret(params.namespace)
const secret = secretList.body.items.find(secret => { const secret = secretList.body.items.find(secret => {
const { annotations } = secret.metadata; const { annotations } = secret.metadata;

View File

@ -1,34 +1,25 @@
import { LensApiRequest } from "../router" import { LensApiRequest } from "../router"
import { LensApi } from "../lens-api" import { LensApi } from "../lens-api"
import requestPromise from "request-promise-native" import { PrometheusClusterQuery, PrometheusIngressQuery, PrometheusNodeQuery, PrometheusPodQuery, PrometheusProvider, PrometheusPvcQuery, PrometheusQueryOpts } from "../prometheus/provider-registry"
import { PrometheusProviderRegistry, PrometheusProvider, PrometheusNodeQuery, PrometheusClusterQuery, PrometheusPodQuery, PrometheusPvcQuery, PrometheusIngressQuery, PrometheusQueryOpts} from "../prometheus/provider-registry"
import { apiPrefix } from "../../common/vars";
export type IMetricsQuery = string | string[] | { export type IMetricsQuery = string | string[] | {
[metricName: string]: string; [metricName: string]: string;
} }
class MetricsRoute extends LensApi { class MetricsRoute extends LensApi {
async routeMetrics(request: LensApiRequest) {
public async routeMetrics(request: LensApiRequest) { const { response, cluster, payload } = request
const { response, cluster} = request
const query: IMetricsQuery = request.payload;
const serverUrl = `http://127.0.0.1:${cluster.port}${apiPrefix.KUBE_BASE}`
const headers = {
"Host": `${cluster.id}.localhost:${cluster.port}`,
"Content-type": "application/json",
}
const queryParams: IMetricsQuery = {} const queryParams: IMetricsQuery = {}
request.query.forEach((value: string, key: string) => { request.query.forEach((value: string, key: string) => {
queryParams[key] = value queryParams[key] = value
}) })
let prometheusPath: string
let metricsUrl: string
let prometheusProvider: PrometheusProvider let prometheusProvider: PrometheusProvider
try { try {
const prometheusPath = await cluster.contextHandler.getPrometheusPath() [prometheusPath, prometheusProvider] = await Promise.all([
metricsUrl = `${serverUrl}/api/v1/namespaces/${prometheusPath}/proxy${cluster.getPrometheusApiPrefix()}/api/v1/query_range` cluster.contextHandler.getPrometheusPath(),
prometheusProvider = await cluster.contextHandler.getPrometheusProvider() cluster.contextHandler.getPrometheusProvider()
])
} catch { } catch {
this.respondJson(response, {}) this.respondJson(response, {})
return return
@ -36,18 +27,10 @@ class MetricsRoute extends LensApi {
// prometheus metrics loader // prometheus metrics loader
const attempts: { [query: string]: number } = {}; const attempts: { [query: string]: number } = {};
const maxAttempts = 5; const maxAttempts = 5;
const loadMetrics = (orgQuery: string): Promise<any> => { const loadMetrics = (promQuery: string): Promise<any> => {
const query = orgQuery.trim() const query = promQuery.trim()
const attempt = attempts[query] = (attempts[query] || 0) + 1; const attempt = attempts[query] = (attempts[query] || 0) + 1;
return requestPromise(metricsUrl, { return cluster.getMetrics(prometheusPath, { query, ...queryParams }).catch(async error => {
resolveWithFullResponse: false,
headers: headers,
json: true,
qs: {
query: query,
...queryParams
}
}).catch(async (error) => {
if (attempt < maxAttempts && (error.statusCode && error.statusCode != 404)) { if (attempt < maxAttempts && (error.statusCode && error.statusCode != 404)) {
await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request
return loadMetrics(query); return loadMetrics(query);
@ -63,16 +46,14 @@ class MetricsRoute extends LensApi {
// return data in same structure as query // return data in same structure as query
let data: any; let data: any;
if (typeof query === "string") { if (typeof payload === "string") {
data = await loadMetrics(query) data = await loadMetrics(payload)
} } else if (Array.isArray(payload)) {
else if (Array.isArray(query)) { data = await Promise.all(payload.map(loadMetrics));
data = await Promise.all(query.map(loadMetrics)); } else {
}
else {
data = {}; data = {};
const result = await Promise.all( const result = await Promise.all(
Object.entries(query).map((queryEntry: any) => { Object.entries(payload).map((queryEntry: any) => {
const queryName: string = queryEntry[0] const queryName: string = queryEntry[0]
const queryOpts: PrometheusQueryOpts = queryEntry[1] const queryOpts: PrometheusQueryOpts = queryEntry[1]
const queries = prometheusProvider.getQueries(queryOpts) const queries = prometheusProvider.getQueries(queryOpts)
@ -80,7 +61,7 @@ class MetricsRoute extends LensApi {
return loadMetrics(q) return loadMetrics(q)
}) })
); );
Object.keys(query).forEach((metricName, index) => { Object.keys(payload).forEach((metricName, index) => {
data[metricName] = result[index]; data[metricName] = result[index];
}); });
} }

View File

@ -37,7 +37,7 @@ class PortForward {
public async start() { public async start() {
this.localPort = await getFreePort() this.localPort = await getFreePort()
const kubectlBin = await bundledKubectl.kubectlPath() const kubectlBin = await bundledKubectl.getPath()
const args = [ const args = [
"--kubeconfig", this.kubeConfig, "--kubeconfig", this.kubeConfig,
"port-forward", "port-forward",
@ -88,7 +88,7 @@ class PortForwardRoute extends LensApi {
namespace: namespace, namespace: namespace,
name: resourceName, name: resourceName,
port: port, port: port,
kubeConfig: cluster.proxyKubeconfigPath() kubeConfig: cluster.getProxyKubeconfigPath()
}) })
const started = await portForward.start() const started = await portForward.start()
if (!started) { if (!started) {

View File

@ -0,0 +1,17 @@
import { LensApiRequest } from "../router"
import { LensApi } from "../lens-api"
import { ResourceApplier } from "../resource-applier"
class ResourceApplierApiRoute extends LensApi {
public async applyResource(request: LensApiRequest) {
const { response, cluster, payload } = request
try {
const resource = await new ResourceApplier(cluster).apply(payload);
this.respondJson(response, [resource], 200)
} catch (error) {
this.respondText(response, error, 422)
}
}
}
export const resourceApplierRoute = new ResourceApplierApiRoute()

View File

@ -87,10 +87,10 @@ class WatchRoute extends LensApi {
response.setHeader("Content-Type", "text/event-stream") response.setHeader("Content-Type", "text/event-stream")
response.setHeader("Cache-Control", "no-cache") response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive") response.setHeader("Connection", "keep-alive")
logger.debug("watch using kubeconfig:" + JSON.stringify(cluster.proxyKubeconfig(), null, 2)) logger.debug("watch using kubeconfig:" + JSON.stringify(cluster.getProxyKubeconfig(), null, 2))
apis.forEach(apiUrl => { apis.forEach(apiUrl => {
const watcher = new ApiWatcher(apiUrl, cluster.proxyKubeconfig(), response) const watcher = new ApiWatcher(apiUrl, cluster.getProxyKubeconfig(), response)
watcher.start() watcher.start()
watchers.push(watcher) watchers.push(watcher)
}) })

View File

@ -5,10 +5,11 @@ import path from "path"
import shellEnv from "shell-env" import shellEnv from "shell-env"
import { app } from "electron" import { app } from "electron"
import { Kubectl } from "./kubectl" import { Kubectl } from "./kubectl"
import { tracker } from "./tracker" import { Cluster } from "./cluster"
import { Cluster, ClusterPreferences } from "./cluster" import { ClusterPreferences } from "../common/cluster-store";
import { helmCli } from "./helm-cli" import { helmCli } from "./helm/helm-cli"
import { isWindows } from "../common/vars"; import { isWindows } from "../common/vars";
import { tracker } from "../common/tracker";
export class ShellSession extends EventEmitter { export class ShellSession extends EventEmitter {
static shellEnvs: Map<string, any> = new Map() static shellEnvs: Map<string, any> = new Map()
@ -24,10 +25,10 @@ export class ShellSession extends EventEmitter {
protected running = false; protected running = false;
protected clusterId: string; protected clusterId: string;
constructor(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster) { constructor(socket: WebSocket, cluster: Cluster) {
super() super()
this.websocket = socket this.websocket = socket
this.kubeconfigPath = pathToKubeconfig this.kubeconfigPath = cluster.kubeConfigPath
this.kubectl = new Kubectl(cluster.version) this.kubectl = new Kubectl(cluster.version)
this.preferences = cluster.preferences || {} this.preferences = cluster.preferences || {}
this.clusterId = cluster.id this.clusterId = cluster.id

View File

@ -1,5 +1,7 @@
import shellEnv from "shell-env" import shellEnv from "shell-env"
import os from "os"; import os from "os";
import { app } from "electron";
import logger from "./logger";
interface Env { interface Env {
[key: string]: string; [key: string]: string;
@ -9,14 +11,21 @@ interface Env {
* shellSync loads what would have been the environment if this application was * shellSync loads what would have been the environment if this application was
* run from the command line, into the process.env object. This is especially * run from the command line, into the process.env object. This is especially
* useful on macos where this always needs to be done. * useful on macos where this always needs to be done.
* @param locale Should be electron's `app.getLocale()`
*/ */
export function shellSync(locale: string) { export async function shellSync() {
const { shell } = os.userInfo(); const { shell } = os.userInfo();
const env: Env = JSON.parse(JSON.stringify(shellEnv.sync(shell)))
let envVars = {};
try {
envVars = await shellEnv(shell);
} catch (error) {
logger.error(`shellEnv: ${error}`)
}
const env: Env = JSON.parse(JSON.stringify(envVars));
if (!env.LANG) { if (!env.LANG) {
// the LANG env var expects an underscore instead of electron's dash // the LANG env var expects an underscore instead of electron's dash
env.LANG = `${locale.replace('-', '_')}.UTF-8`; env.LANG = `${app.getLocale().replace('-', '_')}.UTF-8`;
} else if (!env.LANG.endsWith(".UTF-8")) { } else if (!env.LANG.endsWith(".UTF-8")) {
env.LANG += ".UTF-8" env.LANG += ".UTF-8"
} }

View File

@ -1,4 +0,0 @@
import { Tracker } from "../common/tracker"
import { app, remote } from "electron"
export const tracker = new Tracker(app || remote.app);

View File

@ -1,7 +0,0 @@
import { webContents } from "electron"
/**
* Helper to find the correct web contents handle for main window
*/
export function findMainWebContents() {
return webContents.getAllWebContents().find(w => w.getType() === "window");
}

View File

@ -1,89 +1,96 @@
import { BrowserWindow, shell } from "electron" import type { ClusterId } from "../common/cluster-store";
import { PromiseIpc } from "electron-promise-ipc" import { BrowserWindow, dialog, ipcMain, shell, WebContents, webContents } from "electron"
import windowStateKeeper from "electron-window-state" import windowStateKeeper from "electron-window-state"
import { tracker } from "./tracker"; import { observable } from "mobx";
import { getStaticUrl } from "../common/register-static"; import { initMenu } from "./menu";
export class WindowManager { export class WindowManager {
public mainWindow: BrowserWindow = null; protected mainView: BrowserWindow;
public splashWindow: BrowserWindow = null; protected splashWindow: BrowserWindow;
protected promiseIpc: any
protected windowState: windowStateKeeper.State; protected windowState: windowStateKeeper.State;
constructor({ showSplash = true } = {}) { @observable activeClusterId: ClusterId;
this.promiseIpc = new PromiseIpc({ timeout: 2000 })
// Manage main window size&position with persistence constructor(protected proxyPort: number) {
// Manage main window size and position with state persistence
this.windowState = windowStateKeeper({ this.windowState = windowStateKeeper({
defaultHeight: 900, defaultHeight: 900,
defaultWidth: 1440, defaultWidth: 1440,
}); });
this.splashWindow = new BrowserWindow({ const { width, height, x, y } = this.windowState;
width: 500, this.mainView = new BrowserWindow({
height: 300, x, y, width, height,
backgroundColor: "#1e2124",
center: true,
frame: false,
resizable: false,
show: false, show: false,
webPreferences: { minWidth: 900,
nodeIntegration: true minHeight: 760,
}
})
if (showSplash) {
this.splashWindow.loadURL(getStaticUrl("splash.html"))
this.splashWindow.show()
}
this.mainWindow = new BrowserWindow({
show: false,
x: this.windowState.x,
y: this.windowState.y,
width: this.windowState.width,
height: this.windowState.height,
backgroundColor: "#1e2124",
titleBarStyle: "hidden", titleBarStyle: "hidden",
backgroundColor: "#1e2124",
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
webviewTag: true nodeIntegrationInSubFrames: true,
enableRemoteModule: true,
}, },
}); });
this.windowState.manage(this.mainView);
// Hook window state manager into window lifecycle
this.windowState.manage(this.mainWindow);
// handle close event
this.mainWindow.on("close", () => {
this.mainWindow = null;
});
// open external links in default browser (target=_blank, window.open) // open external links in default browser (target=_blank, window.open)
this.mainWindow.webContents.on("new-window", (event, url) => { this.mainView.webContents.on("new-window", (event, url) => {
event.preventDefault(); event.preventDefault();
shell.openExternal(url); shell.openExternal(url);
}); });
// handle external links // track visible cluster from ui
this.mainWindow.webContents.on("will-navigate", (event, link) => { ipcMain.on("cluster-view:change", (event, clusterId: ClusterId) => {
if (link.startsWith("http://localhost")) { this.activeClusterId = clusterId;
return; });
}
event.preventDefault();
shell.openExternal(link);
})
this.mainWindow.on("focus", () => { // load & show app
tracker.event("app", "focus") this.showMain();
}) initMenu(this);
} }
public showMain(url: string) { navigate({ url, channel, frameId }: { url: string, channel: string, frameId?: number }) {
this.mainWindow.loadURL(url).then(() => { if (frameId) {
this.splashWindow.hide() this.mainView.webContents.sendToFrame(frameId, channel, url);
this.splashWindow.loadURL("data:text/html;charset=utf-8,").then(() => { } else {
this.splashWindow.close() this.mainView.webContents.send(channel, url);
this.mainWindow.show() }
}) }
})
async showMain() {
try {
await this.showSplash();
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
this.mainView.show();
this.splashWindow.close();
} catch (err) {
dialog.showErrorBox("ERROR!", err.toString())
}
}
async showSplash() {
if (!this.splashWindow) {
this.splashWindow = new BrowserWindow({
width: 500,
height: 300,
backgroundColor: "#1e2124",
center: true,
frame: false,
resizable: false,
show: false,
webPreferences: {
nodeIntegration: true
}
});
await this.splashWindow.loadURL("static://splash.html");
}
this.splashWindow.show();
}
destroy() {
this.windowState.unmanage();
this.splashWindow.destroy();
this.mainView.destroy();
} }
} }

View File

@ -1,17 +1,16 @@
/* Early store format had the kubeconfig directly under context name, this moves /* Early store format had the kubeconfig directly under context name, this moves
it under the kubeConfig key */ it under the kubeConfig key */
import { isTestEnv } from "../../common/vars"; import { migration } from "../migration-wrapper";
export function migration(store: any) { export default migration({
if(!isTestEnv) { version: "2.0.0-beta.2",
console.log("CLUSTER STORE, MIGRATION: 2.0.0-beta.2"); run(store, log) {
for (const value of store) {
const contextName = value[0];
// Looping all the keys gives out the store internal stuff too...
if (contextName === "__internal__" || value[1].hasOwnProperty('kubeConfig')) continue;
store.set(contextName, { kubeConfig: value[1] });
}
} }
for (const value of store) { })
const contextName = value[0];
// Looping all the keys gives out the store internal stuff too...
if(contextName === "__internal__" || value[1].hasOwnProperty('kubeConfig')) continue;
store.set(contextName, { kubeConfig: value[1] });
}
}

View File

@ -1,15 +1,14 @@
// Cleans up a store that had the state related data stored // Cleans up a store that had the state related data stored
import { isTestEnv } from "../../common/vars"; import { migration } from "../migration-wrapper";
export function migration(store: any) { export default migration({
if (!isTestEnv) { version: "2.4.1",
console.log("CLUSTER STORE, MIGRATION: 2.4.1"); run(store, log) {
for (const value of store) {
const contextName = value[0];
if (contextName === "__internal__") continue;
const cluster = value[1];
store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {} });
}
} }
for (const value of store) { })
const contextName = value[0];
if (contextName === "__internal__") continue;
const cluster = value[1];
store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {} });
}
}

View File

@ -1,19 +1,19 @@
// Move cluster icon from root to preferences // Move cluster icon from root to preferences
import { isTestEnv } from "../../common/vars"; import { migration } from "../migration-wrapper";
export function migration(store: any) { export default migration({
if(!isTestEnv) { version: "2.6.0-beta.2",
console.log("CLUSTER STORE, MIGRATION: 2.6.0-beta.2"); run(store, log) {
} for (const value of store) {
for (const value of store) { const clusterKey = value[0];
const clusterKey = value[0]; if (clusterKey === "__internal__") continue
if(clusterKey === "__internal__") continue const cluster = value[1];
const cluster = value[1]; if (!cluster.preferences) cluster.preferences = {};
if(!cluster.preferences) cluster.preferences = {}; if (cluster.icon) {
if(cluster.icon) { cluster.preferences.icon = cluster.icon;
cluster.preferences.icon = cluster.icon; delete (cluster["icon"]);
delete(cluster["icon"]); }
store.set(clusterKey, { contextName: clusterKey, kubeConfig: value[1].kubeConfig, preferences: value[1].preferences });
} }
store.set(clusterKey, { contextName: clusterKey, kubeConfig: value[1].kubeConfig, preferences: value[1].preferences });
} }
} })

View File

@ -1,38 +1,38 @@
import * as yaml from "js-yaml" import { migration } from "../migration-wrapper";
import { isTestEnv } from "../../common/vars"; import yaml from "js-yaml"
// Convert access token and expiry from arrays into strings export default migration({
export function migration(store: any) { version: "2.6.0-beta.3",
if(!isTestEnv) { run(store, log) {
console.log("CLUSTER STORE, MIGRATION: 2.6.0-beta.3"); for (const value of store) {
} const clusterKey = value[0];
for (const value of store) { if (clusterKey === "__internal__") continue
const clusterKey = value[0]; const cluster = value[1];
if(clusterKey === "__internal__") continue if (!cluster.kubeConfig) continue
const cluster = value[1]; const kubeConfig = yaml.safeLoad(cluster.kubeConfig)
if(!cluster.kubeConfig) continue if (!kubeConfig.hasOwnProperty('users')) continue
const kubeConfig = yaml.safeLoad(cluster.kubeConfig) const userObj = kubeConfig.users[0]
if(!kubeConfig.hasOwnProperty('users')) continue if (userObj) {
const userObj = kubeConfig.users[0] const user = userObj.user
if (userObj) { if (user["auth-provider"] && user["auth-provider"].config) {
const user = userObj.user const authConfig = user["auth-provider"].config
if (user["auth-provider"] && user["auth-provider"].config) { if (authConfig["access-token"]) {
const authConfig = user["auth-provider"].config authConfig["access-token"] = `${authConfig["access-token"]}`
if (authConfig["access-token"]) { }
authConfig["access-token"] = `${authConfig["access-token"]}` if (authConfig.expiry) {
authConfig.expiry = `${authConfig.expiry}`
}
log(authConfig)
user["auth-provider"].config = authConfig
kubeConfig.users = [{
name: userObj.name,
user: user
}]
cluster.kubeConfig = yaml.safeDump(kubeConfig)
store.set(clusterKey, cluster)
} }
if (authConfig.expiry) {
authConfig.expiry = `${authConfig.expiry}`
}
console.log(authConfig)
user["auth-provider"].config = authConfig
kubeConfig.users = [{
name: userObj.name,
user: user
}]
cluster.kubeConfig = yaml.safeDump(kubeConfig)
store.set(clusterKey, cluster)
} }
} }
} }
} })

View File

@ -1,15 +1,15 @@
// Add existing clusters to "default" workspace // Add existing clusters to "default" workspace
import { isTestEnv } from "../../common/vars"; import { migration } from "../migration-wrapper";
export function migration(store: any) { export default migration({
if(!isTestEnv) { version: "2.7.0-beta.0",
console.log("CLUSTER STORE, MIGRATION: 2.7.0-beta.0"); run(store, log) {
for (const value of store) {
const clusterKey = value[0];
if(clusterKey === "__internal__") continue
const cluster = value[1];
cluster.workspace = "default"
store.set(clusterKey, cluster)
}
} }
for (const value of store) { })
const clusterKey = value[0];
if(clusterKey === "__internal__") continue
const cluster = value[1];
cluster.workspace = "default"
store.set(clusterKey, cluster)
}
}

View File

@ -1,25 +1,25 @@
// add id for clusters and store them to array // Add id for clusters and store them to array
import { migration } from "../migration-wrapper";
import { v4 as uuid } from "uuid" import { v4 as uuid } from "uuid"
import { isTestEnv } from "../../common/vars";
export function migration(store: any) { export default migration({
if(!isTestEnv) { version: "2.7.0-beta.1",
console.log("CLUSTER STORE, MIGRATION: 2.7.0-beta.1"); run(store, log) {
} const clusters: any[] = []
const clusters: any[] = [] for (const value of store) {
for (const value of store) { const clusterKey = value[0];
const clusterKey = value[0]; if (clusterKey === "__internal__") continue
if(clusterKey === "__internal__") continue if (clusterKey === "clusters") continue
if(clusterKey === "clusters") continue const cluster = value[1];
const cluster = value[1]; cluster.id = uuid()
cluster.id = uuid() if (!cluster.preferences.clusterName) {
if (!cluster.preferences.clusterName) { cluster.preferences.clusterName = clusterKey
cluster.preferences.clusterName = clusterKey }
clusters.push(cluster)
store.delete(clusterKey)
}
if (clusters.length > 0) {
store.set("clusters", clusters)
} }
clusters.push(cluster)
store.delete(clusterKey)
} }
if (clusters.length > 0) { })
store.set("clusters", clusters)
}
}

View File

@ -1,39 +1,38 @@
// move embedded kubeconfig into separate file and add reference to it to cluster settings // Move embedded kubeconfig into separate file and add reference to it to cluster settings
import { app } from "electron"
import { ensureDirSync } from "fs-extra"
import * as path from "path"
import { KubeConfig } from "@kubernetes/client-node";
import { writeEmbeddedKubeConfig } from "../../common/utils/kubeconfig"
export function migration(store: any) { import path from "path"
console.log("CLUSTER STORE, MIGRATION: 3.6.0-beta.1"); import { app, remote } from "electron"
const clusters: any[] = [] import { migration } from "../migration-wrapper";
import { ensureDirSync } from "fs-extra"
import { ClusterModel } from "../../common/cluster-store";
import { loadConfig, saveConfigToAppFiles } from "../../common/kube-helpers";
const kubeConfigBase = path.join(app.getPath("userData"), "kubeconfigs") export default migration({
ensureDirSync(kubeConfigBase) version: "3.6.0-beta.1",
const storedClusters = store.get("clusters") as any[] run(store, printLog) {
if (!storedClusters) return const migratedClusters: ClusterModel[] = []
const storedClusters: ClusterModel[] = store.get("clusters");
const kubeConfigBase = path.join((app || remote.app).getPath("userData"), "kubeconfigs")
console.log("num clusters to migrate: ", storedClusters.length) if (!storedClusters) return;
for (const cluster of storedClusters ) { ensureDirSync(kubeConfigBase);
try {
// take the embedded kubeconfig and dump it into a file
const kubeConfigFile = writeEmbeddedKubeConfig(cluster.id, cluster.kubeConfig)
cluster.kubeConfigPath = kubeConfigFile
const kc = new KubeConfig() printLog("Number of clusters to migrate: ", storedClusters.length)
kc.loadFromFile(cluster.kubeConfigPath) for (const cluster of storedClusters) {
cluster.contextName = kc.getCurrentContext() try {
// take the embedded kubeconfig and dump it into a file
cluster.kubeConfigPath = saveConfigToAppFiles(cluster.id, cluster.kubeConfig)
cluster.contextName = loadConfig(cluster.kubeConfigPath).getCurrentContext();
delete cluster.kubeConfig;
migratedClusters.push(cluster)
} catch (error) {
printLog(`Failed to migrate Kubeconfig for cluster "${cluster.id}"`, error)
}
}
delete cluster.kubeConfig // "overwrite" the cluster configs
clusters.push(cluster) if (migratedClusters.length > 0) {
} catch(error) { store.set("clusters", migratedClusters)
console.error("failed to migrate kubeconfig for cluster:", cluster.id)
} }
} }
})
// "overwrite" the cluster configs
if (clusters.length > 0) {
store.set("clusters", clusters)
}
}

View File

@ -0,0 +1,19 @@
// Cluster store migrations
import version200Beta2 from "./2.0.0-beta.2"
import version241 from "./2.4.1"
import version260Beta2 from "./2.6.0-beta.2"
import version260Beta3 from "./2.6.0-beta.3"
import version270Beta0 from "./2.7.0-beta.0"
import version270Beta1 from "./2.7.0-beta.1"
import version360Beta1 from "./3.6.0-beta.1"
export default {
...version200Beta2,
...version241,
...version260Beta2,
...version260Beta3,
...version270Beta0,
...version270Beta1,
...version360Beta1,
}

View File

@ -0,0 +1,21 @@
import Config from "conf";
import { isTestEnv } from "../common/vars";
export interface MigrationOpts {
version: string;
run(storeConfig: Config<any>, log: (...args: any[]) => void): void;
}
function infoLog(...args: any[]) {
if (isTestEnv) return;
console.log(...args);
}
export function migration<S = any>({ version, run }: MigrationOpts) {
return {
[version]: (storeConfig: Config<S>) => {
infoLog(`STORE MIGRATION (${storeConfig.path}): ${version}`,);
run(storeConfig, infoLog);
}
};
}

View File

@ -1,4 +1,9 @@
// Add / reset "lastSeenAppVersion" // Add / reset "lastSeenAppVersion"
export function migration(store: any) { import { migration } from "../migration-wrapper";
store.set("lastSeenAppVersion", "0.0.0");
} export default migration({
version: "2.1.0-beta.4",
run(store) {
store.set("lastSeenAppVersion", "0.0.0");
}
})

View File

@ -0,0 +1,7 @@
// User store migrations
import version210Beta4 from "./2.1.0-beta.4"
export default {
...version210Beta4,
}

View File

@ -1,38 +0,0 @@
<template>
<div id="app">
<div id="lens-container" />
<div class="draggable-top" />
<div class="main-view" :class="{ 'menu-visible': isMenuVisible }">
<main-menu v-if="isMenuVisible" />
<router-view />
</div>
<bottom-bar v-if="isMenuVisible" />
</div>
</template>
<script>
import MainMenu from "@/_vue/components/MainMenu/MainMenu";
import BottomBar from "@/_vue/components/BottomBar/BottomBar";
export default {
name: 'Lens',
components: {
BottomBar,
MainMenu
},
computed: {
isMenuVisible: function () { return this.$store.getters.isMenuVisible }
}
}
</script>
<style scoped>
.draggable-top {
-webkit-app-region: drag;
left: 0;
top: 0;
position: absolute;
height: 20px;
width: 100%;
}
</style>

File diff suppressed because one or more lines are too long

View File

@ -1,73 +0,0 @@
// from Lens Dashboard
$lens-main-bg: #1e2124 !default; // dark bg
$lens-pane-bg: #262b2f !default; // all panels main bg
$lens-dock-bg: #2E3136 !default; // terminal and top menu bar
$lens-menu-bg: #36393E !default; // sidemenu on left
$lens-menu-hl: #414448 !default; // sidemenu on left, top left corner
$lens-text-color: #87909c !default;
$lens-text-color-light: #a0a0a0 !default;
$lens-primary: #3d90ce !default;
// export as css variables
:root {
--lens-main-bg: #{$lens-main-bg}; // dark bg
--lens-pane-bg: #{$lens-pane-bg}; // all panels main bg
--lens-dock-bg: #{$lens-dock-bg}; // terminal and top menu bar
--lens-menu-bg: #{$lens-menu-bg}; // sidemenu on left
--lens-menu-hl: #{$lens-menu-hl}; // sidemenu on left, top left corner
--lens-text-color: #{$lens-text-color};
--lens-text-color-light: #{$lens-text-color-light};
--lens-primary: #{$lens-primary};
--lens-bottom-bar-height: 20px;
}
// Base grayscale colors definitions
$white: #fff !default;
$gray-100: #f8f9fa !default;
$gray-200: #e9ecef !default;
$gray-300: #dee2e6 !default;
$gray-400: #ced4da !default;
$gray-500: #adb5bd !default;
$gray-600: #6c757d !default;
$gray-700: #495057 !default;
$gray-800: #343a40 !default;
$gray-900: #1e2124 !default;
$black: #000 !default;
// Base colors definitions
$blue: #3d90ce !default;
$indigo: #6610f2 !default;
$purple: #6f42c1 !default;
$pink: #e83e8c !default;
$red: #CE3933 !default;
$orange: #fd7e14 !default;
$yellow: #ffc107 !default;
$green: #4caf50 !default;
$teal: #20c997 !default;
$cyan: #6ca5b7 !default;
// Theme color default definitions
$primary: $lens-primary !default;
$secondary: $gray-600 !default;
$success: $green !default;
$info: $cyan !default;
$warning: $yellow !default;
$danger: $red !default;
$light: $gray-100 !default;
$dark: $gray-800 !default;
// This table defines the theme colors (variant names)
$theme-colors: () !default;
$theme-colors: map-merge(
(
'primary': $primary,
'secondary': $secondary,
'success': $success,
'info': $info,
'warning': $warning,
'danger': $danger,
'light': $light,
'dark': $dark
),
$theme-colors
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

View File

@ -1,308 +0,0 @@
<template>
<div class="content">
<b-container fluid class="h-100">
<b-row align-h="around">
<b-col lg="7">
<div class="card">
<h2>Add Cluster</h2>
<div class="add-cluster">
<b-form @submit.prevent="doAddCluster">
<b-form-group
label="Choose config:"
>
<b-form-file
v-model="file"
:state="Boolean(file)"
placeholder="Choose a file or drop it here..."
drop-placeholder="Drop file here..."
@input="reloadKubeContexts()"
/>
<div class="mt-3">
Selected file: {{ file ? file.name : '' }}
</div>
<b-form-select
id="kubecontext-select"
v-model="kubecontext"
:options="contextNames"
@change="onSelect($event)"
/>
<b-button v-b-toggle.collapse-advanced variant="link">
Proxy settings
</b-button>
</b-form-group>
<b-collapse id="collapse-advanced">
<b-form-group
label="HTTP Proxy server. Used for communicating with Kubernetes API."
description="A HTTP proxy server URL (format: http://<address>:<port>)."
>
<b-form-input
v-model="httpsProxy"
/>
</b-form-group>
</b-collapse>
<b-form-group
label="Kubeconfig:"
v-if="status === 'ERROR' || kubecontext === 'custom'"
>
<div class="editor">
<prism-editor v-model="clusterconfig" language="yaml" />
</div>
</b-form-group>
<b-alert variant="danger" show v-if="status === 'ERROR'">
{{ errorMsg }}
<div v-if="errorDetails !== ''">
<b-button v-b-toggle.collapse-error variant="link" size="sm">
Show details
</b-button>
<b-collapse id="collapse-error">
<code>
{{ errorDetails }}
</code>
</b-collapse>
</div>
</b-alert>
<b-form-row>
<b-col>
<b-button variant="primary" type="submit" :disabled="clusterconfig === ''">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
{{ addButtonText }}
</b-button>
</b-col>
</b-form-row>
</b-form>
</div>
</div>
</b-col>
<b-col lg="5" class="help d-none d-lg-block">
<h3>Clusters associated with Lens</h3>
<p>
Add clusters by clicking the <span class="text-primary">Add Cluster</span> button.
You'll need to obtain a working kubeconfig for the cluster you want to add.
</p>
<p>
Each <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#context">cluster context</a> is added as a separate item in the left-side cluster menu to allow you to operate easily on multiple clusters and/or contexts.
</p>
<p>
For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/">Kubernetes docs</a>
</p>
<p>
NOTE: Any manually added cluster is not merged into your kubeconfig file.
</p>
<p>
To see your currently enabled config with <code>kubectl</code>, use <code>kubectl config view --minify --raw</code> command in your terminal.
</p>
<p>
When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens app.
</p>
<a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator">
<h4>OIDC (OpenID Connect)</h4>
</a>
<div>
<p>
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
</p>
<b>Dedicated refresh token</b>
<p>
As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes.
If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user.
One way to achieve this is with <a href="https://github.com/int128/kubelogin">kubelogin</a> tool by removing the tokens (both <code>id_token</code> and <code>refresh_token</code>) from the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token.
</p>
</div>
<h4>Exec auth plugins</h4>
<p>
When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration">exec auth</a> plugins make sure the paths that are used to call any binaries are full paths as Lens app might not be able to call binaries with relative paths. Make also sure that you pass all needed information either as arguments or env variables in the config, Lens app might not have all login shell env variables set automatically.
</p>
</b-col>
</b-row>
</b-container>
</div>
</template>
<script>
import * as PrismEditor from 'vue-prism-editor'
import * as k8s from "@kubernetes/client-node"
import { dumpConfigYaml } from "../../../main/k8s"
import ClustersMixin from "@/_vue/mixins/ClustersMixin";
import * as path from "path"
import fs from 'fs'
import { v4 as uuidv4 } from 'uuid';
import { writeEmbeddedKubeConfig} from "../../../common/utils/kubeconfig"
class ClusterAccessError extends Error {}
export default {
name: 'AddClusterPage',
mixins: [ClustersMixin],
props: { },
components: {
PrismEditor,
},
data(){
return {
file: null,
filepath: null,
clusterconfig: "",
httpsProxy: "",
kubecontext: "",
status: "",
errorMsg: "",
errorCluster: "",
errorDetails: "",
seenContexts: []
}
},
mounted: function() {
const kubeConfigPath = path.join(process.env.HOME, '.kube', 'config')
this.filepath = kubeConfigPath
this.file = new File(fs.readFileSync(this.filepath), this.filepath)
this.$store.dispatch("reloadAvailableKubeContexts", this.filepath);
this.seenContexts = JSON.parse(JSON.stringify(this.$store.getters.seenContexts)) // clone seenContexts from store
this.storeSeenContexts()
},
computed: {
isProcessing: function() {
return this.status === "PROCESSING";
},
addButtonText: function() {
if (this.kubecontext === "custom") {
return "Add Cluster(s)"
} else {
return "Add Cluster"
}
},
contextNames: function() {
const configs = this.availableContexts
const names = configs.map((kc) => {
return { text: kc.currentContext + (this.isNewContext(kc.currentContext) ? " (new)": ""), value: dumpConfigYaml(kc) }
})
names.unshift({text: "Select kubeconfig", value: ""})
names.push({text: "Custom ...", value: "custom"})
return names;
},
},
methods: {
reloadKubeContexts() {
this.filepath = this.file.path
this.$store.dispatch("reloadAvailableKubeContexts", this.file.path);
},
isNewContext(context) {
return this.newContexts.indexOf(context) > -1
},
storeSeenContexts() {
const configs = this.$store.getters.availableKubeContexts
const contexts = configs.map((kc) => {
return kc.currentContext
})
this.$store.dispatch("addSeenContexts", contexts)
},
onSelect: function() {
this.status = "";
if (this.kubecontext === "custom") {
this.clusterconfig = "";
} else {
this.clusterconfig = this.kubecontext;
}
},
isOidcAuth: function(authProvider) {
if (!authProvider) { return false }
if (authProvider.name === "oidc") { return true }
return false;
},
doAddCluster: async function() {
// Clear previous error details
this.errorMsg = ""
this.errorCluster = ""
this.errorDetails = ""
this.status = "PROCESSING"
try {
const kc = new k8s.KubeConfig();
kc.loadFromString(this.clusterconfig); // throws TypeError if we cannot parse kubeconfig
const clusterId = uuidv4();
// We need to store the kubeconfig to "app-home"/
if (this.kubecontext === "custom") {
this.filepath = writeEmbeddedKubeConfig(clusterId, this.clusterconfig)
}
const clusterInfo = {
id: clusterId,
kubeConfigPath: this.filepath,
contextName: kc.currentContext,
preferences: {
clusterName: kc.currentContext
},
workspace: this.$store.getters.currentWorkspace.id
}
if (this.httpsProxy) {
clusterInfo.preferences.httpsProxy = this.httpsProxy
}
console.log("sending clusterInfo:", clusterInfo)
let res = await this.$store.dispatch('addCluster', clusterInfo)
console.log("addCluster result:", res)
if(!res){
this.status = "ERROR";
return false;
}
this.status = "SUCCESS"
this.$router.push({
name: "cluster-page",
params: {
id: res.id
},
}).catch((err) => {})
} catch (error) {
console.log("addCluster raised:", error)
if(typeof error === 'string') {
this.errorMsg = error;
} else if(error instanceof TypeError) {
this.errorMsg = "cannot parse kubeconfig";
} else if(error.response && error.response.statusCode === 401) {
this.errorMsg = "invalid kubeconfig (access denied)"
} else if(error.message) {
this.errorMsg = error.message
} else if(error instanceof ClusterAccessError) {
this.errorMsg = `Invalid kubeconfig context ${error.context}`
this.errorCluster = error.cluster
this.errorDetails = error.details
}
this.status = "ERROR";
return false;
}
return true;
}
}
}
</script>
<style scoped lang="scss">
.help{
border-left: 1px solid #353a3e;
padding-top: 20px;
&:first-child{
padding-top: 0;
}
h3{
padding: 0.75rem 0 0.75rem 0;
}
height: 100vh;
overflow-y: auto;
}
h2{
padding: 0.75rem;
}
.card {
margin-top: 20px;
}
.add-cluster {
padding: 0.75rem;
}
.btn-link {
padding-left: 0;
}
</style>

View File

@ -1,115 +0,0 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid lens-workspaces">
<div class="header sticky-top">
<h2><i class="material-icons">layers</i> Workspaces</h2>
</div>
<div class="row">
<div class="col-3" />
<div class="col-6">
<h2>New Workspace</h2>
<b-form @submit.prevent="createWorkspace">
<b-form-group
label="Name:"
label-for="input-name"
>
<b-form-input
id="input-name"
v-model="workspace.name"
trim
/>
</b-form-group>
<b-form-group
label="Description:"
label-for="input-description"
>
<b-form-input
id="input-description"
v-model="workspace.description"
trim
/>
</b-form-group>
<b-form-row>
<b-col>
<b-button variant="primary" type="submit">
Create Workspace
</b-button>
</b-col>
</b-form-row>
</b-form>
</div>
<div class="col-3" />
</div>
</div>
</div>
</template>
<script>
import { v4 as uuid } from "uuid";
import ClosePageButton from "@/_vue/components/common/ClosePageButton";
export default {
name: 'AddWorkspacePage',
components: {
ClosePageButton
},
data() {
return {
workspace: {
id: uuid(),
name: "",
description: ""
},
errors: {
name: null,
description: null
}
}
},
computed: {
},
methods: {
createWorkspace: function() {
this.$store.commit("addWorkspace", this.workspace)
this.$router.push({
name: "workspaces-page"
})
}
},
mounted: function() {
this.$store.commit("hideMenu");
},
destroyed: function() {
this.$store.commit("showMenu");
}
}
</script>
<style lang="scss" scoped>
#app > .main-view > .content {
left: 70px;
right: 70px;
}
h2 {
i {
position: relative;
top: 6px;
}
}
.lens-workspaces {
height: 100%;
overflow-y: scroll;
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
.header {
padding-top: 15px;
}
}
</style>

View File

@ -1,88 +0,0 @@
<template>
<div class="bottom-bar">
<div id="workspace-area">
<i class="material-icons">layers</i> {{ currentWorkspace.name }}
</div>
<b-popover target="workspace-area" triggers="click" placement="top" :show.sync="show">
<template v-slot:title>
<a href="#" @click.prevent="goWorkspaces"><i class="material-icons">layers</i> Workspaces</a>
</template>
<ul
v-for="workspace in workspaces"
:key="workspace.id"
:workspace="workspace"
class="list-group list-group-flush"
>
<li class="list-group-item">
<a href="#" @click.prevent="switchWorkspace(workspace)">{{ workspace.name }}</a>
</li>
</ul>
</b-popover>
</div>
</template>
<script>
export default {
name: "BottomBar",
data() {
return {
show: false
}
},
computed: {
currentWorkspace: function() {
return this.$store.getters.currentWorkspace;
},
workspaces: function() {
return this.$store.getters.workspaces;
}
},
methods: {
switchWorkspace: function(workspace) {
this.show = false;
this.$store.commit("setCurrentWorkspace", workspace);
this.$store.dispatch("clearClusters");
this.$store.dispatch("refreshClusters", workspace);
this.$router.push({
name: "landing-page"
})
},
goWorkspaces: function() {
this.$router.push({
name: "workspaces-page"
})
}
}
}
</script>
<style scoped lang="scss">
.bottom-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: var(--lens-bottom-bar-height);
background-color: var(--lens-primary);
z-index: 2000;
}
#workspace-area {
position: absolute;
bottom: 2px;
right: 10px;
display: block;
color: #fff;
opacity: 0.9;
font-size: 11px;
cursor: pointer;
&.active{
opacity: 1.0;
}
i {
position: relative;
top: 4px;
font-size: 14px;
}
}
</style>

View File

@ -1,157 +0,0 @@
<template>
<div class="content">
<div class="h-100">
<div class="wrapper" v-if="status === 'LOADING'">
<cube-spinner text="" />
<div class="auth-output">
<!-- eslint-disable-next-line vue/no-v-html -->
<pre class="auth-output" v-html="authOutput" />
</div>
</div>
<div class="wrapper" v-if="status === 'ERROR'">
<div class="error">
<i class="material-icons">{{ error_icon }}</i>
<div class="text-center">
<h2>{{ cluster.preferences.clusterName }}</h2>
<!-- eslint-disable-next-line vue/no-v-html -->
<pre v-html="authOutput" />
<b-button variant="link" @click="tryAgain">
Reconnect
</b-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import CubeSpinner from "@/_vue/components/CubeSpinner";
export default {
name: "ClusterPage",
components: {
CubeSpinner
},
data(){
return {
authOutput: ""
}
},
computed: {
cluster: function() {
return this.$store.getters.clusterById(this.$route.params.id);
},
online: function() {
if (!this.cluster) { return false }
return this.cluster.online;
},
accessible: function() {
if (!this.cluster) { return false }
return this.cluster.accessible;
},
lens: function() {
return this.$store.getters.lensById(this.cluster.id);
},
status: function() {
if (this.cluster) {
if (this.cluster.accessible && this.lens.loaded === true) {
return "SUCCESS";
} else if (this.cluster.accessible === false) {
return "ERROR";
}
return "LOADING";
}
return "ERROR";
},
error_icon: function() {
if (!this.cluster.online) {
return "cloud_off"
} else {
return "https"
}
}
},
methods: {
tryAgain: function() {
this.authOutput = ""
this.cluster.accessible = null
setTimeout(() => {
this.loadLens()
}, 1000)
},
loadLens: function() {
this.authOutput = "Connecting ...\n";
this.$promiseIpc.on(`kube-auth:${this.cluster.id}`, (output) => {
this.authOutput += output.data;
})
this.toggleLens();
return this.$store.dispatch("refineCluster", this.$route.params.id);
},
lensLoaded: function() {
console.log("lens loaded")
this.lens.loaded = true;
this.$store.commit("updateLens", this.lens);
},
// Called only when online state changes
toggleLens: function() {
if (!this.lens) { return }
if (this.accessible) {
setTimeout(this.activateLens, 0); // see: https://github.com/electron/electron/issues/10016
} else {
this.hideLens();
}
},
activateLens: async function() {
console.log("activate lens")
if (!this.lens.webview) {
console.log("creating an iframe")
const webview = document.createElement('iframe');
webview.addEventListener('load', this.lensLoaded);
webview.src = this.cluster.url;
this.lens.webview = webview;
}
this.$store.dispatch("attachWebview", this.lens);
this.$tracker.event("cluster", "open");
},
hideLens: function() {
this.$store.dispatch("hideWebviews");
}
},
created() {
this.loadLens();
},
destroyed() {
this.hideLens();
},
watch: {
"$route": "loadLens",
"online": "toggleLens",
"cluster": "toggleLens",
"accessible": function(newStatus, oldStatus) {
console.log("accessible watch, vals:", newStatus, oldStatus);
if(newStatus === false) { // accessble == false
this.$tracker.event("cluster", "open-failed");
}
},
}
};
</script>
<style scoped lang="scss">
div.auth-output {
padding-top: 250px;
width: 70%;
pre {
height: 100px;
text-align: center;
}
}
.error {
width: 90%;
}
pre {
font-size: 80%;
overflow: auto;
max-height: 150px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More