1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Fix cluster frame display issue

- Add some defensive code to prevent this sort of infinite loop

- Add some unit tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-05-30 08:29:31 -04:00
parent 96ed99a06b
commit 5dca04a12d
22 changed files with 1822 additions and 104 deletions

View File

@ -7,14 +7,13 @@ import fs from "fs";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import path from "path"; import path from "path";
import fse from "fs-extra"; import fse from "fs-extra";
import type { Cluster } from "../cluster/cluster";
import type { ClusterStore } from "../cluster-store/cluster-store"; import type { ClusterStore } from "../cluster-store/cluster-store";
import { Console } from "console"; import { Console } from "console";
import { stdout, stderr } from "process"; import { stdout, stderr } from "process";
import getCustomKubeConfigDirectoryInjectable from "../app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; import getCustomKubeConfigDirectoryInjectable from "../app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable";
import clusterStoreInjectable from "../cluster-store/cluster-store.injectable"; import clusterStoreInjectable from "../cluster-store/cluster-store.injectable";
import type { ClusterModel } from "../cluster-types";
import type { DiContainer } from "@ogre-tools/injectable"; import type { DiContainer } from "@ogre-tools/injectable";
import type { CreateCluster } from "../cluster/create-cluster-injection-token";
import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token"; import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token";
import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting";
@ -25,17 +24,19 @@ import directoryForTempInjectable from "../app-paths/directory-for-temp/director
import kubectlBinaryNameInjectable from "../../main/kubectl/binary-name.injectable"; import kubectlBinaryNameInjectable from "../../main/kubectl/binary-name.injectable";
import kubectlDownloadingNormalizedArchInjectable from "../../main/kubectl/normalized-arch.injectable"; import kubectlDownloadingNormalizedArchInjectable from "../../main/kubectl/normalized-arch.injectable";
import normalizedPlatformInjectable from "../vars/normalized-platform.injectable"; import normalizedPlatformInjectable from "../vars/normalized-platform.injectable";
import fsInjectable from "../fs/fs.injectable";
console = new Console(stdout, stderr); console = new Console(stdout, stderr);
const testDataIcon = fs.readFileSync( const testDataIcon = fs.readFileSync(
"test-data/cluster-store-migration-icon.png", "test-data/cluster-store-migration-icon.png",
); );
const clusterServerUrl = "https://localhost";
const kubeconfig = ` const kubeconfig = `
apiVersion: v1 apiVersion: v1
clusters: clusters:
- cluster: - cluster:
server: https://localhost server: ${clusterServerUrl}
name: test name: test
contexts: contexts:
- context: - context:
@ -78,7 +79,7 @@ jest.mock("electron", () => ({
describe("cluster-store", () => { describe("cluster-store", () => {
let mainDi: DiContainer; let mainDi: DiContainer;
let clusterStore: ClusterStore; let clusterStore: ClusterStore;
let createCluster: (model: ClusterModel) => Cluster; let createCluster: CreateCluster;
beforeEach(async () => { beforeEach(async () => {
mainDi = getDiForUnitTesting({ doGeneralOverrides: true }); mainDi = getDiForUnitTesting({ doGeneralOverrides: true });
@ -94,6 +95,7 @@ describe("cluster-store", () => {
mainDi.permitSideEffects(getConfigurationFileModelInjectable); mainDi.permitSideEffects(getConfigurationFileModelInjectable);
mainDi.permitSideEffects(appVersionInjectable); mainDi.permitSideEffects(appVersionInjectable);
mainDi.permitSideEffects(clusterStoreInjectable); mainDi.permitSideEffects(clusterStoreInjectable);
mainDi.permitSideEffects(fsInjectable);
mainDi.unoverride(clusterStoreInjectable); mainDi.unoverride(clusterStoreInjectable);
}); });
@ -143,6 +145,8 @@ describe("cluster-store", () => {
getCustomKubeConfigDirectory("foo"), getCustomKubeConfigDirectory("foo"),
kubeconfig, kubeconfig,
), ),
}, {
clusterServerUrl,
}); });
clusterStore.addCluster(cluster); clusterStore.addCluster(cluster);

View File

@ -16,13 +16,17 @@ import { disposer, toJS } from "../utils";
import type { ClusterModel, ClusterId, ClusterState } from "../cluster-types"; import type { ClusterModel, ClusterId, ClusterState } from "../cluster-types";
import { requestInitialClusterStates } from "../../renderer/ipc"; import { requestInitialClusterStates } from "../../renderer/ipc";
import { clusterStates } from "../ipc/cluster"; import { clusterStates } from "../ipc/cluster";
import type { CreateCluster } from "../cluster/create-cluster-injection-token";
import { loadConfigFromString, validateKubeConfig } from "../kube-helpers";
import type { ReadFileSync } from "../fs/read-file-sync.injectable";
export interface ClusterStoreModel { export interface ClusterStoreModel {
clusters?: ClusterModel[]; clusters?: ClusterModel[];
} }
interface Dependencies { interface Dependencies {
createCluster: (model: ClusterModel) => Cluster; createCluster: CreateCluster;
readFileSync: ReadFileSync;
} }
export class ClusterStore extends BaseStore<ClusterStoreModel> { export class ClusterStore extends BaseStore<ClusterStoreModel> {
@ -111,12 +115,24 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return undefined; return undefined;
} }
private createNewCluster(model: ClusterModel): Cluster {
const kubeConfigData = this.dependencies.readFileSync(model.kubeConfigPath);
const { config } = loadConfigFromString(kubeConfigData);
const result = validateKubeConfig(config, model.contextName);
if (result.error) {
throw result.error;
}
return this.dependencies.createCluster(model, { clusterServerUrl: result.cluster.server });
}
addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { addCluster(clusterOrModel: ClusterModel | Cluster): Cluster {
appEventBus.emit({ name: "cluster", action: "add" }); appEventBus.emit({ name: "cluster", action: "add" });
const cluster = clusterOrModel instanceof Cluster const cluster = clusterOrModel instanceof Cluster
? clusterOrModel ? clusterOrModel
: this.dependencies.createCluster(clusterOrModel); : this.createNewCluster(clusterOrModel);
this.clusters.set(cluster.id, cluster); this.clusters.set(cluster.id, cluster);
@ -136,7 +152,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
if (cluster) { if (cluster) {
cluster.updateModel(clusterModel); cluster.updateModel(clusterModel);
} else { } else {
cluster = this.dependencies.createCluster(clusterModel); cluster = this.createNewCluster(clusterModel);
} }
newClusters.set(clusterModel.id, cluster); newClusters.set(clusterModel.id, cluster);
} catch (error) { } catch (error) {

View File

@ -3,6 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
/** /**
* JSON serializable metadata type * JSON serializable metadata type
*/ */
@ -67,6 +68,15 @@ export interface ClusterModel {
labels?: Record<string, string>; labels?: Record<string, string>;
} }
/**
* This data is retreived from the kubeconfig file before calling the cluster constructor.
*
* That is done to remove the external dependency on the construction of Cluster instances.
*/
export interface ClusterConfigData {
clusterServerUrl: string;
}
/** /**
* The complete set of cluster settings or preferences * The complete set of cluster settings or preferences
*/ */

View File

@ -10,13 +10,13 @@ import type { KubeConfig } from "@kubernetes/client-node";
import { HttpError } from "@kubernetes/client-node"; import { HttpError } from "@kubernetes/client-node";
import type { Kubectl } from "../../main/kubectl/kubectl"; import type { Kubectl } from "../../main/kubectl/kubectl";
import type { KubeconfigManager } from "../../main/kubeconfig-manager/kubeconfig-manager"; import type { KubeconfigManager } from "../../main/kubeconfig-manager/kubeconfig-manager";
import { loadConfigFromFile, loadConfigFromFileSync, validateKubeConfig } from "../kube-helpers"; import { loadConfigFromFile } from "../kube-helpers";
import type { KubeApiResource, KubeResource } from "../rbac"; import type { KubeApiResource, KubeResource } from "../rbac";
import { apiResourceRecord, apiResources } from "../rbac"; import { apiResourceRecord, apiResources } from "../rbac";
import type { VersionDetector } from "../../main/cluster-detectors/version-detector"; import type { VersionDetector } from "../../main/cluster-detectors/version-detector";
import type { DetectorRegistry } from "../../main/cluster-detectors/detector-registry"; import type { DetectorRegistry } from "../../main/cluster-detectors/detector-registry";
import plimit from "p-limit"; import plimit from "p-limit";
import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../cluster-types"; import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate, ClusterConfigData } from "../cluster-types";
import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../cluster-types";
import { disposer, isDefined, isRequestError, toJS } from "../utils"; import { disposer, isDefined, isRequestError, toJS } from "../utils";
import type { Response } from "request"; import type { Response } from "request";
@ -236,27 +236,11 @@ export class Cluster implements ClusterModel, ClusterState {
return this.preferences.defaultNamespace; return this.preferences.defaultNamespace;
} }
constructor(private readonly dependencies: ClusterDependencies, model: ClusterModel) { constructor(private readonly dependencies: ClusterDependencies, model: ClusterModel, configData: ClusterConfigData) {
makeObservable(this); makeObservable(this);
this.id = model.id; this.id = model.id;
this.updateModel(model); this.updateModel(model);
this.apiUrl = configData.clusterServerUrl;
const { config } = loadConfigFromFileSync(this.kubeConfigPath);
const validationError = validateKubeConfig(config, this.contextName);
if (validationError) {
throw validationError;
}
const context = config.getContextObject(this.contextName);
assert(context);
const cluster = config.getCluster(context.cluster);
assert(cluster);
this.apiUrl = cluster.server;
// for the time being, until renderer gets its own cluster type // for the time being, until renderer gets its own cluster type
this._contextHandler = this.dependencies.createContextHandler(this); this._contextHandler = this.dependencies.createContextHandler(this);

View File

@ -3,10 +3,10 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectionToken } from "@ogre-tools/injectable"; import { getInjectionToken } from "@ogre-tools/injectable";
import type { ClusterModel } from "../cluster-types"; import type { ClusterConfigData, ClusterModel } from "../cluster-types";
import type { Cluster } from "./cluster"; import type { Cluster } from "./cluster";
export type CreateCluster = (model: ClusterModel) => Cluster; export type CreateCluster = (model: ClusterModel, configData: ClusterConfigData) => Cluster;
export const createClusterInjectionToken = getInjectionToken<CreateCluster>({ export const createClusterInjectionToken = getInjectionToken<CreateCluster>({
id: "create-cluster-token", id: "create-cluster-token",

View File

@ -0,0 +1,19 @@
/**
* 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 fsInjectable from "./fs.injectable";
export type ReadFileSync = (filePath: string) => string;
const readFileSyncInjectable = getInjectable({
id: "read-file-sync",
instantiate: (di): ReadFileSync => {
const { readFileSync } = di.inject(fsInjectable);
return (filePath) => readFileSync(filePath, "utf-8");
},
});
export default readFileSyncInjectable;

View File

@ -150,7 +150,7 @@ export function loadConfigFromString(content: string): ConfigResult {
export interface SplitConfigEntry { export interface SplitConfigEntry {
config: KubeConfig; config: KubeConfig;
error?: string; validationResult: ValidateKubeConfigResult;
} }
/** /**
@ -179,7 +179,7 @@ export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] {
return { return {
config, config,
error: validateKubeConfig(config, ctx.name)?.toString(), validationResult: validateKubeConfig(config, ctx.name),
}; };
}); });
} }
@ -243,25 +243,44 @@ export function dumpConfigYaml(kubeConfig: PartialDeep<KubeConfig>): string {
return yaml.dump(config, { skipInvalid: true }); return yaml.dump(config, { skipInvalid: true });
} }
export type ValidateKubeConfigResult = {
error: Error;
} | {
error?: undefined;
context: Context;
cluster: Cluster;
user: User;
};
/** /**
* Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required) * Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
* *
* Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes * Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes
*/ */
export function validateKubeConfig(config: KubeConfig, contextName: string): Error | undefined { export function validateKubeConfig(config: KubeConfig, contextName: string): ValidateKubeConfigResult {
const contextObject = config.getContextObject(contextName); const context = config.getContextObject(contextName);
if (!contextObject) { if (!context) {
return new Error(`No valid context object provided in kubeconfig for context '${contextName}'`); return {
error: new Error(`No valid context object provided in kubeconfig for context '${contextName}'`),
};
} }
if (!config.getCluster(contextObject.cluster)) { const cluster = config.getCluster(context.cluster);
return new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
if (!cluster) {
return {
error: new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`),
};
} }
if (!config.getUser(contextObject.user)) { const user = config.getUser(context.user);
return new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
if (!user) {
return {
error: new Error(`No valid user object provided in kubeconfig for context '${contextName}'`),
};
} }
return undefined; return { cluster, user, context };
} }

View File

@ -2,4 +2,5 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import "@testing-library/jest-dom/extend-expect";
import "@testing-library/jest-dom";

View File

@ -25,9 +25,11 @@ import { createHash } from "crypto";
import { homedir } from "os"; import { homedir } from "os";
import globToRegExp from "glob-to-regexp"; import globToRegExp from "glob-to-regexp";
import { inspect } from "util"; import { inspect } from "util";
import type { ClusterModel, UpdateClusterModel } from "../../../common/cluster-types"; import type { ClusterConfigData, UpdateClusterModel } from "../../../common/cluster-types";
import type { Cluster } from "../../../common/cluster/cluster"; import type { Cluster } from "../../../common/cluster/cluster";
import type { CatalogEntityRegistry } from "../../catalog/entity-registry"; import type { CatalogEntityRegistry } from "../../catalog/entity-registry";
import type { CreateCluster } from "../../../common/cluster/create-cluster-injection-token";
import assert from "assert";
const logPrefix = "[KUBECONFIG-SYNC]:"; const logPrefix = "[KUBECONFIG-SYNC]:";
@ -56,7 +58,7 @@ interface KubeconfigSyncManagerDependencies {
readonly directoryForKubeConfigs: string; readonly directoryForKubeConfigs: string;
readonly entityRegistry: CatalogEntityRegistry; readonly entityRegistry: CatalogEntityRegistry;
readonly clusterManager: ClusterManager; readonly clusterManager: ClusterManager;
createCluster: (model: ClusterModel) => Cluster; createCluster: CreateCluster;
} }
const kubeConfigSyncName = "lens:kube-sync"; const kubeConfigSyncName = "lens:kube-sync";
@ -147,17 +149,26 @@ export class KubeconfigSyncManager {
} }
// exported for testing // exported for testing
export function configToModels(rootConfig: KubeConfig, filePath: string): UpdateClusterModel[] { export function configToModels(rootConfig: KubeConfig, filePath: string): [UpdateClusterModel, ClusterConfigData][] {
const validConfigs = []; const validConfigs: ReturnType<typeof configToModels> = [];
for (const { config, error } of splitConfig(rootConfig)) { for (const { config, error } of splitConfig(rootConfig)) {
if (error) { if (error) {
logger.debug(`${logPrefix} context failed validation: ${error}`, { context: config.currentContext, filePath }); logger.debug(`${logPrefix} context failed validation: ${error}`, { context: config.currentContext, filePath });
} else { } else {
validConfigs.push({ const cluster = config.getCluster(config.currentContext);
assert(cluster, "Config somehow passed validations but still doesn't have a cluster");
validConfigs.push([
{
kubeConfigPath: filePath, kubeConfigPath: filePath,
contextName: config.currentContext, contextName: config.currentContext,
}); },
{
clusterServerUrl: cluster.server,
},
]);
} }
} }
@ -169,7 +180,7 @@ type RootSource = ObservableMap<string, RootSourceValue>;
interface ComputeDiffDependencies { interface ComputeDiffDependencies {
directoryForKubeConfigs: string; directoryForKubeConfigs: string;
createCluster: (model: ClusterModel) => Cluster; createCluster: CreateCluster;
clusterManager: ClusterManager; clusterManager: ClusterManager;
} }
@ -184,15 +195,15 @@ export const computeDiff = ({ directoryForKubeConfigs, createCluster, clusterMan
} }
const rawModels = configToModels(config, filePath); const rawModels = configToModels(config, filePath);
const models = new Map(rawModels.map(m => [m.contextName, m])); const models = new Map(rawModels.map(([model, configData]) => [model.contextName, [model, configData] as const]));
logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath }); logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath });
for (const [contextName, value] of source) { for (const [contextName, value] of source) {
const model = models.get(contextName); const data = models.get(contextName);
// remove and disconnect clusters that were removed from the config // remove and disconnect clusters that were removed from the config
if (!model) { if (!data) {
// remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting // remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting
clusterManager.deleting.delete(value[0].id); clusterManager.deleting.delete(value[0].id);
@ -207,17 +218,17 @@ export const computeDiff = ({ directoryForKubeConfigs, createCluster, clusterMan
// diff against that // diff against that
// or update the model and mark it as not needed to be added // or update the model and mark it as not needed to be added
value[0].updateModel(model); value[0].updateModel(data[0]);
models.delete(contextName); models.delete(contextName);
logger.debug(`${logPrefix} Updated old cluster from sync`, { filePath, contextName }); logger.debug(`${logPrefix} Updated old cluster from sync`, { filePath, contextName });
} }
for (const [contextName, model] of models) { for (const [contextName, [model, configData]] of models) {
// add new clusters to the source // add new clusters to the source
try { try {
const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex"); const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex");
const cluster = ClusterStore.getInstance().getById(clusterId) || createCluster({ ...model, id: clusterId }); const cluster = ClusterStore.getInstance().getById(clusterId) || createCluster({ ...model, id: clusterId }, configData);
if (!cluster.apiUrl) { if (!cluster.apiUrl) {
throw new Error("Cluster constructor failed, see above error"); throw new Error("Cluster constructor failed, see above error");

View File

@ -32,7 +32,7 @@ const createClusterInjectable = getInjectable({
createVersionDetector: di.inject(createVersionDetectorInjectable), createVersionDetector: di.inject(createVersionDetectorInjectable),
}; };
return (model) => new Cluster(dependencies, model); return (model, configData) => new Cluster(dependencies, model, configData);
}, },
injectionToken: createClusterInjectionToken, injectionToken: createClusterInjectionToken,

View File

@ -41,7 +41,7 @@ function getContexts(config: KubeConfig): Map<string, Option> {
splitConfig(config) splitConfig(config)
.map(({ config, error }) => [config.currentContext, { .map(({ config, error }) => [config.currentContext, {
config, config,
error, error: error?.toString(),
}]), }]),
); );
} }

View File

@ -17,7 +17,6 @@ import { ClusterIssues } from "./cluster-issues";
import { ClusterMetrics } from "./cluster-metrics"; import { ClusterMetrics } from "./cluster-metrics";
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store"; import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
import { ClusterPieCharts } from "./cluster-pie-charts"; import { ClusterPieCharts } from "./cluster-pie-charts";
import { getActiveClusterEntity } from "../../api/catalog/entity/legacy-globals";
import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import type { EventStore } from "../+events/store"; import type { EventStore } from "../+events/store";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
@ -71,7 +70,7 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
this.metricPoller.stop(); this.metricPoller.stop();
} }
renderMetrics(isMetricsHidden?: boolean) { renderMetrics(isMetricsHidden: boolean) {
if (isMetricsHidden) { if (isMetricsHidden) {
return null; return null;
} }
@ -84,7 +83,7 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
); );
} }
renderClusterOverview(isLoaded: boolean, isMetricsHidden?: boolean) { renderClusterOverview(isLoaded: boolean, isMetricsHidden: boolean) {
if (!isLoaded) { if (!isLoaded) {
return <Spinner center/>; return <Spinner center/>;
} }
@ -98,9 +97,9 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
} }
render() { render() {
const { eventStore, nodeStore } = this.props; const { eventStore, nodeStore, hostedCluster } = this.props;
const isLoaded = nodeStore.isLoaded && eventStore.isLoaded; const isLoaded = nodeStore.isLoaded && eventStore.isLoaded;
const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Cluster); const isMetricHidden = hostedCluster.isMetricHidden(ClusterMetricsResourceType.Cluster);
return ( return (
<TabLayout> <TabLayout>

View File

@ -12,51 +12,48 @@ import { CommandDialog } from "./command-dialog";
import type { ClusterId } from "../../../common/cluster-types"; import type { ClusterId } from "../../../common/cluster-types";
import type { CommandOverlay } from "./command-overlay.injectable"; import type { CommandOverlay } from "./command-overlay.injectable";
import commandOverlayInjectable from "./command-overlay.injectable"; import commandOverlayInjectable from "./command-overlay.injectable";
import { isMac } from "../../../common/vars"; import type { ipcRendererOn } from "../../../common/ipc";
import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry"; import { broadcastMessage } from "../../../common/ipc";
import { broadcastMessage, ipcRendererOn } from "../../../common/ipc";
import type { Disposer } from "../../utils";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import type { AddWindowEventListener } from "../../window/event-listener.injectable";
import windowAddEventListenerInjectable from "../../window/event-listener.injectable"; import windowAddEventListenerInjectable from "../../window/event-listener.injectable";
import type { IComputedValue } from "mobx"; import type { IComputedValue } from "mobx";
import matchedClusterIdInjectable from "../../navigation/matched-cluster-id.injectable"; import matchedClusterIdInjectable from "../../navigation/matched-cluster-id.injectable";
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
import hostedClusterIdInjectable from "../../cluster-frame-context/hosted-cluster-id.injectable"; import hostedClusterIdInjectable from "../../cluster-frame-context/hosted-cluster-id.injectable";
import isMacInjectable from "../../../common/vars/is-mac.injectable";
import legacyOnChannelListenInjectable from "../../ipc/legacy-channel-listen.injectable";
interface Dependencies { interface Dependencies {
addWindowEventListener: <K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions) => Disposer; addWindowEventListener: AddWindowEventListener;
commandOverlay: CommandOverlay; commandOverlay: CommandOverlay;
clusterId?: ClusterId; clusterId: ClusterId | undefined;
matchedClusterId: IComputedValue<ClusterId>; matchedClusterId: IComputedValue<ClusterId | undefined>;
entityRegistry: CatalogEntityRegistry; isMac: boolean;
legacyOnChannelListen: typeof ipcRendererOn;
} }
@observer @observer
class NonInjectedCommandContainer extends React.Component<Dependencies> { class NonInjectedCommandContainer extends React.Component<Dependencies> {
private escHandler(event: KeyboardEvent) { private escHandler = (event: KeyboardEvent) => {
const { commandOverlay } = this.props;
if (event.key === "Escape") { if (event.key === "Escape") {
event.stopPropagation(); event.stopPropagation();
commandOverlay.close(); this.props.commandOverlay.close();
}
} }
};
handleCommandPalette = () => { handleCommandPalette = () => {
const { commandOverlay, entityRegistry } = this.props; const matchedClusterId = this.props.matchedClusterId.get();
const clusterIsActive = this.props.matchedClusterId.get() !== undefined;
if (clusterIsActive) { if (matchedClusterId !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion broadcastMessage(`command-palette:${matchedClusterId}:open`);
broadcastMessage(`command-palette:${entityRegistry.activeEntity!.getId()}:open`);
} else { } else {
commandOverlay.open(<CommandDialog />); this.props.commandOverlay.open(<CommandDialog />);
} }
}; };
onKeyboardShortcut(action: () => void) { onKeyboardShortcut(action: () => void) {
return ({ key, shiftKey, ctrlKey, altKey, metaKey }: KeyboardEvent) => { return ({ key, shiftKey, ctrlKey, altKey, metaKey }: KeyboardEvent) => {
const ctrlOrCmd = isMac ? metaKey && !ctrlKey : !metaKey && ctrlKey; const ctrlOrCmd = this.props.isMac ? metaKey && !ctrlKey : !metaKey && ctrlKey;
if (key === "p" && shiftKey && ctrlOrCmd && !altKey) { if (key === "p" && shiftKey && ctrlOrCmd && !altKey) {
action(); action();
@ -75,9 +72,9 @@ class NonInjectedCommandContainer extends React.Component<Dependencies> {
: "command-palette:open"; : "command-palette:open";
disposeOnUnmount(this, [ disposeOnUnmount(this, [
ipcRendererOn(ipcChannel, action), this.props.legacyOnChannelListen(ipcChannel, action),
addWindowEventListener("keydown", this.onKeyboardShortcut(action)), addWindowEventListener("keydown", this.onKeyboardShortcut(action)),
addWindowEventListener("keyup", (e) => this.escHandler(e), true), addWindowEventListener("keyup", this.escHandler, true),
]); ]);
} }
@ -106,6 +103,7 @@ export const CommandContainer = withInjectables<Dependencies>(NonInjectedCommand
addWindowEventListener: di.inject(windowAddEventListenerInjectable), addWindowEventListener: di.inject(windowAddEventListenerInjectable),
commandOverlay: di.inject(commandOverlayInjectable), commandOverlay: di.inject(commandOverlayInjectable),
matchedClusterId: di.inject(matchedClusterIdInjectable), matchedClusterId: di.inject(matchedClusterIdInjectable),
entityRegistry: di.inject(catalogEntityRegistryInjectable), isMac: di.inject(isMacInjectable),
legacyOnChannelListen: di.inject(legacyOnChannelListenInjectable),
}), }),
}); });

View File

@ -80,9 +80,7 @@ class NonInjectedSidebarItem extends React.Component<
return ( return (
<div <div
className={cssNames("SidebarItem")} className={cssNames("SidebarItem")}
data-testid="sidebar-item" data-testid={`sidebar-item-${this.id}`}
data-test-id={this.id}
data-id-test={this.id}
data-is-active-test={this.isActive} data-is-active-test={this.isActive}
data-parent-id-test={this.registration.parentId} data-parent-id-test={this.registration.parentId}
> >

View File

@ -28,7 +28,7 @@ const createClusterInjectable = getInjectable({
createVersionDetector: () => { throw new Error("Tried to access back-end feature in front-end."); }, createVersionDetector: () => { throw new Error("Tried to access back-end feature in front-end."); },
}; };
return (model) => new Cluster(dependencies, model); return (model, configData) => new Cluster(dependencies, model, configData);
}, },
injectionToken: createClusterInjectionToken, injectionToken: createClusterInjectionToken,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.centering {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}

View File

@ -0,0 +1,124 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { DiContainer } from "@ogre-tools/injectable";
import type { RenderResult } from "@testing-library/react";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import { render as testingLibraryRender } from "@testing-library/react";
import React from "react";
import { DiContextProvider } from "@ogre-tools/injectable-react";
import { Router } from "react-router";
import { DefaultProps } from "../../mui-base-theme";
import { ClusterFrame } from "./cluster-frame";
import historyInjectable from "../../navigation/history.injectable";
import allowedResourcesInjectable from "../../../common/cluster-store/allowed-resources.injectable";
import { computed } from "mobx";
import type { Cluster } from "../../../common/cluster/cluster";
import createClusterInjectable from "../../create-cluster/create-cluster.injectable";
import hostedClusterInjectable from "../../../common/cluster-store/hosted-cluster.injectable";
import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import hostedClusterIdInjectable from "../../../common/cluster-store/hosted-cluster-id.injectable";
import legacyOnChannelListenInjectable from "../../ipc/legacy-channel-listen.injectable";
import currentRouteComponentInjectable from "../../routes/current-route-component.injectable";
describe("<ClusterFrame />", () => {
let render: () => RenderResult;
let di: DiContainer;
let cluster: Cluster;
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
render = () => testingLibraryRender((
<DiContextProvider value={{ di }}>
<Router history={di.inject(historyInjectable)}>
{DefaultProps(ClusterFrame)}
</Router>
</DiContextProvider>
));
di.override(subscribeStoresInjectable, () => jest.fn().mockImplementation(() => jest.fn()));
di.override(legacyOnChannelListenInjectable, () => jest.fn().mockImplementation(() => jest.fn()));
di.override(directoryForUserDataInjectable, () => "/some/irrelavent/path");
di.override(storesAndApisCanBeCreatedInjectable, () => true);
const createCluster = di.inject(createClusterInjectable);
cluster = createCluster(
{
contextName: "my-cluster",
id: "123456",
kubeConfigPath: "/irrelavent",
},
{
clusterServerUrl: "https://localhost",
},
);
di.override(hostedClusterInjectable, () => cluster);
di.override(hostedClusterIdInjectable, () => cluster.id);
});
describe("given cluster with list nodes and namespaces permissions", () => {
beforeEach(() => {
di.override(allowedResourcesInjectable, () => computed(() => new Set(["nodes", "namespaces"])));
});
it("renders", () => {
const result = render();
expect(result.container).toMatchSnapshot();
});
it("shows cluster overview sidebar item as active", () => {
const result = render();
const clusterOverviewSidebarItem = result.getByTestId("sidebar-item-cluster-overview");
expect(clusterOverviewSidebarItem.getAttribute("data-is-active-test")).toBe("true");
});
describe("given no matching component", () => {
beforeEach(() => {
di.override(currentRouteComponentInjectable, () => computed(() => undefined));
});
describe("given current url is starting url", () => {
it("renders", () => {
const result = render();
expect(result.container).toMatchSnapshot();
});
it("shows warning message", () => {
const result = render();
expect(
result.getByText("An error has occured. No route can be found matching the current route, which is also the starting route."),
).toBeInTheDocument();
});
});
});
});
describe("given cluster without list nodes, but with namespaces permissions", () => {
beforeEach(() => {
di.override(allowedResourcesInjectable, () => computed(() => new Set(["namespaces"])));
});
it("renders", () => {
const result = render();
expect(result.container).toMatchSnapshot();
});
it("shows workloads overview sidebar item as active", () => {
const result = render();
const workloadsOverviewSidebarItem = result.getByTestId("sidebar-item-workloads");
expect(workloadsOverviewSidebarItem.getAttribute("data-is-active-test")).toBe("true");
});
});
});

View File

@ -2,6 +2,7 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import styles from "./cluster-frame.module.css";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import type { IComputedValue } from "mobx"; import type { IComputedValue } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
@ -30,12 +31,14 @@ import { disposer } from "../../utils";
import currentRouteComponentInjectable from "../../routes/current-route-component.injectable"; import currentRouteComponentInjectable from "../../routes/current-route-component.injectable";
import startUrlInjectable from "./start-url.injectable"; import startUrlInjectable from "./start-url.injectable";
import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable"; import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable";
import currentPathInjectable from "../../routes/current-path.injectable";
interface Dependencies { interface Dependencies {
namespaceStore: NamespaceStore; namespaceStore: NamespaceStore;
currentRouteComponent: IComputedValue<React.ElementType<{}> | undefined | null>; currentRouteComponent: IComputedValue<React.ElementType<{}> | undefined>;
startUrl: IComputedValue<string>; startUrl: IComputedValue<string>;
subscribeStores: SubscribeStores; subscribeStores: SubscribeStores;
currentPath: IComputedValue<string>;
} }
export const NonInjectedClusterFrame = observer(({ export const NonInjectedClusterFrame = observer(({
@ -43,6 +46,7 @@ export const NonInjectedClusterFrame = observer(({
currentRouteComponent, currentRouteComponent,
startUrl, startUrl,
subscribeStores, subscribeStores,
currentPath,
}: Dependencies) => { }: Dependencies) => {
useEffect(() => disposer( useEffect(() => disposer(
subscribeStores([ subscribeStores([
@ -52,6 +56,8 @@ export const NonInjectedClusterFrame = observer(({
), []); ), []);
const Component = currentRouteComponent.get(); const Component = currentRouteComponent.get();
const starting = startUrl.get();
const current = currentPath.get();
return ( return (
<ErrorBoundary> <ErrorBoundary>
@ -62,7 +68,16 @@ export const NonInjectedClusterFrame = observer(({
{ {
Component Component
? <Component /> ? <Component />
: <Redirect to={startUrl.get()} /> // NOTE: this check is to prevent an infinite loop
: starting !== current
? <Redirect to={startUrl.get()} />
: (
<div className={styles.centering}>
<div className="error">
An error has occured. No route can be found matching the current route, which is also the starting route.
</div>
</div>
)
} }
</MainLayout> </MainLayout>
@ -87,6 +102,7 @@ export const ClusterFrame = withInjectables<Dependencies>(NonInjectedClusterFram
subscribeStores: di.inject(subscribeStoresInjectable), subscribeStores: di.inject(subscribeStoresInjectable),
startUrl: di.inject(startUrlInjectable), startUrl: di.inject(startUrlInjectable),
currentRouteComponent: di.inject(currentRouteComponentInjectable), currentRouteComponent: di.inject(currentRouteComponentInjectable),
currentPath: di.inject(currentPathInjectable),
}), }),
}); });

View File

@ -4,29 +4,30 @@
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx"; import { computed } from "mobx";
import type { KubeResource } from "../../../common/rbac";
import isAllowedResourceInjectable from "../../../common/utils/is-allowed-resource.injectable";
import clusterOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable"; import clusterOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable";
import workloadsOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/workloads/overview/workloads-overview-route.injectable"; import workloadsOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/workloads/overview/workloads-overview-route.injectable";
import { buildURL } from "../../../common/utils/buildUrl";
const startUrlInjectable = getInjectable({ const startUrlInjectable = getInjectable({
id: "start-url", id: "start-url",
instantiate: (di) => { instantiate: (di) => {
const isAllowedResource = (resourceName: string) => di.inject(isAllowedResourceInjectable, resourceName);
const clusterOverviewRoute = di.inject(clusterOverviewRouteInjectable); const clusterOverviewRoute = di.inject(clusterOverviewRouteInjectable);
const workloadOverviewRoute = di.inject(workloadsOverviewRouteInjectable); const workloadOverviewRoute = di.inject(workloadsOverviewRouteInjectable);
const clusterOverviewUrl = buildURL(clusterOverviewRoute.path);
const workloadOverviewUrl = buildURL(workloadOverviewRoute.path);
return computed(() => { return computed(() => {
const resources: KubeResource[] = ["events", "nodes", "pods"]; if (clusterOverviewRoute.isEnabled.get()) {
return clusterOverviewRoute.path;
}
return resources.every((resourceName) => isAllowedResource(resourceName)) if (workloadOverviewRoute.isEnabled.get()) {
? clusterOverviewUrl return workloadOverviewRoute.path;
: workloadOverviewUrl; }
/**
* NOTE: This will never be executed as `workloadOverviewRoute.isEnabled` always is true. It
* is here is guard against accidental changes at a distance within `workloadOverviewRoute`.
*/
throw new Error("Exhausted all possible starting locations and none are active. This is a bug.");
}); });
}, },
}); });

View File

@ -0,0 +1,15 @@
/**
* 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 { ipcRendererOn } from "../../common/ipc";
const legacyOnChannelListenInjectable = getInjectable({
id: "legacy-on-channel-listen",
instantiate: () => ipcRendererOn,
causesSideEffects: true,
});
export default legacyOnChannelListenInjectable;

View File

@ -6,6 +6,8 @@
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import type { Disposer } from "../utils"; import type { Disposer } from "../utils";
export type AddWindowEventListener = typeof addWindowEventListener;
function addWindowEventListener<K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): Disposer { function addWindowEventListener<K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): Disposer {
window.addEventListener(type, listener, options); window.addEventListener(type, listener, options);