diff --git a/docs/administration/management-commands.md b/docs/administration/management-commands.md index 0b3ae62c8..0e7f604b8 100644 --- a/docs/administration/management-commands.md +++ b/docs/administration/management-commands.md @@ -34,6 +34,16 @@ Populate the cached file size for image attachments that predate the `image_size python3 netbox/manage.py populate_image_sizes ``` +## rebuild_config_context_cache + +Pre-render and cache the merged config context data for all devices and virtual machines. The [upgrade script](../installation/upgrading.md) runs this automatically, so it is not usually necessary to invoke it by hand. It is useful to complete an interrupted run, or (with `--force`) to repair the cache after a bulk write which bypassed NetBox's change handling (cache invalidation is driven by model signals, which a direct `queryset.update()` does not emit). + +By default, only those objects whose cache is empty are rendered, so the command is safe to interrupt and re-run. This also means that a default run will not correct a cache which is populated but stale, as a write which bypassed cache invalidation leaves it: Pass `--force` to re-render every object regardless of its current cache. Either form may be run on a live system, as any object whose cache is empty falls back to rendering its config context on demand. See [Context Data](../features/context-data.md) for details. + +``` +python3 netbox/manage.py rebuild_config_context_cache [--force] +``` + ## rebuild_prefixes Rebuild the IPAM prefix hierarchy, recalculating the depth and child counts for all prefixes. diff --git a/docs/release-notes/version-4.7.md b/docs/release-notes/version-4.7.md index 2a9a3d3b3..991570fea 100644 --- a/docs/release-notes/version-4.7.md +++ b/docs/release-notes/version-4.7.md @@ -14,32 +14,44 @@ !!! warning "Redis 6.0 or Later Required" This release of NetBox drops support for Redis 5.x. +!!! warning "Extended Upgrade Duration" + Two steps in this upgrade scale with the size of the database, and may take considerably longer than a typical NetBox upgrade. + + The migration which replaces django-mptt with `ltree` adds and alters columns on each hierarchical table before backfilling it in a single statement. The schema changes hold an `ACCESS EXCLUSIVE` lock, which blocks reads as well as writes for their duration; the backfill which follows locks every row it updates, blocking concurrent writes to those rows until it commits. On a large deployment this can last several minutes. The table most likely to be affected is `dcim_inventoryitem`, which can hold millions of rows; the others (region, sitegroup, location, devicerole, platform, modulebay, inventoryitemtemplate, tenantgroup, contactgroup, and wirelesslangroup) are typically far smaller. + + The upgrade script then runs the `rebuild_config_context_cache` management command, which issues one `UPDATE` per device and virtual machine. + + Plan a maintenance window accordingly, and note that these migrations are not reversible in practice: Reversing them restores the MPTT columns but does not repopulate them. + ### Breaking Changes * PostgreSQL 14 is no longer supported. NetBox now requires PostgreSQL 15 or later: The upgrade script will abort when connected to an earlier release. (NetBox v4.6 reported this as a warning.) * Redis 5.x is no longer supported. NetBox now requires Redis 6.0 or later. * Selection and multiple selection custom field values are now returned as objects specifying both the raw value and its human-friendly label (e.g. `{"value": "datacenter", "label": "Data Center"}`) in both the REST and GraphQL APIs. These fields continue to accept the raw value on write. -* The `protocol` and `ports` fields on the `ipam.Service` and `ipam.ServiceTemplate` models have been replaced by a unified `port_mappings` field, which supports multiple protocols per service. The legacy fields are retained (as deprecated) in the REST and GraphQL APIs, but at the ORM level they are now read-only properties derived from `port_mappings`: Passing `protocol` or `ports` to the model raises a `TypeError`, and assigning to `service.ports` raises an `AttributeError`. +* The `protocol` and `ports` fields on the `ipam.Service` and `ipam.ServiceTemplate` models have been replaced by a unified `port_mappings` field, which supports multiple protocols per service. The legacy fields are retained (as deprecated) in the REST and GraphQL APIs, but at the ORM level they are now read-only properties derived from `port_mappings`: Passing `protocol` or `ports` to the model raises a `TypeError`, and assigning to `service.ports` raises an `AttributeError`. This restriction applies only at the ORM level: The REST API continues to accept the legacy pair on write, translating it into `port_mappings`. * Because `protocol` is now filtered against the `port_mappings` array rather than a dedicated model field, the character-based REST filter lookups previously generated for it (`protocol__ic`, `protocol__isw`, `protocol__empty`, etc.) are no longer available. The `port__empty` lookup has been removed as well. * The GraphQL filters for `ipam.Service` and `ipam.ServiceTemplate` have changed shape: The nested `ports` integer lookup has been replaced by the flat `port`, `port__gt`, `port__gte`, `port__lt`, and `port__lte` parameters (each accepting a list of values), alongside the new `port_mappings` parameter. Additionally, the members of `ServiceProtocolEnum` have been renamed to drop a spurious `ROLE_` prefix (e.g. `ROLE_TCP` is now `TCP`). * Config context data is now pre-rendered and cached for each device and virtual machine, and is always included in their REST API representations. The `DeviceWithConfigContextSerializer` and `VirtualMachineWithConfigContextSerializer` classes have been removed (merged into the base serializers), and the `?exclude=config_context` query parameter is now silently ignored. * Failed bulk create and update operations via the REST API now return a structured response of the form `{"detail": ..., "errors": [{"index": N, "errors": {...}}]}`, correlating each error with the index of the offending object in the submitted list. (Bulk operations remain all-or-none.) * API token plaintexts can no longer be specified by the client when creating a token via the REST API. The `token` field is now read-only, and any value supplied is ignored. (This restriction was already in effect in the web UI.) * Executing a custom script via the REST API now requires that the calling token have its write ability enabled. +* The `username` argument has been removed from `extras.webhooks.send_webhook()` (the value remains available to webhook templates as `request.user`). Any webhook jobs still enqueued when the workers are restarted will fail with a `TypeError`, so the background queues should be allowed to drain before upgrading. * Updates to the global search cache are now deferred to a background task. As a result, a newly created or modified object may not appear in search results for a brief period. (When no background worker is running, the index is updated synchronously as before.) -* Nested group models (Region, SiteGroup, Location, DeviceRole, Platform, TenantGroup, ContactGroup, WirelessLANGroup, etc.) are now backed by a PostgreSQL `ltree` column rather than django-mptt. The MPTT-backed `NestedGroupModel` base class is retained for backward compatibility with plugins, but is deprecated: New code should use `NestedLtreeGroupModel` instead. +* Hierarchical models are now backed by a PostgreSQL `ltree` column rather than django-mptt. This covers the nested group models (Region, SiteGroup, Location, DeviceRole, Platform, TenantGroup, ContactGroup, WirelessLANGroup, etc.) as well as ModuleBay, InventoryItem, and InventoryItemTemplate. The `lft`, `rght`, `tree_id`, and `level` columns have been dropped from every migrated model: `level` remains available as a Python property, but can no longer be used in a queryset filter or `order_by()` clause. NetBox's `ltree` implementation deliberately covers only the subset of MPTT's API which NetBox itself uses (`get_ancestors()`, `get_descendants()`, `get_children()`, and `add_related_count()`); methods such as `get_root()`, `get_family()`, `is_leaf_node()`, `move_to()`, and `insert_at()` are no longer available. The MPTT-backed `NestedGroupModel` base class is retained for backward compatibility with plugins, but is deprecated: New code should use `NestedLtreeGroupModel` instead. * django-tables2 has been upgraded to v3.0, which renames its `querystring` template tag to `querystring_replace` and removes the `RelatedLinkColumn` class. +* `social-auth-app-django` and `social-auth-core` have been upgraded to v6.0 and v5.1 respectively, each a major release. Deployments which employ single sign-on should test authentication against a non-production instance before upgrading. * The `request` object passed to custom link templates is now a sanitized subset of the current request. Only the `id`, `path`, `path_info`, `method`, `GET`, and `user` attributes are available; cookies, headers, and session state are no longer accessible. * URL custom field values are now validated against the [`ALLOWED_URL_SCHEMES`](../configuration/security.md#allowed_url_schemes) configuration parameter. A value entered without a scheme is assumed to use `https` and stored as an absolute URL. * Webhooks now support a configurable timeout. If you have lowered `RQ_DEFAULT_TIMEOUT` to 60 seconds or less, you must also set [`WEBHOOK_DEFAULT_TIMEOUT`](../configuration/miscellaneous.md#webhook_default_timeout) to a lower value; NetBox will refuse to start otherwise. * Specifying an email server under the [`EMAIL`](../configuration/system.md#email) configuration parameter is now mandatory in order to send mail: A deployment which does not define `EMAIL['SERVER']` will raise an `InvalidMailer` exception when attempting to send, rather than failing at the SMTP connection. -* The upgrade script now runs the `rebuild_config_context_cache` management command to populate the new config context cache. This may extend the duration of the upgrade for deployments with a large number of devices and virtual machines. +* NetBox now populates Django's `MAILERS` setting rather than the individual `EMAIL_*` settings which it supersedes. `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_SSL`, `EMAIL_USE_TLS`, `EMAIL_TIMEOUT`, `EMAIL_SSL_CERTFILE`, and `EMAIL_SSL_KEYFILE` are no longer defined, and `EMAIL_BACKEND` is no longer consulted. Plugin code which reads any of these, or which calls `django.core.mail.get_connection()` with an explicit backend (now raising a `RuntimeError`), must be updated. The `EMAIL` configuration parameter itself is unchanged. +* The upgrade script now runs the `rebuild_config_context_cache` management command to populate the new config context cache. This issues one `UPDATE` per device and virtual machine, and may extend the duration of the upgrade considerably for deployments with a large number of either. The command skips objects whose cache is already populated, so it is safe to interrupt and re-run; it may also be deferred until after NetBox is back online, as any object whose cache is empty falls back to rendering its config context on demand. * Creating a custom field which has a default value, and deleting a custom field, are now deferred to a background job where the field's assigned object types hold more than [`BULK_UPDATE_CHUNK_SIZE`](../configuration/system.md#bulk_update_chunk_size) objects in total. * The obsolete `populate_custom_field_defaults()` method has been removed from `CustomFieldsMixin`. * `CustomField.objects.get_for_model()` and the `custom_fields` property of `CustomFieldsMixin` now return a list rather than a queryset, and `get_for_model()` returns only those fields which are active: Any whose stored data is being updated by a background job is omitted (see [field status](../customization/custom-fields.md#field-status)) unless selected via its `statuses` argument. * Removal of deprecated behavior * The `housekeeping` management command has been removed. (Its constituent tasks are performed by the individual management commands introduced in NetBox v4.6.) - * NetBox's custom `querystring` template tag has been removed in favor of Django's built-in tag of the same name. + * NetBox's custom `querystring` template tag has been removed in favor of Django's built-in tag of the same name. The two are not interchangeable: Django's tag reads the current request from the template context, so the `request` argument must be dropped (`{% querystring request page=1 %}` becomes `{% querystring page=1 %}`; passing `request` raises a `TemplateSyntaxError`). It also returns a bare `?` where NetBox's tag returned an empty string. * The legacy Sentry configuration parameters `SENTRY_DSN`, `SENTRY_SAMPLE_RATE`, `SENTRY_SEND_DEFAULT_PII`, and `SENTRY_TRACES_SAMPLE_RATE` have been removed. Use `SENTRY_CONFIG` instead. * The obsolete `DEFAULT_ACTION_PERMISSIONS` constant has been removed. * Support for legacy view action mappings has been dropped, and the `LEGACY_ACTIONS` constant has been removed. @@ -229,16 +241,18 @@ Event rule conditions can now inspect the pre-change and post-change snapshots c * Add optional integer field `timeout` * `ipam.Service` * Add the `port_mappings` list field - * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise + * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise. They remain writable: A request may specify either `port_mappings` or the legacy pair, but not both in conflict * The brief representation now includes `port_mappings` in place of `protocol` and `ports` * `ipam.ServiceTemplate` * Add the `port_mappings` list field - * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise + * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise. They remain writable: A request may specify either `port_mappings` or the legacy pair, but not both in conflict * The brief representation now includes `port_mappings` in place of `protocol` and `ports` * `users.Token` * The `token` field is now read-only; a plaintext value can no longer be specified on creation * `virtualization.VirtualMachine` * Add read-only JSON field `config_context` (previously available only via `VirtualMachineWithConfigContextSerializer`) +* `virtualization.VMInterface` + * The `mac_address` field is now writable, and creates or updates the interface's primary MAC address ### GraphQL API Changes diff --git a/netbox/core/apps.py b/netbox/core/apps.py index 2a75c4dd9..44c537d68 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -29,7 +29,11 @@ class CoreConfig(AppConfig): def ready(self): from core.api import schema # noqa: F401 - from core.checks import check_duplicate_indexes, check_redis_version # noqa: F401 + from core.checks import ( # noqa: F401 + check_duplicate_indexes, + check_postgresql_version, + check_redis_version, + ) from netbox import context_managers # noqa: F401 from netbox.models.features import register_models from netbox.search import signals as search_signals # noqa: F401 diff --git a/netbox/core/checks.py b/netbox/core/checks.py index 596878a82..bffe8cb2e 100644 --- a/netbox/core/checks.py +++ b/netbox/core/checks.py @@ -1,13 +1,26 @@ +import logging + from django.apps import apps from django.core.cache import cache from django.core.checks import Error, Tags, register +from django.core.exceptions import ImproperlyConfigured +from django.db import DatabaseError, InterfaceError, NotSupportedError, OperationalError, connections from django.db.models import Index, UniqueConstraint __all__ = ( 'check_duplicate_indexes', + 'check_postgresql_version', 'check_redis_version', ) +# The minimum major version of PostgreSQL required by NetBox. `SHOW server_version_num` reports the +# server version as a single integer of the form MMmmmm (e.g. 150004 for PostgreSQL 15.4), so the +# major version is scaled by 10000 when comparing against it. +POSTGRESQL_MIN_VERSION = 15 +POSTGRESQL_VERSION_MULTIPLIER = 10000 + +logger = logging.getLogger('netbox.core.checks') + @register(Tags.models) def check_duplicate_indexes(app_configs, **kwargs): @@ -43,6 +56,85 @@ def check_duplicate_indexes(app_configs, **kwargs): return errors +@register(Tags.database) +def check_postgresql_version(app_configs, databases=None, **kwargs): + """ + Report an error if the PostgreSQL version is less than POSTGRESQL_MIN_VERSION. + """ + errors = [] + + # Validate only those database aliases which Django has asked us to check. A value of None means + # that no database may be touched during this run, so there is nothing to validate: commands which + # do intend to use a connection declare its alias (e.g. `migrate` passes the value of --database). + # This mirrors Django's own database checks; see checks.database.check_database_backends(). + if databases is None: + return errors + + for alias in databases: + connection = connections[alias] + + # A plugin may register a connection to some other type of database; only PostgreSQL + # connections are subject to NetBox's minimum version requirement. + if connection.vendor != 'postgresql': + continue + + try: + with connection.cursor() as cursor: + cursor.execute('SHOW server_version_num') + row = cursor.fetchone() + except NotSupportedError: + # Django refuses to use the connection at all when the server predates the minimum version + # which Django itself supports (BaseDatabaseWrapper.check_database_version_supported()), so + # the query above never runs. The PostgreSQL backend registers no validation checks of its + # own, so report the requirement here rather than letting the raw exception surface as a + # traceback the first time something touches the database. + errors.append( + Error( + f"Database '{alias}': The configured PostgreSQL version is not supported. NetBox " + f"requires PostgreSQL {POSTGRESQL_MIN_VERSION} or later.", + hint=f'Please upgrade to PostgreSQL {POSTGRESQL_MIN_VERSION} or later.', + id='netbox.E001', + ) + ) + continue + except (ImproperlyConfigured, InterfaceError, OperationalError): + # The database is unreachable, has yet to be provisioned, or the connection is no longer + # usable. (InterfaceError is a sibling of DatabaseError, not a subclass, so it must be + # named explicitly.) Leave the version unverified rather than reporting a spurious error. + continue + except DatabaseError: + # The server is reachable but rejected the query (e.g. a connection pooler which intercepts + # SHOW). Record why the version could not be determined rather than failing silently. + logger.warning(f"Database '{alias}': Failed to determine the PostgreSQL version.", exc_info=True) + continue + + if not row: + logger.warning(f"Database '{alias}': `SHOW server_version_num` returned no result.") + continue + + try: + pg_version = int(row[0]) + except (TypeError, ValueError): + # `SHOW server_version_num` reports an integer, but a pooler which answers the statement + # itself may report a dotted version instead. Leave the version unverified rather than + # raising out of the check and aborting the calling command. + logger.warning(f"Database '{alias}': Unable to parse the PostgreSQL version from {row[0]!r}.") + continue + + if pg_version < POSTGRESQL_MIN_VERSION * POSTGRESQL_VERSION_MULTIPLIER: + major_version = pg_version // POSTGRESQL_VERSION_MULTIPLIER + errors.append( + Error( + f"Database '{alias}': PostgreSQL {major_version} is not supported. NetBox requires " + f"PostgreSQL {POSTGRESQL_MIN_VERSION} or later.", + hint=f'Please upgrade to PostgreSQL {POSTGRESQL_MIN_VERSION} or later.', + id='netbox.E001', + ) + ) + + return errors + + @register(Tags.caches) def check_redis_version(app_configs, **kwargs): """ diff --git a/netbox/core/tests/test_checks.py b/netbox/core/tests/test_checks.py new file mode 100644 index 000000000..a5610cdaa --- /dev/null +++ b/netbox/core/tests/test_checks.py @@ -0,0 +1,175 @@ +from unittest.mock import MagicMock, patch + +from django.db import InterfaceError, NotSupportedError, OperationalError, ProgrammingError +from django.test import TestCase + +from core.checks import POSTGRESQL_MIN_VERSION, POSTGRESQL_VERSION_MULTIPLIER, check_postgresql_version + +# `SHOW server_version_num` results representing the oldest supported and newest unsupported releases +SUPPORTED_VERSION = POSTGRESQL_MIN_VERSION * POSTGRESQL_VERSION_MULTIPLIER +UNSUPPORTED_VERSION = (POSTGRESQL_MIN_VERSION - 1) * POSTGRESQL_VERSION_MULTIPLIER + 10 +OBSOLETE_VERSION = (POSTGRESQL_MIN_VERSION - 2) * POSTGRESQL_VERSION_MULTIPLIER + 1 + + +class PostgreSQLVersionCheckTestCase(TestCase): + """ + Test the system check which enforces NetBox's minimum PostgreSQL version. + """ + @staticmethod + def mock_connection(server_version_num=None, exception=None, vendor='postgresql'): + """ + Return a mock database connection which yields the given `SHOW server_version_num` result, or + which raises `exception` when a cursor is requested. + """ + connection = MagicMock() + connection.vendor = vendor + if exception is not None: + connection.cursor.side_effect = exception + else: + cursor = MagicMock() + cursor.fetchone.return_value = (str(server_version_num),) + connection.cursor.return_value.__enter__.return_value = cursor + return connection + + @staticmethod + def mock_connections(**connections): + """ + Return a patcher replacing the connection handler with the given alias-to-connection mapping. + """ + return patch('core.checks.connections', connections) + + def test_supported_version(self): + """ + No error is reported for the minimum supported PostgreSQL version or later. + """ + for version in ( + SUPPORTED_VERSION, + SUPPORTED_VERSION + 2, + SUPPORTED_VERSION + (2 * POSTGRESQL_VERSION_MULTIPLIER), + ): + with self.subTest(version=version): + with self.mock_connections(default=self.mock_connection(version)): + self.assertEqual(check_postgresql_version(None, databases=['default']), []) + + def test_unsupported_version(self): + """ + An error is reported for any release preceding the minimum supported version. + """ + with self.mock_connections(default=self.mock_connection(UNSUPPORTED_VERSION)): + errors = check_postgresql_version(None, databases=['default']) + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].id, 'netbox.E001') + self.assertIn(f'PostgreSQL {POSTGRESQL_MIN_VERSION - 1} is not supported', errors[0].msg) + + def test_connection_rejected_by_django(self): + """ + Django's backend refuses to connect at all when the server predates its own minimum supported + version, so the version query never runs. The check must still report the requirement. + """ + error = NotSupportedError('PostgreSQL 14 or later is required (found 13.10).') + with self.mock_connections(default=self.mock_connection(exception=error)): + errors = check_postgresql_version(None, databases=['default']) + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].id, 'netbox.E001') + self.assertIn(f'NetBox requires PostgreSQL {POSTGRESQL_MIN_VERSION} or later', errors[0].msg) + + def test_database_unavailable(self): + """ + An unreachable database leaves the version unverified rather than reporting a spurious error. + """ + exception = OperationalError('could not connect to server') + with self.mock_connections(default=self.mock_connection(exception=exception)): + self.assertEqual(check_postgresql_version(None, databases=['default']), []) + + def test_version_query_rejected(self): + """ + A reachable server which rejects the version query leaves the version unverified, but logs why. + """ + exception = ProgrammingError('unrecognized configuration parameter') + with self.mock_connections(default=self.mock_connection(exception=exception)): + with self.assertLogs('netbox.core.checks', level='WARNING') as cm: + self.assertEqual(check_postgresql_version(None, databases=['default']), []) + self.assertIn('Failed to determine the PostgreSQL version', cm.output[0]) + + def test_connection_unusable(self): + """ + An unusable connection leaves the version unverified rather than raising out of the check. + InterfaceError descends from Error rather than DatabaseError, so it is handled explicitly. + """ + exception = InterfaceError('connection already closed') + with self.mock_connections(default=self.mock_connection(exception=exception)): + self.assertEqual(check_postgresql_version(None, databases=['default']), []) + + def test_version_unparseable(self): + """ + A version which cannot be parsed as an integer (e.g. as reported by an intervening connection + pooler) leaves the version unverified, but logs why. + """ + connection = self.mock_connection(SUPPORTED_VERSION) + connection.cursor.return_value.__enter__.return_value.fetchone.return_value = ('15.4',) + with self.mock_connections(default=connection): + with self.assertLogs('netbox.core.checks', level='WARNING') as cm: + self.assertEqual(check_postgresql_version(None, databases=['default']), []) + self.assertIn('Unable to parse the PostgreSQL version', cm.output[0]) + + def test_version_query_empty(self): + """ + A version query which returns no result leaves the version unverified, but logs why. + """ + connection = self.mock_connection(SUPPORTED_VERSION) + connection.cursor.return_value.__enter__.return_value.fetchone.return_value = None + with self.mock_connections(default=connection): + with self.assertLogs('netbox.core.checks', level='WARNING') as cm: + self.assertEqual(check_postgresql_version(None, databases=['default']), []) + self.assertIn('returned no result', cm.output[0]) + + def test_specified_aliases(self): + """ + Only the database aliases supplied by Django are checked, and each is identified by name. + """ + connections = { + 'default': self.mock_connection(SUPPORTED_VERSION + 2), + 'legacy': self.mock_connection(UNSUPPORTED_VERSION), + } + with self.mock_connections(**connections): + errors = check_postgresql_version(None, databases=['legacy']) + self.assertEqual(len(errors), 1) + self.assertIn("Database 'legacy'", errors[0].msg) + connections['default'].cursor.assert_not_called() + + def test_multiple_aliases(self): + """ + Every alias supplied by Django is checked. + """ + connections = { + 'default': self.mock_connection(UNSUPPORTED_VERSION), + 'legacy': self.mock_connection(OBSOLETE_VERSION), + } + with self.mock_connections(**connections): + errors = check_postgresql_version(None, databases=['default', 'legacy']) + self.assertEqual(len(errors), 2) + self.assertIn("Database 'default'", errors[0].msg) + self.assertIn("Database 'legacy'", errors[1].msg) + + def test_no_aliases(self): + """ + No connection is opened when Django supplies no aliases, indicating that this run may not touch + the database (e.g. `collectstatic`, or any command which declares no aliases of its own). + """ + connections = { + 'default': self.mock_connection(UNSUPPORTED_VERSION), + 'legacy': self.mock_connection(OBSOLETE_VERSION), + } + with self.mock_connections(**connections): + self.assertEqual(check_postgresql_version(None, databases=None), []) + connections['default'].cursor.assert_not_called() + connections['legacy'].cursor.assert_not_called() + + def test_non_postgresql_connection(self): + """ + Connections to other types of databases (e.g. those registered by a plugin) are ignored. + """ + connection = self.mock_connection(vendor='mysql') + with self.mock_connections(default=connection): + self.assertEqual(check_postgresql_version(None, databases=['default']), []) + connection.cursor.assert_not_called()