Restructure to monorepo (#6907)

* wip: restructure to monorepo

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* refactor create-release-pr to a package

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* build fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* github workflow fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix typo

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* add webpack-env types to core

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix github workflows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* refactor/fix integration tests

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* lint fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* yarn run dev

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* eslint settings for vscode

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* move templates to right package

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* open-lens build fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* integration test fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix nx task dependencies

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* use bash shell for unit tests in test workflow

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix test:unit for windows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix win-ca webpack error in open-lens

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix win-ca webpack error in open-lens

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix build:app on windows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* remove ELECTRON_BUILDER_EXTRA_ARGS

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* sync src/ from master

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* remove Makefile from core

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2023-01-24 10:46:26 -08:00
1 parent 0066ca4ea2
commit 2657df2293
3321 files changed
+28699 -11447

No files matched your search

@@ -0,0 +1,35 @@
/**
* 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 { Cluster } from "../../common/cluster/cluster";
import type WebSocket from "ws";
import openLocalShellSessionInjectable from "./local-shell-session/open.injectable";
import openNodeShellSessionInjectable from "./node-shell-session/open.injectable";
export interface OpenShellSessionArgs {
websocket: WebSocket;
cluster: Cluster;
tabId: string;
nodeName?: string;
}
export type OpenShellSession = (args: OpenShellSessionArgs) => Promise<void>;
const openShellSessionInjectable = getInjectable({
id: "open-shell-session",
instantiate: (di): OpenShellSession => {
const openLocalShellSession = di.inject(openLocalShellSessionInjectable);
const openNodeShellSession = di.inject(openNodeShellSessionInjectable);
return ({ nodeName, ...args }) => (
nodeName
? openNodeShellSession({ nodeName, ...args })
: openLocalShellSession(args)
);
},
});
export default openShellSessionInjectable;
@@ -0,0 +1,7 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./node-shell-session/node-shell-session";
export * from "./local-shell-session/local-shell-session";
@@ -0,0 +1,71 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { UserStore } from "../../../common/user-store";
import type { ShellSessionArgs, ShellSessionDependencies } from "../shell-session";
import { ShellSession } from "../shell-session";
import type { ModifyTerminalShellEnv } from "../shell-env-modifier/modify-terminal-shell-env.injectable";
import type { JoinPaths } from "../../../common/path/join-paths.injectable";
import type { GetDirnameOfPath } from "../../../common/path/get-dirname.injectable";
import type { GetBasenameOfPath } from "../../../common/path/get-basename.injectable";
export interface LocalShellSessionDependencies extends ShellSessionDependencies {
readonly directoryForBinaries: string;
readonly userStore: UserStore;
modifyTerminalShellEnv: ModifyTerminalShellEnv;
joinPaths: JoinPaths;
getDirnameOfPath: GetDirnameOfPath;
getBasenameOfPath: GetBasenameOfPath;
}
export class LocalShellSession extends ShellSession {
ShellType = "shell";
constructor(protected readonly dependencies: LocalShellSessionDependencies, args: ShellSessionArgs) {
super(dependencies, args);
}
protected getPathEntries(): string[] {
return [this.dependencies.directoryForBinaries];
}
protected get cwd(): string | undefined {
return this.cluster.preferences?.terminalCWD;
}
public async open() {
// extensions can modify the env
const env = this.dependencies.modifyTerminalShellEnv(this.cluster.id, await this.getCachedShellEnv());
const shell = env.PTYSHELL;
if (!shell) {
throw new Error("PTYSHELL is not defined with the environment");
}
const args = await this.getShellArgs(shell);
await this.openShellProcess(shell, args, env);
}
protected async getShellArgs(shell: string): Promise<string[]> {
const pathFromPreferences = this.dependencies.userStore.kubectlBinariesPath || this.kubectl.getBundledPath();
const kubectlPathDir = this.dependencies.userStore.downloadKubectlBinaries
? await this.kubectlBinDirP
: this.dependencies.getDirnameOfPath(pathFromPreferences);
switch(this.dependencies.getBasenameOfPath(shell)) {
case "powershell.exe":
return ["-NoExit", "-command", `& {$Env:PATH="${kubectlPathDir};${this.dependencies.directoryForBinaries};$Env:PATH"}`];
case "bash":
return ["--init-file", this.dependencies.joinPaths(await this.kubectlBinDirP, ".bash_set_path")];
case "fish":
return ["--login", "--init-command", `export PATH="${kubectlPathDir}:${this.dependencies.directoryForBinaries}:$PATH"; export KUBECONFIG="${await this.kubeconfigPathP}"`];
case "zsh":
return ["--login"];
default:
return [];
}
}
}
@@ -0,0 +1,69 @@
/**
* 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 { LocalShellSessionDependencies } from "./local-shell-session";
import { LocalShellSession } from "./local-shell-session";
import createKubectlInjectable from "../../kubectl/create-kubectl.injectable";
import modifyTerminalShellEnvInjectable from "../shell-env-modifier/modify-terminal-shell-env.injectable";
import directoryForBinariesInjectable from "../../../common/app-paths/directory-for-binaries/directory-for-binaries.injectable";
import isMacInjectable from "../../../common/vars/is-mac.injectable";
import type { Cluster } from "../../../common/cluster/cluster";
import isWindowsInjectable from "../../../common/vars/is-windows.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import userStoreInjectable from "../../../common/user-store/user-store.injectable";
import type WebSocket from "ws";
import getDirnameOfPathInjectable from "../../../common/path/get-dirname.injectable";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
import computeShellEnvironmentInjectable from "../../../features/shell-sync/main/compute-shell-environment.injectable";
import spawnPtyInjectable from "../spawn-pty.injectable";
import userShellSettingInjectable from "../../../common/user-store/shell-setting.injectable";
import appNameInjectable from "../../../common/vars/app-name.injectable";
import buildVersionInjectable from "../../vars/build-version/build-version.injectable";
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
import statInjectable from "../../../common/fs/stat.injectable";
export interface OpenLocalShellSessionArgs {
websocket: WebSocket;
cluster: Cluster;
tabId: string;
}
export type OpenLocalShellSession = (args: OpenLocalShellSessionArgs) => Promise<void>;
const openLocalShellSessionInjectable = getInjectable({
id: "open-local-shell-session",
instantiate: (di): OpenLocalShellSession => {
const createKubectl = di.inject(createKubectlInjectable);
const dependencies: LocalShellSessionDependencies = {
directoryForBinaries: di.inject(directoryForBinariesInjectable),
isMac: di.inject(isMacInjectable),
isWindows: di.inject(isWindowsInjectable),
logger: di.inject(loggerInjectable),
userStore: di.inject(userStoreInjectable),
userShellSetting: di.inject(userShellSettingInjectable),
appName: di.inject(appNameInjectable),
buildVersion: di.inject(buildVersionInjectable),
modifyTerminalShellEnv: di.inject(modifyTerminalShellEnvInjectable),
emitAppEvent: di.inject(emitAppEventInjectable),
getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
joinPaths: di.inject(joinPathsInjectable),
getBasenameOfPath: di.inject(getBasenameOfPathInjectable),
computeShellEnvironment: di.inject(computeShellEnvironmentInjectable),
spawnPty: di.inject(spawnPtyInjectable),
stat: di.inject(statInjectable),
};
return (args) => {
const kubectl = createKubectl(args.cluster.version);
const session = new LocalShellSession(dependencies, { kubectl, ...args });
return session.open();
};
},
});
export default openLocalShellSessionInjectable;
@@ -0,0 +1,105 @@
/**
* 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 type 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 pathExistsSyncInjectable from "../../../common/fs/path-exists-sync.injectable";
import pathExistsInjectable from "../../../common/fs/path-exists.injectable";
import readJsonSyncInjectable from "../../../common/fs/read-json-sync.injectable";
import statInjectable from "../../../common/fs/stat.injectable";
import writeJsonSyncInjectable from "../../../common/fs/write-json-sync.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 { OpenShellSession } from "../create-shell-session.injectable";
import type { SpawnPty } from "../spawn-pty.injectable";
import spawnPtyInjectable from "../spawn-pty.injectable";
import openLocalShellSessionInjectable from "./open.injectable";
describe("technical unit tests for local shell sessions", () => {
let di: DiContainer;
beforeEach(() => {
di = getDiForUnitTesting({
doGeneralOverrides: true,
});
di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
di.override(buildVersionInjectable, () => ({
get: () => "1.1.1",
}));
di.override(pathExistsInjectable, () => () => { throw new Error("tried call pathExists without override"); });
di.override(pathExistsSyncInjectable, () => () => { throw new Error("tried call pathExistsSync without override"); });
di.override(readJsonSyncInjectable, () => () => { throw new Error("tried call readJsonSync without override"); });
di.override(writeJsonSyncInjectable, () => () => { throw new Error("tried call writeJsonSync without override"); });
di.override(statInjectable, () => () => { throw new Error("tried call stat without override"); });
});
describe("when on windows", () => {
let openLocalShellSession: OpenShellSession;
let spawnPtyMock: jest.MockedFunction<SpawnPty>;
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<Kubectl> as Kubectl);
openLocalShellSession = di.inject(openLocalShellSessionInjectable);
});
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 websocket = {
on: jest.fn(() => websocket),
once: jest.fn(() => websocket),
} as Partial<WebSocket> as WebSocket;
await openLocalShellSession({
cluster: {
getProxyKubeconfigPath: async () => "/some-proxy-kubeconfig",
preferences: {},
} as Partial<Cluster> as Cluster,
tabId: "my-tab-id",
websocket,
});
});
});
});
});
@@ -0,0 +1,173 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { v4 as uuid } from "uuid";
import { Watch, CoreV1Api } from "@kubernetes/client-node";
import type { KubeConfig } from "@kubernetes/client-node";
import type { ShellSessionArgs, ShellSessionDependencies } from "../shell-session";
import { ShellOpenError, ShellSession } from "../shell-session";
import { get, once } from "lodash";
import { NodeApi } from "../../../common/k8s-api/endpoints";
import { TerminalChannels } from "../../../common/terminal/channels";
import type { CreateKubeJsonApiForCluster } from "../../../common/k8s-api/create-kube-json-api-for-cluster.injectable";
import type { CreateKubeApi } from "../../../common/k8s-api/create-kube-api.injectable";
export interface NodeShellSessionArgs extends ShellSessionArgs {
nodeName: string;
}
export interface NodeShellSessionDependencies extends ShellSessionDependencies {
createKubeJsonApiForCluster: CreateKubeJsonApiForCluster;
createKubeApi: CreateKubeApi;
}
export class NodeShellSession extends ShellSession {
ShellType = "node-shell";
protected readonly podName = `node-shell-${uuid()}`;
protected readonly nodeName: string;
protected readonly cwd: string | undefined = undefined;
constructor(protected readonly dependencies: NodeShellSessionDependencies, { nodeName, ...args }: NodeShellSessionArgs) {
super(dependencies, args);
this.nodeName = nodeName;
}
public async open() {
const kc = await this.cluster.getProxyKubeconfig();
const coreApi = kc.makeApiClient(CoreV1Api);
const shell = await this.kubectl.getPath();
const cleanup = once(() => {
coreApi
.deleteNamespacedPod(this.podName, "kube-system")
.catch(error => this.dependencies.logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error));
});
this.websocket.once("close", cleanup);
try {
await this.createNodeShellPod(coreApi);
await this.waitForRunningPod(kc);
} catch (error) {
cleanup();
this.send({
type: TerminalChannels.STDOUT,
data: `Error occurred: ${get(error, "response.body.message", error ? String(error) : "unknown error")}`,
});
throw new ShellOpenError(
"failed to create node pod",
error instanceof Error
? { cause: error }
: undefined,
);
}
const env = await this.getCachedShellEnv();
const args = ["exec", "-i", "-t", "-n", "kube-system", this.podName, "--"];
const nodeApi = this.dependencies.createKubeApi(NodeApi, {
request: this.dependencies.createKubeJsonApiForCluster(this.cluster.id),
});
const node = await nodeApi.get({ name: this.nodeName });
if (!node) {
throw new Error(`No node with name=${this.nodeName} found`);
}
const nodeOs = node.getOperatingSystem();
switch (nodeOs) {
default:
this.dependencies.logger.warn(`[NODE-SHELL-SESSION]: could not determine node OS, falling back with assumption of linux`);
// fallthrough
case "linux":
args.push("sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))");
break;
case "windows":
args.push("powershell");
break;
}
await this.openShellProcess(shell, args, env);
}
protected createNodeShellPod(coreApi: CoreV1Api) {
const imagePullSecrets = this.cluster.imagePullSecret
? [{
name: this.cluster.imagePullSecret,
}]
: undefined;
return coreApi
.createNamespacedPod("kube-system", {
metadata: {
name: this.podName,
namespace: "kube-system",
},
spec: {
nodeName: this.nodeName,
restartPolicy: "Never",
terminationGracePeriodSeconds: 0,
hostPID: true,
hostIPC: true,
hostNetwork: true,
tolerations: [{
operator: "Exists",
}],
priorityClassName: "system-node-critical",
containers: [{
name: "shell",
image: this.cluster.nodeShellImage,
securityContext: {
privileged: true,
},
command: ["nsenter"],
args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"],
}],
imagePullSecrets,
},
});
}
protected waitForRunningPod(kc: KubeConfig): Promise<void> {
this.dependencies.logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`);
return new Promise((resolve, reject) => {
new Watch(kc)
.watch(`/api/v1/namespaces/kube-system/pods`,
{},
// callback is called for each received object.
(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) => {
this.dependencies.logger.error(`[NODE-SHELL]: ${this.podName} was not created in time`);
reject(err);
},
)
.then(req => {
setTimeout(() => {
this.dependencies.logger.error(`[NODE-SHELL]: aborting wait for ${this.podName}, timing out`);
req.abort();
reject("Pod creation timed out");
}, 2 * 60 * 1000); // 2 * 60 * 1000
})
.catch(error => {
this.dependencies.logger.error(`[NODE-SHELL]: waiting for ${this.podName} failed: ${error}`);
reject(error);
});
});
}
}
@@ -0,0 +1,61 @@
/**
* 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 { Cluster } from "../../../common/cluster/cluster";
import type WebSocket from "ws";
import createKubectlInjectable from "../../kubectl/create-kubectl.injectable";
import type { NodeShellSessionDependencies } from "./node-shell-session";
import { NodeShellSession } from "./node-shell-session";
import isMacInjectable from "../../../common/vars/is-mac.injectable";
import isWindowsInjectable from "../../../common/vars/is-windows.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import createKubeJsonApiForClusterInjectable from "../../../common/k8s-api/create-kube-json-api-for-cluster.injectable";
import computeShellEnvironmentInjectable from "../../../features/shell-sync/main/compute-shell-environment.injectable";
import spawnPtyInjectable from "../spawn-pty.injectable";
import userShellSettingInjectable from "../../../common/user-store/shell-setting.injectable";
import appNameInjectable from "../../../common/vars/app-name.injectable";
import buildVersionInjectable from "../../vars/build-version/build-version.injectable";
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
import statInjectable from "../../../common/fs/stat.injectable";
import createKubeApiInjectable from "../../../common/k8s-api/create-kube-api.injectable";
export interface NodeShellSessionArgs {
websocket: WebSocket;
cluster: Cluster;
tabId: string;
nodeName: string;
}
export type OpenNodeShellSession = (args: NodeShellSessionArgs) => Promise<void>;
const openNodeShellSessionInjectable = getInjectable({
id: "open-node-shell-session",
instantiate: (di): OpenNodeShellSession => {
const createKubectl = di.inject(createKubectlInjectable);
const dependencies: NodeShellSessionDependencies = {
isMac: di.inject(isMacInjectable),
isWindows: di.inject(isWindowsInjectable),
logger: di.inject(loggerInjectable),
userShellSetting: di.inject(userShellSettingInjectable),
appName: di.inject(appNameInjectable),
buildVersion: di.inject(buildVersionInjectable),
createKubeJsonApiForCluster: di.inject(createKubeJsonApiForClusterInjectable),
computeShellEnvironment: di.inject(computeShellEnvironmentInjectable),
spawnPty: di.inject(spawnPtyInjectable),
emitAppEvent: di.inject(emitAppEventInjectable),
stat: di.inject(statInjectable),
createKubeApi: di.inject(createKubeApiInjectable),
};
return async (args) => {
const kubectl = createKubectl(args.cluster.version);
const session = new NodeShellSession(dependencies, { kubectl, ...args });
return session.open();
};
},
});
export default openNodeShellSessionInjectable;
@@ -0,0 +1,49 @@
/**
* 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 { computed } from "mobx";
import type { ClusterId } from "../../../common/cluster-types";
import { isDefined } from "../../../common/utils";
import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable";
import catalogEntityRegistryInjectable from "../../catalog/entity-registry.injectable";
export type ModifyTerminalShellEnv = (clusterId: ClusterId, env: Partial<Record<string, string>>) => Partial<Record<string, string>>;
const modifyTerminalShellEnvInjectable = getInjectable({
id: "terminal-shell-env-modify",
instantiate: (di): ModifyTerminalShellEnv => {
const extensions = di.inject(mainExtensionsInjectable);
const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
const terminalShellEnvModifiers = computed(() => (
extensions.get()
.map((extension) => extension.terminalShellEnvModifier)
.filter(isDefined)
));
return (clusterId, env) => {
const modifiers = terminalShellEnvModifiers.get();
if (modifiers.length === 0) {
return env;
}
const entity = catalogEntityRegistry.findById(clusterId);
if (entity) {
const ctx = { catalogEntity: entity };
// clone it so the passed value is not also modified
env = JSON.parse(JSON.stringify(env));
env = modifiers.reduce((env, modifier) => modifier(ctx, env), env);
}
return env;
};
},
});
export default modifyTerminalShellEnvInjectable;
@@ -0,0 +1,12 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { CatalogEntity } from "../../../common/catalog";
export interface ShellEnvContext {
catalogEntity: CatalogEntity;
}
export type ShellEnvModifier = (ctx: ShellEnvContext, env: Record<string, string | undefined>) => Record<string, string | undefined>;
@@ -0,0 +1,405 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Cluster } from "../../common/cluster/cluster";
import type { Kubectl } from "../kubectl/kubectl";
import type WebSocket from "ws";
import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars";
import path from "path";
import os from "os";
import type * as pty from "node-pty";
import { getOrInsertWith } from "../../common/utils";
import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels";
import type { Logger } from "../../common/logger";
import type { ComputeShellEnvironment } from "../../features/shell-sync/main/compute-shell-environment.injectable";
import type { SpawnPty } from "./spawn-pty.injectable";
import type { InitializableState } from "../../common/initializable-state/create";
import type { EmitAppEvent } from "../../common/app-event-bus/emit-event.injectable";
import type { Stat } from "../../common/fs/stat.injectable";
import type { IComputedValue } from "mobx";
export class ShellOpenError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(`${message}`, options);
this.name = this.constructor.name;
Error.captureStackTrace(this);
}
}
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 interface ShellSessionDependencies {
readonly isWindows: boolean;
readonly isMac: boolean;
readonly logger: Logger;
readonly userShellSetting: IComputedValue<string>;
readonly appName: string;
readonly buildVersion: InitializableState<string>;
computeShellEnvironment: ComputeShellEnvironment;
spawnPty: SpawnPty;
emitAppEvent: EmitAppEvent;
stat: Stat;
}
export interface ShellSessionArgs {
kubectl: Kubectl;
websocket: WebSocket;
cluster: Cluster;
tabId: string;
}
export abstract class ShellSession {
abstract readonly ShellType: string;
private static readonly shellEnvs = new Map<string, Record<string, string | undefined>>();
private static readonly processes = new Map<string, pty.IPty>();
/**
* 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 {
// ignore error
}
}
this.processes.clear();
}
protected running = false;
protected readonly kubectlBinDirP: Promise<string>;
protected readonly kubeconfigPathP: Promise<string>;
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: Partial<Record<string, string>>, cwd: string): { shellProcess: pty.IPty; resume: boolean } {
const resume = ShellSession.processes.has(this.terminalId);
const shellProcess = getOrInsertWith(ShellSession.processes, this.terminalId, () => (
this.dependencies.spawnPty(shell, args, {
rows: 30,
cols: 80,
cwd,
env,
name: "xterm-256color",
// TODO: Something else is broken here so we need to force the use of winPty on windows
useConpty: false,
})
));
this.dependencies.logger.info(`[SHELL-SESSION]: PTY for ${this.terminalId} is ${resume ? "resumed" : "started"} with PID=${shellProcess.pid}`);
return { shellProcess, resume };
}
constructor(protected readonly dependencies: ShellSessionDependencies, { kubectl, websocket, cluster, tabId: terminalId }: ShellSessionArgs) {
this.kubectl = kubectl;
this.websocket = websocket;
this.cluster = cluster;
this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath();
this.kubectlBinDirP = this.kubectl.binDir();
this.terminalId = `${cluster.id}:${terminalId}`;
}
protected send(message: TerminalMessage): void {
this.websocket.send(JSON.stringify(message));
}
protected async getCwd(env: Record<string, string | undefined>): Promise<string> {
const cwdOptions = [this.cwd];
if (this.dependencies.isWindows) {
cwdOptions.push(
env.USERPROFILE,
os.homedir(),
"C:\\",
);
} else {
cwdOptions.push(
env.HOME,
os.homedir(),
);
if (this.dependencies.isMac) {
cwdOptions.push("/Users");
} else {
cwdOptions.push("/home");
}
}
for (const potentialCwd of cwdOptions) {
if (!potentialCwd) {
continue;
}
try {
const stats = await this.dependencies.stat(potentialCwd);
if (stats.isDirectory()) {
return potentialCwd;
}
} catch {
// ignore error
}
}
return "."; // Always valid
}
protected async openShellProcess(shell: string, args: string[], env: Record<string, string | undefined>) {
const cwd = await this.getCwd(env);
const { shellProcess, resume } = this.ensureShellProcess(shell, args, env, cwd);
if (resume) {
this.send({ type: TerminalChannels.CONNECTED });
}
this.running = true;
shellProcess.onData(data => this.send({ type: TerminalChannels.STDOUT, data }));
shellProcess.onExit(({ exitCode }) => {
this.dependencies.logger.info(`[SHELL-SESSION]: shell has exited for ${this.terminalId} closed with exitcode=${exitCode}`);
// This might already be false because of the kill() within the websocket.on("close") handler
if (this.running) {
this.running = false;
if (exitCode > 0) {
this.send({ type: TerminalChannels.STDOUT, data: "Terminal will auto-close in 15 seconds ..." });
setTimeout(() => this.exit(), 15 * 1000);
} else {
this.exit();
}
}
});
this.websocket
.on("message", (rawData: unknown): void => {
if (!this.running) {
return void this.dependencies.logger.debug(`[SHELL-SESSION]: received message from ${this.terminalId}, but shellProcess isn't running`);
}
if (!(rawData instanceof Buffer)) {
return void this.dependencies.logger.error(`[SHELL-SESSION]: Received message non-buffer message.`, { rawData });
}
const data = rawData.toString();
try {
const message: TerminalMessage = JSON.parse(data);
switch (message.type) {
case TerminalChannels.STDIN:
shellProcess.write(message.data);
break;
case TerminalChannels.RESIZE:
shellProcess.resize(message.data.width, message.data.height);
break;
case TerminalChannels.PING:
this.dependencies.logger.silly(`[SHELL-SESSION]: ${this.terminalId} ping!`);
break;
default:
this.dependencies.logger.warn(`[SHELL-SESSION]: unknown or unhandleable message type for ${this.terminalId}`, message);
break;
}
} catch (error) {
this.dependencies.logger.error(`[SHELL-SESSION]: failed to handle message for ${this.terminalId}`, error);
}
})
.once("close", code => {
this.dependencies.logger.info(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${WebSocketCloseEvent[code]}(${code})`, { cluster: this.cluster.getMeta() });
const stopShellSession = this.running
&& (
(
code !== WebSocketCloseEvent.AbnormalClosure
&& code !== WebSocketCloseEvent.GoingAway
)
|| this.cluster.disconnected
);
if (stopShellSession) {
this.running = false;
try {
this.dependencies.logger.info(`[SHELL-SESSION]: Killing shell process (pid=${shellProcess.pid}) for ${this.terminalId}`);
shellProcess.kill();
ShellSession.processes.delete(this.terminalId);
} catch (error) {
this.dependencies.logger.warn(`[SHELL-SESSION]: failed to kill shell process (pid=${shellProcess.pid}) for ${this.terminalId}`, error);
}
}
});
this.dependencies.emitAppEvent({ name: this.ShellType, action: "open" });
}
protected getPathEntries(): string[] {
return [];
}
protected async getCachedShellEnv() {
const { id: clusterId } = this.cluster;
let env = ShellSession.shellEnvs.get(clusterId);
if (!env) {
env = await this.getShellEnv();
ShellSession.shellEnvs.set(clusterId, env);
} else {
// refresh env in the background
this.getShellEnv().then((shellEnv: any) => {
ShellSession.shellEnvs.set(clusterId, shellEnv);
});
}
return env;
}
protected async getShellEnv() {
const shell = this.dependencies.userShellSetting.get();
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 (this.dependencies.isWindows) {
env.PTYSHELL = shell || "powershell.exe";
env.PATH = pathStr;
env.LENS_SESSION = "true";
env.WSLENV = [
env.WSLENV,
"KUBECONFIG/up:LENS_SESSION/u",
]
.filter(Boolean)
.join(":");
} else if (shell !== undefined) {
env.PTYSHELL = shell;
env.PATH = pathStr;
} else {
env.PTYSHELL = ""; // blank runs the system default shell
}
if (path.basename(env.PTYSHELL) === "zsh") {
env.OLD_ZDOTDIR = env.ZDOTDIR || env.HOME;
env.ZDOTDIR = await this.kubectlBinDirP;
env.DISABLE_AUTO_UPDATE = "true";
}
env.PTYPID = process.pid.toString();
env.KUBECONFIG = await this.kubeconfigPathP;
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;
}
env.NO_PROXY = [
"localhost",
"127.0.0.1",
env.NO_PROXY,
]
.filter(Boolean)
.join();
return env;
}
protected exit(code = WebSocketCloseEvent.NormalClosure) {
if (this.websocket.readyState == this.websocket.OPEN) {
this.websocket.close(code);
}
}
}
@@ -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");
});
@@ -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<IWindowsPtyForkOptions, "env"> & { env?: Partial<Record<string, string>> };
export type UnixSpawnPtyOptions = Omit<IPtyForkOptions, "env"> & { env?: Partial<Record<string, string>> };
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;