diff --git a/build/notarize.js b/build/notarize.js index ded81f6dd1..0bf1903e59 100644 --- a/build/notarize.js +++ b/build/notarize.js @@ -22,5 +22,6 @@ exports.default = async function notarizing(context) { appPath: `${appOutDir}/${appName}.app`, appleId: process.env.APPLEID, appleIdPassword: process.env.APPLEIDPASS, + ascProvider:process.env.ASCPROVIDER, }); }; diff --git a/package.json b/package.json index bea1f97e0b..c10a1829d0 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": "6.1.11", + "version": "6.1.12", "main": "static/build/main.js", "copyright": "© 2022 OpenLens Authors", "license": "MIT", diff --git a/src/common/user-store/resolved-shell.injectable.ts b/src/common/user-store/resolved-shell.injectable.ts new file mode 100644 index 0000000000..98c219feff --- /dev/null +++ b/src/common/user-store/resolved-shell.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import userStoreInjectable from "./user-store.injectable"; + +const resolvedShellInjectable = getInjectable({ + id: "resolved-shell", + instantiate: (di) => di.inject(userStoreInjectable).resolvedShell, +}); + +export default resolvedShellInjectable; diff --git a/src/main/__test__/shell-session.test.ts b/src/main/__test__/shell-session.test.ts index 1fe8915e26..c8c0f1c8df 100644 --- a/src/main/__test__/shell-session.test.ts +++ b/src/main/__test__/shell-session.test.ts @@ -7,14 +7,14 @@ import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; describe("clearKubeconfigEnvVars tests", () => { it("should not touch non kubeconfig keys", () => { - expect(clearKubeconfigEnvVars({ a: 1 })).toStrictEqual({ a: 1 }); + expect(clearKubeconfigEnvVars({ a: "22" })).toStrictEqual({ a: "22" }); }); it("should remove a single kubeconfig key", () => { - expect(clearKubeconfigEnvVars({ a: 1, kubeconfig: "1" })).toStrictEqual({ a: 1 }); + expect(clearKubeconfigEnvVars({ a: "22", kubeconfig: "1" })).toStrictEqual({ a: "22" }); }); it("should remove a two kubeconfig key", () => { - expect(clearKubeconfigEnvVars({ a: 1, kubeconfig: "1", kUbeconfig: "1" })).toStrictEqual({ a: 1 }); + expect(clearKubeconfigEnvVars({ a: "22", kubeconfig: "1", kUbeconfig: "1" })).toStrictEqual({ a: "22" }); }); }); diff --git a/src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.ts b/src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.ts index 97b6dedd4c..ef7e2d68d3 100644 --- a/src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.ts +++ b/src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.ts @@ -16,7 +16,7 @@ interface Dependencies { authenticateRequest: (clusterId: ClusterId, tabId: string, shellToken: string | undefined) => boolean; createShellSession: (args: { - webSocket: WebSocket; + websocket: WebSocket; cluster: Cluster; tabId: string; nodeName?: string; @@ -37,8 +37,8 @@ export const shellApiRequest = ({ createShellSession, authenticateRequest, clust const ws = new WebSocketServer({ noServer: true }); - ws.handleUpgrade(req, socket, head, (webSocket) => { - const shell = createShellSession({ webSocket, cluster, tabId, nodeName }); + ws.handleUpgrade(req, socket, head, (websocket) => { + const shell = createShellSession({ websocket, cluster, tabId, nodeName }); shell.open() .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${nodeName ? "node" : "local"} shell`, error)); diff --git a/src/main/shell-session/create-shell-session.injectable.ts b/src/main/shell-session/create-shell-session.injectable.ts index 5bd5569053..fa1b3203ea 100644 --- a/src/main/shell-session/create-shell-session.injectable.ts +++ b/src/main/shell-session/create-shell-session.injectable.ts @@ -9,7 +9,7 @@ import localShellSessionInjectable from "./local-shell-session/local-shell-sessi import nodeShellSessionInjectable from "./node-shell-session/node-shell-session.injectable"; interface Args { - webSocket: WebSocket; + websocket: WebSocket; cluster: Cluster; tabId: string; nodeName?: string; diff --git a/src/main/shell-session/local-shell-session/local-shell-session.injectable.ts b/src/main/shell-session/local-shell-session/local-shell-session.injectable.ts index ed9b86819b..eb8124df06 100644 --- a/src/main/shell-session/local-shell-session/local-shell-session.injectable.ts +++ b/src/main/shell-session/local-shell-session/local-shell-session.injectable.ts @@ -8,9 +8,18 @@ import type { Cluster } from "../../../common/cluster/cluster"; import type WebSocket from "ws"; import createKubectlInjectable from "../../kubectl/create-kubectl.injectable"; import terminalShellEnvModifiersInjectable from "../shell-env-modifier/terminal-shell-env-modify.injectable"; +import appNameInjectable from "../../../common/vars/app-name.injectable"; +import buildVersionInjectable from "../../vars/build-version/build-version.injectable"; +import computeShellEnvironmentInjectable from "../../utils/shell-env/compute-shell-environment.injectable"; +import isMacInjectable from "../../../common/vars/is-mac.injectable"; +import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import resolvedShellInjectable from "../../../common/user-store/resolved-shell.injectable"; +import spawnPtyInjectable from "../spawn-pty.injectable"; +import userStoreInjectable from "../../../common/user-store/user-store.injectable"; interface InstantiationParameter { - webSocket: WebSocket; + websocket: WebSocket; cluster: Cluster; tabId: string; } @@ -18,13 +27,26 @@ interface InstantiationParameter { const localShellSessionInjectable = getInjectable({ id: "local-shell-session", - instantiate: (di, { cluster, tabId, webSocket }: InstantiationParameter) => { + instantiate: (di, { cluster, tabId, websocket }: InstantiationParameter) => { const createKubectl = di.inject(createKubectlInjectable); - const localShellEnvModify = di.inject(terminalShellEnvModifiersInjectable); - const kubectl = createKubectl(cluster.version); - - return new LocalShellSession(localShellEnvModify, kubectl, webSocket, cluster, tabId); + return new LocalShellSession({ + modifyTerminalShellEnv: di.inject(terminalShellEnvModifiersInjectable), + appName: di.inject(appNameInjectable), + buildVersion: di.inject(buildVersionInjectable), + computeShellEnvironment: di.inject(computeShellEnvironmentInjectable), + isMac: di.inject(isMacInjectable), + isWindows: di.inject(isWindowsInjectable), + logger: di.inject(loggerInjectable), + resolvedShell: di.inject(resolvedShellInjectable), + spawnPty: di.inject(spawnPtyInjectable), + userStore: di.inject(userStoreInjectable), + }, { + kubectl: createKubectl(cluster.version), + websocket, + cluster, + tabId, + }); }, lifecycle: lifecycleEnum.transient, diff --git a/src/main/shell-session/local-shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session/local-shell-session.ts index 9f42c7f242..c324718bcc 100644 --- a/src/main/shell-session/local-shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session/local-shell-session.ts @@ -3,20 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type WebSocket from "ws"; import path from "path"; -import { UserStore } from "../../../common/user-store"; -import type { Cluster } from "../../../common/cluster/cluster"; +import type { UserStore } from "../../../common/user-store"; import type { ClusterId } from "../../../common/cluster-types"; +import type { ShellSessionArgs, ShellSessionDependencies } from "../shell-session"; import { ShellSession } from "../shell-session"; -import type { Kubectl } from "../../kubectl/kubectl"; import { baseBinariesDir } from "../../../common/vars"; +interface LocalShellSessionDependencies extends ShellSessionDependencies { + readonly userStore: UserStore; + modifyTerminalShellEnv: (clusterId: ClusterId, env: Record) => Record; +} + export class LocalShellSession extends ShellSession { ShellType = "shell"; - constructor(protected shellEnvModify: (clusterId: ClusterId, env: Record) => Record, kubectl: Kubectl, websocket: WebSocket, cluster: Cluster, terminalId: string) { - super(kubectl, websocket, cluster, terminalId); + constructor(protected readonly dependencies: LocalShellSessionDependencies, args: ShellSessionArgs) { + super(dependencies, args); } protected getPathEntries(): string[] { @@ -28,11 +31,8 @@ export class LocalShellSession extends ShellSession { } public async open() { - let env = await this.getCachedShellEnv(); - // extensions can modify the env - env = this.shellEnvModify(this.cluster.id, env); - + const env = this.dependencies.modifyTerminalShellEnv(this.cluster.id, await this.getCachedShellEnv()); const shell = env.PTYSHELL; if (!shell) { @@ -45,8 +45,8 @@ export class LocalShellSession extends ShellSession { } protected async getShellArgs(shell: string): Promise { - const pathFromPreferences = UserStore.getInstance().kubectlBinariesPath || this.kubectl.getBundledPath(); - const kubectlPathDir = UserStore.getInstance().downloadKubectlBinaries ? await this.kubectlBinDirP : path.dirname(pathFromPreferences); + const pathFromPreferences = this.dependencies.userStore.kubectlBinariesPath || this.kubectl.getBundledPath(); + const kubectlPathDir = this.dependencies.userStore.downloadKubectlBinaries ? await this.kubectlBinDirP : path.dirname(pathFromPreferences); switch(path.basename(shell)) { case "powershell.exe": diff --git a/src/main/shell-session/local-shell-session/techincal.test.ts b/src/main/shell-session/local-shell-session/techincal.test.ts new file mode 100644 index 0000000000..d790db6849 --- /dev/null +++ b/src/main/shell-session/local-shell-session/techincal.test.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DiContainer } from "@ogre-tools/injectable"; +import { WebSocket } from "ws"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import type { Cluster } from "../../../common/cluster/cluster"; +import resolvedShellInjectable from "../../../common/user-store/resolved-shell.injectable"; +import platformInjectable from "../../../common/vars/platform.injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import createKubectlInjectable from "../../kubectl/create-kubectl.injectable"; +import type { Kubectl } from "../../kubectl/kubectl"; +import buildVersionInjectable from "../../vars/build-version/build-version.injectable"; +import type { SpawnPty } from "../spawn-pty.injectable"; +import spawnPtyInjectable from "../spawn-pty.injectable"; +import localShellSessionInjectable from "./local-shell-session.injectable"; + +describe("technical unit tests for local shell sessions", () => { + let di: DiContainer; + + beforeEach(() => { + di = getDiForUnitTesting({ + doGeneralOverrides: true, + }); + + di.override(resolvedShellInjectable, () => "powershell.exe"); + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); + di.override(buildVersionInjectable, () => ({ + get: () => "1.1.1", + })); + }); + + describe("when on windows", () => { + let spawnPtyMock: jest.MockedFunction; + + beforeEach(() => { + di.override(platformInjectable, () => "win32"); + + spawnPtyMock = jest.fn(); + di.override(spawnPtyInjectable, () => spawnPtyMock); + + di.override(createKubectlInjectable, () => () => ({ + binDir: async () => "/some-kubectl-binary-dir", + getBundledPath: () => "/some-bundled-kubectl-path", + }) as Partial as Kubectl); + }); + + describe("when opening a local shell session", () => { + it("should pass through all environment variables to shell", async () => { + process.env.MY_TEST_ENV_VAR = "true"; + + spawnPtyMock.mockImplementationOnce((file, args, options) => { + expect(options.env).toMatchObject({ + MY_TEST_ENV_VAR: "true", + }); + + return { + cols: 80, + rows: 40, + pid: 12343, + handleFlowControl: false, + kill: jest.fn(), + onData: jest.fn(), + onExit: jest.fn(), + pause: jest.fn(), + process: "my-pty", + resize: jest.fn(), + resume: jest.fn(), + write: jest.fn(), + on: jest.fn(), + + }; + }); + + const session = di.inject(localShellSessionInjectable, { + cluster: { + getProxyKubeconfigPath: async () => "/some-proxy-kubeconfig", + preferences: {}, + } as Partial as Cluster, + tabId: "my-tab-id", + websocket: new WebSocket(null), + }); + + await session.open(); + }); + }); + }); +}); diff --git a/src/main/shell-session/node-shell-session/node-shell-session.injectable.ts b/src/main/shell-session/node-shell-session/node-shell-session.injectable.ts index e2514708e6..b54dc2dfe1 100644 --- a/src/main/shell-session/node-shell-session/node-shell-session.injectable.ts +++ b/src/main/shell-session/node-shell-session/node-shell-session.injectable.ts @@ -7,9 +7,17 @@ import type { Cluster } from "../../../common/cluster/cluster"; import type WebSocket from "ws"; import createKubectlInjectable from "../../kubectl/create-kubectl.injectable"; import { NodeShellSession } from "./node-shell-session"; +import loggerInjectable from "../../../common/logger.injectable"; +import resolvedShellInjectable from "../../../common/user-store/resolved-shell.injectable"; +import appNameInjectable from "../../../common/vars/app-name.injectable"; +import isMacInjectable from "../../../common/vars/is-mac.injectable"; +import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; +import computeShellEnvironmentInjectable from "../../utils/shell-env/compute-shell-environment.injectable"; +import buildVersionInjectable from "../../vars/build-version/build-version.injectable"; +import spawnPtyInjectable from "../spawn-pty.injectable"; interface InstantiationParameter { - webSocket: WebSocket; + websocket: WebSocket; cluster: Cluster; tabId: string; nodeName: string; @@ -18,12 +26,25 @@ interface InstantiationParameter { const nodeShellSessionInjectable = getInjectable({ id: "node-shell-session", - instantiate: (di, { cluster, tabId, webSocket, nodeName }: InstantiationParameter) => { + instantiate: (di, { cluster, tabId, websocket, nodeName }: InstantiationParameter) => { const createKubectl = di.inject(createKubectlInjectable); - const kubectl = createKubectl(cluster.version); - - return new NodeShellSession(nodeName, kubectl, webSocket, cluster, tabId); + return new NodeShellSession({ + appName: di.inject(appNameInjectable), + buildVersion: di.inject(buildVersionInjectable), + computeShellEnvironment: di.inject(computeShellEnvironmentInjectable), + isMac: di.inject(isMacInjectable), + isWindows: di.inject(isWindowsInjectable), + logger: di.inject(loggerInjectable), + resolvedShell: di.inject(resolvedShellInjectable), + spawnPty: di.inject(spawnPtyInjectable), + }, { + nodeName, + kubectl: createKubectl(cluster.version), + websocket, + cluster, + tabId, + }); }, lifecycle: lifecycleEnum.transient, diff --git a/src/main/shell-session/node-shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session/node-shell-session.ts index 4c619a612c..81bbcc3fc5 100644 --- a/src/main/shell-session/node-shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session/node-shell-session.ts @@ -3,28 +3,32 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type WebSocket from "ws"; import { v4 as uuid } from "uuid"; import { Watch, CoreV1Api } from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node"; -import type { Cluster } from "../../../common/cluster/cluster"; +import type { ShellSessionArgs, ShellSessionDependencies } from "../shell-session"; import { ShellOpenError, ShellSession } from "../shell-session"; import { get, once } from "lodash"; import { Node, NodeApi } from "../../../common/k8s-api/endpoints"; import { KubeJsonApi } from "../../../common/k8s-api/kube-json-api"; import logger from "../../logger"; -import type { Kubectl } from "../../kubectl/kubectl"; import { TerminalChannels } from "../../../common/terminal/channels"; +interface NodeShellArgs extends ShellSessionArgs { + nodeName: string; +} + export class NodeShellSession extends ShellSession { ShellType = "node-shell"; protected readonly podName = `node-shell-${uuid()}`; protected readonly cwd: string | undefined = undefined; + protected readonly nodeName: string; - constructor(protected nodeName: string, kubectl: Kubectl, socket: WebSocket, cluster: Cluster, terminalId: string) { - super(kubectl, socket, cluster, terminalId); + constructor(deps: ShellSessionDependencies, args: NodeShellArgs) { + super(deps, args); + this.nodeName = args.nodeName; } public async open() { diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 8176d090fa..e66ba91eee 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -6,19 +6,19 @@ import type { Cluster } from "../../common/cluster/cluster"; import type { Kubectl } from "../kubectl/kubectl"; import type WebSocket from "ws"; -import { shellEnv } from "../utils/shell-env"; -import { app } from "electron"; import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; import path from "path"; import os, { userInfo } from "os"; -import { isMac, isWindows } from "../../common/vars"; -import { UserStore } from "../../common/user-store"; -import * as pty from "node-pty"; +import type * as pty from "node-pty"; import { appEventBus } from "../../common/app-event-bus/event-bus"; import logger from "../logger"; import { stat } from "fs/promises"; import { getOrInsertWith } from "../../common/utils"; import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels"; +import type { Logger } from "../../common/logger"; +import type { ComputeShellEnvironment } from "../utils/shell-env/compute-shell-environment.injectable"; +import type { SpawnPty } from "./spawn-pty.injectable"; +import type { InitializableState } from "../../common/initializable-state/create"; export class ShellOpenError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -104,6 +104,24 @@ export enum WebSocketCloseEvent { TlsHandshake = 1015, } +export interface ShellSessionDependencies { + readonly isWindows: boolean; + readonly isMac: boolean; + readonly logger: Logger; + readonly resolvedShell: string | undefined; + readonly appName: string; + readonly buildVersion: InitializableState; + computeShellEnvironment: ComputeShellEnvironment; + spawnPty: SpawnPty; +} + +export interface ShellSessionArgs { + kubectl: Kubectl; + websocket: WebSocket; + cluster: Cluster; + tabId: string; +} + export abstract class ShellSession { abstract readonly ShellType: string; @@ -130,17 +148,20 @@ export abstract class ShellSession { protected readonly kubectlBinDirP: Promise; protected readonly kubeconfigPathP: Promise; protected readonly terminalId: string; + protected readonly kubectl: Kubectl; + protected readonly websocket: WebSocket; + protected readonly cluster: Cluster; protected abstract get cwd(): string | undefined; - protected ensureShellProcess(shell: string, args: string[], env: Record, cwd: string): { shellProcess: pty.IPty; resume: boolean } { + protected ensureShellProcess(shell: string, args: string[], env: Partial>, cwd: string): { shellProcess: pty.IPty; resume: boolean } { const resume = ShellSession.processes.has(this.terminalId); const shellProcess = getOrInsertWith(ShellSession.processes, this.terminalId, () => ( - pty.spawn(shell, args, { + this.dependencies.spawnPty(shell, args, { rows: 30, cols: 80, cwd, - env: env as Record, + env, name: "xterm-256color", // TODO: Something else is broken here so we need to force the use of winPty on windows useConpty: false, @@ -152,10 +173,13 @@ export abstract class ShellSession { return { shellProcess, resume }; } - constructor(protected readonly kubectl: Kubectl, protected readonly websocket: WebSocket, protected readonly cluster: Cluster, terminalId: string) { + constructor(protected readonly dependencies: ShellSessionDependencies, args: ShellSessionArgs) { + this.cluster = args.cluster; + this.kubectl = args.kubectl; + this.websocket = args.websocket; this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubectlBinDirP = this.kubectl.binDir(); - this.terminalId = `${cluster.id}:${terminalId}`; + this.terminalId = `${this.cluster.id}:${args.tabId}`; } protected send(message: TerminalMessage): void { @@ -165,7 +189,7 @@ export abstract class ShellSession { protected async getCwd(env: Record): Promise { const cwdOptions = [this.cwd]; - if (isWindows) { + if (this.dependencies.isWindows) { cwdOptions.push( env.USERPROFILE, os.homedir(), @@ -177,7 +201,7 @@ export abstract class ShellSession { os.homedir(), ); - if (isMac) { + if (this.dependencies.isMac) { cwdOptions.push("/Users"); } else { cwdOptions.push("/home"); @@ -313,19 +337,27 @@ export abstract class ShellSession { } protected async getShellEnv() { - const shell = UserStore.getInstance().resolvedShell; - const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv(shell || userInfo().shell)))); - const pathStr = [await this.kubectlBinDirP, ...this.getPathEntries(), process.env.PATH].join(path.delimiter); + const shell = this.dependencies.resolvedShell || userInfo().shell; + const result = await this.dependencies.computeShellEnvironment(shell); + const rawEnv = (() => { + if (result.callWasSuccessful) { + return result.response ?? process.env; + } + + return process.env; + })(); + + const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(rawEnv))); + const pathStr = [await this.kubectlBinDirP, ...this.getPathEntries(), env.PATH].join(path.delimiter); delete env.DEBUG; // don't pass DEBUG into shells - if (isWindows) { - env.SystemRoot = process.env.SystemRoot; + if (this.dependencies.isWindows) { env.PTYSHELL = shell || "powershell.exe"; env.PATH = pathStr; env.LENS_SESSION = "true"; env.WSLENV = [ - process.env.WSLENV, + env.WSLENV, "KUBECONFIG/up:LENS_SESSION/u", ] .filter(Boolean) @@ -345,8 +377,8 @@ export abstract class ShellSession { env.PTYPID = process.pid.toString(); env.KUBECONFIG = await this.kubeconfigPathP; - env.TERM_PROGRAM = app.getName(); - env.TERM_PROGRAM_VERSION = app.getVersion(); + env.TERM_PROGRAM = this.dependencies.appName; + env.TERM_PROGRAM_VERSION = this.dependencies.buildVersion.get(); if (this.cluster.preferences.httpsProxy) { env.HTTPS_PROXY = this.cluster.preferences.httpsProxy; diff --git a/src/main/shell-session/spawn-pty.global-override-for-injectable.ts b/src/main/shell-session/spawn-pty.global-override-for-injectable.ts new file mode 100644 index 0000000000..239f7c5e47 --- /dev/null +++ b/src/main/shell-session/spawn-pty.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getGlobalOverride } from "../../common/test-utils/get-global-override"; +import spawnPtyInjectable from "./spawn-pty.injectable"; + +export default getGlobalOverride(spawnPtyInjectable, () => () => { + throw new Error("Tried to spawn a PTY without an override"); +}); diff --git a/src/main/shell-session/spawn-pty.injectable.ts b/src/main/shell-session/spawn-pty.injectable.ts new file mode 100644 index 0000000000..0639c7307b --- /dev/null +++ b/src/main/shell-session/spawn-pty.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IPty, IPtyForkOptions, IWindowsPtyForkOptions } from "node-pty"; +import { spawn } from "node-pty"; + +export type WindowsSpawnPtyOptions = Omit & { env?: Partial> }; +export type UnixSpawnPtyOptions = Omit & { env?: Partial> }; +export type SpawnPtyOptions = UnixSpawnPtyOptions | WindowsSpawnPtyOptions; + +export type SpawnPty = (file: string, args: string[], options: SpawnPtyOptions) => IPty; + +const spawnPtyInjectable = getInjectable({ + id: "spawn-pty", + instantiate: () => spawn as SpawnPty, + causesSideEffects: true, +}); + +export default spawnPtyInjectable; diff --git a/src/main/start-main-application/runnables/setup-shell.injectable.ts b/src/main/start-main-application/runnables/setup-shell.injectable.ts index 80800e8073..9eb736b2db 100644 --- a/src/main/start-main-application/runnables/setup-shell.injectable.ts +++ b/src/main/start-main-application/runnables/setup-shell.injectable.ts @@ -5,11 +5,11 @@ import { getInjectable } from "@ogre-tools/injectable"; import loggerInjectable from "../../../common/logger.injectable"; import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; -import { shellEnv } from "../../utils/shell-env"; import os from "os"; import { unionPATHs } from "../../../common/utils/union-env-path"; import isSnapPackageInjectable from "../../../common/vars/is-snap-package.injectable"; import electronAppInjectable from "../../electron-app/electron-app.injectable"; +import computeShellEnvironmentInjectable from "../../utils/shell-env/compute-shell-environment.injectable"; const setupShellInjectable = getInjectable({ id: "setup-shell", @@ -18,12 +18,24 @@ const setupShellInjectable = getInjectable({ const logger = di.inject(loggerInjectable); const isSnapPackage = di.inject(isSnapPackageInjectable); const electronApp = di.inject(electronAppInjectable); + const computeShellEnvironment = di.inject(computeShellEnvironmentInjectable); return { - run: async () => { + id: "setup-shell", + run: async (): Promise => { logger.info("🐚 Syncing shell environment"); - const env = await shellEnv(os.userInfo().shell); + const result = await computeShellEnvironment(os.userInfo().shell); + + if (!result.callWasSuccessful) { + return void logger.error(`[SHELL-SYNC]: ${result.error}`); + } + + const env = result.response; + + if (!env) { + return void logger.debug("[SHELL-SYNC]: nothing to do, env not special in shells"); + } if (!env.LANG) { // the LANG env var expects an underscore instead of electron's dash diff --git a/src/main/utils/clear-kube-env-vars.ts b/src/main/utils/clear-kube-env-vars.ts index fab11be4e1..f678f44807 100644 --- a/src/main/utils/clear-kube-env-vars.ts +++ b/src/main/utils/clear-kube-env-vars.ts @@ -13,7 +13,7 @@ const anyKubeconfig = /^kubeconfig$/i; * before KUBECONFIG and we only set KUBECONFIG. * @param env The current copy of env */ -export function clearKubeconfigEnvVars(env: Record): Record { +export function clearKubeconfigEnvVars(env: Partial>): Partial> { return Object.fromEntries( Object.entries(env) .filter(([key]) => anyKubeconfig.exec(key) === null), diff --git a/src/main/utils/shell-env.ts b/src/main/utils/shell-env.ts deleted file mode 100644 index 6f4cce0be8..0000000000 --- a/src/main/utils/shell-env.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { spawn } from "child_process"; -import { randomUUID } from "crypto"; -import { basename } from "path"; -import { isWindows } from "../../common/vars"; -import logger from "../logger"; - -export type EnvironmentVariables = Partial>; - - -async function unixShellEnvironment(shell: string): Promise { - const runAsNode = process.env["ELECTRON_RUN_AS_NODE"]; - const noAttach = process.env["ELECTRON_NO_ATTACH_CONSOLE"]; - const env = { - ...process.env, - ELECTRON_RUN_AS_NODE: "1", - ELECTRON_NO_ATTACH_CONSOLE: "1", - }; - const mark = randomUUID().replace(/-/g, ""); - const regex = new RegExp(`${mark}(.*)${mark}`); - const shellName = basename(shell); - let command: string; - let shellArgs: string[]; - - if (/^pwsh(-preview)?$/.test(shellName)) { - // Older versions of PowerShell removes double quotes sometimes so we use "double single quotes" which is how - // you escape single quotes inside of a single quoted string. - command = `& '${process.execPath}' -p '''${mark}'' + JSON.stringify(process.env) + ''${mark}'''`; - shellArgs = ["-Login", "-Command"]; - } else { - command = `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; - - if (shellName === "tcsh") { - shellArgs = ["-ic"]; - } else { - shellArgs = ["-ilc"]; - } - } - - return new Promise((resolve, reject) => { - const shellProcess = spawn(shell, [...shellArgs, command], { - detached: true, - stdio: ["ignore", "pipe", "pipe"], - env, - }); - const stdout: Buffer[] = []; - - shellProcess.on("error", (err) => reject(err)); - shellProcess.stdout.on("data", b => stdout.push(b)); - shellProcess.on("close", (code, signal) => { - if (code || signal) { - return reject(new Error(`Unexpected return code from spawned shell (code: ${code}, signal: ${signal})`)); - } - - try { - const rawOutput = Buffer.concat(stdout).toString("utf-8"); - const match = regex.exec(rawOutput); - const strippedRawOutput = match ? match[1] : "{}"; - const resolvedEnv = JSON.parse(strippedRawOutput); - - if (runAsNode) { - resolvedEnv["ELECTRON_RUN_AS_NODE"] = runAsNode; - } else { - delete resolvedEnv["ELECTRON_RUN_AS_NODE"]; - } - - if (noAttach) { - resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"] = noAttach; - } else { - delete resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"]; - } - - resolve(resolvedEnv); - } catch(err) { - reject(err); - } - }); - }); -} - -let shellSyncFailed = false; - -/** - * Attempts to get the shell environment per the user's existing startup scripts. - * If the environment can't be retrieved after 5 seconds an error message is logged. - * Subsequent calls after such a timeout simply log an error message without trying - * to get the environment, unless forceRetry is true. - * @param shell the shell to get the environment from - * @returns object containing the shell's environment variables. An empty object is - * returned if the call fails. - */ -export async function shellEnv(shell: string) : Promise { - if (isWindows) { - return {}; - } - - if (!shellSyncFailed) { - try { - return await Promise.race([ - unixShellEnvironment(shell), - new Promise((_resolve, reject) => setTimeout(() => { - reject(new Error("Resolving shell environment is taking very long. Please review your shell configuration.")); - }, 30_000)), - ]); - } catch (error) { - logger.error(`shellEnv: ${error}`); - shellSyncFailed = true; - } - } else { - logger.error("shellSync(): Resolving shell environment took too long. Please review your shell configuration."); - } - - return {}; -} diff --git a/src/main/utils/shell-env/compute-shell-environment.injectable.ts b/src/main/utils/shell-env/compute-shell-environment.injectable.ts new file mode 100644 index 0000000000..e3a60fcd03 --- /dev/null +++ b/src/main/utils/shell-env/compute-shell-environment.injectable.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncResult } from "../../../common/utils/async-result"; +import { getInjectable } from "@ogre-tools/injectable"; +import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; +import { disposer } from "../../../common/utils"; +import computeUnixShellEnvironmentInjectable from "./compute-unix-shell-environment.injectable"; + +export type EnvironmentVariables = Partial>; +export type ComputeShellEnvironment = (shell: string) => Promise>; + +const computeShellEnvironmentInjectable = getInjectable({ + id: "compute-shell-environment", + instantiate: (di): ComputeShellEnvironment => { + const isWindows = di.inject(isWindowsInjectable); + const computeUnixShellEnvironment = di.inject(computeUnixShellEnvironmentInjectable); + + if (isWindows) { + return async () => ({ + callWasSuccessful: true, + response: undefined, + }); + } + + return async (shell) => { + const controller = new AbortController(); + const shellEnv = computeUnixShellEnvironment(shell, { signal: controller.signal }); + const cleanup = disposer(); + + const timeoutHandle = setTimeout(() => controller.abort(), 30_000); + + cleanup.push(() => clearTimeout(timeoutHandle)); + + try { + return { + callWasSuccessful: true, + response: await shellEnv, + }; + } catch (error) { + if (controller.signal.aborted) { + return { + callWasSuccessful: false, + error: "Resolving shell environment is taking very long. Please review your shell configuration.", + }; + } + + return { + callWasSuccessful: false, + error: String(error), + }; + } finally { + cleanup(); + } + }; + }, +}); + +export default computeShellEnvironmentInjectable; + diff --git a/src/main/utils/shell-env/compute-unix-shell-environment.global-override-for-injectable.ts b/src/main/utils/shell-env/compute-unix-shell-environment.global-override-for-injectable.ts new file mode 100644 index 0000000000..aac810aa62 --- /dev/null +++ b/src/main/utils/shell-env/compute-unix-shell-environment.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../../../common/test-utils/get-global-override"; +import computeUnixShellEnvironmentInjectable from "./compute-unix-shell-environment.injectable"; + +export default getGlobalOverride(computeUnixShellEnvironmentInjectable, () => async () => { + throw new Error("Tried to get unix shell env without override"); +}); diff --git a/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts b/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts new file mode 100644 index 0000000000..7186e96a88 --- /dev/null +++ b/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { spawn } from "child_process"; +import { randomUUID } from "crypto"; +import { basename } from "path"; +import type { EnvironmentVariables } from "./compute-shell-environment.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +interface UnixShellEnvOptions { + signal?: AbortSignal; +} + +export type ComputeUnixShellEnvironment = (shell: string, opts?: UnixShellEnvOptions) => Promise; + +const computeUnixShellEnvironmentInjectable = getInjectable({ + id: "compute-unix-shell-environment", + instantiate: (): ComputeUnixShellEnvironment => async (shell, opts) => { + const runAsNode = process.env["ELECTRON_RUN_AS_NODE"]; + const noAttach = process.env["ELECTRON_NO_ATTACH_CONSOLE"]; + const env = { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + ELECTRON_NO_ATTACH_CONSOLE: "1", + }; + const mark = randomUUID().replace(/-/g, ""); + const regex = new RegExp(`${mark}(.*)${mark}`); + const shellName = basename(shell); + let command: string; + let shellArgs: string[]; + + if (/^pwsh(-preview)?$/.test(shellName)) { + // Older versions of PowerShell removes double quotes sometimes so we use "double single quotes" which is how + // you escape single quotes inside of a single quoted string. + command = `& '${process.execPath}' -p '''${mark}'' + JSON.stringify(process.env) + ''${mark}'''`; + shellArgs = ["-Login", "-Command"]; + } else { + command = `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; + + if (shellName === "tcsh") { + shellArgs = ["-ic"]; + } else { + shellArgs = ["-ilc"]; + } + } + + return new Promise((resolve, reject) => { + const shellProcess = spawn(shell, [...shellArgs, command], { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env, + }); + const stdout: Buffer[] = []; + + opts?.signal?.addEventListener("abort", () => shellProcess.kill()); + + shellProcess.on("error", (err) => reject(err)); + shellProcess.stdout.on("data", b => stdout.push(b)); + shellProcess.on("close", (code, signal) => { + if (code || signal) { + return reject(new Error(`Unexpected return code from spawned shell (code: ${code}, signal: ${signal})`)); + } + + try { + const rawOutput = Buffer.concat(stdout).toString("utf-8"); + const match = regex.exec(rawOutput); + const strippedRawOutput = match ? match[1] : "{}"; + const resolvedEnv = JSON.parse(strippedRawOutput); + + if (runAsNode) { + resolvedEnv["ELECTRON_RUN_AS_NODE"] = runAsNode; + } else { + delete resolvedEnv["ELECTRON_RUN_AS_NODE"]; + } + + if (noAttach) { + resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"] = noAttach; + } else { + delete resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"]; + } + + resolve(resolvedEnv); + } catch(err) { + reject(err); + } + }); + }); + }, + causesSideEffects: true, +}); + +export default computeUnixShellEnvironmentInjectable;