From b5d970f7bbee10c34c6086b41708f1b379f683bd Mon Sep 17 00:00:00 2001 From: bctiemann Date: Mon, 10 Mar 2025 10:51:41 -0400 Subject: [PATCH] Closes: #18535 - Skip incompatible plugins during startup (#18537) * Skip incompatible plugins during startup and remove from PLUGINS * Handle exceptions on request processors in incompatible plugins, and display status in Plugins page * Revert "Handle exceptions on request processors in incompatible plugins, and display status in Plugins page" This reverts commit d97bf2ab146114cc13d751878a17a383de0fd5f8. * Resolve merge conflicts * Skip incompatible plugins during startup and remove from PLUGINS * Rename Installed column to Active, and add custom PluginActiveColumn with tooltip * Fix is_installed * Simplify plugin_config.validate syntax Co-authored-by: Jeremy Stretch * Merge feature * Revert "Merge feature" This reverts commit d1ea60f08270b9e79d30b9fa9859049aa371f4c6. * Undo simplification * Add failed_to_load logic * Use a TemplateColumn for is_installed * Remove custom column class * Remove merge vestige * Simplify plugin attributes for is_installed column * Use placeholders for false values to increase legibility of the plugins table --------- Co-authored-by: Jeremy Stretch --- netbox/core/exceptions.py | 7 +++++++ netbox/core/plugins.py | 16 ++++++++++------ netbox/core/tables/plugins.py | 8 ++++++-- netbox/core/tables/template_code.py | 12 ++++++++++++ netbox/netbox/middleware.py | 6 +++++- netbox/netbox/plugins/__init__.py | 5 +++-- netbox/netbox/settings.py | 16 +++++++++++----- 7 files changed, 54 insertions(+), 16 deletions(-) diff --git a/netbox/core/exceptions.py b/netbox/core/exceptions.py index 8412b0378..5790704c2 100644 --- a/netbox/core/exceptions.py +++ b/netbox/core/exceptions.py @@ -1,2 +1,9 @@ +from django.core.exceptions import ImproperlyConfigured + + class SyncError(Exception): pass + + +class IncompatiblePluginError(ImproperlyConfigured): + pass diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py index d31a699e4..e4d14b810 100644 --- a/netbox/core/plugins.py +++ b/netbox/core/plugins.py @@ -65,9 +65,11 @@ class Plugin: is_certified: bool = False release_latest: PluginVersion = field(default_factory=PluginVersion) release_recent_history: list[PluginVersion] = field(default_factory=list) - is_local: bool = False # extra field for locally installed plugins - is_installed: bool = False + is_local: bool = False # Indicates that the plugin is listed in settings.PLUGINS (i.e. installed) + is_loaded: bool = False # Indicates whether the plugin successfully loaded at launch installed_version: str = '' + netbox_min_version: str = '' + netbox_max_version: str = '' def get_local_plugins(plugins=None): @@ -78,7 +80,7 @@ def get_local_plugins(plugins=None): local_plugins = {} # Gather all locally-installed plugins - for plugin_name in registry['plugins']['installed']: + for plugin_name in settings.PLUGINS: plugin = importlib.import_module(plugin_name) plugin_config: PluginConfig = plugin.config installed_version = plugin_config.version @@ -92,15 +94,17 @@ def get_local_plugins(plugins=None): tag_line=plugin_config.description, description_short=plugin_config.description, is_local=True, - is_installed=True, + is_loaded=plugin_name in registry['plugins']['installed'], installed_version=installed_version, + netbox_min_version=plugin_config.min_version, + netbox_max_version=plugin_config.max_version, ) # Update catalog entries for local plugins, or add them to the list if not listed for k, v in local_plugins.items(): if k in plugins: - plugins[k].is_local = True - plugins[k].is_installed = True + plugins[k].is_local = v.is_local + plugins[k].is_loaded = v.is_loaded plugins[k].installed_version = v.installed_version else: plugins[k] = v diff --git a/netbox/core/tables/plugins.py b/netbox/core/tables/plugins.py index 96c612366..a7773b4de 100644 --- a/netbox/core/tables/plugins.py +++ b/netbox/core/tables/plugins.py @@ -2,6 +2,7 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ from netbox.tables import BaseTable, columns +from .template_code import PLUGIN_IS_INSTALLED __all__ = ( 'CatalogPluginTable', @@ -48,12 +49,15 @@ class CatalogPluginTable(BaseTable): verbose_name=_('Author') ) is_local = columns.BooleanColumn( + false_mark=None, verbose_name=_('Local') ) - is_installed = columns.BooleanColumn( - verbose_name=_('Installed') + is_installed = columns.TemplateColumn( + verbose_name=_('Active'), + template_code=PLUGIN_IS_INSTALLED ) is_certified = columns.BooleanColumn( + false_mark=None, verbose_name=_('Certified') ) created_at = columns.DateTimeColumn( diff --git a/netbox/core/tables/template_code.py b/netbox/core/tables/template_code.py index c8f0058e7..9fc652c4c 100644 --- a/netbox/core/tables/template_code.py +++ b/netbox/core/tables/template_code.py @@ -14,3 +14,15 @@ OBJECTCHANGE_OBJECT = """ OBJECTCHANGE_REQUEST_ID = """ {{ value }} """ + +PLUGIN_IS_INSTALLED = """ +{% if record.is_local %} + {% if record.is_loaded %} + + {% else %} + + {% endif %} +{% else %} + +{% endif %} +""" diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index b9424bd7c..4f9721430 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -2,6 +2,7 @@ from contextlib import ExitStack import logging import uuid +import warnings from django.conf import settings from django.contrib import auth, messages @@ -37,7 +38,10 @@ class CoreMiddleware: # Apply all registered request processors with ExitStack() as stack: for request_processor in registry['request_processors']: - stack.enter_context(request_processor(request)) + try: + stack.enter_context(request_processor(request)) + except Exception as e: + warnings.warn(f'Failed to initialize request processor {request_processor}: {e}') response = self.get_response(request) # Check if language cookie should be renewed diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py index bb3280ac4..b7bb0ef9f 100644 --- a/netbox/netbox/plugins/__init__.py +++ b/netbox/netbox/plugins/__init__.py @@ -6,6 +6,7 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string from packaging import version +from core.exceptions import IncompatiblePluginError from netbox.registry import registry from netbox.search import register_search from netbox.utils import register_data_backend @@ -140,14 +141,14 @@ class PluginConfig(AppConfig): if cls.min_version is not None: min_version = version.parse(cls.min_version) if current_version < min_version: - raise ImproperlyConfigured( + raise IncompatiblePluginError( f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: " f"{netbox_version})." ) if cls.max_version is not None: max_version = version.parse(cls.max_version) if current_version > max_version: - raise ImproperlyConfigured( + raise IncompatiblePluginError( f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: " f"{netbox_version})." ) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bb187ac73..57b143f5f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,6 +12,7 @@ from django.core.validators import URLValidator from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ +from core.exceptions import IncompatiblePluginError from netbox.config import PARAMS as CONFIG_PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.plugins import PluginConfig @@ -821,6 +822,15 @@ for plugin_name in PLUGINS: f"__init__.py file and point to the PluginConfig subclass." ) + # Validate version compatibility and user-provided configuration settings and assign defaults + if plugin_name not in PLUGINS_CONFIG: + PLUGINS_CONFIG[plugin_name] = {} + try: + plugin_config.validate(PLUGINS_CONFIG[plugin_name], RELEASE.version) + except IncompatiblePluginError as e: + warnings.warn(f'Unable to load plugin {plugin_name}: {e}') + continue + # Register the plugin as installed successfully registry['plugins']['installed'].append(plugin_name) @@ -853,11 +863,6 @@ for plugin_name in PLUGINS: sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) INSTALLED_APPS = list(sorted_apps) - # Validate user-provided configuration settings and assign defaults - if plugin_name not in PLUGINS_CONFIG: - PLUGINS_CONFIG[plugin_name] = {} - plugin_config.validate(PLUGINS_CONFIG[plugin_name], RELEASE.version) - # Add middleware plugin_middleware = plugin_config.middleware if plugin_middleware and type(plugin_middleware) in (list, tuple): @@ -879,6 +884,7 @@ for plugin_name in PLUGINS: else: raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple") + # UNSUPPORTED FUNCTIONALITY: Import any local overrides. try: from .local_settings import *