diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 0000000000..8ff35b5e4f --- /dev/null +++ b/build/installer.nsh @@ -0,0 +1,12 @@ +!macro customInit + ; Workaround for installer handing when the app directory is removed manually + ${ifNot} ${FileExists} "$INSTDIR" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\{${UNINSTALL_APP_KEY}}" + ${EndIf} + + ; Workaround for the old-format uninstall registry key (some people report it causes hangups, too) + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" "QuietUninstallString" + StrCmp $0 "" proceed 0 + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" + proceed: +!macroend \ No newline at end of file diff --git a/integration/specs/app_spec.ts b/integration/specs/app_spec.ts index c3e4044d3b..02024d1647 100644 --- a/integration/specs/app_spec.ts +++ b/integration/specs/app_spec.ts @@ -17,15 +17,12 @@ describe("app start", () => { const addMinikubeCluster = async (app: Application) => { await app.client.click("div.add-cluster") await app.client.waitUntilTextExists("div", "Select kubeconfig file") - await app.client.click("div.Select__control") - await app.client.waitUntilTextExists("div", "minikube") - await app.client.click("div.minikube") await app.client.click("button.primary") } const waitForMinikubeDashboard = async (app: Application) => { await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started") - let windowCount = await app.client.getWindowCount() + await app.client.getWindowCount() await app.client.waitForExist(`iframe[name="minikube"]`) await app.client.frame("minikube") await app.client.waitUntilTextExists("span.link-text", "Cluster") @@ -35,10 +32,8 @@ describe("app start", () => { app = util.setup() await app.start() await app.client.waitUntilWindowLoaded() - let windowCount = await app.client.getWindowCount() - while (windowCount > 1) { // Wait for splash screen to be closed - windowCount = await app.client.getWindowCount() - } + // Wait for splash screen to be closed + while (await app.client.getWindowCount() > 1); await app.client.windowByIndex(0) await app.client.waitUntilWindowLoaded() }, 20000) @@ -48,7 +43,7 @@ describe("app start", () => { }) it('allows to add a cluster', async () => { - const status = spawnSync("minikube status", {shell: true}) + const status = spawnSync("minikube status", { shell: true }) if (status.status !== 0) { console.warn("minikube not running, skipping test") return @@ -61,7 +56,7 @@ describe("app start", () => { }) it('allows to create a pod', async () => { - const status = spawnSync("minikube status", {shell: true}) + const status = spawnSync("minikube status", { shell: true }) if (status.status !== 0) { console.warn("minikube not running, skipping test") return diff --git a/package.json b/package.json index fde556adcc..934095b84e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "3.6.0-rc.2", + "version": "3.6.4", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", @@ -151,6 +151,9 @@ } ] }, + "nsis": { + "include": "build/installer.nsh" + }, "publish": [ { "provider": "github", @@ -214,6 +217,7 @@ "semver": "^7.3.2", "serializr": "^2.0.3", "shell-env": "^3.0.0", + "spdy": "^4.0.2", "tar": "^6.0.2", "tcp-port-used": "^1.0.1", "tempy": "^0.5.0", @@ -258,6 +262,7 @@ "@types/request-promise-native": "^1.0.17", "@types/semver": "^7.2.0", "@types/shelljs": "^0.8.8", + "@types/spdy": "^3.4.4", "@types/tcp-port-used": "^1.0.0", "@types/tempy": "^0.3.0", "@types/terser-webpack-plugin": "^3.0.0", diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 146a81af36..0b77a370b1 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -77,7 +77,7 @@ export class BaseStore extends Singleton { ); if (ipcMain) { const callback = (event: IpcMainEvent, model: T) => { - logger.debug(`[STORE]: SYNC ${this.name} from renderer`, { model }); + logger.silly(`[STORE]: SYNC ${this.name} from renderer`, { model }); this.onSync(model); }; ipcMain.on(this.syncChannel, callback); @@ -85,7 +85,7 @@ export class BaseStore extends Singleton { } if (ipcRenderer) { const callback = (event: IpcRendererEvent, model: T) => { - logger.debug(`[STORE]: SYNC ${this.name} from main`, { model }); + logger.silly(`[STORE]: SYNC ${this.name} from main`, { model }); this.onSync(model); }; ipcRenderer.on(this.syncChannel, callback); diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index b8f9f5f84b..f48ce0f9c4 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -14,6 +14,14 @@ export const clusterIpc = { }, }), + refresh: createIpcChannel({ + channel: "cluster:refresh", + handle: (clusterId: ClusterId) => { + const cluster = clusterStore.getById(clusterId); + if (cluster) return cluster.refresh(); + }, + }), + disconnect: createIpcChannel({ channel: "cluster:disconnect", handle: (clusterId: ClusterId) => { diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index b870858927..82e4d10198 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -76,7 +76,7 @@ export class ClusterStore extends BaseStore { if (ipcRenderer) { ipcRenderer.on("cluster:state", (event, model: ClusterState) => { this.applyWithoutSync(() => { - logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model); + logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host}`, model); this.getById(model.id)?.updateModel(model); }) }) diff --git a/src/common/ipc.ts b/src/common/ipc.ts index b6b49aee51..97c4dd05cd 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc.ts @@ -68,7 +68,7 @@ export function broadcastIpc({ channel, frameId, frameOnly, webContentId, filter } views.forEach(webContent => { const type = webContent.getType(); - logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args }); + logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args }); if (!frameOnly) { webContent.send(channel, ...args); } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index ff5eaae32d..302317e52e 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -44,7 +44,7 @@ export class ClusterManager { // lens-server is connecting to 127.0.0.1:/ if (req.headers.host.startsWith("127.0.0.1")) { const clusterId = req.url.split("/")[1] - const cluster = clusterStore.getById(clusterId) + cluster = clusterStore.getById(clusterId) if (cluster) { // we need to swap path prefix so that request is proxied to kube api req.url = req.url.replace(`/${clusterId}`, apiKubePrefix) diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 80bd208a40..9fc9172296 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -92,7 +92,7 @@ export class Cluster implements ClusterModel { async init(port: number) { try { this.contextHandler = new ContextHandler(this); - this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler); + this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler, port); this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`; this.initialized = true; logger.info(`[CLUSTER]: "${this.contextName}" init success`, { @@ -111,12 +111,10 @@ export class Cluster implements ClusterModel { 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), ); } @@ -135,7 +133,13 @@ export class Cluster implements ClusterModel { if (this.disconnected || (!init && !this.accessible)) { await this.reconnect(); } - await this.refresh(); + await this.refreshConnectionStatus() + if (this.accessible) { + await this.refreshAllowedResources() + this.ready = true + this.kubeCtl = new Kubectl(this.version) + this.kubeCtl.ensureKubectl() // download kubectl in background, so it's not blocking dashboard + } return this.pushState(); } @@ -161,15 +165,14 @@ export class Cluster implements ClusterModel { @action async refresh() { logger.info(`[CLUSTER]: refresh`, this.getMeta()); - await this.refreshConnectionStatus(); // refresh "version", "online", etc. + await this.whenInitialized; + await this.refreshConnectionStatus(); 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; @@ -178,8 +181,8 @@ export class Cluster implements ClusterModel { this.refreshEvents(), this.refreshAllowedResources(), ]); - this.ready = true } + this.pushState(); } @action @@ -396,7 +399,7 @@ export class Cluster implements ClusterModel { } pushState = (state = this.getState()): ClusterState => { - logger.debug(`[CLUSTER]: push-state`, state); + logger.silly(`[CLUSTER]: push-state`, state); broadcastIpc({ channel: "cluster:state", frameId: this.frameId, diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index 32e933d8d4..a3cf6185dd 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -70,7 +70,8 @@ export class ContextHandler { async resolveAuthProxyUrl() { const proxyPort = await this.ensurePort(); - return `http://127.0.0.1:${proxyPort}`; + const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : "" + return `http://127.0.0.1:${proxyPort}${path}`; } async getApiTarget(isWatchRequest = false): Promise { @@ -88,7 +89,7 @@ export class ContextHandler { protected async newApiTarget(timeout: number): Promise { const proxyUrl = await this.resolveAuthProxyUrl(); return { - target: proxyUrl + this.clusterUrl.path, + target: proxyUrl, changeOrigin: true, timeout: timeout, headers: { diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index 5f75899389..59a47c3fbc 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -11,7 +11,7 @@ export class KubeconfigManager { protected configDir = app.getPath("temp") protected tempFile: string; - constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) { + constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) { this.init(); } @@ -28,6 +28,10 @@ export class KubeconfigManager { return this.tempFile; } + protected resolveProxyUrl() { + return `http://127.0.0.1:${this.port}/${this.cluster.id}` + } + /** * 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. @@ -42,7 +46,7 @@ export class KubeconfigManager { clusters: [ { name: contextName, - server: await contextHandler.resolveAuthProxyUrl(), + server: this.resolveProxyUrl(), skipTLSVerify: undefined, } ], @@ -62,7 +66,7 @@ export class KubeconfigManager { // write const configYaml = dumpConfigYaml(proxyConfig); fs.ensureDir(path.dirname(tempFile)); - fs.writeFileSync(tempFile, configYaml); + fs.writeFileSync(tempFile, configYaml, { mode: 0o600 }); this.tempFile = tempFile; logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); return tempFile; diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 7cc4584750..765b3f4d1a 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -1,13 +1,14 @@ import net from "net"; import http from "http"; +import spdy from "spdy"; import httpProxy from "http-proxy"; import url from "url"; import * as WebSocket from "ws" +import { apiPrefix, apiKubePrefix } from "../common/vars" import { openShell } from "./node-shell-session"; import { Router } from "./router" import { ClusterManager } from "./cluster-manager" import { ContextHandler } from "./context-handler"; -import { apiKubePrefix } from "../common/vars"; import logger from "./logger" export class LensProxy { @@ -40,37 +41,49 @@ export class LensProxy { 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) => { + const spdyProxy = spdy.createServer({ + spdy: { + plain: true, + connection: { + autoSpdy31: true + } + } + }, (req: http.IncomingMessage, res: http.ServerResponse) => { + this.handleRequest(proxy, req, res) + }) + spdyProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { + if (req.url.startsWith(`${apiPrefix}?`)) { + this.handleWsUpgrade(req, socket, head) + } else { + if (req.headers.upgrade?.startsWith("SPDY")) { + this.handleSpdyProxy(proxy, req, socket, head) + } else { + socket.end() + } + } + }) + spdyProxy.on("error", (err) => { logger.error("proxy error", err) - }); - return customProxy; + }) + return spdyProxy + } + + protected async handleSpdyProxy(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) { + const cluster = this.clusterManager.getClusterForRequest(req) + if (cluster) { + const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "") + const apiUrl = url.parse(cluster.apiUrl) + const res = new http.ServerResponse(req) + res.assignSocket(socket) + res.setHeader("Location", proxyUrl) + res.setHeader("Host", apiUrl.hostname) + res.statusCode = 302 + res.end() + } } 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; diff --git a/src/main/logger.ts b/src/main/logger.ts index 4068aaacd2..f4e2707c27 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -2,14 +2,16 @@ import { app, remote } from "electron"; import winston from "winston" import { isDebugging } from "../common/vars"; +const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info" + const consoleOptions: winston.transports.ConsoleTransportOptions = { handleExceptions: false, - level: isDebugging ? "debug" : "info", + level: logLevel, } const fileOptions: winston.transports.FileTransportOptions = { handleExceptions: false, - level: isDebugging ? "debug" : "info", + level: logLevel, filename: "lens.log", dirname: (app ?? remote?.app)?.getPath("logs"), maxsize: 16 * 1024, diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index 704d97382a..1d9d722f57 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -39,7 +39,7 @@ export class ShellSession extends EventEmitter { public async open() { this.kubectlBinDir = await this.kubectl.binDir() const pathFromPreferences = userStore.preferences.kubectlBinariesPath || Kubectl.bundledKubectlPath - this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? await this.kubectl.binDir() : path.dirname(pathFromPreferences) + this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? this.kubectlBinDir : path.dirname(pathFromPreferences) this.helmBinDir = helmCli.getBinaryDir() const env = await this.getCachedShellEnv() const shell = env.PTYSHELL diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index be5a95e47a..348d5bbd3f 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -23,8 +23,8 @@ export class WindowManager { this.mainView = new BrowserWindow({ x, y, width, height, show: false, - minWidth: 900, - minHeight: 760, + minWidth: 700, // accommodate 800 x 600 display minimum + minHeight: 500, // accommodate 800 x 600 display minimum titleBarStyle: "hidden", backgroundColor: "#1e2124", webPreferences: { diff --git a/src/renderer/components/+404/not-found.tsx b/src/renderer/components/+404/not-found.tsx index e585abe326..a158e6f124 100644 --- a/src/renderer/components/+404/not-found.tsx +++ b/src/renderer/components/+404/not-found.tsx @@ -1,15 +1,15 @@ import React from "react"; import { Trans } from "@lingui/macro"; -import { MainLayout } from "../layout/main-layout"; +import { TabLayout } from "../layout/tab-layout"; export class NotFound extends React.Component { render() { return ( - +

Page not found

-
- ) + + ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 0350cdcd6b..624df4c1bf 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -80,7 +80,6 @@ export class AddCluster extends React.Component { const contexts = this.getContexts(this.kubeConfigLocal); this.kubeContexts.replace(contexts); break; - case KubeConfigSourceTab.TEXT: try { this.error = "" @@ -91,6 +90,10 @@ export class AddCluster extends React.Component { } break; } + + if (this.kubeContexts.size === 1) { + this.selectedContexts.push(this.kubeContexts.keys().next().value) + } } getContexts(config: KubeConfig): Map { @@ -206,7 +209,7 @@ export class AddCluster extends React.Component { Select kubeconfig file} - active={this.sourceTab == KubeConfigSourceTab.FILE}/> + active={this.sourceTab == KubeConfigSourceTab.FILE} /> Paste as text} @@ -320,13 +323,15 @@ export class AddCluster extends React.Component { return (
{context} - {isNew && } - {isSelected && } + {isNew && } + {isSelected && }
) }; render() { + const addDisabled = this.selectedContexts.length === 0 + return (