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 d1ea60f082.

* 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>
This commit is contained in:
bctiemann 2025-03-10 10:51:41 -04:00 committed by GitHub
parent c35f5f829a
commit b5d970f7bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 54 additions and 16 deletions

View File

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

View File

@ -65,9 +65,11 @@ class Plugin:
is_certified: bool = False is_certified: bool = False
release_latest: PluginVersion = field(default_factory=PluginVersion) release_latest: PluginVersion = field(default_factory=PluginVersion)
release_recent_history: list[PluginVersion] = field(default_factory=list) release_recent_history: list[PluginVersion] = field(default_factory=list)
is_local: bool = False # extra field for locally installed plugins is_local: bool = False # Indicates that the plugin is listed in settings.PLUGINS (i.e. installed)
is_installed: bool = False is_loaded: bool = False # Indicates whether the plugin successfully loaded at launch
installed_version: str = '' installed_version: str = ''
netbox_min_version: str = ''
netbox_max_version: str = ''
def get_local_plugins(plugins=None): def get_local_plugins(plugins=None):
@ -78,7 +80,7 @@ def get_local_plugins(plugins=None):
local_plugins = {} local_plugins = {}
# Gather all locally-installed 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 = importlib.import_module(plugin_name)
plugin_config: PluginConfig = plugin.config plugin_config: PluginConfig = plugin.config
installed_version = plugin_config.version installed_version = plugin_config.version
@ -92,15 +94,17 @@ def get_local_plugins(plugins=None):
tag_line=plugin_config.description, tag_line=plugin_config.description,
description_short=plugin_config.description, description_short=plugin_config.description,
is_local=True, is_local=True,
is_installed=True, is_loaded=plugin_name in registry['plugins']['installed'],
installed_version=installed_version, 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 # Update catalog entries for local plugins, or add them to the list if not listed
for k, v in local_plugins.items(): for k, v in local_plugins.items():
if k in plugins: if k in plugins:
plugins[k].is_local = True plugins[k].is_local = v.is_local
plugins[k].is_installed = True plugins[k].is_loaded = v.is_loaded
plugins[k].installed_version = v.installed_version plugins[k].installed_version = v.installed_version
else: else:
plugins[k] = v plugins[k] = v

View File

@ -2,6 +2,7 @@ import django_tables2 as tables
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.tables import BaseTable, columns from netbox.tables import BaseTable, columns
from .template_code import PLUGIN_IS_INSTALLED
__all__ = ( __all__ = (
'CatalogPluginTable', 'CatalogPluginTable',
@ -48,12 +49,15 @@ class CatalogPluginTable(BaseTable):
verbose_name=_('Author') verbose_name=_('Author')
) )
is_local = columns.BooleanColumn( is_local = columns.BooleanColumn(
false_mark=None,
verbose_name=_('Local') verbose_name=_('Local')
) )
is_installed = columns.BooleanColumn( is_installed = columns.TemplateColumn(
verbose_name=_('Installed') verbose_name=_('Active'),
template_code=PLUGIN_IS_INSTALLED
) )
is_certified = columns.BooleanColumn( is_certified = columns.BooleanColumn(
false_mark=None,
verbose_name=_('Certified') verbose_name=_('Certified')
) )
created_at = columns.DateTimeColumn( created_at = columns.DateTimeColumn(

View File

@ -14,3 +14,15 @@ OBJECTCHANGE_OBJECT = """
OBJECTCHANGE_REQUEST_ID = """ OBJECTCHANGE_REQUEST_ID = """
<a href="{% url 'core:objectchange_list' %}?request_id={{ value }}">{{ value }}</a> <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 %}
"""

View File

@ -2,6 +2,7 @@ from contextlib import ExitStack
import logging import logging
import uuid import uuid
import warnings
from django.conf import settings from django.conf import settings
from django.contrib import auth, messages from django.contrib import auth, messages
@ -37,7 +38,10 @@ class CoreMiddleware:
# Apply all registered request processors # Apply all registered request processors
with ExitStack() as stack: with ExitStack() as stack:
for request_processor in registry['request_processors']: for request_processor in registry['request_processors']:
try:
stack.enter_context(request_processor(request)) 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) response = self.get_response(request)
# Check if language cookie should be renewed # Check if language cookie should be renewed

View File

@ -6,6 +6,7 @@ from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from packaging import version from packaging import version
from core.exceptions import IncompatiblePluginError
from netbox.registry import registry from netbox.registry import registry
from netbox.search import register_search from netbox.search import register_search
from netbox.utils import register_data_backend from netbox.utils import register_data_backend
@ -140,14 +141,14 @@ class PluginConfig(AppConfig):
if cls.min_version is not None: if cls.min_version is not None:
min_version = version.parse(cls.min_version) min_version = version.parse(cls.min_version)
if current_version < min_version: if current_version < min_version:
raise ImproperlyConfigured( raise IncompatiblePluginError(
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: " f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: "
f"{netbox_version})." f"{netbox_version})."
) )
if cls.max_version is not None: if cls.max_version is not None:
max_version = version.parse(cls.max_version) max_version = version.parse(cls.max_version)
if current_version > max_version: if current_version > max_version:
raise ImproperlyConfigured( raise IncompatiblePluginError(
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: " f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: "
f"{netbox_version})." f"{netbox_version})."
) )

View File

@ -12,6 +12,7 @@ from django.core.validators import URLValidator
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.exceptions import IncompatiblePluginError
from netbox.config import PARAMS as CONFIG_PARAMS from netbox.config import PARAMS as CONFIG_PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
@ -821,6 +822,15 @@ for plugin_name in PLUGINS:
f"__init__.py file and point to the PluginConfig subclass." 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 # Register the plugin as installed successfully
registry['plugins']['installed'].append(plugin_name) registry['plugins']['installed'].append(plugin_name)
@ -853,11 +863,6 @@ for plugin_name in PLUGINS:
sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS))))
INSTALLED_APPS = list(sorted_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 # Add middleware
plugin_middleware = plugin_config.middleware plugin_middleware = plugin_config.middleware
if plugin_middleware and type(plugin_middleware) in (list, tuple): if plugin_middleware and type(plugin_middleware) in (list, tuple):
@ -879,6 +884,7 @@ for plugin_name in PLUGINS:
else: else:
raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple") raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple")
# UNSUPPORTED FUNCTIONALITY: Import any local overrides. # UNSUPPORTED FUNCTIONALITY: Import any local overrides.
try: try:
from .local_settings import * from .local_settings import *