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 = """ + + + + +""" + MATCH_CONFIG = """{{ record.match_config|linebreaksbr }}""" @@ -339,6 +373,7 @@ class Meta(BaseTable.Meta): "match_config", "config_type", "custom_compliance", + "config_remediation", ) default_columns = ( "pk", @@ -349,6 +384,7 @@ class Meta(BaseTable.Meta): "match_config", "config_type", "custom_compliance", + "config_remediation", ) @@ -436,3 +472,62 @@ class Meta(BaseTable.Meta): "intended_repository", "jinja_repository", ) + + +class RemediationSettingTable(BaseTable): + """Table to display RemediationSetting Rules.""" + + pk = ToggleColumn() + platform = LinkColumn("plugins:nautobot_golden_config:remediationsetting", args=[A("pk")]) + + class Meta(BaseTable.Meta): + """Table to display RemediationSetting Meta Data.""" + + model = models.RemediationSetting + fields = ("pk", "platform", "remediation_type") + default_columns = ("pk", "platform", "remediation_type") + + +# ConfigPlan + + +class ConfigPlanTable(StatusTableMixin, BaseTable): + """Table to display Config Plans.""" + + pk = ToggleColumn() + device = LinkColumn("plugins:nautobot_golden_config:configplan", args=[A("pk")]) + job_result = TemplateColumn( + template_code=""" """ + ) + config_set = TemplateColumn(template_code=CONFIG_SET_BUTTON, verbose_name="Config Set", orderable=False) + tags = TagColumn(url_name="plugins:nautobot_golden_config:configplan_list") + + class Meta(BaseTable.Meta): + """Table to display Config Plans Meta Data.""" + + model = models.ConfigPlan + fields = ( + "pk", + "device", + "created", + "plan_type", + "feature", + "change_control_id", + "change_control_url", + "job_result", + "config_set", + "status", + "tags", + ) + default_columns = ( + "pk", + "device", + "created", + "plan_type", + "feature", + "change_control_id", + "change_control_url", + "job_result", + "config_set", + "status", + ) diff --git a/nautobot_golden_config/templates/nautobot_golden_config/compliance_device_report.html b/nautobot_golden_config/templates/nautobot_golden_config/compliance_device_report.html index 01e3d66b..caf6dc3c 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/compliance_device_report.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/compliance_device_report.html @@ -170,6 +170,19 @@

{% block title %}Configuration Compliance - {{ device.name }}{% endblock %}< {% endif %} + {% if item.remediation != None %} + + Remediating Configuration + +
{{ item.remediation|condition_render_json }}
+ + + + + + {% endif %} {% endfor %} diff --git a/nautobot_golden_config/templates/nautobot_golden_config/compliancerule_retrieve.html b/nautobot_golden_config/templates/nautobot_golden_config/compliancerule_retrieve.html index c72e71a5..d78402d1 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/compliancerule_retrieve.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/compliancerule_retrieve.html @@ -33,7 +33,11 @@ Custom Compliance - {{ object.custom_compliance }} + {{ object.custom_compliance|render_boolean }} + + + Remediation Enabled + {{ object.config_remediation|render_boolean }} diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance.html b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance.html index 3994c4ef..dc7531cc 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance.html @@ -92,6 +92,10 @@

{{ object }}

Missing Configuration
{{ object.missing }}
+ + Remediating Configuration +
{{ object.remediation }}
+ Ordered diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configplan_create.html b/nautobot_golden_config/templates/nautobot_golden_config/configplan_create.html new file mode 100644 index 00000000..7e95ae42 --- /dev/null +++ b/nautobot_golden_config/templates/nautobot_golden_config/configplan_create.html @@ -0,0 +1,73 @@ +{% extends 'generic/object_create.html' %} +{% load form_helpers %} +{% load helpers %} +{% load static %} + +{% block title %}Generate Config Plans{% endblock %} + +{% block form %} +
+
Plan Details
+
+ {% render_field form.plan_type %} + {% render_field form.change_control_id %} + {% render_field form.change_control_url %} + {% render_field form.feature %} + {% render_field form.commands %} +
+
Plan Filters
+
+ +

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 %} +
+
+ +{% endblock %} + +{% block buttons %} +{% include "nautobot_golden_config/job_result_modal.html" %} + + + + + +{% endblock %} + +{% block javascript %} + + + +{% endblock javascript %} \ No newline at end of file diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configplan_list.html b/nautobot_golden_config/templates/nautobot_golden_config/configplan_list.html new file mode 100644 index 00000000..d745e5db --- /dev/null +++ b/nautobot_golden_config/templates/nautobot_golden_config/configplan_list.html @@ -0,0 +1,34 @@ +{% extends 'generic/object_list.html' %} + +{% block bulk_buttons %} + + + + +{% if perms.extras.run_job %} + +{% endif %} +{% endblock %} diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configplan_retrieve.html b/nautobot_golden_config/templates/nautobot_golden_config/configplan_retrieve.html new file mode 100644 index 00000000..2b08ac51 --- /dev/null +++ b/nautobot_golden_config/templates/nautobot_golden_config/configplan_retrieve.html @@ -0,0 +1,67 @@ +{% extends 'generic/object_detail.html' %} +{% load helpers %} + +{% block content_left_page %} +
+
+ Config Plan Details +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Device{{ object.device }}
Date Created{{ object.created }}
Plan Type{{ object.plan_type | title }}
Features + {% if object.feature.exists %} +
    + {% for feature in object.feature.all %} +
  • {{ feature }}
  • + {% endfor %} +
