diff --git a/.gitignore b/.gitignore
index a73a104d..2605d8cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ creds.env
nautobot_golden_config/transposer.py
docker-compose.override.yml
packages/
+invoke.yml
# Ansible Retry Files
*.retry
diff --git a/development/Dockerfile b/development/Dockerfile
index d6f70552..177cd531 100644
--- a/development/Dockerfile
+++ b/development/Dockerfile
@@ -64,7 +64,7 @@ RUN pip show nautobot | grep "^Version: " | sed -e 's/Version: /nautobot==/' > c
# We can't use the entire freeze as it takes forever to resolve with rigidly fixed non-direct dependencies,
# especially those that are only direct to Nautobot but the container included versions slightly mismatch
RUN poetry export -f requirements.txt --without-hashes --output poetry_freeze_base.txt
-RUN poetry export -f requirements.txt --dev --without-hashes --output poetry_freeze_all.txt
+RUN poetry export -f requirements.txt --with dev --without-hashes --output poetry_freeze_all.txt
RUN sort poetry_freeze_base.txt poetry_freeze_all.txt | uniq -u > poetry_freeze_dev.txt
# Install all local project as editable, constrained on Nautobot version, to get any additional
diff --git a/development/docker-compose.base.yml b/development/docker-compose.base.yml
index 3fbf8f10..26356204 100644
--- a/development/docker-compose.base.yml
+++ b/development/docker-compose.base.yml
@@ -21,8 +21,9 @@ services:
condition: "service_started"
db:
condition: "service_healthy"
- <<: *nautobot-build
- <<: *nautobot-base
+ <<:
+ - *nautobot-build
+ - *nautobot-base
worker:
entrypoint:
- "sh"
diff --git a/docs/images/config_plan-edit.png b/docs/images/config_plan-edit.png
new file mode 100644
index 00000000..04b52f09
Binary files /dev/null and b/docs/images/config_plan-edit.png differ
diff --git a/docs/images/config_plan-generate-filters.png b/docs/images/config_plan-generate-filters.png
new file mode 100644
index 00000000..0f018a61
Binary files /dev/null and b/docs/images/config_plan-generate-filters.png differ
diff --git a/docs/images/config_plan-generate-manual.png b/docs/images/config_plan-generate-manual.png
new file mode 100644
index 00000000..08fabe20
Binary files /dev/null and b/docs/images/config_plan-generate-manual.png differ
diff --git a/docs/images/config_plan-generate-missing.png b/docs/images/config_plan-generate-missing.png
new file mode 100644
index 00000000..f97c1cc6
Binary files /dev/null and b/docs/images/config_plan-generate-missing.png differ
diff --git a/docs/images/config_plan-view.png b/docs/images/config_plan-view.png
new file mode 100644
index 00000000..d9b704a5
Binary files /dev/null and b/docs/images/config_plan-view.png differ
diff --git a/docs/images/remediation_custom_function_setup.png b/docs/images/remediation_custom_function_setup.png
new file mode 100644
index 00000000..dc2a0dcd
Binary files /dev/null and b/docs/images/remediation_custom_function_setup.png differ
diff --git a/docs/images/remediation_enable_compliance_rule_feature.png b/docs/images/remediation_enable_compliance_rule_feature.png
new file mode 100644
index 00000000..ea9ebc51
Binary files /dev/null and b/docs/images/remediation_enable_compliance_rule_feature.png differ
diff --git a/docs/images/remediation_hier_edit_options.png b/docs/images/remediation_hier_edit_options.png
new file mode 100644
index 00000000..a9ffcd6d
Binary files /dev/null and b/docs/images/remediation_hier_edit_options.png differ
diff --git a/docs/images/remediation_settings_per_platform.png b/docs/images/remediation_settings_per_platform.png
new file mode 100644
index 00000000..ad16567b
Binary files /dev/null and b/docs/images/remediation_settings_per_platform.png differ
diff --git a/docs/images/remediation_validate_feature.png b/docs/images/remediation_validate_feature.png
new file mode 100644
index 00000000..118ec3a6
Binary files /dev/null and b/docs/images/remediation_validate_feature.png differ
diff --git a/docs/user/app_feature_config_plans.md b/docs/user/app_feature_config_plans.md
new file mode 100644
index 00000000..fbb516a2
--- /dev/null
+++ b/docs/user/app_feature_config_plans.md
@@ -0,0 +1,66 @@
+# Navigating Config Plans
+
+The natural progression for the Golden Config application is providing the ability to execute config deployments. One specific example is to work toward making one or more devices configuration compliant. To aid in this effort, the Golden Config application has the ability to generate plans containing sets of configuration commands from various sources with the intent of deploying them to devices.
+
+The current sources of these plans (i.e. plan types) are as follows:
+
+- The **Intended** configuration(s) of Compliance Feature(s)
+- The **Missing** configuration(s) of Compliance Feature(s)
+- The **Remediation** configuration(s) of Compliance Feature(s) (*)
+- A **Manual** set of configuration commands
+
+!!! note
+ The Intended, Missing and Remediation configuration come from the [Configuration Compliance](./app_feature_compliance.md#compliance-details-view) object that is created when you run the [Perform Configuration Compliance Job](./app_feature_compliance.md#starting-a-compliance-job).
+
+Much like a Configuration Compliance object, each Config Plan is tied directly to a single Device.
+
+## Viewing a Config Plan
+
+You can view a plan by navigating to **Golden Config -> Config Plans** and choosing a generated plan from the list. A Config Plan comprises of the following fields:
+
+- **Device**: The device the plan is to be deployed to.
+- **Date Created**: The date the plan was generated.
+- **Plan Type**: The type of plan used to generate it.
+- **Config Set**: The set of commands to be deployed.
+- **Features** (If Applicable): The Compliance Feature(s) the config set was generated from.
+- **Change Control ID** (Optional): A text field that be used for grouping and filtering plans.
+- **Change Control URL** (Optional): A URL field that can be used to link to an external system tracking change controls.
+- **Job Result**: The Job that generated the plan(s).
+- **Status**: The status of the plan.
+
+![Config Plan View](../images/config_plan-view.png)
+
+## Generating Config Plans
+
+In order to generate a plan, navigate to **Golden Config -> Config Plans** and hit the **Add** button. After choosing the type of plan you want to generate, you can then filter the list of devices you want to generate a Config Plan for by selecting either the list of devices themselves or a by choosing one or more related items such as Location or Status. If you select a plan type that is derived from a Configuration Compliance object, you will have the ability to only generate plans for one or more features, but selecting no features will generate plans for all applicable features.
+
+In addition, you have the ability to specify a Change Control ID & URL that can be associated with all of the plans that will be generated. This can come in handy when it comes to filtering the list of plans to ultimately deploy.
+
+Once you have selected the appropriate options, you can click the **Generate** button which will start a Job to generate the plans.
+
+### Screenshots
+
+![Config Plan Generate Missing](../images/config_plan-generate-missing.png)
+
+![Config Plan Generate Filters](../images/config_plan-generate-filters.png)
+
+![Config Plan Generate Manual](../images/config_plan-generate-manual.png)
+
+### Generating Config Plans via API
+
+The HTTP(S) POST method is not currently enabled for the Config Plan serializer to create plans directly via API. Instead you may run the **GenerateConfigPlans** Job directly via the `plugins/nautobot_golden_config.jobs/GenerateConfigPlans` API endpoint.
+
+## Editing a Config Plan
+
+After a Config Plan is generated you have the ability to edit (or bulk edit) the following fields:
+
+- Change Control ID
+- Change Control URL
+- Status
+- Notes
+- Tags
+
+!!! note
+ You will not be able to modify the Config Set after generation. If it does not contain the desired commands, you will need to delete the plan and recreate it after ensuring the source of the generated commands has been updated.
+
+![Config Plan Edit](../images/config_plan-edit.png)
diff --git a/docs/user/app_feature_remediation.md b/docs/user/app_feature_remediation.md
new file mode 100644
index 00000000..61c9c0cf
--- /dev/null
+++ b/docs/user/app_feature_remediation.md
@@ -0,0 +1,69 @@
+# Navigating Configuration Remediation
+
+Automated network configuration remediation is a systematic approach that leverages technology and processes to address and rectify configuration issues in network devices.
+It involves the use of the Golden Configuration plugin to understand the current configuration state, compare it against the intended configuration state, and automatically generate remediation data.
+Automated network configuration remediation improves efficiency by eliminating manual efforts and reducing the risk of human errors. It enables rapid response to security vulnerabilities, minimizes downtime, and enhances compliance with regulatory and industry standards.
+
+
+The current sources of data to generate remediating configuration are as follows:
+
+- The **Intended** configuration of a specific Compliance Feature
+- The **Missing** configuration of a specific Compliance Feature
+- The **Extra** configuration of a specific Compliance Feature
+
+Based on this information, Golden Configuration will create a remediating configuration (if enabled for that particular platform and compliance feature). This configuration snippet will be represented as a "Remediating Configuration" field in the compliance detailed view:
+
+- The **Remediation** configuration of a specific Compliance Feature
+
+
+!!! note
+ The Intended, Missing and Extra configuration come from the [Configuration Compliance](./app_feature_compliance.md#compliance-details-view) object that is created when you run the [Perform Configuration Compliance Job](./app_feature_compliance.md#starting-a-compliance-job).
+
+
+## Setting up Configuration Remediation
+
+The type of remediation to be performed in a particular platform is defined by navigating to **Golden Config -> Remediation Settings**.
+Network device operating systems (Nautobot Platforms) can consume two different types of remediation, namely:
+
+- **HIERCONFIG remediation (CLI - hierarchical)**
+- **Custom Remediation**
+
+![Remediation Platform Settings](../images/remediation_settings_per_platform.png)
+
+### Hier Config Remediation Type
+
+Hier Config is a python library that is able to take a running configuration of a network device, compare it to its intended configuration, and build the remediation steps necessary to bring a device into spec with its intended configuration. Hier Config has been used extensively on:
+
+- Cisco IOS
+- Cisco IOSXR
+- Cisco NXOS
+- Arista EOS
+- Ruckus FastIron
+
+However, any Network Operating System (NOS) that utilizes a CLI syntax that is structured in a similar fashion to Cisco IOS should work mostly out of the box.
+Default Hier config options can be used or customized on a per platform basis, as shown below:
+
+![Hier Options Customization](../images/remediation_hier_edit_options.png)
+
+For additional information on how to customize Hier Config options, please refer to the Hierarchical Configuration development guide:
+https://netdevops.io/hier_config/advanced-topics/
+
+### Custom Config Remediation Type
+
+When a Network Operating System delivers configuration data in a format that is not CLI/Hierarchical, we can still perform remediation by using the Custom Remediation options. Custom Remediation is defined within a Python function that takes as input a Configuration Compliance object and returns a Remediation Field.
+Custom remediation performs a call to the remediation function every time a Compliance Job runs. Custom Remediation allows the user to control the configuration comparison process (between intended and actual configuration) and use additional Nautobot or external data to produce the remediation plan. Custom remediation functions need to be defined in PLUGIN_CONFIG for `nautobot_plugin_golden_config` the nautobot_config.py file, as show below:
+
+![Custom Remediation Function Setup](../images/remediation_custom_function_setup.png)
+
+## Enabling Configuration Remediation
+
+Once remediation settings are configured for a particular platform, remediation can be enabled on a per compliance rule basis. In order to enable configuration remediation for a particular rule, navigate to **Golden Config -> Compliance Rules**, and choose a rule for a platform that has remediation settings set up. Edit the compliance rule and check the box "Enable Remediation". This action effectively enables remediation for that particular Platform/Feature pair.
+
+![Enable Configuration Remediation per Feature](../images/remediation_enable_compliance_rule_feature.png)
+
+
+## Validating Configuration Remediation
+
+Once remediation is configured for a particular Platform/Feature pair, it is possible to validate remediation operations by running a compliance job. Navigate to **Jobs -> Perform Configuration Compliance** and run a compliance job for a device that has remediation enabled. Verify that remediation data has been generated by navigating to **Golden Config -> Config Compliance**, select the device and check the compliance status for the feature with remediation enabled and the "Remediating Configuration" field, as shown below:
+
+![Validate Configuration Remediation](../images/remediation_validate_feature.png)
\ No newline at end of file
diff --git a/mkdocs.yml b/mkdocs.yml
index 37eb0098..95edbb3a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -104,6 +104,8 @@ nav:
- Navigate Intended: "user/app_feature_intended.md"
- Navigate SoT Agg: "user/app_feature_sotagg.md"
- 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"
- Getting Started: "user/app_getting_started.md"
- Frequently Asked Questions: "user/app_faq.md"
- External Interactions: "user/app_external_interactions.md"
diff --git a/nautobot_golden_config/__init__.py b/nautobot_golden_config/__init__.py
index cf32a8c2..c2beb32e 100644
--- a/nautobot_golden_config/__init__.py
+++ b/nautobot_golden_config/__init__.py
@@ -7,6 +7,7 @@
from jinja2 import StrictUndefined
from django.db.models.signals import post_migrate
+from nautobot.core.signals import nautobot_database_ready
from nautobot.extras.plugins import PluginConfig
@@ -45,7 +46,15 @@ def ready(self):
"""Register custom signals."""
from nautobot_golden_config.models import ConfigCompliance # pylint: disable=import-outside-toplevel
- from .signals import config_compliance_platform_cleanup # pylint: disable=import-outside-toplevel
+ # pylint: disable=import-outside-toplevel
+ from .signals import (
+ config_compliance_platform_cleanup,
+ post_migrate_create_statuses,
+ post_migrate_create_job_button,
+ )
+
+ nautobot_database_ready.connect(post_migrate_create_statuses, sender=self)
+ nautobot_database_ready.connect(post_migrate_create_job_button, sender=self)
super().ready()
post_migrate.connect(config_compliance_platform_cleanup, sender=ConfigCompliance)
diff --git a/nautobot_golden_config/api/serializers.py b/nautobot_golden_config/api/serializers.py
index 522fd97f..5856328e 100644
--- a/nautobot_golden_config/api/serializers.py
+++ b/nautobot_golden_config/api/serializers.py
@@ -2,11 +2,15 @@
# pylint: disable=too-many-ancestors
from rest_framework import serializers
+from nautobot.apps.api import WritableNestedSerializer
+from nautobot.extras.api.fields import StatusSerializerField
from nautobot.extras.api.serializers import TaggedObjectSerializer
from nautobot.extras.api.nested_serializers import NestedDynamicGroupSerializer
+from nautobot.extras.models import Status
+from nautobot.dcim.api.nested_serializers import NestedDeviceSerializer
from nautobot.dcim.api.serializers import DeviceSerializer
from nautobot.dcim.models import Device
-from nautobot.extras.api.serializers import NautobotModelSerializer
+from nautobot.extras.api.serializers import NautobotModelSerializer, StatusModelSerializerMixin
from nautobot_golden_config import models
@@ -149,3 +153,45 @@ def get_config(self, obj):
config_details = models.GoldenConfig.objects.get(device=obj)
return get_config_postprocessing(config_details, request)
+
+
+class RemediationSettingSerializer(NautobotModelSerializer, TaggedObjectSerializer):
+ """Serializer for RemediationSetting object."""
+
+ url = serializers.HyperlinkedIdentityField(
+ view_name="plugins-api:nautobot_golden_config-api:remediationsetting-detail"
+ )
+
+ class Meta:
+ """Set Meta Data for RemediationSetting, will serialize all fields."""
+
+ model = models.RemediationSetting
+ choices_fields = ["remediation_type"]
+ fields = "__all__"
+
+
+class ConfigPlanSerializer(NautobotModelSerializer, TaggedObjectSerializer, StatusModelSerializerMixin):
+ """Serializer for ConfigPlan object."""
+
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:nautobot_golden_config-api:configplan-detail")
+ device = NestedDeviceSerializer(required=False)
+ status = StatusSerializerField(required=False, queryset=Status.objects.all())
+
+ class Meta:
+ """Set Meta Data for ConfigPlan, will serialize all fields."""
+
+ model = models.ConfigPlan
+ fields = "__all__"
+ read_only_fields = ["device", "plan_type", "feature", "config_set"]
+
+
+class NestedConfigPlanSerializer(WritableNestedSerializer):
+ """Nested serializer for ConfigPlan object."""
+
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:nautobot_golden_config-api:configplan-detail")
+
+ class Meta:
+ """Set Meta Data for ConfigPlan, will serialize brief fields."""
+
+ model = models.ConfigPlan
+ fields = ["id", "url", "device", "plan_type"]
diff --git a/nautobot_golden_config/api/urls.py b/nautobot_golden_config/api/urls.py
index c4364419..1e34aa58 100644
--- a/nautobot_golden_config/api/urls.py
+++ b/nautobot_golden_config/api/urls.py
@@ -14,7 +14,9 @@
router.register("golden-config-settings", views.GoldenConfigSettingViewSet)
router.register("config-remove", views.ConfigRemoveViewSet)
router.register("config-replace", views.ConfigReplaceViewSet)
+router.register("remediation-setting", views.RemediationSettingViewSet)
router.register("config-postprocessing", views.ConfigToPushViewSet)
+router.register("config-plan", views.ConfigPlanViewSet)
urlpatterns = router.urls
urlpatterns.append(
path(
diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py
index 4f31e4c5..39c91d70 100644
--- a/nautobot_golden_config/api/views.py
+++ b/nautobot_golden_config/api/views.py
@@ -114,3 +114,22 @@ class ConfigToPushViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
permission_classes = [IsAuthenticated & ConfigPushPermissions]
queryset = Device.objects.all()
serializer_class = serializers.ConfigToPushSerializer
+
+
+class RemediationSettingViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors
+ """API viewset for interacting with RemediationSetting objects."""
+
+ queryset = models.RemediationSetting.objects.all()
+ serializer_class = serializers.RemediationSettingSerializer
+ filterset_class = filters.RemediationSettingFilterSet
+
+
+class ConfigPlanViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors
+ """API viewset for interacting with ConfigPlan objects."""
+
+ queryset = models.ConfigPlan.objects.all()
+ serializer_class = serializers.ConfigPlanSerializer
+ filterset_class = filters.ConfigPlanFilterSet
+
+ # Disabling POST as these should only be created via Job.
+ http_method_names = ["get", "put", "patch", "delete", "head", "options"]
diff --git a/nautobot_golden_config/choices.py b/nautobot_golden_config/choices.py
index 7d51d7a1..b69dd09b 100644
--- a/nautobot_golden_config/choices.py
+++ b/nautobot_golden_config/choices.py
@@ -12,3 +12,31 @@ class ComplianceRuleConfigTypeChoice(ChoiceSet):
(TYPE_CLI, "CLI"),
(TYPE_JSON, "JSON"),
)
+
+
+class RemediationTypeChoice(ChoiceSet):
+ """Choiceset used by RemediationSetting."""
+
+ TYPE_HIERCONFIG = "hierconfig"
+ TYPE_CUSTOM = "custom_remediation"
+
+ CHOICES = (
+ (TYPE_HIERCONFIG, "HIERCONFIG"),
+ (TYPE_CUSTOM, "CUSTOM_REMEDIATION"),
+ )
+
+
+class ConfigPlanTypeChoice(ChoiceSet):
+ """Choiceset used by ConfigPlan."""
+
+ TYPE_INTENDED = "intended"
+ TYPE_MISSING = "missing"
+ TYPE_REMEDIATION = "remediation"
+ TYPE_MANUAL = "manual"
+
+ CHOICES = (
+ (TYPE_INTENDED, "Intended"),
+ (TYPE_MISSING, "Missing"),
+ (TYPE_REMEDIATION, "Remediation"),
+ (TYPE_MANUAL, "Manual"),
+ )
diff --git a/nautobot_golden_config/filter_extensions.py b/nautobot_golden_config/filter_extensions.py
new file mode 100644
index 00000000..6eeb24fa
--- /dev/null
+++ b/nautobot_golden_config/filter_extensions.py
@@ -0,0 +1,23 @@
+"""Custom filter to extend base API for filterform use case."""
+import django_filters
+from nautobot.apps.filters import FilterExtension
+
+
+def config_plan_null_search(queryset, name, value): # pylint: disable=unused-argument
+ """Query to ensure config plans are not empty."""
+ return queryset.filter(config_plan__isnull=False).distinct()
+
+
+class JobResultFilterExtension(FilterExtension):
+ """Filter provided to be used in select2 query for only jobs that were used by ConfigPlan."""
+
+ model = "extras.jobresult"
+
+ filterset_fields = {
+ "nautobot_golden_config_config_plan_null": django_filters.BooleanFilter(
+ label="Is FK to ConfigPlan Model", method=config_plan_null_search
+ )
+ }
+
+
+filter_extensions = [JobResultFilterExtension]
diff --git a/nautobot_golden_config/filters.py b/nautobot_golden_config/filters.py
index 43bf7c0d..8617d96c 100644
--- a/nautobot_golden_config/filters.py
+++ b/nautobot_golden_config/filters.py
@@ -2,15 +2,18 @@
import django_filters
from django.db.models import Q
-from nautobot.dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, Region, Site
from nautobot.dcim.filters import DeviceFilterSet
-from nautobot.extras.filters import StatusFilter
-from nautobot.extras.filters import NautobotFilterSet
-from nautobot.extras.models import Status
+from nautobot.dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, Region, Site
+from nautobot.extras.filters import NautobotFilterSet, StatusFilter
+from nautobot.extras.models import JobResult, Status
from nautobot.tenancy.models import Tenant, TenantGroup
-from nautobot.utilities.filters import TreeNodeMultipleChoiceFilter
-from nautobot.utilities.filters import MultiValueDateTimeFilter
-
+from nautobot.utilities.filters import (
+ BaseFilterSet,
+ MultiValueDateTimeFilter,
+ NameSlugSearchFilterSet,
+ TagFilter,
+ TreeNodeMultipleChoiceFilter,
+)
from nautobot_golden_config import models
@@ -363,3 +366,104 @@ class Meta:
model = models.GoldenConfigSetting
fields = ["id", "name", "slug", "weight", "backup_repository", "intended_repository", "jinja_repository"]
+
+
+class RemediationSettingFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+ """Inherits Base Class CustomFieldModelFilterSet."""
+
+ q = django_filters.CharFilter(
+ method="search",
+ label="Search",
+ )
+ platform = django_filters.ModelMultipleChoiceFilter(
+ field_name="platform__name",
+ queryset=Platform.objects.all(),
+ to_field_name="name",
+ label="Platform Name",
+ )
+ platform_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Platform.objects.all(),
+ label="Platform ID",
+ )
+ remediation_type = django_filters.ModelMultipleChoiceFilter(
+ field_name="remediation_type",
+ queryset=models.RemediationSetting.objects.all(),
+ to_field_name="remediation_type",
+ label="Remediation Type",
+ )
+
+ def search(self, queryset, name, value): # pylint: disable=unused-argument
+ """Perform the filtered search."""
+ if not value.strip():
+ return queryset
+ qs_filter = Q(platform__name__icontains=value) | Q(remediation_type__icontains=value)
+ return queryset.filter(qs_filter)
+
+ class Meta:
+ """Boilerplate filter Meta data for Remediation Setting."""
+
+ model = models.RemediationSetting
+ fields = ["id", "platform", "remediation_type"]
+
+
+class ConfigPlanFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+ """Inherits Base Class BaseFilterSet."""
+
+ q = django_filters.CharFilter(
+ method="search",
+ label="Search",
+ )
+ device_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Device.objects.all(),
+ label="Device ID",
+ )
+ device = django_filters.ModelMultipleChoiceFilter(
+ field_name="device__name",
+ queryset=Device.objects.all(),
+ to_field_name="name",
+ label="Device Name",
+ )
+ feature_id = django_filters.ModelMultipleChoiceFilter(
+ field_name="feature__id",
+ queryset=models.ComplianceFeature.objects.all(),
+ to_field_name="id",
+ label="Feature ID",
+ )
+ feature = django_filters.ModelMultipleChoiceFilter(
+ field_name="feature__name",
+ queryset=models.ComplianceFeature.objects.all(),
+ to_field_name="name",
+ label="Feature Name",
+ )
+ job_result_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=JobResult.objects.filter(config_plan__isnull=False).distinct(),
+ label="JobResult ID",
+ )
+ change_control_id = django_filters.CharFilter(
+ field_name="change_control_id",
+ lookup_expr="exact",
+ )
+ status_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Status.objects.all(),
+ label="Status ID",
+ )
+ status = django_filters.ModelMultipleChoiceFilter(
+ field_name="status__name",
+ queryset=Status.objects.all(),
+ to_field_name="name",
+ label="Status",
+ )
+ tag = TagFilter()
+
+ def search(self, queryset, name, value): # pylint: disable=unused-argument
+ """Perform the filtered search."""
+ if not value.strip():
+ return queryset
+ qs_filter = Q(device__name__icontains=value) | Q(change_control_id__icontains=value)
+ return queryset.filter(qs_filter)
+
+ class Meta:
+ """Boilerplate filter Meta data for Config Plan."""
+
+ model = models.ConfigPlan
+ fields = ["id", "device", "created", "plan_type", "feature", "change_control_id", "status"]
diff --git a/nautobot_golden_config/forms.py b/nautobot_golden_config/forms.py
index 8539df8f..ca80d8b9 100644
--- a/nautobot_golden_config/forms.py
+++ b/nautobot_golden_config/forms.py
@@ -1,18 +1,18 @@
"""Forms for Device Configuration Backup."""
# pylint: disable=too-many-ancestors
-from django import forms
+import json
import nautobot.extras.forms as extras_forms
import nautobot.utilities.forms as utilities_forms
-from nautobot.dcim.models import Device, Platform, Region, Site, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup
-from nautobot.extras.models import Status, GitRepository, DynamicGroup
+from django import forms
+from nautobot.dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, Region, Site
+from nautobot.extras.forms import NautobotBulkEditForm, NautobotFilterForm, NautobotModelForm
+from nautobot.extras.models import DynamicGroup, GitRepository, JobResult, Status, Tag
from nautobot.tenancy.models import Tenant, TenantGroup
-from nautobot.utilities.forms import SlugField
-from nautobot.extras.forms import NautobotFilterForm, NautobotBulkEditForm, NautobotModelForm
-
-
+from nautobot.utilities.forms import add_blank_choice, DatePicker, SlugField, TagFilterField
from nautobot_golden_config import models
+from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice, ConfigPlanTypeChoice, RemediationTypeChoice
# ConfigCompliance
@@ -123,6 +123,7 @@ class Meta:
"config_type",
"match_config",
"custom_compliance",
+ "config_remediation",
)
@@ -145,6 +146,14 @@ class ComplianceRuleBulkEditForm(NautobotBulkEditForm):
"""BulkEdit form for ComplianceRule instances."""
pk = forms.ModelMultipleChoiceField(queryset=models.ComplianceRule.objects.all(), widget=forms.MultipleHiddenInput)
+ description = forms.CharField(max_length=200, required=False)
+ config_type = forms.ChoiceField(
+ required=False,
+ choices=utilities_forms.add_blank_choice(ComplianceRuleConfigTypeChoice),
+ )
+ config_ordered = forms.NullBooleanField(required=False, widget=utilities_forms.BulkEditNullBooleanSelect())
+ custom_compliance = forms.NullBooleanField(required=False, widget=utilities_forms.BulkEditNullBooleanSelect())
+ config_remediation = forms.NullBooleanField(required=False, widget=utilities_forms.BulkEditNullBooleanSelect())
class Meta:
"""Boilerplate form Meta data for ComplianceRule."""
@@ -191,6 +200,7 @@ class ComplianceFeatureBulkEditForm(NautobotBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=models.ComplianceFeature.objects.all(), widget=forms.MultipleHiddenInput
)
+ description = forms.CharField(max_length=200, required=False)
class Meta:
"""Boilerplate form Meta data for ComplianceFeature."""
@@ -244,6 +254,7 @@ class ConfigRemoveBulkEditForm(NautobotBulkEditForm):
"""BulkEdit form for ConfigRemove instances."""
pk = forms.ModelMultipleChoiceField(queryset=models.ConfigRemove.objects.all(), widget=forms.MultipleHiddenInput)
+ description = forms.CharField(max_length=200, required=False)
class Meta:
"""Boilerplate form Meta data for ConfigRemove."""
@@ -309,6 +320,7 @@ class ConfigReplaceBulkEditForm(NautobotBulkEditForm):
"""BulkEdit form for ConfigReplace instances."""
pk = forms.ModelMultipleChoiceField(queryset=models.ConfigReplace.objects.all(), widget=forms.MultipleHiddenInput)
+ description = forms.CharField(max_length=200, required=False)
class Meta:
"""Boilerplate form Meta data for ConfigReplace."""
@@ -390,3 +402,233 @@ class Meta:
"""Boilerplate form Meta data for GoldenConfigSetting."""
nullable_fields = []
+
+
+# Remediation Setting
+class RemediationSettingForm(NautobotModelForm):
+ """Create/Update Form for Remediation Settings instances."""
+
+ class Meta:
+ """Boilerplate form Meta data for Remediation Settings."""
+
+ model = models.RemediationSetting
+ fields = (
+ "platform",
+ "remediation_type",
+ "remediation_options",
+ )
+
+
+class RemediationSettingFilterForm(NautobotFilterForm):
+ """Filter Form for Remediation Settings."""
+
+ model = models.RemediationSetting
+ q = forms.CharField(required=False, label="Search")
+ platform = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=Platform.objects.all(), required=False, display_field="name", to_field_name="name"
+ )
+ remediation_type = forms.ChoiceField(
+ choices=add_blank_choice(RemediationTypeChoice), required=False, label="Remediation Type"
+ )
+
+
+class RemediationSettingCSVForm(extras_forms.CustomFieldModelCSVForm):
+ """CSV Form for RemediationSetting instances."""
+
+ class Meta:
+ """Boilerplate form Meta data for RemediationSetting."""
+
+ model = models.RemediationSetting
+ fields = models.RemediationSetting.csv_headers
+
+
+class RemediationSettingBulkEditForm(NautobotBulkEditForm):
+ """BulkEdit form for RemediationSetting instances."""
+
+ pk = forms.ModelMultipleChoiceField(
+ queryset=models.RemediationSetting.objects.all(), widget=forms.MultipleHiddenInput
+ )
+ remediation_type = forms.ChoiceField(choices=RemediationTypeChoice, label="Remediation Type")
+
+ class Meta:
+ """Boilerplate form Meta data for RemediationSetting."""
+
+ nullable_fields = []
+
+
+# ConfigPlan
+
+
+class ConfigPlanForm(NautobotModelForm):
+ """Form for ConfigPlan instances."""
+
+ plan_type = forms.ChoiceField(choices=add_blank_choice(ConfigPlanTypeChoice), required=True, label="Plan Type")
+ change_control_id = forms.CharField(required=False, label="Change Control ID")
+ change_control_url = forms.URLField(required=False, label="Change Control URL")
+
+ feature = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=models.ComplianceFeature.objects.all(),
+ display_field="name",
+ required=False,
+ help_text="Note: Selecting no features will generate plans for all applicable features.",
+ )
+ commands = forms.CharField(
+ widget=forms.Textarea,
+ help_text=(
+ "Enter your configuration template here representing CLI configuration.
"
+ 'You may use Jinja2 templating. Example: {% if "foo" in bar %}foo{% endif %}
'
+ "You can also reference the device object with obj
.
"
+ "For example: hostname {{ obj.name }}
or ip address {{ obj.primary_ip4.host }}
"
+ ),
+ required=True,
+ )
+
+ tenant_group = utilities_forms.DynamicModelMultipleChoiceField(queryset=TenantGroup.objects.all(), required=False)
+ tenant = utilities_forms.DynamicModelMultipleChoiceField(queryset=Tenant.objects.all(), required=False)
+ # Requires https://github.com/nautobot/nautobot-plugin-golden-config/issues/430
+ # location = utilities_forms.DynamicModelMultipleChoiceField(queryset=Location.objects.all(), required=False)
+ region = utilities_forms.DynamicModelMultipleChoiceField(queryset=Region.objects.all(), required=False)
+ site = utilities_forms.DynamicModelMultipleChoiceField(queryset=Site.objects.all(), required=False)
+ rack_group = utilities_forms.DynamicModelMultipleChoiceField(queryset=RackGroup.objects.all(), required=False)
+ rack = utilities_forms.DynamicModelMultipleChoiceField(queryset=Rack.objects.all(), required=False)
+ role = utilities_forms.DynamicModelMultipleChoiceField(queryset=DeviceRole.objects.all(), required=False)
+ manufacturer = utilities_forms.DynamicModelMultipleChoiceField(queryset=Manufacturer.objects.all(), required=False)
+ platform = utilities_forms.DynamicModelMultipleChoiceField(queryset=Platform.objects.all(), required=False)
+ device_type = utilities_forms.DynamicModelMultipleChoiceField(queryset=DeviceType.objects.all(), required=False)
+ device = utilities_forms.DynamicModelMultipleChoiceField(queryset=Device.objects.all(), required=False)
+ tag = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(), query_params={"content_types": "dcim.device"}, required=False
+ )
+ status = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=Status.objects.all(), query_params={"content_types": "dcim.device"}, required=False
+ )
+
+ def __init__(self, *args, **kwargs):
+ """Method to get data from Python -> Django template -> JS in support of toggle form fields."""
+ super().__init__(*args, **kwargs)
+ hide_form_data = [
+ {
+ "event_field": "id_plan_type",
+ "values": [
+ {"name": "manual", "show": ["id_commands"], "hide": ["id_feature"]},
+ {"name": "missing", "show": ["id_feature"], "hide": ["id_commands"]},
+ {"name": "intended", "show": ["id_feature"], "hide": ["id_commands"]},
+ {"name": "remediation", "show": ["id_feature"], "hide": ["id_commands"]},
+ {"name": "", "show": [], "hide": ["id_commands", "id_feature"]},
+ ],
+ }
+ ]
+ # Example of how to use this `JSON.parse('{{ form.hide_form_data|safe }}')`
+ self.hide_form_data = json.dumps(hide_form_data)
+
+ class Meta:
+ """Boilerplate form Meta data for ConfigPlan."""
+
+ model = models.ConfigPlan
+ fields = (
+ "plan_type",
+ "change_control_id",
+ "feature",
+ "commands",
+ "tenant",
+ # "location", Requires https://github.com/nautobot/nautobot-plugin-golden-config/issues/430
+ "region",
+ "site",
+ "rack_group",
+ "rack",
+ "role",
+ "manufacturer",
+ "platform",
+ "device_type",
+ "device",
+ "tag",
+ "status",
+ )
+
+
+class ConfigPlanUpdateForm(NautobotModelForm):
+ """Form for ConfigPlan instances."""
+
+ change_control_id = forms.CharField(required=False, label="Change Control ID")
+ change_control_url = forms.URLField(required=False, label="Change Control URL")
+ status = utilities_forms.DynamicModelChoiceField(
+ queryset=Status.objects.all(),
+ query_params={"content_types": models.ConfigPlan._meta.label_lower},
+ required=False,
+ )
+ tag = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(), query_params={"content_types": "dcim.device"}, required=False
+ )
+
+ class Meta:
+ """Boilerplate form Meta data for ConfigPlan."""
+
+ model = models.ConfigPlan
+ fields = (
+ "change_control_id",
+ "change_control_url",
+ "tag",
+ "status",
+ )
+
+
+class ConfigPlanFilterForm(NautobotFilterForm):
+ """Filter Form for ConfigPlan."""
+
+ model = models.ConfigPlan
+
+ q = forms.CharField(required=False, label="Search")
+ device_id = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=Device.objects.all(), required=False, null_option="None", label="Device"
+ )
+ created__lte = forms.DateTimeField(label="Created Before", required=False, widget=DatePicker())
+ created__gte = forms.DateTimeField(label="Created After", required=False, widget=DatePicker())
+ plan_type = forms.ChoiceField(
+ choices=add_blank_choice(ConfigPlanTypeChoice), required=False, widget=forms.Select(), label="Plan Type"
+ )
+ feature = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=models.ComplianceFeature.objects.all(),
+ required=False,
+ null_option="None",
+ label="Feature",
+ to_field_name="name",
+ )
+ change_control_id = forms.CharField(required=False, label="Change Control ID")
+ job_result_id = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=JobResult.objects.all(),
+ query_params={"nautobot_golden_config_config_plan_null": True},
+ label="Job Result",
+ required=False,
+ display_field="id",
+ )
+ status = utilities_forms.DynamicModelMultipleChoiceField(
+ required=False,
+ queryset=Status.objects.all(),
+ query_params={"content_types": models.ConfigPlan._meta.label_lower},
+ display_field="label",
+ label="Status",
+ to_field_name="name",
+ )
+ tag = TagFilterField(model)
+
+
+class ConfigPlanBulkEditForm(extras_forms.TagsBulkEditFormMixin, NautobotBulkEditForm):
+ """BulkEdit form for ConfigPlan instances."""
+
+ pk = forms.ModelMultipleChoiceField(queryset=models.ConfigPlan.objects.all(), widget=forms.MultipleHiddenInput)
+ status = utilities_forms.DynamicModelChoiceField(
+ queryset=Status.objects.all(),
+ query_params={"content_types": models.ConfigPlan._meta.label_lower},
+ required=False,
+ )
+ change_control_id = forms.CharField(required=False, label="Change Control ID")
+ change_control_url = forms.URLField(required=False, label="Change Control URL")
+
+ class Meta:
+ """Boilerplate form Meta data for ConfigPlan."""
+
+ nullable_fields = [
+ "change_control_id",
+ "change_control_url",
+ "tags",
+ ]
diff --git a/nautobot_golden_config/jobs.py b/nautobot_golden_config/jobs.py
index c114a1ac..f7f83783 100644
--- a/nautobot_golden_config/jobs.py
+++ b/nautobot_golden_config/jobs.py
@@ -5,13 +5,36 @@
from nautobot.dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, Region, Site
from nautobot.extras.datasources.git import ensure_git_repository
-from nautobot.extras.jobs import BooleanVar, Job, MultiObjectVar, ObjectVar
+from nautobot.extras.jobs import (
+ BooleanVar,
+ ChoiceVar,
+ Job,
+ JobButtonReceiver,
+ MultiObjectVar,
+ ObjectVar,
+ StringVar,
+ TextVar,
+)
from nautobot.extras.models import DynamicGroup, GitRepository, Status, Tag
from nautobot.tenancy.models import Tenant, TenantGroup
+from nornir_nautobot.exceptions import NornirNautobotException
+
+from nautobot_golden_config.choices import ConfigPlanTypeChoice
+from nautobot_golden_config.models import ComplianceFeature, ConfigPlan
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
from nautobot_golden_config.nornir_plays.config_intended import config_intended
-from nautobot_golden_config.utilities.constant import ENABLE_BACKUP, ENABLE_COMPLIANCE, ENABLE_INTENDED
+from nautobot_golden_config.utilities.config_plan import (
+ config_plan_default_status,
+ generate_config_set_from_compliance_feature,
+ generate_config_set_from_manual,
+)
+from nautobot_golden_config.utilities.constant import (
+ ENABLE_BACKUP,
+ ENABLE_COMPLIANCE,
+ ENABLE_INTENDED,
+)
from nautobot_golden_config.utilities.git import GitRepo
from nautobot_golden_config.utilities.helper import get_job_filter
@@ -268,6 +291,177 @@ def run(self, data, commit):
ComplianceJob().run.__func__(self, data, True) # pylint: disable=too-many-function-args
+class GenerateConfigPlans(Job, FormEntry):
+ """Job to generate config plans."""
+
+ # Device QS Filters
+ tenant_group = FormEntry.tenant_group
+ tenant = FormEntry.tenant
+ region = FormEntry.region
+ site = FormEntry.site
+ rack_group = FormEntry.rack_group
+ rack = FormEntry.rack
+ role = FormEntry.role
+ manufacturer = FormEntry.manufacturer
+ platform = FormEntry.platform
+ device_type = FormEntry.device_type
+ device = FormEntry.device
+ tag = FormEntry.tag
+ status = FormEntry.status
+ debug = FormEntry.debug
+
+ # Config Plan generation fields
+ plan_type = ChoiceVar(choices=ConfigPlanTypeChoice.CHOICES)
+ feature = MultiObjectVar(model=ComplianceFeature, required=False)
+ change_control_id = StringVar(required=False)
+ change_control_url = StringVar(required=False)
+ commands = TextVar(required=False)
+
+ class Meta:
+ """Meta object boilerplate for config plan generation."""
+
+ name = "Generate Config Plans"
+ description = "Generate config plans for devices."
+ # Defaulting to hidden as this should be primarily called by the View
+ hidden = True
+
+ def __init__(self, *args, **kwargs):
+ """Initialize the job."""
+ super().__init__(*args, **kwargs)
+ self._plan_type = None
+ self._feature = None
+ self._change_control_id = None
+ self._change_control_url = None
+ self._commands = None
+ self._device_qs = Device.objects.none()
+ self._status = config_plan_default_status()
+
+ def _validate_inputs(self, data):
+ self._plan_type = data["plan_type"]
+ self._feature = data.get("feature", [])
+ 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._plan_type in ["intended", "missing", "remediation"]:
+ if not self._feature:
+ self._feature = ComplianceFeature.objects.all()
+ if self._plan_type in ["manual"]:
+ if not self._commands:
+ self.log_failure("No commands entered for config plan generation.")
+ return False
+ return True
+
+ def _generate_config_plan_from_feature(self):
+ """Generate config plans from features."""
+ for device in self._device_qs:
+ config_sets = []
+ features = []
+ for feature in self._feature:
+ config_set = generate_config_set_from_compliance_feature(device, self._plan_type, feature)
+ if not config_set:
+ continue
+ config_sets.append(config_set)
+ features.append(feature)
+
+ if not config_sets:
+ _features = ", ".join([str(feat) for feat in self._feature])
+ self.log_debug(f"Device `{device}` does not have `{self._plan_type}` configs for `{_features}`.")
+ continue
+ config_plan = ConfigPlan.objects.create(
+ device=device,
+ plan_type=self._plan_type,
+ config_set="\n".join(config_sets),
+ change_control_id=self._change_control_id,
+ change_control_url=self._change_control_url,
+ status=self._status,
+ job_result=self.job_result,
+ )
+ config_plan.feature.set(features)
+ config_plan.validated_save()
+ _features = ", ".join([str(feat) for feat in features])
+ self.log_success(obj=config_plan, message=f"Config plan created for `{device}` with feature `{_features}`.")
+
+ def _generate_config_plan_from_manual(self):
+ """Generate config plans from manual."""
+ default_context = {
+ "request": self.request,
+ "user": self.request.user,
+ }
+ for device in self._device_qs:
+ config_set = generate_config_set_from_manual(device, self._commands, context=default_context)
+ if not config_set:
+ self.log_debug(f"Device {self.device} did not return a rendered config set from the provided commands.")
+ continue
+ config_plan = ConfigPlan.objects.create(
+ device=device,
+ plan_type=self._plan_type,
+ config_set=config_set,
+ change_control_id=self._change_control_id,
+ change_control_url=self._change_control_url,
+ status=self._status,
+ job_result=self.job_result,
+ )
+ self.log_success(obj=config_plan, message=f"Config plan created for {device} with manual commands.")
+
+ def run(self, data, commit):
+ """Run config plan generation process."""
+ self.log_debug("Starting config plan generation job.")
+ if not self._validate_inputs(data):
+ return
+ try:
+ self._device_qs = get_job_filter(data)
+ except NornirNautobotException as exc:
+ self.log_failure(str(exc))
+ return
+ if self._plan_type in ["intended", "missing", "remediation"]:
+ self.log_debug("Starting config plan generation for compliance features.")
+ self._generate_config_plan_from_feature()
+ elif self._plan_type in ["manual"]:
+ self.log_debug("Starting config plan generation for manual commands.")
+ self._generate_config_plan_from_manual()
+ else:
+ self.log_failure(f"Unknown config plan type {self._plan_type}.")
+ return
+
+
+class DeployConfigPlans(Job):
+ """Job to deploy config plans."""
+
+ config_plan = MultiObjectVar(model=ConfigPlan, required=True)
+ debug = BooleanVar(description="Enable for more verbose debug logging")
+
+ class Meta:
+ """Meta object boilerplate for config plan deployment."""
+
+ name = "Deploy Config Plans"
+ description = "Deploy config plans to devices."
+
+ def run(self, data, commit):
+ """Run config plan deployment process."""
+ self.log_debug("Starting config plan deployment job.")
+ config_deployment(self, data, commit)
+ if commit and not self.failed:
+ config_plan_qs = data["config_plan"]
+ config_plan_qs.delete()
+
+
+class DeployConfigPlanJobButtonReceiver(JobButtonReceiver):
+ """Job button to deploy a config plan."""
+
+ class Meta:
+ """Meta object boilerplate for config plan deployment job button."""
+
+ name = "Deploy Config Plan (Job Button Receiver)"
+
+ def receive_job_button(self, obj):
+ """Run config plan deployment process."""
+ self.log_debug("Starting config plan deployment job.")
+ data = {"debug": False, "config_plan": ConfigPlan.objects.filter(id=obj.id)}
+ config_deployment(self, data, commit=True)
+ if not self.failed:
+ obj.delete()
+
+
# Conditionally allow jobs based on whether or not turned on.
jobs = []
if ENABLE_BACKUP:
@@ -276,4 +470,12 @@ def run(self, data, commit):
jobs.append(IntendedJob)
if ENABLE_COMPLIANCE:
jobs.append(ComplianceJob)
-jobs.extend([AllGoldenConfig, AllDevicesGoldenConfig])
+jobs.extend(
+ [
+ AllGoldenConfig,
+ AllDevicesGoldenConfig,
+ GenerateConfigPlans,
+ DeployConfigPlans,
+ DeployConfigPlanJobButtonReceiver,
+ ]
+)
diff --git a/nautobot_golden_config/migrations/0025_remediation_settings.py b/nautobot_golden_config/migrations/0025_remediation_settings.py
new file mode 100644
index 00000000..a2b77cb3
--- /dev/null
+++ b/nautobot_golden_config/migrations/0025_remediation_settings.py
@@ -0,0 +1,65 @@
+# Generated by Django 3.2.16 on 2023-07-07 09:21
+
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import nautobot.extras.models.mixins
+import taggit.managers
+import uuid
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("dcim", "0019_device_redundancy_group_data_migration"),
+ ("extras", "0053_relationship_required_on"),
+ ("nautobot_golden_config", "0024_convert_custom_compliance_rules"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="configcompliance",
+ name="remediation",
+ field=models.JSONField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="compliancerule",
+ name="config_remediation",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.CreateModel(
+ name="RemediationSetting",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True
+ ),
+ ),
+ ("created", models.DateField(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),
+ ),
+ ("remediation_type", models.CharField(default="hierconfig", max_length=50)),
+ ("remediation_options", models.JSONField(blank=True, default=dict)),
+ (
+ "platform",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="remediation_settings",
+ to="dcim.platform",
+ ),
+ ),
+ ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=(
+ models.Model,
+ nautobot.extras.models.mixins.DynamicGroupMixin,
+ nautobot.extras.models.mixins.NotesMixin,
+ ),
+ ),
+ ]
diff --git a/nautobot_golden_config/migrations/0026_configplan.py b/nautobot_golden_config/migrations/0026_configplan.py
new file mode 100644
index 00000000..2c93462d
--- /dev/null
+++ b/nautobot_golden_config/migrations/0026_configplan.py
@@ -0,0 +1,74 @@
+# Generated by Django 3.2.20 on 2023-09-01 14:51
+
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import nautobot.extras.models.mixins
+import nautobot.extras.models.statuses
+import taggit.managers
+import uuid
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("dcim", "0023_interface_redundancy_group_data_migration"),
+ ("extras", "0058_jobresult_add_time_status_idxs"),
+ ("nautobot_golden_config", "0025_remediation_settings"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ConfigPlan",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True
+ ),
+ ),
+ ("created", models.DateField(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),
+ ),
+ ("plan_type", models.CharField(max_length=20)),
+ ("config_set", models.TextField()),
+ ("change_control_id", models.CharField(blank=True, max_length=50, null=True)),
+ ("change_control_url", models.URLField(blank=True)),
+ (
+ "device",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, related_name="config_plan", to="dcim.device"
+ ),
+ ),
+ (
+ "feature",
+ models.ManyToManyField(
+ blank=True, related_name="config_plan", to="nautobot_golden_config.ComplianceFeature"
+ ),
+ ),
+ (
+ "job_result",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, related_name="config_plan", to="extras.jobresult"
+ ),
+ ),
+ (
+ "status",
+ nautobot.extras.models.statuses.StatusField(
+ blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="extras.status"
+ ),
+ ),
+ ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")),
+ ],
+ options={
+ "ordering": ("-created", "device"),
+ },
+ bases=(
+ models.Model,
+ nautobot.extras.models.mixins.DynamicGroupMixin,
+ nautobot.extras.models.mixins.NotesMixin,
+ ),
+ ),
+ ]
diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py
index 5bcaf717..47459001 100644
--- a/nautobot_golden_config/models.py
+++ b/nautobot_golden_config/models.py
@@ -10,16 +10,18 @@
from django.shortcuts import reverse
from django.utils.module_loading import import_string
from django.utils.text import slugify
+from hier_config import Host as HierConfigHost
from nautobot.core.models.generics import PrimaryModel
from nautobot.extras.models import DynamicGroup, ObjectChange
+from nautobot.extras.models.statuses import StatusField
from nautobot.extras.utils import extras_features
from nautobot.utilities.utils import serialize_object, serialize_object_v2
from netutils.config.compliance import feature_compliance
-from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice
+from netutils.lib_mapper import HIERCONFIG_LIB_MAPPER_REVERSE
+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.utils import get_platform
-
LOGGER = logging.getLogger(__name__)
GRAPHQL_STR_START = "query ($device_id: ID!)"
@@ -35,6 +37,11 @@
ERROR_MSG + "Specifically the key {} was expected to be of type(s) {} and the value of {} was not that type(s)."
)
+CUSTOM_FUNCTIONS = {
+ "get_custom_compliance": "custom",
+ "get_custom_remediation": RemediationTypeChoice.TYPE_CUSTOM,
+}
+
def _is_jsonable(val):
"""Check is value can be converted to json."""
@@ -129,22 +136,56 @@ def _verify_get_custom_compliance_data(compliance_details):
raise ValidationError(VALIDATION_MSG.format(val, "String or Json", compliance_details[val]))
+def _get_hierconfig_remediation(obj):
+ """Returns the remediating config."""
+ hierconfig_os = HIERCONFIG_LIB_MAPPER_REVERSE.get(obj.device.platform.slug)
+ if not hierconfig_os:
+ raise ValidationError(f"platform {obj.device.platform.slug} is not supported by hierconfig.")
+
+ try:
+ remediation_setting_obj = RemediationSetting.objects.get(platform=obj.rule.platform)
+ except Exception as err: # pylint: disable=broad-except:
+ raise ValidationError(f"Platform {obj.device.platform.slug} has no Remediation Settings defined.") from err
+
+ remediation_options = remediation_setting_obj.remediation_options
+
+ try:
+ hc_kwargs = {"hostname": obj.device.name, "os": hierconfig_os}
+ if remediation_options:
+ hc_kwargs.update(hconfig_options=remediation_options)
+ host = HierConfigHost(**hc_kwargs)
+
+ except Exception as err: # pylint: disable=broad-except:
+ raise Exception( # pylint: disable=broad-exception-raised
+ f"Cannot instantiate HierConfig on {obj.device.name}, check Device, Platform and Hier Options."
+ ) from err
+
+ host.load_generated_config(obj.intended)
+ host.load_running_config(obj.actual)
+ host.remediation_config()
+ remediation_config = host.remediation_config_filtered_text(include_tags={}, exclude_tags={})
+
+ return remediation_config
+
+
# The below maps the provided compliance types
FUNC_MAPPER = {
ComplianceRuleConfigTypeChoice.TYPE_CLI: _get_cli_compliance,
ComplianceRuleConfigTypeChoice.TYPE_JSON: _get_json_compliance,
+ RemediationTypeChoice.TYPE_HIERCONFIG: _get_hierconfig_remediation,
}
# The below conditionally add the custom provided compliance type
-if PLUGIN_CFG.get("get_custom_compliance"):
- try:
- FUNC_MAPPER["custom"] = import_string(PLUGIN_CFG["get_custom_compliance"])
- except Exception as error: # pylint: disable=broad-except
- msg = (
- "There was an issue attempting to import the get_custom_compliance function of"
- f"{PLUGIN_CFG['get_custom_compliance']}, this is expected with a local configuration issue "
- "and not related to the Golden Configuration Plugin, please contact your system admin for further details"
- )
- raise Exception(msg).with_traceback(error.__traceback__)
+for custom_function, custom_type in CUSTOM_FUNCTIONS.items():
+ if PLUGIN_CFG.get(custom_function):
+ try:
+ FUNC_MAPPER[custom_type] = import_string(PLUGIN_CFG[custom_function])
+ except Exception as error: # pylint: disable=broad-except
+ msg = (
+ "There was an issue attempting to import the custom function of"
+ f"{PLUGIN_CFG[custom_function]}, this is expected with a local configuration issue "
+ "and not related to the Golden Configuration Plugin, please contact your system admin for further details"
+ )
+ raise Exception(msg).with_traceback(error.__traceback__)
@extras_features(
@@ -212,6 +253,15 @@ class ComplianceRule(PrimaryModel): # pylint: disable=too-many-ancestors
verbose_name="Configured Ordered",
help_text="Whether or not the configuration order matters, such as in ACLs.",
)
+
+ config_remediation = models.BooleanField(
+ default=False,
+ null=False,
+ blank=False,
+ verbose_name="Config Remediation",
+ help_text="Whether or not the config remediation is executed for this compliance rule.",
+ )
+
match_config = models.TextField(
null=True,
blank=True,
@@ -237,8 +287,14 @@ class ComplianceRule(PrimaryModel): # pylint: disable=too-many-ancestors
"match_config",
"config_type",
"custom_compliance",
+ "config_remediation",
]
+ @property
+ def remediation_setting(self):
+ """Returns remediation settings for a particular platform."""
+ return RemediationSetting.objects.filter(platform=self.platform).first()
+
def to_csv(self):
"""Indicates model fields to return as csv."""
return (
@@ -249,6 +305,7 @@ def to_csv(self):
self.match_config,
self.config_type,
self.custom_compliance,
+ self.config_remediation,
)
class Meta:
@@ -291,6 +348,8 @@ class ConfigCompliance(PrimaryModel): # pylint: disable=too-many-ancestors
compliance = models.BooleanField(null=True, blank=True)
actual = models.JSONField(blank=True, help_text="Actual Configuration for feature")
intended = models.JSONField(blank=True, help_text="Intended Configuration for feature")
+ # these three are config snippets exposed for the ConfigDeployment.
+ remediation = models.JSONField(blank=True, null=True, help_text="Remediation Configuration for the device")
missing = models.JSONField(blank=True, help_text="Configuration that should be on the device.")
extra = models.JSONField(blank=True, help_text="Configuration that should not be on the device.")
ordered = models.BooleanField(default=True)
@@ -332,7 +391,7 @@ def __str__(self):
"""String representation of a the compliance."""
return f"{self.device} -> {self.rule} -> {self.compliance}"
- def save(self, *args, **kwargs):
+ def compliance_on_save(self):
"""The actual configuration compliance happens here, but the details for actual compliance job would be found in FUNC_MAPPER."""
if self.rule.custom_compliance:
if not FUNC_MAPPER.get("custom"):
@@ -350,6 +409,28 @@ def save(self, *args, **kwargs):
self.missing = compliance_details["missing"]
self.extra = compliance_details["extra"]
+ def remediation_on_save(self):
+ """The actual remediation happens here, before saving the object."""
+ if self.compliance:
+ self.remediation = None
+ return
+
+ if not self.rule.config_remediation:
+ self.remediation = None
+ return
+
+ if not self.rule.remediation_setting:
+ self.remediation = None
+ return
+
+ remediation_config = FUNC_MAPPER[self.rule.remediation_setting.remediation_type](obj=self)
+ self.remediation = remediation_config
+
+ def save(self, *args, **kwargs):
+ """The actual configuration compliance happens here, but the details for actual compliance job would be found in FUNC_MAPPER."""
+ self.compliance_on_save()
+ self.remediation_on_save()
+
super().save(*args, **kwargs)
@@ -700,3 +781,115 @@ def get_absolute_url(self):
def __str__(self):
"""Return a simple string if model is called."""
return self.name
+
+
+@extras_features(
+ "graphql",
+)
+class RemediationSetting(PrimaryModel): # pylint: disable=too-many-ancestors
+ """RemediationSetting details."""
+
+ # Remediation points to the platform
+ platform = models.OneToOneField(
+ to="dcim.Platform",
+ on_delete=models.CASCADE,
+ related_name="remediation_settings",
+ null=False,
+ blank=False,
+ )
+
+ remediation_type = models.CharField(
+ max_length=50,
+ default=RemediationTypeChoice.TYPE_HIERCONFIG,
+ choices=RemediationTypeChoice,
+ help_text="Whether the remediation setting is type HierConfig or custom.",
+ )
+
+ # takes options.json.
+ remediation_options = models.JSONField(
+ blank=True,
+ default=dict,
+ help_text="Remediation Configuration for the device",
+ )
+
+ csv_headers = [
+ "platform",
+ "remediation_type",
+ ]
+
+ class Meta:
+ """Meta information for RemediationSettings model."""
+
+ ordering = ("platform", "remediation_type")
+
+ def to_csv(self):
+ """Indicates model fields to return as csv."""
+ return (
+ self.platform,
+ self.remediation_type,
+ )
+
+ def __str__(self):
+ """Return a sane string representation of the instance."""
+ return str(self.platform.slug)
+
+ def get_absolute_url(self):
+ """Absolute url for the RemediationRule instance."""
+ return reverse("plugins:nautobot_golden_config:remediationsetting", args=[self.pk])
+
+
+@extras_features(
+ "custom_fields",
+ "custom_links",
+ "custom_validators",
+ "export_templates",
+ "graphql",
+ "relationships",
+ "webhooks",
+ "statuses",
+)
+class ConfigPlan(PrimaryModel): # pylint: disable=too-many-ancestors
+ """ConfigPlan for Golden Configuration Plan Model definition."""
+
+ plan_type = models.CharField(max_length=20, choices=ConfigPlanTypeChoice, verbose_name="Plan Type")
+ device = models.ForeignKey(
+ to="dcim.Device",
+ on_delete=models.CASCADE,
+ related_name="config_plan",
+ )
+ config_set = models.TextField(help_text="Configuration set to be applied to device.")
+ feature = models.ManyToManyField(
+ to=ComplianceFeature,
+ related_name="config_plan",
+ blank=True,
+ )
+ job_result = models.ForeignKey(
+ to="extras.JobResult",
+ on_delete=models.CASCADE,
+ related_name="config_plan",
+ null=False,
+ blank=False,
+ verbose_name="Job Result",
+ )
+ change_control_id = models.CharField(
+ max_length=50,
+ blank=True,
+ null=True,
+ verbose_name="Change Control ID",
+ help_text="Change Control ID for this configuration plan.",
+ )
+ change_control_url = models.URLField(blank=True, verbose_name="Change Control URL")
+ status = StatusField(blank=True, null=True, on_delete=models.PROTECT)
+
+ class Meta:
+ """Meta information for ConfigPlan model."""
+
+ ordering = ("-created", "device")
+
+ def __str__(self):
+ """Return a simple string if model is called."""
+ return f"{self.device.name}-{self.plan_type}-{self.created}"
+
+ def get_absolute_url(self):
+ """Return absolute URL for instance."""
+ return reverse("plugins:nautobot_golden_config:configplan", args=[self.pk])
diff --git a/nautobot_golden_config/navigation.py b/nautobot_golden_config/navigation.py
index 15796086..e20e4c3d 100644
--- a/nautobot_golden_config/navigation.py
+++ b/nautobot_golden_config/navigation.py
@@ -4,30 +4,27 @@
from nautobot.utilities.choices import ButtonColorChoices
from nautobot_golden_config.utilities.constant import ENABLE_COMPLIANCE, ENABLE_BACKUP
-items = [
+items_operate = [
NavMenuItem(
link="plugins:nautobot_golden_config:goldenconfig_list",
- name="Home",
+ name="Config Overview",
permissions=["nautobot_golden_config.view_goldenconfig"],
)
]
+items_setup = []
+
if ENABLE_COMPLIANCE:
- items.append(
+ items_operate.append(
NavMenuItem(
link="plugins:nautobot_golden_config:configcompliance_list",
- name="Configuration Compliance",
+ name="Config Compliance",
permissions=["nautobot_golden_config.view_configcompliance"],
)
)
- items.append(
- NavMenuItem(
- link="plugins:nautobot_golden_config:configcompliance_report",
- name="Compliance Report",
- permissions=["nautobot_golden_config.view_configcompliance"],
- )
- )
- items.append(
+
+if ENABLE_COMPLIANCE:
+ items_setup.append(
NavMenuItem(
link="plugins:nautobot_golden_config:compliancerule_list",
name="Compliance Rules",
@@ -43,7 +40,9 @@
),
)
)
- items.append(
+
+if ENABLE_COMPLIANCE:
+ items_setup.append(
NavMenuItem(
link="plugins:nautobot_golden_config:compliancefeature_list",
name="Compliance Features",
@@ -60,8 +59,35 @@
)
)
+
+if ENABLE_COMPLIANCE:
+ items_operate.append(
+ NavMenuItem(
+ link="plugins:nautobot_golden_config:configcompliance_report",
+ name="Compliance Report",
+ permissions=["nautobot_golden_config.view_configcompliance"],
+ )
+ )
+
+items_operate.append(
+ NavMenuItem(
+ link="plugins:nautobot_golden_config:configplan_list",
+ name="Config Plans",
+ permissions=["nautobot_golden_config.view_configplan"],
+ buttons=(
+ NavMenuButton(
+ link="plugins:nautobot_golden_config:configplan_add",
+ title="Generate Config Plan",
+ icon_class="mdi mdi-plus-thick",
+ button_class=ButtonColorChoices.GREEN,
+ permissions=["nautobot_golden_config.add_configplan"],
+ ),
+ ),
+ )
+)
+
if ENABLE_BACKUP:
- items.append(
+ items_setup.append(
NavMenuItem(
link="plugins:nautobot_golden_config:configremove_list",
name="Config Removals",
@@ -77,7 +103,9 @@
),
)
)
- items.append(
+
+if ENABLE_BACKUP:
+ items_setup.append(
NavMenuItem(
link="plugins:nautobot_golden_config:configreplace_list",
name="Config Replacements",
@@ -95,10 +123,28 @@
)
-items.append(
+if ENABLE_COMPLIANCE:
+ items_setup.append(
+ NavMenuItem(
+ link="plugins:nautobot_golden_config:remediationsetting_list",
+ name="Remediation Settings",
+ permissions=["nautobot_golden_config.view_remediationsetting"],
+ buttons=(
+ NavMenuButton(
+ link="plugins:nautobot_golden_config:remediationsetting_add",
+ title="Remediation Settings",
+ icon_class="mdi mdi-plus-thick",
+ button_class=ButtonColorChoices.GREEN,
+ permissions=["nautobot_golden_config.add_remediationsetting"],
+ ),
+ ),
+ )
+ )
+
+items_setup.append(
NavMenuItem(
link="plugins:nautobot_golden_config:goldenconfigsetting_list",
- name="Settings",
+ name="Golden Config Settings",
permissions=["nautobot_golden_config.view_goldenconfigsetting"],
buttons=(
NavMenuButton(
@@ -117,6 +163,9 @@
NavMenuTab(
name="Golden Config",
weight=1000,
- groups=(NavMenuGroup(name="Golden Config", weight=100, items=tuple(items)),),
+ groups=(
+ NavMenuGroup(name="Manage", weight=100, items=tuple(items_operate)),
+ (NavMenuGroup(name="Setup", weight=100, items=tuple(items_setup))),
+ ),
),
)
diff --git a/nautobot_golden_config/nornir_plays/config_deployment.py b/nautobot_golden_config/nornir_plays/config_deployment.py
new file mode 100644
index 00000000..1862f7ea
--- /dev/null
+++ b/nautobot_golden_config/nornir_plays/config_deployment.py
@@ -0,0 +1,87 @@
+"""Nornir job for deploying configurations."""
+from datetime import datetime
+from nautobot.dcim.models import Device
+from nautobot_plugin_nornir.plugins.inventory.nautobot_orm import NautobotORMInventory
+from nautobot_plugin_nornir.constants import NORNIR_SETTINGS
+from nautobot_plugin_nornir.utils import get_dispatcher
+from nornir import InitNornir
+from nornir.core.task import Result, Task
+from nornir.core.plugins.inventory import InventoryPluginRegister
+from nornir_nautobot.plugins.tasks.dispatcher import dispatcher
+from nornir_nautobot.utils.logger import NornirLogger
+
+from nautobot_golden_config.nornir_plays.processor import ProcessGoldenConfig
+
+InventoryPluginRegister.register("nautobot-inventory", NautobotORMInventory)
+
+
+def run_deployment(task: Task, logger: NornirLogger, commit: bool, config_plan_qs) -> Result:
+ """Deploy configurations to device."""
+ obj = task.host.data["obj"]
+ plans_to_deploy = config_plan_qs.filter(device=obj)
+ consolidated_config_set = "\n".join(plans_to_deploy.values_list("config_set", flat=True))
+ logger.log_debug(f"Consolidated config set: {consolidated_config_set}")
+ # TODO: We should add post-processing rendering here
+ # after https://github.com/nautobot/nautobot-plugin-golden-config/issues/443
+
+ if commit:
+ result = task.run(
+ task=dispatcher,
+ name="DEPLOY CONFIG TO DEVICE",
+ method="merge_config",
+ obj=obj,
+ logger=logger,
+ config=consolidated_config_set,
+ default_drivers_mapping=get_dispatcher(),
+ )[1].result["result"]
+ logger.log_success(obj=obj, message="Successfully deployed configuration to device.")
+ else:
+ result = None
+ logger.log_info(obj=obj, message="Commit not enabled. Configuration not deployed to device.")
+
+ return Result(host=task.host, result=result)
+
+
+def config_deployment(job_result, data, commit):
+ """Nornir play to deploy configurations."""
+ now = datetime.now()
+ logger = NornirLogger(__name__, job_result, data.get("debug"))
+ logger.log_debug("Starting config deployment")
+
+ config_plan_qs = data["config_plan"]
+ if config_plan_qs.filter(status__slug="not-approved").exists():
+ logger.log_failure(
+ obj=None,
+ message="Cannot deploy configuration(s). One or more config plans are not approved.",
+ )
+ raise ValueError("Cannot deploy configuration(s). One or more config plans are not approved.")
+ device_qs = Device.objects.filter(config_plan__in=config_plan_qs).distinct()
+
+ try:
+ with InitNornir(
+ runner=NORNIR_SETTINGS.get("runner"),
+ logging={"enabled": False},
+ inventory={
+ "plugin": "nautobot-inventory",
+ "options": {
+ "credentials_class": NORNIR_SETTINGS.get("credentials"),
+ "params": NORNIR_SETTINGS.get("inventory_params"),
+ "queryset": device_qs,
+ "defaults": {"now": now},
+ },
+ },
+ ) as nornir_obj:
+ nr_with_processors = nornir_obj.with_processors([ProcessGoldenConfig(logger)])
+
+ nr_with_processors.run(
+ task=run_deployment,
+ name="DEPLOY CONFIG",
+ logger=logger,
+ commit=commit,
+ config_plan_qs=config_plan_qs,
+ )
+ except Exception as err:
+ logger.log_failure(obj=None, message=f"Failed to initialize Nornir: {err}")
+ raise
+
+ logger.log_debug("Completed configuration deployment.")
diff --git a/nautobot_golden_config/signals.py b/nautobot_golden_config/signals.py
index d099c75f..7ff3baa7 100755
--- a/nautobot_golden_config/signals.py
+++ b/nautobot_golden_config/signals.py
@@ -1,11 +1,55 @@
"""Signal helpers."""
-
+from django.apps import apps as global_apps
from django.db.models.signals import post_save
from django.dispatch import receiver
from nautobot.dcim.models import Platform
from nautobot_golden_config import models
+def post_migrate_create_statuses(sender, apps=global_apps, **kwargs): # pylint: disable=unused-argument
+ """Callback function for post_migrate() -- create Status records."""
+ Status = apps.get_model("extras", "Status") # pylint: disable=invalid-name
+ ContentType = apps.get_model("contenttypes", "ContentType") # pylint: disable=invalid-name
+ for status_config in [
+ {
+ "name": "Approved",
+ "slug": "approved",
+ "defaults": {
+ "description": "Config plan is approved",
+ "color": "4caf50", # Green
+ },
+ },
+ {
+ "name": "Not Approved",
+ "slug": "not-approved",
+ "defaults": {
+ "description": "Config plan is not approved",
+ "color": "f44336", # Red
+ },
+ },
+ ]:
+ status, _ = Status.objects.get_or_create(**status_config)
+ status.content_types.add(ContentType.objects.get_for_model(models.ConfigPlan))
+
+
+def post_migrate_create_job_button(sender, apps=global_apps, **kwargs): # pylint: disable=unused-argument
+ """Callback function for post_migrate() -- create JobButton records."""
+ JobButton = apps.get_model("extras", "JobButton") # pylint: disable=invalid-name
+ Job = apps.get_model("extras", "Job") # pylint: disable=invalid-name
+ ContentType = apps.get_model("contenttypes", "ContentType") # pylint: disable=invalid-name
+ configplan_type = ContentType.objects.get_for_model(models.ConfigPlan)
+ job_button_config = {
+ "name": "Deploy Config Plan",
+ "job": Job.objects.get(job_class_name="DeployConfigPlanJobButtonReceiver"),
+ "defaults": {
+ "text": "Deploy",
+ "button_class": "primary",
+ },
+ }
+ jobbutton, _ = JobButton.objects.get_or_create(**job_button_config)
+ jobbutton.content_types.set([configplan_type])
+
+
@receiver(post_save, sender=models.ConfigCompliance)
def config_compliance_platform_cleanup(sender, instance, **kwargs): # pylint: disable=unused-argument
"""Signal helper to delete any orphaned ConfigCompliance objects. Caused by device platform changes."""
diff --git a/nautobot_golden_config/static/run_job.js b/nautobot_golden_config/static/run_job.js
new file mode 100644
index 00000000..20bf2da4
--- /dev/null
+++ b/nautobot_golden_config/static/run_job.js
@@ -0,0 +1,213 @@
+
+/**
+ * Used in conjuction with `job_result_modal` to pop up the modal, start the job, provide progress spinner,
+ * provide job status, job results link, redirect link, and error message.
+ *
+ * @requires nautobot_csrf_token - The CSRF token obtained from Nautobot.
+ * @param {string} jobClass - The jobs `class_path` as defined on the job detail page.
+ * @param {Object} data - The object containing payload data to send to the job.
+ * @param {string} redirectUrlTemplate - The redirect url to provide, you have access to jobData with the syntax like `{jobData.someKey}`, leave `undefined` if none is required.
+ */
+function startJob(jobClass, data, redirectUrlTemplate) {
+ var jobApi = `/api/extras/jobs/${jobClass}/run/`;
+
+ $.ajax({
+ type: 'POST',
+ url: jobApi,
+ contentType: "application/json",
+ data: JSON.stringify({"data": data}),
+ dataType: 'json',
+ headers: {
+ 'X-CSRFToken': nautobot_csrf_token
+ },
+ beforeSend: function() {
+ // Normalize to base as much as you can.
+ $('#jobStatus').html("Pending").show();
+ $('#loaderImg').show();
+ $('#jobResults').hide();
+ $('#redirectLink').hide();
+ $('#detailMessages').hide();
+ },
+ success: function(jobData) {
+ $('#jobStatus').html("Started").show();
+ var jobResultUrl = "/extras/job-results/" + jobData.result.id + "/";
+ $('#jobResults').html(iconLink(jobResultUrl, "mdi-open-in-new", "Job Details")).show();
+ pollJobStatus(jobData.result.url);
+ if (typeof redirectUrlTemplate !== "undefined") {
+ var redirectUrl = _renderTemplate(redirectUrlTemplate, jobData);
+ $('#redirectLink').html(iconLink(redirectUrl, "mdi-open-in-new", "Info"));
+ }
+ },
+ error: function(e) {
+ $("#loaderImg").hide();
+ console.log("There was an error with your request...");
+ console.log("error: " + JSON.stringify(e));
+ $('#jobStatus').html("Failed").show();
+ $('#detailMessages').show();
+ $('#detailMessages').attr('class', 'alert alert-danger text-center');
+ $('#detailMessages').html("Error: " + e.responseText);
+ }
+ });
+}
+
+/**
+ * Polls the status of a job with the given job ID.
+ *
+ * This function makes an AJAX request to the server,
+ * to get the current status of the job with the specified job ID.
+ * It continues to poll the status until the job completes or fails.
+ * The job status is updated in the HTML element with ID 'jobStatus'.
+ * If the job encounters an error, additional error details are shown.
+ * The call is not made async, so that the parent call will wait until
+ * this is completed.
+ *
+ * @requires nautobot_csrf_token - The CSRF token obtained from Nautobot.
+ * @param {string} jobId - The ID of the job to poll.
+ * @returns {void}
+ */
+function pollJobStatus(jobId) {
+ $.ajax({
+ url: jobId,
+ type: "GET",
+ async: false,
+ dataType: "json",
+ headers: {
+ 'X-CSRFToken': nautobot_csrf_token
+ },
+ success: function(data) {
+ $('#jobStatus').html(data.status.value.charAt(0).toUpperCase() + data.status.value.slice(1)).show();
+ if (["errored", "failed"].includes(data.status.value)) {
+ $("#loaderImg").hide();
+ $('#detailMessages').show();
+ $('#detailMessages').attr('class', 'alert alert-warning text-center');
+ $('#detailMessages').html("Job started but failed during the Job run. This job may have partially completed. See Job Results for more details on the errors.");
+ } else if (["running", "pending"].includes(data.status.value)) {
+ // Job is still processing, continue polling
+ setTimeout(function() {
+ pollJobStatus(jobId);
+ }, 1000); // Poll every 1 seconds
+ } else if (data.status.value == "completed") {
+ $("#loaderImg").hide();
+ $('#detailMessages').show();
+ configPlanCount(data.id)
+ .then(function(planCount) {
+ $('#redirectLink').show();
+ $('#detailMessages').attr('class', 'alert alert-success text-center');
+ $('#detailMessages').html(
+ "Job Completed Successfully."+
+ "
Number of Config Plans generated: " + planCount
+ )
+ })
+ .catch(function(error) {
+ $('#detailMessages').attr('class', 'alert alert-warning text-center');
+ $('#detailMessages').html(
+ "Job completed successfully, but no Config Plans were generated."+
+ "
If this is unexpected, please validate your input parameters."
+ )
+ });
+ }
+ },
+ error: function(e) {
+ $("#loaderImg").hide();
+ console.log("There was an error with your request...");
+ console.log("error: " + JSON.stringify(e));
+ $('#detailMessages').show();
+ $('#detailMessages').attr('class', 'alert alert-danger text-center');
+ $('#detailMessages').html("Error: " + e.responseText);
+ }
+ });
+}
+
+/**
+ * Converts a list of form data objects to a dictionary.
+ *
+ * @param {FormData} formData - The form data object to be converted.
+ * @param {string[]} listKeys - The list of keys for which values should be collected as lists.
+ * @returns {Object} - The dictionary representation of the form data.
+ */
+function formDataToDictionary(formData, listKeys) {
+ const dict = {};
+
+ formData.forEach(item => {
+ const { name, value } = item;
+ if (listKeys.includes(name)) {
+ if (!dict[name]) {
+ dict[name] = [value];
+ } else {
+ dict[name].push(value);
+ }
+ } else {
+ dict[name] = value;
+ }
+ });
+
+ return dict;
+}
+
+/**
+ * Generates an HTML anchor link with an icon.
+ *
+ * @param {string} url - The URL to link to.
+ * @param {string} icon - The name of the Material Design Icon to use.
+ * @param {string} title - The title to display when hovering over the icon.
+ * @returns {string} - The HTML anchor link with the specified icon.
+ */
+function iconLink(url, icon, title) {
+
+ const linkUrl = `` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ``
+ return linkUrl
+}
+
+/**
+ * Renders a template string with placeholders replaced by corresponding values from jobData.
+ *
+ * @param {string} templateString - The template string with placeholders in the form of `{jobData.someKey}`.
+ * @param {Object} jobData - The object containing data to replace placeholders in the template.
+ * @returns {string} - The rendered string with placeholders replaced by actual values from jobData.
+ */
+function _renderTemplate(templateString, data) {
+ // Create a regular expression to match placeholders in the template
+ const placeholderRegex = /\{jobData\.([^\}]+)\}/g;
+
+ // Replace placeholders with corresponding values from jobData
+ const renderedString = templateString.replace(placeholderRegex, (match, key) => {
+ const keys = key.split(".");
+ let value = data;
+ for (const k of keys) {
+ if (value.hasOwnProperty(k)) {
+ value = value[k];
+ } else {
+ return match; // If the key is not found, keep the original placeholder
+ }
+ }
+ return value;
+ });
+
+ return renderedString;
+}
+
+
+function configPlanCount(jobResultId) {
+ return new Promise(function(resolve, reject) {
+ var configPlanApi = `/api/plugins/golden-config/config-plan/?job_result_id=${jobResultId}`;
+ $.ajax({
+ url: configPlanApi,
+ type: "GET",
+ dataType: "json",
+ headers: {
+ 'X-CSRFToken': nautobot_csrf_token
+ },
+ success: function(data) {
+ var count = data.count;
+ resolve(count);
+ },
+ error: function(e) {
+ resolve(e);
+ }
+ });
+ });
+}
\ No newline at end of file
diff --git a/nautobot_golden_config/static/toggle_fields.js b/nautobot_golden_config/static/toggle_fields.js
new file mode 100644
index 00000000..d0232c4e
--- /dev/null
+++ b/nautobot_golden_config/static/toggle_fields.js
@@ -0,0 +1,86 @@
+
+/**
+ * Clear fields in forms based on conditions specified in the 'data' parameter.
+ *
+ * This function takes in an array of data objects, where each object contains a 'values'
+ * property with an array of conditions. Each condition has a 'hide' property that contains
+ * a list of field names. The function iterates through the 'data' array and hides the fields
+ * specified in the 'hide' property for each condition. See `setupFieldListeners` doc
+ * string for example data and more details.
+ *
+ * @param {Object[]} data - An array of data objects, each with a 'values' property.
+ * @returns {void} - This function does not return anything.
+ */
+function clearFields(data) {
+ // Iterate through the data array
+ data.forEach(item => {
+ // Get the field and value objects
+ var values = item["values"];
+
+ // Iterate through the values array
+ values.forEach(condition => {
+ // Hide the fields specified in "hide" array
+ condition["hide"].forEach(fieldToHide => $("#" + fieldToHide).parent().parent().hide());
+ });
+ });
+}
+
+/**
+ * Set up event listeners for fields based on conditions specified in the 'data' parameter.
+ *
+ * This function takes in an array of data objects, where each object contains an 'event_field'
+ * property with the ID of a prior field. It also contains a 'values' property with an array of conditions.
+ * Each condition has 'name', 'show', and 'hide' properties. The function iterates through the 'data' array
+ * and sets up change event listeners for the prior fields. When the prior field's value changes, the function
+ * checks the conditions and shows or hides fields based on the selected value. Please note that this is
+ * intended to be used in a django form rended_field, which adds `id_` to the field, such as `id_commands`.
+ * Additionally, consider an empty "", `name` key to hide everything as shown. Example data being expected:
+ *
+ * const hideFormData = [
+ * {
+ * "event_field": "id_plan_type",
+ * "values": [
+ * {
+ * "name": "manual",
+ * "show": ["id_commands"],
+ * "hide": ["id_feature"]
+ * },
+ * {
+ * "name": "missing",
+ * "show": ["id_feature"],
+ * "hide": ["id_commands"]
+ * },
+ * {
+ * "name": "", // Used for blank field
+ * "show": [],
+ * "hide": ["id_feature", "id_commands"]
+ * }
+ * }
+ * ]
+ *
+ * @param {Object[]} data - An array of data objects, each with 'event_field' and 'values' properties.
+ * @returns {void} - This function does not return anything.
+ */
+function setupFieldListeners(data) {
+ // Iterate through the hideFormData array
+ data.forEach(item => {
+ // Get the prior field element by its ID
+ var priorField = $("#" + item["event_field"]);
+
+ // Handle the change event of the prior field
+ priorField.on("change", function() {
+ // Get the selected value of the prior field
+ var selectedValue = priorField.val();
+
+ // Iterate through the values array
+ item["values"].forEach(condition => {
+ if (condition["name"] === selectedValue) {
+ // Show the fields specified in "show" array
+ condition["show"].forEach(fieldToShow => $("#" + fieldToShow).parent().parent().show());
+ // Hide the fields specified in "hide" array
+ condition["hide"].forEach(fieldToHide => $("#" + fieldToHide).parent().parent().hide());
+ }
+ });
+ });
+ });
+}
\ No newline at end of file
diff --git a/nautobot_golden_config/tables.py b/nautobot_golden_config/tables.py
index ec6938e9..b892a5bf 100644
--- a/nautobot_golden_config/tables.py
+++ b/nautobot_golden_config/tables.py
@@ -5,9 +5,11 @@
from django_tables2 import Column, LinkColumn, TemplateColumn
from django_tables2.utils import A
+from nautobot.extras.tables import StatusTableMixin
from nautobot.utilities.tables import (
BaseTable,
ToggleColumn,
+ TagColumn,
)
from nautobot_golden_config import models
from nautobot_golden_config.utilities.constant import (
@@ -85,6 +87,38 @@
{% endif %}
"""
+CONFIG_SET_BUTTON = """
+
+
+
+
+
{{ item.remediation|condition_render_json }}+ +
{{ object.missing }}
{{ object.remediation }}
Note: Selecting no filters will generate plans for all applicable devices.
+ + {% render_field form.tenant_group %} + {% render_field form.tenant %} + + {% render_field form.region %} + {% render_field form.site %} + {% render_field form.rack_group %} + {% render_field form.rack %} + {% render_field form.role %} + {% render_field form.manufacturer %} + {% render_field form.platform %} + {% render_field form.device_type %} + {% render_field form.device %} + {% render_field form.tag %} + {% render_field form.status %} +Device | +{{ object.device }} | +
Date Created | +{{ object.created }} | +
Plan Type | +{{ object.plan_type | title }} | +
Features | +
+ {% if object.feature.exists %}
+
|
+
Change Control ID | +{{ object.change_control_id | placeholder }} | +
Change Control URL | +{{ object.change_control_url|placeholder }} | +
Job Result | +{{ object.job_result|placeholder }} | +
Status | ++ {{ object.get_status_display }} + | +
Config Set | +
+ {{ object.config_set }}+ + |
+
Platform | +{{ object.platform }} | +
Remediation Type | +{{ object.remediation_type }} | +
Remediation Options | +{{ object.remediation_options|render_json }} |
+