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):
pass
class IncompatiblePluginError(ImproperlyConfigured):
pass

View File

@ -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

View File

@ -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(

View File

@ -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 %}
"""

View File

@ -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']:
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

View File

@ -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})."
)

View File

@ -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 *