diff --git a/app/electron/main.ts b/app/electron/main.ts index bab5cd20243..fe690156540 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -69,6 +69,16 @@ const args = yargs(hideBin(process.argv)) describe: 'Disable use of GPU. For people who may have buggy graphics drivers', type: 'boolean', }, + 'disable-plugins': { + describe: 'Disable specific plugins or all plugins if no argument is provided', + type: 'string', + coerce: arg => { + if (typeof arg === 'undefined') { + return 'all'; + } + return arg.split(','); + }, + }, }) .positional('kubeconfig', { describe: diff --git a/app/electron/plugin-management.ts b/app/electron/plugin-management.ts index 6a04019bb8a..a365cc512f9 100644 --- a/app/electron/plugin-management.ts +++ b/app/electron/plugin-management.ts @@ -73,6 +73,7 @@ export class PluginManager { * Installs a plugin from the specified URL. * @param {string} URL - The URL of the plugin to install. * @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin will be installed. + * @param {string[]} [disabledPlugins = []] - An array of disabled plugins. * @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking. * @param {function} [progressCallback=null] - Optional callback for progress updates. * @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation. @@ -81,6 +82,7 @@ export class PluginManager { static async install( URL, destinationFolder = defaultPluginsDir(), + disabledPlugins: string[] = [], headlampVersion = '', progressCallback: null | ProgressCallback = null, signal: AbortSignal | null = null @@ -93,6 +95,10 @@ export class PluginManager { signal ); + if (disabledPlugins.includes(name)) { + throw new Error(`Plugin ${name} is disabled`); + } + // sleep(2000); // comment out for testing // create the destination folder if it doesn't exist @@ -119,6 +125,7 @@ export class PluginManager { * Updates an installed plugin to the latest version. * @param {string} pluginName - The name of the plugin to update. * @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin is installed. + * @param {string[]} [disabledPlugins = []] - An array of disabled plugins. * @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking. * @param {null | ProgressCallback} [progressCallback=null] - Optional callback for progress updates. * @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation. @@ -127,11 +134,16 @@ export class PluginManager { static async update( pluginName, destinationFolder = defaultPluginsDir(), + disabledPlugins: string[] = [], headlampVersion = '', progressCallback: null | ProgressCallback = null, signal: AbortSignal | null = null ): Promise { try { + if (disabledPlugins.includes(pluginName)) { + throw new Error(`Plugin ${pluginName} is disabled`); + } + // @todo: should list call take progressCallback? const installedPlugins = PluginManager.list(destinationFolder); if (!installedPlugins) { @@ -195,15 +207,21 @@ export class PluginManager { * Uninstalls a plugin from the specified folder. * @param {string} name - The name of the plugin to uninstall. * @param {string} [folder=defaultPluginsDir()] - The folder where the plugin is installed. + * @param {string[]} [disabledPlugins = []] - An array of disabled plugins. * @param {function} [progressCallback=null] - Optional callback for progress updates. * @returns {void} */ static uninstall( name, folder = defaultPluginsDir(), + disabledPlugins: string[] = [], progressCallback: null | ProgressCallback = null ) { try { + if (disabledPlugins.includes(name)) { + throw new Error(`Plugin ${name} is disabled`); + } + // @todo: should list call take progressCallback? const installedPlugins = PluginManager.list(folder); if (!installedPlugins) { @@ -239,13 +257,20 @@ export class PluginManager { /** * Lists all valid plugins in the specified folder. * @param {string} [folder=defaultPluginsDir()] - The folder to list plugins from. + * @param {string[]} [disabledPlugins = []] - An array of disabled plugins. * @param {function} [progressCallback=null] - Optional callback for progress updates. * @returns {Array} An array of objects representing valid plugins. */ - static list(folder = defaultPluginsDir(), progressCallback: null | ProgressCallback = null) { + static list( + folder = defaultPluginsDir(), + disabledPlugins: string[] = [], + progressCallback: null | ProgressCallback = null + ) { try { const pluginsData: PluginData[] = []; + const disableAllPlugins = disabledPlugins.length === 0; + // Read all entries in the specified folder const entries = fs.readdirSync(folder, { withFileTypes: true }); @@ -260,7 +285,13 @@ export class PluginManager { // Read package.json to get the plugin name and version const packageJsonPath = path.join(pluginDir, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - const pluginName = packageJson.name || pluginFolder.name; + const pluginName: string = packageJson.name || pluginFolder.name; + + if (disableAllPlugins || disabledPlugins.includes(pluginName)) { + console.log(`Plugin ${pluginName} is disabled`); + continue; + } + const pluginTitle = packageJson.artifacthub.title; const pluginVersion = packageJson.version || null; const artifacthubURL = packageJson.artifacthub ? packageJson.artifacthub.url : null; diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index d271c874dd2..c0569e3e2c1 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -51,6 +51,7 @@ type HeadlampConfig struct { staticDir string pluginDir string staticPluginDir string + disabledPlugins string oidcClientID string oidcClientSecret string oidcIdpIssuerURL string @@ -316,13 +317,18 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { logger.Log(logger.LevelInfo, nil, nil, "Helm support: "+fmt.Sprint(config.enableHelm)) logger.Log(logger.LevelInfo, nil, nil, "Proxy URLs: "+fmt.Sprint(config.proxyURLs)) - plugins.PopulatePluginsCache(config.staticPluginDir, config.pluginDir, config.cache) + plugins.PopulatePluginsCache(config.staticPluginDir, config.pluginDir, config.cache, config.disabledPlugins) if !config.useInCluster { // in-cluster mode is unlikely to want reloading plugins. pluginEventChan := make(chan string) go plugins.Watch(config.pluginDir, pluginEventChan) - go plugins.HandlePluginEvents(config.staticPluginDir, config.pluginDir, pluginEventChan, config.cache) + go plugins.HandlePluginEvents(config.staticPluginDir, + config.pluginDir, + pluginEventChan, + config.cache, + config.disabledPlugins, + ) // in-cluster mode is unlikely to want reloading kubeconfig. go kubeconfig.LoadAndWatchFiles(config.kubeConfigStore, kubeConfigPath, kubeconfig.KubeConfig) } diff --git a/backend/cmd/server.go b/backend/cmd/server.go index 2959990c40a..863b2f3466c 100644 --- a/backend/cmd/server.go +++ b/backend/cmd/server.go @@ -28,6 +28,7 @@ func main() { staticDir: conf.StaticDir, insecure: conf.InsecureSsl, pluginDir: conf.PluginsDir, + disabledPlugins: conf.DisabledPlugins, oidcClientID: conf.OidcClientID, oidcClientSecret: conf.OidcClientSecret, oidcIdpIssuerURL: conf.OidcIdpIssuerURL, diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index bb110b897e9..7c7c6594c18 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -2,7 +2,6 @@ package config import ( "errors" - "flag" "fmt" "io/fs" "os" @@ -11,9 +10,11 @@ import ( "runtime" "strings" + pflagProvider "github.com/knadh/koanf/providers/posflag" + flag "github.com/spf13/pflag" + "github.com/headlamp-k8s/headlamp/backend/pkg/logger" "github.com/knadh/koanf" - "github.com/knadh/koanf/providers/basicflag" "github.com/knadh/koanf/providers/env" ) @@ -35,6 +36,7 @@ type Config struct { OidcClientSecret string `koanf:"oidc-client-secret"` OidcIdpIssuerURL string `koanf:"oidc-idp-issuer-url"` OidcScopes string `koanf:"oidc-scopes"` + DisabledPlugins string `koanf:"disable-plugins"` } func (c *Config) Validate() error { @@ -62,7 +64,7 @@ func (c *Config) Validate() error { func Parse(args []string) (*Config, error) { var config Config - f := flagset() + f := flagset(&config) k := koanf.New(".") @@ -73,7 +75,7 @@ func Parse(args []string) (*Config, error) { } // First Load default args from flags - if err := k.Load(basicflag.Provider(f, "."), nil); err != nil { + if err := k.Load(pflagProvider.Provider(f, ".", k), nil); err != nil { logger.Log(logger.LevelError, nil, err, "loading default config from flags") return nil, fmt.Errorf("error loading default config from flags: %w", err) @@ -96,7 +98,7 @@ func Parse(args []string) (*Config, error) { } // Load only the flags that were set - if err := k.Load(basicflag.ProviderWithValue(f, ".", func(key string, value string) (string, interface{}) { + if err := k.Load(pflagProvider.ProviderWithValue(f, ".", k, func(key string, value string) (string, interface{}) { flagSet := false f.Visit(func(f *flag.Flag) { if f.Name == key { @@ -146,10 +148,10 @@ func Parse(args []string) (*Config, error) { return &config, nil } -func flagset() *flag.FlagSet { +func flagset(c *Config) *flag.FlagSet { f := flag.NewFlagSet("config", flag.ContinueOnError) - f.Bool("in-cluster", false, "Set when running from a k8s cluster") + f.BoolP("in-cluster", "i", false, "Set when running from a k8s cluster") f.Bool("dev", false, "Allow connections from other origins") f.Bool("insecure-ssl", false, "Accept/Ignore all server SSL certificates") f.Bool("enable-dynamic-clusters", false, "Enable dynamic clusters, which stores stateless clusters in the frontend.") @@ -161,12 +163,17 @@ func flagset() *flag.FlagSet { f.Uint("port", defaultPort, "Port to listen from") f.String("proxy-urls", "", "Allow proxy requests to specified URLs") - f.String("oidc-client-id", "", "ClientID for OIDC") + f.StringP("oidc-client-id", "o", "", "ClientID for OIDC") f.String("oidc-client-secret", "", "ClientSecret for OIDC") f.String("oidc-idp-issuer-url", "", "Identity provider issuer URL for OIDC") f.String("oidc-scopes", "profile,email", "A comma separated list of scopes needed from the OIDC provider") + f.StringVar(&c.DisabledPlugins, "disable-plugins", "", + "List of plugin names to disable, or empty to disable all plugins") + + f.Lookup("disable-plugins").NoOptDefVal = "all" + return f } diff --git a/backend/pkg/config/config_test.go b/backend/pkg/config/config_test.go index b8d6826def4..0d6fc91e4f1 100644 --- a/backend/pkg/config/config_test.go +++ b/backend/pkg/config/config_test.go @@ -36,7 +36,7 @@ func TestParse(t *testing.T) { os.Setenv("HEADLAMP_CONFIG_OIDC_CLIENT_SECRET", "superSecretBotsStayAwayPlease") defer os.Unsetenv("HEADLAMP_CONFIG_OIDC_CLIENT_SECRET") args := []string{ - "go run ./cmd", "-in-cluster", + "go run ./cmd", "--in-cluster", } conf, err := config.Parse(args) require.NoError(t, err) diff --git a/backend/pkg/plugins/plugins.go b/backend/pkg/plugins/plugins.go index 431052af218..45af88bef45 100644 --- a/backend/pkg/plugins/plugins.go +++ b/backend/pkg/plugins/plugins.go @@ -77,20 +77,43 @@ func periodicallyWatchSubfolders(watcher *fsnotify.Watcher, path string, interva } } +// GetDisabledPlugins returns a list of disabled plugins and a boolean indicating if all plugins are disabled. +func GetDisabledPlugins(disablePluginsFlag string) ([]string, bool) { + // If the flag is an empty string, all plugins are disabled. + if disablePluginsFlag == "all" { + return nil, true + } + + // Parse the list of plugins to disable. + disabledPlugins := strings.Split(disablePluginsFlag, ",") + for i := range disabledPlugins { + disabledPlugins[i] = strings.TrimSpace(disabledPlugins[i]) + } + + return disabledPlugins, false +} + // GeneratePluginPaths takes the staticPluginDir and pluginDir and returns a list of plugin paths. -func GeneratePluginPaths(staticPluginDir string, pluginDir string) ([]string, error) { +func GeneratePluginPaths(staticPluginDir string, pluginDir string, disabledPluginsFlag string) ([]string, error) { + // Get the list of disabled plugins and whether all plugins are disabled. + disabledPlugins, disableAll := GetDisabledPlugins(disabledPluginsFlag) + + if disableAll { + return []string{}, nil + } + var pluginListURLStatic []string if staticPluginDir != "" { var err error - pluginListURLStatic, err = pluginBasePathListForDir(staticPluginDir, "static-plugins") + pluginListURLStatic, err = pluginBasePathListForDir(staticPluginDir, "static-plugins", disabledPlugins) if err != nil { return nil, err } } - pluginListURL, err := pluginBasePathListForDir(pluginDir, "plugins") + pluginListURL, err := pluginBasePathListForDir(pluginDir, "plugins", disabledPlugins) if err != nil { return nil, err } @@ -104,7 +127,7 @@ func GeneratePluginPaths(staticPluginDir string, pluginDir string) ([]string, er } // pluginBasePathListForDir returns a list of valid plugin paths for the given directory. -func pluginBasePathListForDir(pluginDir string, baseURL string) ([]string, error) { +func pluginBasePathListForDir(pluginDir string, baseURL string, disabledPlugins []string) ([]string, error) { files, err := os.ReadDir(pluginDir) if err != nil && !os.IsNotExist(err) { logger.Log(logger.LevelError, map[string]string{"pluginDir": pluginDir}, @@ -116,6 +139,12 @@ func pluginBasePathListForDir(pluginDir string, baseURL string) ([]string, error pluginListURLs := make([]string, 0, len(files)) for _, f := range files { + if disabledPlugins != nil && utils.Contains(disabledPlugins, f.Name()) { + logger.Log(logger.LevelInfo, map[string]string{"plugin": f.Name()}, + nil, "Not including plugin path, plugin is disabled") + continue + } + if !f.IsDir() { pluginPath := filepath.Join(pluginDir, f.Name()) logger.Log(logger.LevelInfo, map[string]string{"pluginPath": pluginPath}, @@ -154,6 +183,7 @@ func pluginBasePathListForDir(pluginDir string, baseURL string) ([]string, error // and plugin refresh key in the cache. func HandlePluginEvents(staticPluginDir, pluginDir string, notify <-chan string, cache cache.Cache[interface{}], + disabledPluginsFlag string, ) { for range notify { // set the plugin refresh key to true @@ -163,7 +193,7 @@ func HandlePluginEvents(staticPluginDir, pluginDir string, } // generate the plugin list - pluginList, err := GeneratePluginPaths(staticPluginDir, pluginDir) + pluginList, err := GeneratePluginPaths(staticPluginDir, pluginDir, disabledPluginsFlag) if err != nil && !os.IsNotExist(err) { logger.Log(logger.LevelError, nil, err, "generating plugins path") } @@ -176,7 +206,22 @@ func HandlePluginEvents(staticPluginDir, pluginDir string, } // PopulatePluginsCache populates the plugin list and plugin refresh key in the cache. -func PopulatePluginsCache(staticPluginDir, pluginDir string, cache cache.Cache[interface{}]) { +func PopulatePluginsCache(staticPluginDir, pluginDir string, + cache cache.Cache[interface{}], disabledPluginsFlag string, +) { + _, disableAll := GetDisabledPlugins(disabledPluginsFlag) + + if disableAll { + logger.Log(logger.LevelInfo, nil, nil, "All plugins are disabled") + // Update cache to reflect that all plugins are disabled. + err := cache.Set(context.Background(), PluginListKey, []string{}) + if err != nil { + logger.Log(logger.LevelError, nil, err, "setting empty plugin list in cache") + } + + return + } + // set the plugin refresh key to false err := cache.Set(context.Background(), PluginRefreshKey, false) if err != nil { @@ -185,7 +230,7 @@ func PopulatePluginsCache(staticPluginDir, pluginDir string, cache cache.Cache[i } // generate the plugin list - pluginList, err := GeneratePluginPaths(staticPluginDir, pluginDir) + pluginList, err := GeneratePluginPaths(staticPluginDir, pluginDir, disabledPluginsFlag) if err != nil && !os.IsNotExist(err) { logger.Log(logger.LevelError, map[string]string{"staticPluginDir": staticPluginDir, "pluginDir": pluginDir}, diff --git a/backend/pkg/plugins/plugins_test.go b/backend/pkg/plugins/plugins_test.go index e01aca81eee..ccec91b9991 100644 --- a/backend/pkg/plugins/plugins_test.go +++ b/backend/pkg/plugins/plugins_test.go @@ -111,7 +111,7 @@ func TestGeneratePluginPaths(t *testing.T) { //nolint:funlen _, err = os.Create(packageJSONPath) require.NoError(t, err) - pathList, err := plugins.GeneratePluginPaths("", testDirName) + pathList, err := plugins.GeneratePluginPaths("", testDirName, "") require.NoError(t, err) require.Contains(t, pathList, "plugins/"+subDirName) @@ -120,7 +120,7 @@ func TestGeneratePluginPaths(t *testing.T) { //nolint:funlen require.NoError(t, err) // test without any valid plugin - pathList, err = plugins.GeneratePluginPaths("", testDirName) + pathList, err = plugins.GeneratePluginPaths("", testDirName, "") require.NoError(t, err) require.Empty(t, pathList) }) @@ -141,7 +141,7 @@ func TestGeneratePluginPaths(t *testing.T) { //nolint:funlen _, err = os.Create(packageJSONPath) require.NoError(t, err) - pathList, err := plugins.GeneratePluginPaths(testDirName, "") + pathList, err := plugins.GeneratePluginPaths(testDirName, "", "") require.NoError(t, err) require.Contains(t, pathList, "static-plugins/"+subDirName) @@ -150,7 +150,7 @@ func TestGeneratePluginPaths(t *testing.T) { //nolint:funlen require.NoError(t, err) // test without any valid plugin - pathList, err = plugins.GeneratePluginPaths(testDirName, "") + pathList, err = plugins.GeneratePluginPaths(testDirName, "", "") require.NoError(t, err) require.Empty(t, pathList) }) @@ -168,7 +168,7 @@ func TestGeneratePluginPaths(t *testing.T) { //nolint:funlen require.NoError(t, err) // test with file as plugin Dir - pathList, err := plugins.GeneratePluginPaths(fileName, "") + pathList, err := plugins.GeneratePluginPaths(fileName, "", "") assert.Error(t, err) assert.Nil(t, pathList) }) @@ -213,7 +213,7 @@ func TestHandlePluginEvents(t *testing.T) { //nolint:funlen // create cache ch := cache.New[interface{}]() - go plugins.HandlePluginEvents("", testDirPath, events, ch) + go plugins.HandlePluginEvents("", testDirPath, events, ch, "") // plugin list key should be empty pluginList, err := ch.Get(context.Background(), plugins.PluginListKey) @@ -289,7 +289,7 @@ func TestPopulatePluginsCache(t *testing.T) { ch := cache.New[interface{}]() // call PopulatePluginsCache - plugins.PopulatePluginsCache("", "", ch) + plugins.PopulatePluginsCache("", "", ch, "") // check if the plugin refresh key is set to false pluginRefresh, err := ch.Get(context.Background(), plugins.PluginRefreshKey)