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:
parent
905bbe9d3f
commit
5670312c47
@ -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
2
.gitignore
vendored
@ -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
|
||||||
|
|||||||
2
.yarnrc
2
.yarnrc
@ -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"
|
||||||
|
|||||||
13
README.md
13
README.md
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
1
__mocks__/styleMock.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = {};
|
||||||
@ -1,3 +1,3 @@
|
|||||||
import { helmCli } from "../src/main/helm-cli"
|
import { helmCli } from "../src/main/helm/helm-cli"
|
||||||
|
|
||||||
helmCli.ensureBinary()
|
helmCli.ensureBinary()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
69
package.json
69
package.json
@ -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
138
src/common/base-store.ts
Normal 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
59
src/common/cluster-ipc.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
|||||||
@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
333
src/common/cluster-store_test.ts
Normal file
333
src/common/cluster-store_test.ts
Normal 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
76
src/common/ipc.ts
Normal 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
167
src/common/kube-helpers.ts
Normal 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
50
src/common/rbac.ts
Normal 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;
|
||||||
|
}
|
||||||
12
src/common/register-protocol.ts
Normal file
12
src/common/register-protocol.ts
Normal 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 });
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 };
|
|
||||||
|
|||||||
@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
102
src/common/user-store_test.ts
Normal file
102
src/common/user-store_test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
5
src/common/utils/cloneJson.ts
Normal file
5
src/common/utils/cloneJson.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// Clone json-serializable object
|
||||||
|
|
||||||
|
export function cloneJsonObject<T = object>(obj: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
}
|
||||||
12
src/common/utils/defineGlobal.ts
Executable file
12
src/common/utils/defineGlobal.ts
Executable 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);
|
||||||
|
}
|
||||||
6
src/common/utils/getRandId.ts
Normal file
6
src/common/utils/getRandId.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
28
src/common/utils/singleton.ts
Normal file
28
src/common/utils/singleton.ts
Normal 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;
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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 }
|
|
||||||
|
|||||||
128
src/common/workspace-store_test.ts
Normal file
128
src/common/workspace-store_test.ts
Normal 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");
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 || ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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}`
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
||||||
@ -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 {
|
||||||
|
|
||||||
@ -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: []})}
|
||||||
})
|
})
|
||||||
130
src/main/helm/helm-repo-manager.ts
Normal file
130
src/main/helm/helm-repo-manager.ts
Normal 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>()
|
||||||
@ -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
|
||||||
@ -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();
|
||||||
})
|
})
|
||||||
|
|||||||
153
src/main/k8s.ts
153
src/main/k8s.ts
@ -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"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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
141
src/main/lens-proxy.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/main/menu.ts
199
src/main/menu.ts
@ -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)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
|
||||||
@ -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
6
src/main/routes/index.ts
Normal 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"
|
||||||
@ -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;
|
||||||
@ -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];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -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) {
|
||||||
17
src/main/routes/resource-applier-route.ts
Normal file
17
src/main/routes/resource-applier-route.ts
Normal 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()
|
||||||
@ -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)
|
||||||
})
|
})
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { Tracker } from "../common/tracker"
|
|
||||||
import { app, remote } from "electron"
|
|
||||||
|
|
||||||
export const tracker = new Tracker(app || remote.app);
|
|
||||||
@ -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");
|
|
||||||
}
|
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 || {} });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
19
src/migrations/cluster-store/index.ts
Normal file
19
src/migrations/cluster-store/index.ts
Normal 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,
|
||||||
|
}
|
||||||
21
src/migrations/migration-wrapper.ts
Normal file
21
src/migrations/migration-wrapper.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
7
src/migrations/user-store/index.ts
Normal file
7
src/migrations/user-store/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// User store migrations
|
||||||
|
|
||||||
|
import version210Beta4 from "./2.1.0-beta.4"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
...version210Beta4,
|
||||||
|
}
|
||||||
@ -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
@ -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 |
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user