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

Misc. cleanup ahead of the v4.7.0 release (#23084)

Jeremy Stretch 19 часов назад
Родитель
Сommit
6345ed1de2

+ 10 - 0
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.

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

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

+ 92 - 0
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):
     """

+ 175 - 0
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()