Просмотр исходного кода

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 <jstretch@netboxlabs.com>

* 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 <jstretch@netboxlabs.com>
bctiemann 11 месяцев назад
Родитель
Сommit
b5d970f7bb

+ 7 - 0
netbox/core/exceptions.py

@@ -1,2 +1,9 @@
+from django.core.exceptions import ImproperlyConfigured
+
+
 class SyncError(Exception):
     pass
+
+
+class IncompatiblePluginError(ImproperlyConfigured):
+    pass

+ 10 - 6
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

+ 6 - 2
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(

+ 12 - 0
netbox/core/tables/template_code.py

@@ -14,3 +14,15 @@ OBJECTCHANGE_OBJECT = """
 OBJECTCHANGE_REQUEST_ID = """
 <a href="{% url 'core:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
 """
+
+PLUGIN_IS_INSTALLED = """
+{% if record.is_local %}
+    {% if record.is_loaded %}
+        <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
+    {% else %}
+        <span class="text-danger"><i class="mdi mdi-alert" data-bs-toggle="tooltip" title="Could not load plugin. Version may be incompatible. Min version: {{ record.netbox_min_version }}, max version: {{ record.netbox_max_version }}"></i></span>
+    {% endif %}
+{% else %}
+    <span class="text-muted">&mdash;</span>
+{% endif %}
+"""

+ 5 - 1
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

+ 3 - 2
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})."
                 )

+ 11 - 5
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 *