diff --git a/frontend/__tests__/module/k8s/k8s-models.spec.ts b/frontend/__tests__/module/k8s/k8s-models.spec.ts index 028b855ecd9..888cca8769f 100644 --- a/frontend/__tests__/module/k8s/k8s-models.spec.ts +++ b/frontend/__tests__/module/k8s/k8s-models.spec.ts @@ -1,6 +1,6 @@ -import { referenceFor, referenceForCRD, referenceForOwnerRef, referenceForModel, kindForReference, versionForReference } from '../../../public/module/k8s'; +import { referenceFor, referenceForCRD, referenceForOwnerRef, referenceForModel, kindForReference, versionForReference, modelsToMap } from '../../../public/module/k8s'; import { testNamespace, testClusterServiceVersion, testCRD, testOwnedResourceInstance } from '../../../__mocks__/k8sResourcesMocks'; -import { PodModel, DeploymentModel } from '../../../public/models'; +import { PodModel, DeploymentModel, SubscriptionModel, PrometheusModel } from '../../../public/models'; describe('referenceFor', () => { @@ -56,3 +56,20 @@ describe('versionForReference', () => { expect(versionForReference(referenceFor(testClusterServiceVersion))).toEqual('v1alpha1'); }); }); + +describe('modelsToMap', () => { + + it('returns a map with keys based on model.kind for models with crd:false', () => { + expect(modelsToMap([ PodModel, DeploymentModel ]).toObject()).toEqual({ + [PodModel.kind]: PodModel, + [DeploymentModel.kind]: DeploymentModel, + }); + }); + + it('returns a map with keys based on referenceForModel for models with crd:true', () => { + expect(modelsToMap([ SubscriptionModel, PrometheusModel ]).toObject()).toEqual({ + [referenceForModel(SubscriptionModel)]: SubscriptionModel, + [referenceForModel(PrometheusModel)]: PrometheusModel, + }); + }); +}); diff --git a/frontend/packages/console-demo-plugin/src/models.ts b/frontend/packages/console-demo-plugin/src/models.ts new file mode 100644 index 00000000000..7aff834a701 --- /dev/null +++ b/frontend/packages/console-demo-plugin/src/models.ts @@ -0,0 +1,15 @@ +import { K8sKind } from '@console/internal/module/k8s'; + +export const FooBarModel: K8sKind = { + apiGroup: 'test.io', + apiVersion: 'v1alpha1', + kind: 'FooBar', + label: 'Foo Bar', + labelPlural: 'Foo Bars', + path: 'foobars', + plural: 'foobars', + abbr: 'FOOBAR', + namespaced: true, + id: 'foobar', + crd: true, +}; diff --git a/frontend/packages/console-demo-plugin/src/plugin.ts b/frontend/packages/console-demo-plugin/src/plugin.ts index 11401fa3221..672f64198f1 100644 --- a/frontend/packages/console-demo-plugin/src/plugin.ts +++ b/frontend/packages/console-demo-plugin/src/plugin.ts @@ -1,5 +1,8 @@ +import * as _ from 'lodash-es'; + import { Plugin, + ModelDefinition, ModelFeatureFlag, HrefNavItem, ResourceNSNavItem, @@ -12,7 +15,10 @@ import { import { PodModel } from '@console/internal/models'; import { FLAGS } from '@console/internal/const'; +import * as models from './models'; + type ConsumedExtensions = + | ModelDefinition | ModelFeatureFlag | HrefNavItem | ResourceNSNavItem @@ -21,6 +27,12 @@ type ConsumedExtensions = | ResourceDetailPage; const plugin: Plugin = [ + { + type: 'ModelDefinition', + properties: { + models: _.values(models), + }, + }, { type: 'FeatureFlag/Model', properties: { diff --git a/frontend/packages/console-plugin-sdk/src/codegen/__tests__/index.spec.ts b/frontend/packages/console-plugin-sdk/src/codegen/__tests__/index.spec.ts index 48ad53b794b..5db1cd9ef92 100644 --- a/frontend/packages/console-plugin-sdk/src/codegen/__tests__/index.spec.ts +++ b/frontend/packages/console-plugin-sdk/src/codegen/__tests__/index.spec.ts @@ -1,8 +1,8 @@ import { Package, PluginPackage, - isValidPluginPackage, - resolveActivePlugins, + isPluginPackage, + getActivePluginPackages, getActivePluginsModule, } from '..'; @@ -10,36 +10,36 @@ const templatePackage: Package = { name: 'test', version: '1.2.3', readme: '', _ describe('codegen', () => { - describe('isValidPluginPackage', () => { + describe('isPluginPackage', () => { it('returns false if package.consolePlugin is missing', () => { - expect(isValidPluginPackage({ + expect(isPluginPackage({ ...templatePackage, })).toBe(false); }); it('returns false if package.consolePlugin.entry is missing', () => { - expect(isValidPluginPackage({ + expect(isPluginPackage({ ...templatePackage, consolePlugin: {}, })).toBe(false); }); it('returns false if package.consolePlugin.entry is an empty string', () => { - expect(isValidPluginPackage({ + expect(isPluginPackage({ ...templatePackage, consolePlugin: { entry: '' }, })).toBe(false); }); - it('returns true if package.consolePlugin.entry is not an empty string', () => { - expect(isValidPluginPackage({ + it('returns true if package.consolePlugin.entry is a non-empty string', () => { + expect(isPluginPackage({ ...templatePackage, consolePlugin: { entry: 'plugin.ts' }, })).toBe(true); }); }); - describe('resolveActivePlugins', () => { + describe('getActivePluginPackages', () => { it('filters out packages which are not listed in appPackage.dependencies', () => { const appPackage: Package = { ...templatePackage, @@ -65,14 +65,14 @@ describe('codegen', () => { }, ]; - expect(resolveActivePlugins(appPackage, pluginPackages)).toEqual([ + expect(getActivePluginPackages(appPackage, pluginPackages)).toEqual([ { ...pluginPackages[0] }, ]); }); }); describe('getActivePluginsModule', () => { - it('returns the source of a module that exports the list of active plugins', () => { + it('returns module source that exports the list of active plugins', () => { const pluginPackages: PluginPackage[] = [ { ...templatePackage, @@ -88,16 +88,17 @@ describe('codegen', () => { }, ]; - const expectedModule = ` + expect(getActivePluginsModule(pluginPackages)).toBe(` const activePlugins = []; + import plugin_0 from 'bar/src/plugin.ts'; - activePlugins.push(plugin_0); + activePlugins.push({ name: 'bar', extensions: plugin_0 }); + import plugin_1 from 'qux-plugin/index.ts'; - activePlugins.push(plugin_1); - export default activePlugins; - `.replace(/^\s+/gm, ''); + activePlugins.push({ name: 'qux-plugin', extensions: plugin_1 }); - expect(getActivePluginsModule(pluginPackages)).toBe(expectedModule); + export default activePlugins; + `.replace(/^\s+/gm, '')); }); }); diff --git a/frontend/packages/console-plugin-sdk/src/codegen/index.ts b/frontend/packages/console-plugin-sdk/src/codegen/index.ts index b08bdf07872..4d5600f0f3d 100644 --- a/frontend/packages/console-plugin-sdk/src/codegen/index.ts +++ b/frontend/packages/console-plugin-sdk/src/codegen/index.ts @@ -5,57 +5,59 @@ import * as readPkg from 'read-pkg'; export type Package = readPkg.NormalizedPackageJson; -export interface PluginPackage extends Package { +export type PluginPackage = Package & { consolePlugin: { entry: string; } -} +}; -export function isValidPluginPackage(pkg: Package): pkg is PluginPackage { +/** + * Return `true` if the given package represents a Console plugin. + */ +export const isPluginPackage = (pkg: Package): pkg is PluginPackage => { if (!(pkg as PluginPackage).consolePlugin) { return false; } const entry = (pkg as PluginPackage).consolePlugin.entry; - return typeof entry === 'string' && entry.length > 0; -} + return typeof entry === 'string' && !!entry; +}; /** * Read package metadata and detect any plugins. * * @param packageFiles Paths to `package.json` files (all the monorepo packages). */ -export function readPackages(packageFiles: string[]) { +export const readPackages = (packageFiles: string[]) => { const pkgList: Package[] = packageFiles.map(file => readPkg.sync({ cwd: path.dirname(file), normalize: true })); return { appPackage: pkgList.find(pkg => pkg.name === '@console/app'), - pluginPackages: pkgList.filter(isValidPluginPackage), + pluginPackages: pkgList.filter(isPluginPackage), }; -} +}; /** - * Resolve the list of active plugins. + * Get the list of plugins to be used for the build. */ -export function resolveActivePlugins(appPackage: Package, pluginPackages: PluginPackage[]) { +export const getActivePluginPackages = (appPackage: Package, pluginPackages: PluginPackage[]) => { return pluginPackages.filter(pkg => appPackage.dependencies[pkg.name] === pkg.version); -} +}; /** - * Generate the "active plugins" module source. + * Generate the `@console/active-plugins` module source. */ -export function getActivePluginsModule(activePluginPackages: PluginPackage[]): string { +export const getActivePluginsModule = (pluginPackages: PluginPackage[]) => { let output = ` const activePlugins = []; `; - for (const pkg of activePluginPackages) { - const importName = `plugin_${activePluginPackages.indexOf(pkg)}`; - const importPath = `${pkg.name}/${pkg.consolePlugin.entry}`; + for (const pkg of pluginPackages) { + const importName = `plugin_${pluginPackages.indexOf(pkg)}`; output = ` ${output} - import ${importName} from '${importPath}'; - activePlugins.push(${importName}); + import ${importName} from '${pkg.name}/${pkg.consolePlugin.entry}'; + activePlugins.push({ name: '${pkg.name}', extensions: ${importName} }); `; } @@ -65,4 +67,4 @@ export function getActivePluginsModule(activePluginPackages: PluginPackage[]): s `; return output.replace(/^\s+/gm, ''); -} +}; diff --git a/frontend/packages/console-plugin-sdk/src/registry.ts b/frontend/packages/console-plugin-sdk/src/registry.ts index a8e609be763..98a6cb13077 100644 --- a/frontend/packages/console-plugin-sdk/src/registry.ts +++ b/frontend/packages/console-plugin-sdk/src/registry.ts @@ -1,5 +1,13 @@ import * as _ from 'lodash-es'; -import { Extension, PluginList, isNavItem, isResourcePage, isFeatureFlag } from './typings'; + +import { + Extension, + ActivePlugin, + isModelDefinition, + isFeatureFlag, + isNavItem, + isResourcePage, +} from './typings'; /** * Registry used to query for Console extensions. @@ -8,8 +16,16 @@ export class ExtensionRegistry { private readonly extensions: Extension[]; - public constructor(plugins: PluginList) { - this.extensions = _.flatMap(plugins); + public constructor(plugins: ActivePlugin[]) { + this.extensions = _.flatMap(plugins.map(p => p.extensions)); + } + + public getModelDefinitions() { + return this.extensions.filter(isModelDefinition); + } + + public getFeatureFlags() { + return this.extensions.filter(isFeatureFlag); } public getNavItems(section: string) { @@ -20,8 +36,4 @@ export class ExtensionRegistry { return this.extensions.filter(isResourcePage); } - public getFeatureFlags() { - return this.extensions.filter(isFeatureFlag); - } - } diff --git a/frontend/packages/console-plugin-sdk/src/typings/index.ts b/frontend/packages/console-plugin-sdk/src/typings/index.ts index 4619ab416cc..231ba3c17f6 100644 --- a/frontend/packages/console-plugin-sdk/src/typings/index.ts +++ b/frontend/packages/console-plugin-sdk/src/typings/index.ts @@ -1,21 +1,25 @@ /** - * An extension of the Console web application. + * An extension of the Console application. * * Each extension is a realization (instance) of an extension `type` using the * parameters provided via the `properties` object. * - * Core extension types should follow `Category` or `Category/Specialization` - * format, e.g. `NavItem/Href`. + * The value of extension `type` should be formatted in a way that describes + * the broader category as well as any specialization(s), for example: * - * @todo(vojtech) write ESLint rule to guard against extension type duplicity + * - `ModelDefinition` + * - `NavItem/Href` + * - `Dashboards/Overview/Utilization` + * + * TODO(vojtech): write ESLint rule to guard against extension type duplicity */ -export interface Extension

{ +export type Extension

= { type: string; properties: P; -} +}; /** - * A plugin is simply a list of extensions. + * From plugin author perspective, a plugin is simply a list of extensions. * * Plugin metadata is stored in the `package.json` file of the corresponding * monorepo package. The `consolePlugin.entry` path should point to a module @@ -56,12 +60,17 @@ export interface Extension

{ export type Plugin> = E[]; /** - * A list of arbitrary plugins. + * From Console application perspective, a plugin is a list of extensions + * enhanced with additional data. */ -export type PluginList = Plugin>[]; +export type ActivePlugin = { + name: string; + extensions: Extension[]; +}; // TODO(vojtech): internal code needed by plugin SDK should be moved to console-shared package export * from './features'; +export * from './models'; export * from './nav'; export * from './pages'; diff --git a/frontend/packages/console-plugin-sdk/src/typings/models.ts b/frontend/packages/console-plugin-sdk/src/typings/models.ts new file mode 100644 index 00000000000..9845753df3b --- /dev/null +++ b/frontend/packages/console-plugin-sdk/src/typings/models.ts @@ -0,0 +1,16 @@ +import { Extension } from '.'; +import { K8sKind } from '@console/internal/module/k8s'; + +namespace ExtensionProperties { + export interface ModelDefinition { + models: K8sKind[]; + } +} + +export interface ModelDefinition extends Extension { + type: 'ModelDefinition'; +} + +export function isModelDefinition(e: Extension): e is ModelDefinition { + return e.type === 'ModelDefinition'; +} diff --git a/frontend/public/module/k8s/k8s-models.ts b/frontend/public/module/k8s/k8s-models.ts index 3e1f08a4dcb..3bc3e3ea817 100644 --- a/frontend/public/module/k8s/k8s-models.ts +++ b/frontend/public/module/k8s/k8s-models.ts @@ -5,20 +5,45 @@ import { K8sResourceKindReference, K8sKind } from './index'; import * as staticModels from '../../models'; import { referenceForModel, kindForReference } from './k8s'; import store from '../../redux'; +import * as plugins from '../../plugins'; + +export const modelsToMap = (models: K8sKind[]): ImmutableMap => { + return ImmutableMap() + .withMutations(map => { + models.forEach(model => { + if (model.crd) { + map.set(referenceForModel(model), model); + } else { + // TODO: Use `referenceForModel` even for known API objects + map.set(model.kind, model); + } + }); + }); +}; /** * Contains static resource definitions for Kubernetes objects. * Keys are of type `group:version:Kind`, but TypeScript doesn't support regex types (https://github.com/Microsoft/TypeScript/issues/6579). */ -const k8sModels = ImmutableMap() - .withMutations(models => _.forEach(staticModels, model => { - if (model.crd) { - models.set(referenceForModel(model), model); - } else { - // TODO: Use `referenceForModel` even for known API objects - models.set(model.kind, model); - } - })); +let k8sModels = modelsToMap(_.values(staticModels)); + +const hasModel = (model: K8sKind) => k8sModels.has(referenceForModel(model)) || k8sModels.has(model.kind); + +k8sModels = k8sModels.withMutations(map => { + const baseModelCount = map.size; + const pluginModels = _.flatMap(plugins.registry.getModelDefinitions().map(md => md.properties.models)); + + const pluginModelsToAdd = pluginModels.filter(model => !hasModel(model)); + map.merge(modelsToMap(pluginModelsToAdd)); + + _.difference(pluginModels, pluginModelsToAdd).forEach(model => { + // eslint-disable-next-line no-console + console.warn(`attempt to redefine model ${referenceForModel(model)}`); + }); + + // eslint-disable-next-line no-console + console.info(`${map.size - baseModelCount} new models added by plugins`); +}); /** * Provides a synchronous way to acquire a statically-defined Kubernetes model. @@ -49,4 +74,3 @@ export const modelFor = (ref: K8sResourceKindReference) => { * NOTE: This will not work for CRDs defined at runtime, use `connectToModels` instead. */ export const allModels = () => k8sModels; - diff --git a/frontend/public/plugins.ts b/frontend/public/plugins.ts index 1a33c323d01..f8eb131f400 100644 --- a/frontend/public/plugins.ts +++ b/frontend/public/plugins.ts @@ -1,16 +1,16 @@ /* eslint-disable no-undef */ -import { PluginList, ExtensionRegistry } from '@console/plugin-sdk'; +import { ActivePlugin, ExtensionRegistry } from '@console/plugin-sdk'; export * from '@console/plugin-sdk'; // the '@console/active-plugins' module is generated during webpack build const activePlugins = (process.env.NODE_ENV !== 'test') - ? require('@console/active-plugins').default as PluginList + ? require('@console/active-plugins').default as ActivePlugin[] : []; export const registry = new ExtensionRegistry(activePlugins); if (process.env.NODE_ENV !== 'test') { // eslint-disable-next-line no-console - console.info(`${activePlugins.length} plugins active`); + console.info(`Active plugins: [${activePlugins.map(p => p.name).join(', ')}]`); } diff --git a/frontend/webpack.config.ts b/frontend/webpack.config.ts index 345a5de871d..4348274a7a3 100644 --- a/frontend/webpack.config.ts +++ b/frontend/webpack.config.ts @@ -8,7 +8,7 @@ import * as ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; import * as MiniCssExtractPlugin from 'mini-css-extract-plugin'; import * as VirtualModulesPlugin from 'webpack-virtual-modules'; -import { readPackages, resolveActivePlugins, getActivePluginsModule } from '@console/plugin-sdk/src/codegen'; +import { readPackages, getActivePluginPackages, getActivePluginsModule } from '@console/plugin-sdk/src/codegen'; const NODE_ENV = process.env.NODE_ENV; @@ -134,7 +134,7 @@ if (NODE_ENV === 'production') { /* Console plugin support */ const packageFiles = glob.sync('packages/*/package.json', { absolute: true }); const { appPackage, pluginPackages } = readPackages(packageFiles); -const activePluginPackages = appPackage ? resolveActivePlugins(appPackage, pluginPackages) : []; +const activePluginPackages = appPackage ? getActivePluginPackages(appPackage, pluginPackages) : []; config.plugins.push( new VirtualModulesPlugin({