+ {% else %} + {{ None | placeholder }} + {% endif %} +
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 }}
+ + + +
+
+{% endblock %} \ No newline at end of file diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configplan_update.html b/nautobot_golden_config/templates/nautobot_golden_config/configplan_update.html new file mode 100644 index 00000000..15098919 --- /dev/null +++ b/nautobot_golden_config/templates/nautobot_golden_config/configplan_update.html @@ -0,0 +1,17 @@ +{% extends 'generic/object_create.html' %} +{% load form_helpers %} +{% load helpers %} +{% load static %} + +{% block form %} +
+
Plan Details
+
+ {% render_field form.change_control_id %} + {% render_field form.change_control_url %} + {% render_field form.status %} + {% render_field form.tag %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/nautobot_golden_config/templates/nautobot_golden_config/job_result_modal.html b/nautobot_golden_config/templates/nautobot_golden_config/job_result_modal.html new file mode 100644 index 00000000..51b319eb --- /dev/null +++ b/nautobot_golden_config/templates/nautobot_golden_config/job_result_modal.html @@ -0,0 +1,42 @@ +{% load static %} + diff --git a/nautobot_golden_config/templates/nautobot_golden_config/remediationsetting_retrieve.html b/nautobot_golden_config/templates/nautobot_golden_config/remediationsetting_retrieve.html new file mode 100644 index 00000000..0344822e --- /dev/null +++ b/nautobot_golden_config/templates/nautobot_golden_config/remediationsetting_retrieve.html @@ -0,0 +1,24 @@ +{% extends 'generic/object_detail.html' %} +{% load helpers %} + +{% block content_left_page %} +
+
+ Remediation Setting Details +
+ + + + + + + + + + + + + +
Platform{{ object.platform }}
Remediation Type{{ object.remediation_type }}
Remediation Options
{{ object.remediation_options|render_json }}
+
+{% endblock %} \ No newline at end of file diff --git a/nautobot_golden_config/tests/conftest.py b/nautobot_golden_config/tests/conftest.py index 195ef3b0..657053e7 100644 --- a/nautobot_golden_config/tests/conftest.py +++ b/nautobot_golden_config/tests/conftest.py @@ -1,14 +1,21 @@ """Params for testing.""" +from datetime import datetime +import uuid +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.text import slugify from nautobot.dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, Region, Site from nautobot.extras.datasources.registry import get_datasource_contents -from nautobot.extras.models import GitRepository, GraphQLQuery, Status, Tag +from nautobot.extras.models import GitRepository, GraphQLQuery, Status, Tag, JobResult from nautobot.tenancy.models import Tenant, TenantGroup +import pytz from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice from nautobot_golden_config.models import ComplianceFeature, ComplianceRule, ConfigCompliance +User = get_user_model() + + def create_device_data(): """Creates a Device and associated data.""" manufacturers = ( @@ -171,7 +178,7 @@ def create_orphan_device(name="orphan"): return device -def create_feature_rule_json(device, feature="foo", rule="json"): +def create_feature_rule_json(device, feature="foo1", rule="json"): """Creates a Feature/Rule Mapping and Returns the rule.""" feature_obj, _ = ComplianceFeature.objects.get_or_create(slug=feature, name=feature) rule = ComplianceRule( @@ -184,6 +191,47 @@ def create_feature_rule_json(device, feature="foo", rule="json"): return rule +def create_feature_rule_json_with_remediation(device, feature="foo2", rule="json"): + """Creates a Feature/Rule Mapping with remediation enabled and Returns the rule.""" + feature_obj, _ = ComplianceFeature.objects.get_or_create(slug=feature, name=feature) + rule = ComplianceRule( + feature=feature_obj, + platform=device.platform, + config_type=ComplianceRuleConfigTypeChoice.TYPE_JSON, + config_ordered=False, + config_remediation=True, + ) + rule.save() + return rule + + +def create_feature_rule_cli_with_remediation(device, feature="foo3", rule="cli"): + """Creates a Feature/Rule Mapping with remediation enabled and Returns the rule.""" + feature_obj, _ = ComplianceFeature.objects.get_or_create(slug=feature, name=feature) + rule = ComplianceRule( + feature=feature_obj, + platform=device.platform, + config_type=ComplianceRuleConfigTypeChoice.TYPE_CLI, + config_ordered=False, + config_remediation=True, + ) + rule.save() + return rule + + +def create_feature_rule_cli(device, feature="foo_cli"): + """Creates a Feature/Rule Mapping and Returns the rule.""" + feature_obj, _ = ComplianceFeature.objects.get_or_create(slug=feature, name=feature) + rule, _ = ComplianceRule.objects.get_or_create( + feature=feature_obj, + platform=device.platform, + config_type=ComplianceRuleConfigTypeChoice.TYPE_CLI, + config_ordered=False, + ) + rule.save() + return rule + + def create_config_compliance(device, compliance_rule=None, actual=None, intended=None): """Creates a ConfigCompliance to be used with tests.""" config_compliance = ConfigCompliance.objects.create( @@ -403,3 +451,19 @@ def create_saved_queries() -> None: query=query, ) saved_query_5.save() + + +def create_job_result() -> None: + """Create a JobResult and return the object.""" + obj_type = ContentType.objects.get(app_label="extras", model="job") + user, _ = User.objects.get_or_create(username="testuser") + result = JobResult.objects.create( + name="Test-Job-Result", + obj_type=obj_type, + user=user, + job_id=uuid.uuid4(), + ) + result.status = "completed" + result.completed = datetime.now(pytz.UTC) + result.validated_save() + return result diff --git a/nautobot_golden_config/tests/test_api.py b/nautobot_golden_config/tests/test_api.py index 03480059..f8a7e7f2 100644 --- a/nautobot_golden_config/tests/test_api.py +++ b/nautobot_golden_config/tests/test_api.py @@ -1,24 +1,26 @@ """Unit tests for nautobot_golden_config.""" from copy import deepcopy + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType - from django.urls import reverse +from nautobot.dcim.models import Device, Platform +from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, Status +from nautobot.utilities.testing import APITestCase, APIViewTestCases from rest_framework import status - -from nautobot.utilities.testing import APITestCase -from nautobot.extras.models import GitRepository, GraphQLQuery, DynamicGroup -from nautobot_golden_config.models import GoldenConfigSetting +from nautobot_golden_config.choices import RemediationTypeChoice +from nautobot_golden_config.models import ConfigPlan, GoldenConfigSetting, RemediationSetting from .conftest import ( + create_config_compliance, create_device, + create_device_data, create_feature_rule_json, - create_config_compliance, create_git_repos, + create_job_result, create_saved_queries, ) - User = get_user_model() @@ -275,3 +277,127 @@ def test_settings_api_clean_up(self): # Put back a general GoldenConfigSetting object. global_settings = GoldenConfigSetting.objects.create(dynamic_group=self.dynamic_group) global_settings.save() + + +# pylint: disable=too-many-ancestors +class RemediationSettingTest(APIViewTestCases.APIViewTestCase): + """Test API for Remediation Settings.""" + + model = RemediationSetting + choices_fields = ["remediation_type"] + + @classmethod + def setUpTestData(cls): + create_device_data() + platform1 = Platform.objects.get(name="Platform 1") + platform2 = Platform.objects.get(name="Platform 2") + platform3 = Platform.objects.get(name="Platform 3") + type_cli = RemediationTypeChoice.TYPE_HIERCONFIG + type_custom = RemediationTypeChoice.TYPE_CUSTOM + + # RemediationSetting type Hier with default values. + RemediationSetting.objects.create( + platform=platform1, + remediation_type=type_cli, + ) + # RemediationSetting type Hier with custom options. + RemediationSetting.objects.create( + platform=platform2, remediation_type=type_cli, remediation_options={"some_option": "some_value"} + ) + # RemediationSetting type Custom with custom options. + RemediationSetting.objects.create( + platform=platform3, + remediation_type=type_custom, + ) + + platforms = ( + Platform.objects.create(name="Platform 4", slug="platform-4"), + Platform.objects.create(name="Platform 5", slug="platform-5"), + Platform.objects.create(name="Platform 6", slug="platform-6"), + ) + + cls.create_data = [ + {"platform": platforms[0].pk, "remediation_type": type_cli}, + { + "platform": platforms[1].pk, + "remediation_type": type_cli, + "remediation_options": {"some_option": "some_value"}, + }, + {"platform": platforms[2].pk, "remediation_type": type_custom}, + ] + + cls.update_data = { + "remediation_type": type_custom, + } + + cls.bulk_update_data = { + "remediation_type": type_cli, + } + + def test_list_objects_brief(self): + """Skipping test due to brief_fields not implemented.""" + + +# pylint: disable=too-many-ancestors,too-many-locals +class ConfigPlanTest(APIViewTestCases.APIViewTestCase): + """Test API for ConfigPlan.""" + + model = ConfigPlan + brief_fields = ["device", "display", "id", "plan_type", "url"] + # The Status serializer field requires slug, but the model field returns the UUID. + validation_excluded_fields = ["status"] + + @classmethod + def setUpTestData(cls): + create_device_data() + device1 = Device.objects.get(name="Device 1") + device2 = Device.objects.get(name="Device 2") + device3 = Device.objects.get(name="Device 3") + + rule1 = create_feature_rule_json(device1, feature="Test Feature 1") + rule2 = create_feature_rule_json(device2, feature="Test Feature 2") + rule3 = create_feature_rule_json(device3, feature="Test Feature 3") + + job_result1 = create_job_result() + job_result2 = create_job_result() + job_result3 = create_job_result() + + features = [rule1.feature, rule2.feature, rule3.feature] + plan_types = ["intended", "missing", "remediation"] + job_result_ids = [job_result1.id, job_result2.id, job_result3.id] + not_approved_status = Status.objects.get(slug="not-approved") + approved_status = Status.objects.get(slug="approved") + + for cont in range(1, 4): + plan = ConfigPlan.objects.create( + device=Device.objects.get(name=f"Device {cont}"), + plan_type=plan_types[cont - 1], + config_set=f"Test Config Set {cont}", + change_control_id=f"Test Change Control ID {cont}", + change_control_url=f"https://{cont}.example.com/", + status=not_approved_status, + job_result_id=job_result_ids[cont - 1], + ) + plan.feature.add(features[cont - 1]) + plan.validated_save() + + cls.update_data = { + "change_control_id": "Test Change Control ID 4", + "change_control_url": "https://4.example.com/", + "status": approved_status.slug, + } + + cls.bulk_update_data = { + "change_control_id": "Test Change Control ID 5", + "change_control_url": "https://5.example.com/", + "status": approved_status.slug, + } + + def test_create_object(self): + """Skipping test due to POST method not allowed.""" + + def test_create_object_without_permission(self): + """Skipping test due to POST method not allowed.""" + + def test_bulk_create_objects(self): + """Skipping test due to POST method not allowed.""" diff --git a/nautobot_golden_config/tests/test_filters.py b/nautobot_golden_config/tests/test_filters.py index 28aa4a08..95fec53e 100644 --- a/nautobot_golden_config/tests/test_filters.py +++ b/nautobot_golden_config/tests/test_filters.py @@ -4,9 +4,11 @@ from django.test import TestCase from nautobot.dcim.models import Device, Platform +from nautobot.extras.models import Status, Tag +from nautobot.utilities.testing import FilterTestCases from nautobot_golden_config import filters, models -from .conftest import create_feature_rule_json, create_device_data +from .conftest import create_feature_rule_json, create_device_data, create_feature_rule_cli, create_job_result class ConfigComplianceModelTestCase(TestCase): @@ -299,3 +301,173 @@ def test_search(self): """Test filtering by Q search value.""" params = {"q": self.obj1.name[-1:]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + +# pylint: disable=too-many-ancestors +# pylint: disable=too-many-instance-attributes +class ConfigPlanFilterTestCase(FilterTestCases.FilterTestCase): + """Test filtering operations for ConfigPlan Model.""" + + queryset = models.ConfigPlan.objects.all() + filterset = filters.ConfigPlanFilterSet + + def setUp(self): + """Setup Object.""" + create_device_data() + self.device1 = Device.objects.get(name="Device 1") + self.device2 = Device.objects.get(name="Device 2") + self.rule1 = create_feature_rule_cli(self.device1, feature="Feature 1") + self.feature1 = self.rule1.feature + self.rule2 = create_feature_rule_cli(self.device2, feature="Feature 2") + self.feature2 = self.rule2.feature + self.rule3 = create_feature_rule_cli(self.device1, feature="Feature 3") + self.feature3 = self.rule3.feature + self.status1 = Status.objects.get(name="Not Approved") + self.status2 = Status.objects.get(name="Approved") + self.tag1, _ = Tag.objects.get_or_create(name="Tag 1") + self.tag2, _ = Tag.objects.get_or_create(name="Tag 2") + self.job_result1 = create_job_result() + self.job_result2 = create_job_result() + self.config_plan1 = models.ConfigPlan.objects.create( + device=self.device1, + plan_type="intended", + created="2020-01-01", + config_set="intended test", + change_control_id="12345", + status=self.status2, + job_result_id=self.job_result1.id, + ) + self.config_plan1.tags.add(self.tag1) + self.config_plan1.feature.add(self.feature1) + self.config_plan1.validated_save() + self.config_plan2 = models.ConfigPlan.objects.create( + device=self.device1, + plan_type="missing", + created="2020-01-02", + config_set="missing test", + change_control_id="23456", + status=self.status1, + job_result_id=self.job_result1.id, + ) + self.config_plan2.tags.add(self.tag2) + self.config_plan2.feature.add(self.feature2) + self.config_plan2.validated_save() + self.config_plan3 = models.ConfigPlan.objects.create( + device=self.device2, + plan_type="remediation", + created="2020-01-03", + config_set="remediation test", + change_control_id="34567", + status=self.status2, + job_result_id=self.job_result2.id, + ) + self.config_plan3.tags.add(self.tag2) + self.config_plan3.feature.set([self.feature1, self.feature3]) + self.config_plan3.validated_save() + self.config_plan4 = models.ConfigPlan.objects.create( + device=self.device2, + plan_type="manual", + created="2020-01-04", + config_set="manual test", + change_control_id="45678", + status=self.status1, + job_result_id=self.job_result1.id, + ) + self.config_plan4.tags.add(self.tag1) + self.config_plan4.validated_save() + + def test_full(self): + """Test without filtering to ensure all have been added.""" + self.assertEqual(self.queryset.count(), 4) + + def test_search_device_name(self): + """Test filtering by Q search value.""" + params = {"q": "Device 1"} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_search_change_control_id(self): + """Test filtering by Q search value.""" + params = {"q": "345"} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_filter_device_id(self): + """Test filtering by Device ID.""" + params = {"device_id": [self.device1.pk]} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 2) + self.assertQuerysetEqualAndNotEmpty(filterset.qs, self.queryset.filter(device=self.device1).distinct()) + + def test_filter_device(self): + """Test filtering by Device.""" + params = {"device": [self.device1.name]} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 2) + self.assertQuerysetEqualAndNotEmpty( + filterset.qs, self.queryset.filter(device__name=self.device1.name).distinct() + ) + + def test_filter_feature_id(self): + """Test filtering by Feature ID.""" + params = {"feature_id": [self.feature1.pk]} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 2) + self.assertQuerysetEqualAndNotEmpty(filterset.qs, self.queryset.filter(feature=self.feature1).distinct()) + + def test_filter_feature(self): + """Test filtering by Feature.""" + params = {"feature": [self.feature1.name]} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 2) + self.assertQuerysetEqualAndNotEmpty( + filterset.qs, self.queryset.filter(feature__name=self.feature1.name).distinct() + ) + + def test_filter_change_control_id(self): + """Test filtering by Change Control ID.""" + params = {"change_control_id": self.config_plan1.change_control_id} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 1) + self.assertQuerysetEqualAndNotEmpty( + filterset.qs, self.queryset.filter(change_control_id=self.config_plan1.change_control_id).distinct() + ) + + def test_filter_status_id(self): + """Test filtering by Status ID.""" + params = {"status_id": [self.status1.pk]} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 2) + self.assertQuerysetEqualAndNotEmpty(filterset.qs, self.queryset.filter(status=self.status1).distinct()) + + def test_filter_status(self): + """Test filtering by Status.""" + params = {"status": [self.status1.name]} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 2) + self.assertQuerysetEqualAndNotEmpty( + filterset.qs, self.queryset.filter(status__name=self.status1.name).distinct() + ) + + def test_filter_plan_type(self): + """Test filtering by Plan Type.""" + params = {"plan_type": self.config_plan1.plan_type} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 1) + self.assertQuerysetEqualAndNotEmpty( + filterset.qs, self.queryset.filter(plan_type=self.config_plan1.plan_type).distinct() + ) + + def test_filter_tag(self): + """Test filtering by Tag.""" + params = {"tag": [self.tag1.slug]} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 2) + self.assertQuerysetEqualAndNotEmpty(filterset.qs, self.queryset.filter(tags__name=self.tag1.name).distinct()) + + def test_job_result_id(self): + """Test filtering by Job Result ID.""" + params = {"job_result_id": [self.job_result1.pk]} + filterset = self.filterset(params, self.queryset) + self.assertEqual(filterset.qs.count(), 3) + self.assertQuerysetEqualAndNotEmpty( + filterset.qs, self.queryset.filter(job_result_id=self.job_result1.id).distinct() + ) diff --git a/nautobot_golden_config/tests/test_models.py b/nautobot_golden_config/tests/test_models.py index 31a65c54..897f14db 100644 --- a/nautobot_golden_config/tests/test_models.py +++ b/nautobot_golden_config/tests/test_models.py @@ -5,11 +5,25 @@ from django.db.utils import IntegrityError from django.test import TestCase from nautobot.dcim.models import Platform -from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery -from nautobot_golden_config.models import ConfigCompliance, ConfigRemove, ConfigReplace, GoldenConfigSetting +from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, Status +from nautobot_golden_config.choices import RemediationTypeChoice +from nautobot_golden_config.models import ( + ConfigCompliance, + ConfigPlan, + ConfigRemove, + ConfigReplace, + GoldenConfigSetting, + RemediationSetting, +) from nautobot_golden_config.tests.conftest import create_git_repos -from .conftest import create_config_compliance, create_device, create_feature_rule_json, create_saved_queries +from .conftest import ( + create_config_compliance, + create_device, + create_feature_rule_json, + create_saved_queries, + create_job_result, +) class ConfigComplianceModelTestCase(TestCase): @@ -133,9 +147,8 @@ def setUp(self): def test_absolute_url_success(self): """Verify that get_absolute_url() returns the expected URL.""" url_string = self.global_settings.get_absolute_url() - self.assertEqual( - url_string.rstrip("/"), f"/plugins/golden-config/golden-config-setting/{self.global_settings.pk}" - ) + # Changed from assertEqual to assertIn to account for trailing slash added in later versions. + self.assertIn(f"/plugins/golden-config/golden-config-setting/{self.global_settings.pk}", url_string) def test_good_graphql_query_invalid_starts_with(self): """Valid graphql query, however invalid in the usage with golden config plugin.""" @@ -277,3 +290,153 @@ def test_edit_line_replace_entry(self): self.assertEqual(self.line_replace.description, new_desc) self.assertEqual(self.line_replace.regex, new_regex) self.assertEqual(self.line_replace.replace, "") + + +class ConfigPlanModelTestCase(TestCase): + """Test ConfigPlan Model.""" + + def setUp(self): + """Setup Object.""" + self.device = create_device() + self.rule = create_feature_rule_json(self.device) + self.feature = self.rule.feature + self.status = Status.objects.get(slug="not-approved") + self.job_result = create_job_result() + + def test_create_config_plan_intended(self): + """Test Create Object.""" + config_plan = ConfigPlan.objects.create( + device=self.device, + plan_type="intended", + config_set="test intended config", + change_control_id="1234", + change_control_url="https://1234.example.com/", + status=self.status, + job_result_id=self.job_result.id, + ) + config_plan.feature.add(self.feature) + config_plan.validated_save() + self.assertEqual(config_plan.device, self.device) + self.assertEqual(config_plan.feature.first(), self.feature) + self.assertEqual(config_plan.config_set, "test intended config") + self.assertEqual(config_plan.change_control_id, "1234") + self.assertEqual(config_plan.status, self.status) + self.assertEqual(config_plan.plan_type, "intended") + + def test_create_config_plan_intended_multiple_features(self): + """Test Create Object.""" + rule2 = create_feature_rule_json(self.device, feature="feature2") + config_plan = ConfigPlan.objects.create( + device=self.device, + plan_type="intended", + config_set="test intended config", + change_control_id="1234", + change_control_url="https://1234.example.com/", + status=self.status, + job_result_id=self.job_result.id, + ) + config_plan.feature.set([self.feature, rule2.feature]) + config_plan.validated_save() + self.assertEqual(config_plan.device, self.device) + self.assertIn(self.feature.id, config_plan.feature.all().values_list("id", flat=True)) + self.assertIn(rule2.feature.id, config_plan.feature.all().values_list("id", flat=True)) + self.assertEqual(config_plan.config_set, "test intended config") + self.assertEqual(config_plan.change_control_id, "1234") + self.assertEqual(config_plan.status, self.status) + self.assertEqual(config_plan.plan_type, "intended") + + def test_create_config_plan_missing(self): + """Test Create Object.""" + config_plan = ConfigPlan.objects.create( + device=self.device, + plan_type="missing", + config_set="test missing config", + change_control_id="2345", + change_control_url="https://2345.example.com/", + status=self.status, + job_result_id=self.job_result.id, + ) + config_plan.feature.add(self.feature) + config_plan.validated_save() + self.assertEqual(config_plan.device, self.device) + self.assertEqual(config_plan.feature.first(), self.feature) + self.assertEqual(config_plan.config_set, "test missing config") + self.assertEqual(config_plan.change_control_id, "2345") + self.assertEqual(config_plan.status, self.status) + self.assertEqual(config_plan.plan_type, "missing") + + def test_create_config_plan_remediation(self): + """Test Create Object.""" + config_plan = ConfigPlan.objects.create( + device=self.device, + plan_type="remediation", + config_set="test remediation config", + change_control_id="3456", + change_control_url="https://3456.example.com/", + status=self.status, + job_result_id=self.job_result.id, + ) + config_plan.feature.add(self.feature) + config_plan.validated_save() + self.assertEqual(config_plan.device, self.device) + self.assertEqual(config_plan.feature.first(), self.feature) + self.assertEqual(config_plan.config_set, "test remediation config") + self.assertEqual(config_plan.change_control_id, "3456") + self.assertEqual(config_plan.status, self.status) + self.assertEqual(config_plan.plan_type, "remediation") + + def test_create_config_plan_manual(self): + """Test Create Object.""" + config_plan = ConfigPlan.objects.create( + device=self.device, + plan_type="manual", + config_set="test manual config", + job_result_id=self.job_result.id, + ) + self.assertEqual(config_plan.device, self.device) + self.assertEqual(config_plan.config_set, "test manual config") + self.assertEqual(config_plan.plan_type, "manual") + + +class RemediationSettingModelTestCase(TestCase): + """Test Remediation Setting Model.""" + + def setUp(self): + """Setup Object.""" + self.platform = Platform.objects.create(slug="cisco_ios") + self.remediation_options = { + "optionA": "someValue", + "optionB": "someotherValue", + "optionC": "anotherValue", + } + + def test_create_remediation_setting_hier(self): + """Test Create Hier Remediation Setting.""" + remediation_setting = RemediationSetting.objects.create( + platform=self.platform, + remediation_type=RemediationTypeChoice.TYPE_HIERCONFIG, + remediation_options=self.remediation_options, + ) + self.assertEqual(remediation_setting.platform, self.platform) + self.assertEqual(remediation_setting.remediation_type, RemediationTypeChoice.TYPE_HIERCONFIG) + self.assertEqual(remediation_setting.remediation_options, self.remediation_options) + + def test_create_remediation_setting_custom(self): + """Test Create Custom Remediation Setting.""" + remediation_setting = RemediationSetting.objects.create( + platform=self.platform, + remediation_type=RemediationTypeChoice.TYPE_CUSTOM, + remediation_options=self.remediation_options, + ) + self.assertEqual(remediation_setting.platform, self.platform) + self.assertEqual(remediation_setting.remediation_type, RemediationTypeChoice.TYPE_CUSTOM) + self.assertEqual(remediation_setting.remediation_options, self.remediation_options) + + def test_create_remediation_setting_default_values(self): + """Test Create Default Remediation Setting""" + remediation_setting = RemediationSetting.objects.create( + platform=self.platform, + ) + self.assertEqual(remediation_setting.platform, self.platform) + self.assertEqual(remediation_setting.remediation_type, RemediationTypeChoice.TYPE_HIERCONFIG) + self.assertEqual(remediation_setting.remediation_options, {}) diff --git a/nautobot_golden_config/tests/test_utilities/test_config_plan.py b/nautobot_golden_config/tests/test_utilities/test_config_plan.py new file mode 100644 index 00000000..0ebdcd9c --- /dev/null +++ b/nautobot_golden_config/tests/test_utilities/test_config_plan.py @@ -0,0 +1,61 @@ +"""Unit tests for the nautobot_golden_config utilities config_plan.""" +import unittest +from unittest.mock import Mock, patch + +from nautobot_golden_config.utilities.config_plan import ( + generate_config_set_from_compliance_feature, + generate_config_set_from_manual, + config_plan_default_status, +) +from nautobot_golden_config.tests.conftest import create_device, create_feature_rule_cli, create_config_compliance + + +class ConfigPlanTest(unittest.TestCase): + """Test Config Plan Utility.""" + + def setUp(self): + """Setup test.""" + mock_feature_compliance = Mock( + return_value={ + "compliant": False, + "ordered_compliant": False, + "missing": "foo missing", + "extra": "", + } + ) + self.patcher = patch("nautobot_golden_config.models.feature_compliance", mock_feature_compliance) + self.patcher.start() + self.addCleanup(self.patcher.stop) + + self.device = create_device(name="config_plan_utility_test") + self.feature_rule = create_feature_rule_cli(self.device) + self.feature_rule.match_config = "foo" + self.feature_rule.save() + self.actual = "foo actual" + self.missing = "foo missing" + self.intended = "foo actual\nfoo missing" + self.config_compliance = create_config_compliance(self.device, self.feature_rule, self.actual, self.intended) + + def test_generate_config_set_from_compliance_feature_intended(self): + """Test generate_config_set_from_compliance_feature.""" + plan_type = "intended" + config_set = generate_config_set_from_compliance_feature(self.device, plan_type, self.feature_rule.feature) + self.assertEqual(config_set, self.intended) + + def test_generate_config_set_from_compliance_feature_missing(self): + """Test generate_config_set_from_compliance_feature.""" + plan_type = "missing" + config_set = generate_config_set_from_compliance_feature(self.device, plan_type, self.feature_rule.feature) + self.assertEqual(config_set, self.missing) + + def test_generate_config_set_from_manual(self): + """Test generate_config_set_from_manual.""" + commands = "hostname {{ obj.name }}" + config_set = generate_config_set_from_manual(self.device, commands) + self.assertEqual(config_set, "hostname config_plan_utility_test") + + def test_config_plan_default_status(self): + """Test config_plan_default_status.""" + status = config_plan_default_status() + self.assertEqual(status.name, "Not Approved") + self.assertEqual(status.slug, "not-approved") diff --git a/nautobot_golden_config/tests/test_views.py b/nautobot_golden_config/tests/test_views.py index cc546009..03757d33 100644 --- a/nautobot_golden_config/tests/test_views.py +++ b/nautobot_golden_config/tests/test_views.py @@ -1,20 +1,23 @@ """Unit tests for nautobot_golden_config views.""" import datetime -from unittest import mock +from unittest import mock, skipIf +from packaging import version from lxml import html +from django.conf import settings from django.test import TestCase from django.urls import reverse from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from nautobot.dcim.models import Device -from nautobot.extras.models import Relationship, RelationshipAssociation +from nautobot.extras.models import Relationship, RelationshipAssociation, Status +from nautobot.utilities.testing import ViewTestCases from nautobot_golden_config import views, models -from .conftest import create_feature_rule_json, create_device_data +from .conftest import create_feature_rule_json, create_device_data, create_job_result User = get_user_model() @@ -292,3 +295,83 @@ def test_csv_export_with_filter(self): device_names_in_export = [entry.split(",")[0] for entry in csv_data[1:]] device_names_in_site_1 = [device.name for device in devices_in_site_1] self.assertEqual(device_names_in_export, device_names_in_site_1) + + +# pylint: disable=too-many-ancestors,too-many-locals +class ConfigPlanTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + # Disabling Create tests because ConfigPlans are created via Job + # ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, +): + """Test ConfigPlan views.""" + + model = models.ConfigPlan + + @classmethod + def setUpTestData(cls): + create_device_data() + device1 = Device.objects.get(name="Device 1") + device2 = Device.objects.get(name="Device 2") + device3 = Device.objects.get(name="Device 3") + + rule1 = create_feature_rule_json(device1, feature="Test Feature 1") + rule2 = create_feature_rule_json(device2, feature="Test Feature 2") + rule3 = create_feature_rule_json(device3, feature="Test Feature 3") + rule4 = create_feature_rule_json(device3, feature="Test Feature 4") + + job_result1 = create_job_result() + job_result2 = create_job_result() + job_result3 = create_job_result() + + not_approved_status = Status.objects.get(slug="not-approved") + approved_status = Status.objects.get(slug="approved") + + plan1 = models.ConfigPlan.objects.create( + device=device1, + plan_type="intended", + config_set="Test Config Set 1", + change_control_id="Test Change Control ID 1", + change_control_url="https://1.example.com/", + status=not_approved_status, + job_result_id=job_result1.id, + ) + plan1.feature.add(rule1.feature) + plan1.validated_save() + plan2 = models.ConfigPlan.objects.create( + device=device2, + plan_type="missing", + config_set="Test Config Set 2", + change_control_id="Test Change Control ID 2", + change_control_url="https://2.example.com/", + status=not_approved_status, + job_result_id=job_result2.id, + ) + plan2.feature.add(rule2.feature) + plan2.validated_save() + plan3 = models.ConfigPlan.objects.create( + device=device3, + plan_type="remediation", + config_set="Test Config Set 3", + change_control_id="Test Change Control ID 3", + change_control_url="https://3.example.com/", + status=not_approved_status, + job_result_id=job_result3.id, + ) + plan3.feature.set([rule3.feature, rule4.feature]) + plan3.validated_save() + + # Used for EditObjectViewTestCase + cls.form_data = { + "change_control_id": "Test Change Control ID 4", + "change_control_url": "https://4.example.com/", + "status": approved_status.pk, + } + + @skipIf(version.parse(settings.VERSION) <= version.parse("1.5.5"), "Bug in 1.5.4 and below") + def test_list_objects_with_permission(self): + """Overriding test for versions < 1.5.5.""" + super().test_list_objects_with_permission() diff --git a/nautobot_golden_config/urls.py b/nautobot_golden_config/urls.py index 0fe57814..684a1784 100644 --- a/nautobot_golden_config/urls.py +++ b/nautobot_golden_config/urls.py @@ -12,6 +12,8 @@ router.register("golden-config-setting", views.GoldenConfigSettingUIViewSet) router.register("config-remove", views.ConfigRemoveUIViewSet) router.register("config-replace", views.ConfigReplaceUIViewSet) +router.register("remediation-setting", views.RemediationSettingUIViewSet) +router.register("config-plan", views.ConfigPlanUIViewSet) urlpatterns = [ @@ -41,4 +43,5 @@ views.ComplianceDeviceFilteredReport.as_view(), name="configcompliance_filter_report", ), + path("config-plan/bulk_deploy/", views.ConfigPlanBulkDeploy.as_view(), name="configplan_bulk_deploy"), ] + router.urls diff --git a/nautobot_golden_config/utilities/config_plan.py b/nautobot_golden_config/utilities/config_plan.py new file mode 100644 index 00000000..49b23445 --- /dev/null +++ b/nautobot_golden_config/utilities/config_plan.py @@ -0,0 +1,45 @@ +"""Functions to support config plan.""" +from nautobot.dcim.models import Device +from nautobot.extras.models import Status +from nautobot.utilities.utils import render_jinja2 + +from nautobot_golden_config.models import ComplianceFeature + + +# TODO: Make the default Status configurable +def config_plan_default_status(): + """Return the default status for config plan.""" + return Status.objects.filter( + content_types__model="configplan", + slug="not-approved", + ).first() + + +def generate_config_set_from_compliance_feature(device: Device, plan_type: str, feature: ComplianceFeature): + """Generate config set from config compliance. + + Args: + device (Device): Device to generate config set for. + plan_type (str): The ConfigCompliance attribute to pull from. + feature (ComplianceFeature): The feature to generate config set for. + """ + # Grab the config compliance for the feature + feature_compliance = device.configcompliance_set.filter(rule__feature=feature).first() + # If the config compliance exists and has the plan type generated, return the config set + if feature_compliance and hasattr(feature_compliance, plan_type) and getattr(feature_compliance, plan_type): + return getattr(feature_compliance, plan_type) + return "" + + +def generate_config_set_from_manual(device: Device, commands: str, context: dict = None): + """Generate config set from manual config set. + + Args: + device (Device): Device to generate config set for. + commands (str): The commands for the generated config set. + context (dict, optional): The context to render the commands with. + """ + if context is None: + context = {} + context.update({"obj": device}) + return render_jinja2(template_code=commands, context=context) diff --git a/nautobot_golden_config/views.py b/nautobot_golden_config/views.py index b38111c2..5de262c6 100644 --- a/nautobot_golden_config/views.py +++ b/nautobot_golden_config/views.py @@ -15,23 +15,28 @@ from django.db.models import Count, ExpressionWrapper, F, FloatField, Max, ProtectedError, Q from django.forms import ModelMultipleChoiceField, MultipleHiddenInput from django.shortcuts import redirect, render +from django.views.generic import View +from django.urls import reverse +from django.utils.html import format_html from django_pivot.pivot import pivot from nautobot.core.views import generic from nautobot.core.views.viewsets import NautobotUIViewSet from nautobot.dcim.forms import DeviceFilterForm from nautobot.dcim.models import Device +from nautobot.extras.jobs import run_job +from nautobot.extras.models import Job, JobResult +from nautobot.extras.utils import get_job_content_type from nautobot.utilities.error_handlers import handle_protectederror from nautobot.utilities.forms import ConfirmationForm -from nautobot.utilities.utils import csv_format -from nautobot.utilities.views import ContentTypePermissionRequiredMixin - +from nautobot.utilities.utils import copy_safe_request, csv_format +from nautobot.utilities.views import ContentTypePermissionRequiredMixin, ObjectPermissionRequiredMixin from nautobot_golden_config import filters, forms, models, tables from nautobot_golden_config.api import serializers +from nautobot_golden_config.jobs import DeployConfigPlans +from nautobot_golden_config.utilities.config_postprocessing import get_config_postprocessing from nautobot_golden_config.utilities.constant import CONFIG_FEATURES, ENABLE_COMPLIANCE, PLUGIN_CFG from nautobot_golden_config.utilities.graphql import graph_ql_query from nautobot_golden_config.utilities.helper import get_device_to_settings_map -from nautobot_golden_config.utilities.config_postprocessing import get_config_postprocessing - LOGGER = logging.getLogger(__name__) @@ -790,3 +795,81 @@ class ConfigReplaceUIViewSet(NautobotUIViewSet): serializer_class = serializers.ConfigReplaceSerializer table_class = tables.ConfigReplaceTable lookup_field = "pk" + + +class RemediationSettingUIViewSet(NautobotUIViewSet): + """Views for the RemediationSetting model.""" + + bulk_create_form_class = forms.RemediationSettingCSVForm + bulk_update_form_class = forms.RemediationSettingBulkEditForm + filterset_class = filters.RemediationSettingFilterSet + filterset_form_class = forms.RemediationSettingFilterForm + form_class = forms.RemediationSettingForm + queryset = models.RemediationSetting.objects.all() + serializer_class = serializers.RemediationSettingSerializer + table_class = tables.RemediationSettingTable + lookup_field = "pk" + + +class ConfigPlanUIViewSet(NautobotUIViewSet): + """Views for the ConfigPlan model.""" + + bulk_update_form_class = forms.ConfigPlanBulkEditForm + filterset_class = filters.ConfigPlanFilterSet + filterset_form_class = forms.ConfigPlanFilterForm + form_class = forms.ConfigPlanForm + queryset = models.ConfigPlan.objects.all() + serializer_class = serializers.ConfigPlanSerializer + table_class = tables.ConfigPlanTable + lookup_field = "pk" + action_buttons = ("add",) + + def get_form_class(self, **kwargs): + """Helper function to get form_class for different views.""" + if self.action == "update": + return forms.ConfigPlanUpdateForm + return super().get_form_class(**kwargs) + + def create(self, request, *args, **kwargs): + """Helper function to warn if the Job is not enabled to run.""" + job = Job.objects.get(name="Generate Config Plans") + if not job.enabled: + messages.warning( + request, + format_html( + "The Job to generate Config Plans is not yet enabled. " + f"Click here to edit the Job." + ), + ) + return super().create(request, *args, **kwargs) + + +class ConfigPlanBulkDeploy(ObjectPermissionRequiredMixin, View): + """View to run the Config Plan Deploy Job.""" + + queryset = models.ConfigPlan.objects.all() + + def get_required_permission(self): + """Permissions required for the view.""" + return "extras.run_job" + + def post(self, request): + """Enqueue the job and redirect to the job results page.""" + config_plan_pks = request.POST.getlist("pk") + if not config_plan_pks: + messages.warning(request, "No Config Plans selected for deployment.") + return redirect("plugins:nautobot_golden_config:configplan_list") + + job_data = {"config_plan": config_plan_pks} + + result = JobResult.enqueue_job( + func=run_job, + name=DeployConfigPlans.class_path, + obj_type=get_job_content_type(), + user=request.user, + data=job_data, + request=copy_safe_request(request), + commit=request.POST.get("commit", False), + ) + + return redirect(result.get_absolute_url()) diff --git a/poetry.lock b/poetry.lock index c34042e7..89c76ad6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1511,13 +1511,13 @@ six = ">=1.12" [[package]] name = "griffe" -version = "0.35.1" +version = "0.35.2" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." optional = false python-versions = ">=3.8" files = [ - {file = "griffe-0.35.1-py3-none-any.whl", hash = "sha256:ff580073a71793cc58ed1fad696aee49c4bd9e637d3e0cde5b39a269ad8e59e4"}, - {file = "griffe-0.35.1.tar.gz", hash = "sha256:1e3bf605344ab32fe2729161bb4f7761996684f838dfd5a7c60af03a0b20375f"}, + {file = "griffe-0.35.2-py3-none-any.whl", hash = "sha256:9650d6d0369c22f29f2c1bec9548ddc7f448f8ca38698a5799f92f736824e749"}, + {file = "griffe-0.35.2.tar.gz", hash = "sha256:84ecfe3df17454993b8dd485201566609ac6706a2eb22e3f402da2a39f9f6b5f"}, ] [package.dependencies] @@ -1534,6 +1534,20 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "hier-config" +version = "2.2.2" +description = "A network configuration comparison tool, used to build remediation configurations." +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "hier-config-2.2.2.tar.gz", hash = "sha256:a394f6783de2f93f641cbb3a819da931585281fed81cfc7adc71268eb340c632"}, + {file = "hier_config-2.2.2-py3-none-any.whl", hash = "sha256:cb5af71a765cb92d7478cb3695291220d9680696fbc77a790089ec8ca1f743cd"}, +] + +[package.dependencies] +PyYAML = ">=5.4" + [[package]] name = "httpcore" version = "0.17.3" @@ -2495,19 +2509,22 @@ nautobot = ">=1.2.0,<2.0.0" [[package]] name = "nautobot-plugin-nornir" -version = "1.0.0" +version = "1.0.1" description = "Nautobot Nornir plugin." optional = false -python-versions = ">=3.6,<4.0" +python-versions = ">=3.7,<4.0" files = [ - {file = "nautobot-plugin-nornir-1.0.0.tar.gz", hash = "sha256:d9301329decd7e4b9b5578c72e05c44ac84fc747002135b99538152a5ae27de3"}, - {file = "nautobot_plugin_nornir-1.0.0-py3-none-any.whl", hash = "sha256:14e4098bf0a4d0d5ca3c550e75c6acf88502fb774791865488a9eaac5eff402e"}, + {file = "nautobot_plugin_nornir-1.0.1-py3-none-any.whl", hash = "sha256:b19aff3fad27c9d7ab49f1f07f740236e95502d27371e60032950110264c34bd"}, + {file = "nautobot_plugin_nornir-1.0.1.tar.gz", hash = "sha256:a39ebc42fd90657294e909e7041f492a35cdce436d73db54468eea4e04d65963"}, ] [package.dependencies] -nautobot = ">=1.2.0" +importlib-metadata = "4.13.0" netutils = ">=1.0.0" -nornir-nautobot = ">=2.2.0,<3.0.0" +nornir-nautobot = ">=2.6.0,<3.0.0" + +[package.extras] +nautobot = ["nautobot (>=1.4.0,<2.0.0)"] [[package]] name = "ncclient" @@ -3191,19 +3208,22 @@ pylint = ">=1.7" [[package]] name = "pymdown-extensions" -version = "10.1" +version = "10.2" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.7" files = [ - {file = "pymdown_extensions-10.1-py3-none-any.whl", hash = "sha256:ef25dbbae530e8f67575d222b75ff0649b1e841e22c2ae9a20bad9472c2207dc"}, - {file = "pymdown_extensions-10.1.tar.gz", hash = "sha256:508009b211373058debb8247e168de4cbcb91b1bff7b5e961b2c3e864e00b195"}, + {file = "pymdown_extensions-10.2-py3-none-any.whl", hash = "sha256:fbb86243db9a681602e3b869deef000211c55d0261015a5cc41d6f34d2afc57f"}, + {file = "pymdown_extensions-10.2.tar.gz", hash = "sha256:06042274876eb4267f12a389daf505eabaebc38bdca26725560c9afda5867549"}, ] [package.dependencies] markdown = ">=3.2" pyyaml = "*" +[package.extras] +extra = ["pygments (>=2.12)"] + [[package]] name = "pynacl" version = "1.5.0" @@ -3232,17 +3252,18 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] [[package]] name = "pynautobot" -version = "1.4.0" +version = "1.5.0" description = "Nautobot API client library" optional = false python-versions = ">=3.7,<4.0" files = [ - {file = "pynautobot-1.4.0-py3-none-any.whl", hash = "sha256:6bc053b095728ed0af40d097a7513c3e16c51ec63aad46f691f50b3f6c82bdfe"}, - {file = "pynautobot-1.4.0.tar.gz", hash = "sha256:87c93976248f99f2adc0e22d7a39e7f0aac3460451607078bfee93742742c9d4"}, + {file = "pynautobot-1.5.0-py3-none-any.whl", hash = "sha256:aa5bdf18148d82715b26e1a7abf0796bb28da05fece3d206b6f42749d2f466b1"}, + {file = "pynautobot-1.5.0.tar.gz", hash = "sha256:50ac1e12f377ce2f1d156056e9ec3333c8a74bf6269e145889606da92b8752b4"}, ] [package.dependencies] -requests = ">=2.20.0,<3.0.0" +requests = ">=2.30.0,<3.0.0" +urllib3 = ">=1.21.1,<1.27" [[package]] name = "pyparsing" @@ -3444,6 +3465,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -3451,8 +3473,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -3469,6 +3498,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -3476,6 +3506,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -3804,18 +3835,18 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( [[package]] name = "singledispatch" -version = "4.0.0" +version = "4.1.0" description = "Backport functools.singledispatch to older Pythons." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "singledispatch-4.0.0-py2.py3-none-any.whl", hash = "sha256:b8f69397a454b45b91e2f949fcc87896c53718ca59aab6367966e8b3f010ec77"}, - {file = "singledispatch-4.0.0.tar.gz", hash = "sha256:f3c327a968651a7f4b03586eab7d90a07b05ff3ef7942d1967036eb9f75ab8fc"}, + {file = "singledispatch-4.1.0-py2.py3-none-any.whl", hash = "sha256:6061bd291204beaeac90cdbc342b68d213b7a6efb44ae6c5e6422a78be351c8a"}, + {file = "singledispatch-4.1.0.tar.gz", hash = "sha256:f3430b886d5b4213d07d715096a75da5e4a8105284c497b9aee6d6d48bfe90cb"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"] [[package]] name = "six" @@ -4129,20 +4160,19 @@ files = [ [[package]] name = "urllib3" -version = "2.0.4" +version = "1.26.16" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, - {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, + {file = "urllib3-1.26.16-py2.py3-none-any.whl", hash = "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f"}, + {file = "urllib3-1.26.16.tar.gz", hash = "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "vine" @@ -4338,4 +4368,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.12" -content-hash = "65595d9d3c9189035c24128539cd3a0d6589801fa3d3d767d8f89e5cd039e78e" +content-hash = "842aea8162d6866e37d36f334e6a4c125cdde114a087275d506427565373b4e6" diff --git a/pyproject.toml b/pyproject.toml index 59aacfd1..e85aede0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,10 @@ django-pivot = "^1.8.1" matplotlib = "^3.3.2" nautobot = "^1.6.1" nautobot-plugin-nornir = ">=1.0.0" +netutils = "^1.5.0" +# Already a dependecy of nautobot-plugin-nornir, but adding the required minimum version needed +nornir-nautobot = "^2.6.0" +hier-config = "^2.2.2" nautobot-capacity-metrics = "2.0.0" [tool.poetry.group.dev.dependencies]