diff --git a/changes/997.added b/changes/997.added
new file mode 100644
index 000000000..e277175ca
--- /dev/null
+++ b/changes/997.added
@@ -0,0 +1 @@
+Added configuration hash grouping feature for identifying and grouping devices with identical non-compliant configurations.
\ No newline at end of file
diff --git a/docs/user/app_feature_hash.md b/docs/user/app_feature_hash.md
new file mode 100644
index 000000000..249b38bab
--- /dev/null
+++ b/docs/user/app_feature_hash.md
@@ -0,0 +1,97 @@
+# Configuration Hash Grouping
+
+The **Configuration Hash Grouping** feature enables administrators to identify devices that have identical non-compliant configurations, making it easier to troubleshoot and fix configuration issues that affect multiple devices simultaneously. This feature groups devices by their configuration hash values, allowing you to see patterns in configuration drift and apply fixes to entire groups at once.
+
+## Overview
+
+When configuration compliance issues affect multiple devices with identical misconfigurations, the traditional approach of reviewing each device individually can be time-consuming and inefficient. The Configuration Hash Grouping feature solves this by:
+
+- Automatically grouping devices with identical configuration hashes
+- Providing a unified view of devices sharing the same configuration issues
+- Enabling bulk remediation operations on groups of devices
+- Simplifying troubleshooting of widespread configuration problems
+
+## How It Works
+
+The Configuration Hash Grouping feature uses a three-model architecture to efficiently organize and display configuration data:
+
+### Architecture Components
+
+1. **ConfigHashGrouping**: Groups devices with identical configuration hashes
+2. **ConfigComplianceHash**: Links individual devices to configuration hash groups
+3. **ConfigCompliance**: Provides the base compliance data (existing model, modified for integration)
+
+### Hash Generation Process
+
+When configuration compliance jobs run, the system:
+
+1. Computes SHA-256 hashes of device configuration content
+2. Creates or finds existing ConfigHashGrouping records for each unique hash
+3. Links devices to the appropriate hash groups via ConfigComplianceHash records
+4. Stores the actual configuration content once per unique hash for display purposes
+
+This approach eliminates duplicate storage while maintaining fast access to configuration data for analysis.
+
+## Accessing Configuration Hash Grouping
+
+To access the Configuration Hash Grouping feature:
+
+1. Navigate to **Golden Config** in the main navigation menu
+2. Under the **Manage** section, select **Hash Grouping Report**
+3. The main view displays groups of devices with identical configuration hashes
+
+!!! note
+ You must have the `view_configcompliance` permission to access this feature.
+
+## Configuration Hash Grouping Views
+
+### Main Grouping View
+
+The main Configuration Hash Grouping view (`/config-compliance/hash-grouping/`) displays:
+
+- **Feature Name**: The compliance rule feature being evaluated
+- **Device Count**: Number of devices sharing the same configuration hash (clickable to view devices)
+- **Configuration Preview**: Expandable view of the actual configuration content
+- **Action Buttons**: Quick access to remediation jobs and other operations
+
+### Device-Level Hash View
+
+The device-level view (`/config-compliance/config-hash/`) provides:
+
+- Individual device details linked to their hash groups
+- Device-specific configuration information
+- Direct navigation to device compliance details
+
+### Interactive Features
+
+The user interface includes several interactive elements:
+
+- **Expand/Collapse**: Toggle individual configuration displays
+- **Master Toggle**: Expand or collapse all configurations at once
+- **AJAX Loading**: Smooth loading of configuration content without page refreshes
+- **Fixed-Width Containers**: Consistent layout that prevents content shifting
+
+## API Access
+
+The Configuration Hash Grouping feature provides REST API access for programmatic integration:
+
+### Endpoints
+
+- **ConfigHashGrouping**: `/api/plugins/golden-config/config-hash-grouping/`
+- **ConfigComplianceHash**: `/api/plugins/golden-config/config-compliance-hash/`
+
+### Example API Usage
+
+```bash
+# Get all hash groups
+curl -H "Authorization: Token YOUR_TOKEN" \
+ http://nautobot/api/plugins/golden-config/config-hash-grouping/
+
+# Get devices in a specific hash group
+curl -H "Authorization: Token YOUR_TOKEN" \
+ http://nautobot/api/plugins/golden-config/config-compliance-hash/
+```
+
+## Summary
+
+The Configuration Hash Grouping feature represents a significant enhancement to Nautobot Golden Config's compliance capabilities. By grouping devices with identical configuration hashes, it provides network administrators with powerful tools for identifying, analyzing, and resolving configuration issues at scale. The feature's three-model architecture ensures excellent performance while maintaining data integrity, and its seamless integration with existing Golden Config functionality makes it immediately useful in any network automation workflow.
diff --git a/mkdocs.yml b/mkdocs.yml
index bf6db23ac..ea3daa620 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -122,6 +122,7 @@ nav:
- Navigate Configuration Post-Processing: "user/app_feature_config_postprocessing.md"
- Navigate Config Plans: "user/app_feature_config_plans.md"
- Navigate Remediation: "user/app_feature_remediation.md"
+ - Navigate Config Hashes: "user/app_feature_hash.md"
- Frequently Asked Questions: "user/faq.md"
- External Interactions: "user/external_interactions.md"
- Troubleshooting:
diff --git a/nautobot_golden_config/api/serializers.py b/nautobot_golden_config/api/serializers.py
index 9be99a287..a2d5959d3 100644
--- a/nautobot_golden_config/api/serializers.py
+++ b/nautobot_golden_config/api/serializers.py
@@ -162,3 +162,23 @@ def get_remote_branches(self, obj):
class Meta: # noqa: D106 # undocumented-public-nested-class
model = GitRepository
fields = "__all__"
+
+
+class ConfigHashGroupingSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):
+ """Serializer for ConfigHashGrouping object."""
+
+ class Meta:
+ """Set Meta Data for ConfigHashGrouping, will serialize all fields."""
+
+ model = models.ConfigHashGrouping
+ fields = "__all__"
+
+
+class ConfigComplianceHashSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):
+ """Serializer for ConfigComplianceHash object."""
+
+ class Meta:
+ """Set Meta Data for ConfigComplianceHash, will serialize all fields."""
+
+ model = models.ConfigComplianceHash
+ fields = "__all__"
diff --git a/nautobot_golden_config/api/urls.py b/nautobot_golden_config/api/urls.py
index 2f5d5b9e2..f14febb62 100644
--- a/nautobot_golden_config/api/urls.py
+++ b/nautobot_golden_config/api/urls.py
@@ -16,6 +16,8 @@
router.register("remediation-setting", views.RemediationSettingViewSet)
router.register("config-postprocessing", views.ConfigToPushViewSet)
router.register("config-plan", views.ConfigPlanViewSet)
+router.register("config-hash-grouping", views.ConfigHashGroupingViewSet)
+router.register("config-compliance-hash", views.ConfigComplianceHashViewSet)
urlpatterns = [
path(
diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py
index 4ec089523..6540b9acc 100644
--- a/nautobot_golden_config/api/views.py
+++ b/nautobot_golden_config/api/views.py
@@ -210,6 +210,22 @@ def get_serializer_context(self):
return context
+class ConfigHashGroupingViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors
+ """API viewset for interacting with ConfigHashGrouping objects."""
+
+ queryset = models.ConfigHashGrouping.objects.all()
+ serializer_class = serializers.ConfigHashGroupingSerializer
+ filterset_class = filters.ConfigHashGroupingFilterSet
+
+
+class ConfigComplianceHashViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors
+ """API viewset for interacting with ConfigComplianceHash objects."""
+
+ queryset = models.ConfigComplianceHash.objects.all()
+ serializer_class = serializers.ConfigComplianceHashSerializer
+ filterset_class = filters.ConfigComplianceHashFilterSet
+
+
class GenerateIntendedConfigException(APIException):
"""Exception for when the intended config cannot be generated."""
diff --git a/nautobot_golden_config/filters.py b/nautobot_golden_config/filters.py
index b876cef3a..5aa52f0da 100644
--- a/nautobot_golden_config/filters.py
+++ b/nautobot_golden_config/filters.py
@@ -1,6 +1,7 @@
"""Filtering for nautobot_golden_config."""
import django_filters
+from django.db.models import Count, Exists, F, OuterRef, Q
from nautobot.apps.filters import (
MultiValueDateTimeFilter,
NaturalKeyOrPKMultipleChoiceFilter,
@@ -146,6 +147,56 @@ class ConfigComplianceFilterSet(GoldenConfigFilterSet): # pylint: disable=too-m
to_field_name="slug",
label="ComplianceFeature (slug)",
)
+ compliance = django_filters.BooleanFilter(
+ field_name="compliance",
+ label="Compliance Status",
+ )
+ config_hash_group = django_filters.CharFilter(
+ method="filter_by_hash_group",
+ label="Config Hash Group",
+ )
+ config_hash = django_filters.CharFilter(
+ method="filter_by_config_hash",
+ label="Config Hash",
+ )
+
+ def filter_by_hash_group(self, queryset, _name, value):
+ """Filter ConfigCompliance records by config hash group ID."""
+ if not value:
+ return queryset
+
+ try:
+ # Get the hash group
+ hash_group = models.ConfigHashGrouping.objects.get(pk=value)
+
+ # Find all devices that are linked to this hash group via ConfigComplianceHash
+ devices_in_group = models.ConfigComplianceHash.objects.filter(
+ config_group=hash_group, config_type="actual"
+ ).values_list("device_id", flat=True)
+
+ # Filter ConfigCompliance records to show only these devices for this rule
+ return queryset.filter(device_id__in=devices_in_group, rule=hash_group.rule)
+
+ except models.ConfigHashGrouping.DoesNotExist:
+ # If hash group doesn't exist, return empty queryset
+ return queryset.none()
+
+ def filter_by_config_hash(self, queryset, _name, value):
+ """Filter ConfigCompliance by hash value, exact or prefix."""
+ if not value:
+ return queryset
+ matching_groups = models.ConfigHashGrouping.objects.filter(
+ config_hash__istartswith=value,
+ )
+ if not matching_groups.exists():
+ return queryset.none()
+ matching_hash_rows = models.ConfigComplianceHash.objects.filter(
+ device=OuterRef("device"),
+ rule=OuterRef("rule"),
+ config_type="actual",
+ config_group__in=matching_groups,
+ )
+ return queryset.filter(Exists(matching_hash_rows))
class Meta:
"""Meta class attributes for ConfigComplianceFilter."""
@@ -154,6 +205,134 @@ class Meta:
fields = "__all__"
+class ConfigHashGroupingFilterSet(NautobotFilterSet):
+ """Custom filter for configuration hash grouping that handles device filtering properly."""
+
+ feature = django_filters.ModelMultipleChoiceFilter(
+ field_name="rule__feature__name",
+ queryset=models.ComplianceFeature.objects.all(),
+ to_field_name="name",
+ label="Feature",
+ )
+
+ device = NaturalKeyOrPKMultipleChoiceFilter(
+ queryset=Device.objects.all(),
+ to_field_name="name",
+ label="Device (name or ID)",
+ method="filter_by_device",
+ )
+
+ class Meta:
+ """Meta class attributes for ConfigHashGroupingFilterSet."""
+
+ model = models.ConfigHashGrouping
+ fields = "__all__"
+
+ def filter_by_device(self, queryset, _name, value):
+ """Filter ConfigHashGrouping records by devices that are members of the groups."""
+ if not value:
+ return queryset
+
+ # Get device IDs from the filter value
+ device_ids = []
+ for device in value:
+ if hasattr(device, "id"):
+ device_ids.append(device.id)
+ else:
+ device_ids.append(device)
+
+ # Find all ConfigHashGrouping IDs where these devices have corresponding ConfigComplianceHash records
+ hash_group_ids = (
+ models.ConfigComplianceHash.objects.filter(device_id__in=device_ids, config_group__isnull=False)
+ .values_list("config_group_id", flat=True)
+ .distinct()
+ )
+
+ return queryset.filter(id__in=hash_group_ids)
+
+
+class ConfigComplianceHashFilterSet(GoldenConfigFilterSet): # pylint: disable=too-many-ancestors
+ """Custom filter for mismatch grouping that handles device filtering properly."""
+
+ location = TreeNodeMultipleChoiceFilter(
+ queryset=Location.objects.all(),
+ field_name="device__location",
+ to_field_name="name",
+ label="Location (name)",
+ )
+ platform = NaturalKeyOrPKMultipleChoiceFilter(
+ field_name="device__platform",
+ queryset=Platform.objects.all(),
+ to_field_name="name",
+ label="Platform (name or ID)",
+ )
+
+ device = NaturalKeyOrPKMultipleChoiceFilter(
+ field_name="device",
+ queryset=Device.objects.all(),
+ to_field_name="name",
+ label="Device (name or ID)",
+ )
+
+ def filter_device(self, queryset, _name, value):
+ """Custom device filtering for grouped mismatch data."""
+ # Get the devices to filter by
+ device_ids = [device.id if hasattr(device, "id") else device for device in value]
+
+ # Find ConfigComplianceHash records for these devices that correspond to non-compliant actual configs
+ hash_records = (
+ models.ConfigComplianceHash.objects.filter(
+ device_id__in=device_ids,
+ config_type="actual",
+ device__configcompliance__rule=F("rule"),
+ device__configcompliance__compliance=False,
+ )
+ .values("rule", "config_hash")
+ .distinct()
+ )
+
+ # Build filters for rule+hash combinations
+ hash_filters = Q()
+ filter_count = 0
+ for record in hash_records:
+ if record["config_hash"]:
+ hash_filters |= Q(rule=record["rule"], config_hash=record["config_hash"])
+ filter_count += 1
+
+ if hash_filters:
+ # Filter the base ConfigComplianceHash records before they get grouped
+ base_qs = models.ConfigComplianceHash.objects.filter(
+ config_type="actual",
+ device__configcompliance__rule=F("rule"),
+ device__configcompliance__compliance=False,
+ ).filter(hash_filters)
+
+ # Apply grouping to the filtered base queryset
+ grouped_qs = (
+ base_qs.values(
+ "rule__feature__id", "rule__feature__name", "rule__feature__slug", "config_hash", "config_content"
+ )
+ .annotate(
+ device_count=Count("device", distinct=True),
+ feature_id=F("rule__feature__id"),
+ feature_name=F("rule__feature__name"),
+ feature_slug=F("rule__feature__slug"),
+ )
+ .filter(device_count__gt=1)
+ .order_by("-device_count", "rule__feature__name")
+ )
+
+ return grouped_qs
+
+ return queryset.none()
+
+ class Meta:
+ """Boilerplate filter Meta data for Config Hash."""
+
+ model = models.ConfigComplianceHash
+ fields = "__all__"
+
+
class ComplianceFeatureFilterSet(NautobotFilterSet):
"""Inherits Base Class NautobotFilterSet."""
@@ -257,7 +436,7 @@ class GoldenConfigSettingFilterSet(NautobotFilterSet):
method="filter_device_id",
)
- def filter_device_id(self, queryset, name, value): # pylint: disable=unused-argument
+ def filter_device_id(self, queryset, _name, value):
"""Filter by Device ID."""
if not value:
return queryset
diff --git a/nautobot_golden_config/forms.py b/nautobot_golden_config/forms.py
index 654e6be86..d89a8c25d 100644
--- a/nautobot_golden_config/forms.py
+++ b/nautobot_golden_config/forms.py
@@ -154,9 +154,15 @@ class ConfigComplianceFilterForm(DeviceRelatedFilterForm):
"device_status",
"device_type",
"device",
+ "config_hash",
]
q = django_forms.CharField(required=False, label="Search")
+ config_hash = django_forms.CharField(
+ required=False,
+ label="Config Hash",
+ help_text="Full hash or first 7+ characters",
+ )
def __init__(self, *args, **kwargs):
"""Required for status to work."""
@@ -685,3 +691,56 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if version.parse(settings.VERSION) < version.parse("2.4.2"):
self.fields["git_repository_branch"].widget = django_forms.HiddenInput
+
+
+class ConfigHashGroupingFilterForm(django_forms.Form):
+ """Filter Form for Configuration Hash Grouping."""
+
+ model = models.ConfigHashGrouping
+
+ q = django_forms.CharField(required=False, label="Search")
+
+ feature = forms.DynamicModelMultipleChoiceField(
+ queryset=models.ComplianceFeature.objects.all(),
+ required=False,
+ label="Feature",
+ to_field_name="name",
+ )
+ device = forms.DynamicModelMultipleChoiceField(queryset=Device.objects.all(), required=False)
+
+
+class ConfigComplianceHashFilterForm(DeviceRelatedFilterForm):
+ """Filter Form for Config Hash Group."""
+
+ model = models.ConfigComplianceHash
+ field_order = [
+ "q",
+ "location_id",
+ "location",
+ "role",
+ "manufacturer",
+ "platform",
+ "device_status",
+ "device_type",
+ "feature",
+ ]
+ q = django_forms.CharField(required=False, label="Search")
+ feature = forms.DynamicModelMultipleChoiceField(
+ queryset=models.ComplianceFeature.objects.all(),
+ required=False,
+ label="Feature",
+ to_field_name="slug",
+ )
+
+ def __init__(self, *args, **kwargs):
+ """Required for status to work."""
+ super().__init__(*args, **kwargs)
+ self.fields["device_status"] = forms.DynamicModelMultipleChoiceField(
+ required=False,
+ queryset=Status.objects.all(),
+ query_params={"content_types": Device._meta.label_lower},
+ display_field="label",
+ label="Device Status",
+ to_field_name="name",
+ )
+ self.order_fields(self.field_order) # Reorder fields again
diff --git a/nautobot_golden_config/jobs.py b/nautobot_golden_config/jobs.py
index ff84d8612..6d0d0925b 100644
--- a/nautobot_golden_config/jobs.py
+++ b/nautobot_golden_config/jobs.py
@@ -31,7 +31,13 @@
from nautobot_golden_config.choices import ConfigPlanTypeChoice
from nautobot_golden_config.exceptions import BackupFailure, ComplianceFailure, IntendedGenerationFailure
-from nautobot_golden_config.models import ComplianceFeature, ConfigPlan, GoldenConfig
+from nautobot_golden_config.models import (
+ ComplianceFeature,
+ ConfigComplianceHash,
+ ConfigHashGrouping,
+ ConfigPlan,
+ GoldenConfig,
+)
from nautobot_golden_config.nornir_plays.config_backup import config_backup
from nautobot_golden_config.nornir_plays.config_compliance import config_compliance
from nautobot_golden_config.nornir_plays.config_deployment import config_deployment
@@ -393,12 +399,13 @@ def run(self, *args, **data): # pylint: disable=unused-argument, too-many-branc
raise NornirNautobotException(error_msg)
-class GenerateConfigPlans(Job, FormEntry):
+class GenerateConfigPlans(Job, FormEntry): # pylint: disable=too-many-instance-attributes
"""Job to generate config plans."""
# Config Plan generation fields
plan_type = ChoiceVar(choices=ConfigPlanTypeChoice.CHOICES)
feature = MultiObjectVar(model=ComplianceFeature, required=False)
+ config_hash = StringVar(required=False)
change_control_id = StringVar(required=False)
change_control_url = StringVar(required=False)
commands = TextVar(required=False)
@@ -417,6 +424,7 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._plan_type = None
self._feature = None
+ self._config_hash = ""
self._change_control_id = None
self._change_control_url = None
self._commands = None
@@ -433,9 +441,19 @@ def plan_status(self):
def _validate_inputs(self, data):
self._plan_type = data["plan_type"]
self._feature = data.get("feature", [])
+ self._config_hash = data.get("config_hash", "") or ""
self._change_control_id = data.get("change_control_id", "")
self._change_control_url = data.get("change_control_url", "")
self._commands = data.get("commands", "")
+ if self._config_hash:
+ if self._plan_type != "remediation":
+ error_msg = "`config_hash` is only supported with `plan_type=remediation`."
+ self.logger.error(error_msg)
+ raise ValueError(error_msg)
+ if not self._feature or len(self._feature) != 1:
+ error_msg = "`config_hash` requires exactly one `feature` to be supplied."
+ self.logger.error(error_msg)
+ raise ValueError(error_msg)
if self._plan_type in ["intended", "missing", "remediation"]:
if not self._feature:
self._feature = ComplianceFeature.objects.all()
@@ -445,6 +463,35 @@ def _validate_inputs(self, data):
self.logger.error(error_msg)
raise ValueError(error_msg)
+ def _resolve_devices_from_config_hash(self, data):
+ """Restrict ``data['device']`` to devices in the supplied hash group.
+
+ Mutates ``data`` in place so the existing :func:`get_job_filter` flow
+ applies Golden Config scope on top of the resolved set.
+ """
+ feature = list(self._feature)[0]
+ try:
+ config_group = ConfigHashGrouping.objects.get(rule__feature=feature, config_hash=self._config_hash)
+ except ConfigHashGrouping.DoesNotExist as error:
+ error_msg = f"No configuration hash group found for feature `{feature}` and hash `{self._config_hash}`."
+ self.logger.error(error_msg)
+ raise ValueError(error_msg) from error
+ device_pks = list(
+ ConfigComplianceHash.objects.filter(
+ config_group=config_group,
+ config_type="actual",
+ device__configcompliance__rule__feature=feature,
+ device__configcompliance__compliance=False,
+ )
+ .values_list("device_id", flat=True)
+ .distinct()
+ )
+ if not device_pks:
+ error_msg = f"No non-compliant devices found in hash group for feature `{feature}`."
+ self.logger.error(error_msg)
+ raise ValueError(error_msg)
+ data["device"] = Device.objects.filter(pk__in=device_pks)
+
def _generate_config_plan_from_feature(self):
"""Generate config plans from features."""
for device in self._device_qs:
@@ -512,6 +559,8 @@ def run(self, **data):
update_dynamic_groups_cache()
self.logger.debug("Starting config plan generation job.")
self._validate_inputs(data)
+ if self._config_hash:
+ self._resolve_devices_from_config_hash(data)
try:
self._device_qs = get_job_filter(data)
except NornirNautobotException as error:
diff --git a/nautobot_golden_config/migrations/0032_confighashgrouping_configcompliancehash_and_more.py b/nautobot_golden_config/migrations/0032_confighashgrouping_configcompliancehash_and_more.py
new file mode 100644
index 000000000..4cfd31158
--- /dev/null
+++ b/nautobot_golden_config/migrations/0032_confighashgrouping_configcompliancehash_and_more.py
@@ -0,0 +1,123 @@
+# Generated by Django 4.2.24 on 2025-09-23 13:42
+
+import uuid
+
+import django.core.serializers.json
+import django.db.models.deletion
+import nautobot.core.models.fields
+import nautobot.extras.models.mixins
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("dcim", "0067_controllermanageddevicegroup_tenant"),
+ ("extras", "0122_add_graphqlquery_owner_content_type"),
+ ("nautobot_golden_config", "0031_alter_configplan_change_control_url"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ConfigHashGrouping",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True
+ ),
+ ),
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "_custom_field_data",
+ models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+ ),
+ ("config_hash", models.CharField(blank=True, db_index=True, max_length=64)),
+ ("config_content", models.JSONField(blank=True)),
+ (
+ "rule",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="config_hash_groups",
+ to="nautobot_golden_config.compliancerule",
+ ),
+ ),
+ ("tags", nautobot.core.models.fields.TagsField(through="extras.TaggedItem", to="extras.Tag")),
+ ],
+ options={
+ "ordering": ["rule", "config_hash"],
+ },
+ bases=(
+ nautobot.extras.models.mixins.DynamicGroupMixin,
+ nautobot.extras.models.mixins.NotesMixin,
+ models.Model,
+ ),
+ ),
+ migrations.CreateModel(
+ name="ConfigComplianceHash",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True
+ ),
+ ),
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "_custom_field_data",
+ models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+ ),
+ ("config_type", models.CharField(max_length=20)),
+ ("config_hash", models.CharField(blank=True, db_index=True, max_length=64)),
+ (
+ "config_group",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="hash_records",
+ to="nautobot_golden_config.confighashgrouping",
+ ),
+ ),
+ ("device", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="dcim.device")),
+ (
+ "rule",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="config_hashes",
+ to="nautobot_golden_config.compliancerule",
+ ),
+ ),
+ ("tags", nautobot.core.models.fields.TagsField(through="extras.TaggedItem", to="extras.Tag")),
+ ],
+ options={
+ "ordering": ["device", "rule", "config_type"],
+ },
+ bases=(
+ nautobot.extras.models.mixins.DynamicGroupMixin,
+ nautobot.extras.models.mixins.NotesMixin,
+ models.Model,
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="confighashgrouping",
+ index=models.Index(fields=["rule", "config_hash"], name="nautobot_go_rule_id_634624_idx"),
+ ),
+ migrations.AlterUniqueTogether(
+ name="confighashgrouping",
+ unique_together={("rule", "config_hash")},
+ ),
+ migrations.AddIndex(
+ model_name="configcompliancehash",
+ index=models.Index(fields=["rule", "config_hash"], name="nautobot_go_rule_id_7178cb_idx"),
+ ),
+ migrations.AddIndex(
+ model_name="configcompliancehash",
+ index=models.Index(fields=["rule", "config_type", "config_hash"], name="nautobot_go_rule_id_23ac1a_idx"),
+ ),
+ migrations.AlterUniqueTogether(
+ name="configcompliancehash",
+ unique_together={("device", "rule", "config_type")},
+ ),
+ ]
diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py
index 472e1b206..4c6e46416 100644
--- a/nautobot_golden_config/models.py
+++ b/nautobot_golden_config/models.py
@@ -1,4 +1,5 @@
"""Django Models for tracking the configuration compliance per feature and device."""
+# pylint: disable=too-many-lines
import json
import logging
@@ -23,6 +24,7 @@
from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice, ConfigPlanTypeChoice, RemediationTypeChoice
from nautobot_golden_config.utilities.constant import ENABLE_SOTAGG, PLUGIN_CFG
+from nautobot_golden_config.utilities.hash_utils import compute_config_hash
LOGGER = logging.getLogger(__name__)
GRAPHQL_STR_START = "query ($device_id: ID!)"
@@ -356,6 +358,15 @@ def clean(self):
if self.config_type == ComplianceRuleConfigTypeChoice.TYPE_CLI and not self.match_config:
raise ValidationError("CLI configuration set, but no configuration set to match.")
+ def cleanup_orphaned_hash_groups(self):
+ """Remove ConfigHashGrouping records for this rule that no longer have any linked devices."""
+ orphaned_groups = ConfigHashGrouping.objects.filter(rule=self).exclude(
+ id__in=ConfigComplianceHash.objects.filter(rule=self, config_group__isnull=False).values_list(
+ "config_group_id", flat=True
+ )
+ )
+ orphaned_groups.delete()
+
@extras_features(
"custom_fields",
@@ -366,7 +377,7 @@ def clean(self):
"relationships",
"webhooks",
)
-class ConfigCompliance(PrimaryModel): # pylint: disable=too-many-ancestors
+class ConfigCompliance(PrimaryModel): # pylint: disable=too-many-ancestors, too-many-instance-attributes
"""Configuration compliance details."""
device = models.ForeignKey(to="dcim.Device", on_delete=models.CASCADE, help_text="The device")
@@ -431,6 +442,66 @@ def compliance_on_save(self):
self.missing = compliance_details["missing"]
self.extra = compliance_details["extra"]
+ # Update or create ConfigComplianceHash records and ConfigHashGrouping for grouping
+ self._update_config_hashes()
+
+ def _update_config_hashes(self):
+ """Update or create ConfigComplianceHash records and ConfigHashGrouping for actual and intended configs."""
+ # Compute configuration hashes
+ actual_hash = compute_config_hash(self.actual)
+ intended_hash = compute_config_hash(self.intended)
+
+ # Handle actual config grouping
+ if actual_hash and not self.compliance: # Only group non-compliant configs
+ # Get or create the config hash group for actual configs
+ config_group, _ = ConfigHashGrouping.objects.get_or_create(
+ rule=self.rule,
+ config_hash=actual_hash,
+ defaults={
+ "config_content": self.actual,
+ },
+ )
+
+ # Create/update the hash record for actual config and link to group
+ ConfigComplianceHash.objects.update_or_create(
+ device=self.device,
+ rule=self.rule,
+ config_type="actual",
+ defaults={
+ "config_hash": actual_hash,
+ "config_group": config_group,
+ },
+ )
+ else:
+ # Create/update hash record for actual config without group (compliant or empty)
+ hash_record, created = ConfigComplianceHash.objects.update_or_create(
+ device=self.device,
+ rule=self.rule,
+ config_type="actual",
+ defaults={
+ "config_hash": actual_hash,
+ "config_group": None,
+ },
+ )
+ # Explicitly ensure config_group is None for existing records
+ if not created and hash_record.config_group is not None:
+ hash_record.config_group = None
+ hash_record.save()
+
+ # Create/update hash record for intended config (no grouping needed for intended)
+ ConfigComplianceHash.objects.update_or_create(
+ device=self.device,
+ rule=self.rule,
+ config_type="intended",
+ defaults={
+ "config_hash": intended_hash,
+ "config_group": None, # Intended configs don't get grouped
+ },
+ )
+
+ # Clean up orphaned ConfigHashGrouping records that no longer have any linked devices
+ self.rule.cleanup_orphaned_hash_groups()
+
def remediation_on_save(self):
"""The actual remediation happens here, before saving the object."""
if self.compliance:
@@ -458,7 +529,14 @@ def save(self, *args, **kwargs):
# in behavior
if kwargs.get("update_fields"):
kwargs["update_fields"].update(
- {"compliance", "compliance_int", "ordered", "missing", "extra", "remediation"}
+ {
+ "compliance",
+ "compliance_int",
+ "ordered",
+ "missing",
+ "extra",
+ "remediation",
+ }
)
super().save(*args, **kwargs)
@@ -885,3 +963,89 @@ class Meta:
def __str__(self):
"""Return a simple string if model is called."""
return f"{self.device.name}-{self.plan_type}-{self.created}"
+
+
+@extras_features(
+ "custom_fields",
+ "custom_links",
+ "custom_validators",
+ "export_templates",
+ "graphql",
+ "relationships",
+ "webhooks",
+)
+class ConfigComplianceHash(PrimaryModel): # pylint: disable=too-many-ancestors
+ """Configuration compliance hash storage for linking devices to configuration hash groups."""
+
+ device = models.ForeignKey(to="dcim.Device", on_delete=models.CASCADE, help_text="The device")
+ rule = models.ForeignKey(to="ComplianceRule", on_delete=models.CASCADE, related_name="config_hashes")
+ config_type = models.CharField(
+ max_length=20,
+ choices=[("actual", "Actual"), ("intended", "Intended")],
+ help_text="Type of configuration (actual or intended)",
+ )
+ config_hash = models.CharField(
+ max_length=64, blank=True, help_text="SHA-256 hash of the configuration content", db_index=True
+ )
+ config_group = models.ForeignKey(
+ to="ConfigHashGrouping",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ help_text="Reference to the configuration hash group (only for actual configs)",
+ related_name="hash_records",
+ )
+
+ class Meta:
+ """Set unique together fields for model."""
+
+ ordering = ["device", "rule", "config_type"]
+ unique_together = ("device", "rule", "config_type")
+ indexes = [
+ models.Index(fields=["rule", "config_hash"]),
+ models.Index(fields=["rule", "config_type", "config_hash"]),
+ ]
+
+ def __str__(self):
+ """String representation of the hash record."""
+ return f"{self.device} -> {self.rule} -> {self.config_type} -> {self.config_hash}"
+
+ def delete(self, *args, **kwargs):
+ """Override delete to clean up orphaned ConfigHashGrouping records."""
+ result = super().delete(*args, **kwargs)
+
+ # Clean up orphaned groups for this rule after deletion
+ self.rule.cleanup_orphaned_hash_groups()
+ return result
+
+
+@extras_features(
+ "custom_fields",
+ "custom_links",
+ "custom_validators",
+ "export_templates",
+ "graphql",
+ "relationships",
+ "webhooks",
+)
+class ConfigHashGrouping(PrimaryModel): # pylint: disable=too-many-ancestors
+ """Groups devices with identical actual configuration hashes."""
+
+ rule = models.ForeignKey(to="ComplianceRule", on_delete=models.CASCADE, related_name="config_hash_groups")
+ config_hash = models.CharField(
+ max_length=64, blank=True, help_text="SHA-256 hash of the actual configuration content", db_index=True
+ )
+ config_content = models.JSONField(blank=True, help_text="Actual configuration content for display purposes")
+
+ class Meta:
+ """Set unique together fields for model."""
+
+ ordering = ["rule", "config_hash"]
+ unique_together = ("rule", "config_hash")
+ indexes = [
+ models.Index(fields=["rule", "config_hash"]),
+ ]
+
+ def __str__(self):
+ """String representation of the config hash group."""
+ return f"{self.rule} -> {self.config_hash}"
diff --git a/nautobot_golden_config/navigation.py b/nautobot_golden_config/navigation.py
index 144f001a3..41a27e496 100644
--- a/nautobot_golden_config/navigation.py
+++ b/nautobot_golden_config/navigation.py
@@ -60,7 +60,6 @@
)
)
-
if ENABLE_COMPLIANCE:
items_operate.append(
NavMenuItem(
@@ -70,6 +69,22 @@
)
)
+if ENABLE_COMPLIANCE:
+ items_operate.append(
+ NavMenuItem(
+ link="plugins:nautobot_golden_config:configcompliancehash_list",
+ name="Config Hashes",
+ permissions=["nautobot_golden_config.view_confighash"],
+ )
+ )
+ items_operate.append(
+ NavMenuItem(
+ link="plugins:nautobot_golden_config:confighashgrouping_list",
+ name="Config Hash Report",
+ permissions=["nautobot_golden_config.view_confighashgrouping"],
+ )
+ )
+
if ENABLE_PLAN:
items_operate.append(
NavMenuItem(
diff --git a/nautobot_golden_config/static/run_job.js b/nautobot_golden_config/static/run_job.js
index de9783000..6346304f3 100644
--- a/nautobot_golden_config/static/run_job.js
+++ b/nautobot_golden_config/static/run_job.js
@@ -211,4 +211,22 @@ function getMessage(jobResultId) {
return new Promise((resolve) => {
resolve("Job Completed Successfully.");
});
+}
+
+function configPlanCount(jobResultId) {
+ return new Promise(function (resolve) {
+ $.ajax({
+ url: `/api/plugins/golden-config/config-plan/?plan_result_id=${jobResultId}`,
+ type: "GET",
+ dataType: "json",
+ headers: {'X-CSRFToken': nautobot_csrf_token},
+ success: function (data) {
+ resolve("Job Completed Successfully.
Number of Config Plans generated: " + data.count);
+ },
+ error: function () {
+ resolve("Job completed successfully, but no Config Plans were generated."
+ + "
If this is unexpected, please validate your input parameters.");
+ }
+ });
+ });
}
\ No newline at end of file
diff --git a/nautobot_golden_config/tables.py b/nautobot_golden_config/tables.py
index 03be49b30..17b54753e 100644
--- a/nautobot_golden_config/tables.py
+++ b/nautobot_golden_config/tables.py
@@ -122,6 +122,26 @@ def actual_fields():
return tuple(active_fields)
+def get_display_template(field_name):
+ """Return a display template for the given field name."""
+ return (
+ """
+ {% load helpers %}
+
+
+ """
+ )
+
+
#
# Columns
#
@@ -557,3 +577,113 @@ class Meta(BaseTable.Meta):
"config_set",
"status",
)
+
+
+# Config Hash
+
+
+class ConfigComplianceHashTable(BaseTable):
+ """Table for displaying individual ConfigComplianceHash records with bulk operations."""
+
+ pk = ToggleColumn()
+ device = LinkColumn("dcim:device", args=[A("device.pk")], verbose_name="Device")
+ rule = LinkColumn("plugins:nautobot_golden_config:compliancerule", args=[A("rule.pk")], verbose_name="Feature")
+ actual_config_hash = Column(verbose_name="Actual Config Hash", accessor="config_hash")
+ intended_config_hash = Column(verbose_name="Intended Config Hash", accessor="config_hash")
+
+ def render_actual_config_hash(self, value):
+ """Render actual config hash truncated to its first 7 characters."""
+ if value:
+ return value[:7]
+ return value
+
+ def render_intended_config_hash(self, record):
+ """Render intended config hash."""
+ # Get intended hash from ConfigComplianceHash records for the same device/rule
+ try:
+ intended_hash_record = models.ConfigComplianceHash.objects.get(
+ device=record.device, rule=record.rule, config_type="intended"
+ )
+ if intended_hash_record.config_hash:
+ return intended_hash_record.config_hash[:7]
+ return "--"
+ except models.ConfigComplianceHash.DoesNotExist:
+ return "--"
+
+ class Meta(BaseTable.Meta):
+ """Meta information for ConfigComplianceHashTable."""
+
+ model = models.ConfigComplianceHash
+ fields = (
+ "pk",
+ "device",
+ "rule",
+ "actual_config_hash",
+ "intended_config_hash",
+ )
+ default_columns = (
+ "pk",
+ "device",
+ "rule",
+ "actual_config_hash",
+ "intended_config_hash",
+ )
+
+
+class ConfigHashGroupingTable(BaseTable): # pylint: disable=nb-sub-class-name
+ """Table for displaying configuration hash grouping results."""
+
+ pk = ToggleColumn()
+ feature_name = Column(verbose_name="Feature", accessor="feature_name")
+ device_count = TemplateColumn(
+ template_code="""
+
+ {{ record.device_count }}
+
+ """,
+ verbose_name="Device Count",
+ orderable=True,
+ order_by=("device_count",),
+ )
+ config_content = TemplateColumn(
+ template_code=get_display_template("config_content"),
+ verbose_name="Configuration Snippet",
+ orderable=False,
+ )
+ actions = TemplateColumn(
+ template_code="""
+