From 90d4899ecee1db8cf89f3b620d3fa3a3504b430c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Nov 2021 15:15:44 +0300 Subject: [PATCH 01/39] Bump tailwindcss from 2.2.17 to 2.2.19 (#4278) Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss) from 2.2.17 to 2.2.19. - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/master/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v2.2.17...v2.2.19) --- updated-dependencies: - dependency-name: tailwindcss dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a551ed34d5..32585a5fe2 100644 --- a/package.json +++ b/package.json @@ -361,7 +361,7 @@ "sass-loader": "^8.0.2", "sharp": "^0.29.2", "style-loader": "^2.0.0", - "tailwindcss": "^2.2.17", + "tailwindcss": "^2.2.19", "ts-jest": "26.5.6", "ts-loader": "^7.0.5", "ts-node": "^10.4.0", diff --git a/yarn.lock b/yarn.lock index 18bf4ec035..aa89abe93b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13212,10 +13212,10 @@ table@^6.0.9: string-width "^4.2.3" strip-ansi "^6.0.1" -tailwindcss@^2.2.17: - version "2.2.17" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-2.2.17.tgz#c6332731f9ff1b6628ff589c95c38685347775e3" - integrity sha512-WgRpn+Pxn7eWqlruxnxEbL9ByVRWi3iC10z4b6dW0zSdnkPVC4hPMSWLQkkW8GCyBIv/vbJ0bxIi9dVrl4CfoA== +tailwindcss@^2.2.19: + version "2.2.19" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-2.2.19.tgz#540e464832cd462bb9649c1484b0a38315c2653c" + integrity sha512-6Ui7JSVtXadtTUo2NtkBBacobzWiQYVjYW0ZnKaP9S1ZCKQ0w7KVNz+YSDI/j7O7KCMHbOkz94ZMQhbT9pOqjw== dependencies: arg "^5.0.1" bytes "^3.0.0" From 3666c56319a9d1c3507c1f7062a5e01667e60889 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 8 Nov 2021 10:12:17 -0500 Subject: [PATCH 02/39] Display loading spinner while switching between helm charts (#4269) --- .../+apps-helm-charts/helm-chart-details.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx index ae27987f8b..739d8082e3 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx @@ -49,9 +49,9 @@ const LargeTooltip = withStyles({ @observer export class HelmChartDetails extends Component { @observable chartVersions: HelmChart[]; - @observable selectedChart: HelmChart; - @observable readme: string = null; - @observable error: string = null; + @observable selectedChart?: HelmChart; + @observable readme?: string; + @observable error?: string; private abortController?: AbortController; @@ -68,6 +68,10 @@ export class HelmChartDetails extends Component { disposeOnUnmount(this, [ reaction(() => this.props.chart, async ({ name, repo, version }) => { try { + this.selectedChart = undefined; + this.chartVersions = undefined; + this.readme = undefined; + const { readme, versions } = await getChartDetails(repo, name, { version }); this.readme = readme; From a399b8ee203f85e61c31f7c87a5df4bffb0210d4 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 8 Nov 2021 10:12:26 -0500 Subject: [PATCH 03/39] Fix crash on HorizontalPodAutoscalers view (#4276) --- src/renderer/components/+config-autoscalers/hpa.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx index 428a84b369..5133aa1d4e 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -50,11 +50,14 @@ interface Props extends RouteComponentProps { export class HorizontalPodAutoscalers extends React.Component { getTargets(hpa: HorizontalPodAutoscaler) { const metrics = hpa.getMetrics(); - const metricsRemainCount = metrics.length - 1; - const metricsRemain = metrics.length > 1 ? <>{metricsRemainCount} more... : null; - const metricValues = hpa.getMetricValues(metrics[0]); - return

{metricValues} {metricsRemain && "+"}{metricsRemain}

; + if (metrics.length === 0) { + return

--

; + } + + const metricsRemain = metrics.length > 1 ? `+${metrics.length - 1} more...` : ""; + + return

{hpa.getMetricValues(metrics[0])} {metricsRemain}

; } render() { From c09a57370ebfef5d6ff93fff10f1e782255d3af5 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 8 Nov 2021 19:02:10 -0500 Subject: [PATCH 04/39] Block shell websocket connections from non-lens renderers (#4285) * Block shell websocket connections from non-lens renderers Signed-off-by: Sebastian Malton * Add IPC based authentication tokens Signed-off-by: Sebastian Malton * Fix race condition on tab start Signed-off-by: Sebastian Malton --- src/main/index.ts | 5 +- src/main/lens-proxy.ts | 2 +- src/main/proxy-functions/shell-api-request.ts | 81 +++++++++++++++---- src/main/shell-session/node-shell-session.ts | 2 +- src/main/shell-session/shell-session.ts | 2 +- src/renderer/api/terminal-api.ts | 37 +++++---- 6 files changed, 89 insertions(+), 40 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 439d2e6471..fc443c3896 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -63,7 +63,7 @@ import { ensureDir } from "fs-extra"; import { Router } from "./router"; import { initMenu } from "./menu"; import { initTray } from "./tray"; -import { kubeApiRequest, shellApiRequest } from "./proxy-functions"; +import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions"; import { AppPaths } from "../common/app-paths"; injectSystemCAs(); @@ -122,7 +122,7 @@ app.on("second-instance", (event, argv) => { WindowManager.getInstance(false)?.ensureMainWindow(); }); -app.on("ready", async () => { +app.on("ready", async () => { logger.info(`🚀 Starting ${productName} from "${AppPaths.get("exe")}"`); logger.info("🐚 Syncing shell environment"); await shellSync(); @@ -134,6 +134,7 @@ app.on("ready", async () => { registerFileProtocol("static", __static); PrometheusProviderRegistry.createInstance(); + ShellRequestAuthenticator.createInstance().init(); initializers.initPrometheusProviderRegistry(); /** diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index a3bee528cd..d22981ddf4 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -82,7 +82,7 @@ export class LensProxy extends Singleton { const reqHandler = isInternal ? shellApiRequest : kubeApiRequest; (async () => reqHandler({ req, socket, head }))() - .catch(error => logger.error(logger.error(`[LENS-PROXY]: failed to handle proxy upgrade: ${error}`))); + .catch(error => logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); }); } diff --git a/src/main/proxy-functions/shell-api-request.ts b/src/main/proxy-functions/shell-api-request.ts index 3edaecff81..db88336a2a 100644 --- a/src/main/proxy-functions/shell-api-request.ts +++ b/src/main/proxy-functions/shell-api-request.ts @@ -19,29 +19,78 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type http from "http"; -import url from "url"; import logger from "../logger"; -import * as WebSocket from "ws"; +import { Server as WebSocketServer } from "ws"; import { NodeShellSession, LocalShellSession } from "../shell-session"; import type { ProxyApiRequestArgs } from "./types"; import { ClusterManager } from "../cluster-manager"; +import URLParse from "url-parse"; +import { ExtendedMap, Singleton } from "../../common/utils"; +import type { ClusterId } from "../../common/cluster-types"; +import { ipcMainHandle } from "../../common/ipc"; +import * as uuid from "uuid"; -export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs) { - const ws = new WebSocket.Server({ noServer: true }); +export class ShellRequestAuthenticator extends Singleton { + private tokens = new ExtendedMap>(); - ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); - const nodeParam = url.parse(req.url, true).query["node"]?.toString(); - const shell = nodeParam - ? new NodeShellSession(socket, cluster, nodeParam) - : new LocalShellSession(socket, cluster); + init() { + ipcMainHandle("cluster:shell-api", (event, clusterId, tabId) => { + const authToken = uuid.v4(); + + this.tokens + .getOrInsert(clusterId, () => new Map()) + .set(tabId, authToken); + + return authToken; + }); + } + + /** + * Authenticates a single use token for creating a new shell + * @param clusterId The `ClusterId` for the shell + * @param tabId The ID for the shell + * @param token The value that is being presented as a one time authentication token + * @returns `true` if `token` was valid, false otherwise + */ + authenticate(clusterId: ClusterId, tabId: string, token: string): boolean { + const clusterTokens = this.tokens.get(clusterId); + + if (!clusterTokens) { + return false; + } + + const authToken = clusterTokens.get(tabId); + + // need both conditions to prevent `undefined === undefined` being true here + if (typeof authToken === "string" && authToken === token) { + // remove the token because it is a single use token + clusterTokens.delete(tabId); + + return true; + } + + return false; + } +} + +export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs): void { + const cluster = ClusterManager.getInstance().getClusterForRequest(req); + const { query: { node, shellToken, id: tabId }} = new URLParse(req.url, true); + + if (!cluster || !ShellRequestAuthenticator.getInstance().authenticate(cluster.id, tabId, shellToken)) { + socket.write("Invalid shell request"); + + return void socket.end(); + } + + const ws = new WebSocketServer({ noServer: true }); + + ws.handleUpgrade(req, socket, head, (webSocket) => { + const shell = node + ? new NodeShellSession(webSocket, cluster, node) + : new LocalShellSession(webSocket, cluster); shell.open() - .catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error })); - })); - - ws.handleUpgrade(req, socket, head, (con) => { - ws.emit("connection", con, req); + .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${node ? "node" : "local"} shell`, error)); }); } diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts index 494137de7d..db0bb89bd3 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type * as WebSocket from "ws"; +import type WebSocket from "ws"; import { v4 as uuid } from "uuid"; import * as k8s from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node"; diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 987a00afdd..ba05a22dc5 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -22,7 +22,7 @@ import fse from "fs-extra"; import type { Cluster } from "../cluster"; import { Kubectl } from "../kubectl"; -import type * as WebSocket from "ws"; +import type WebSocket from "ws"; import { shellEnv } from "../utils/shell-env"; import { app } from "electron"; import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 1a66b25f46..a1533430db 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -19,13 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { boundMethod, base64, EventEmitter } from "../utils"; +import { boundMethod, base64, EventEmitter, getHostedClusterId } from "../utils"; import { WebSocketApi } from "./websocket-api"; import isEqual from "lodash/isEqual"; import { isDevelopment } from "../../common/vars"; import url from "url"; import { makeObservable, observable } from "mobx"; -import type { ParsedUrlQueryInput } from "querystring"; +import { ipcRenderer } from "electron"; export enum TerminalChannels { STDIN = 0, @@ -50,7 +50,7 @@ enum TerminalColor { export type TerminalApiQuery = Record & { id: string; node?: string; - type?: string | "node"; + type?: string; }; export class TerminalApi extends WebSocketApi { @@ -58,9 +58,8 @@ export class TerminalApi extends WebSocketApi { public onReady = new EventEmitter<[]>(); @observable public isReady = false; - public readonly url: string; - constructor(protected options: TerminalApiQuery) { + constructor(protected query: TerminalApiQuery) { super({ logging: isDevelopment, flushOnOpen: false, @@ -68,30 +67,30 @@ export class TerminalApi extends WebSocketApi { }); makeObservable(this); - const { hostname, protocol, port } = location; - const query: ParsedUrlQueryInput = { - id: options.id, - }; - - if (options.node) { - query.node = options.node; - query.type = options.type || "node"; + if (query.node) { + query.type ||= "node"; } + } - this.url = url.format({ + async connect() { + this.emitStatus("Connecting ..."); + + const shellToken = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id); + const { hostname, protocol, port } = location; + const socketUrl = url.format({ protocol: protocol.includes("https") ? "wss" : "ws", hostname, port, pathname: "/api", - query, + query: { + ...this.query, + shellToken, + }, slashes: true, }); - } - connect() { - this.emitStatus("Connecting ..."); this.onData.addListener(this._onReady, { prepend: true }); - super.connect(this.url); + super.connect(socketUrl); } destroy() { From 5eb1b8553d3988ccd963616a904b0a1fc1e0b8ed Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 8 Nov 2021 20:11:40 -0500 Subject: [PATCH 05/39] Support window nodes for node-shells (#3624) * Support window nodes for node-shells Signed-off-by: Sebastian Malton * Also check beta.kubernetes/os label Signed-off-by: Sebastian Malton * Add comment about linux fallback Signed-off-by: Sebastian Malton --- src/common/k8s-api/endpoints/nodes.api.ts | 11 ++++----- src/common/k8s-api/kube-api.ts | 5 +--- src/common/k8s-api/kube-json-api.ts | 18 ++++++++++++++- src/main/cluster-manager.ts | 13 +++-------- src/main/shell-session/node-shell-session.ts | 24 ++++++++++++++++++-- 5 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/common/k8s-api/endpoints/nodes.api.ts b/src/common/k8s-api/endpoints/nodes.api.ts index 3f4e0fb412..f46eaa8811 100644 --- a/src/common/k8s-api/endpoints/nodes.api.ts +++ b/src/common/k8s-api/endpoints/nodes.api.ts @@ -236,13 +236,10 @@ export class Node extends KubeObject { } getOperatingSystem(): string { - const label = this.getLabels().find(label => label.startsWith("kubernetes.io/os=")); - - if (label) { - return label.split("=", 2)[1]; - } - - return "linux"; + return this.metadata?.labels?.["kubernetes.io/os"] + || this.metadata?.labels?.["beta.kubernetes.io/os"] + || this.status?.nodeInfo?.operatingSystem + || "linux"; } isUnschedulable() { diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 850d5dcb05..79c432c6f9 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -224,10 +224,7 @@ export class KubeApi { isNamespaced = options.objectConstructor?.namespaced, } = options || {}; - if (!options.apiBase) { - options.apiBase = objectConstructor.apiBase; - } - const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase); + const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase || objectConstructor.apiBase); this.kind = kind; this.isNamespaced = isNamespaced; diff --git a/src/common/k8s-api/kube-json-api.ts b/src/common/k8s-api/kube-json-api.ts index 2e6e892b21..3776634a47 100644 --- a/src/common/k8s-api/kube-json-api.ts +++ b/src/common/k8s-api/kube-json-api.ts @@ -21,6 +21,9 @@ import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; import type { Response } from "node-fetch"; +import type { Cluster } from "../../main/cluster"; +import { LensProxy } from "../../main/lens-proxy"; +import { apiKubePrefix, isDebugging } from "../vars"; export interface KubeJsonApiListMetadata { resourceVersion: string; @@ -70,6 +73,20 @@ export interface KubeJsonApiError extends JsonApiError { } export class KubeJsonApi extends JsonApi { + static forCluster(cluster: Cluster): KubeJsonApi { + const port = LensProxy.getInstance().port; + + return new this({ + serverAddress: `http://127.0.0.1:${port}`, + apiBase: apiKubePrefix, + debug: isDebugging, + }, { + headers: { + "Host": `${cluster.id}.localhost:${port}`, + }, + }); + } + protected parseError(error: KubeJsonApiError | any, res: Response): string[] { const { status, reason, message } = error; @@ -80,4 +97,3 @@ export class KubeJsonApi extends JsonApi { return super.parseError(error, res); } } - diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 53c79fda7c..b72084751d 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -240,27 +240,20 @@ export class ClusterManager extends Singleton { } getClusterForRequest(req: http.IncomingMessage): Cluster { - let cluster: Cluster = null; - // lens-server is connecting to 127.0.0.1:/ if (req.headers.host.startsWith("127.0.0.1")) { const clusterId = req.url.split("/")[1]; - - cluster = this.store.getById(clusterId); + const cluster = this.store.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); } - } else if (req.headers["x-cluster-id"]) { - cluster = this.store.getById(req.headers["x-cluster-id"].toString()); - } else { - const clusterId = getClusterIdFromHost(req.headers.host); - cluster = this.store.getById(clusterId); + return cluster; } - return cluster; + return this.store.getById(getClusterIdFromHost(req.headers.host)); } } diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts index db0bb89bd3..f13ae9fdb6 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session.ts @@ -26,6 +26,9 @@ import type { KubeConfig } from "@kubernetes/client-node"; import type { Cluster } from "../cluster"; import { ShellOpenError, ShellSession } from "./shell-session"; import { get } from "lodash"; +import { Node, NodesApi } from "../../common/k8s-api/endpoints"; +import { KubeJsonApi } from "../../common/k8s-api/kube-json-api"; +import logger from "../logger"; export class NodeShellSession extends ShellSession { ShellType = "node-shell"; @@ -55,10 +58,27 @@ export class NodeShellSession extends ShellSession { throw new ShellOpenError("failed to create node pod", error); } - const args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]; const env = await this.getCachedShellEnv(); + const args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--"]; + const nodeApi = new NodesApi({ + objectConstructor: Node, + request: KubeJsonApi.forCluster(this.cluster), + }); + const node = await nodeApi.get({ name: this.nodeName }); + const nodeOs = node.getOperatingSystem(); - await super.open(shell, args, env); + switch (nodeOs) { + default: + logger.warn(`[NODE-SHELL-SESSION]: could not determine node OS, falling back with assumption of linux`); + case "linux": + args.push("sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"); + break; + case "windows": + args.push("powershell"); + break; + } + + return super.open(shell, args, env); } protected createNodeShellPod() { From 262f471da808634891f74a501313671b8542adaa Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Tue, 9 Nov 2021 11:55:09 +0200 Subject: [PATCH 06/39] Add default WebStorm configuration files (#4293) Signed-off-by: Janne Savolainen --- .idea/.gitignore | 5 ++ .idea/codeStyles/Project.xml | 73 ++++++++++++++++++++ .idea/codeStyles/codeStyleConfig.xml | 5 ++ .idea/encodings.xml | 6 ++ .idea/inspectionProfiles/Project_Default.xml | 6 ++ .idea/jsLinters/eslint.xml | 6 ++ .idea/lens.iml | 19 +++++ .idea/misc.xml | 6 ++ .idea/modules.xml | 8 +++ .idea/prettier.xml | 6 ++ .idea/vcs.xml | 6 ++ .idea/webResources.xml | 14 ++++ 12 files changed, 160 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/jsLinters/eslint.xml create mode 100644 .idea/lens.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/prettier.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/webResources.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000..b58b603fea --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000000..7abc908e60 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,73 @@ + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000000..79ee123c2b --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000000..97626ba454 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000000..03d9549ea8 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/jsLinters/eslint.xml b/.idea/jsLinters/eslint.xml new file mode 100644 index 0000000000..541945bb08 --- /dev/null +++ b/.idea/jsLinters/eslint.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/lens.iml b/.idea/lens.iml new file mode 100644 index 0000000000..079203f10b --- /dev/null +++ b/.idea/lens.iml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000..039e6a7313 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..4af03d0504 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000000..8004cebd99 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..94a25f7f4c --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/webResources.xml b/.idea/webResources.xml new file mode 100644 index 0000000000..717d9d6656 --- /dev/null +++ b/.idea/webResources.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file From b52bd297843b7147bdb11b84d2402cc031794b24 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Tue, 9 Nov 2021 13:41:46 +0300 Subject: [PATCH 07/39] Cluster sidebar dropdown (#4292) * Making cluster area hoverable Signed-off-by: Alex Andreev * Move sidebar cluster block to separate component Signed-off-by: Alex Andreev * Clean up Signed-off-by: Alex Andreev * Add keyboard support Signed-off-by: Alex Andreev * Adding tests Signed-off-by: Alex Andreev * Cleaning up Signed-off-by: Alex Andreev --- integration/__tests__/cluster-pages.tests.ts | 10 ++ .../layout/__tests__/sidebar-cluster.test.tsx | 74 +++++++++ .../layout/sidebar-cluster.module.css | 57 +++++++ .../components/layout/sidebar-cluster.tsx | 140 ++++++++++++++++++ .../components/layout/sidebar.module.css | 14 -- src/renderer/components/layout/sidebar.tsx | 64 +------- 6 files changed, 284 insertions(+), 75 deletions(-) create mode 100644 src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx create mode 100644 src/renderer/components/layout/sidebar-cluster.module.css create mode 100644 src/renderer/components/layout/sidebar-cluster.tsx diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 36173c0e9d..64ca60380d 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -331,6 +331,14 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { await cleanup(); }, 10*60*1000); + it("shows cluster context menu in sidebar", async () => { + await frame.click(`[data-testid="sidebar-cluster-dropdown"]`); + await frame.waitForSelector(`.Menu >> text="Add to Hotbar"`); + await frame.waitForSelector(`.Menu >> text="Settings"`); + await frame.waitForSelector(`.Menu >> text="Disconnect"`); + await frame.waitForSelector(`.Menu >> text="Delete"`); + }); + it("should navigate around common cluster pages", async () => { for (const test of commonPageTests) { if (isTopPageTest(test)) { @@ -362,6 +370,8 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { } }, 10*60*1000); + + it("show logs and highlight the log search entries", async () => { await frame.click(`a[href="/workloads"]`); await frame.click(`a[href="/pods"]`); diff --git a/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx new file mode 100644 index 0000000000..195368408d --- /dev/null +++ b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, fireEvent } from "@testing-library/react"; +import { SidebarCluster } from "../sidebar-cluster"; +import { KubernetesCluster } from "../../../../common/catalog-entities"; + +jest.mock("../../../../common/hotbar-store", () => ({ + HotbarStore: { + getInstance: () => ({ + isAddedToActive: jest.fn(), + }), + }, +})); + +const clusterEntity = new KubernetesCluster({ + metadata: { + uid: "test-uid", + name: "test-cluster", + source: "local", + labels: {}, + }, + spec: { + kubeconfigPath: "", + kubeconfigContext: "", + }, + status: { + phase: "connected", + }, +}); + +describe("", () => { + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("renders cluster avatar and name", () => { + const { getByText } = render(); + + expect(getByText("tc")).toBeInTheDocument(); + expect(getByText("test-cluster")).toBeInTheDocument(); + }); + + it("renders cluster menu", async () => { + const { getByTestId, getByText } = render(); + const link = getByTestId("sidebar-cluster-dropdown"); + + fireEvent.click(link); + expect(await getByText("Add to Hotbar")).toBeInTheDocument(); + }); +}); + diff --git a/src/renderer/components/layout/sidebar-cluster.module.css b/src/renderer/components/layout/sidebar-cluster.module.css new file mode 100644 index 0000000000..37a9aed9fa --- /dev/null +++ b/src/renderer/components/layout/sidebar-cluster.module.css @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +.SidebarCluster { + display: flex; + align-items: center; + padding: 1.25rem; + cursor: pointer; + + &:focus-visible { + background: var(--blue); + color: white; + } + + &:hover { + background: var(--sidebarLogoBackground); + } +} + +.clusterName { + font-weight: bold; + overflow: hidden; + word-break: break-word; + color: var(--textColorAccent); + display: -webkit-box; + /* Simulate text-overflow:ellipsis styles but for multiple text lines */ + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.menu { + width: 185px; + margin-top: -10px; +} + +.avatar { + font-weight: 500; + margin-right: 1.25rem; +} \ No newline at end of file diff --git a/src/renderer/components/layout/sidebar-cluster.tsx b/src/renderer/components/layout/sidebar-cluster.tsx new file mode 100644 index 0000000000..8b47ac122f --- /dev/null +++ b/src/renderer/components/layout/sidebar-cluster.tsx @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import styles from "./sidebar-cluster.module.css"; +import { observable } from "mobx"; +import React, { useState } from "react"; +import { HotbarStore } from "../../../common/hotbar-store"; +import { broadcastMessage } from "../../../common/ipc"; +import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; +import { IpcRendererNavigationEvents } from "../../navigation/events"; +import { Avatar } from "../avatar/avatar"; +import { Icon } from "../icon"; +import { navigate } from "../../navigation"; +import { Menu, MenuItem } from "../menu"; +import { ConfirmDialog } from "../confirm-dialog"; + +const contextMenu: CatalogEntityContextMenuContext = observable({ + menuItems: [], + navigate: (url: string, forceMainFrame = true) => { + if (forceMainFrame) { + broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url); + } else { + navigate(url); + } + }, +}); + +function onMenuItemClick(menuItem: CatalogEntityContextMenu) { + if (menuItem.confirm) { + ConfirmDialog.open({ + okButtonProps: { + primary: false, + accent: true, + }, + ok: () => { + menuItem.onClick(); + }, + message: menuItem.confirm.message, + }); + } else { + menuItem.onClick(); + } +} + +export function SidebarCluster({ clusterEntity }: { clusterEntity: CatalogEntity }) { + const [opened, setOpened] = useState(false); + + if (!clusterEntity) { + return null; + } + + const onMenuOpen = () => { + const hotbarStore = HotbarStore.getInstance(); + const isAddedToActive = HotbarStore.getInstance().isAddedToActive(clusterEntity); + const title = isAddedToActive + ? "Remove from Hotbar" + : "Add to Hotbar"; + const onClick = isAddedToActive + ? () => hotbarStore.removeFromHotbar(metadata.uid) + : () => hotbarStore.addToHotbar(clusterEntity); + + contextMenu.menuItems = [{ title, onClick }]; + clusterEntity.onContextMenuOpen(contextMenu); + + toggle(); + }; + + const onKeyDown = (evt: React.KeyboardEvent) => { + if (evt.code == "Space") { + toggle(); + } + }; + + const toggle = () => { + setOpened(!opened); + }; + + const { metadata, spec } = clusterEntity; + const id = `cluster-${metadata.uid}`; + + return ( + + ); +} diff --git a/src/renderer/components/layout/sidebar.module.css b/src/renderer/components/layout/sidebar.module.css index cf1257627f..13f10c76a9 100644 --- a/src/renderer/components/layout/sidebar.module.css +++ b/src/renderer/components/layout/sidebar.module.css @@ -40,17 +40,3 @@ padding: 3px; border-radius: 50%; } - -.cluster { - @apply flex items-center m-5; -} - -.clusterName { - @apply font-bold overflow-hidden; - word-break: break-word; - color: var(--textColorAccent); - display: -webkit-box; - /* Simulate text-overflow:ellipsis styles but for multiple text lines */ - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; -} diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index f89b06e6d4..702a7a61d2 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -32,7 +32,7 @@ import { Storage } from "../+storage"; import { Network } from "../+network"; import { crdStore } from "../+custom-resources/crd.store"; import { CustomResources } from "../+custom-resources/custom-resources"; -import { isActiveRoute, navigate } from "../../navigation"; +import { isActiveRoute } from "../../navigation"; import { isAllowedResource } from "../../../common/utils/allowed-resource"; import { Spinner } from "../spinner"; import { ClusterPageMenuRegistration, ClusterPageMenuRegistry, ClusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries"; @@ -41,12 +41,7 @@ import { Apps } from "../+apps"; import * as routes from "../../../common/routes"; import { Config } from "../+config"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { HotbarIcon } from "../hotbar/hotbar-icon"; -import { makeObservable, observable } from "mobx"; -import type { CatalogEntityContextMenuContext } from "../../../common/catalog"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { broadcastMessage } from "../../../common/ipc"; -import { IpcRendererNavigationEvents } from "../../navigation/events"; +import { SidebarCluster } from "./sidebar-cluster"; interface Props { className?: string; @@ -55,21 +50,6 @@ interface Props { @observer export class Sidebar extends React.Component { static displayName = "Sidebar"; - @observable private contextMenu: CatalogEntityContextMenuContext = { - menuItems: [], - navigate: (url: string, forceMainFrame = true) => { - if (forceMainFrame) { - broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url); - } else { - navigate(url); - } - }, - }; - - constructor(props: Props) { - super(props); - makeObservable(this); - } async componentDidMount() { crdStore.reloadAll(); @@ -198,44 +178,6 @@ export class Sidebar extends React.Component { }); } - renderCluster() { - if (!this.clusterEntity) { - return null; - } - - const { metadata, spec } = this.clusterEntity; - - return ( -
- navigate("/")} - menuItems={this.contextMenu.menuItems} - onMenuOpen={() => { - const hotbarStore = HotbarStore.getInstance(); - const isAddedToActive = HotbarStore.getInstance().isAddedToActive(this.clusterEntity); - const title = isAddedToActive - ? "Remove from Hotbar" - : "Add to Hotbar"; - const onClick = isAddedToActive - ? () => hotbarStore.removeFromHotbar(metadata.uid) - : () => hotbarStore.addToHotbar(this.clusterEntity); - - this.contextMenu.menuItems = [{ title, onClick }]; - this.clusterEntity.onContextMenuOpen(this.contextMenu); - }} - /> -
- {metadata.name} -
-
- ); - } - get clusterEntity() { return catalogEntityRegistry.activeEntity; } @@ -245,7 +187,7 @@ export class Sidebar extends React.Component { return (
- {this.renderCluster()} +
Date: Tue, 9 Nov 2021 09:33:42 -0500 Subject: [PATCH 08/39] Use .status.phase instead of computing it for Pod.getStatusMessage (#4286) --- src/common/k8s-api/endpoints/pods.api.ts | 87 +++++++++---------- .../components/+workloads-pods/pods.tsx | 2 +- 2 files changed, 41 insertions(+), 48 deletions(-) diff --git a/src/common/k8s-api/endpoints/pods.api.ts b/src/common/k8s-api/endpoints/pods.api.ts index bdcddf035d..361e72ca54 100644 --- a/src/common/k8s-api/endpoints/pods.api.ts +++ b/src/common/k8s-api/endpoints/pods.api.ts @@ -165,40 +165,40 @@ interface IContainerProbe { failureThreshold?: number; } +export interface ContainerStateRunning { + startedAt: string; +} + +export interface ContainerStateWaiting { + reason: string; + message: string; +} + +export interface ContainerStateTerminated { + startedAt: string; + finishedAt: string; + exitCode: number; + reason: string; + containerID?: string; + message?: string; + signal?: number; +} + +/** + * ContainerState holds a possible state of container. Only one of its members + * may be specified. If none of them is specified, the default one is + * `ContainerStateWaiting`. + */ +export interface ContainerState { + running?: ContainerStateRunning; + waiting?: ContainerStateWaiting; + terminated?: ContainerStateTerminated; +} + export interface IPodContainerStatus { name: string; - state?: { - [index: string]: object; - running?: { - startedAt: string; - }; - waiting?: { - reason: string; - message: string; - }; - terminated?: { - startedAt: string; - finishedAt: string; - exitCode: number; - reason: string; - }; - }; - lastState?: { - [index: string]: object; - running?: { - startedAt: string; - }; - waiting?: { - reason: string; - message: string; - }; - terminated?: { - startedAt: string; - finishedAt: string; - exitCode: number; - reason: string; - }; - }; + state?: ContainerState; + lastState?: ContainerState; ready: boolean; restartCount: number; image: string; @@ -373,23 +373,16 @@ export class Pod extends WorkloadKubeObject { } // Returns pod phase or container error if occurred - getStatusMessage() { - if (this.getReason() === PodStatus.EVICTED) return "Evicted"; - if (this.metadata.deletionTimestamp) return "Terminating"; - - const statuses = this.getContainerStatuses(false); // not including initContainers - - for (const { state } of statuses.reverse()) { - if (state.waiting) { - return state.waiting.reason || "Waiting"; - } - - if (state.terminated) { - return state.terminated.reason || "Terminated"; - } + getStatusMessage(): string { + if (this.getReason() === PodStatus.EVICTED) { + return "Evicted"; } - return this.getStatusPhase(); + if (this.metadata.deletionTimestamp) { + return "Terminating"; + } + + return this.getStatusPhase() || "Waiting"; } getStatusPhase() { diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx index a7f3a46a62..7688861cdc 100644 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ b/src/renderer/components/+workloads-pods/pods.tsx @@ -69,7 +69,7 @@ export class Pods extends React.Component { formatters: { tableView: true, }, - children: Object.keys(state).map(status => ( + children: Object.keys(state).map((status: keyof typeof state) => (
{name} ({status}{ready ? ", ready" : ""}) From 4b9fdddac4702f894cd7ba264c0ccc75b5690fe2 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 9 Nov 2021 09:33:53 -0500 Subject: [PATCH 09/39] Specify GiB for lens prometheus metrics instead of GB (#4287) --- extensions/metrics-cluster-feature/src/metrics-settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/metrics-cluster-feature/src/metrics-settings.tsx b/extensions/metrics-cluster-feature/src/metrics-settings.tsx index 238d71bbf0..de0edd5be3 100644 --- a/extensions/metrics-cluster-feature/src/metrics-settings.tsx +++ b/extensions/metrics-cluster-feature/src/metrics-settings.tsx @@ -61,14 +61,14 @@ export class MetricsSettings extends React.Component { persistence: { enabled: false, storageClass: null, - size: "20G", + size: "20GiB", }, nodeExporter: { enabled: false, }, retention: { time: "2d", - size: "5GB", + size: "5GiB", }, kubeStateMetrics: { enabled: false, From 8de3cbe5ee98cb8422501f81e80cead3f792229f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Nov 2021 09:34:18 -0500 Subject: [PATCH 10/39] Bump @types/node from 14.17.27 to 14.17.33 (#4290) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 32585a5fe2..f80016b251 100644 --- a/package.json +++ b/package.json @@ -278,7 +278,7 @@ "@types/md5-file": "^4.0.2", "@types/mini-css-extract-plugin": "^0.9.1", "@types/mock-fs": "^4.13.1", - "@types/node": "14.17.27", + "@types/node": "14.17.33", "@types/node-fetch": "^2.5.12", "@types/npm": "^2.0.32", "@types/progress-bar-webpack-plugin": "^2.1.2", diff --git a/yarn.lock b/yarn.lock index aa89abe93b..f574288642 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1606,10 +1606,10 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@14.17.27", "@types/node@^14.6.2": - version "14.17.27" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.27.tgz#5054610d37bb5f6e21342d0e6d24c494231f3b85" - integrity sha512-94+Ahf9IcaDuJTle/2b+wzvjmutxXAEXU6O81JHblYXUg2BDG+dnBy7VxIPHKAyEEDHzCMQydTJuWvrE+Aanzw== +"@types/node@*", "@types/node@14.17.33", "@types/node@^14.6.2": + version "14.17.33" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.33.tgz#011ee28e38dc7aee1be032ceadf6332a0ab15b12" + integrity sha512-noEeJ06zbn3lOh4gqe2v7NMGS33jrulfNqYFDjjEbhpDEHR5VTxgYNQSBqBlJIsBJW3uEYDgD6kvMnrrhGzq8g== "@types/node@^10.12.0": version "10.17.24" From e5bf5920fca64b03aad64b6cd6ff58cbb1c11af6 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 9 Nov 2021 12:30:55 -0500 Subject: [PATCH 11/39] Keep shell processes alive between network offline periods (#4258) --- src/main/index.ts | 2 + src/main/proxy-functions/shell-api-request.ts | 4 +- src/main/shell-session/local-shell-session.ts | 2 +- src/main/shell-session/node-shell-session.ts | 6 +- src/main/shell-session/shell-session.ts | 148 +++++++++++++++--- src/renderer/api/terminal-api.ts | 8 +- 6 files changed, 141 insertions(+), 29 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index fc443c3896..e5b7f3d9f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,6 +65,7 @@ import { initMenu } from "./menu"; import { initTray } from "./tray"; import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions"; import { AppPaths } from "../common/app-paths"; +import { ShellSession } from "./shell-session/shell-session"; injectSystemCAs(); @@ -228,6 +229,7 @@ app.on("ready", async () => { onQuitCleanup.push( initMenu(windowManager), initTray(windowManager), + () => ShellSession.cleanup(), ); installDeveloperTools(); diff --git a/src/main/proxy-functions/shell-api-request.ts b/src/main/proxy-functions/shell-api-request.ts index db88336a2a..620c1126ce 100644 --- a/src/main/proxy-functions/shell-api-request.ts +++ b/src/main/proxy-functions/shell-api-request.ts @@ -87,8 +87,8 @@ export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs): voi ws.handleUpgrade(req, socket, head, (webSocket) => { const shell = node - ? new NodeShellSession(webSocket, cluster, node) - : new LocalShellSession(webSocket, cluster); + ? new NodeShellSession(webSocket, cluster, node, tabId) + : new LocalShellSession(webSocket, cluster, tabId); shell.open() .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${node ? "node" : "local"} shell`, error)); diff --git a/src/main/shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session.ts index ed1208fcbd..1223ea7c3e 100644 --- a/src/main/shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session.ts @@ -40,7 +40,7 @@ export class LocalShellSession extends ShellSession { const shell = env.PTYSHELL; const args = await this.getShellArgs(shell); - super.open(env.PTYSHELL, args, env); + await this.openShellProcess(env.PTYSHELL, args, env); } protected async getShellArgs(shell: string): Promise { diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts index f13ae9fdb6..2ccf79ae62 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session.ts @@ -40,8 +40,8 @@ export class NodeShellSession extends ShellSession { return undefined; } - constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) { - super(socket, cluster); + constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string, terminalId: string) { + super(socket, cluster, terminalId); } public async open() { @@ -78,7 +78,7 @@ export class NodeShellSession extends ShellSession { break; } - return super.open(shell, args, env); + await this.openShellProcess(shell, args, env); } protected createNodeShellPod() { diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index ba05a22dc5..bfd9a1601e 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -31,6 +31,7 @@ import { isWindows } from "../../common/vars"; import { UserStore } from "../../common/user-store"; import * as pty from "node-pty"; import { appEventBus } from "../../common/event-bus"; +import logger from "../logger"; export class ShellOpenError extends Error { constructor(message: string, public cause: Error) { @@ -40,41 +41,140 @@ export class ShellOpenError extends Error { } } +export enum WebSocketCloseEvent { + /** + * The connection successfully completed the purpose for which it was created. + */ + NormalClosure = 1000, + /** + * The endpoint is going away, either because of a server failure or because + * the browser is navigating away from the page that opened the connection. + */ + GoingAway = 1001, + /** + * The endpoint is terminating the connection due to a protocol error. + */ + ProtocolError = 1002, + /** + * The connection is being terminated because the endpoint received data of a + * type it cannot accept. (For example, a text-only endpoint received binary + * data.) + */ + UnsupportedData = 1003, + /** + * Indicates that no status code was provided even though one was expected. + */ + NoStatusReceived = 1005, + /** + * Indicates that a connection was closed abnormally (that is, with no close + * frame being sent) when a status code is expected. + */ + AbnormalClosure = 1006, + /** + * The endpoint is terminating the connection because a message was received + * that contained inconsistent data (e.g., non-UTF-8 data within a text message). + */ + InvalidFramePayloadData = 1007, + /** + * The endpoint is terminating the connection because it received a message + * that violates its policy. This is a generic status code, used when codes + * 1003 and 1009 are not suitable. + */ + PolicyViolation = 1008, + /** + * The endpoint is terminating the connection because a data frame was + * received that is too large. + */ + MessageTooBig = 1009, + /** + * The client is terminating the connection because it expected the server to + * negotiate one or more extension, but the server didn't. + */ + MissingExtension = 1010, + /** + * The server is terminating the connection because it encountered an + * unexpected condition that prevented it from fulfilling the request. + */ + InternalError = 1011, + /** + * The server is terminating the connection because it is restarting. + */ + ServiceRestart = 1012, + /** + * The server is terminating the connection due to a temporary condition, + * e.g. it is overloaded and is casting off some of its clients. + */ + TryAgainLater = 1013, + /** + * The server was acting as a gateway or proxy and received an invalid + * response from the upstream server. This is similar to 502 HTTP Status Code. + */ + BadGateway = 1014, + /** + * Indicates that the connection was closed due to a failure to perform a TLS + * handshake (e.g., the server certificate can't be verified). + */ + TlsHandshake = 1015, +} + export abstract class ShellSession { abstract ShellType: string; - static shellEnvs: Map> = new Map(); + private static shellEnvs = new Map>(); + private static processes = new Map(); + + /** + * Kill all remaining shell backing processes. Should be called when about to + * quit + */ + public static cleanup(): void { + for (const shellProcess of this.processes.values()) { + try { + process.kill(shellProcess.pid); + } catch {} + } + + this.processes.clear(); + } protected kubectl: Kubectl; protected running = false; - protected shellProcess: pty.IPty; protected kubectlBinDirP: Promise; protected kubeconfigPathP: Promise; + protected readonly terminalId: string; protected abstract get cwd(): string | undefined; - constructor(protected websocket: WebSocket, protected cluster: Cluster) { + protected ensureShellProcess(shell: string, args: string[], env: Record, cwd: string): pty.IPty { + if (!ShellSession.processes.has(this.terminalId)) { + ShellSession.processes.set(this.terminalId, pty.spawn(shell, args, { + cols: 80, + cwd, + env, + name: "xterm-256color", + rows: 30, + })); + } + + return ShellSession.processes.get(this.terminalId); + } + + constructor(protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) { this.kubectl = new Kubectl(cluster.version); this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubectlBinDirP = this.kubectl.binDir(); + this.terminalId = `${cluster.id}:${terminalId}`; } - protected async open(shell: string, args: string[], env: Record) { + protected async openShellProcess(shell: string, args: string[], env: Record) { const cwd = (this.cwd && await fse.pathExists(this.cwd)) ? this.cwd : env.HOME; + const shellProcess = this.ensureShellProcess(shell, args, env, cwd); - this.shellProcess = pty.spawn(shell, args, { - cols: 80, - cwd, - env, - name: "xterm-256color", - rows: 30, - }); this.running = true; - - this.shellProcess.onData(data => this.sendResponse(data)); - this.shellProcess.onExit(({ exitCode }) => { + shellProcess.onData(data => this.sendResponse(data)); + shellProcess.onExit(({ exitCode }) => { this.running = false; if (exitCode > 0) { @@ -95,24 +195,28 @@ export abstract class ShellSession { switch (data[0]) { case "0": - this.shellProcess.write(message); + shellProcess.write(message); break; case "4": const { Width, Height } = JSON.parse(message); - this.shellProcess.resize(Width, Height); + shellProcess.resize(Width, Height); break; } }) - .on("close", () => { - if (this.running) { + .on("close", (code) => { + logger.debug(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${code}`); + + if (this.running && code !== WebSocketCloseEvent.AbnormalClosure) { + // This code is the one that gets sent when the network is turned off try { - process.kill(this.shellProcess.pid); + logger.info(`[SHELL-SESSION]: Killing shell process for ${this.terminalId}`); + process.kill(shellProcess.pid); + ShellSession.processes.delete(this.terminalId); } catch (e) { } + this.running = false; } - - this.running = false; }); appEventBus.emit({ name: this.ShellType, action: "open" }); @@ -191,7 +295,7 @@ export abstract class ShellSession { return env; } - protected exit(code = 1000) { + protected exit(code = WebSocketCloseEvent.NormalClosure) { if (this.websocket.readyState == this.websocket.OPEN) { this.websocket.close(code); } diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index a1533430db..09e67d3603 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -73,7 +73,13 @@ export class TerminalApi extends WebSocketApi { } async connect() { - this.emitStatus("Connecting ..."); + if (!this.socket) { + /** + * Only emit this message if we are not "reconnecting", so as to keep the + * output display clean when the computer wakes from sleep + */ + this.emitStatus("Connecting ..."); + } const shellToken = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id); const { hostname, protocol, port } = location; From 18d695348b141cf028f63bb8a696f515226c6326 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 9 Nov 2021 13:43:45 -0500 Subject: [PATCH 12/39] Change base64 functions to be exported not as object children (#4298) --- src/common/utils/base64.ts | 25 +++++++++++++++++-------- src/common/utils/index.ts | 3 ++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/common/utils/base64.ts b/src/common/utils/base64.ts index 8484e128b1..9bae5f79bf 100755 --- a/src/common/utils/base64.ts +++ b/src/common/utils/base64.ts @@ -23,11 +23,20 @@ import * as Base64 from "crypto-js/enc-base64"; import * as Utf8 from "crypto-js/enc-utf8"; -export const base64 = { - decode(data: string) { - return Base64.parse(data).toString(Utf8); - }, - encode(data: string) { - return Utf8.parse(data).toString(Base64); - }, -}; +/** + * Computes utf-8 from base64 + * @param data A Base64 encoded string + * @returns The original utf-8 string + */ +export function decode(data: string): string { + return Base64.parse(data).toString(Utf8); +} + +/** + * Computes base64 from utf-8 + * @param data A normal string + * @returns A base64 encoded version + */ +export function encode(data: string): string { + return Utf8.parse(data).toString(Base64); +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 2875877a16..733867f1cb 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -28,7 +28,6 @@ export function noop(...args: T): void { export * from "./app-version"; export * from "./autobind"; -export * from "./base64"; export * from "./camelCase"; export * from "./cloneJson"; export * from "./cluster-id-url-parsing"; @@ -62,9 +61,11 @@ export * from "./types"; import * as iter from "./iter"; import * as array from "./array"; import * as tuple from "./tuple"; +import * as base64 from "./base64"; export { iter, array, tuple, + base64, }; From 81b972a2d0e99cd59d1dc6a2510dc6505233b175 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 9 Nov 2021 14:24:32 -0500 Subject: [PATCH 13/39] Make ws:// authentication more timing safe (#4297) --- src/main/proxy-functions/shell-api-request.ts | 15 +++++++++------ src/renderer/api/terminal-api.ts | 9 +++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/main/proxy-functions/shell-api-request.ts b/src/main/proxy-functions/shell-api-request.ts index 620c1126ce..76340585e1 100644 --- a/src/main/proxy-functions/shell-api-request.ts +++ b/src/main/proxy-functions/shell-api-request.ts @@ -28,14 +28,17 @@ import URLParse from "url-parse"; import { ExtendedMap, Singleton } from "../../common/utils"; import type { ClusterId } from "../../common/cluster-types"; import { ipcMainHandle } from "../../common/ipc"; -import * as uuid from "uuid"; +import crypto from "crypto"; +import { promisify } from "util"; + +const randomBytes = promisify(crypto.randomBytes); export class ShellRequestAuthenticator extends Singleton { - private tokens = new ExtendedMap>(); + private tokens = new ExtendedMap>(); init() { - ipcMainHandle("cluster:shell-api", (event, clusterId, tabId) => { - const authToken = uuid.v4(); + ipcMainHandle("cluster:shell-api", async (event, clusterId, tabId) => { + const authToken = Uint8Array.from(await randomBytes(128)); this.tokens .getOrInsert(clusterId, () => new Map()) @@ -60,9 +63,9 @@ export class ShellRequestAuthenticator extends Singleton { } const authToken = clusterTokens.get(tabId); + const buf = Uint8Array.from(Buffer.from(token, "base64")); - // need both conditions to prevent `undefined === undefined` being true here - if (typeof authToken === "string" && authToken === token) { + if (authToken instanceof Uint8Array && authToken.length === buf.length && crypto.timingSafeEqual(authToken, buf)) { // remove the token because it is a single use token clusterTokens.delete(tabId); diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 09e67d3603..779d729934 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -81,7 +81,12 @@ export class TerminalApi extends WebSocketApi { this.emitStatus("Connecting ..."); } - const shellToken = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id); + const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id); + + if (!(authTokenArray instanceof Uint8Array)) { + throw new TypeError("ShellApi token is not a Uint8Array"); + } + const { hostname, protocol, port } = location; const socketUrl = url.format({ protocol: protocol.includes("https") ? "wss" : "ws", @@ -90,7 +95,7 @@ export class TerminalApi extends WebSocketApi { pathname: "/api", query: { ...this.query, - shellToken, + shellToken: Buffer.from(authTokenArray).toString("base64"), }, slashes: true, }); From 655b180a04f84070dc26d628997eeb186f0548d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Nov 2021 14:47:15 -0500 Subject: [PATCH 14/39] Bump @types/react from 17.0.33 to 17.0.34 (#4299) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f80016b251..db00e3619e 100644 --- a/package.json +++ b/package.json @@ -284,7 +284,7 @@ "@types/progress-bar-webpack-plugin": "^2.1.2", "@types/proper-lockfile": "^4.1.2", "@types/randomcolor": "^0.5.6", - "@types/react": "^17.0.33", + "@types/react": "^17.0.34", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.9", "@types/react-router-dom": "^5.3.2", diff --git a/yarn.lock b/yarn.lock index f574288642..8119a81fed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1771,10 +1771,10 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^17.0.33": - version "17.0.33" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.33.tgz#e01ae3de7613dac1094569880bb3792732203ad5" - integrity sha512-pLWntxXpDPaU+RTAuSGWGSEL2FRTNyRQOjSWDke/rxRg14ncsZvx8AKWMWZqvc1UOaJIAoObdZhAWvRaHFi5rw== +"@types/react@*", "@types/react@^17.0.34": + version "17.0.34" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102" + integrity sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" From 142610ae0cba68e05b85c50a8c017d58eb387164 Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Tue, 9 Nov 2021 18:52:19 -0500 Subject: [PATCH 15/39] release v5.3.0-alpha.10 (#4289) Signed-off-by: Jim Ehrismann --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index db00e3619e..c201fb7a98 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.3.0-alpha.9", + "version": "5.3.0-alpha.10", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", From 1ce2e727afefbea3a13b1435fed2bc7e1a8887fc Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 9 Nov 2021 22:52:36 -0500 Subject: [PATCH 16/39] Fix crash in IngressDetails (#4267) * Fix crash in IngressDetails - Adds check in Ingress.getServiceNamePort() for a .spec.defaultBackend.resource instead of .spec.defaultBackend.service Signed-off-by: Sebastian Malton * remove undefined Signed-off-by: Sebastian Malton * Add comment about RequireExactlyOne Signed-off-by: Sebastian Malton --- src/common/k8s-api/endpoints/ingress.api.ts | 22 +++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/common/k8s-api/endpoints/ingress.api.ts b/src/common/k8s-api/endpoints/ingress.api.ts index 277b47536c..7b80dca29e 100644 --- a/src/common/k8s-api/endpoints/ingress.api.ts +++ b/src/common/k8s-api/endpoints/ingress.api.ts @@ -25,6 +25,7 @@ import { IMetrics, metricsApi } from "./metrics.api"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { RequireExactlyOne } from "type-fest"; export class IngressApi extends KubeApi { } @@ -58,7 +59,7 @@ export interface ILoadBalancerIngress { // extensions/v1beta1 interface IExtensionsBackend { serviceName: string; - servicePort: number; + servicePort: number | string; } // networking.k8s.io/v1 @@ -101,14 +102,18 @@ export interface Ingress { }[]; // extensions/v1beta1 backend?: IExtensionsBackend; - // networking.k8s.io/v1 - defaultBackend?: INetworkingBackend & { + /** + * The default backend which is exactly on of: + * - service + * - resource + */ + defaultBackend?: RequireExactlyOne }; status: { loadBalancer: { @@ -153,10 +158,11 @@ export class Ingress extends KubeObject { return routes; } - getServiceNamePort() { - const { spec } = this; - const serviceName = spec?.defaultBackend?.service.name ?? spec?.backend?.serviceName; - const servicePort = spec?.defaultBackend?.service.port.number ?? spec?.defaultBackend?.service.port.name ?? spec?.backend?.servicePort; + getServiceNamePort(): IExtensionsBackend { + const { spec: { backend, defaultBackend } = {}} = this; + + const serviceName = defaultBackend?.service?.name ?? backend?.serviceName; + const servicePort = defaultBackend?.service?.port.number ?? defaultBackend?.service?.port.name ?? backend?.servicePort; return { serviceName, From c37a8aee0a8fc18ffa82ca8a35d8904924e12e32 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 9 Nov 2021 22:52:46 -0500 Subject: [PATCH 17/39] Correctly catch and notify user about NodeShell pod failures (#4244) * Correctly catch and notify user about NodeShell pod failures Signed-off-by: Sebastian Malton * Fix error throwing sometimes during Terminal.detach Signed-off-by: Sebastian Malton --- src/main/lens-proxy.ts | 14 ++- src/main/proxy-functions/kube-api-request.ts | 9 +- src/main/proxy-functions/types.ts | 2 + src/main/shell-session/node-shell-session.ts | 43 +++++---- src/renderer/api/websocket-api.ts | 10 +- .../components/dock/terminal-window.tsx | 2 +- src/renderer/components/dock/terminal.ts | 91 +++++++++---------- 7 files changed, 87 insertions(+), 84 deletions(-) diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index d22981ddf4..b1eb4467d3 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -60,12 +60,10 @@ export class LensProxy extends Singleton { public port: number; - constructor(protected router: Router, functions: LensProxyFunctions) { + constructor(protected router: Router, { shellApiRequest, kubeApiRequest, getClusterForRequest }: LensProxyFunctions) { super(); - const { shellApiRequest, kubeApiRequest } = functions; - - this.getClusterForRequest = functions.getClusterForRequest; + this.getClusterForRequest = getClusterForRequest; this.proxyServer = spdy.createServer({ spdy: { @@ -79,9 +77,15 @@ export class LensProxy extends Singleton { this.proxyServer .on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { const isInternal = req.url.startsWith(`${apiPrefix}?`); + const cluster = getClusterForRequest(req); + + if (!cluster) { + return void logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`); + } + const reqHandler = isInternal ? shellApiRequest : kubeApiRequest; - (async () => reqHandler({ req, socket, head }))() + (async () => reqHandler({ req, socket, head, cluster }))() .catch(error => logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); }); } diff --git a/src/main/proxy-functions/kube-api-request.ts b/src/main/proxy-functions/kube-api-request.ts index cbd18f9beb..c5c299e41d 100644 --- a/src/main/proxy-functions/kube-api-request.ts +++ b/src/main/proxy-functions/kube-api-request.ts @@ -23,18 +23,11 @@ import { chunk } from "lodash"; import net from "net"; import url from "url"; import { apiKubePrefix } from "../../common/vars"; -import { ClusterManager } from "../cluster-manager"; import type { ProxyApiRequestArgs } from "./types"; const skipRawHeaders = new Set(["Host", "Authorization"]); -export async function kubeApiRequest({ req, socket, head }: ProxyApiRequestArgs) { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); - - if (!cluster) { - return; - } - +export async function kubeApiRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); const apiUrl = url.parse(cluster.apiUrl); const pUrl = url.parse(proxyUrl); diff --git a/src/main/proxy-functions/types.ts b/src/main/proxy-functions/types.ts index 57d3db1b7c..2a41b9b97f 100644 --- a/src/main/proxy-functions/types.ts +++ b/src/main/proxy-functions/types.ts @@ -21,9 +21,11 @@ import type http from "http"; import type net from "net"; +import type { Cluster } from "../cluster"; export interface ProxyApiRequestArgs { req: http.IncomingMessage, socket: net.Socket, head: Buffer, + cluster: Cluster, } diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts index 2ccf79ae62..4ab1d97745 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session.ts @@ -33,12 +33,10 @@ import logger from "../logger"; export class NodeShellSession extends ShellSession { ShellType = "node-shell"; - protected podId = `node-shell-${uuid()}`; + protected readonly podName = `node-shell-${uuid()}`; protected kc: KubeConfig; - protected get cwd(): string | undefined { - return undefined; - } + protected readonly cwd: string | undefined = undefined; constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string, terminalId: string) { super(socket, cluster, terminalId); @@ -59,7 +57,7 @@ export class NodeShellSession extends ShellSession { } const env = await this.getCachedShellEnv(); - const args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--"]; + const args = ["exec", "-i", "-t", "-n", "kube-system", this.podName, "--"]; const nodeApi = new NodesApi({ objectConstructor: Node, request: KubeJsonApi.forCluster(this.cluster), @@ -93,7 +91,7 @@ export class NodeShellSession extends ShellSession { .makeApiClient(k8s.CoreV1Api) .createNamespacedPod("kube-system", { metadata: { - name: this.podId, + name: this.podName, namespace: "kube-system", }, spec: { @@ -121,33 +119,39 @@ export class NodeShellSession extends ShellSession { } protected waitForRunningPod(): Promise { - return new Promise((resolve, reject) => { - const watch = new k8s.Watch(this.kc); + logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`); - watch + return new Promise((resolve, reject) => { + new k8s.Watch(this.kc) .watch(`/api/v1/namespaces/kube-system/pods`, {}, // callback is called for each received object. - (type, obj) => { - if (obj.metadata.name == this.podId && obj.status.phase === "Running") { - resolve(); + (type, { metadata: { name }, status }) => { + if (name === this.podName) { + switch (status.phase) { + case "Running": + return resolve(); + case "Failed": + return reject(`Failed to be created: ${status.message || "unknown error"}`); + } } }, // done callback is called if the watch terminates normally (err) => { - console.log(err); + logger.error(`[NODE-SHELL]: ${this.podName} was not created in time`); reject(err); }, ) .then(req => { setTimeout(() => { - console.log("aborting"); + logger.error(`[NODE-SHELL]: aborting wait for ${this.podName}, timing out`); req.abort(); - }, 2 * 60 * 1000); + reject("Pod creation timed out"); + }, 2 * 60 * 1000); // 2 * 60 * 1000 }) - .catch(err => { - console.log("watch failed"); - reject(err); + .catch(error => { + logger.error(`[NODE-SHELL]: waiting for ${this.podName} failed: ${error}`); + reject(error); }); }); } @@ -161,6 +165,7 @@ export class NodeShellSession extends ShellSession { this .kc .makeApiClient(k8s.CoreV1Api) - .deleteNamespacedPod(this.podId, "kube-system"); + .deleteNamespacedPod(this.podName, "kube-system") + .catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error)); } } diff --git a/src/renderer/api/websocket-api.ts b/src/renderer/api/websocket-api.ts index 99917f2f30..fbe5142360 100644 --- a/src/renderer/api/websocket-api.ts +++ b/src/renderer/api/websocket-api.ts @@ -37,11 +37,11 @@ interface IMessage { } export enum WebSocketApiState { - PENDING = -1, - OPEN, - CONNECTING, - RECONNECTING, - CLOSED, + PENDING = "pending", + OPEN = "open", + CONNECTING = "connecting", + RECONNECTING = "reconnecting", + CLOSED = "closed", } export class WebSocketApi { diff --git a/src/renderer/components/dock/terminal-window.tsx b/src/renderer/components/dock/terminal-window.tsx index 233f74859a..58508a11a5 100644 --- a/src/renderer/components/dock/terminal-window.tsx +++ b/src/renderer/components/dock/terminal-window.tsx @@ -53,7 +53,7 @@ export class TerminalWindow extends React.Component { } activate(tabId: TabId) { - if (this.terminal) this.terminal.detach(); // detach previous + this.terminal?.detach(); // detach previous this.terminal = TerminalStore.getInstance().getTerminal(tabId); this.terminal.attachTo(this.elem); } diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index da54c5b98a..b9f5855203 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -26,16 +26,15 @@ import { FitAddon } from "xterm-addon-fit"; import { dockStore, TabId } from "./dock.store"; import type { TerminalApi } from "../../api/terminal-api"; import { ThemeStore } from "../../theme.store"; -import { boundMethod } from "../../utils"; +import { boundMethod, disposer } from "../../utils"; import { isMac } from "../../../common/vars"; import { camelCase } from "lodash"; import { UserStore } from "../../../common/user-store"; import { clipboard } from "electron"; +import logger from "../../../common/logger"; export class Terminal { - static spawningPool: HTMLElement; - - static init() { + public static readonly spawningPool = (() => { // terminal element must be in DOM before attaching via xterm.open(elem) // https://xtermjs.org/docs/api/terminal/classes/terminal/#open const pool = document.createElement("div"); @@ -43,8 +42,9 @@ export class Terminal { pool.className = "terminal-init"; pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden"; document.body.appendChild(pool); - Terminal.spawningPool = pool; - } + + return pool; + })(); static async preloadFonts() { const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires @@ -54,13 +54,22 @@ export class Terminal { document.fonts.add(fontFace); } - public xterm: XTerm; - public fitAddon: FitAddon; - public scrollPos = 0; - public disposers: Function[] = []; + private xterm: XTerm | null = new XTerm({ + cursorBlink: true, + cursorStyle: "bar", + fontSize: 13, + fontFamily: "RobotoMono", + }); + private readonly fitAddon = new FitAddon(); + private scrollPos = 0; + private disposer = disposer(); @boundMethod protected setTheme(colors: Record) { + if (!this.xterm) { + return; + } + // Replacing keys stored in styles to format accepted by terminal // E.g. terminalBrightBlack -> brightBlack const colorPrefix = "terminal"; @@ -73,17 +82,13 @@ export class Terminal { } get elem() { - return this.xterm.element; + return this.xterm?.element; } get viewport() { return this.xterm.element.querySelector(".xterm-viewport"); } - constructor(public tabId: TabId, protected api: TerminalApi) { - this.init(); - } - get isActive() { const { isOpen, selectedTabId } = dockStore; @@ -96,22 +101,15 @@ export class Terminal { } detach() { - Terminal.spawningPool.appendChild(this.elem); + const { elem } = this; + + if (elem) { + Terminal.spawningPool.appendChild(elem); + } } - async init() { - if (this.xterm) { - return; - } - this.xterm = new XTerm({ - cursorBlink: true, - cursorStyle: "bar", - fontSize: 13, - fontFamily: "RobotoMono", - }); - + constructor(public tabId: TabId, protected api: TerminalApi) { // enable terminal addons - this.fitAddon = new FitAddon(); this.xterm.loadAddon(this.fitAddon); this.xterm.open(Terminal.spawningPool); @@ -128,7 +126,7 @@ export class Terminal { this.api.onData.addListener(this.onApiData); window.addEventListener("resize", this.onResize); - this.disposers.push( + this.disposer.push( reaction(() => ThemeStore.getInstance().activeTheme.colors, this.setTheme, { fireImmediately: true, }), @@ -142,16 +140,18 @@ export class Terminal { } destroy() { - if (!this.xterm) return; - this.disposers.forEach(dispose => dispose()); - this.disposers = []; - this.xterm.dispose(); - this.xterm = null; + if (this.xterm) { + this.disposer(); + this.xterm.dispose(); + this.xterm = null; + } } fit = () => { // Since this function is debounced we need to read this value as late as possible - if (!this.isActive) return; + if (!this.isActive || !this.xterm) { + return; + } try { this.fitAddon.fit(); @@ -159,9 +159,8 @@ export class Terminal { this.api.sendTerminalSize(cols, rows); } catch (error) { - console.error(error); - - return; // see https://github.com/lensapp/lens/issues/1891 + // see https://github.com/lensapp/lens/issues/1891 + logger.error(`[TERMINAL]: failed to resize terminal to fit`, error); } }; @@ -204,19 +203,21 @@ export class Terminal { }; onContextMenu = () => { - const { terminalCopyOnSelect } = UserStore.getInstance(); - const textFromClipboard = clipboard.readText(); + if ( + // don't paste if user hasn't turned on the feature + UserStore.getInstance().terminalCopyOnSelect - if (terminalCopyOnSelect) { - this.xterm.paste(textFromClipboard); + // don't paste if the clipboard doesn't have text + && clipboard.availableFormats().includes("text/plain") + ) { + this.xterm.paste(clipboard.readText()); } }; onSelectionChange = () => { - const { terminalCopyOnSelect } = UserStore.getInstance(); const selection = this.xterm.getSelection().trim(); - if (terminalCopyOnSelect && selection !== "") { + if (UserStore.getInstance().terminalCopyOnSelect && selection) { clipboard.writeText(selection); } }; @@ -251,5 +252,3 @@ export class Terminal { return true; }; } - -Terminal.init(); From 8846b5c96fc8ee276d06b7d73db1eb7d4aac9d7c Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 10 Nov 2021 13:47:36 +0200 Subject: [PATCH 18/39] handle aborted responses in lens proxy (#4308) Signed-off-by: Jari Kolehmainen --- src/main/lens-proxy.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index b1eb4467d3..bfaaab27ef 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -146,6 +146,10 @@ export class LensProxy extends Singleton { res.flushHeaders(); } } + + proxyRes.on("aborted", () => { // happens when proxy target aborts connection + res.end(); + }); }); proxy.on("error", (error, req, res, target) => { From 38ebbc8b9e4730f6e7eb28a47d8725c74711fa7e Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 10 Nov 2021 14:28:21 +0200 Subject: [PATCH 19/39] Fix: Catalog's visibility columns menu -> Empty title for Icon (#4189) * fix: Catalog -> Columns visibility menu -> Empty title for "Icon" Signed-off-by: Roman * moving entity-icon inside name column / fixes Signed-off-by: Roman * fix lint Signed-off-by: Roman * added vertical alignment for texts in the name column Signed-off-by: Roman --- src/common/utils/makeCss.ts | 30 ------- .../+catalog/catalog-entity-item.tsx | 5 +- .../components/+catalog/catalog.module.css | 90 +++++++++++-------- src/renderer/components/+catalog/catalog.tsx | 57 +++++------- .../icon/{push-pin.svg => push_pin.svg} | 0 5 files changed, 78 insertions(+), 104 deletions(-) delete mode 100644 src/common/utils/makeCss.ts rename src/renderer/components/icon/{push-pin.svg => push_pin.svg} (100%) diff --git a/src/common/utils/makeCss.ts b/src/common/utils/makeCss.ts deleted file mode 100644 index 0187f0196b..0000000000 --- a/src/common/utils/makeCss.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -/** - * Function expands generic CSS Modules literal types and adds dictionary with arbitrary - * indexes. - * @param styles Styles imported from CSS Module having only literal types - * @returns Passed style list with expanded typescript types - */ -export function makeCss(styles: T) { - return styles as typeof styles & { [key: string]: string }; -} diff --git a/src/renderer/components/+catalog/catalog-entity-item.tsx b/src/renderer/components/+catalog/catalog-entity-item.tsx index 1489ef9efc..56b12c9ad1 100644 --- a/src/renderer/components/+catalog/catalog-entity-item.tsx +++ b/src/renderer/components/+catalog/catalog-entity-item.tsx @@ -26,12 +26,9 @@ import type { ItemObject } from "../../../common/item.store"; import { Badge } from "../badge"; import { navigation } from "../../navigation"; import { searchUrlParam } from "../input"; -import { makeCss } from "../../../common/utils/makeCss"; import { KubeObject } from "../../../common/k8s-api/kube-object"; import type { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; -const css = makeCss(styles); - export class CatalogEntityItem implements ItemObject { constructor(public entity: T, private registry: CatalogEntityRegistry) { if (!(entity instanceof CatalogEntity)) { @@ -79,7 +76,7 @@ export class CatalogEntityItem implements ItemObject { return this.labels .map(label => ( span { overflow: hidden; - text-overflow: ellipsis; - padding-right: 24px; + text-overflow: ellipsis; + padding-left: var(--padding); + } - .pinIcon { - position: absolute; - right: 0; - opacity: 0; - transition: none; + :global(.HotbarIcon){ + align-self: center; - &:hover { - /* Drop styles defined for */ - background-color: transparent; - box-shadow: none; - } + div { + /* icons with plain text */ + font-size: var(--unit); + } + + .Icon { + /* icons with font-icon */ + font-size: var(--small-size); } } - &:hover .pinIcon { - opacity: 1; + .pinIcon { + position: absolute; + right: 0; + transition: none; + display: none; + + &:hover { + /* Drop styles defined for */ + background-color: transparent; + box-shadow: none; + } } } -.iconCell { - @apply flex items-center max-w-[40px]; -} - -.iconCell > div * { - font-size: var(--unit); -} - -.nameCell { -} - .sourceCell { max-width: 100px; } .statusCell { max-width: 100px; -} -.connected, .available { - color: var(--colorSuccess); -} + :global { + .connected, .available { + color: var(--colorSuccess); + } -.disconnected, .deleting, .unavailable { - color: var(--halfGray); + .disconnected, .deleting, .unavailable { + color: var(--halfGray); + } + } } .labelsCell { diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 1c1da5e32c..62b3258f6d 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -37,8 +37,7 @@ import { CatalogAddButton } from "./catalog-add-button"; import type { RouteComponentProps } from "react-router"; import { Notifications } from "../notifications"; import { MainLayout } from "../layout/main-layout"; -import { createStorage, cssNames, prevDefault } from "../../utils"; -import { makeCss } from "../../../common/utils/makeCss"; +import { createStorage, prevDefault } from "../../utils"; import { CatalogEntityDetails } from "./catalog-entity-details"; import { browseCatalogTab, catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { CatalogMenu } from "./catalog-menu"; @@ -56,8 +55,6 @@ enum sortBy { status = "status", } -const css = makeCss(styles); - interface Props extends RouteComponentProps { catalogEntityStore?: CatalogEntityStore; } @@ -73,6 +70,7 @@ export class Catalog extends React.Component { makeObservable(this); this.catalogEntityStore = props.catalogEntityStore; } + static defaultProps = { catalogEntityStore: new CatalogEntityStore(), }; @@ -105,7 +103,7 @@ export class Catalog extends React.Component { this.activeTab = routeTab; this.catalogEntityStore.activeCategory = item; }); - } catch(error) { + } catch (error) { console.error(error); Notifications.error(

Unknown category: {routeTab}

); } @@ -177,7 +175,7 @@ export class Catalog extends React.Component { renderNavigation() { return ( - + ); } @@ -214,23 +212,7 @@ export class Catalog extends React.Component { const isItemInHotbar = HotbarStore.getInstance().isAddedToActive(item.entity); return ( -
- {item.name} - isItemInHotbar ? this.removeFromHotbar(item) : this.addToHotbar(item))} - /> -
- ); - } - - renderIcon(item: CatalogEntityItem) { - return ( - + <> { background={item.entity.spec.icon?.background} size={24} /> - + {item.name} + isItemInHotbar ? this.removeFromHotbar(item) : this.addToHotbar(item))} + /> + ); } @@ -254,11 +245,11 @@ export class Catalog extends React.Component { return ( item.name, @@ -270,23 +261,21 @@ export class Catalog extends React.Component { entity => entity.searchFields, ]} renderTableHeader={[ - { title: "", className: css.iconCell, id: "icon" }, - { title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" }, - !activeCategory && { title: "Kind", className: css.kindCell, sortBy: sortBy.kind, id: "kind" }, - { title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" }, - { title: "Labels", className: css.labelsCell, id: "labels" }, - { title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" }, + { title: "Name", className: styles.entityName, sortBy: sortBy.name, id: "name" }, + !activeCategory && { title: "Kind", sortBy: sortBy.kind, id: "kind" }, + { title: "Source", className: styles.sourceCell, sortBy: sortBy.source, id: "source" }, + { title: "Labels", className: styles.labelsCell, id: "labels" }, + { title: "Status", className: styles.statusCell, sortBy: sortBy.status, id: "status" }, ].filter(Boolean)} customizeTableRowProps={item => ({ disabled: !item.enabled, })} renderTableContents={item => [ - this.renderIcon(item), this.renderName(item), !activeCategory && item.kind, item.source, item.getLabelBadges(), - { title: item.phase, className: cssNames(css[item.phase]) }, + {item.phase}, ].filter(Boolean)} onDetails={this.onDetails} renderItemMenu={this.renderItemMenu} @@ -302,7 +291,7 @@ export class Catalog extends React.Component { return (
- { this.renderList() } + {this.renderList()}
{ this.catalogEntityStore.selectedItem diff --git a/src/renderer/components/icon/push-pin.svg b/src/renderer/components/icon/push_pin.svg similarity index 100% rename from src/renderer/components/icon/push-pin.svg rename to src/renderer/components/icon/push_pin.svg From c8fbb359671c75c8edbc399a9966684158ad0512 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 10 Nov 2021 15:41:51 +0200 Subject: [PATCH 20/39] Use forked node-fetch (#4301) --- package.json | 2 +- yarn.lock | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index c201fb7a98..8ec42160d4 100644 --- a/package.json +++ b/package.json @@ -220,7 +220,7 @@ "moment-timezone": "^0.5.33", "monaco-editor": "^0.29.1", "monaco-editor-webpack-plugin": "^5.0.0", - "node-fetch": "^2.6.6", + "node-fetch": "lensapp/node-fetch#2.x", "node-pty": "^0.10.1", "npm": "^6.14.15", "p-limit": "^3.1.0", diff --git a/yarn.lock b/yarn.lock index 8119a81fed..adb7d8ad93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9761,10 +9761,9 @@ node-fetch@2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@^2.6.6: +node-fetch@lensapp/node-fetch#2.x: version "2.6.6" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89" - integrity sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA== + resolved "https://codeload.github.com/lensapp/node-fetch/tar.gz/c869d40ba7dd3bce392c34e36118c225b6ada8fb" dependencies: whatwg-url "^5.0.0" From de4c7e4cffa7ad5656e0c259aff4637cdcd2b91e Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 10 Nov 2021 12:07:55 -0500 Subject: [PATCH 21/39] Fix race condition in refreshViews (#4094) --- src/main/catalog/catalog-entity-registry.ts | 4 +- src/main/cluster-manager.ts | 20 ++- src/main/initializers/ipc.ts | 15 +- .../cluster-manager/cluster-view.tsx | 18 +- .../components/cluster-manager/lens-views.ts | 159 +++++++++++------- src/renderer/lens-app.tsx | 7 + 6 files changed, 135 insertions(+), 88 deletions(-) diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index 063f6f2356..e09af5501c 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -20,7 +20,7 @@ */ import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; -import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityKindData } from "../../common/catalog"; +import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor, CatalogEntityKindData } from "../../common/catalog"; import { iter } from "../../common/utils"; export class CatalogEntityRegistry { @@ -59,7 +59,7 @@ export class CatalogEntityRegistry { return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; } - getItemsByEntityClass({ apiVersion, kind }: CatalogEntityKindData): T[] { + getItemsByEntityClass({ apiVersion, kind }: CatalogEntityKindData & CatalogEntityConstructor): T[] { return this.getItemsForApiKind(apiVersion, kind); } } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index b72084751d..ee1707b726 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -41,6 +41,8 @@ export class ClusterManager extends Singleton { private store = ClusterStore.getInstance(); deleting = observable.set(); + @observable visibleCluster: ClusterId | undefined = undefined; + constructor() { super(); makeObservable(this); @@ -61,8 +63,22 @@ export class ClusterManager extends Singleton { { fireImmediately: false }, ); - reaction(() => catalogEntityRegistry.getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { - this.syncClustersFromCatalog(entities); + reaction( + () => catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster), + entities => this.syncClustersFromCatalog(entities), + ); + + reaction(() => [ + catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster), + this.visibleCluster, + ] as const, ([entities, visibleCluster]) => { + for (const entity of entities) { + if (entity.getId() === visibleCluster) { + entity.status.active = true; + } else { + entity.status.active = false; + } + } }); observe(this.deleting, change => { diff --git a/src/main/initializers/ipc.ts b/src/main/initializers/ipc.ts index ff79bd091b..206a1673db 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -20,13 +20,12 @@ */ import { BrowserWindow, dialog, IpcMainInvokeEvent } from "electron"; -import { KubernetesCluster } from "../../common/catalog-entities"; import { clusterFrameMap } from "../../common/cluster-frames"; import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc"; import type { ClusterId } from "../../common/cluster-types"; import { ClusterStore } from "../../common/cluster-store"; import { appEventBus } from "../../common/event-bus"; -import { dialogShowOpenDialogHandler, ipcMainHandle } from "../../common/ipc"; +import { dialogShowOpenDialogHandler, ipcMainHandle, ipcMainOn } from "../../common/ipc"; import { catalogEntityRegistry } from "../catalog"; import { pushCatalogToRenderer } from "../catalog-pusher"; import { ClusterManager } from "../cluster-manager"; @@ -54,16 +53,8 @@ export function initIpcMainHandlers() { } }); - ipcMainHandle(clusterVisibilityHandler, (event: IpcMainInvokeEvent, clusterId: ClusterId, visible: boolean) => { - const entity = catalogEntityRegistry.getById(clusterId); - - for (const kubeEntity of catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster)) { - kubeEntity.status.active = false; - } - - if (entity) { - entity.status.active = visible; - } + ipcMainOn(clusterVisibilityHandler, (event, clusterId?: ClusterId) => { + ClusterManager.getInstance().visibleCluster = clusterId; }); ipcMainHandle(clusterRefreshHandler, (event, clusterId: ClusterId) => { diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index 78626cccb1..73cc479455 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -25,7 +25,7 @@ import { computed, makeObservable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; import { ClusterStatus } from "./cluster-status"; -import { hasLoadedView, initView, refreshViews } from "./lens-views"; +import { ClusterFrameHandler } from "./lens-views"; import type { Cluster } from "../../../main/cluster"; import { ClusterStore } from "../../../common/cluster-store"; import { requestMain } from "../../../common/ipc"; @@ -57,7 +57,7 @@ export class ClusterView extends React.Component { @computed get isReady(): boolean { const { cluster, clusterId } = this; - return cluster?.ready && cluster?.available && hasLoadedView(clusterId); + return cluster?.ready && cluster?.available && ClusterFrameHandler.getInstance().hasLoadedView(clusterId); } componentDidMount() { @@ -65,25 +65,23 @@ export class ClusterView extends React.Component { } componentWillUnmount() { - refreshViews(); + ClusterFrameHandler.getInstance().clearVisibleCluster(); catalogEntityRegistry.activeEntity = null; } bindEvents() { disposeOnUnmount(this, [ reaction(() => this.clusterId, async (clusterId) => { - refreshViews(clusterId); // refresh visibility of active cluster - initView(clusterId); // init cluster-view (iframe), requires parent container #lens-views to be in DOM + ClusterFrameHandler.getInstance().setVisibleCluster(clusterId); + ClusterFrameHandler.getInstance().initView(clusterId); requestMain(clusterActivateHandler, clusterId, false); // activate and fetch cluster's state from main - catalogEntityRegistry.activeEntity = catalogEntityRegistry.getById(clusterId); + catalogEntityRegistry.activeEntity = clusterId; }, { fireImmediately: true, }), - reaction(() => [this.cluster?.ready, this.cluster?.disconnected], (values) => { - const disconnected = values[1]; - - if (hasLoadedView(this.clusterId) && disconnected) { + reaction(() => [this.cluster?.ready, this.cluster?.disconnected], ([, disconnected]) => { + if (ClusterFrameHandler.getInstance().hasLoadedView(this.clusterId) && disconnected) { navigate(catalogURL()); // redirect to catalog when active cluster get disconnected/not available } }), diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index b4f8415e71..947fa387f1 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/lens-views.ts @@ -19,82 +19,117 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { observable, when } from "mobx"; +import { action, IReactionDisposer, makeObservable, observable, reaction, when } from "mobx"; import logger from "../../../main/logger"; -import { requestMain } from "../../../common/ipc"; import { clusterVisibilityHandler } from "../../../common/cluster-ipc"; import { ClusterStore } from "../../../common/cluster-store"; import type { ClusterId } from "../../../common/cluster-types"; -import { getClusterFrameUrl } from "../../utils"; +import { getClusterFrameUrl, Singleton } from "../../utils"; +import { ipcRenderer } from "electron"; export interface LensView { - isLoaded?: boolean - clusterId: ClusterId; - view: HTMLIFrameElement + isLoaded: boolean; + frame: HTMLIFrameElement; } -export const lensViews = observable.map(); +export class ClusterFrameHandler extends Singleton { + private views = observable.map(); + @observable private visibleCluster: string | null = null; -export function hasLoadedView(clusterId: ClusterId): boolean { - return !!lensViews.get(clusterId)?.isLoaded; -} - -export async function initView(clusterId: ClusterId) { - const cluster = ClusterStore.getInstance().getById(clusterId); - - if (!cluster || lensViews.has(clusterId)) { - return; + constructor() { + super(); + makeObservable(this); + reaction(() => this.visibleCluster, this.handleVisibleClusterChange); } - logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`); - const parentElem = document.getElementById("lens-views"); - const iframe = document.createElement("iframe"); - - iframe.id = `cluster-frame-${cluster.id}`; - iframe.name = cluster.contextName; - iframe.setAttribute("src", getClusterFrameUrl(clusterId)); - iframe.addEventListener("load", () => { - logger.info(`[LENS-VIEW]: loaded from ${iframe.src}`); - lensViews.get(clusterId).isLoaded = true; - }, { once: true }); - lensViews.set(clusterId, { clusterId, view: iframe }); - parentElem.appendChild(iframe); - - logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`); - - try { - await when(() => cluster.ready, { timeout: 5_000 }); // we cannot wait forever because cleanup would be blocked for broken cluster connections - logger.info(`[LENS-VIEW]: cluster is ready, clusterId=${clusterId}`); - } finally { - await autoCleanOnRemove(clusterId, iframe); + public hasLoadedView(clusterId: string): boolean { + return Boolean(this.views.get(clusterId)?.isLoaded); } -} -export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrameElement) { - await when(() => { + @action + public initView(clusterId: ClusterId) { const cluster = ClusterStore.getInstance().getById(clusterId); - return !cluster || (cluster.disconnected && lensViews.get(clusterId)?.isLoaded); - }); - logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`); - lensViews.delete(clusterId); + if (!cluster || this.views.has(clusterId)) { + return; + } - iframe.parentNode.removeChild(iframe); -} - -export function refreshViews(visibleClusterId?: string) { - logger.info(`[LENS-VIEW]: refreshing iframe views, visible cluster id=${visibleClusterId}`); - const cluster = ClusterStore.getInstance().getById(visibleClusterId); - - lensViews.forEach(({ clusterId, view, isLoaded }) => { - const isCurrent = clusterId === cluster?.id; - const isReady = cluster?.available && cluster?.ready; - const isVisible = isCurrent && isLoaded && isReady; - - view.style.display = isVisible ? "flex" : "none"; - - requestMain(clusterVisibilityHandler, clusterId, isVisible).catch(() => { - logger.error(`[LENS-VIEW]: failed to set cluster visibility, clusterId=${clusterId}`); - }); - }); + logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`); + const parentElem = document.getElementById("lens-views"); + const iframe = document.createElement("iframe"); + + iframe.id = `cluster-frame-${cluster.id}`; + iframe.name = cluster.contextName; + iframe.style.display = "none"; + iframe.setAttribute("src", getClusterFrameUrl(clusterId)); + iframe.addEventListener("load", () => { + logger.info(`[LENS-VIEW]: loaded from ${iframe.src}`); + this.views.get(clusterId).isLoaded = true; + }, { once: true }); + this.views.set(clusterId, { frame: iframe, isLoaded: false }); + parentElem.appendChild(iframe); + + logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`); + + const dispose = when( + () => cluster.ready, + () => logger.info(`[LENS-VIEW]: cluster is ready, clusterId=${clusterId}`), + ); + + when( + // cluster.disconnect is set to `false` when the cluster starts to connect + () => !cluster.disconnected, + () => { + when( + () => { + const cluster = ClusterStore.getInstance().getById(clusterId); + + return !cluster || (cluster.disconnected && this.views.get(clusterId)?.isLoaded); + }, + () => { + logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`); + this.views.delete(clusterId); + iframe.parentNode.removeChild(iframe); + dispose(); + }, + ); + }, + ); + } + + public setVisibleCluster(clusterId: ClusterId) { + this.visibleCluster = clusterId; + } + + public clearVisibleCluster() { + this.visibleCluster = null; + } + + private prevVisibleClusterChange?: IReactionDisposer; + + private handleVisibleClusterChange = (clusterId: ClusterId | undefined) => { + logger.info(`[LENS-VIEW]: refreshing iframe views, visible cluster id=${clusterId}`); + + ipcRenderer.send(clusterVisibilityHandler); + + const cluster = ClusterStore.getInstance().getById(clusterId); + + for (const { frame: view } of this.views.values()) { + view.style.display = "none"; + } + + if (cluster) { + const lensView = this.views.get(clusterId); + + this.prevVisibleClusterChange?.(); + this.prevVisibleClusterChange = when( + () => cluster.available && cluster.ready && lensView.isLoaded, + () => { + logger.info(`[LENS-VIEW]: cluster id=${clusterId} should now be visible`); + lensView.frame.style.display = "flex"; + ipcRenderer.send(clusterVisibilityHandler, clusterId); + }, + ); + } + }; } diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index 7d9e41a8b6..17322e9a01 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -38,6 +38,7 @@ import { IpcRendererNavigationEvents } from "./navigation/events"; import { catalogEntityRegistry } from "./api/catalog-entity-registry"; import logger from "../common/logger"; import { unmountComponentAtNode } from "react-dom"; +import { ClusterFrameHandler } from "./components/cluster-manager/lens-views"; injectSystemCAs(); @@ -61,6 +62,12 @@ export class LensApp extends React.Component { }; } + constructor(props: {}) { + super(props); + + ClusterFrameHandler.createInstance(); + } + componentDidMount() { ipcRenderer.send(IpcRendererNavigationEvents.LOADED); } From d060459ccdb37e1906c27fd3cbcf008dc6d46efd Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 10 Nov 2021 16:39:57 -0500 Subject: [PATCH 22/39] Close issues with 'needs-information' after 60 days (#4305) --- .github/workflows/stale.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..4055a26a99 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,14 @@ +name: Cull Stale Issues +on: + schedule: + - cron: "30 1 * * *" +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@4 + with: + only-issue-labels: "needs-information" + repo-token: "${{ secrets.GITHUB_TOKEN }}" + # -1 here means that PRs will never be marked as stale + days-before-pr-stale: -1 From a86b306a48c0a02ddc5e28faa487865847872fac Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 12 Nov 2021 09:07:57 +0200 Subject: [PATCH 23/39] add possibility to pass non-generic KubeApi to forRemoteCluster (#4321) Signed-off-by: Jari Kolehmainen --- src/common/k8s-api/__tests__/kube-api.test.ts | 36 ++++++++++++++++--- src/common/k8s-api/kube-api.ts | 19 ++++++---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 3cd0768689..510cfe280c 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -19,13 +19,39 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { Pod } from "../endpoints/pods.api"; +import { Pod, PodsApi } from "../endpoints/pods.api"; import { forRemoteCluster, KubeApi } from "../kube-api"; import { KubeJsonApi } from "../kube-json-api"; import { KubeObject } from "../kube-object"; describe("forRemoteCluster", () => { - it("builds api client", async (done) => { + it("builds api client for KubeObject", async () => { + const api = forRemoteCluster({ + cluster: { + server: "https://127.0.0.1:6443", + }, + user: { + token: "daa", + }, + }, Pod); + + expect(api).toBeInstanceOf(KubeApi); + }); + + it("builds api client for given KubeApi", async () => { + const api = forRemoteCluster({ + cluster: { + server: "https://127.0.0.1:6443", + }, + user: { + token: "daa", + }, + }, Pod, PodsApi); + + expect(api).toBeInstanceOf(PodsApi); + }); + + it("calls right api endpoint", async () => { const api = forRemoteCluster({ cluster: { server: "https://127.0.0.1:6443", @@ -38,13 +64,13 @@ describe("forRemoteCluster", () => { (fetch as any).mockResponse(async (request: any) => { expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); - done(); - return { - body: "", + body: "hello", }; }); + expect.hasAssertions(); + await api.list(); }); }); diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 79c432c6f9..779656855d 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -115,7 +115,7 @@ export interface IRemoteKubeApiConfig { } } -export function forCluster(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor): KubeApi { +export function forCluster = KubeApi>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): KubeApi { const url = new URL(apiBase.config.serverAddress); const request = new KubeJsonApi({ serverAddress: apiBase.config.serverAddress, @@ -127,15 +127,18 @@ export function forCluster(cluster: ILocalKubeApiConfig, k }, }); - return new KubeApi({ + if (!apiClass) { + apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; + } + + return new apiClass({ objectConstructor: kubeClass, request, }); } -export function forRemoteCluster(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor): KubeApi { +export function forRemoteCluster = KubeApi>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): Y { const reqInit: RequestInit = {}; - const agentOptions: AgentOptions = {}; if (config.cluster.skipTLSVerify === true) { @@ -172,8 +175,12 @@ export function forRemoteCluster(config: IRemoteKubeApiCon } : {}), }, reqInit); - return new KubeApi({ - objectConstructor: kubeClass, + if (!apiClass) { + apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; + } + + return new apiClass({ + objectConstructor: kubeClass as KubeObjectConstructor, request, }); } From 23d5d40d624ebebe9963503c2fd67bef058aec88 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 12 Nov 2021 11:15:33 +0200 Subject: [PATCH 24/39] Implement KubeApi.patch (#4325) * implement KubeApi.patch Signed-off-by: Jari Kolehmainen * cleanup tests Signed-off-by: Jari Kolehmainen * keep it backward compatible Signed-off-by: Jari Kolehmainen --- src/common/k8s-api/__tests__/kube-api.test.ts | 77 +++++++++++++++++-- src/common/k8s-api/kube-api.ts | 27 +++++++ src/common/k8s-api/kube-object.store.ts | 22 +++++- src/common/k8s-api/kube-object.ts | 8 ++ 4 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 510cfe280c..ffc03a4fe7 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -19,11 +19,19 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { Pod, PodsApi } from "../endpoints/pods.api"; +import type { Request } from "node-fetch"; import { forRemoteCluster, KubeApi } from "../kube-api"; import { KubeJsonApi } from "../kube-json-api"; import { KubeObject } from "../kube-object"; +class TestKubeObject extends KubeObject { + static kind = "Pod"; + static namespaced = true; + static apiBase = "/api/v1/pods"; +} + +class TestKubeApi extends KubeApi {} + describe("forRemoteCluster", () => { it("builds api client for KubeObject", async () => { const api = forRemoteCluster({ @@ -33,7 +41,7 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, Pod); + }, TestKubeObject); expect(api).toBeInstanceOf(KubeApi); }); @@ -46,9 +54,9 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, Pod, PodsApi); + }, TestKubeObject, TestKubeApi); - expect(api).toBeInstanceOf(PodsApi); + expect(api).toBeInstanceOf(TestKubeApi); }); it("calls right api endpoint", async () => { @@ -59,7 +67,7 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, Pod); + }, TestKubeObject); (fetch as any).mockResponse(async (request: any) => { expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); @@ -167,4 +175,63 @@ describe("KubeApi", () => { expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiGroup).toEqual("extensions"); }); + + describe("patch", () => { + let api: TestKubeApi; + + beforeEach(() => { + api = new TestKubeApi({ + request, + objectConstructor: TestKubeObject, + }); + }); + + it("sends strategic patch by default", async () => { + expect.hasAssertions(); + + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("PATCH"); + expect(request.headers.get("content-type")).toMatch("strategic-merge-patch"); + expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + + return {}; + }); + + await api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }); + }); + + it("allows to use merge patch", async () => { + expect.hasAssertions(); + + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("PATCH"); + expect(request.headers.get("content-type")).toMatch("merge-patch"); + expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + + return {}; + }); + + await api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }, "merge"); + }); + + it("allows to use json patch", async () => { + expect.hasAssertions(); + + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("PATCH"); + expect(request.headers.get("content-type")).toMatch("json-patch"); + expect(request.body.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }])); + + return {}; + }); + + await api.patch({ name: "test", namespace: "default" }, [ + { op: "replace", path: "/spec/replicas", value: 2 }, + ], "json"); + }); + }); }); diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 779656855d..c9930a148c 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -37,6 +37,7 @@ import { noop } from "../utils"; import type { RequestInit } from "node-fetch"; import AbortController from "abort-controller"; import { Agent, AgentOptions } from "https"; +import type { Patch } from "rfc6902"; export interface IKubeApiOptions { /** @@ -207,6 +208,14 @@ export type KubeApiWatchOptions = { retry?: boolean; }; +export type KubeApiPatchType = "merge" | "json" | "strategic"; + +const patchTypeHeaders: Record = { + "merge": "application/merge-patch+json", + "json": "application/json-patch+json", + "strategic": "application/strategic-merge-patch+json", +}; + export class KubeApi { readonly kind: string; readonly apiBase: string; @@ -475,6 +484,24 @@ export class KubeApi { return parsed; } + async patch({ name = "", namespace = "default" } = {}, data?: Partial | Patch, strategy: KubeApiPatchType = "strategic"): Promise { + await this.checkPreferredVersion(); + const apiUrl = this.getUrl({ namespace, name }); + + const res = await this.request.patch(apiUrl, { data }, { + headers: { + "content-type": patchTypeHeaders[strategy], + }, + }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`PATCH request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; + } + async delete({ name = "", namespace = "default" }) { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index f4e0542f66..885d4b9347 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -295,16 +295,30 @@ export abstract class KubeObjectStore extends ItemStore } async patch(item: T, patch: Patch): Promise { - return this.postUpdate(await item.patch(patch)); + return this.postUpdate( + await this.api.patch( + { + name: item.getName(), namespace: item.getNs(), + }, + patch, + "json", + ), + ); } async update(item: T, data: Partial): Promise { - return this.postUpdate(await item.update(data)); + return this.postUpdate( + await this.api.update( + { + name: item.getName(), namespace: item.getNs(), + }, + data, + ), + ); } async remove(item: T) { - await item.delete(); - this.items.remove(item); + await this.api.delete({ name: item.getName(), namespace: item.getNs() }); this.selectedItemsIds.delete(item.getId()); } diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index 0cb4ac15f6..ffed59b3df 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -306,6 +306,9 @@ export class KubeObject { for (const op of patch) { if (KubeObject.nonEditablePaths.has(op.path)) { @@ -328,6 +331,8 @@ export class KubeObject): Promise { // use unified resource-applier api for updating all k8s objects @@ -337,6 +342,9 @@ export class KubeObject Date: Fri, 12 Nov 2021 08:49:21 -0500 Subject: [PATCH 25/39] Improve cluster connection visibility (#4266) --- src/common/cluster-store.ts | 20 +-- src/common/cluster-types.ts | 9 +- src/main/__test__/kube-auth-proxy.test.ts | 75 +++++++--- src/main/cluster.ts | 95 ++++++------ src/main/context-handler.ts | 6 +- src/main/kube-auth-proxy.ts | 82 +++++------ src/main/kubeconfig-manager.ts | 11 +- src/main/window-manager.ts | 2 +- .../cluster-manager/cluster-status.tsx | 136 +++++++++--------- 9 files changed, 229 insertions(+), 207 deletions(-) diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 26806ae239..d5dfdadfd1 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -26,7 +26,7 @@ import { Cluster } from "../main/cluster"; import migrations from "../migrations/cluster-store"; import logger from "../main/logger"; import { appEventBus } from "./event-bus"; -import { ipcMainHandle, ipcMainOn, ipcRendererOn, requestMain } from "./ipc"; +import { ipcMainHandle, requestMain } from "./ipc"; import { disposer, toJS } from "./utils"; import type { ClusterModel, ClusterId, ClusterState } from "./cluster-types"; @@ -37,8 +37,6 @@ export interface ClusterStoreModel { const initialStates = "cluster:states"; export class ClusterStore extends BaseStore { - private static StateChannel = "cluster:state"; - clusters = observable.map(); protected disposer = disposer(); @@ -83,21 +81,13 @@ export class ClusterStore extends BaseStore { } } - handleStateChange = (event: any, clusterId: string, state: ClusterState) => { - logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host} (${webFrame.routingId})`, clusterId, state); - this.getById(clusterId)?.setState(state); - }; - registerIpcListener() { logger.info(`[CLUSTER-STORE] start to listen (${webFrame.routingId})`); + const ipc = ipcMain ?? ipcRenderer; - if (ipcMain) { - this.disposer.push(ipcMainOn(ClusterStore.StateChannel, this.handleStateChange)); - } - - if (ipcRenderer) { - this.disposer.push(ipcRendererOn(ClusterStore.StateChannel, this.handleStateChange)); - } + ipc?.on("cluster:state", (event, clusterId: ClusterId, state: ClusterState) => { + this.getById(clusterId)?.setState(state); + }); } unregisterIpcListener() { diff --git a/src/common/cluster-types.ts b/src/common/cluster-types.ts index 0c40f9ebed..cb958953f6 100644 --- a/src/common/cluster-types.ts +++ b/src/common/cluster-types.ts @@ -122,6 +122,14 @@ export enum ClusterStatus { Offline = 0, } +/** + * The message format for the "cluster::connection-update" channels + */ +export interface KubeAuthUpdate { + message: string; + isError: boolean; +} + /** * The OpenLens known static metadata keys */ @@ -173,7 +181,6 @@ export interface ClusterState { disconnected: boolean; accessible: boolean; ready: boolean; - failureReason: string; isAdmin: boolean; allowedNamespaces: string[] allowedResources: string[] diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index f04b948861..a2ba4cb453 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -55,7 +55,7 @@ import { ChildProcess, spawn } from "child_process"; import { bundledKubectlPath, Kubectl } from "../kubectl"; import { mock, MockProxy } from "jest-mock-extended"; import { waitUntilUsed } from "tcp-port-used"; -import type { Readable } from "stream"; +import { EventEmitter, Readable } from "stream"; import { UserStore } from "../../common/user-store"; import { Console } from "console"; import { stdout, stderr } from "process"; @@ -134,30 +134,73 @@ describe("kube auth proxy tests", () => { describe("spawn tests", () => { let mockedCP: MockProxy; - let listeners: Record void>; + let listeners: EventEmitter; let proxy: KubeAuthProxy; beforeEach(async () => { mockedCP = mock(); - listeners = {}; + listeners = new EventEmitter(); jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true)); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)); mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { - listeners[event] = listener; + listeners.on(event, listener); return mockedCP; }); mockedCP.stderr = mock(); mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners[`stderr/${event}`] = listener; + listeners.on(`stderr/${event}`, listener); + + return mockedCP.stderr; + }); + mockedCP.stderr.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stderr/${event}`, listener); + + return mockedCP.stderr; + }); + mockedCP.stderr.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stderr/${event}`, listener); + + return mockedCP.stderr; + }); + mockedCP.stderr.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.once(`stderr/${event}`, listener); + + return mockedCP.stderr; + }); + mockedCP.stderr.removeAllListeners.mockImplementation((event?: string): Readable => { + listeners.removeAllListeners(event ?? `stderr/${event}`); return mockedCP.stderr; }); mockedCP.stdout = mock(); mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners[`stdout/${event}`] = listener; - listeners[`stdout/${event}`]("Starting to serve on 127.0.0.1:9191"); + listeners.on(`stdout/${event}`, listener); + + if (event === "data") { + listeners.emit("stdout/data", "Starting to serve on 127.0.0.1:9191"); + } + + return mockedCP.stdout; + }); + mockedCP.stdout.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.once(`stdout/${event}`, listener); + + return mockedCP.stdout; + }); + mockedCP.stdout.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stdout/${event}`, listener); + + return mockedCP.stdout; + }); + mockedCP.stdout.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stdout/${event}`, listener); + + return mockedCP.stdout; + }); + mockedCP.stdout.removeAllListeners.mockImplementation((event?: string): Readable => { + listeners.removeAllListeners(event ?? `stdout/${event}`); return mockedCP.stdout; }); @@ -175,36 +218,36 @@ describe("kube auth proxy tests", () => { it("should call spawn and broadcast errors", async () => { await proxy.run(); - listeners["error"]({ message: "foobarbat" }); + listeners.emit("error", { message: "foobarbat" }); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "foobarbat", error: true }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "foobarbat", isError: true }); }); it("should call spawn and broadcast exit", async () => { await proxy.run(); - listeners["exit"](0); + listeners.emit("exit", 0); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "proxy exited with code: 0", error: false }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "proxy exited with code: 0", isError: false }); }); it("should call spawn and broadcast errors from stderr", async () => { await proxy.run(); - listeners["stderr/data"]("an error"); + listeners.emit("stderr/data", "an error"); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "an error", error: true }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "an error", isError: true }); }); it("should call spawn and broadcast stdout serving info", async () => { await proxy.run(); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "Authentication proxy started\n" }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "Authentication proxy started", isError: false }); }); it("should call spawn and broadcast stdout other info", async () => { await proxy.run(); - listeners["stdout/data"]("some info"); + listeners.emit("stdout/data", "some info"); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "some info" }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "some info", isError: false }); }); }); }); diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 6f465c909b..1cbf4f980c 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -32,7 +32,7 @@ import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; import { DetectorRegistry } from "./cluster-detectors/detector-registry"; import plimit from "p-limit"; -import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-types"; +import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../common/cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types"; import { storedKubeConfigFolder, toJS } from "../common/utils"; import type { Response } from "request"; @@ -117,12 +117,6 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable disconnected = true; - /** - * Connection failure reason - * - * @observable - */ - @observable failureReason: string; /** * Does user have admin like access * @@ -358,14 +352,20 @@ export class Cluster implements ClusterModel, ClusterState { if (this.disconnected || !this.accessible) { await this.reconnect(); } + + this.broadcastConnectUpdate("Refreshing connection status ..."); await this.refreshConnectionStatus(); if (this.accessible) { + this.broadcastConnectUpdate("Refreshing cluster accessibility ..."); await this.refreshAccessibility(); - this.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard + // download kubectl in background, so it's not blocking dashboard + this.ensureKubectl() + .catch(error => logger.warn(`[CLUSTER]: failed to download kubectl for clusterId=${this.id}`, error)); + this.broadcastConnectUpdate("Connected, waiting for view to load ..."); } - this.activated = true; + this.activated = true; this.pushState(); } @@ -445,9 +445,8 @@ export class Cluster implements ClusterModel, ClusterState { private async refreshAccessibility(): Promise { this.isAdmin = await this.isClusterAdmin(); this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" }); - - await this.refreshAllowedResources(); - + this.allowedNamespaces = await this.getAllowedNamespaces(); + this.allowedResources = await this.getAllowedResources(); this.ready = true; } @@ -462,15 +461,6 @@ export class Cluster implements ClusterModel, ClusterState { this.accessible = connectionStatus == ClusterStatus.AccessGranted; } - /** - * @internal - */ - @action - async refreshAllowedResources() { - this.allowedNamespaces = await this.getAllowedNamespaces(); - this.allowedResources = await this.getAllowedResources(); - } - async getKubeconfig(): Promise { const { config } = await loadConfigFromFile(this.kubeConfigPath); @@ -501,34 +491,35 @@ export class Cluster implements ClusterModel, ClusterState { this.metadata.version = versionData.value; - this.failureReason = null; - return ClusterStatus.AccessGranted; } catch (error) { - logger.error(`Failed to connect cluster "${this.contextName}": ${error}`); + logger.error(`[CLUSTER]: Failed to connect to "${this.contextName}": ${error}`); if (error.statusCode) { if (error.statusCode >= 400 && error.statusCode < 500) { - this.failureReason = "Invalid credentials"; - - return ClusterStatus.AccessDenied; - } else { - this.failureReason = error.error || error.message; - - return ClusterStatus.Offline; - } - } else if (error.failed === true) { - if (error.timedOut === true) { - this.failureReason = "Connection timed out"; - - return ClusterStatus.Offline; - } else { - this.failureReason = "Failed to fetch credentials"; + this.broadcastConnectUpdate("Invalid credentials", true); return ClusterStatus.AccessDenied; } + + this.broadcastConnectUpdate(error.error || error.message, true); + + return ClusterStatus.Offline; } - this.failureReason = error.message; + + if (error.failed === true) { + if (error.timedOut === true) { + this.broadcastConnectUpdate("Connection timed out", true); + + return ClusterStatus.Offline; + } + + this.broadcastConnectUpdate("Failed to fetch credentials", true); + + return ClusterStatus.AccessDenied; + } + + this.broadcastConnectUpdate(error.message, true); return ClusterStatus.Offline; } @@ -579,7 +570,7 @@ export class Cluster implements ClusterModel, ClusterState { } toJSON(): ClusterModel { - const model: ClusterModel = { + return toJS({ id: this.id, contextName: this.contextName, kubeConfigPath: this.kubeConfigPath, @@ -589,29 +580,24 @@ export class Cluster implements ClusterModel, ClusterState { metadata: this.metadata, accessibleNamespaces: this.accessibleNamespaces, labels: this.labels, - }; - - return toJS(model); + }); } /** * Serializable cluster-state used for sync btw main <-> renderer */ getState(): ClusterState { - const state: ClusterState = { + return toJS({ apiUrl: this.apiUrl, online: this.online, ready: this.ready, disconnected: this.disconnected, accessible: this.accessible, - failureReason: this.failureReason, isAdmin: this.isAdmin, allowedNamespaces: this.allowedNamespaces, allowedResources: this.allowedResources, isGlobalWatchEnabled: this.isGlobalWatchEnabled, - }; - - return toJS(state); + }); } /** @@ -643,6 +629,17 @@ export class Cluster implements ClusterModel, ClusterState { }; } + /** + * broadcast an authentication update concerning this cluster + * @internal + */ + broadcastConnectUpdate(message: string, isError = false): void { + const update: KubeAuthUpdate = { message, isError }; + + logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() }); + broadcastMessage(`cluster:${this.id}:connection-update`, update); + } + protected async getAllowedNamespaces() { if (this.accessibleNamespaces.length) { return this.accessibleNamespaces; diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index 76458bdd30..dddb45228e 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -146,7 +146,7 @@ export class ContextHandler { proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy; } this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv); - this.kubeAuthProxy.run(); + await this.kubeAuthProxy.run(); } await this.kubeAuthProxy.whenReady; @@ -157,8 +157,4 @@ export class ContextHandler { this.kubeAuthProxy = undefined; this.apiTarget = undefined; } - - get proxyLastError(): string { - return this.kubeAuthProxy?.lastError || ""; - } } diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index c1e76e40fb..c52f97d4b1 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -22,7 +22,6 @@ import { ChildProcess, spawn } from "child_process"; import { waitUntilUsed } from "tcp-port-used"; import { randomBytes } from "crypto"; -import { broadcastMessage } from "../common/ipc"; import type { Cluster } from "./cluster"; import { Kubectl } from "./kubectl"; import logger from "./logger"; @@ -30,22 +29,16 @@ import * as url from "url"; import { getPortFrom } from "./utils/get-port"; import { makeObservable, observable, when } from "mobx"; -export interface KubeAuthProxyLog { - data: string; - error?: boolean; // stream=stderr -} - const startingServeRegex = /^starting to serve on (?
.+)/i; export class KubeAuthProxy { - public lastError: string; public readonly apiPrefix: string; public get port(): number { return this._port; } - protected _port: number; + protected _port?: number; protected cluster: Cluster; protected env: NodeJS.ProcessEnv = null; protected proxyProcess: ChildProcess; @@ -92,67 +85,56 @@ export class KubeAuthProxy { this.proxyProcess = spawn(proxyBin, args, { env: this.env }); this.proxyProcess.on("error", (error) => { - this.sendIpcLogMessage({ data: error.message, error: true }); + this.cluster.broadcastConnectUpdate(error.message, true); this.exit(); }); this.proxyProcess.on("exit", (code) => { - this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 }); + this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code > 0); + this.exit(); + }); + + this.proxyProcess.on("disconnect", () => { + this.cluster.broadcastConnectUpdate("Proxy disconnected communications", true ); this.exit(); }); this.proxyProcess.stderr.on("data", (data) => { - this.lastError = this.parseError(data.toString()); - this.sendIpcLogMessage({ data: data.toString(), error: true }); + this.cluster.broadcastConnectUpdate(data.toString(), true); + }); + + this.proxyProcess.stdout.on("data", (data: any) => { + if (typeof this._port === "number") { + this.cluster.broadcastConnectUpdate(data.toString()); + } }); this._port = await getPortFrom(this.proxyProcess.stdout, { lineRegex: startingServeRegex, - onFind: () => this.sendIpcLogMessage({ data: "Authentication proxy started\n" }), + onFind: () => this.cluster.broadcastConnectUpdate("Authentication proxy started"), }); - this.proxyProcess.stdout.on("data", (data: any) => { - this.sendIpcLogMessage({ data: data.toString() }); - }); + try { + await waitUntilUsed(this.port, 500, 10000); + this.ready = true; + } catch (error) { + this.cluster.broadcastConnectUpdate("Proxy port failed to be used within timelimit, restarting...", true); + this.exit(); - await waitUntilUsed(this.port, 500, 10000); - - this.ready = true; - } - - protected parseError(data: string) { - const error = data.split("http: proxy error:").slice(1).join("").trim(); - let errorMsg = error; - const jsonError = error.split("Response: ")[1]; - - if (jsonError) { - try { - const parsedError = JSON.parse(jsonError); - - errorMsg = parsedError.error_description || parsedError.error || jsonError; - } catch (_) { - errorMsg = jsonError.trim(); - } + return this.run(); } - - return errorMsg; - } - - protected sendIpcLogMessage(res: KubeAuthProxyLog) { - const channel = `kube-auth:${this.cluster.id}`; - - logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() }); - broadcastMessage(channel, res); } public exit() { this.ready = false; - if (!this.proxyProcess) return; - logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()); - this.proxyProcess.kill(); - this.proxyProcess.removeAllListeners(); - this.proxyProcess.stderr.removeAllListeners(); - this.proxyProcess.stdout.removeAllListeners(); - this.proxyProcess = null; + + if (this.proxyProcess) { + logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()); + this.proxyProcess.removeAllListeners(); + this.proxyProcess.stderr.removeAllListeners(); + this.proxyProcess.stdout.removeAllListeners(); + this.proxyProcess.kill(); + this.proxyProcess = null; + } } } diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index 9ad90a2dc5..c3b5f3f63b 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -49,7 +49,7 @@ export class KubeconfigManager { try { this.tempFile = await this.createProxyKubeconfig(); } catch (err) { - logger.error(`Failed to created temp config for auth-proxy`, { err }); + logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, { err }); } } @@ -61,7 +61,7 @@ export class KubeconfigManager { return; } - logger.info(`Deleting temporary kubeconfig: ${this.tempFile}`); + logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`); await fs.unlink(this.tempFile); } @@ -70,7 +70,7 @@ export class KubeconfigManager { return; } - logger.info(`Deleting temporary kubeconfig: ${this.tempFile}`); + logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`); await fs.unlink(this.tempFile); this.tempFile = undefined; } @@ -80,8 +80,7 @@ export class KubeconfigManager { await this.contextHandler.ensureServer(); this.tempFile = await this.createProxyKubeconfig(); } catch (err) { - console.log(err); - logger.error(`Failed to created temp config for auth-proxy`, { err }); + logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, err); } } @@ -124,7 +123,7 @@ export class KubeconfigManager { await fs.ensureDir(path.dirname(tempFile)); await fs.writeFile(tempFile, configYaml, { mode: 0o600 }); - logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); + logger.debug(`[KUBECONFIG-MANAGER]: Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); return tempFile; } diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 38b0698de1..70713b22e8 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -119,7 +119,7 @@ export class WindowManager extends Singleton { logger.info("[WINDOW-MANAGER]: Main window loaded"); }) .on("will-attach-webview", (event, webPreferences, params) => { - logger.info("[WINDOW-MANAGER]: Attaching webview"); + logger.debug("[WINDOW-MANAGER]: Attaching webview"); // Following is security recommendations because we allow webview tag (webviewTag: true) // suggested by https://www.electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation // and https://www.electronjs.org/docs/tutorial/security#10-do-not-use-allowpopups diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 7c7cdd2714..70fbfab30c 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -21,9 +21,8 @@ import styles from "./cluster-status.module.css"; -import { ipcRenderer } from "electron"; import { computed, observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import React from "react"; import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { ClusterStore } from "../../../common/cluster-store"; @@ -33,10 +32,9 @@ import { cssNames, IClassName } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; import { Spinner } from "../spinner"; -import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy"; import { navigate } from "../../navigation"; import { entitySettingsURL } from "../../../common/routes"; -import type { ClusterId } from "../../../common/cluster-types"; +import type { ClusterId, KubeAuthUpdate } from "../../../common/cluster-types"; interface Props { className?: IClassName; @@ -45,7 +43,7 @@ interface Props { @observer export class ClusterStatus extends React.Component { - @observable authOutput: KubeAuthProxyLog[] = []; + @observable authOutput: KubeAuthUpdate[] = []; @observable isReconnecting = false; constructor(props: Props) { @@ -58,31 +56,31 @@ export class ClusterStatus extends React.Component { } @computed get hasErrors(): boolean { - return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason; + return this.authOutput.some(({ isError }) => isError); } - async componentDidMount() { - ipcRendererOn(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => { - this.authOutput.push({ - data: res.data.trimRight(), - error: res.error, - }); - }); + componentDidMount() { + disposeOnUnmount(this, [ + ipcRendererOn(`cluster:${this.cluster.id}:connection-update`, (evt, res: KubeAuthUpdate) => { + this.authOutput.push(res); + }), + ]); } - componentWillUnmount() { - ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`); - } - - activateCluster = async (force = false) => { - await requestMain(clusterActivateHandler, this.props.clusterId, force); - }; - reconnect = async () => { this.authOutput = []; this.isReconnecting = true; - await this.activateCluster(true); - this.isReconnecting = false; + + try { + await requestMain(clusterActivateHandler, this.props.clusterId, true); + } catch (error) { + this.authOutput.push({ + message: error.toString(), + isError: true, + }); + } finally { + this.isReconnecting = false; + } }; manageProxySettings = () => { @@ -94,58 +92,68 @@ export class ClusterStatus extends React.Component { })); }; - renderContent() { - const { authOutput, cluster, hasErrors } = this; - const failureReason = cluster.failureReason; + renderAuthenticationOutput() { + return ( +
+        {
+          this.authOutput.map(({ message, isError }, index) => (
+            

+ {message.trim()} +

+ )) + } +
+ ); + } - if (!hasErrors || this.isReconnecting) { - return ( -
- -
-            

{this.isReconnecting ? "Reconnecting" : "Connecting"}…

- {authOutput.map(({ data, error }, index) => { - return

{data}

; - })} -
-
- ); + renderStatusIcon() { + if (this.hasErrors) { + return ; } return ( -
- -

- {cluster.preferences.clusterName} -

-
-          {authOutput.map(({ data, error }, index) => {
-            return 

{data}

; - })} + <> + +
+          

{this.isReconnecting ? "Reconnecting" : "Connecting"}…

- {failureReason && ( -
{failureReason}
- )} -
+ ); } + renderReconnectionHelp() { + if (this.hasErrors && !this.isReconnecting) { + return ( + <> + + {this.waiting && ( )} diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx index c8beecc334..50417ce9dd 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -170,7 +170,7 @@ export class PodContainerPort extends React.Component { this.portForward()}> {text} - + {this.waiting && ( )} From 53dd538350af531775287a981919b675afcb1920 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 15 Nov 2021 16:32:46 -0500 Subject: [PATCH 38/39] Fix edit-resource by correctly saving first draft (#4342) --- .../components/dock/edit-resource.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/renderer/components/dock/edit-resource.tsx b/src/renderer/components/dock/edit-resource.tsx index 2c4065527f..78171db965 100644 --- a/src/renderer/components/dock/edit-resource.tsx +++ b/src/renderer/components/dock/edit-resource.tsx @@ -22,7 +22,7 @@ import "./edit-resource.scss"; import React from "react"; -import { action, computed, makeObservable, observable } from "mobx"; +import { computed, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; import yaml from "js-yaml"; import type { DockTab } from "./dock.store"; @@ -64,26 +64,19 @@ export class EditResource extends React.Component { return ""; // wait until tab's data and kube-object resource are loaded } - const { draft } = editResourceStore.getData(this.tabId); + const editData = editResourceStore.getData(this.tabId); - if (typeof draft === "string") { - return draft; + if (typeof editData.draft === "string") { + return editData.draft; } - return yaml.dump(this.resource.toPlainObject()); // dump resource first time + const firstDraft = yaml.dump(this.resource.toPlainObject()); // dump resource first time + + return editData.firstDraft = firstDraft; } - @action - saveDraft(draft: string | object) { - if (typeof draft === "object") { - draft = draft ? yaml.dump(draft) : undefined; - } - - editResourceStore.setData(this.tabId, { - firstDraft: draft, // this must be before the next line - ...editResourceStore.getData(this.tabId), - draft, - }); + saveDraft(draft: string) { + editResourceStore.getData(this.tabId).draft = draft; } onChange = (draft: string) => { From 385de469ad0bf4c731d91477db206c66c7f116d0 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 16 Nov 2021 09:14:41 +0200 Subject: [PATCH 39/39] allow to call KubeApi.delete without namespace parameter (#4349) Signed-off-by: Jari Kolehmainen --- src/common/k8s-api/__tests__/kube-api.test.ts | 29 +++++++++++++++++-- src/common/k8s-api/kube-api.ts | 2 +- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 73c5cb0460..dd79b9c386 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -245,17 +245,40 @@ describe("KubeApi", () => { }); }); - it("sends correct request", async () => { + it("sends correct request with empty namespace", async () => { + expect.hasAssertions(); + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("DELETE"); + expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/pods/foo?propagationPolicy=Background"); + + return {}; + }); + + await api.delete({ name: "foo", namespace: "" }); + }); + + it("sends correct request without namespace", async () => { expect.hasAssertions(); (fetch as any).mockResponse(async (request: Request) => { - console.log(request.url); expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"); return {}; }); - await api.delete({ name: "foo", namespace: "default" }); + await api.delete({ name: "foo" }); + }); + + it("sends correct request with namespace", async () => { + expect.hasAssertions(); + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("DELETE"); + expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods/foo?propagationPolicy=Background"); + + return {}; + }); + + await api.delete({ name: "foo", namespace: "kube-system" }); }); it("allows to change propagationPolicy", async () => { diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 7a9b856f51..840187f047 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -504,7 +504,7 @@ export class KubeApi { return parsed; } - async delete({ name = "", namespace = "default", propagationPolicy = "Background" }: { name: string, namespace: string, propagationPolicy?: PropagationPolicy }) { + async delete({ name = "", namespace = "default", propagationPolicy = "Background" }: { name: string, namespace?: string, propagationPolicy?: PropagationPolicy }) { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); const reqInit = {