From 79c01daf6ab8d055d49f005153f1d61b7758793f Mon Sep 17 00:00:00 2001 From: Panu Horsmalahti Date: Wed, 19 Jan 2022 14:57:42 +0200 Subject: [PATCH] Support extending KubernetesCluster in extensions (#4702) * Support extending KubernetesCluster in extensions Signed-off-by: Panu Horsmalahti * Simplify getItemsByEntityClass Signed-off-by: Panu Horsmalahti * Make apiVersion string. Signed-off-by: Panu Horsmalahti * Improve entity loading for extension custom types. Signed-off-by: Panu Horsmalahti * Improve comment. Signed-off-by: Panu Horsmalahti * Fix lint. Signed-off-by: Panu Horsmalahti * Properly handle loading custom entity in cluster-frame Signed-off-by: Panu Horsmalahti * Avoid .bind with .loadOnClusterRenderer Signed-off-by: Panu Horsmalahti * Fix lint. Signed-off-by: Panu Horsmalahti * Revert style change. Signed-off-by: Panu Horsmalahti * Make loadOnClusterRenderer arrow function again, revert autoInitExtensions change as unnecessary Signed-off-by: Panu Horsmalahti * Remove commented code. Signed-off-by: Panu Horsmalahti * Document extending KubernetesCluster in extension guides. Signed-off-by: Panu Horsmalahti --- docs/extensions/guides/README.md | 1 + .../guides/extending-kubernetes-cluster.md | 69 +++++++++++++++++++ mkdocs.yml | 1 + .../catalog-entities/kubernetes-cluster.ts | 4 +- .../extension-loader/extension-loader.ts | 68 ++++++++++++------ src/extensions/lens-extension.ts | 6 +- src/main/catalog/catalog-entity-registry.ts | 6 +- src/renderer/api/catalog-entity-registry.ts | 17 ++++- .../init-cluster-frame/init-cluster-frame.ts | 9 +-- .../init-root-frame/init-root-frame.ts | 8 +-- 10 files changed, 154 insertions(+), 35 deletions(-) create mode 100644 docs/extensions/guides/extending-kubernetes-cluster.md diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index 434aec7444..09a0bce0a1 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -20,6 +20,7 @@ Each guide or code sample includes the following: | [Main process extension](main-extension.md) | Main.LensExtension | | [Renderer process extension](renderer-extension.md) | Renderer.LensExtension | | [Resource stack (cluster feature)](resource-stack.md) | | +| [Extending KubernetesCluster)](extending-kubernetes-cluster.md) | | | [Stores](stores.md) | | | [Components](components.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | | diff --git a/docs/extensions/guides/extending-kubernetes-cluster.md b/docs/extensions/guides/extending-kubernetes-cluster.md new file mode 100644 index 0000000000..5c8170a2fe --- /dev/null +++ b/docs/extensions/guides/extending-kubernetes-cluster.md @@ -0,0 +1,69 @@ +# Extending KubernetesCluster + +Extension can specify it's own subclass of Common.Catalog.KubernetesCluster. Extension can also specify a new Category for it in the Catalog. + +## Extending Common.Catalog.KubernetesCluster + +``` typescript +import { Common } from "@k8slens/extensions"; + +// The kind must be different from KubernetesCluster's kind +export const kind = "ManagedDevCluster"; + +export class ManagedDevCluster extends Common.Catalog.KubernetesCluster { + public static readonly kind = kind; + + public readonly kind = kind; +} +``` + +## Extending Common.Catalog.CatalogCategory + +These custom Catalog entities can be added a new Category in the Catalog. + +``` typescript +import { Common } from "@k8slens/extensions"; +import { kind, ManagedDevCluster } from "../entities/ManagedDevCluster"; + +class ManagedDevClusterCategory extends Common.Catalog.CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "Managed Dev Clusters", + icon: "" + }; + public spec: Common.Catalog.CatalogCategorySpec = { + group: "entity.k8slens.dev", + versions: [ + { + name: "v1alpha1", + entityClass: ManagedDevCluster as any, + }, + ], + names: { + kind + }, + }; +} + +export { ManagedDevClusterCategory }; +export type { ManagedDevClusterCategory as ManagedDevClusterCategoryType }; +``` + +The category needs to be registered in the `onActivate()` method both in main and renderer + +``` typescript +// in main's on onActivate +Main.Catalog.catalogCategories.add(new ManagedDevClusterCategory()); +``` + +``` typescript +// in renderer's on onActivate +Renderer.Catalog.catalogCategories.add(new ManagedDevClusterCategory()); +``` + +You can then add the entities to the Catalog as a new source: + +``` typescript +this.addCatalogSource("managedDevClusters", this.managedDevClusters); +``` diff --git a/mkdocs.yml b/mkdocs.yml index 4ed763e6d3..b869a63ee9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,7 @@ nav: - Renderer Extension: extensions/guides/renderer-extension.md - Catalog: extensions/guides/catalog.md - Resource Stack: extensions/guides/resource-stack.md + - Extending KubernetesCluster: extensions/guides/extending-kubernetes-cluster.md - Stores: extensions/guides/stores.md - Working with MobX: extensions/guides/working-with-mobx.md - Protocol Handlers: extensions/guides/protocol-handlers.md diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 2dfb3750ff..813d5d3866 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -59,8 +59,8 @@ export interface KubernetesClusterStatus extends CatalogEntityStatus { } export class KubernetesCluster extends CatalogEntity { - public static readonly apiVersion = "entity.k8slens.dev/v1alpha1"; - public static readonly kind = "KubernetesCluster"; + public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1"; + public static readonly kind: string = "KubernetesCluster"; public readonly apiVersion = KubernetesCluster.apiVersion; public readonly kind = KubernetesCluster.kind; diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index 675786dfe3..dd86857cf3 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -270,11 +270,12 @@ export class ExtensionLoader { }); }; - loadOnClusterRenderer = (entity: KubernetesCluster) => { + loadOnClusterRenderer = (getCluster: () => KubernetesCluster) => { logger.debug(`${logModule}: load on cluster renderer (dashboard)`); this.autoInitExtensions(async (extension: LensRendererExtension) => { - if ((await extension.isEnabledForCluster(entity)) === false) { + // getCluster must be a callback, as the entity might be available only after an extension has been loaded + if ((await extension.isEnabledForCluster(getCluster())) === false) { return []; } @@ -299,11 +300,15 @@ export class ExtensionLoader { }); }; - protected autoInitExtensions(register: (ext: LensExtension) => Promise) { - const loadingExtensions: ExtensionLoading[] = []; + protected async loadExtensions(installedExtensions: Map, register: (ext: LensExtension) => Promise) { + // Steps of the function: + // 1. require and call .activate for each Extension + // 2. Wait until every extension's onActivate has been resolved + // 3. Call .enable for each extension + // 4. Return ExtensionLoading[] - reaction(() => this.toJSON(), async installedExtensions => { - for (const [extId, extension] of installedExtensions) { + const extensions = [...installedExtensions.entries()] + .map(([extId, extension]) => { const alreadyInit = this.instances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); if (extension.isCompatible && extension.isEnabled && !alreadyInit) { @@ -312,7 +317,8 @@ export class ExtensionLoader { if (!LensExtensionClass) { this.nonInstancesByName.add(extension.manifest.name); - continue; + + return null; } const instance = this.dependencies.createExtensionInstance( @@ -320,27 +326,49 @@ export class ExtensionLoader { extension, ); - const loaded = instance.enable(register).catch((err) => { - logger.error(`${logModule}: failed to enable`, { ext: extension, err }); - }); - - loadingExtensions.push({ + return { + extId, + instance, isBundled: extension.isBundled, - loaded, - }); - this.instances.set(extId, instance); + activated: instance.activate(), + }; } catch (err) { logger.error(`${logModule}: activation extension error`, { ext: extension, err }); } } else if (!extension.isEnabled && alreadyInit) { this.removeInstance(extId); } - } - }, { - fireImmediately: true, - }); - return loadingExtensions; + return null; + }) + // Remove null values + .filter(extension => Boolean(extension)); + + // We first need to wait until each extension's `onActivate` is resolved, + // as this might register new catalog categories. Afterwards we can safely .enable the extension. + await Promise.all(extensions.map(extension => extension.activated)); + + // Return ExtensionLoading[] + return extensions.map(extension => { + const loaded = extension.instance.enable(register).catch((err) => { + logger.error(`${logModule}: failed to enable`, { ext: extension, err }); + }); + + this.instances.set(extension.extId, extension.instance); + + return { + isBundled: extension.isBundled, + loaded, + }; + }); + } + + protected autoInitExtensions(register: (ext: LensExtension) => Promise) { + // Setup reaction to load extensions on JSON changes + reaction(() => this.toJSON(), installedExtensions => this.loadExtensions(installedExtensions, register)); + + // Load initial extensions + return this.loadExtensions(this.toJSON(), register); } protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null { diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index ae42ec37ff..c892464ab8 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -86,7 +86,6 @@ export class LensExtension { } try { - await this.onActivate(); this._isEnabled = true; this[Disposers].push(...await register(this)); @@ -113,6 +112,11 @@ export class LensExtension { } } + @action + activate() { + return this.onActivate(); + } + protected onActivate(): Promise | void { return; } diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index 9e9c92f526..65f60d8e4e 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -4,7 +4,7 @@ */ import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; -import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor, CatalogEntityKindData } from "../../common/catalog"; +import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor } from "../../common/catalog"; import { iter } from "../../common/utils"; export class CatalogEntityRegistry { @@ -43,8 +43,8 @@ export class CatalogEntityRegistry { return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; } - getItemsByEntityClass({ apiVersion, kind }: CatalogEntityKindData & CatalogEntityConstructor): T[] { - return this.getItemsForApiKind(apiVersion, kind); + getItemsByEntityClass(constructor: CatalogEntityConstructor): T[] { + return this.items.filter((item) => item instanceof constructor) as T[]; } } diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index e6becb6cc1..85477245a4 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -47,10 +47,25 @@ export class CatalogEntityRegistry { makeObservable(this); } - get activeEntity(): CatalogEntity | null { + protected getActiveEntityById() { return this._entities.get(this.activeEntityId) || null; } + get activeEntity(): CatalogEntity | null { + const entity = this.getActiveEntityById(); + + // If the entity was not found but there are rawEntities to be processed, + // try to process them and return the entity. + // This might happen if an extension registered a new Catalog category. + if (this.activeEntityId && !entity && this.rawEntities.length > 0) { + this.processRawEntities(); + + return this.getActiveEntityById(); + } + + return entity; + } + set activeEntity(raw: CatalogEntity | string | null) { if (raw) { const id = typeof raw === "string" diff --git a/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.ts b/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.ts index a8ff4e6ad9..22acc43be5 100644 --- a/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.ts +++ b/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.ts @@ -19,7 +19,7 @@ import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; interface Dependencies { hostedCluster: Cluster; - loadExtensions: (entity: CatalogEntity) => void; + loadExtensions: (getCluster: () => CatalogEntity) => void; catalogEntityRegistry: CatalogEntityRegistry; frameRoutingId: number; emitEvent: (event: AppEvent) => void; @@ -47,11 +47,12 @@ export const initClusterFrame = catalogEntityRegistry.activeEntity = hostedCluster.id; - // Only load the extensions once the catalog has been populated + // Only load the extensions once the catalog has been populated. + // Note that the Catalog might still have unprocessed entities until the extensions are fully loaded. when( - () => Boolean(catalogEntityRegistry.activeEntity), + () => catalogEntityRegistry.items.length > 0, () => - loadExtensions(catalogEntityRegistry.activeEntity as KubernetesCluster), + loadExtensions(() => catalogEntityRegistry.activeEntity as KubernetesCluster), { timeout: 15_000, onError: (error) => { diff --git a/src/renderer/frames/root-frame/init-root-frame/init-root-frame.ts b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.ts index 61d1d7883e..c166847e2c 100644 --- a/src/renderer/frames/root-frame/init-root-frame/init-root-frame.ts +++ b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.ts @@ -11,15 +11,15 @@ import type { ExtensionLoading } from "../../../../extensions/extension-loader"; import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry"; interface Dependencies { - loadExtensions: () => ExtensionLoading[] + loadExtensions: () => Promise; // TODO: Move usages of third party library behind abstraction - ipcRenderer: { send: (name: string) => void } + ipcRenderer: { send: (name: string) => void }; // TODO: Remove dependencies being here only for correct timing of initialization bindProtocolAddRouteHandlers: () => void; lensProtocolRouterRenderer: { init: () => void }; - catalogEntityRegistry: CatalogEntityRegistry + catalogEntityRegistry: CatalogEntityRegistry; } const logPrefix = "[ROOT-FRAME]:"; @@ -40,7 +40,7 @@ export const initRootFrame = // maximum time to let bundled extensions finish loading const timeout = delay(10000); - const loadingExtensions = loadExtensions(); + const loadingExtensions = await loadExtensions(); const loadingBundledExtensions = loadingExtensions .filter((e) => e.isBundled)