Turn on strict mode in tsconfig.json, some helpful lints, and required cleanup where strictness necessitates it (#5195)

* Turn on strict mode in tsconfig.json

- Add route, clusterRoute, and payloadValidatedClusterRoute helper
  functions to improve types with backend routes

- Turn on the following new lints:
  - react/jsx-first-prop-new-line
  - react/jsx-wrap-multilines
  - react/jsx-one-expression-per-line
  - react/jsx-max-props-per-line
  - react/jsx-indent
  - react/jsx-indent-props

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix build

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Replace KubeObject scope strings with enum

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Revert package.json version changes

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* revert move hostedCluster(Id)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* change some type param names to be not single letters

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove copy-extension-themes

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* add new make clean action

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix build to not use webpack for generating types

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix kube-object-menu.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix select.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix catalog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* revert move fileNameMigration to index

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix ref logic error

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix log-resource-selector.test.tsx tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix dock-store.test.ts test by overriding createStorage to not touch file system

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix cluster.test.ts tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix kube=api.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fixed hotbar-store.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix kubeconfig-manager.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix cluster-role-bindings/__tests__/dialog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix role-bindings/__tests__/dialog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix pods.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix delete-cluster-dialog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix daemonset.store.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix replicaset.store.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix statefulsets/dialog/dialog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix replicasets/scale-dialog/dialog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix deployments.store.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix deployments/scale/dialog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix cronjob.store.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix stateful-set.api.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix deployment.api.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix api-manager.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix statefulset.store.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix job.store.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix pods.store.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix scroll-spy.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix hotbar-remove-command.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix catalog-entity-registry.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix welcome.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix verify-that-all-routes-have-route-component.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix pod-tolerations.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* better fix for previous 3 fixes, plus also select.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix kube-object-menu.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix app-paths.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix dock-tabs.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix isReactNode typing

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix sub-title.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix drawer.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix list-layout.tsx and header.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix error-boundary.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix upgrade-chart/store.ts and dock-tab.store.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix install-chart/store.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix edit-resource/store.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix create-resource/store.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix namespace-select.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix namespace-select-filter.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix crd-list.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix wrong types for extensions

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix circular dependency

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix circular dependency on catalogCategoryRegistry

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix api-kube

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix type errors, most <Select /> errors

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fixing more type errors

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* some more fixing type errors

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* convert all KubeApis to injectable with legacy global backups

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* factor out into a common file all the exports

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* convert all KubeObjectStores to injectable with legacy global backups

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix lint

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove unused legacy KubeApi globals

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix bad previous commit

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* more crash fixing

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* try and fix behavioural tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix sidebar-and-tab-navigation-for-core.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix sidebar-and-tab-navigation-for-extensions.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-using-application-menu.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix catalog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Make ThemeStore non-singleton and fix navigation-to-terminal-preferences.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* extensions.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix catalog-entity-registry.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-using-application-menu.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix log-resource-selector.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix dock-tabs.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix delete-cluster-dialog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-to-kubernetes-preferences.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-to-editor-preferences.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-to-proxy-preferences.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-using-application-menu.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-to-application-preferences.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix dock-store.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix select.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix role-bindings/__tests__/dialog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix hotbar-remove-command.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix cluster-role-bindings/__tests__/dialog.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-to-extension-specific-preferences.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-to-telemetry-preferences.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix closing-preferences.test.tsx

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-to-editor-preferences.test.ts

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix navigation-to-proxy-preferences.test.ts

- Fix other type errors too

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* final tweaks

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Add more tsconfig files, fix bug in <Catalog>

- Make all of history, navigation injectable

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix type errors

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Convert all of kube-details-params/ and navigate/ to injectable

- This fixes a runtime error that was encountered during testing

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix runtime errors on renderer

- remove all static uses of `createPageParam` (and then removed the
  legacy global)
- Made LensRendererExtension and LensMainExtension just used
  dependencies and not the getLegacyDi
- Fixed circular dep in extension-loader

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Move registerStore calls to after injectMany

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* replace all the rest of the legacy uses of apiManager

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix stack overflow and cycles in DI

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix NamespaceSelectFilter not opening

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix WizardStep and AddNamespaceDialog

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix KubeApi's not being registered

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* cleanup WindowManager

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Proper fix for Wizard, fix NamespaceStore.subscribe

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Rewrite withTooltip to be more type correct

- Fixes mobx related "too many recursive actions" error

- Change all the uses of withTooltips to be functional components

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Add e2e test to cover kube api registration

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* cleanup internal-commands

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove cast in <Animate>

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix command-palette e2e test

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix type error after rebase

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix test name

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix lint

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix code to help CodeQL scanner

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* update intree extension lock files

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix build-extensions picking wrong @types/react

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix tests from rebase

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix type error

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Make KubeconfigSyncManager more injectable

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix crash in test mode for Dialog

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* make Select snapshots deterministic

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix new type error

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix kube-object.store.test.ts typing

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix merge build issues

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix snapshots after merge

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix lint after merge

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* reexport BaseKubeJsonApiObjectMetadata

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix typo in terminalSpawningPool

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove duplicate license header

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix typo to waitUntilDefined

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove iter use from getLegacyGlobalDiForExtensionApi

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove complex createStorage override

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* override logger with mocks only when needed for tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove specialized overrideStore flags for getDiForUnitTesting

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove unnecessary | undefined types from the exactOptionalFieldTypes experiment

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* use more descriptive name for local test mocks

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove unnecessary addition to 'make clean' target

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove oddity of KubeObjectStore.getById(undefined) being allowed

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* rename KubeObject.getDescriptor in favour of name without fundemental JS meaning

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Simplify legacyRegisterApi when working in behaviour unit tests

- Don't emit within main environment as there should be no auto
  registering there

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* change confusing variable name in ReactiveDuration

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* make visitor pattern more explicit for Entity contextMenuOpen

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* toggleDetails -> toggleKubeDetailsPane is more specific

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* remove outdated comment

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix bug where LensExtension dependencies are not set

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix tests from the revert of react 18

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix more tests from merge

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix typings with new is-compatible-extension tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* more type fixing

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Revert in-tree extension versions

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Improve name of guarding injectable for stores and apis

- New name better implies that it is just a guard state and does not do
  anything

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Add helper for <Select>.isMulti for storing in a Set<Value>

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix is-compatible-extension.test.ts types

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton authored and GitHub committed 2022-05-16 07:17:57 -04:00
1 parent 381d77c633
commit dfcb7c3330
1062 files changed
+26512 -28689

No files matched your search

+14 -34
View File
@@ -3,32 +3,6 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
const logger = {
silly: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
info: jest.fn(),
error: jest.fn(),
crit: jest.fn(),
};
jest.mock("winston", () => ({
format: {
colorize: jest.fn(),
combine: jest.fn(),
simple: jest.fn(),
label: jest.fn(),
timestamp: jest.fn(),
printf: jest.fn(),
splat: jest.fn(),
},
createLogger: jest.fn().mockReturnValue(logger),
transports: {
Console: jest.fn(),
File: jest.fn(),
},
}));
jest.mock("../../common/ipc");
jest.mock("request");
jest.mock("request-promise-native");
@@ -43,6 +17,8 @@ import { createClusterInjectionToken } from "../../common/cluster/create-cluster
import authorizationReviewInjectable from "../../common/cluster/authorization-review.injectable";
import listNamespacesInjectable from "../../common/cluster/list-namespaces.injectable";
import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable";
import type { ClusterContextHandler } from "../context-handler/context-handler";
import { parse } from "url";
console = new Console(process.stdout, process.stderr); // fix mockFS
@@ -83,9 +59,17 @@ describe("create clusters", () => {
di.override(authorizationReviewInjectable, () => () => () => Promise.resolve(true));
di.override(listNamespacesInjectable, () => () => () => Promise.resolve([ "default" ]));
di.override(createContextHandlerInjectable, () => () => {
throw new Error("you should never come here");
});
di.override(createContextHandlerInjectable, () => (cluster) => ({
restartServer: jest.fn(),
stopServer: jest.fn(),
clusterUrl: parse(cluster.apiUrl),
getApiTarget: jest.fn(),
getPrometheusDetails: jest.fn(),
resolveAuthProxyCa: jest.fn(),
resolveAuthProxyUrl: jest.fn(),
setupPrometheus: jest.fn(),
ensureServer: jest.fn(),
} as ClusterContextHandler));
createCluster = di.inject(createClusterInjectionToken);
@@ -99,6 +83,7 @@ describe("create clusters", () => {
});
afterEach(() => {
cluster.disconnect();
mockFs.restore();
});
@@ -121,11 +106,6 @@ describe("create clusters", () => {
kubeConfigPath: "minikube-config.yml",
});
cluster.contextHandler = {
ensureServer: jest.fn(),
stopServer: jest.fn(),
} as any;
jest.spyOn(cluster, "reconnect");
jest.spyOn(cluster, "refreshConnectionStatus");
+11 -11
View File
@@ -4,7 +4,7 @@
*/
import { UserStore } from "../../common/user-store";
import type { ContextHandler } from "../context-handler/context-handler";
import type { ClusterContextHandler } from "../context-handler/context-handler";
import type { PrometheusService } from "../prometheus";
import { PrometheusProvider, PrometheusProviderRegistry } from "../prometheus";
import mockFs from "mock-fs";
@@ -48,7 +48,7 @@ class TestProvider extends PrometheusProvider {
throw new Error("getQuery is not implemented.");
}
async getPrometheusService(): Promise<PrometheusService> {
async getPrometheusService(): Promise<PrometheusService | undefined> {
switch (this.alwaysFail) {
case ServiceResult.Success:
return {
@@ -66,14 +66,14 @@ class TestProvider extends PrometheusProvider {
}
const clusterStub = {
getProxyKubeconfig: (): any => ({
makeApiClient: (): any => undefined,
getProxyKubeconfig: () => ({
makeApiClient: (): void => undefined,
}),
apiUrl: "http://localhost:81",
} as Cluster;
} as unknown as Cluster;
describe("ContextHandler", () => {
let createContextHandler: (cluster: Cluster) => ContextHandler;
let createContextHandler: (cluster: Cluster) => ClusterContextHandler | undefined;
beforeEach(async () => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
@@ -119,7 +119,7 @@ describe("ContextHandler", () => {
expect(() => {
// TODO: Unit test shouldn't access protected or private methods
const contextHandler = createContextHandler(clusterStub) as any;
const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise<PrometheusService> };
return contextHandler.getPrometheusService();
}).rejects.toBeDefined();
@@ -149,7 +149,7 @@ describe("ContextHandler", () => {
}
// TODO: Unit test shouldn't access protected or private methods
const contextHandler = createContextHandler(clusterStub) as any;
const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise<PrometheusService> };
const service = await contextHandler.getPrometheusService();
@@ -180,7 +180,7 @@ describe("ContextHandler", () => {
}
// TODO: Unit test shouldn't access protected or private methods
const contextHandler = createContextHandler(clusterStub) as any;
const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise<PrometheusService> };
const service = await contextHandler.getPrometheusService();
@@ -217,7 +217,7 @@ describe("ContextHandler", () => {
}
// TODO: Unit test shouldn't access protected or private methods
const contextHandler = createContextHandler(clusterStub) as any;
const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise<PrometheusService> };
const service = await contextHandler.getPrometheusService();
@@ -233,7 +233,7 @@ describe("ContextHandler", () => {
reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success));
// TODO: Unit test shouldn't access protected or private methods
const contextHandler = createContextHandler(clusterStub) as any;
const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise<PrometheusService> };
const service = await contextHandler.getPrometheusService();
+33 -33
View File
@@ -142,69 +142,69 @@ describe("kube auth proxy tests", () => {
beforeEach(async () => {
mockedCP = mock<ChildProcess>();
listeners = new EventEmitter();
const stderr = mockedCP.stderr = mock<Readable>();
const stdout = mockedCP.stdout = mock<Readable>();
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 => {
mockedCP.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): ChildProcess => {
listeners.on(event, listener);
return mockedCP;
});
mockedCP.stderr = mock<Readable>();
mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.on(`stderr/${event}`, listener);
mockedCP.stderr.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.on(`stderr/${String(event)}`, listener);
return mockedCP.stderr;
return stderr;
});
mockedCP.stderr.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stderr/${event}`, listener);
mockedCP.stderr.off.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stderr/${String(event)}`, listener);
return mockedCP.stderr;
return stderr;
});
mockedCP.stderr.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stderr/${event}`, listener);
mockedCP.stderr.removeListener.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stderr/${String(event)}`, listener);
return mockedCP.stderr;
return stderr;
});
mockedCP.stderr.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.once(`stderr/${event}`, listener);
mockedCP.stderr.once.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.once(`stderr/${String(event)}`, listener);
return mockedCP.stderr;
return stderr;
});
mockedCP.stderr.removeAllListeners.mockImplementation((event?: string): Readable => {
listeners.removeAllListeners(event ?? `stderr/${event}`);
mockedCP.stderr.removeAllListeners.mockImplementation((event?: string | symbol): Readable => {
listeners.removeAllListeners(event ?? `stderr/${String(event)}`);
return mockedCP.stderr;
return stderr;
});
mockedCP.stdout = mock<Readable>();
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.on(`stdout/${event}`, listener);
mockedCP.stdout.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.on(`stdout/${String(event)}`, listener);
if (event === "data") {
listeners.emit("stdout/data", "Starting to serve on 127.0.0.1:9191");
}
return mockedCP.stdout;
return stdout;
});
mockedCP.stdout.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.once(`stdout/${event}`, listener);
mockedCP.stdout.once.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.once(`stdout/${String(event)}`, listener);
return mockedCP.stdout;
return stdout;
});
mockedCP.stdout.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stdout/${event}`, listener);
mockedCP.stdout.off.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stdout/${String(event)}`, listener);
return mockedCP.stdout;
return stdout;
});
mockedCP.stdout.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stdout/${event}`, listener);
mockedCP.stdout.removeListener.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stdout/${String(event)}`, listener);
return mockedCP.stdout;
return stdout;
});
mockedCP.stdout.removeAllListeners.mockImplementation((event?: string): Readable => {
listeners.removeAllListeners(event ?? `stdout/${event}`);
mockedCP.stdout.removeAllListeners.mockImplementation((event?: string | symbol): Readable => {
listeners.removeAllListeners(event ?? `stdout/${String(event)}`);
return mockedCP.stdout;
return stdout;
});
mockSpawn.mockImplementationOnce((command: string): ChildProcess => {
expect(path.basename(command).split(".")[0]).toBe("lens-k8s-proxy");
+33 -37
View File
@@ -2,33 +2,6 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
const logger = {
silly: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
info: jest.fn(),
error: jest.fn(),
crit: jest.fn(),
};
jest.mock("winston", () => ({
format: {
colorize: jest.fn(),
combine: jest.fn(),
simple: jest.fn(),
label: jest.fn(),
timestamp: jest.fn(),
padLevels: jest.fn(),
ms: jest.fn(),
printf: jest.fn(),
splat: jest.fn(),
},
createLogger: jest.fn().mockReturnValue(logger),
transports: {
Console: jest.fn(),
File: jest.fn(),
},
}));
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager";
@@ -43,19 +16,34 @@ import { createClusterInjectionToken } from "../../common/cluster/create-cluster
import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable";
import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable";
import type { DiContainer } from "@ogre-tools/injectable";
import { parse } from "url";
import loggerInjectable from "../../common/logger.injectable";
import type { Logger } from "../../common/logger";
import assert from "assert";
console = new Console(process.stdout, process.stderr); // fix mockFS
describe("kubeconfig manager tests", () => {
let clusterFake: Cluster;
let createKubeconfigManager: (cluster: Cluster) => KubeconfigManager;
let di: DiContainer;
let createKubeconfigManager: (cluster: Cluster) => KubeconfigManager | undefined;
let di: DiContainer;
let loggerMock: jest.Mocked<Logger>;
beforeEach(async () => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(directoryForTempInjectable, () => "some-directory-for-temp");
loggerMock = {
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
silly: jest.fn(),
};
di.override(loggerInjectable, () => loggerMock);
mockFs({
"minikube-config.yml": JSON.stringify({
apiVersion: "v1",
@@ -82,9 +70,17 @@ describe("kubeconfig manager tests", () => {
await di.runSetups();
di.override(createContextHandlerInjectable, () => () => {
throw new Error("you should never come here");
});
di.override(createContextHandlerInjectable, () => (cluster) => ({
restartServer: jest.fn(),
stopServer: jest.fn(),
clusterUrl: parse(cluster.apiUrl),
getApiTarget: jest.fn(),
getPrometheusDetails: jest.fn(),
resolveAuthProxyCa: jest.fn(),
resolveAuthProxyUrl: jest.fn(),
setupPrometheus: jest.fn(),
ensureServer: jest.fn(),
}));
const createCluster = di.inject(createClusterInjectionToken);
@@ -96,10 +92,6 @@ describe("kubeconfig manager tests", () => {
kubeConfigPath: "minikube-config.yml",
});
clusterFake.contextHandler = {
ensureServer: () => Promise.resolve(),
} as any;
jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo");
});
@@ -110,7 +102,9 @@ describe("kubeconfig manager tests", () => {
it("should create 'temp' kube config with proxy", async () => {
const kubeConfManager = createKubeconfigManager(clusterFake);
expect(logger.error).not.toBeCalled();
assert(kubeConfManager, "should actually create one");
expect(loggerMock.error).not.toBeCalled();
expect(await kubeConfManager.getPath()).toBe(`some-directory-for-temp${path.sep}kubeconfig-foo`);
// this causes an intermittent "ENXIO: no such device or address, read" error
// const file = await fse.readFile(await kubeConfManager.getPath());
@@ -125,6 +119,8 @@ describe("kubeconfig manager tests", () => {
it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => {
const kubeConfManager = createKubeconfigManager(clusterFake);
assert(kubeConfManager, "should actually create one");
const configPath = await kubeConfManager.getPath();
expect(await fse.pathExists(configPath)).toBe(true);
-4
View File
@@ -3,10 +3,6 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* @jest-environment jsdom
*/
import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars";
describe("clearKubeconfigEnvVars tests", () => {
+4 -4
View File
@@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { LensApiRequest, Route } from "../router/router";
import type { LensApiRequest, Route } from "../router/route";
import staticFileRouteInjectable from "../routes/static-file-route.injectable";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
@@ -24,7 +24,7 @@ jest.mock("electron", () => ({
}));
describe("static-file-route", () => {
let handleStaticFileRoute: Route<Buffer>;
let handleStaticFileRoute: Route<Buffer, "/{path*}">;
beforeEach(async () => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
@@ -40,7 +40,7 @@ describe("static-file-route", () => {
path: "../index.ts",
},
raw: {},
} as LensApiRequest<any>;
} as LensApiRequest<"/{path*}">;
const result = await handleStaticFileRoute.handler(request);
@@ -57,7 +57,7 @@ describe("static-file-route", () => {
path: "router.test.ts",
},
raw: { req },
} as LensApiRequest<any>;
} as LensApiRequest<"/{path*}">;
const result = await handleStaticFileRoute.handler(request);
@@ -9,8 +9,9 @@ import { getElectronAppPath } from "./get-electron-app-path";
const getElectronAppPathInjectable = getInjectable({
id: "get-electron-app-path",
instantiate: (di) =>
getElectronAppPath({ app: di.inject(electronAppInjectable) }),
instantiate: (di) => getElectronAppPath({
app: di.inject(electronAppInjectable),
}),
});
export default getElectronAppPathInjectable;
@@ -11,7 +11,7 @@ import joinPathsInjectable from "../../../common/path/join-paths.injectable";
import { joinPathsFake } from "../../../common/test-utils/join-paths-fake";
describe("get-electron-app-path", () => {
let getElectronAppPath: (name: string) => string | null;
let getElectronAppPath: (name: string) => string;
beforeEach(async () => {
const di = getDiForUnitTesting({ doGeneralOverrides: false });
@@ -37,7 +37,7 @@ describe("get-electron-app-path", () => {
await di.runSetups();
getElectronAppPath = di.inject(getElectronAppPathInjectable);
getElectronAppPath = di.inject(getElectronAppPathInjectable) as (name: string) => string;
});
it("given app path exists, when called, returns app path", () => {
@@ -9,12 +9,16 @@ interface Dependencies {
app: App;
}
export const getElectronAppPath =
({ app }: Dependencies) =>
(name: PathName) : string | null => {
try {
return app.getPath(name);
} catch (e) {
return "";
}
};
export type GetElectronAppPath = (name: PathName) => string;
export const getElectronAppPath = ({
app,
}: Dependencies): GetElectronAppPath => (
(name) => {
try {
return app.getPath(name);
} catch (e) {
return "";
}
}
);
+1 -1
View File
@@ -15,7 +15,7 @@ import { ipcMain } from "electron";
import { nextUpdateChannel } from "./utils/update-channel";
import { UserStore } from "../common/user-store";
let installVersion: null | string = null;
let installVersion: undefined | string;
export function isAutoUpdateEnabled() {
return autoUpdater.isUpdaterActive() && isPublishConfigured;
@@ -7,7 +7,7 @@ import { ObservableMap } from "mobx";
import type { CatalogEntity } from "../../../common/catalog";
import { loadFromOptions } from "../../../common/kube-helpers";
import type { Cluster } from "../../../common/cluster/cluster";
import { computeDiff as computeDiffFor, configToModels } from "../kubeconfig-sync-manager/kubeconfig-sync-manager";
import { computeDiff as computeDiffFor, configToModels } from "../kubeconfig-sync/manager";
import mockFs from "mock-fs";
import fs from "fs";
import { ClusterManager } from "../../cluster-manager";
@@ -53,7 +53,7 @@ describe("kubeconfig-sync.source tests", () => {
});
di.override(clusterStoreInjectable, () =>
ClusterStore.createInstance({ createCluster: () => null }),
ClusterStore.createInstance({ createCluster: () => null as never }),
);
di.permitSideEffects(getConfigurationFileModelInjectable);
@@ -4,8 +4,9 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable";
import { KubeconfigSyncManager } from "./kubeconfig-sync-manager";
import { KubeconfigSyncManager } from "./manager";
import { createClusterInjectionToken } from "../../../common/cluster/create-cluster-injection-token";
import catalogEntityRegistryInjectable from "../../catalog/entity-registry.injectable";
const kubeconfigSyncManagerInjectable = getInjectable({
id: "kubeconfig-sync-manager",
@@ -13,6 +14,7 @@ const kubeconfigSyncManagerInjectable = getInjectable({
instantiate: (di) => new KubeconfigSyncManager({
directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable),
createCluster: di.inject(createClusterInjectionToken),
entityRegistry: di.inject(catalogEntityRegistryInjectable),
}),
});
@@ -6,9 +6,9 @@
import type { IComputedValue, ObservableMap } from "mobx";
import { action, observable, computed, runInAction, makeObservable, observe } from "mobx";
import type { CatalogEntity } from "../../../common/catalog";
import { catalogEntityRegistry } from "../../catalog";
import type { FSWatcher } from "chokidar";
import { watch } from "chokidar";
import type { Stats } from "fs";
import fs from "fs";
import path from "path";
import type stream from "stream";
@@ -26,6 +26,7 @@ import globToRegExp from "glob-to-regexp";
import { inspect } from "util";
import type { ClusterModel, UpdateClusterModel } from "../../../common/cluster-types";
import type { Cluster } from "../../../common/cluster/cluster";
import type { CatalogEntityRegistry } from "../../catalog/entity-registry";
const logPrefix = "[KUBECONFIG-SYNC]:";
@@ -51,18 +52,19 @@ const folderSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB
const fileSyncMaxAllowedFileReadSize = 16 * folderSyncMaxAllowedFileReadSize; // 32 MiB
interface Dependencies {
directoryForKubeConfigs: string;
readonly directoryForKubeConfigs: string;
readonly entityRegistry: CatalogEntityRegistry;
createCluster: (model: ClusterModel) => Cluster;
}
const kubeConfigSyncName = "lens:kube-sync";
export class KubeconfigSyncManager {
protected sources = observable.map<string, [IComputedValue<CatalogEntity[]>, Disposer]>();
protected readonly sources = observable.map<string, [IComputedValue<CatalogEntity[]>, Disposer]>();
protected syncing = false;
protected syncListDisposer?: Disposer;
constructor(private dependencies: Dependencies) {
constructor(protected readonly dependencies: Dependencies) {
makeObservable(this);
}
@@ -76,7 +78,7 @@ export class KubeconfigSyncManager {
logger.info(`${logPrefix} starting requested syncs`);
catalogEntityRegistry.addComputedSource(kubeConfigSyncName, computed(() => (
this.dependencies.entityRegistry.addComputedSource(kubeConfigSyncName, computed(() => (
Array.from(iter.flatMap(
this.sources.values(),
([entities]) => entities.get(),
@@ -110,7 +112,7 @@ export class KubeconfigSyncManager {
this.stopOldSync(filePath);
}
catalogEntityRegistry.removeSource(kubeConfigSyncName);
this.dependencies.entityRegistry.removeSource(kubeConfigSyncName);
this.syncing = false;
}
@@ -164,7 +166,7 @@ type RootSourceValue = [Cluster, CatalogEntity];
type RootSource = ObservableMap<string, RootSourceValue>;
// exported for testing
export const computeDiff = ({ directoryForKubeConfigs, createCluster }: Dependencies) => (contents: string, source: RootSource, filePath: string): void => {
export const computeDiff = ({ directoryForKubeConfigs, createCluster }: Pick<Dependencies, "createCluster" | "directoryForKubeConfigs">) => (contents: string, source: RootSource, filePath: string): void => {
runInAction(() => {
try {
const { config, error } = loadConfigFromString(contents);
@@ -226,7 +228,6 @@ export const computeDiff = ({ directoryForKubeConfigs, createCluster }: Dependen
}
}
} catch (error) {
console.log(error);
logger.warn(`${logPrefix} Failed to compute diff: ${error}`, { filePath });
source.clear(); // clear source if we have failed so as to not show outdated information
}
@@ -322,12 +323,13 @@ const watchFileChanges = (filePath: string, dependencies: Dependencies): [ICompu
stabilityThreshold: 1000,
},
atomic: 150, // for "atomic writes"
alwaysStat: true,
});
const diffChangedConfig = diffChangedConfigFor(dependencies);
watcher
.on("change", (childFilePath, stats) => {
.on("change", (childFilePath, stats: Stats): void => {
const cleanup = cleanupFns.get(childFilePath);
if (!cleanup) {
@@ -343,7 +345,7 @@ const watchFileChanges = (filePath: string, dependencies: Dependencies): [ICompu
maxAllowedFileReadSize,
}));
})
.on("add", (childFilePath, stats) => {
.on("add", (childFilePath, stats: Stats): void => {
if (isFolderSync) {
const fileName = path.basename(childFilePath);
@@ -368,7 +370,7 @@ const watchFileChanges = (filePath: string, dependencies: Dependencies): [ICompu
})
.on("error", error => logger.error(`${logPrefix} watching file/folder failed: ${error}`, { filePath }));
} catch (error) {
console.log(error.stack);
console.log((error as { stack: unknown }).stack);
logger.warn(`${logPrefix} failed to start watching changes: ${error}`);
}
})();
@@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import catalogEntityRegistryInjectable from "../catalog/catalog-entity-registry.injectable";
import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable";
import { generalCatalogEntityInjectionToken } from "../../common/catalog-entities/general-catalog-entities/general-catalog-entity-injection-token";
import { computed } from "mobx";
@@ -7,8 +7,10 @@ import { observable, reaction } from "mobx";
import type { WebLinkSpec, WebLinkStatus } from "../../../common/catalog-entities";
import { WebLink } from "../../../common/catalog-entities";
import type { CatalogEntityMetadata } from "../../../common/catalog";
import { catalogCategoryRegistry, CatalogEntity } from "../../../common/catalog";
import { CatalogEntityRegistry } from "../catalog-entity-registry";
import { CatalogEntity } from "../../../common/catalog";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import catalogEntityRegistryInjectable from "../entity-registry.injectable";
import type { CatalogEntityRegistry } from "../entity-registry";
class InvalidEntity extends CatalogEntity<CatalogEntityMetadata, WebLinkStatus, WebLinkSpec> {
public readonly apiVersion = "entity.k8slens.dev/v1alpha1";
@@ -32,7 +34,7 @@ class InvalidEntity extends CatalogEntity<CatalogEntityMetadata, WebLinkStatus,
}
describe("CatalogEntityRegistry", () => {
let registry: CatalogEntityRegistry;
let entityRegistry: CatalogEntityRegistry;
const entity = new WebLink({
metadata: {
uid: "test",
@@ -63,26 +65,28 @@ describe("CatalogEntityRegistry", () => {
});
beforeEach(() => {
registry = new CatalogEntityRegistry(catalogCategoryRegistry);
const di = getDiForUnitTesting({ doGeneralOverrides: true });
entityRegistry = di.inject(catalogEntityRegistryInjectable);
});
describe("addSource", () => {
it ("allows to add an observable source", () => {
const source = observable.array([]);
const source = observable.array<WebLink>([]);
registry.addObservableSource("test", source);
expect(registry.items.length).toEqual(0);
entityRegistry.addObservableSource("test", source);
expect(entityRegistry.items.length).toEqual(0);
source.push(entity);
expect(registry.items.length).toEqual(1);
expect(entityRegistry.items.length).toEqual(1);
});
it ("added source change triggers reaction", (done) => {
const source = observable.array([]);
const source = observable.array<WebLink>([]);
registry.addObservableSource("test", source);
reaction(() => registry.items, () => {
entityRegistry.addObservableSource("test", source);
reaction(() => entityRegistry.items, () => {
done();
});
@@ -92,31 +96,31 @@ describe("CatalogEntityRegistry", () => {
describe("removeSource", () => {
it ("removes source", () => {
const source = observable.array([]);
const source = observable.array<WebLink>([]);
registry.addObservableSource("test", source);
entityRegistry.addObservableSource("test", source);
source.push(entity);
registry.removeSource("test");
entityRegistry.removeSource("test");
expect(registry.items.length).toEqual(0);
expect(entityRegistry.items.length).toEqual(0);
});
});
describe("items", () => {
it("returns added items", () => {
expect(registry.items.length).toBe(0);
expect(entityRegistry.items.length).toBe(0);
const source = observable.array([entity]);
registry.addObservableSource("test", source);
expect(registry.items.length).toBe(1);
entityRegistry.addObservableSource("test", source);
expect(entityRegistry.items.length).toBe(1);
});
it("does not return items without matching category", () => {
const source = observable.array([invalidEntity]);
registry.addObservableSource("test", source);
expect(registry.items.length).toBe(0);
entityRegistry.addObservableSource("test", source);
expect(entityRegistry.items.length).toBe(0);
});
});
});
+6 -46
View File
@@ -3,50 +3,10 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { action, computed, type IComputedValue, type IObservableArray, makeObservable, observable } from "mobx";
import type { CatalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor } from "../../common/catalog";
import { catalogCategoryRegistry } from "../../common/catalog";
import { iter } from "../../common/utils";
import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import catalogEntityRegistryInjectable from "./entity-registry.injectable";
export class CatalogEntityRegistry {
protected sources = observable.map<string, IComputedValue<CatalogEntity[]>>();
constructor(private categoryRegistry: CatalogCategoryRegistry) {
makeObservable(this);
}
@action addObservableSource(id: string, source: IObservableArray<CatalogEntity>) {
this.sources.set(id, computed(() => source));
}
@action addComputedSource(id: string, source: IComputedValue<CatalogEntity[]>) {
this.sources.set(id, source);
}
@action removeSource(id: string) {
this.sources.delete(id);
}
@computed get items(): CatalogEntity[] {
return Array.from(
iter.filter(
iter.flatMap(this.sources.values(), source => source.get()),
entity => this.categoryRegistry.getCategoryForEntity(entity),
),
);
}
getById<T extends CatalogEntity>(id: string): T | undefined {
return this.items.find(entity => entity.getId() === id) as T | undefined;
}
getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {
return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[];
}
getItemsByEntityClass<T extends CatalogEntity>(constructor: CatalogEntityConstructor<T>): T[] {
return this.items.filter((item) => item instanceof constructor) as T[];
}
}
export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
/**
* @deprecated use `di.inject(catalogEntityRegistryInjectable)` instead
*/
export const catalogEntityRegistry = asLegacyGlobalForExtensionApi(catalogEntityRegistryInjectable);
@@ -3,12 +3,14 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { catalogEntityRegistry } from "./catalog-entity-registry";
import hasCategoryForEntityInjectable from "../../common/catalog/has-category-for-entity.injectable";
import { CatalogEntityRegistry } from "./entity-registry";
const catalogEntityRegistryInjectable = getInjectable({
id: "catalog-entity-registry",
instantiate: () => catalogEntityRegistry,
causesSideEffects: true,
instantiate: (di) => new CatalogEntityRegistry({
hasCategoryForEntity: di.inject(hasCategoryForEntityInjectable),
}),
});
export default catalogEntityRegistryInjectable;
+54
View File
@@ -0,0 +1,54 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { action, computed, type IComputedValue, type IObservableArray, makeObservable, observable } from "mobx";
import type { CatalogEntity } from "../../common/catalog";
import type { HasCategoryForEntity } from "../../common/catalog/has-category-for-entity.injectable";
import { iter } from "../../common/utils";
interface Dependencies {
readonly hasCategoryForEntity: HasCategoryForEntity;
}
export class CatalogEntityRegistry {
protected sources = observable.map<string, IComputedValue<CatalogEntity[]>>();
constructor(protected readonly dependencies: Dependencies) {
makeObservable(this);
}
@action addObservableSource(id: string, source: IObservableArray<CatalogEntity>) {
this.sources.set(id, computed(() => source));
}
@action addComputedSource(id: string, source: IComputedValue<CatalogEntity[]>) {
this.sources.set(id, source);
}
@action removeSource(id: string) {
this.sources.delete(id);
}
@computed get items(): CatalogEntity[] {
return Array.from(
iter.filter(
iter.flatMap(this.sources.values(), source => source.get()),
entity => this.dependencies.hasCategoryForEntity(entity),
),
);
}
findById(id: string): CatalogEntity | undefined {
return this.items.find(entity => entity.getId() === id);
}
filterItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[] {
return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind);
}
filterItemsByPredicate<E extends CatalogEntity>(filter: (item: CatalogEntity) => item is E): E[] {
return this.items.filter(filter);
}
}
+1
View File
@@ -4,3 +4,4 @@
*/
export * from "./catalog-entity-registry";
export * from "./entity-registry";
+3 -3
View File
@@ -5,12 +5,12 @@
import { getInjectable } from "@ogre-tools/injectable";
import { spawn } from "child_process";
export type Spawn = typeof spawn;
const spawnInjectable = getInjectable({
id: "spawn",
instantiate: () => {
return spawn;
},
instantiate: (): Spawn => spawn,
causesSideEffects: true,
});
@@ -12,15 +12,12 @@ export interface ClusterDetectionResult {
accuracy: number;
}
export class BaseClusterDetector {
key: string;
export abstract class BaseClusterDetector {
abstract readonly key: string;
constructor(public cluster: Cluster) {
}
constructor(public readonly cluster: Cluster) {}
detect(): Promise<ClusterDetectionResult> {
return null;
}
abstract detect(): Promise<ClusterDetectionResult | null>;
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
return k8sRequest(this.cluster, path, options);
@@ -9,10 +9,12 @@ import { Singleton } from "../../common/utils";
import type { Cluster } from "../../common/cluster/cluster";
import type { BaseClusterDetector, ClusterDetectionResult } from "./base-cluster-detector";
export class DetectorRegistry extends Singleton {
registry = observable.array<typeof BaseClusterDetector>([], { deep: false });
export type DetectorConstructor = new (cluster: Cluster) => BaseClusterDetector;
add(detectorClass: typeof BaseClusterDetector): this {
export class DetectorRegistry extends Singleton {
registry = observable.array<DetectorConstructor>([], { deep: false });
add(detectorClass: DetectorConstructor): this {
this.registry.push(detectorClass);
return this;
@@ -8,12 +8,11 @@ import { ClusterMetadataKey } from "../../common/cluster-types";
export class DistributionDetector extends BaseClusterDetector {
key = ClusterMetadataKey.DISTRIBUTION;
version: string;
public async detect() {
this.version = await this.getKubernetesVersion();
const version = await this.getKubernetesVersion();
if (this.isRke()) {
if (this.isRke(version)) {
return { value: "rke", accuracy: 80 };
}
@@ -21,19 +20,19 @@ export class DistributionDetector extends BaseClusterDetector {
return { value: "rancher-desktop", accuracy: 80 };
}
if (this.isK3s()) {
if (this.isK3s(version)) {
return { value: "k3s", accuracy: 80 };
}
if (this.isGKE()) {
if (this.isGKE(version)) {
return { value: "gke", accuracy: 80 };
}
if (this.isEKS()) {
if (this.isEKS(version)) {
return { value: "eks", accuracy: 80 };
}
if (this.isIKS()) {
if (this.isIKS(version)) {
return { value: "iks", accuracy: 80 };
}
@@ -45,27 +44,27 @@ export class DistributionDetector extends BaseClusterDetector {
return { value: "digitalocean", accuracy: 90 };
}
if (this.isK0s()) {
if (this.isK0s(version)) {
return { value: "k0s", accuracy: 80 };
}
if (this.isVMWare()) {
if (this.isVMWare(version)) {
return { value: "vmware", accuracy: 90 };
}
if (this.isMirantis()) {
if (this.isMirantis(version)) {
return { value: "mirantis", accuracy: 90 };
}
if (this.isAlibaba()) {
if (this.isAlibaba(version)) {
return { value: "alibaba", accuracy: 90 };
}
if (this.isHuawei()) {
if (this.isHuawei(version)) {
return { value: "huawei", accuracy: 90 };
}
if (this.isTke()) {
if (this.isTke(version)) {
return { value: "tencent", accuracy: 90 };
}
@@ -85,11 +84,11 @@ export class DistributionDetector extends BaseClusterDetector {
return { value: "docker-desktop", accuracy: 80 };
}
if (this.isCustom() && await this.isOpenshift()) {
if (this.isCustom(version) && await this.isOpenshift()) {
return { value: "openshift", accuracy: 90 };
}
if (this.isCustom()) {
if (this.isCustom(version)) {
return { value: "custom", accuracy: 10 };
}
@@ -104,24 +103,24 @@ export class DistributionDetector extends BaseClusterDetector {
return response.gitVersion;
}
protected isGKE() {
return this.version.includes("gke");
protected isGKE(version: string) {
return version.includes("gke");
}
protected isEKS() {
return this.version.includes("eks");
protected isEKS(version: string) {
return version.includes("eks");
}
protected isIKS() {
return this.version.includes("IKS");
protected isIKS(version: string) {
return version.includes("IKS");
}
protected isAKS() {
return this.cluster.apiUrl.includes("azmk8s.io");
}
protected isMirantis() {
return this.version.includes("-mirantis-") || this.version.includes("-docker-");
protected isMirantis(version: string) {
return version.includes("-mirantis-") || version.includes("-docker-");
}
protected isDigitalOcean() {
@@ -144,40 +143,40 @@ export class DistributionDetector extends BaseClusterDetector {
return this.cluster.contextName === "docker-desktop";
}
protected isTke() {
return this.version.includes("-tke.");
protected isTke(version: string) {
return version.includes("-tke.");
}
protected isCustom() {
return this.version.includes("+");
protected isCustom(version: string) {
return version.includes("+");
}
protected isVMWare() {
return this.version.includes("+vmware");
protected isVMWare(version: string) {
return version.includes("+vmware");
}
protected isRke() {
return this.version.includes("-rancher");
protected isRke(version: string) {
return version.includes("-rancher");
}
protected isRancherDesktop() {
return this.cluster.contextName === "rancher-desktop";
}
protected isK3s() {
return this.version.includes("+k3s");
protected isK3s(version: string) {
return version.includes("+k3s");
}
protected isK0s() {
return this.version.includes("-k0s") || this.version.includes("+k0s");
protected isK0s(version: string) {
return version.includes("-k0s") || version.includes("+k0s");
}
protected isAlibaba() {
return this.version.includes("-aliyun");
protected isAlibaba(version: string) {
return version.includes("-aliyun");
}
protected isHuawei() {
return this.version.includes("-CCE");
protected isHuawei(version: string) {
return version.includes("-CCE");
}
protected async isOpenshift() {
@@ -8,7 +8,6 @@ import { ClusterMetadataKey } from "../../common/cluster-types";
export class VersionDetector extends BaseClusterDetector {
key = ClusterMetadataKey.VERSION;
value: string;
public async detect() {
const version = await this.getKubernetesVersion();
+13 -9
View File
@@ -9,10 +9,10 @@ import { action, makeObservable, observable, observe, reaction, toJS } from "mob
import type { Cluster } from "../common/cluster/cluster";
import logger from "./logger";
import { apiKubePrefix } from "../common/vars";
import { getClusterIdFromHost, Singleton } from "../common/utils";
import { getClusterIdFromHost, isErrnoException, Singleton } from "../common/utils";
import { catalogEntityRegistry } from "./catalog";
import type { KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster";
import { KubernetesCluster, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster";
import { isKubernetesCluster, KubernetesCluster, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster";
import { ipcMainOn } from "../common/ipc";
import { once } from "lodash";
import { ClusterStore } from "../common/cluster-store/cluster-store";
@@ -49,12 +49,12 @@ export class ClusterManager extends Singleton {
);
reaction(
() => catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster) as KubernetesCluster[],
() => catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster),
entities => this.syncClustersFromCatalog(entities),
);
reaction(() => [
catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster),
catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster),
this.visibleCluster,
] as const, ([entities, visibleCluster]) => {
for (const entity of entities) {
@@ -68,7 +68,7 @@ export class ClusterManager extends Singleton {
observe(this.deleting, change => {
if (change.type === "add") {
this.updateEntityStatus(catalogEntityRegistry.getById(change.newValue));
this.updateEntityStatus(catalogEntityRegistry.findById(change.newValue) as KubernetesCluster);
}
});
@@ -197,7 +197,7 @@ export class ClusterManager extends Singleton {
*/
this.store.addCluster(model);
} catch (error) {
if (error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) {
if (isErrnoException(error) && error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) {
logger.warn(`${logPrefix} kubeconfig file disappeared`, model);
} else {
logger.error(`${logPrefix} failed to add cluster: ${error}`, model);
@@ -207,7 +207,7 @@ export class ClusterManager extends Singleton {
cluster.kubeConfigPath = entity.spec.kubeconfigPath;
cluster.contextName = entity.spec.kubeconfigContext;
if (entity.spec.accessibleNamespace) {
if (entity.spec.accessibleNamespaces) {
cluster.accessibleNamespaces = entity.spec.accessibleNamespaces;
}
@@ -258,9 +258,13 @@ export class ClusterManager extends Singleton {
});
}
getClusterForRequest(req: http.IncomingMessage): Cluster {
getClusterForRequest(req: http.IncomingMessage): Cluster | undefined {
if (!req.headers.host) {
return undefined;
}
// lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) {
if (req.url && req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1];
const cluster = this.store.getById(clusterId);
+44 -16
View File
@@ -32,8 +32,20 @@ interface Dependencies {
authProxyCa: string;
}
export class ContextHandler {
public clusterUrl: UrlWithStringQuery;
export interface ClusterContextHandler {
readonly clusterUrl: UrlWithStringQuery;
setupPrometheus(preferences?: ClusterPrometheusPreferences): void;
getPrometheusDetails(): Promise<PrometheusDetails>;
resolveAuthProxyUrl(): Promise<string>;
resolveAuthProxyCa(): string;
getApiTarget(isLongRunningRequest?: boolean): Promise<httpProxy.ServerOptions>;
restartServer(): Promise<void>;
ensureServer(): Promise<void>;
stopServer(): void;
}
export class ContextHandler implements ClusterContextHandler {
public readonly clusterUrl: UrlWithStringQuery;
protected kubeAuthProxy?: KubeAuthProxy;
protected apiTarget?: httpProxy.ServerOptions;
protected prometheusProvider?: string;
@@ -46,7 +58,7 @@ export class ContextHandler {
public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) {
this.prometheusProvider = preferences.prometheusProvider?.type;
this.prometheus = preferences.prometheus || null;
this.prometheus = preferences.prometheus;
}
public async getPrometheusDetails(): Promise<PrometheusDetails> {
@@ -82,7 +94,7 @@ export class ContextHandler {
}
protected async getPrometheusService(): Promise<PrometheusService> {
if (this.prometheus !== null && this.prometheusProvider !== null) {
if (this.prometheus && this.prometheusProvider) {
return {
id: this.prometheusProvider,
namespace: this.prometheus.namespace,
@@ -117,11 +129,11 @@ export class ContextHandler {
throw Object.assign(new Error("No Prometheus service found"), { cause: errors });
}
async resolveAuthProxyUrl() {
await this.ensureServer();
async resolveAuthProxyUrl(): Promise<string> {
const kubeAuthProxy = await this.ensureServerHelper();
const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : "";
return `https://127.0.0.1:${this.kubeAuthProxy.port}${this.kubeAuthProxy.apiPrefix}${path}`;
return `https://127.0.0.1:${kubeAuthProxy.port}${kubeAuthProxy.apiPrefix}${path}`;
}
resolveAuthProxyCa() {
@@ -139,30 +151,32 @@ export class ContextHandler {
}
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
await this.ensureServer();
const ca = this.dependencies.authProxyCa;
const kubeAuthProxy = await this.ensureServerHelper();
const ca = this.resolveAuthProxyCa();
const clusterPath = this.clusterUrl.path !== "/" ? this.clusterUrl.path : "";
const apiPrefix = `${this.kubeAuthProxy.apiPrefix}${clusterPath}`;
const apiPrefix = `${kubeAuthProxy.apiPrefix}${clusterPath}`;
const headers: Record<string, string> = {};
if (this.clusterUrl.hostname) {
headers.Host = this.clusterUrl.hostname;
}
return {
target: {
protocol: "https:",
host: "127.0.0.1",
port: this.kubeAuthProxy.port,
port: kubeAuthProxy.port,
path: apiPrefix,
ca,
},
changeOrigin: true,
timeout,
secure: true,
headers: {
"Host": this.clusterUrl.hostname,
},
headers,
};
}
async ensureServer() {
protected async ensureServerHelper(): Promise<KubeAuthProxy> {
if (!this.kubeAuthProxy) {
const proxyEnv = Object.assign({}, process.env);
@@ -171,9 +185,23 @@ export class ContextHandler {
}
this.kubeAuthProxy = this.dependencies.createKubeAuthProxy(this.cluster, proxyEnv);
await this.kubeAuthProxy.run();
return this.kubeAuthProxy;
}
await this.kubeAuthProxy.whenReady;
return this.kubeAuthProxy;
}
async ensureServer(): Promise<void> {
await this.ensureServerHelper();
}
async restartServer(): Promise<void> {
this.stopServer();
await this.ensureServerHelper();
}
stopServer() {
@@ -5,6 +5,7 @@
import { getInjectable } from "@ogre-tools/injectable";
import selfsigned from "selfsigned";
import type { Cluster } from "../../common/cluster/cluster";
import type { ClusterContextHandler } from "./context-handler";
import { ContextHandler } from "./context-handler";
import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable";
import { getKubeAuthProxyCertificate } from "../kube-auth-proxy/get-kube-auth-proxy-certificate";
@@ -14,15 +15,18 @@ const createContextHandlerInjectable = getInjectable({
id: "create-context-handler",
instantiate: (di) => {
return (cluster: Cluster) => {
const createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable);
return (cluster: Cluster): ClusterContextHandler | undefined => {
const clusterUrl = new URLParse(cluster.apiUrl);
const dependencies = {
createKubeAuthProxy: di.inject(createKubeAuthProxyInjectable),
authProxyCa: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate).cert,
};
return new ContextHandler(dependencies, cluster);
return new ContextHandler(
{
createKubeAuthProxy,
authProxyCa: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate).cert,
},
cluster,
);
};
},
});
@@ -12,6 +12,7 @@ import createContextHandlerInjectable from "../context-handler/create-context-ha
import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token";
import authorizationReviewInjectable from "../../common/cluster/authorization-review.injectable";
import listNamespacesInjectable from "../../common/cluster/list-namespaces.injectable";
import loggerInjectable from "../../common/logger.injectable";
const createClusterInjectable = getInjectable({
id: "create-cluster",
@@ -24,6 +25,7 @@ const createClusterInjectable = getInjectable({
createContextHandler: di.inject(createContextHandlerInjectable),
createAuthorizationReview: di.inject(authorizationReviewInjectable),
createListNamespaces: di.inject(listNamespacesInjectable),
logger: di.inject(loggerInjectable),
};
return (model) => new Cluster(dependencies, model);
@@ -0,0 +1,32 @@
/**
* 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 { createExtensionInstanceInjectionToken } from "../../extensions/extension-loader/create-extension-instance.token";
import fileSystemProvisionerStoreInjectable from "../../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable";
import { lensExtensionDependencies } from "../../extensions/lens-extension";
import type { LensMainExtensionDependencies } from "../../extensions/lens-extension-set-dependencies";
import type { LensMainExtension } from "../../extensions/lens-main-extension";
import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable";
const createExtensionInstanceInjectable = getInjectable({
id: "create-extension-instance",
instantiate: (di) => {
const deps: LensMainExtensionDependencies = {
fileSystemProvisionerStore: di.inject(fileSystemProvisionerStoreInjectable),
entityRegistry: di.inject(catalogEntityRegistryInjectable),
};
return (ExtensionClass, extension) => {
const instance = new ExtensionClass(extension) as LensMainExtension;
(instance as { [lensExtensionDependencies]: LensMainExtensionDependencies })[lensExtensionDependencies] = deps;
return instance;
};
},
injectionToken: createExtensionInstanceInjectionToken,
});
export default createExtensionInstanceInjectable;
+20 -23
View File
@@ -4,7 +4,7 @@
*/
import glob from "glob";
import { kebabCase, memoize, noop } from "lodash/fp";
import { kebabCase, memoize } from "lodash/fp";
import { createContainer } from "@ogre-tools/injectable";
import { Environments, setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
@@ -20,13 +20,12 @@ import loggerInjectable from "../common/logger.injectable";
import spawnInjectable from "./child-process/spawn.injectable";
import extensionsStoreInjectable from "../extensions/extensions-store/extensions-store.injectable";
import type { ExtensionsStore } from "../extensions/extensions-store/extensions-store";
import fileSystemProvisionerStoreInjectable from "../extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable";
import type { FileSystemProvisionerStore } from "../extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store";
import fileSystemProvisionerStoreInjectable from "../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable";
import type { FileSystemProvisionerStore } from "../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store";
import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable";
import type { ClusterStore } from "../common/cluster-store/cluster-store";
import type { Cluster } from "../common/cluster/cluster";
import userStoreInjectable from "../common/user-store/user-store.injectable";
import type { UserStore } from "../common/user-store";
import isMacInjectable from "../common/vars/is-mac.injectable";
import isWindowsInjectable from "../common/vars/is-windows.injectable";
import isLinuxInjectable from "../common/vars/is-linux.injectable";
@@ -34,11 +33,15 @@ import getAbsolutePathInjectable from "../common/path/get-absolute-path.injectab
import { getAbsolutePathFake } from "../common/test-utils/get-absolute-path-fake";
import joinPathsInjectable from "../common/path/join-paths.injectable";
import { joinPathsFake } from "../common/test-utils/join-paths-fake";
import hotbarStoreInjectable from "../common/hotbar-store.injectable";
import hotbarStoreInjectable from "../common/hotbars/store.injectable";
import type { GetDiForUnitTestingOptions } from "../test-utils/get-dis-for-unit-testing";
import { noop } from "../renderer/utils";
export function getDiForUnitTesting(opts: GetDiForUnitTestingOptions = {}) {
const {
doGeneralOverrides = false,
} = opts;
export const getDiForUnitTesting = (
{ doGeneralOverrides } = { doGeneralOverrides: false },
) => {
const di = createContainer();
setLegacyGlobalDiForExtensionApi(di, Environments.main);
@@ -56,6 +59,11 @@ export const getDiForUnitTesting = (
di.preventSideEffects();
if (doGeneralOverrides) {
di.override(hotbarStoreInjectable, () => ({}));
di.override(userStoreInjectable, () => ({}));
di.override(extensionsStoreInjectable, () => ({ isEnabled: (opts) => (void opts, false) }) as ExtensionsStore);
di.override(clusterStoreInjectable, () => ({ getById: (id) => (void id, {}) as Cluster }) as ClusterStore);
di.override(fileSystemProvisionerStoreInjectable, () => ({}) as FileSystemProvisionerStore);
di.override(isMacInjectable, () => true);
di.override(isWindowsInjectable, () => false);
di.override(isLinuxInjectable, () => false);
@@ -63,17 +71,6 @@ export const getDiForUnitTesting = (
di.override(getAbsolutePathInjectable, () => getAbsolutePathFake);
di.override(joinPathsInjectable, () => joinPathsFake);
// eslint-disable-next-line unused-imports/no-unused-vars-ts
di.override(extensionsStoreInjectable, () => ({ isEnabled: ({ id, isBundled }) => false }) as ExtensionsStore);
di.override(hotbarStoreInjectable, () => ({}));
di.override(fileSystemProvisionerStoreInjectable, () => ({}) as FileSystemProvisionerStore);
// eslint-disable-next-line unused-imports/no-unused-vars-ts
di.override(clusterStoreInjectable, () => ({ getById: (id): Cluster => ({}) as Cluster }) as ClusterStore);
di.override(userStoreInjectable, () => ({}) as UserStore);
di.override(
getElectronAppPathInjectable,
() => (name: string) => `some-electron-app-path-for-${kebabCase(name)}`,
@@ -83,13 +80,12 @@ export const getDiForUnitTesting = (
di.override(appNameInjectable, () => "some-electron-app-name");
di.override(registerChannelInjectable, () => () => undefined);
di.override(directoryForBundledBinariesInjectable, () => "some-bin-directory");
di.override(spawnInjectable, () => () => {
return {
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
on: jest.fn(),
} as any;
} as never;
});
di.override(writeJsonFileInjectable, () => () => {
@@ -107,13 +103,14 @@ export const getDiForUnitTesting = (
di.override(loggerInjectable, () => ({
warn: noop,
debug: noop,
error: (message: string, ...args: any) => console.error(message, ...args),
error: noop,
info: noop,
silly: noop,
}));
}
return di;
};
}
const getInjectableFilePaths = memoize(() => [
...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }),
+13 -3
View File
@@ -5,15 +5,21 @@
import { promiseExecFile } from "../../common/utils/promise-exec";
import type { BaseEncodingOptions } from "fs";
import type { ExecFileOptions } from "child_process";
import type { ExecFileOptions, ExecFileOptionsWithStringEncoding } from "child_process";
import { helmBinaryPath } from "../../common/vars";
import { UserStore } from "../../common/user-store";
import { isChildProcessError } from "../../common/utils";
/**
* ExecFile the bundled helm CLI
* @returns STDOUT
*/
export async function execHelm(args: string[], options?: BaseEncodingOptions & ExecFileOptions): Promise<string> {
export async function execHelm(args: string[], { encoding, ...rest }: BaseEncodingOptions & ExecFileOptions = {}): Promise<string> {
const options: ExecFileOptionsWithStringEncoding = {
encoding: encoding ?? "utf-8",
...rest,
};
try {
const opts = { ...options };
@@ -27,6 +33,10 @@ export async function execHelm(args: string[], options?: BaseEncodingOptions & E
return stdout;
} catch (error) {
throw error?.stderr || error;
if (isChildProcessError(error, "string")) {
throw error.stderr || error;
}
throw error;
}
}
+25 -12
View File
@@ -9,8 +9,10 @@ import * as yaml from "js-yaml";
import type { HelmRepo } from "./helm-repo-manager";
import logger from "../logger";
import type { RepoHelmChartList } from "../../common/k8s-api/endpoints/helm-charts.api";
import { iter, sortCharts } from "../../common/utils";
import { iter, put, sortCharts } from "../../common/utils";
import { execHelm } from "./exec";
import type { SetRequired } from "type-fest";
import { assert } from "console";
interface ChartCacheEntry {
data: Buffer;
@@ -23,9 +25,15 @@ export interface HelmCacheFile {
}
export class HelmChartManager {
static #cache = new Map<string, ChartCacheEntry>();
static readonly #cache = new Map<string, ChartCacheEntry>();
private constructor(protected repo: HelmRepo) {}
protected readonly repo: SetRequired<HelmRepo, "cacheFilePath">;
private constructor(repo: HelmRepo) {
assert(repo.cacheFilePath, "CacheFilePath must be provided on the helm repo");
this.repo = repo as SetRequired<HelmRepo, "cacheFilePath">;
}
static forRepo(repo: HelmRepo) {
return new this(repo);
@@ -76,25 +84,30 @@ export class HelmChartManager {
const normalized = normalizeHelmCharts(this.repo.name, data.entries);
HelmChartManager.#cache.set(this.repo.name, {
data: v8.serialize(normalized),
mtimeMs: cacheFileStats.mtimeMs,
});
return put(
HelmChartManager.#cache,
this.repo.name,
{
data: v8.serialize(normalized),
mtimeMs: cacheFileStats.mtimeMs,
},
);
}
protected async cachedYaml(): Promise<RepoHelmChartList> {
if (!HelmChartManager.#cache.has(this.repo.name)) {
await this.updateYamlCache();
let cacheEntry = HelmChartManager.#cache.get(this.repo.name);
if (!cacheEntry) {
cacheEntry = await this.updateYamlCache();
} else {
const newStats = await fs.promises.stat(this.repo.cacheFilePath);
const cacheEntry = HelmChartManager.#cache.get(this.repo.name);
if (cacheEntry.mtimeMs < newStats.mtimeMs) {
await this.updateYamlCache();
cacheEntry = await this.updateYamlCache();
}
}
return v8.deserialize(HelmChartManager.#cache.get(this.repo.name).data);
return v8.deserialize(cacheEntry.data);
}
}
+19 -9
View File
@@ -9,6 +9,9 @@ import * as yaml from "js-yaml";
import { toCamelCase } from "../../common/utils/camelCase";
import { execFile } from "child_process";
import { execHelm } from "./exec";
import assert from "assert";
import type { JsonObject, JsonValue } from "type-fest";
import { isObject, json } from "../../common/utils";
export async function listReleases(pathToKubeconfig: string, namespace?: string): Promise<Record<string, any>[]> {
const args = [
@@ -25,17 +28,17 @@ export async function listReleases(pathToKubeconfig: string, namespace?: string)
args.push("--kubeconfig", pathToKubeconfig);
const output = JSON.parse(await execHelm(args));
const output = json.parse(await execHelm(args));
if (!Array.isArray(output) || output.length == 0) {
return [];
}
return output.map(toCamelCase);
return output.filter(isObject).map(toCamelCase);
}
export async function installChart(chart: string, values: any, name: string | undefined = "", namespace: string, version: string, kubeconfigPath: string) {
export async function installChart(chart: string, values: JsonValue, name: string | undefined = "", namespace: string, version: string, kubeconfigPath: string) {
const valuesFilePath = tempy.file({ name: "values.yaml" });
await fse.writeFile(valuesFilePath, yaml.dump(values));
@@ -110,10 +113,14 @@ export async function getRelease(name: string, namespace: string, kubeconfigPath
"--output", "json",
];
const release = JSON.parse(await execHelm(args, {
const release = json.parse(await execHelm(args, {
maxBuffer: 32 * 1024 * 1024 * 1024, // 32 MiB
}));
if (!isObject(release) || Array.isArray(release)) {
return undefined;
}
release.resources = await getResources(name, namespace, kubeconfigPath, kubectlPath);
return release;
@@ -155,7 +162,7 @@ export async function getValues(name: string, { namespace, all = false, kubeconf
}
export async function getHistory(name: string, namespace: string, kubeconfigPath: string) {
return JSON.parse(await execHelm([
return json.parse(await execHelm([
"history",
name,
"--output", "json",
@@ -193,7 +200,7 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str
try {
const helmOutput = await execHelm(helmArgs);
return new Promise((resolve, reject) => {
return new Promise<JsonObject[]>((resolve, reject) => {
let stdout = "";
let stderr = "";
const kubectl = execFile(kubectlPath, kubectlArgs);
@@ -202,7 +209,9 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str
.on("exit", (code, signal) => {
if (typeof code === "number") {
if (code === 0) {
resolve(JSON.parse(stdout).items);
const output = json.parse(stdout) as { items: JsonObject[] };
resolve(output.items);
} else {
reject(stderr);
}
@@ -212,10 +221,11 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str
})
.on("error", reject);
assert(kubectl.stderr && kubectl.stdout && kubectl.stdin, "For some reason the IO streams are undefined");
kubectl.stderr.on("data", output => stderr += output);
kubectl.stdout.on("data", output => stdout += output);
kubectl.stdin.write(helmOutput);
kubectl.stdin.end();
kubectl.stdin.end(helmOutput);
});
} catch {
return [];
+25 -16
View File
@@ -11,9 +11,9 @@ import orderBy from "lodash/orderBy";
import logger from "../logger";
import { execHelm } from "./exec";
export type HelmEnv = Record<string, string> & {
HELM_REPOSITORY_CACHE?: string;
HELM_REPOSITORY_CONFIG?: string;
export type HelmEnv = Partial<Record<string, string>> & {
HELM_REPOSITORY_CACHE: string;
HELM_REPOSITORY_CONFIG: string;
};
export interface HelmRepoConfig {
@@ -32,10 +32,14 @@ export interface HelmRepo {
password?: string;
}
interface EnsuredHelmRepoManagerData {
helmEnv: HelmEnv;
didUpdateOnce: boolean;
}
export class HelmRepoManager extends Singleton {
protected repos: HelmRepo[];
protected helmEnv: HelmEnv;
protected didUpdateOnce: boolean;
protected helmEnv?: HelmEnv;
protected didUpdateOnce?: boolean;
public async loadAvailableRepos(): Promise<HelmRepo[]> {
const res = await customRequestPromise({
@@ -48,10 +52,10 @@ export class HelmRepoManager extends Singleton {
return orderBy(res.body as HelmRepo[], repo => repo.name);
}
private async ensureInitialized() {
private async ensureInitialized(): Promise<EnsuredHelmRepoManagerData> {
this.helmEnv ??= await this.parseHelmEnv();
const repos = await this.list();
const repos = await this.list(this.helmEnv);
if (repos.length === 0) {
await this.addRepo({
@@ -64,12 +68,17 @@ export class HelmRepoManager extends Singleton {
await this.update();
this.didUpdateOnce = true;
}
return {
didUpdateOnce: this.didUpdateOnce,
helmEnv: this.helmEnv,
};
}
protected async parseHelmEnv() {
const output = await execHelm(["env"]);
const lines = output.split(/\r?\n/); // split by new line feed
const env: HelmEnv = {};
const env: Partial<Record<string, string>> = {};
lines.forEach((line: string) => {
const [key, value] = line.split("=");
@@ -79,18 +88,18 @@ export class HelmRepoManager extends Singleton {
}
});
return env;
return env as HelmEnv;
}
public async repo(name: string): Promise<HelmRepo> {
public async repo(name: string): Promise<HelmRepo | undefined> {
const repos = await this.repositories();
return repos.find(repo => repo.name === name);
}
private async list(): Promise<HelmRepo[]> {
private async list(helmEnv: HelmEnv): Promise<HelmRepo[]> {
try {
const rawConfig = await readFile(this.helmEnv.HELM_REPOSITORY_CONFIG, "utf8");
const rawConfig = await readFile(helmEnv.HELM_REPOSITORY_CONFIG, "utf8");
const parsedConfig = yaml.load(rawConfig) as HelmRepoConfig;
if (typeof parsedConfig === "object" && parsedConfig) {
@@ -105,13 +114,13 @@ export class HelmRepoManager extends Singleton {
public async repositories(): Promise<HelmRepo[]> {
try {
await this.ensureInitialized();
const { helmEnv } = await this.ensureInitialized();
const repos = await this.list();
const repos = await this.list(helmEnv);
return repos.map(repo => ({
...repo,
cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`,
cacheFilePath: `${helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`,
}));
} catch (error) {
logger.error(`[HELM]: repositories listing error`, error);
+30 -5
View File
@@ -8,6 +8,8 @@ import logger from "../logger";
import { HelmRepoManager } from "./helm-repo-manager";
import { HelmChartManager } from "./helm-chart-manager";
import { deleteRelease, getHistory, getRelease, getValues, installChart, listReleases, rollback, upgradeRelease } from "./helm-release-manager";
import type { JsonObject } from "type-fest";
import { object } from "../../common/utils";
interface GetReleaseValuesArgs {
cluster: Cluster;
@@ -15,8 +17,22 @@ interface GetReleaseValuesArgs {
all: boolean;
}
export interface InstallChartArgs {
chart: string;
values: JsonObject;
name: string;
namespace: string;
version: string;
}
export interface UpdateChartArgs {
chart: string;
values: JsonObject;
version: string;
}
class HelmService {
public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) {
public async installChart(cluster: Cluster, data: InstallChartArgs) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
return installChart(data.chart, data.values, data.name, data.namespace, data.version, proxyKubeconfig);
@@ -25,13 +41,18 @@ class HelmService {
public async listCharts() {
const repositories = await HelmRepoManager.getInstance().repositories();
return Object.fromEntries(
await Promise.all(repositories.map(async repo => [repo.name, await HelmChartManager.forRepo(repo).charts()])),
return object.fromEntries(
await Promise.all(repositories.map(async repo => [repo.name, await HelmChartManager.forRepo(repo).charts()] as const)),
);
}
public async getChart(repoName: string, chartName: string, version = "") {
const repo = await HelmRepoManager.getInstance().repo(repoName);
if (!repo) {
return undefined;
}
const chartManager = HelmChartManager.forRepo(repo);
return {
@@ -43,10 +64,14 @@ class HelmService {
public async getChartValues(repoName: string, chartName: string, version = "") {
const repo = await HelmRepoManager.getInstance().repo(repoName);
if (!repo) {
return undefined;
}
return HelmChartManager.forRepo(repo).getValues(chartName, version);
}
public async listReleases(cluster: Cluster, namespace: string = null) {
public async listReleases(cluster: Cluster, namespace?: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("list releases");
@@ -88,7 +113,7 @@ class HelmService {
return deleteRelease(releaseName, namespace, proxyKubeconfig);
}
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) {
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: UpdateChartArgs) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
const kubectl = await cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
+8 -4
View File
@@ -48,17 +48,18 @@ import extensionDiscoveryInjectable from "../extensions/extension-discovery/exte
import directoryForExesInjectable from "../common/app-paths/directory-for-exes/directory-for-exes.injectable";
import initIpcMainHandlersInjectable from "./initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable";
import directoryForKubeConfigsInjectable from "../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable";
import kubeconfigSyncManagerInjectable from "./catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable";
import kubeconfigSyncManagerInjectable from "./catalog-sources/kubeconfig-sync/manager.injectable";
import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable";
import routerInjectable from "./router/router.injectable";
import shellApiRequestInjectable from "./proxy-functions/shell-api-request/shell-api-request.injectable";
import userStoreInjectable from "../common/user-store/user-store.injectable";
import trayMenuItemsInjectable from "./tray/tray-menu-items.injectable";
import { broadcastNativeThemeOnUpdate } from "./native-theme";
import assert from "assert";
import windowManagerInjectable from "./window-manager.injectable";
import navigateToPreferencesInjectable from "../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable";
import syncGeneralCatalogEntitiesInjectable from "./catalog-sources/sync-general-catalog-entities.injectable";
import hotbarStoreInjectable from "../common/hotbar-store.injectable";
import hotbarStoreInjectable from "../common/hotbars/store.injectable";
import applicationMenuItemsInjectable from "./menu/application-menu-items.injectable";
import type { DiContainer } from "@ogre-tools/injectable";
import { init } from "@sentry/electron/main";
@@ -70,6 +71,7 @@ async function main(di: DiContainer) {
* Note: this MUST be called before electron's "ready" event has been emitted.
*/
initializeSentryReporting(init);
await di.runSetups();
await app.whenReady();
@@ -249,11 +251,13 @@ async function main(di: DiContainer) {
logger.info("🔌 Starting LensProxy");
await lensProxy.listen(); // lensProxy.port available
} catch (error) {
dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`);
dialog.showErrorBox("Lens Error", `Could not start proxy: ${error ? String(error) : "unknown error"}`);
return app.exit();
}
assert(lensProxy.port, "Lens Proxy failed to start");
// test proxy connection
try {
logger.info("🔎 Testing LensProxy connection ...");
@@ -350,7 +354,7 @@ async function main(di: DiContainer) {
extensionLoader.initExtensions(extensions);
} catch (error) {
dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`);
dialog.showErrorBox("Lens Error", `Could not load extensions${error ? `: ${String(error)}` : ""}`);
console.error(error);
console.trace();
}
+4 -2
View File
@@ -4,10 +4,12 @@
*/
import type { OpenDialogOptions } from "electron";
import { BrowserWindow, dialog } from "electron";
import { dialog } from "electron";
import { WindowManager } from "../window-manager";
export async function showOpenDialog(dialogOptions: OpenDialogOptions): Promise<{ canceled: boolean; filePaths: string[] }> {
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), dialogOptions);
const window = await WindowManager.getInstance().ensureMainWindow();
const { canceled, filePaths } = await dialog.showOpenDialog(window, dialogOptions);
return { canceled, filePaths };
}
@@ -12,6 +12,7 @@ import { getBinaryName } from "../../common/vars";
import directoryForBundledBinariesInjectable from "../../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable";
import spawnInjectable from "../child-process/spawn.injectable";
import { getKubeAuthProxyCertificate } from "./get-kube-auth-proxy-certificate";
import loggerInjectable from "../../common/logger.injectable";
export type CreateKubeAuthProxy = (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy;
@@ -27,6 +28,7 @@ const createKubeAuthProxyInjectable = getInjectable({
proxyBinPath: path.join(di.inject(directoryForBundledBinariesInjectable), binaryName),
proxyCert: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate),
spawn: di.inject(spawnInjectable),
logger: di.inject(loggerInjectable),
};
return new KubeAuthProxy(dependencies, cluster, environmentVariables);
@@ -4,36 +4,32 @@
*/
import type * as selfsigned from "selfsigned";
import { getOrInsertWith } from "../../common/utils";
type SelfSignedGenerate = typeof selfsigned.generate;
const certCache: Map<string, selfsigned.SelfSignedCert> = new Map();
const certCache = new Map<string, selfsigned.SelfSignedCert>();
export function getKubeAuthProxyCertificate(hostname: string, generate: SelfSignedGenerate, useCache = true): selfsigned.SelfSignedCert {
if (useCache && certCache.has(hostname)) {
return certCache.get(hostname);
}
const opts = [
{ name: "commonName", value: "Lens Certificate Authority" },
{ name: "organizationName", value: "Lens" },
];
const cert = generate(opts, {
keySize: 2048,
algorithm: "sha256",
days: 365,
extensions: [
{ name: "basicConstraints", cA: true },
{ name: "subjectAltName", altNames: [
{ type: 2, value: hostname },
{ type: 2, value: "localhost" },
{ type: 7, ip: "127.0.0.1" },
] },
export function getKubeAuthProxyCertificate(hostname: string, generate: SelfSignedGenerate): selfsigned.SelfSignedCert {
return getOrInsertWith(certCache, hostname, () => generate(
[
{ name: "commonName", value: "Lens Certificate Authority" },
{ name: "organizationName", value: "Lens" },
],
});
certCache.set(hostname, cert);
return cert;
{
keySize: 2048,
algorithm: "sha256",
days: 365,
extensions: [
{ name: "basicConstraints", cA: true },
{
name: "subjectAltName", altNames: [
{ type: 2, value: hostname },
{ type: 2, value: "localhost" },
{ type: 7, ip: "127.0.0.1" },
],
},
],
},
));
}
+27 -12
View File
@@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ChildProcess, spawn } from "child_process";
import type { ChildProcess } from "child_process";
import { waitUntilUsed } from "tcp-port-used";
import { randomBytes } from "crypto";
import type { Cluster } from "../../common/cluster/cluster";
@@ -11,27 +11,36 @@ import logger from "../logger";
import { getPortFrom } from "../utils/get-port";
import { makeObservable, observable, when } from "mobx";
import type { SelfSignedCert } from "selfsigned";
import assert from "assert";
import { TypedRegEx } from "typed-regex";
import type { Spawn } from "../child-process/spawn.injectable";
import type { Logger } from "../../common/logger";
const startingServeRegex = /starting to serve on (?<address>.+)/i;
const startingServeRegex = TypedRegEx("starting to serve on (?<address>.+)", "i");
export interface KubeAuthProxyDependencies {
proxyBinPath: string;
proxyCert: SelfSignedCert;
spawn: typeof spawn;
readonly proxyBinPath: string;
readonly proxyCert: SelfSignedCert;
spawn: Spawn;
readonly logger: Logger;
}
export class KubeAuthProxy {
public readonly apiPrefix = `/${randomBytes(8).toString("hex")}`;
public get port(): number {
return this._port;
const port = this._port;
assert(port, "port has not yet been initialized");
return port;
}
protected _port: number;
protected _port?: number;
protected proxyProcess?: ChildProcess;
@observable protected ready = false;
constructor(private dependencies: KubeAuthProxyDependencies, protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) {
constructor(private readonly dependencies: KubeAuthProxyDependencies, protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) {
makeObservable(this);
}
@@ -63,7 +72,7 @@ export class KubeAuthProxy {
});
this.proxyProcess.on("exit", (code) => {
this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code > 0);
this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code ? code > 0: false);
this.exit();
});
@@ -72,6 +81,9 @@ export class KubeAuthProxy {
this.exit();
});
assert(this.proxyProcess.stderr);
assert(this.proxyProcess.stdout);
this.proxyProcess.stderr.on("data", (data: Buffer) => {
if (data.includes("http: TLS handshake error")) {
return;
@@ -91,10 +103,13 @@ export class KubeAuthProxy {
onFind: () => this.cluster.broadcastConnectUpdate("Authentication proxy started"),
});
logger.info(`[KUBE-AUTH-PROXY]: found port=${this._port}`);
try {
await waitUntilUsed(this.port, 500, 10000);
this.ready = true;
} catch (error) {
logger.warn("[KUBE-AUTH-PROXY]: waitUntilUsed failed", error);
this.cluster.broadcastConnectUpdate("Proxy port failed to be used within timelimit, restarting...", true);
this.exit();
@@ -108,10 +123,10 @@ export class KubeAuthProxy {
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.stderr?.removeAllListeners();
this.proxyProcess.stdout?.removeAllListeners();
this.proxyProcess.kill();
this.proxyProcess = null;
this.proxyProcess = undefined;
}
}
}
@@ -5,21 +5,26 @@
import { getInjectable } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable";
import type { KubeconfigManagerDependencies } from "./kubeconfig-manager";
import { KubeconfigManager } from "./kubeconfig-manager";
import loggerInjectable from "../../common/logger.injectable";
export interface KubeConfigManagerInstantiationParameter {
cluster: Cluster;
}
export type CreateKubeconfigManager = (cluster: Cluster) => KubeconfigManager | undefined;
const createKubeconfigManagerInjectable = getInjectable({
id: "create-kubeconfig-manager",
instantiate: (di) => {
const dependencies = {
instantiate: (di): CreateKubeconfigManager => {
const dependencies: KubeconfigManagerDependencies = {
directoryForTemp: di.inject(directoryForTempInjectable),
logger: di.inject(loggerInjectable),
};
return (cluster: Cluster) => new KubeconfigManager(dependencies, cluster);
return (cluster) => new KubeconfigManager(dependencies, cluster);
},
});
@@ -5,15 +5,19 @@
import type { KubeConfig } from "@kubernetes/client-node";
import type { Cluster } from "../../common/cluster/cluster";
import type { ContextHandler } from "../context-handler/context-handler";
import type { ClusterContextHandler } from "../context-handler/context-handler";
import path from "path";
import fs from "fs-extra";
import { dumpConfigYaml } from "../../common/kube-helpers";
import logger from "../logger";
import { LensProxy } from "../lens-proxy";
import { isErrnoException } from "../../common/utils";
import type { PartialDeep } from "type-fest";
import type { Logger } from "../../common/logger";
interface Dependencies {
directoryForTemp: string;
export interface KubeconfigManagerDependencies {
readonly directoryForTemp: string;
readonly logger: Logger;
}
export class KubeconfigManager {
@@ -26,9 +30,9 @@ export class KubeconfigManager {
*/
protected tempFilePath: string | null | undefined = null;
protected contextHandler: ContextHandler;
protected readonly contextHandler: ClusterContextHandler;
constructor(private dependencies: Dependencies, protected cluster: Cluster) {
constructor(private readonly dependencies: KubeconfigManagerDependencies, protected cluster: Cluster) {
this.contextHandler = cluster.contextHandler;
}
@@ -42,7 +46,7 @@ export class KubeconfigManager {
}
if (this.tempFilePath === null || !(await fs.pathExists(this.tempFilePath))) {
await this.ensureFile();
return await this.ensureFile();
}
return this.tempFilePath;
@@ -61,7 +65,7 @@ export class KubeconfigManager {
try {
await fs.unlink(this.tempFilePath);
} catch (error) {
if (error.code !== "ENOENT") {
if (isErrnoException(error) && error.code !== "ENOENT") {
throw error;
}
} finally {
@@ -72,7 +76,8 @@ export class KubeconfigManager {
protected async ensureFile() {
try {
await this.contextHandler.ensureServer();
this.tempFilePath = await this.createProxyKubeconfig();
return this.tempFilePath = await this.createProxyKubeconfig();
} catch (error) {
throw Object.assign(new Error("Failed to creat temp config for auth-proxy"), { cause: error });
}
@@ -94,13 +99,12 @@ export class KubeconfigManager {
`kubeconfig-${id}`,
);
const kubeConfig = await cluster.getKubeconfig();
const proxyConfig: Partial<KubeConfig> = {
const proxyConfig: PartialDeep<KubeConfig> = {
currentContext: contextName,
clusters: [
{
name: contextName,
server: this.resolveProxyUrl,
skipTLSVerify: undefined,
},
],
users: [
@@ -111,7 +115,7 @@ export class KubeconfigManager {
user: "proxy",
name: contextName,
cluster: contextName,
namespace: cluster.defaultNamespace || kubeConfig.getContextObject(contextName).namespace,
namespace: cluster.defaultNamespace || kubeConfig.getContextObject(contextName)?.namespace,
},
],
};
+12 -10
View File
@@ -53,12 +53,11 @@ interface Dependencies {
export class Kubectl {
public kubectlVersion: string;
protected directory: string;
protected url: string;
protected path: string;
protected dirname: string;
public static readonly bundledKubectlVersion: string = bundledVersion;
public static readonly bundledKubectlVersion = bundledVersion;
public static invalidBundle = false;
constructor(private dependencies: Dependencies, clusterVersion: string) {
@@ -70,12 +69,14 @@ export class Kubectl {
version = new SemVer(Kubectl.bundledKubectlVersion);
}
const minorVersion = `${version.major}.${version.minor}`;
const fromMajorMinor = kubectlMap.get(`${version.major}.${version.minor}`);
/* minorVersion is the first two digits of kube server version
if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */
if (kubectlMap.has(minorVersion)) {
this.kubectlVersion = kubectlMap.get(minorVersion);
/**
* minorVersion is the first two digits of kube server version if the version map includes that,
* use that version, if not, fallback to the exact x.y.z of kube version
*/
if (fromMajorMinor) {
this.kubectlVersion = fromMajorMinor;
logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using version map`);
} else {
this.kubectlVersion = version.format();
@@ -176,8 +177,8 @@ export class Kubectl {
return true;
}
logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`);
} catch (err) {
logger.error(`Local kubectl failed to run properly (${err.message}), unlinking`);
} catch (error) {
logger.error(`Local kubectl failed to run properly (${error}), unlinking`);
}
await fs.promises.unlink(this.path);
}
@@ -348,7 +349,8 @@ export class Kubectl {
// MacOS packages are only available from default
const { url } = packageMirrors.get(this.dependencies.userStore.downloadMirror)
?? packageMirrors.get(defaultPackageMirror);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
?? packageMirrors.get(defaultPackageMirror)!;
return url;
}
+18 -12
View File
@@ -9,15 +9,19 @@ import spdy from "spdy";
import type httpProxy from "http-proxy";
import { apiPrefix, apiKubePrefix } from "../common/vars";
import type { Router } from "./router/router";
import type { ContextHandler } from "./context-handler/context-handler";
import type { ClusterContextHandler } from "./context-handler/context-handler";
import logger from "./logger";
import { Singleton } from "../common/utils";
import type { Cluster } from "../common/cluster/cluster";
import type { ProxyApiRequestArgs } from "./proxy-functions";
import { appEventBus } from "../common/app-event-bus/event-bus";
import { getBoolean } from "./utils/parse-query";
import assert from "assert";
import type { SetRequired } from "type-fest";
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null;
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined;
export type ServerIncomingMessage = SetRequired<http.IncomingMessage, "url" | "method">;
export interface LensProxyFunctions {
getClusterForRequest: GetClusterForRequest;
@@ -53,13 +57,12 @@ const disallowedPorts = new Set([
]);
export class LensProxy extends Singleton {
protected origin: string;
protected proxyServer: http.Server;
protected closed = false;
protected retryCounters = new Map<string, number>();
protected getClusterForRequest: GetClusterForRequest;
public port: number;
public port?: number;
constructor(protected router: Router, protected proxy: httpProxy, { shellApiRequest, kubeApiUpgradeRequest, getClusterForRequest }: LensProxyFunctions) {
super();
@@ -73,12 +76,12 @@ export class LensProxy extends Singleton {
plain: true,
protocols: ["http/1.1", "spdy/3.1"],
},
}, (req: http.IncomingMessage, res: http.ServerResponse) => {
this.handleRequest(req, res);
}, (req, res) => {
this.handleRequest(req as ServerIncomingMessage, res);
});
this.proxyServer
.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
.on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => {
const cluster = getClusterForRequest(req);
if (!cluster) {
@@ -197,7 +200,7 @@ export class LensProxy extends Singleton {
logger.debug(`Retrying proxy request to url: ${reqId}`);
setTimeout(() => {
this.retryCounters.set(reqId, retryCount + 1);
this.handleRequest(req, res)
this.handleRequest(req as ServerIncomingMessage, res)
.catch(error => logger.error(`[LENS-PROXY]: failed to handle request on proxy error: ${error}`));
}, timeoutMs);
}
@@ -214,8 +217,8 @@ export class LensProxy extends Singleton {
return proxy;
}
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions | void> {
if (req.url.startsWith(apiKubePrefix)) {
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ClusterContextHandler): Promise<httpProxy.ServerOptions | void> {
if (req.url?.startsWith(apiKubePrefix)) {
delete req.headers.authorization;
req.url = req.url.replace(apiKubePrefix, "");
@@ -223,11 +226,13 @@ export class LensProxy extends Singleton {
}
}
protected getRequestId(req: http.IncomingMessage) {
protected getRequestId(req: http.IncomingMessage): string {
assert(req.headers.host);
return req.headers.host + req.url;
}
protected async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
protected async handleRequest(req: ServerIncomingMessage, res: http.ServerResponse) {
const cluster = this.getClusterForRequest(req);
if (cluster) {
@@ -237,6 +242,7 @@ export class LensProxy extends Singleton {
return this.proxy.web(req, res, proxyTarget);
}
}
this.router.route(cluster, req, res);
}
}
@@ -11,9 +11,7 @@ import { openBrowser } from "../../common/utils";
import { showAbout } from "./menu";
import windowManagerInjectable from "../window-manager.injectable";
import type { MenuItemConstructorOptions } from "electron";
import {
webContents,
} from "electron";
import { webContents } from "electron";
import loggerInjectable from "../../common/logger.injectable";
import appNameInjectable from "../app-paths/app-name/app-name.injectable";
import electronMenuItemsInjectable from "./electron-menu-items.injectable";
@@ -26,7 +24,7 @@ import navigateToAddClusterInjectable from "../../common/front-end-routing/route
import isMacInjectable from "../../common/vars/is-mac.injectable";
import { computed } from "mobx";
function ignoreIf(check: boolean, menuItems: MenuItemConstructorOptions[]) {
function ignoreIf(check: boolean, menuItems: MenuItemOpts[]) {
return check ? [] : menuItems;
}
@@ -142,19 +140,14 @@ const applicationMenuItemsInjectable = getInjectable({
},
},
]),
{ type: "separator" },
...(isMac
? ([
{
role: "close",
label: "Close Window",
accelerator: "Shift+Cmd+W",
},
] as MenuItemConstructorOptions[])
: []),
...ignoreIf(!isMac, [
{
role: "close",
label: "Close Window",
accelerator: "Shift+Cmd+W",
},
]),
...ignoreIf(isMac, [
{
label: "Exit",
@@ -311,7 +304,9 @@ const applicationMenuItemsInjectable = getInjectable({
// Modify menu from extensions-api
for (const menuItem of electronMenuItems.get()) {
if (!appMenu.has(menuItem.parentId)) {
const parentMenu = appMenu.get(menuItem.parentId);
if (!parentMenu) {
logger.error(
`[MENU]: cannot register menu item for parentId=${menuItem.parentId}, parent item doesn't exist`,
{ menuItem },
@@ -320,7 +315,7 @@ const applicationMenuItemsInjectable = getInjectable({
continue;
}
appMenu.get(menuItem.parentId).submenu.push(menuItem);
(parentMenu.submenu ??= []).push(menuItem);
}
if (!isMac) {
+1 -3
View File
@@ -37,9 +37,7 @@ export function showAbout() {
});
}
export function buildMenu(
applicationMenuItems: MenuItemOpts[],
) {
export function buildMenu(applicationMenuItems: MenuItemOpts[]) {
Menu.setApplicationMenu(
Menu.buildFromTemplate(applicationMenuItems),
);
@@ -13,7 +13,7 @@ const navigateToUrlInjectable = getInjectable({
const windowManager = di.inject(windowManagerInjectable);
return (url) => {
windowManager.navigateSync(url);
windowManager.navigate(url);
};
},
+10 -8
View File
@@ -4,8 +4,7 @@
*/
import type { CoreV1Api } from "@kubernetes/client-node";
import { inspect } from "util";
import { Singleton } from "../../common/utils";
import { isRequestError, Singleton } from "../../common/utils";
export interface PrometheusService {
id: string;
@@ -30,9 +29,9 @@ export abstract class PrometheusProvider {
protected async getFirstNamespacedService(client: CoreV1Api, ...selectors: string[]): Promise<PrometheusService> {
try {
for (const selector of selectors) {
const { body: { items: [service] }} = await client.listServiceForAllNamespaces(null, null, null, selector);
const { body: { items: [service] }} = await client.listServiceForAllNamespaces(undefined, undefined, undefined, selector);
if (service) {
if (service?.metadata?.namespace && service.metadata.name && service.spec?.ports) {
return {
id: this.id,
namespace: service.metadata.namespace,
@@ -42,7 +41,7 @@ export abstract class PrometheusProvider {
}
}
} catch (error) {
throw new Error(`Failed to list services for Prometheus${this.name} in all namespaces: ${error.response.body.message}`);
throw new Error(`Failed to list services for Prometheus${this.name} in all namespaces: ${isRequestError(error) ? error.response?.body.message : error}`);
}
throw new Error(`No service found for Prometheus${this.name} from any namespace`);
@@ -50,8 +49,11 @@ export abstract class PrometheusProvider {
protected async getNamespacedService(client: CoreV1Api, name: string, namespace: string): Promise<PrometheusService> {
try {
const resp = await client.readNamespacedService(name, namespace);
const service = resp.body;
const { body: service } = await client.readNamespacedService(name, namespace);
if (!service.metadata?.namespace || !service.metadata.name || !service.spec?.ports) {
throw new Error(`Service returned from Prometheus${this.name} in namespace="${namespace}" did not have required information`);
}
return {
id: this.id,
@@ -60,7 +62,7 @@ export abstract class PrometheusProvider {
port: service.spec.ports[0].port,
};
} catch(error) {
throw new Error(`Failed to list services for Prometheus${this.name} in namespace=${inspect(namespace, false, undefined, false)}: ${error.response.body.message}`);
throw new Error(`Failed to list services for Prometheus${this.name} in namespace="${namespace}": ${isRequestError(error) ? error.response?.body.message : error}`);
}
}
}
@@ -8,16 +8,18 @@ import * as uuid from "uuid";
import { broadcastMessage } from "../../../common/ipc";
import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler";
import { delay, noop } from "../../../common/utils";
import { LensExtension } from "../../../extensions/main-api";
import { ExtensionsStore } from "../../../extensions/extensions-store/extensions-store";
import type { LensProtocolRouterMain } from "../lens-protocol-router-main/lens-protocol-router-main";
import mockFs from "mock-fs";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable";
import lensProtocolRouterMainInjectable from "../lens-protocol-router-main/lens-protocol-router-main.injectable";
import extensionsStoreInjectable from "../../../extensions/extensions-store/extensions-store.injectable";
import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable";
import appVersionInjectable from "../../../common/get-configuration-file-model/app-version/app-version.injectable";
import { LensExtension } from "../../../extensions/lens-extension";
import type { LensExtensionId } from "../../../extensions/lens-extension";
import type { ObservableMap } from "mobx";
import extensionInstancesInjectable from "../../../extensions/extension-loader/extension-instances.injectable";
jest.mock("../../../common/ipc");
@@ -28,9 +30,7 @@ function throwIfDefined(val: any): void {
}
describe("protocol router tests", () => {
// TODO: This test suite is using any to access protected property.
// Unit tests are allowed to only public interfaces.
let extensionLoader: any;
let extensionInstances: ObservableMap<LensExtensionId, LensExtension>;
let lpr: LensProtocolRouterMain;
let extensionsStore: ExtensionsStore;
@@ -48,10 +48,8 @@ describe("protocol router tests", () => {
await di.runSetups();
extensionLoader = di.inject(extensionLoaderInjectable);
extensionInstances = di.inject(extensionInstancesInjectable);
extensionsStore = di.inject(extensionsStoreInjectable);
lpr = di.inject(lensProtocolRouterMainInjectable);
lpr.rendererLoaded = true;
@@ -100,8 +98,8 @@ describe("protocol router tests", () => {
handler: noop,
});
extensionLoader.instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" });
extensionInstances.set(extId, ext);
extensionsStore.mergeState([[extId, { enabled: true, name: "@mirantis/minikube" }]]);
lpr.addInternalHandler("/", noop);
@@ -180,8 +178,8 @@ describe("protocol router tests", () => {
handler: params => { called = params.pathname.id; },
});
extensionLoader.instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" });
extensionInstances.set(extId, ext);
extensionsStore.mergeState([[extId, { enabled: true, name: "@foobar/icecream" }]]);
try {
expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined();
@@ -219,8 +217,8 @@ describe("protocol router tests", () => {
handler: params => { called = params.pathname.id; },
});
extensionLoader.instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" });
extensionInstances.set(extId, ext);
extensionsStore.mergeState([[extId, { enabled: true, name: "@foobar/icecream" }]]);
}
{
@@ -245,12 +243,14 @@ describe("protocol router tests", () => {
handler: () => { called = 1; },
});
extensionLoader.instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" });
extensionInstances.set(extId, ext);
extensionsStore.mergeState([[extId, { enabled: true, name: "icecream" }]]);
}
(extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" });
(extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" });
extensionsStore.mergeState([
["@foobar/icecream", { enabled: true, name: "@foobar/icecream" }],
["icecream", { enabled: true, name: "icecream" }],
]);
try {
expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined();
@@ -84,7 +84,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter {
await this._routeToExtension(url);
}
} catch (error) {
broadcastMessage(ProtocolHandlerInvalid, error.toString(), rawUrl);
broadcastMessage(ProtocolHandlerInvalid, error ? String(error) : "unknown error", rawUrl);
if (error instanceof proto.RoutingError) {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url });
@@ -118,7 +118,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter {
return "";
}
protected _routeToInternal(url: URLParse<Record<string, string>>): RouteAttempt {
protected _routeToInternal(url: URLParse<Record<string, string | undefined>>): RouteAttempt {
const rawUrl = url.toString(); // for sending to renderer
const attempt = super._routeToInternal(url);
@@ -127,7 +127,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter {
return attempt;
}
protected async _routeToExtension(url: URLParse<Record<string, string>>): Promise<RouteAttempt> {
protected async _routeToExtension(url: URLParse<Record<string, string | undefined>>): Promise<RouteAttempt> {
const rawUrl = url.toString(); // for sending to renderer
/**
@@ -4,7 +4,8 @@
*/
import { chunk } from "lodash";
import tls from "tls";
import type { ConnectionOptions } from "tls";
import { connect } from "tls";
import url from "url";
import { apiKubePrefix } from "../../common/vars";
import type { ProxyApiRequestArgs } from "./types";
@@ -13,16 +14,16 @@ const skipRawHeaders = new Set(["Host", "Authorization"]);
export async function kubeApiUpgradeRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) {
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "");
const proxyCa = await cluster.contextHandler.resolveAuthProxyCa();
const proxyCa = cluster.contextHandler.resolveAuthProxyCa();
const apiUrl = url.parse(cluster.apiUrl);
const pUrl = url.parse(proxyUrl);
const connectOpts = {
port: parseInt(pUrl.port),
host: pUrl.hostname,
const connectOpts: ConnectionOptions = {
port: pUrl.port ? parseInt(pUrl.port) : undefined,
host: pUrl.hostname ?? undefined,
ca: proxyCa,
};
const proxySocket = tls.connect(connectOpts, () => {
const proxySocket = connect(connectOpts, () => {
proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`);
proxySocket.write(`Host: ${apiUrl.host}\r\n`);
@@ -13,7 +13,7 @@ import type { Cluster } from "../../../common/cluster/cluster";
import type { ClusterId } from "../../../common/cluster-types";
interface Dependencies {
authenticateRequest: (clusterId: ClusterId, tabId: string, shellToken: string) => boolean;
authenticateRequest: (clusterId: ClusterId, tabId: string, shellToken: string | undefined) => boolean;
createShellSession: (args: {
webSocket: WebSocket;
@@ -27,7 +27,7 @@ export const shellApiRequest = ({ createShellSession, authenticateRequest }: Dep
const cluster = ClusterManager.getInstance().getClusterForRequest(req);
const { query: { node: nodeName, shellToken, id: tabId }} = new URLParse(req.url, true);
if (!cluster || !authenticateRequest(cluster.id, tabId, shellToken)) {
if (!tabId || !cluster || !authenticateRequest(cluster.id, tabId, shellToken)) {
socket.write("Invalid shell request");
return void socket.end();
@@ -31,10 +31,10 @@ export class ShellRequestAuthenticator {
* @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 => {
authenticate = (clusterId: ClusterId, tabId: string, token: string | undefined): boolean => {
const clusterTokens = this.tokens.get(clusterId);
if (!clusterTokens) {
if (!clusterTokens || !tabId || !token) {
return false;
}
+2 -1
View File
@@ -5,10 +5,11 @@
import type http from "http";
import type net from "net";
import type { SetRequired } from "type-fest";
import type { Cluster } from "../../common/cluster/cluster";
export interface ProxyApiRequestArgs {
req: http.IncomingMessage;
req: SetRequired<http.IncomingMessage, "url" | "method">;
socket: net.Socket;
head: Buffer;
cluster: Cluster;
+17 -8
View File
@@ -12,7 +12,7 @@ import path from "path";
import tempy from "tempy";
import logger from "./logger";
import { appEventBus } from "../common/app-event-bus/event-bus";
import { cloneJsonObject } from "../common/utils";
import { isChildProcessError } from "../common/utils";
import type { Patch } from "rfc6902";
import { promiseExecFile } from "../common/utils/promise-exec";
@@ -54,7 +54,11 @@ export class ResourceApplier {
return stdout;
} catch (error) {
throw error.stderr ?? error;
if (isChildProcessError(error)) {
throw error.stderr ?? error;
}
throw error;
}
}
@@ -92,7 +96,11 @@ export class ResourceApplier {
return stdout;
} catch (error) {
throw error?.stderr ?? error;
if (isChildProcessError(error)) {
throw error.stderr ?? error;
}
throw error;
} finally {
await fs.unlink(fileName);
}
@@ -142,15 +150,16 @@ export class ResourceApplier {
}
protected sanitizeObject(resource: KubernetesObject | any) {
resource = cloneJsonObject(resource);
delete resource.status;
delete resource.metadata?.resourceVersion;
const annotations = resource.metadata?.annotations;
const res = JSON.parse(JSON.stringify(resource));
delete res.status;
delete res.metadata?.resourceVersion;
const annotations = res.metadata?.annotations;
if (annotations) {
delete annotations["kubectl.kubernetes.io/last-applied-configuration"];
}
return resource;
return res;
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* 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 http from "http";
import type httpProxy from "http-proxy";
import type { LensApiResultContentType } from "./router-content-types";
import type { URLSearchParams } from "url";
import type Joi from "joi";
export type InferParam<
T extends string,
PathParams extends Record<string, string>,
> =
T extends `{${infer P}?}`
? PathParams & Partial<Record<P, string>>
: T extends `{${infer P}}`
? PathParams & Record<P, string>
: PathParams;
export type InferParamFromPath<P extends string> =
P extends `${string}/{${infer B}*}${infer Tail}`
? Tail extends ""
? Record<B, string>
: never
: P extends `${infer A}/${infer B}`
? InferParam<A, InferParamFromPath<B>>
: InferParam<P, {}>;
export interface LensApiRequest<Path extends string> {
path: Path;
payload: unknown;
params: InferParamFromPath<Path>;
cluster: Cluster | undefined;
query: URLSearchParams;
raw: {
req: http.IncomingMessage;
res: http.ServerResponse;
};
}
export interface ClusterLensApiRequest<Path extends string> extends LensApiRequest<Path> {
cluster: Cluster;
}
export interface LensApiResult<Response> {
statusCode?: number;
response?: Response;
error?: any;
contentType?: LensApiResultContentType;
headers?: Partial<Record<string, string>>;
proxy?: httpProxy;
}
export type RouteResponse<Response> =
| LensApiResult<Response>
| void;
export interface RouteHandler<TResponse, Path extends string>{
(request: LensApiRequest<Path>): RouteResponse<TResponse> | Promise<RouteResponse<TResponse>>;
}
export interface BaseRoutePaths<Path extends string> {
path: Path;
method: "get" | "post" | "put" | "patch" | "delete";
}
export interface PayloadValidator<Payload> {
validate(payload: unknown): Joi.ValidationResult<Payload>;
}
export interface ValidatorBaseRoutePaths<Path extends string, Payload> extends BaseRoutePaths<Path> {
payloadValidator: PayloadValidator<Payload>;
}
export interface Route<TResponse, Path extends string> extends BaseRoutePaths<Path> {
handler: RouteHandler<TResponse, Path>;
}
export interface BindHandler<Path extends string> {
<TResponse>(handler: RouteHandler<TResponse, Path>): Route<TResponse, Path>;
}
export function route<Path extends string>(parts: BaseRoutePaths<Path>): BindHandler<Path> {
return (handler) => ({
...parts,
handler,
});
}
export interface ClusterRouteHandler<Response, Path extends string>{
(request: ClusterLensApiRequest<Path>): RouteResponse<Response> | Promise<RouteResponse<Response>>;
}
export interface BindClusterHandler<Path extends string> {
<TResponse>(handler: ClusterRouteHandler<TResponse, Path>): Route<TResponse, Path>;
}
export function clusterRoute<Path extends string>(parts: BaseRoutePaths<Path>): BindClusterHandler<Path> {
return (handler) => ({
...parts,
handler: ({ cluster, ...rest }) => {
if (!cluster) {
return {
error: "Cluster missing",
statusCode: 400,
};
}
return handler({ cluster, ...rest });
},
});
}
export interface ValidatedClusterLensApiRequest<Path extends string, Payload> extends ClusterLensApiRequest<Path> {
payload: Payload;
}
export interface ValidatedClusterRouteHandler<Payload, Response, Path extends string> {
(request: ValidatedClusterLensApiRequest<Path, Payload>): RouteResponse<Response> | Promise<RouteResponse<Response>>;
}
export interface BindValidatedClusterHandler<Path extends string, Payload> {
<Response>(handler: ValidatedClusterRouteHandler<Payload, Response, Path>): Route<Response, Path>;
}
export function payloadValidatedClusterRoute<Path extends string, Payload>({ payloadValidator, ...parts }: ValidatorBaseRoutePaths<Path, Payload>): BindValidatedClusterHandler<Path, Payload> {
const boundClusterRoute = clusterRoute(parts);
return (handler) => boundClusterRoute(({ payload, ...rest }) => {
const validationResult = payloadValidator.validate(payload);
if (validationResult.error) {
return {
error: validationResult.error,
statusCode: 400,
};
}
return handler({
payload: validationResult.value,
...rest,
});
});
}
+1 -1
View File
@@ -2,7 +2,7 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { LensApiResult } from "./router";
import type { LensApiResult } from "./route";
export interface LensApiResultContentType {
resultMapper: (result: LensApiResult<any>) => ({
+17 -3
View File
@@ -2,15 +2,29 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
import type { Route } from "./router";
import type { Injectable } from "@ogre-tools/injectable";
import { getInjectable, getInjectionToken, lifecycleEnum } from "@ogre-tools/injectable";
import { Router } from "./router";
import parseRequestInjectable from "./parse-request.injectable";
import type { Route } from "./route";
export const routeInjectionToken = getInjectionToken<Route<any>>({
export const routeInjectionToken = getInjectionToken<Route<unknown, string>>({
id: "route-injection-token",
});
export function getRouteInjectable<
T,
Path extends string,
>(
opts: Omit<Injectable<Route<T, Path>, Route<T, Path>, void>, "lifecycle" | "injectionToken">,
): Injectable<Route<T, Path>, Route<T, Path>, void> {
return {
...opts,
injectionToken: routeInjectionToken as never,
lifecycle: lifecycleEnum.singleton as never,
};
}
const routerInjectable = getInjectable({
id: "router",
+18 -7
View File
@@ -5,22 +5,33 @@
import routerInjectable, { routeInjectionToken } from "./router.injectable";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import type { Router, RouteHandler, Route } from "./router";
import type { Router } from "./router";
import type { Cluster } from "../../common/cluster/cluster";
import { Request } from "mock-http";
import { getInjectable } from "@ogre-tools/injectable";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import parseRequestInjectable from "./parse-request.injectable";
import { contentTypes } from "./router-content-types";
import mockFs from "mock-fs";
import type { MockInstance } from "jest-mock";
import type { SetRequired } from "type-fest";
import type { Route, RouteHandler } from "./route";
type AsyncFnMock<
TToBeMocked extends (...args: any[]) => any,
TArguments extends Parameters<TToBeMocked> = Parameters<TToBeMocked>,
TResolve extends Awaited<ReturnType<TToBeMocked>> = Awaited<ReturnType<TToBeMocked>>,
> = MockInstance<(...args: TArguments) => Promise<TResolve>, TArguments> & {
resolve: (resolvedValue: TResolve) => Promise<void>;
reject: (rejectValue?: any) => Promise<void>;
} & ((...args: TArguments) => Promise<TResolve>);
describe("router", () => {
let router: Router;
let routeHandlerMock: AsyncFnMock<RouteHandler<any>>;
let routeHandlerMock: AsyncFnMock<RouteHandler<any, string>>;
beforeEach(async () => {
routeHandlerMock = asyncFn();
routeHandlerMock = asyncFn() as AsyncFnMock<RouteHandler<any, string>>;
const di = getDiForUnitTesting({ doGeneralOverrides: true });
@@ -37,7 +48,7 @@ describe("router", () => {
method: "get",
path: "/some-path",
handler: routeHandlerMock,
} as Route<any>),
} as Route<any, string>),
injectionToken: routeInjectionToken,
});
@@ -54,7 +65,7 @@ describe("router", () => {
describe("when navigating to the route", () => {
let actualPromise: Promise<boolean>;
let clusterStub: Cluster;
let requestStub: Request;
let requestStub: SetRequired<Request, "url" | "method">;
let responseStub: any;
beforeEach(() => {
@@ -64,7 +75,7 @@ describe("router", () => {
headers: {
"content-type": "application/json",
},
});
}) as SetRequired<Request, "url" | "method">;
responseStub = { end: jest.fn(), setHeader: jest.fn(), write: jest.fn(), statusCode: undefined };
+10 -55
View File
@@ -5,42 +5,20 @@
import Call from "@hapi/call";
import type http from "http";
import type httpProxy from "http-proxy";
import { toPairs } from "lodash/fp";
import type { Cluster } from "../../common/cluster/cluster";
import type { LensApiResultContentType } from "./router-content-types";
import { contentTypes } from "./router-content-types";
import type { ServerIncomingMessage } from "../lens-proxy";
import type { LensApiRequest, LensApiResult, Route } from "./route";
export interface RouterRequestOpts {
req: http.IncomingMessage;
res: http.ServerResponse;
cluster: Cluster;
params: RouteParams;
cluster: Cluster | undefined;
params: any;
url: URL;
}
export interface RouteParams extends Record<string, string> {
path?: string; // *-route
namespace?: string;
service?: string;
account?: string;
release?: string;
repo?: string;
chart?: string;
}
export interface LensApiRequest<P = any> {
path: string;
payload: P;
params: RouteParams;
cluster: Cluster;
query: URLSearchParams;
raw: {
req: http.IncomingMessage;
res: http.ServerResponse;
};
}
interface Dependencies {
parseRequest: (request: http.IncomingMessage, _: null, options: { parse: boolean; output: string }) => Promise<{ payload: any }>;
}
@@ -48,13 +26,13 @@ interface Dependencies {
export class Router {
protected router = new Call.Router();
constructor(routes: Route<unknown>[], private dependencies: Dependencies) {
constructor(routes: Route<unknown, string>[], private dependencies: Dependencies) {
routes.forEach(route => {
this.router.add({ method: route.method, path: route.path }, handleRoute(route));
});
}
public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise<boolean> {
public async route(cluster: Cluster | undefined, req: ServerIncomingMessage, res: http.ServerResponse): Promise<boolean> {
const url = new URL(req.url, "http://localhost");
const path = url.pathname;
const method = req.method.toLowerCase();
@@ -72,7 +50,7 @@ export class Router {
return false;
}
protected async getRequest(opts: RouterRequestOpts): Promise<LensApiRequest> {
protected async getRequest(opts: RouterRequestOpts): Promise<LensApiRequest<string>> {
const { req, res, url, cluster, params } = opts;
const { payload } = await this.dependencies.parseRequest(req, null, {
@@ -86,37 +64,14 @@ export class Router {
raw: {
req, res,
},
query: url.searchParams,
query: url.searchParams as never,
payload,
params,
};
}
}
export interface LensApiResult<TResult> {
statusCode?: number;
response?: TResult;
error?: any;
contentType?: LensApiResultContentType;
headers?: { [name: string]: string };
proxy?: httpProxy;
}
export type RouteHandler<TResponse> = (
request: LensApiRequest
) =>
| Promise<LensApiResult<TResponse>>
| Promise<void>
| LensApiResult<TResponse>
| void;
export interface Route<TResponse> {
path: string;
method: "get" | "post" | "put" | "patch" | "delete";
handler: RouteHandler<TResponse>;
}
const handleRoute = (route: Route<unknown>) => async (request: LensApiRequest, response: http.ServerResponse) => {
const handleRoute = (route: Route<unknown, string>) => async (request: LensApiRequest<string>, response: http.ServerResponse) => {
let result: LensApiResult<any> | void;
const writeServerResponse = writeServerResponseFor(response);
@@ -126,7 +81,7 @@ const handleRoute = (route: Route<unknown>) => async (request: LensApiRequest, r
} catch(error) {
const mappedResult = contentTypes.txt.resultMapper({
statusCode: 500,
error: error.toString(),
error: error ? String(error) : "unknown error",
});
writeServerResponse(mappedResult);
@@ -2,35 +2,28 @@
* 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 { routeInjectionToken } from "../../../router/router.injectable";
import type { Route } from "../../../router/router";
import { helmService } from "../../../helm/helm-service";
import { getRouteInjectable } from "../../../router/router.injectable";
import { apiPrefix } from "../../../../common/vars";
import type { RawHelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import { route } from "../../../router/route";
import { helmService } from "../../../helm/helm-service";
interface GetChartResponse {
readme: string;
versions: RawHelmChart[];
}
const getChartRouteInjectable = getInjectable({
const getChartRouteInjectable = getRouteInjectable({
id: "get-chart-route",
instantiate: (): Route<GetChartResponse> => ({
instantiate: () => route({
method: "get",
path: `${apiPrefix}/v2/charts/{repo}/{chart}`,
})(async ({ params, query }) => {
const { repo, chart } = params;
handler: async ({ params, query }) => ({
return {
response: await helmService.getChart(
params.repo,
params.chart,
query.get("version"),
repo,
chart,
query.get("version") ?? undefined,
),
}),
};
}),
injectionToken: routeInjectionToken,
});
export default getChartRouteInjectable;
@@ -2,32 +2,24 @@
* 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 { routeInjectionToken } from "../../../router/router.injectable";
import type { Route } from "../../../router/router";
import { getRouteInjectable } from "../../../router/router.injectable";
import { helmService } from "../../../helm/helm-service";
import { apiPrefix } from "../../../../common/vars";
import { route } from "../../../router/route";
const getChartRouteValuesInjectable = getInjectable({
const getChartRouteValuesInjectable = getRouteInjectable({
id: "get-chart-route-values",
instantiate: (): Route<string> => ({
instantiate: () => route({
method: "get",
path: `${apiPrefix}/v2/charts/{repo}/{chart}/values`,
handler: async ({
params,
query,
}) => ({
response: await helmService.getChartValues(
params.repo,
params.chart,
query.get("version"),
),
}),
}),
injectionToken: routeInjectionToken,
})(async ({ params, query }) => ({
response: await helmService.getChartValues(
params.repo,
params.chart,
query.get("version") ?? undefined,
),
})),
});
export default getChartRouteValuesInjectable;
@@ -2,25 +2,20 @@
* 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 { routeInjectionToken } from "../../../router/router.injectable";
import type { Route } from "../../../router/router";
import { getRouteInjectable } from "../../../router/router.injectable";
import { helmService } from "../../../helm/helm-service";
import { apiPrefix } from "../../../../common/vars";
import { route } from "../../../router/route";
const listChartsRouteInjectable = getInjectable({
const listChartsRouteInjectable = getRouteInjectable({
id: "list-charts-route",
instantiate: (): Route<any> => ({
instantiate: () => route({
method: "get",
path: `${apiPrefix}/v2/charts`,
handler: async () => ({
response: await helmService.listCharts(),
}),
}),
injectionToken: routeInjectionToken,
})(async () => ({
response: await helmService.listCharts(),
})),
});
export default listChartsRouteInjectable;
@@ -3,32 +3,23 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { Route } from "../../../router/router";
import { helmService } from "../../../helm/helm-service";
import { routeInjectionToken } from "../../../router/router.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import { getRouteInjectable } from "../../../router/router.injectable";
import { clusterRoute } from "../../../router/route";
const deleteReleaseRouteInjectable = getInjectable({
const deleteReleaseRouteInjectable = getRouteInjectable({
id: "delete-release-route",
instantiate: (): Route<string> => ({
instantiate: () => clusterRoute({
method: "delete",
path: `${apiPrefix}/v2/releases/{namespace}/{release}`,
handler: async (request) => {
const { cluster, params } = request;
return {
response: await helmService.deleteRelease(
cluster,
params.release,
params.namespace,
),
};
},
}),
injectionToken: routeInjectionToken,
})(async ({ cluster, params: { release, namespace }}) => ({
response: await helmService.deleteRelease(
cluster,
release,
namespace,
),
})),
});
export default deleteReleaseRouteInjectable;
@@ -3,32 +3,23 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { Route } from "../../../router/router";
import { helmService } from "../../../helm/helm-service";
import { routeInjectionToken } from "../../../router/router.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import { getRouteInjectable } from "../../../router/router.injectable";
import { clusterRoute } from "../../../router/route";
const getReleaseRouteHistoryInjectable = getInjectable({
const getReleaseRouteHistoryInjectable = getRouteInjectable({
id: "get-release-history-route",
instantiate: (): Route<any> => ({
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace}/{release}/history`,
handler: async (request) => {
const { cluster, params } = request;
return {
response: await helmService.getReleaseHistory(
cluster,
params.release,
params.namespace,
),
};
},
}),
injectionToken: routeInjectionToken,
})(async ({ cluster, params }) => ({
response: await helmService.getReleaseHistory(
cluster,
params.release,
params.namespace,
),
})),
});
export default getReleaseRouteHistoryInjectable;
@@ -3,32 +3,23 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { Route } from "../../../router/router";
import { helmService } from "../../../helm/helm-service";
import { routeInjectionToken } from "../../../router/router.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import { getRouteInjectable } from "../../../router/router.injectable";
import { clusterRoute } from "../../../router/route";
const getReleaseRouteInjectable = getInjectable({
const getReleaseRouteInjectable = getRouteInjectable({
id: "get-release-route",
instantiate: (): Route<any> => ({
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace}/{release}`,
handler: async (request) => {
const { cluster, params } = request;
return {
response: await helmService.getRelease(
cluster,
params.release,
params.namespace,
),
};
},
}),
injectionToken: routeInjectionToken,
})(async ({ cluster, params }) => ({
response: await helmService.getRelease(
cluster,
params.release,
params.namespace,
),
})),
});
export default getReleaseRouteInjectable;
@@ -3,37 +3,27 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { Route } from "../../../router/router";
import { helmService } from "../../../helm/helm-service";
import { routeInjectionToken } from "../../../router/router.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import { getRouteInjectable } from "../../../router/router.injectable";
import { getBoolean } from "../../../utils/parse-query";
import { contentTypes } from "../../../router/router-content-types";
import { clusterRoute } from "../../../router/route";
const getReleaseRouteValuesInjectable = getInjectable({
const getReleaseRouteValuesInjectable = getRouteInjectable({
id: "get-release-values-route",
instantiate: (): Route<string> => ({
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace}/{release}/values`,
})(async ({ cluster, params: { namespace, release }, query }) => ({
response: await helmService.getReleaseValues(release, {
cluster,
namespace,
all: getBoolean(query, "all"),
}),
handler: async (request) => {
const { cluster, params: { namespace, release }, query } = request;
const all = getBoolean(query, "all");
return {
response: await helmService.getReleaseValues(release, {
cluster,
namespace,
all,
}),
contentType: contentTypes.txt,
};
},
}),
injectionToken: routeInjectionToken,
contentType: contentTypes.txt,
})),
});
export default getReleaseRouteValuesInjectable;
@@ -3,34 +3,42 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { Route } from "../../../router/router";
import type { InstallChartArgs } from "../../../helm/helm-service";
import { helmService } from "../../../helm/helm-service";
import { routeInjectionToken } from "../../../router/router.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import { getRouteInjectable } from "../../../router/router.injectable";
import Joi from "joi";
import { payloadValidatedClusterRoute } from "../../../router/route";
interface InstallChartResponse {
log: string;
release: { name: string; namespace: string };
}
const installChartArgsValidator = Joi.object<InstallChartArgs, true, InstallChartArgs>({
chart: Joi
.string()
.required(),
values: Joi
.object()
.required()
.unknown(true),
name: Joi
.string()
.required(),
namespace: Joi
.string()
.required(),
version: Joi
.string()
.required(),
});
const installChartRouteInjectable = getInjectable({
const installChartRouteInjectable = getRouteInjectable({
id: "install-chart-route",
instantiate: () : Route<InstallChartResponse> => ({
instantiate: () => payloadValidatedClusterRoute({
method: "post",
path: `${apiPrefix}/v2/releases`,
handler: async (request) => {
const { payload, cluster } = request;
return {
response: await helmService.installChart(cluster, payload),
statusCode: 201,
};
},
}),
injectionToken: routeInjectionToken,
payloadValidator: installChartArgsValidator,
})(async ({ payload, cluster }) => ({
response: await helmService.installChart(cluster, payload),
statusCode: 201,
})),
});
export default installChartRouteInjectable;
@@ -3,28 +3,19 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { Route } from "../../../router/router";
import { helmService } from "../../../helm/helm-service";
import { routeInjectionToken } from "../../../router/router.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import { getRouteInjectable } from "../../../router/router.injectable";
import { clusterRoute } from "../../../router/route";
const listReleasesRouteInjectable = getInjectable({
const listReleasesRouteInjectable = getRouteInjectable({
id: "list-releases-route",
instantiate: (): Route<Record<string, any>> => ({
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace?}`,
handler: async (request) => {
const { cluster, params } = request;
return {
response: await helmService.listReleases(cluster, params.namespace),
};
},
}),
injectionToken: routeInjectionToken,
})(async ({ cluster, params }) => ({
response: await helmService.listReleases(cluster, params.namespace),
})),
});
export default listReleasesRouteInjectable;
@@ -3,26 +3,31 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { Route } from "../../../router/router";
import { helmService } from "../../../helm/helm-service";
import { routeInjectionToken } from "../../../router/router.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import { getRouteInjectable } from "../../../router/router.injectable";
import Joi from "joi";
import { payloadValidatedClusterRoute } from "../../../router/route";
const rollbackReleaseRouteInjectable = getInjectable({
interface RollbackReleasePayload {
revision: number;
}
const rollbackReleasePayloadValidator = Joi.object<RollbackReleasePayload, true, RollbackReleasePayload>({
revision: Joi
.number()
.required(),
});
const rollbackReleaseRouteInjectable = getRouteInjectable({
id: "rollback-release-route",
instantiate: (): Route<void> => ({
instantiate: () => payloadValidatedClusterRoute({
method: "put",
path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback`,
handler: async (request) => {
const { cluster, params, payload } = request;
await helmService.rollback(cluster, params.release, params.namespace, payload.revision);
},
payloadValidator: rollbackReleasePayloadValidator,
})(async ({ cluster, params: { release, namespace }, payload }) => {
await helmService.rollback(cluster, release, namespace, payload.revision);
}),
injectionToken: routeInjectionToken,
});
export default rollbackReleaseRouteInjectable;
@@ -3,38 +3,39 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { Route } from "../../../router/router";
import type { UpdateChartArgs } from "../../../helm/helm-service";
import { helmService } from "../../../helm/helm-service";
import { routeInjectionToken } from "../../../router/router.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import { getRouteInjectable } from "../../../router/router.injectable";
import { payloadValidatedClusterRoute } from "../../../router/route";
import Joi from "joi";
interface UpdateReleaseResponse {
log: string;
release: { name: string; namespace: string };
}
const updateChartArgsValidator = Joi.object<UpdateChartArgs, true, UpdateChartArgs>({
chart: Joi
.string()
.required(),
version: Joi
.string()
.required(),
values: Joi
.object()
.unknown(true),
});
const updateReleaseRouteInjectable = getInjectable({
const updateReleaseRouteInjectable = getRouteInjectable({
id: "update-release-route",
instantiate: (): Route<UpdateReleaseResponse> => ({
instantiate: () => payloadValidatedClusterRoute({
method: "put",
path: `${apiPrefix}/v2/releases/{namespace}/{release}`,
handler: async ({
payloadValidator: updateChartArgsValidator,
})(async ({ cluster, params, payload }) => ({
response: await helmService.updateRelease(
cluster,
params,
params.release,
params.namespace,
payload,
}) => ({
response: await helmService.updateRelease(
cluster,
params.release,
params.namespace,
payload,
),
}),
}),
injectionToken: routeInjectionToken,
),
})),
});
export default updateReleaseRouteInjectable;
@@ -3,43 +3,88 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { apiPrefix } from "../../../common/vars";
import type { Route } from "../../router/router";
import { routeInjectionToken } from "../../router/router.injectable";
import { getRouteInjectable } from "../../router/router.injectable";
import type { Cluster } from "../../../common/cluster/cluster";
import type { V1Secret } from "@kubernetes/client-node";
import { CoreV1Api } from "@kubernetes/client-node";
import { clusterRoute } from "../../router/route";
const getServiceAccountRouteInjectable = getInjectable({
const getServiceAccountRouteInjectable = getRouteInjectable({
id: "get-service-account-route",
instantiate: (): Route<ReturnType<typeof generateKubeConfig>> => ({
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}`,
})(async ({ params, cluster }) => {
const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api);
const secretList = await client.listNamespacedSecret(params.namespace);
handler: async (request) => {
const { params, cluster } = request;
const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api);
const secretList = await client.listNamespacedSecret(params.namespace);
const secret = secretList.body.items.find(secret => {
const { annotations } = secret.metadata ?? {};
const secret = secretList.body.items.find(secret => {
const { annotations } = secret.metadata;
return annotations?.["kubernetes.io/service-account.name"] === params.account;
});
return annotations && annotations["kubernetes.io/service-account.name"] == params.account;
});
if (!secret) {
return {
error: "No secret found",
statusCode: 404,
};
}
return { response: generateKubeConfig(params.account, secret, cluster) };
},
const kubeconfig = generateKubeConfig(params.account, secret, cluster);
if (!kubeconfig) {
return {
error: "No secret found",
statusCode: 404,
};
}
return {
response: kubeconfig,
};
}),
injectionToken: routeInjectionToken,
});
export default getServiceAccountRouteInjectable;
function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) {
const tokenData = Buffer.from(secret.data["token"], "base64");
interface ServiceAccountKubeConfig {
apiVersion: string;
kind: string;
clusters: {
name: string;
cluster: {
server: string;
"certificate-authority-data": string;
};
}[];
users: {
name: string;
user: {
token: string;
};
}[];
contexts: {
name: string;
context: {
user: string;
cluster: string;
namespace: string | undefined;
};
}[];
"current-context": string;
}
function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster): ServiceAccountKubeConfig | undefined {
if (!secret.data || !secret.metadata) {
return undefined;
}
const { token, "ca.crt": caCrt } = secret.data;
const tokenData = Buffer.from(token, "base64");
return {
"apiVersion": "v1",
@@ -49,7 +94,7 @@ function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster
"name": cluster.contextName,
"cluster": {
"server": cluster.apiUrl,
"certificate-authority-data": secret.data["ca.crt"],
"certificate-authority-data": caCrt,
},
},
],
@@ -3,16 +3,17 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { apiPrefix } from "../../../common/vars";
import type { LensApiRequest, Route } from "../../router/router";
import { routeInjectionToken } from "../../router/router.injectable";
import { getRouteInjectable } from "../../router/router.injectable";
import type { ClusterPrometheusMetadata } from "../../../common/cluster-types";
import { ClusterMetadataKey } from "../../../common/cluster-types";
import logger from "../../logger";
import type { Cluster } from "../../../common/cluster/cluster";
import { getMetrics } from "../../k8s-request";
import type { IMetricsQuery } from "./metrics-query";
import { clusterRoute } from "../../router/route";
import { isObject } from "lodash";
import { isRequestError } from "../../../common/utils";
// This is used for backoff retry tracking.
const ATTEMPTS = [false, false, false, false, true];
@@ -27,8 +28,13 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa
try {
return await getMetrics(cluster, prometheusPath, { query, ...queryParams });
} catch (error) {
if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) {
logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error);
if (isRequestError(error)) {
if (lastAttempt || (error.statusCode && error.statusCode >= 400 && error.statusCode < 500)) {
logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error);
throw new Error("Metrics not available");
}
} else {
logger.error("[Metrics]: metrics not available", error);
throw new Error("Metrics not available");
}
@@ -43,67 +49,66 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa
return Promise.all(queries.map(loadMetric));
}
const addMetricsRoute = async ({ cluster, payload, query }: LensApiRequest) => {
const queryParams: IMetricsQuery = Object.fromEntries(query.entries());
const prometheusMetadata: ClusterPrometheusMetadata = {};
try {
const { prometheusPath, provider } = await cluster.contextHandler.getPrometheusDetails();
prometheusMetadata.provider = provider?.id;
prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type;
if (!prometheusPath) {
prometheusMetadata.success = false;
return { response: {}};
}
// return data in same structure as query
if (typeof payload === "string") {
const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams);
return { response: data };
}
if (Array.isArray(payload)) {
const data = await loadMetrics(payload, cluster, prometheusPath, queryParams);
return { response: data };
}
const queries = Object.entries<Record<string, string>>(payload)
.map(([queryName, queryOpts]) => (
provider.getQuery(queryOpts, queryName)
));
const result = await loadMetrics(queries, cluster, prometheusPath, queryParams);
const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]]));
prometheusMetadata.success = true;
return { response: data };
} catch (error) {
prometheusMetadata.success = false;
logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}:`, error);
return { response: {}};
} finally {
cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata;
}
};
const addMetricsRouteInjectable = getInjectable({
const addMetricsRouteInjectable = getRouteInjectable({
id: "add-metrics-route",
instantiate: (): Route<any> => ({
instantiate: () => clusterRoute({
method: "post",
path: `${apiPrefix}/metrics`,
handler: addMetricsRoute,
}),
})(async ({ cluster, payload, query }) => {
const queryParams: IMetricsQuery = Object.fromEntries(query.entries());
const prometheusMetadata: ClusterPrometheusMetadata = {};
injectionToken: routeInjectionToken,
try {
const { prometheusPath, provider } = await cluster.contextHandler.getPrometheusDetails();
prometheusMetadata.provider = provider?.id;
prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type;
if (!prometheusPath) {
prometheusMetadata.success = false;
return { response: {}};
}
// return data in same structure as query
if (typeof payload === "string") {
const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams);
return { response: data };
}
if (Array.isArray(payload)) {
const data = await loadMetrics(payload, cluster, prometheusPath, queryParams);
return { response: data };
}
if (isObject(payload)) {
const queries = Object.entries(payload as Record<string, Record<string, string>>)
.map(([queryName, queryOpts]) => (
provider.getQuery(queryOpts, queryName)
));
const result = await loadMetrics(queries, cluster, prometheusPath, queryParams);
const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]]));
prometheusMetadata.success = true;
return { response: data };
}
return { response: {}};
} catch (error) {
prometheusMetadata.success = false;
logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}:`, error);
return { response: {}};
} finally {
cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata;
}
}),
});
export default addMetricsRouteInjectable;
@@ -3,32 +3,26 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { apiPrefix } from "../../../common/vars";
import type { Route } from "../../router/router";
import { routeInjectionToken } from "../../router/router.injectable";
import { getRouteInjectable } from "../../router/router.injectable";
import { PrometheusProviderRegistry } from "../../prometheus";
import type { MetricProviderInfo } from "../../../common/k8s-api/endpoints/metrics.api";
import { route } from "../../router/route";
const getMetricProvidersRouteInjectable = getInjectable({
const getMetricProvidersRouteInjectable = getRouteInjectable({
id: "get-metric-providers-route",
instantiate: (): Route<MetricProviderInfo[]> => ({
instantiate: () => route({
method: "get",
path: `${apiPrefix}/metrics/providers`,
handler: () => {
const providers: MetricProviderInfo[] = [];
for (const { name, id, isConfigurable } of PrometheusProviderRegistry.getInstance().providers.values()) {
providers.push({ name, id, isConfigurable });
}
return { response: providers };
},
}),
injectionToken: routeInjectionToken,
})(() => ({
response: Array.from(
PrometheusProviderRegistry
.getInstance()
.providers
.values(),
({ name, id, isConfigurable }) => ({ name, id, isConfigurable }),
),
})),
});
export default getMetricProvidersRouteInjectable;
@@ -7,8 +7,9 @@ import { getPortFrom } from "../../../utils/get-port";
import type { ChildProcessWithoutNullStreams } from "child_process";
import { spawn } from "child_process";
import * as tcpPortUsed from "tcp-port-used";
import { TypedRegEx } from "typed-regex";
const internalPortRegex = /^forwarding from (?<address>.+) ->/i;
const internalPortRegex = TypedRegEx("^forwarding from (?<address>.+) ->", "i");
export interface PortForwardArgs {
clusterId: string;
@@ -36,7 +37,7 @@ export class PortForward {
));
}
public process: ChildProcessWithoutNullStreams;
public process?: ChildProcessWithoutNullStreams;
public clusterId: string;
public kind: string;
public namespace: string;
@@ -98,6 +99,6 @@ export class PortForward {
}
public async stop() {
this.process.kill();
this.process?.kill();
}
}
@@ -2,36 +2,33 @@
* 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 { routeInjectionToken } from "../../router/router.injectable";
import type { LensApiRequest, Route } from "../../router/router";
import { getRouteInjectable } from "../../router/router.injectable";
import { apiPrefix } from "../../../common/vars";
import { PortForward } from "./functionality/port-forward";
import { clusterRoute } from "../../router/route";
const getCurrentPortForward = async (request: LensApiRequest) => {
const { params, query, cluster } = request;
const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
const portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace, port, forwardPort,
});
return { response: { port: portForward?.forwardPort ?? null }};
};
const getCurrentPortForwardRouteInjectable = getInjectable({
const getCurrentPortForwardRouteInjectable = getRouteInjectable({
id: "get-current-port-forward-route",
instantiate: (): Route<{ port: number }> => ({
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`,
handler: getCurrentPortForward,
}),
})(async ({ params, query, cluster }) => {
const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
injectionToken: routeInjectionToken,
const portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace, port, forwardPort,
});
return {
response: {
port: portForward?.forwardPort ?? null,
},
};
}),
});
export default getCurrentPortForwardRouteInjectable;
@@ -2,97 +2,90 @@
* 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 { routeInjectionToken } from "../../router/router.injectable";
import type { LensApiRequest, Route } from "../../router/router";
import { getRouteInjectable } from "../../router/router.injectable";
import { apiPrefix } from "../../../common/vars";
import type { PortForwardArgs } from "./functionality/port-forward";
import { PortForward } from "./functionality/port-forward";
import logger from "../../logger";
import createPortForwardInjectable from "./functionality/create-port-forward.injectable";
import { clusterRoute } from "../../router/route";
interface Dependencies {
createPortForward: (pathToKubeConfig: string, args: PortForwardArgs) => PortForward;
}
const startPortForwardRouteInjectable = getRouteInjectable({
id: "start-current-port-forward-route",
const startPortForward = ({ createPortForward }: Dependencies) => async (request: LensApiRequest) => {
const { params, query, cluster } = request;
const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
instantiate: (di) => {
const createPortForward = di.inject(createPortForwardInjectable);
try {
let portForward = PortForward.getPortforward({
clusterId: cluster.id,
kind: resourceType,
name: resourceName,
namespace,
port,
forwardPort,
});
return clusterRoute({
method: "post",
path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`,
})(async ({ params, query, cluster }) => {
const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
if (!portForward) {
logger.info(
`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`,
);
const thePort =
0 < forwardPort && forwardPort < 65536 ? forwardPort : 0;
portForward = createPortForward(await cluster.getProxyKubeconfigPath(), {
clusterId: cluster.id,
kind: resourceType,
namespace,
name: resourceName,
port,
forwardPort: thePort,
});
const started = await portForward.start();
if (!started) {
logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", {
try {
let portForward = PortForward.getPortforward({
clusterId: cluster.id,
kind: resourceType,
name: resourceName,
namespace,
port,
resourceType,
resourceName,
forwardPort,
});
if (!portForward) {
logger.info(
`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`,
);
const thePort = 0 < forwardPort && forwardPort < 65536
? forwardPort
: 0;
portForward = createPortForward(await cluster.getProxyKubeconfigPath(), {
clusterId: cluster.id,
kind: resourceType,
namespace,
name: resourceName,
port,
forwardPort: thePort,
});
const started = await portForward.start();
if (!started) {
logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", {
namespace,
port,
resourceType,
resourceName,
});
return {
error: {
message: `Failed to forward port ${port} to ${
thePort ? forwardPort : "random port"
}`,
},
};
}
}
return { response: { port: portForward.forwardPort }};
} catch (error) {
logger.error(
`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`,
{ namespace, port, resourceType, resourceName },
);
return {
error: {
message: `Failed to forward port ${port} to ${
thePort ? forwardPort : "random port"
}`,
message: `Failed to forward port ${port}`,
},
};
}
}
return { response: { port: portForward.forwardPort }};
} catch (error) {
logger.error(
`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`,
{ namespace, port, resourceType, resourceName },
);
return {
error: {
message: `Failed to forward port ${port}`,
},
};
}
};
const startPortForwardRouteInjectable = getInjectable({
id: "start-current-port-forward-route",
instantiate: (di): Route<{ port: number }> => ({
method: "post",
path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`,
handler: startPortForward({ createPortForward: di.inject(createPortForwardInjectable) }),
}),
injectionToken: routeInjectionToken,
});
},
});
export default startPortForwardRouteInjectable;
@@ -2,50 +2,42 @@
* 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 { routeInjectionToken } from "../../router/router.injectable";
import type { LensApiRequest, Route } from "../../router/router";
import { getRouteInjectable } from "../../router/router.injectable";
import { apiPrefix } from "../../../common/vars";
import { PortForward } from "./functionality/port-forward";
import logger from "../../logger";
import { clusterRoute } from "../../router/route";
const stopCurrentPortForward = async (request: LensApiRequest) => {
const { params, query, cluster } = request;
const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
const portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace, port, forwardPort,
});
try {
await portForward.stop();
return { response: { status: true }};
} catch (error) {
logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName });
return {
error: {
message: `error stopping a forward port ${port}`,
},
};
}
};
const stopCurrentPortForwardRouteInjectable = getInjectable({
const stopCurrentPortForwardRouteInjectable = getRouteInjectable({
id: "stop-current-port-forward-route",
instantiate: (): Route<{ status: boolean }> => ({
instantiate: () => clusterRoute({
method: "delete",
path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`,
handler: stopCurrentPortForward,
}),
})(async ({ params, query, cluster }) => {
const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
const portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace, port, forwardPort,
});
injectionToken: routeInjectionToken,
try {
await portForward?.stop();
return { response: { status: true }};
} catch (error) {
logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName });
return {
error: {
message: `error stopping a forward port ${port}`,
},
};
}
}),
});
export default stopCurrentPortForwardRouteInjectable;
@@ -2,25 +2,20 @@
* 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 { routeInjectionToken } from "../../router/router.injectable";
import type { Route } from "../../router/router";
import { getRouteInjectable } from "../../router/router.injectable";
import { apiPrefix } from "../../../common/vars";
import { ResourceApplier } from "../../resource-applier";
import { clusterRoute } from "../../router/route";
const applyResourceRouteInjectable = getInjectable({
const applyResourceRouteInjectable = getRouteInjectable({
id: "apply-resource-route",
instantiate: (): Route<string> => ({
instantiate: () => clusterRoute({
method: "post",
path: `${apiPrefix}/stack`,
handler: async ({ cluster, payload }) => ({
response: await new ResourceApplier(cluster).apply(payload),
}),
}),
injectionToken: routeInjectionToken,
})(async ({ cluster, payload }) => ({
response: await new ResourceApplier(cluster).apply(payload),
})),
});
export default applyResourceRouteInjectable;
@@ -2,30 +2,56 @@
* 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 { routeInjectionToken } from "../../router/router.injectable";
import type { Route } from "../../router/router";
import { getRouteInjectable } from "../../router/router.injectable";
import { apiPrefix } from "../../../common/vars";
import { ResourceApplier } from "../../resource-applier";
import { payloadValidatedClusterRoute } from "../../router/route";
import Joi from "joi";
import type { Patch } from "rfc6902";
const patchResourceRouteInjectable = getInjectable({
interface PatchResourcePayload {
name: string;
kind: string;
patch: Patch;
ns?: string;
}
const patchResourcePayloadValidator = Joi.object<PatchResourcePayload, true, PatchResourcePayload>({
name: Joi
.string()
.required(),
kind: Joi
.string()
.required(),
ns: Joi
.string()
.optional(),
patch: Joi
.array()
.allow(
Joi.object({
op: Joi
.string()
.required(),
}).unknown(true),
),
});
const patchResourceRouteInjectable = getRouteInjectable({
id: "patch-resource-route",
instantiate: (): Route<string> => ({
instantiate: () => payloadValidatedClusterRoute({
method: "patch",
path: `${apiPrefix}/stack`,
handler: async ({ cluster, payload }) => ({
response: await new ResourceApplier(cluster).patch(
payload.name,
payload.kind,
payload.patch,
payload.ns,
),
}),
}),
injectionToken: routeInjectionToken,
payloadValidator: patchResourcePayloadValidator,
})(async ({ cluster, payload }) => ({
response: await new ResourceApplier(cluster).patch(
payload.name,
payload.kind,
payload.patch,
payload.ns,
),
})),
});
export default patchResourceRouteInjectable;
+21 -21
View File
@@ -2,12 +2,10 @@
* 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 { LensApiRequest, Route } from "../router/router";
import type { SupportedFileExtension } from "../router/router-content-types";
import { contentTypes } from "../router/router-content-types";
import logger from "../logger";
import { routeInjectionToken } from "../router/router.injectable";
import { getRouteInjectable } from "../router/router.injectable";
import { appName, publicPath, staticFilesDirectory } from "../../common/vars";
import path from "path";
import isDevelopmentInjectable from "../../common/vars/is-development.injectable";
@@ -18,6 +16,8 @@ import getAbsolutePathInjectable from "../../common/path/get-absolute-path.injec
import type { JoinPaths } from "../../common/path/join-paths.injectable";
import joinPathsInjectable from "../../common/path/join-paths.injectable";
import { webpackDevServerPort } from "../../../webpack/vars";
import type { LensApiRequest, RouteResponse } from "../router/route";
import { route } from "../router/route";
interface ProductionDependencies {
readFileBuffer: (path: string) => Promise<Buffer>;
@@ -27,7 +27,7 @@ interface ProductionDependencies {
const handleStaticFileInProduction =
({ readFileBuffer, getAbsolutePath, joinPaths }: ProductionDependencies) =>
async ({ params }: LensApiRequest) => {
async ({ params }: LensApiRequest<"/{path*}">): Promise<RouteResponse<Buffer>> => {
const staticPath = getAbsolutePath(staticFilesDirectory);
let filePath = params.path;
@@ -49,7 +49,7 @@ const handleStaticFileInProduction =
return { response: await readFileBuffer(asset), contentType };
} catch (err) {
if (retryCount > 5) {
logger.error("handleStaticFile:", err.toString());
logger.error("handleStaticFile:", String(err));
return { statusCode: 404 };
}
@@ -67,10 +67,8 @@ interface DevelopmentDependencies {
const handleStaticFileInDevelopment =
({ proxy }: DevelopmentDependencies) =>
(apiReq: LensApiRequest) => {
const { req, res } = apiReq.raw;
if (req.url === "/" || !req.url.startsWith("/build/")) {
({ raw: { req, res }}: LensApiRequest<"/{path*}">): RouteResponse<Buffer> => {
if (req.url === "/" || !req.url?.startsWith("/build/")) {
req.url = `${publicPath}/${appName}.html`;
}
@@ -81,25 +79,27 @@ const handleStaticFileInDevelopment =
return { proxy };
};
const staticFileRouteInjectable = getInjectable({
const staticFileRouteInjectable = getRouteInjectable({
id: "static-file-route",
instantiate: (di): Route<Buffer> => {
instantiate: (di) => {
const isDevelopment = di.inject(isDevelopmentInjectable);
const readFileBuffer = di.inject(readFileBufferInjectable);
const getAbsolutePath = di.inject(getAbsolutePathInjectable);
const joinPaths = di.inject(joinPathsInjectable);
return {
return route({
method: "get",
path: `/{path*}`,
handler: isDevelopment
? handleStaticFileInDevelopment({ proxy: httpProxy.createProxy() })
: handleStaticFileInProduction({ readFileBuffer, getAbsolutePath, joinPaths }),
};
})(
isDevelopment
? handleStaticFileInDevelopment({
proxy: httpProxy.createProxy(),
})
: handleStaticFileInProduction({
readFileBuffer: di.inject(readFileBufferInjectable),
getAbsolutePath: di.inject(getAbsolutePathInjectable),
joinPaths: di.inject(joinPathsInjectable),
}),
);
},
injectionToken: routeInjectionToken,
});
export default staticFileRouteInjectable;
@@ -2,22 +2,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 { Route } from "../../router/router";
import { routeInjectionToken } from "../../router/router.injectable";
import { getRouteInjectable } from "../../router/router.injectable";
import { getAppVersion } from "../../../common/utils";
import { route } from "../../router/route";
const getVersionRouteInjectable = getInjectable({
const getVersionRouteInjectable = getRouteInjectable({
id: "get-version-route",
instantiate: (): Route<{ version: string }> => ({
instantiate: () => route({
method: "get",
path: `/version`,
handler: () => ({ response: { version: getAppVersion() }}),
}),
injectionToken: routeInjectionToken,
})(() => ({
response: {
version: getAppVersion(),
},
})),
});
export default getVersionRouteInjectable;
@@ -15,7 +15,7 @@ import { baseBinariesDir } from "../../../common/vars";
export class LocalShellSession extends ShellSession {
ShellType = "shell";
constructor(protected shellEnvModify: (clusterId: ClusterId, env: Record<string, string>) => Record<string, string>, kubectl: Kubectl, websocket: WebSocket, cluster: Cluster, terminalId: string) {
constructor(protected shellEnvModify: (clusterId: ClusterId, env: Record<string, string | undefined>) => Record<string, string | undefined>, kubectl: Kubectl, websocket: WebSocket, cluster: Cluster, terminalId: string) {
super(kubectl, websocket, cluster, terminalId);
}
@@ -34,9 +34,14 @@ export class LocalShellSession extends ShellSession {
env = this.shellEnvModify(this.cluster.id, env);
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(env.PTYSHELL, args, env);
await this.openShellProcess(shell, args, env);
}
protected async getShellArgs(shell: string): Promise<string[]> {
@@ -5,12 +5,12 @@
import type WebSocket from "ws";
import { v4 as uuid } from "uuid";
import * as k8s from "@kubernetes/client-node";
import { Watch, CoreV1Api } from "@kubernetes/client-node";
import type { KubeConfig } from "@kubernetes/client-node";
import type { Cluster } from "../../../common/cluster/cluster";
import { ShellOpenError, ShellSession } from "../shell-session";
import { get } from "lodash";
import { Node, NodesApi } from "../../../common/k8s-api/endpoints";
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 { TerminalChannels } from "../../../renderer/api/terminal-api";
@@ -20,7 +20,6 @@ export class NodeShellSession extends ShellSession {
ShellType = "node-shell";
protected readonly podName = `node-shell-${uuid()}`;
protected kc: KubeConfig;
protected readonly cwd: string | undefined = undefined;
@@ -29,29 +28,49 @@ export class NodeShellSession extends ShellSession {
}
public async open() {
this.kc = await this.cluster.getProxyKubeconfig();
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 => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error));
});
this.websocket.once("close", cleanup);
try {
await this.createNodeShellPod();
await this.waitForRunningPod();
await this.createNodeShellPod(coreApi);
await this.waitForRunningPod(kc);
} catch (error) {
this.deleteNodeShellPod();
cleanup();
this.send({
type: TerminalChannels.STDOUT,
data: `Error occurred: ${get(error, "response.body.message", error?.toString() || "unknown error")}`,
data: `Error occurred: ${get(error, "response.body.message", error ? String(error) : "unknown error")}`,
});
throw new ShellOpenError("failed to create node pod", 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 = new NodesApi({
const nodeApi = new NodeApi({
objectConstructor: Node,
request: KubeJsonApi.forCluster(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) {
@@ -69,16 +88,14 @@ export class NodeShellSession extends ShellSession {
await this.openShellProcess(shell, args, env);
}
protected createNodeShellPod() {
protected createNodeShellPod(coreApi: CoreV1Api) {
const imagePullSecrets = this.cluster.imagePullSecret
? [{
name: this.cluster.imagePullSecret,
}]
: undefined;
return this
.kc
.makeApiClient(k8s.CoreV1Api)
return coreApi
.createNamespacedPod("kube-system", {
metadata: {
name: this.podName,
@@ -109,11 +126,11 @@ export class NodeShellSession extends ShellSession {
});
}
protected waitForRunningPod(): Promise<void> {
protected waitForRunningPod(kc: KubeConfig): Promise<void> {
logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`);
return new Promise((resolve, reject) => {
new k8s.Watch(this.kc)
new Watch(kc)
.watch(`/api/v1/namespaces/kube-system/pods`,
{},
// callback is called for each received object.
@@ -146,17 +163,4 @@ export class NodeShellSession extends ShellSession {
});
});
}
protected exit() {
super.exit();
this.deleteNodeShellPod();
}
protected deleteNodeShellPod() {
this
.kc
.makeApiClient(k8s.CoreV1Api)
.deleteNamespacedPod(this.podName, "kube-system")
.catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error));
}
}
@@ -6,19 +6,20 @@
import type { IComputedValue } from "mobx";
import { computed } from "mobx";
import type { ClusterId } from "../../../common/cluster-types";
import { isDefined } from "../../../common/utils";
import type { LensMainExtension } from "../../../extensions/lens-main-extension";
import { catalogEntityRegistry } from "../../catalog";
interface Dependencies {
extensions: IComputedValue<LensMainExtension[]>;
}
export const terminalShellEnvModify = ({ extensions }: Dependencies) =>
(clusterId: ClusterId, env: Record<string, string>) => {
export const terminalShellEnvModify = ({ extensions }: Dependencies) =>
(clusterId: ClusterId, env: Record<string, string | undefined>) => {
const terminalShellEnvModifiers = computed(() => (
extensions.get()
.map((extension) => extension.terminalShellEnvModifier)
.filter(Boolean)
.filter(isDefined)
))
.get();
@@ -26,7 +27,7 @@ export const terminalShellEnvModify = ({ extensions }: Dependencies) =>
return env;
}
const entity = catalogEntityRegistry.getById(clusterId);
const entity = catalogEntityRegistry.findById(clusterId);
if (entity) {
const ctx = { catalogEntity: entity };
+19 -21
View File
@@ -20,10 +20,11 @@ import type { TerminalMessage } from "../../renderer/api/terminal-api";
import { TerminalChannels } from "../../renderer/api/terminal-api";
import { deserialize, serialize } from "v8";
import { stat } from "fs/promises";
import { getOrInsertWith } from "../../common/utils";
export class ShellOpenError extends Error {
constructor(message: string, public cause: Error) {
super(`${message}: ${cause}`);
constructor(message: string, options?: ErrorOptions) {
super(`${message}`, options);
this.name = this.constructor.name;
Error.captureStackTrace(this);
}
@@ -106,10 +107,10 @@ export enum WebSocketCloseEvent {
}
export abstract class ShellSession {
abstract ShellType: string;
abstract readonly ShellType: string;
private static shellEnvs = new Map<string, Record<string, string>>();
private static processes = new Map<string, pty.IPty>();
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
@@ -128,35 +129,32 @@ export abstract class ShellSession {
}
protected running = false;
protected kubectlBinDirP: Promise<string>;
protected kubeconfigPathP: Promise<string>;
protected readonly kubectlBinDirP: Promise<string>;
protected readonly kubeconfigPathP: Promise<string>;
protected readonly terminalId: string;
protected abstract get cwd(): string | undefined;
protected ensureShellProcess(shell: string, args: string[], env: Record<string, string>, cwd: string): { shellProcess: pty.IPty; resume: boolean } {
protected ensureShellProcess(shell: string, args: string[], env: Record<string, string | undefined>, cwd: string): { shellProcess: pty.IPty; resume: boolean } {
const resume = ShellSession.processes.has(this.terminalId);
if (!resume) {
ShellSession.processes.set(this.terminalId, pty.spawn(shell, args, {
const shellProcess = getOrInsertWith(ShellSession.processes, this.terminalId, () => (
pty.spawn(shell, args, {
rows: 30,
cols: 80,
cwd,
env,
env: env as Record<string, string>,
name: "xterm-256color",
// TODO: Something else is broken here so we need to force the use of winPty on windows
useConpty: false,
}));
}
const shellProcess = ShellSession.processes.get(this.terminalId);
})
));
logger.info(`[SHELL-SESSION]: PTY for ${this.terminalId} is ${resume ? "resumed" : "started"} with PID=${shellProcess.pid}`);
return { shellProcess, resume };
}
constructor(protected kubectl: Kubectl, protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) {
constructor(protected readonly kubectl: Kubectl, protected readonly websocket: WebSocket, protected readonly cluster: Cluster, terminalId: string) {
this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath();
this.kubectlBinDirP = this.kubectl.binDir();
this.terminalId = `${cluster.id}:${terminalId}`;
@@ -166,7 +164,7 @@ export abstract class ShellSession {
this.websocket.send(serialize(message));
}
protected async getCwd(env: Record<string, string>): Promise<string> {
protected async getCwd(env: Record<string, string | undefined>): Promise<string> {
const cwdOptions = [this.cwd];
if (isWindows) {
@@ -207,7 +205,7 @@ export abstract class ShellSession {
return "."; // Always valid
}
protected async openShellProcess(shell: string, args: string[], env: Record<string, string>) {
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);
@@ -234,7 +232,7 @@ export abstract class ShellSession {
});
this.websocket
.on("message", (data: string | Uint8Array) => {
.on("message", (data: string | Uint8Array): void => {
if (!this.running) {
return void logger.debug(`[SHELL-SESSION]: received message from ${this.terminalId}, but shellProcess isn't running`);
}
@@ -261,7 +259,7 @@ export abstract class ShellSession {
logger.error(`[SHELL-SESSION]: failed to handle message for ${this.terminalId}`, error);
}
})
.on("close", code => {
.once("close", code => {
logger.info(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${WebSocketCloseEvent[code]}(${code})`, { cluster: this.cluster.getMeta() });
const stopShellSession = this.running
@@ -0,0 +1,14 @@
/**
* 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 { storesAndApisCanBeCreatedInjectionToken } from "../common/k8s-api/stores-apis-can-be-created.token";
const storesAndApisCanBeCreatedInjectable = getInjectable({
id: "create-stores-and-apis",
instantiate: () => false,
injectionToken: storesAndApisCanBeCreatedInjectionToken,
});
export default storesAndApisCanBeCreatedInjectable;
+3 -3
View File
@@ -22,7 +22,7 @@ import path from "path";
const TRAY_LOG_PREFIX = "[TRAY]";
// note: instance of Tray should be saved somewhere, otherwise it disappears
export let tray: Tray;
export let tray: Tray | null = null;
function getTrayIconPath(): string {
return path.resolve(
@@ -56,7 +56,7 @@ export function initTray(
try {
const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()), navigateToPreferences);
tray.setContextMenu(menu);
tray?.setContextMenu(menu);
} catch (error) {
logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error });
}
@@ -73,7 +73,7 @@ function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron
...trayItem,
submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined,
click: trayItem.click ? () => {
trayItem.click(trayItem);
trayItem.click?.(trayItem);
} : undefined,
};
}
+14 -6
View File
@@ -12,7 +12,15 @@ interface GetPortArgs {
* Should be case insensitive
* Must have a named matching group called `address`
*/
lineRegex: RegExp;
lineRegex: {
match: (line: string) => {
matched: boolean;
groups?: {
address?: string;
};
raw?: RegExpExecArray;
};
};
/**
* Called when the port is found
*/
@@ -35,15 +43,15 @@ export function getPortFrom(stream: Readable, args: GetPortArgs): Promise<number
const logLines: string[] = [];
return new Promise<number>((resolve, reject) => {
const handler = (data: any) => {
const logItem: string = data.toString();
const match = logItem.match(args.lineRegex);
const handler = (data: unknown) => {
const logItem = String(data);
const match = args.lineRegex.match(logItem);
logLines.push(logItem);
if (match) {
if (match.matched) {
// use unknown protocol so that there is no default port
const addr = new URLParse(`s://${match.groups.address.trim()}`);
const addr = new URLParse(`s://${match.groups?.address?.trim()}`);
args.onFind?.();
stream.off("data", handler);
+1 -1
View File
@@ -9,7 +9,7 @@
* @param channel The current channel that did not have a new version associated with it
* @returns The channel name of the next release version
*/
export function nextUpdateChannel(defaultChannel: string, channel: string): string {
export function nextUpdateChannel(defaultChannel: string, channel: string | null): string {
switch (channel) {
case "alpha":
return "beta";
+54 -52
View File
@@ -18,10 +18,6 @@ import { isMac, productName } from "../common/vars";
import { LensProxy } from "./lens-proxy";
import { bundledExtensionsLoaded } from "../common/ipc/extension-handling";
function isHideable(window: BrowserWindow | null): boolean {
return Boolean(window && !window.isDestroyed());
}
export interface SendToViewArgs {
channel: string;
frameInfo?: ClusterFrameInfo;
@@ -31,12 +27,11 @@ export interface SendToViewArgs {
export class WindowManager extends Singleton {
public mainContentUrl = `http://localhost:${LensProxy.getInstance().port}`;
protected mainWindow: BrowserWindow;
protected splashWindow: BrowserWindow;
protected windowState: windowStateKeeper.State;
protected disposers: Record<string, Function> = {};
protected mainWindow?: BrowserWindow;
protected splashWindow?: BrowserWindow;
protected windowState?: windowStateKeeper.State;
@observable activeClusterId: ClusterId;
@observable activeClusterId?: ClusterId;
constructor() {
super();
@@ -44,14 +39,12 @@ export class WindowManager extends Singleton {
this.bindEvents();
}
private async initMainWindow(showSplash: boolean) {
private async initMainWindow(showSplash: boolean): Promise<BrowserWindow> {
// Manage main window size and position with state persistence
if (!this.windowState) {
this.windowState = windowStateKeeper({
defaultHeight: 900,
defaultWidth: 1440,
});
}
this.windowState ??= windowStateKeeper({
defaultHeight: 900,
defaultWidth: 1440,
});
if (!this.mainWindow) {
// show icon in dock (mac-os only)
@@ -88,9 +81,9 @@ export class WindowManager extends Singleton {
})
.on("closed", () => {
// clean up
this.windowState.unmanage();
this.mainWindow = null;
this.splashWindow = null;
this.windowState?.unmanage();
this.mainWindow = undefined;
this.splashWindow = undefined;
app.dock?.hide(); // hide icon in dock (mac-os)
})
.webContents
@@ -142,13 +135,17 @@ export class WindowManager extends Singleton {
}
try {
if (showSplash) await this.showSplash();
if (showSplash) {
await this.showSplash();
}
logger.info(`[WINDOW-MANAGER]: Loading Main window from url: ${this.mainContentUrl} ...`);
await this.mainWindow.loadURL(this.mainContentUrl);
} catch (error) {
logger.error("Loading main window failed", { error });
dialog.showErrorBox("ERROR!", error.toString());
dialog.showErrorBox("ERROR!", String(error));
}
return this.mainWindow;
}
protected bindEvents() {
@@ -166,7 +163,7 @@ export class WindowManager extends Singleton {
viewHasLoaded = new Promise<void>(resolve => {
ipcMain.once(bundledExtensionsLoaded, () => resolve());
});
await this.initMainWindow(showSplash);
this.mainWindow = await this.initMainWindow(showSplash);
}
try {
@@ -181,27 +178,26 @@ export class WindowManager extends Singleton {
appEventBus.emit({ name: "app", action: "start" });
}, 1000);
} catch (error) {
logger.error(`Showing main window failed: ${error.stack || error}`);
dialog.showErrorBox("ERROR!", error.toString());
logger.error(`Showing main window failed`, error);
dialog.showErrorBox("ERROR!", String(error));
}
return this.mainWindow;
}
sendToView({ channel, frameInfo, data = [] }: SendToViewArgs) {
private sendToView(window: BrowserWindow, { channel, frameInfo, data = [] }: SendToViewArgs) {
if (frameInfo) {
this.mainWindow.webContents.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...data);
window.webContents.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...data);
} else {
this.mainWindow.webContents.send(channel, ...data);
window.webContents.send(channel, ...data);
}
}
async navigateExtension(extId: string, pageId?: string, params?: Record<string, any>, frameId?: number) {
await this.ensureMainWindow();
const window = await this.ensureMainWindow();
const frameInfo = iter.find(clusterFrameMap.values(), frameInfo => frameInfo.frameId === frameId);
this.sendToView({
this.sendToView(window, {
channel: "extension:navigate",
frameInfo,
data: [extId, pageId, params],
@@ -209,34 +205,44 @@ export class WindowManager extends Singleton {
}
async navigate(url: string, frameId?: number) {
await this.ensureMainWindow();
const window = await this.ensureMainWindow();
this.navigateSync(url, frameId);
this.navigateSync(window, url, frameId);
}
navigateSync(url: string, frameId?: number) {
navigateSync(window: BrowserWindow, url: string, frameId?: number) {
const frameInfo = iter.find(clusterFrameMap.values(), frameInfo => frameInfo.frameId === frameId);
const channel = frameInfo
? IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER
: IpcRendererNavigationEvents.NAVIGATE_IN_APP;
this.sendToView({
this.sendToView(window, {
channel,
frameInfo,
data: [url],
});
}
reload() {
const frameInfo = clusterFrameMap.get(this.activeClusterId);
private getActiveClusterFrameInfo() {
if (this.activeClusterId) {
return clusterFrameMap.get(this.activeClusterId);
}
if (frameInfo) {
this.sendToView({ channel: IpcRendererNavigationEvents.RELOAD_PAGE, frameInfo });
return undefined;
}
reload() {
const frameInfo = this.getActiveClusterFrameInfo();
if (frameInfo && this.mainWindow) {
this.sendToView(this.mainWindow, { channel: IpcRendererNavigationEvents.RELOAD_PAGE, frameInfo });
} else {
webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => {
wc.reload();
wc.clearHistory();
});
webContents.getAllWebContents()
.filter(wc => wc.getType() === "window")
.forEach(wc => {
wc.reload();
wc.clearHistory();
});
}
}
@@ -263,23 +269,19 @@ export class WindowManager extends Singleton {
}
hide() {
if (isHideable(this.mainWindow)) {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.hide();
}
if (isHideable(this.splashWindow)) {
if (this.splashWindow && !this.splashWindow.isDestroyed()) {
this.splashWindow.hide();
}
}
destroy() {
this.mainWindow.destroy();
this.splashWindow.destroy();
this.mainWindow = null;
this.splashWindow = null;
Object.entries(this.disposers).forEach(([name, dispose]) => {
dispose();
delete this.disposers[name];
});
this.mainWindow?.destroy();
this.splashWindow?.destroy();
this.mainWindow = undefined;
this.splashWindow = undefined;
}
}