UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

591 lines (590 loc) 24.5 kB
/* * Copyright 2025 The Kubernetes Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * The index.ts should have the functions that Headlamp itself needs for * loading the plugins. * The lib.ts file should carry the bits to be used by plugins whereas */ import * as Iconify from '@iconify/react'; import * as ReactMonacoEditor from '@monaco-editor/react'; import * as MuiLab from '@mui/lab'; import * as MuiMaterial from '@mui/material'; import * as MuiMaterialStyles from '@mui/material/styles'; import { styled } from '@mui/system'; import * as Lodash from 'lodash'; import * as MonacoEditor from 'monaco-editor'; import * as Notistack from 'notistack'; import * as React from 'react'; import * as ReactJSX from 'react/jsx-runtime'; import * as ReactDOM from 'react-dom'; import * as ReactRedux from 'react-redux'; import * as ReactRouter from 'react-router-dom'; import * as Recharts from 'recharts'; import semver from 'semver'; import { Activity } from '../components/activity/Activity'; import { runCommand } from '../components/App/runCommand'; import { themeSlice } from '../components/App/themeSlice'; import * as CommonComponents from '../components/common'; import { addBackstageAuthHeaders } from '../helpers/addBackstageAuthHeaders'; import { getAppUrl } from '../helpers/getAppUrl'; import { isElectron } from '../helpers/isElectron'; import i18next from '../i18n/config'; import * as K8s from '../lib/k8s'; import * as ApiProxy from '../lib/k8s/apiProxy'; import * as Crd from '../lib/k8s/crd'; import * as Notification from '../lib/notification'; import * as Router from '../lib/router'; import * as Utils from '../lib/util'; import { eventAction, HeadlampEventType } from '../redux/headlampEventSlice'; import store from '../redux/stores/store'; import { Headlamp, Plugin } from './lib'; import { changePluginLanguage, initializePluginI18n } from './pluginI18n'; import { useTranslation } from './pluginI18n'; import Registry, * as registryToExport from './registry'; import { getInfoForRunningPlugins, identifyPackages, runPlugin } from './runPlugin'; window.pluginLib = { ApiProxy, ReactMonacoEditor: { ...ReactMonacoEditor, // required for compatibility with plugins built with webpack __esModule: true, }, MonacoEditor, K8s, Crd: { ...Crd, // required for compatibility with plugins built with webpack __esModule: true, }, CommonComponents, MuiMaterial: { ...MuiMaterial, styles: MuiMaterialStyles, }, /** * @mui/styles is not compatible with React.StrictMode or React 18, and it will not be updated. * Workaround is using styled function from @mui/system */ MuiStyles: { makeStyles: styled, }, MuiLab, React, ReactJSX, ReactDOM, Recharts, ReactRouter, ReactRedux, Router, Utils, Iconify, Lodash, Notistack, Notification, Headlamp, Plugin, useTranslation, ...registryToExport, Activity, }; // backwards compat. window.pluginLib.MuiCore = window.pluginLib.MuiMaterial; // @todo: should window.plugins be private? // @todo: Should all the plugin objects be in a single window.Headlamp object? window.plugins = {}; /** * Load external, then local plugins. Then initialize() them in order with a Registry. */ export async function initializePlugins() { // Initialize every plugin in the order they were loaded. return new Promise(resolve => { for (const pluginName of Object.keys(window.plugins)) { const plugin = window.plugins[pluginName]; try { // @todo: what should happen if this fails? plugin.initialize(new Registry()); } catch (e) { console.error(`Plugin initialize() error in ${pluginName}:`, e); } } resolve(undefined); }); } /** * This can be used to filter out which of the plugins we should execute. * * @param sources array of source to execute. Has the same order as packageInfos. * @param packageInfos array of package.json contents * @param appMode if we are in app mode * @param compatibleVersion headlamp-plugin version this build is compatible with. * If the plugin engine version is not compatible, the plugin will not be loaded. * Can be set to a semver range, e.g. '>= 0.6.0' or '0.6.0 - 0.7.0'. * If set to an empty string, all plugin versions will be loaded. * @param settingsPackages the packages from settings * * @returns the sources to execute and incompatible PackageInfos * with this structure { sourcesToExecute, incompatiblePackageInfos } */ export function filterSources(sources, packageInfos, appMode, compatibleVersion, settingsPackages) { const incompatiblePlugins = {}; // combine the parallel arrays const sourcesAndPackageInfos = sources.map((source, i) => { return { source, packageInfo: packageInfos[i] }; }); const enabledSourcesAndPackageInfos = sourcesAndPackageInfos.filter(({ packageInfo }) => { // When not in appMode we don't have settings to enable plugins. if (!appMode) { return true; } // No plugins should be enabled if settings are not set. if (!settingsPackages) { return false; } // settingsPackages might have a different order or length than packageInfos // If it's not in the settings don't enable the plugin. const enabledInSettings = settingsPackages[settingsPackages.findIndex(x => x.name === packageInfo.name)]?.isEnabled === true; return enabledInSettings; }); const compatible = enabledSourcesAndPackageInfos.filter(({ packageInfo }) => { const isCompatible = semver.satisfies(semver.coerce(packageInfo.devDependencies?.['@kinvolk/headlamp-plugin']) || '', compatibleVersion); if (!isCompatible) { incompatiblePlugins[packageInfo.name] = packageInfo; return false; } return true; }); return { sourcesToExecute: compatible.map(({ source }) => source), incompatiblePlugins, }; } /** * Apply priority-based plugin loading logic. * * When multiple versions of the same plugin exist across different locations: * - Priority order: development > user > shipped * - Only the highest priority ENABLED version is loaded * - If a higher priority version is disabled, the next enabled version is loaded * - Lower priority versions are marked with isLoaded=false and overriddenBy info * * @param plugins List of all plugins from all locations * @returns Plugins with isLoaded and overriddenBy fields set appropriately */ export function applyPluginPriority(plugins) { // Group plugins by name const pluginsByName = new Map(); plugins.forEach(plugin => { const existing = pluginsByName.get(plugin.name) || []; existing.push(plugin); pluginsByName.set(plugin.name, existing); }); const result = []; // Process each plugin name group pluginsByName.forEach(versions => { if (versions.length === 1) { // Only one version exists, mark it as loaded if enabled result.push({ ...versions[0], isLoaded: versions[0].isEnabled !== false, }); return; } // Multiple versions exist - apply priority const priorityOrder = [ 'development', 'user', 'shipped', ]; // Sort versions by priority (highest first) const sortedVersions = versions.sort((a, b) => { const aPriority = priorityOrder.indexOf(a.type || 'shipped'); const bPriority = priorityOrder.indexOf(b.type || 'shipped'); return aPriority - bPriority; }); // Find the highest priority enabled version let loadedVersion = null; for (const version of sortedVersions) { if (version.isEnabled !== false) { loadedVersion = version; break; } } // Mark each version appropriately sortedVersions.forEach(version => { if (loadedVersion && version === loadedVersion) { // This is the version that will be loaded result.push({ ...version, isLoaded: true, }); } else { // This version is overridden by a higher priority version result.push({ ...version, isLoaded: false, overriddenBy: loadedVersion?.type, }); } }); }); return result; } /** * Updates settings packages based on what the backend provides. * * - For new plugins (not in settings), includes them with isEnabled=true * - For existing plugins (in settings), preserves their isEnabled preference * - Returns only plugins that exist in the backend list (automatically removing any that are gone) * - Treats plugins with the same name but different types as separate entries * - Each plugin is identified by name + type combination * * @param backendPlugins the list of plugins info from the backend. * @param settingsPlugins the list of plugins the settings already knows about. * @returns plugin info for the settings (only includes plugins from backend). */ export function updateSettingsPackages(backendPlugins, settingsPlugins) { if (backendPlugins.length === 0) return []; // Create a unique key for each plugin (name + type) const getPluginKey = (plugin) => `${plugin.name}@${plugin.type || 'unknown'}`; const pluginsChanged = backendPlugins.length !== settingsPlugins.length || backendPlugins.map(p => getPluginKey(p) + p.version).join('') !== settingsPlugins.map(p => getPluginKey(p) + p.version).join(''); if (!pluginsChanged) { return settingsPlugins; } return backendPlugins.map(plugin => { // Find matching plugin by name AND type const index = settingsPlugins.findIndex(x => x.name === plugin.name && x.type === plugin.type); if (index === -1) { // It's a new one settings doesn't know about, enable it by default return { ...plugin, isEnabled: true, }; } // Merge settings with backend info, preserving user's isEnabled preference return { ...settingsPlugins[index], ...plugin, isEnabled: settingsPlugins[index].isEnabled, }; }); } /** * Runs a plugin with the given info. * * This is not a closure, so it doens't have access to the variables * in the scope of the function that called it. */ function runPluginInner(info) { // We avoid destructuring here in case that is overridden by a plugin. const source = info[0]; const packageName = info[1]; const packageVersion = info[2]; const handleError = info[3]; const PrivateFunction = info[4]; const args = info[5]; const values = info[6]; const privateRunPlugin = info[7]; privateRunPlugin(source, packageName, packageVersion, handleError, PrivateFunction, args, values); } const PLUGIN_LOADING_ERROR = HeadlampEventType.PLUGIN_LOADING_ERROR; const consoleError = console.error; const storeDispatch = store.dispatch; const privateEventAction = eventAction; /** * Handles the error that occurs when a plugin fails to run. * * @param error The error that occurred. * @param packageName The name of the package that failed. * @param packageVersion The version of the package that failed. */ function handlePluginRunError(error, packageName, packageVersion) { consoleError('Plugin execution error in ' + packageName + ':', error); storeDispatch(privateEventAction({ type: PLUGIN_LOADING_ERROR, data: { pluginInfo: { name: packageName, version: packageVersion }, error, }, })); } /** * Retry with exponential backoff starting at 50ms, doubling each time and capped at 1000ms. * Retries continue until the total accumulated wait reaches 30 seconds. * * @param url The URL to fetch. * @param maxTotalWaitMs Maximum total wait time across retries (default 30000ms). * @param baseDelayMs Initial delay before first retry (default 50ms). * @param maxDelayMs Maximum delay per retry (default 1000ms). * @returns A promise that resolves to the response of the fetch request. */ async function fetchWithRetry(url, headers, maxTotalWaitMs = 30000, baseDelayMs = 50, maxDelayMs = 1000) { let attempt = 0; let totalSlept = 0; let lastErr; while (totalSlept < maxTotalWaitMs) { try { const resp = await fetch(url, { headers: new Headers(headers) }); if (!resp.ok) throw new Error(`HTTP error: ${resp.status}`); return resp; } catch (err) { lastErr = err; const remaining = maxTotalWaitMs - totalSlept; if (remaining <= 0) break; const wait = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs, remaining); attempt++; await new Promise(res => setTimeout(res, wait)); totalSlept += wait; } } throw lastErr ?? new Error('Fetch failed after retries'); } /** * Get the list of plugins, * download all the plugin source, * download all the plugin package.json files, * apply priority-based filtering (dev > user > shipped), * filter incompatible plugins and respect enable/disable settings, * execute only the highest priority enabled version of each plugin, * initialize() plugins that register. * * @param settingsPackages The packages settings knows about. * @param onSettingsChange Called when the plugins are different to what is in settings. * @param onIncompatible Called when there are incompatible plugins. * */ export async function fetchAndExecutePlugins(settingsPackages, onSettingsChange, onIncompatible) { const permissionSecretsPromise = permissionSecretsFromApp(); const headers = addBackstageAuthHeaders(); const pluginMetadataList = (await fetchWithRetry(`${getAppUrl()}plugins`, headers).then(resp => resp.json())); // Extract paths for fetching plugin files const pluginPaths = pluginMetadataList.map(metadata => metadata.path); const sourcesPromise = Promise.all(pluginPaths.map(path => fetch(`${getAppUrl()}${path}/main.js`, { headers: new Headers(headers) }).then(resp => resp.text()))); const packageInfosPromise = await Promise.all(pluginPaths.map((path, index) => fetch(`${getAppUrl()}${path}/package.json`, { headers: new Headers(headers) }).then(resp => { if (!resp.ok) { if (resp.status !== 404) { return Promise.reject(resp); } { console.warn('Missing package.json. ' + `Please upgrade the plugin ${path}` + ' by running "headlamp-plugin extract" again.' + ' Please use headlamp-plugin >= 0.8.0'); return { name: path.split('/').slice(-1)[0], version: '0.0.0', author: 'unknown', description: '', type: pluginMetadataList[index].type, folderName: pluginMetadataList[index].name, }; } } return resp.json().then(json => ({ ...json, type: pluginMetadataList[index].type, folderName: pluginMetadataList[index].name, })); }))); const sources = await sourcesPromise; const packageInfos = await packageInfosPromise; const permissionSecrets = await permissionSecretsPromise; // Update settings to include all plugin versions (by name + type) let updatedSettingsPackages = updateSettingsPackages(packageInfos, settingsPackages); // Apply priority-based loading logic updatedSettingsPackages = applyPluginPriority(updatedSettingsPackages); // Notify settings of changes onSettingsChange(updatedSettingsPackages); // Can set this to a semver version range like '>=0.8.0-alpha.3'. // '' means all versions. const compatibleHeadlampPluginVersion = '>=0.8.0-alpha.3'; // Mark incompatible plugins const incompatiblePlugins = {}; updatedSettingsPackages = updatedSettingsPackages.map(plugin => { const isCompatible = semver.satisfies(semver.coerce(plugin.devDependencies?.['@kinvolk/headlamp-plugin']) || '', compatibleHeadlampPluginVersion); if (!isCompatible) { incompatiblePlugins[`${plugin.name}@${plugin.type}`] = plugin; } return { ...plugin, isCompatible, }; }); if (Object.keys(incompatiblePlugins).length > 0) { onIncompatible(incompatiblePlugins); } // Update settings with compatibility info onSettingsChange(updatedSettingsPackages); // Filter to only execute plugins that should be loaded // A plugin is executed if: // 1. It's marked as isLoaded=true (highest priority enabled version) // 2. It's compatible with this version of Headlamp // 3. In app mode, it must be enabled const pluginsToExecute = updatedSettingsPackages.filter(plugin => { // Must be marked as the version to load if (!plugin.isLoaded) { return false; } // Must be compatible if (!plugin.isCompatible) { return false; } // In app mode, must be enabled if (isElectron() && plugin.isEnabled === false) { return false; } return true; }); // Get indices of plugins to execute for matching with sources const indicesToExecute = pluginsToExecute.map(plugin => packageInfos.findIndex(p => p.name === plugin.name && p.type === plugin.type)); const sourcesToExecute = indicesToExecute.map(index => sources[index]); const pluginPathsToExecute = indicesToExecute.map(index => pluginPaths[index]); const packageInfosToExecute = indicesToExecute.map(index => packageInfos[index]); // Save references to the pluginRunCommand and desktopApiSend/Receive. // Plugins can use without worrying about modified global window.desktopApi. // This is to prevent plugins from snooping on the permission secrets. const pluginDesktopApiSend = window?.desktopApi?.send; const pluginDesktopApiReceive = window?.desktopApi?.receive; const internalRunCommand = runCommand; const PrivateFunction = Function; const internalRunPlugin = runPlugin; const isDevelopmentMode = process.env.NODE_ENV === 'development'; const consoleError = console.error; const pluginsLoaded = updatedSettingsPackages .filter(plugin => plugin.isLoaded) .map(plugin => ({ name: plugin.name, version: plugin.version, isEnabled: plugin.isEnabled, type: plugin.type, })); const infoForRunningPlugins = sourcesToExecute .map((source, index) => { return getInfoForRunningPlugins({ source, pluginPath: pluginPathsToExecute[index], packageName: packageInfosToExecute[index].name, packageVersion: packageInfosToExecute[index].version || '', permissionSecrets, handleError: handlePluginRunError, getAllowedPermissions: (pluginName, pluginPath, secrets) => { const secretsToReturn = {}; const isPackage = identifyPackages(pluginPath, pluginName, isDevelopmentMode); if (isPackage['@headlamp-k8s/minikube']) { secretsToReturn['runCmd-minikube'] = secrets['runCmd-minikube']; if (isDevelopmentMode) { secretsToReturn['runCmd-scriptjs-minikube/manage-minikube.js'] = secrets['runCmd-scriptjs-minikube/manage-minikube.js']; } secretsToReturn['runCmd-scriptjs-headlamp_minikube/manage-minikube.js'] = secrets['runCmd-scriptjs-headlamp_minikube/manage-minikube.js']; secretsToReturn['runCmd-scriptjs-headlamp_minikubeprerelease/manage-minikube.js'] = secrets['runCmd-scriptjs-headlamp_minikubeprerelease/manage-minikube.js']; } return secretsToReturn; }, getArgValues: (pluginName, pluginPath, allowedPermissions) => { // allowedPermissions is the return value of getAllowedPermissions const isPackage = identifyPackages(pluginPath, pluginName, isDevelopmentMode); if (isPackage['@headlamp-k8s/minikube']) { // We construct a pluginRunCommand that has private // - permission secrets // - stored desktopApiSend and desktopApiReceive functions that can't be modified function pluginRunCommand(command, args, options) { return internalRunCommand(command, args, options, allowedPermissions, pluginDesktopApiSend, pluginDesktopApiReceive); } return [ ['pluginRunCommand', 'pluginPath'], [pluginRunCommand, pluginPath], ]; } return [[], []]; }, PrivateFunction, internalRunPlugin, consoleError, }); }) .filter(info => info !== undefined); // put the ones with args and values at the start infoForRunningPlugins.sort((a, b) => { const aHasArgs = a[5].length > 0 && a[6].length > 0; const bHasArgs = b[5].length > 0 && b[6].length > 0; if (aHasArgs && !bHasArgs) return -1; if (!aHasArgs && bHasArgs) return 1; return 0; }); infoForRunningPlugins.forEach(runPluginInner); // Initialize plugin i18n after plugins are loaded await initializePluginsI18n(packageInfos, pluginPaths); await afterPluginsRun(pluginsLoaded); } /** * Initialize i18n for all plugins that have i18n configuration */ async function initializePluginsI18n(packageInfos, pluginPaths) { for (let i = 0; i < packageInfos.length; i++) { const packageInfo = packageInfos[i]; const pluginPath = pluginPaths[i]; await initializePluginI18n(packageInfo.name, packageInfo, pluginPath); } // Set up language change synchronization i18next.on('languageChanged', language => { changePluginLanguage(language); }); } /** * This is called after all plugins are loaded. * It initializes the plugins(that need it) and dispatches the PLUGINS_LOADED event. */ async function afterPluginsRun(pluginsLoaded) { await initializePlugins(); store.dispatch(eventAction({ type: HeadlampEventType.PLUGINS_LOADED, data: { plugins: pluginsLoaded }, })); // Refresh theme name if the theme that was used from a plugin was deleted store.dispatch(themeSlice.actions.ensureValidThemeName()); } /** * Asks the main electron process for the permission secrets. * * @returns promise with permissions secrets like { 'runCmd-minikube': 1235555 } */ export async function permissionSecretsFromApp() { const { desktopApi } = window; if (desktopApi) { return new Promise(resolve => { desktopApi.receive('plugin-permission-secrets', (secrets) => { resolve(secrets); }); desktopApi.send('request-plugin-permission-secrets'); }); } else { return new Promise(resolve => resolve({})); } }