diff --git a/.flake8 b/.flake8
index aaa63b60..e3ba27d5 100644
--- a/.flake8
+++ b/.flake8
@@ -1,4 +1,4 @@
[flake8]
# E501: Line length is enforced by Black, so flake8 doesn't need to check it
# W503: Black disagrees with this rule, as does PEP 8; Black wins
-ignore = E501, W503, F811, F401, F405, E203
+ignore = E501, W503
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/development.env b/development/development.env
index 080a7c71..b6023644 100644
--- a/development/development.env
+++ b/development/development.env
@@ -48,4 +48,6 @@ ENABLE_INTENDED=True
ENABLE_BACKUP=True
ENABLE_SOTAGG=True
ENABLE_POSTPROCESSING=True
+ENABLE_PLAN=True
+ENABLE_DEPLOY=True
ALLOWED_OS=all
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/development/nautobot_config.py b/development/nautobot_config.py
index 8f5d1109..e30defc8 100644
--- a/development/nautobot_config.py
+++ b/development/nautobot_config.py
@@ -4,11 +4,9 @@
import sys
from django.utils.module_loading import import_string
-
from nautobot.core.settings import * # noqa: F403
from nautobot.core.settings_funcs import is_truthy, parse_redis_connection
-
#
# Misc. settings
#
@@ -172,8 +170,10 @@
"enable_compliance": is_truthy(os.environ.get("ENABLE_COMPLIANCE", True)),
"enable_intended": is_truthy(os.environ.get("ENABLE_INTENDED", True)),
"enable_sotagg": is_truthy(os.environ.get("ENABLE_SOTAGG", True)),
- "sot_agg_transposer": os.environ.get("SOT_AGG_TRANSPOSER"),
"enable_postprocessing": is_truthy(os.environ.get("ENABLE_POSTPROCESSING", True)),
+ "enable_plan": is_truthy(os.environ.get("ENABLE_PLAN", True)),
+ "enable_deploy": is_truthy(os.environ.get("ENABLE_DEPLOY", True)),
+ "sot_agg_transposer": os.environ.get("SOT_AGG_TRANSPOSER"),
"postprocessing_callables": os.environ.get("POSTPROCESSING_CALLABLES", []),
"postprocessing_subscribed": os.environ.get("POSTPROCESSING_SUBSCRIBED", []),
"jinja_env": {
@@ -198,7 +198,7 @@
# Modify django_jinja Environment for test cases
django_jinja_config = None
-for template in TEMPLATES:
+for template in TEMPLATES: # noqa: F405
if template["BACKEND"].startswith("django_jinja"):
django_jinja_config = template
@@ -211,4 +211,4 @@
jinja_options["undefined"] = "jinja2.StrictUndefined"
# Import filter function to have it register filter with django_jinja
-from nautobot_golden_config.tests import jinja_filters # noqa: E402
+from nautobot_golden_config.tests import jinja_filters # noqa: E402, F401
diff --git a/docs/admin/admin_install.md b/docs/admin/admin_install.md
index b742f8f5..00a88c3d 100644
--- a/docs/admin/admin_install.md
+++ b/docs/admin/admin_install.md
@@ -54,8 +54,10 @@ PLUGINS_CONFIG = {
"enable_compliance": True,
"enable_intended": True,
"enable_sotagg": True,
- "sot_agg_transposer": None,
+ "enable_plan": True,
+ "enable_deploy": True,
"enable_postprocessing": False,
+ "sot_agg_transposer": None,
"postprocessing_callables": [],
"postprocessing_subscribed": [],
"platform_slug_map": None,
@@ -90,7 +92,7 @@ sudo systemctl restart nautobot nautobot-worker nautobot-scheduler
The plugin behavior can be controlled with the following list of settings.
!!! note
- The `enable_backup`, `enable_compliance`, `enable_intended`, `enable_sotagg` and `enable_postprocessing` will toggle inclusion of the entire component.
+ The `enable_backup`, `enable_compliance`, `enable_intended`, `enable_sotagg`, `enable_plan`, `enable_deploy`, and `enable_postprocessing` will toggle inclusion of the entire component.
| Key | Example | Default | Description |
| ------------------------- | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -98,6 +100,8 @@ The plugin behavior can be controlled with the following list of settings.
| enable_compliance | True | True | A boolean to represent whether or not to run the compliance process within the plugin. |
| enable_intended | True | True | A boolean to represent whether or not to generate intended configurations within the plugin. |
| enable_sotagg | True | True | A boolean to represent whether or not to provide a GraphQL query per device to allow the intended configuration to provide data variables to the plugin. |
+| enable_plan | True | True | A boolean to represent whether or not to allow the config plan job to run. |
+| enable_deploy | True | True | A boolean to represent whether or not to be able to deploy configs to network devices. |
| enable_postprocessing | True | False | A boolean to represent whether or not to generate intended configurations to push, with extra processing such as secrets rendering. |
| postprocessing_callables | ['mypackage.myfunction'] | [] | A list of function paths, in dotted format, that are appended to the available methods for post-processing the intended configuration, for instance, the `render_secrets`. |
| postprocessing_subscribed | ['mypackage.myfunction'] | [] | A list of function paths, that should exist as postprocessing_callables, that defines the order of application of during the post-processing process. |
diff --git a/docs/admin/compatibility_matrix.md b/docs/admin/compatibility_matrix.md
index 5b6e64ae..2063d9fc 100644
--- a/docs/admin/compatibility_matrix.md
+++ b/docs/admin/compatibility_matrix.md
@@ -16,3 +16,4 @@ While that last supported version will not be strictly enforced via the `max_ver
| 1.3.X | 1.4.0 | 1.5.2 [Official] |
| 1.4.X | 1.5.3 | 1.5.99 [Official] |
| 1.5.X | 1.6.1 | 1.6.99 [Official] |
+| 1.6.X | 1.6.1 | 1.6.99 [Official] |
diff --git a/docs/admin/release_notes/version_1.6.md b/docs/admin/release_notes/version_1.6.md
new file mode 100755
index 00000000..2bed33b4
--- /dev/null
+++ b/docs/admin/release_notes/version_1.6.md
@@ -0,0 +1,21 @@
+# v1.6 Release Notes
+
+- Add ability to generate ConfigPlans for configurations that need to be deployed, based on multiple plan types.
+- Add a job that can deploy config_set based on a generated ConfigPlan object.
+- Add functionality to compliance result to provide a Remediation plan.
+- Supports Nautobot >=1.6.1,<2.0.0.
+
+## v1.6.0 - 2023-09
+
+### Added
+
+- [#573](https://github.com/nautobot/nautobot-plugin-golden-config/pull/573) - Added the ability to generate remediation configurations and store in ConfigRemediation model
+- [#573](https://github.com/nautobot/nautobot-plugin-golden-config/pull/573) - Added the ability to generate configurations that you plan to deploy from a variety of methods, such as Remediation, intended, manual, etc. via the ConfigPlan model.
+- [#573](https://github.com/nautobot/nautobot-plugin-golden-config/pull/573) - Added the ability to Deploy configurations from the ConfigPlan configurations to your network devices.
+
+### Fixed
+
+- [#585](https://github.com/nautobot/nautobot-plugin-golden-config/pull/585) - Remove Jquery dependency from Google APIs, inherit from Nautobot core instead.
+- [#577](https://github.com/nautobot/nautobot-plugin-golden-config/pull/577) - Fixed various forms fields and filters fields.
+- [#577](https://github.com/nautobot/nautobot-plugin-golden-config/pull/577) - Updated default has_sensitive_data boolean to False.
+- [#577](https://github.com/nautobot/nautobot-plugin-golden-config/pull/577) - Added warning message on views when required jobs are not enabled.
diff --git a/docs/dev/dev_adr.md b/docs/dev/dev_adr.md
index 88f6f593..a8591a0b 100644
--- a/docs/dev/dev_adr.md
+++ b/docs/dev/dev_adr.md
@@ -143,3 +143,17 @@ This function performs an additional permission validation, to check if the requ
Over time device(s) platform may change; whether this is a device refresh or full replacement. A Django `post_save` signal is used on the `ConfigCompliance` model and provides a reliable and efficient way to manage configuration compliance objects. This signal deletes any `ConfigCompliance` objects that don't match the current platform. This decision was made to avoid compliance reporting inconsistencies that can arise when outdated or irrelevant objects remain in the database which were generated with the previous platform.
This has a computational impact when updating a Device object's platform. This is similar to the computational impact of an SQL `cascade` option on a delete. This is largely unavoidable and should be limited in impact, such that it will only be the removal of the number of `ConfigCompliance` objects, which is no bigger than the number of `Config Features`, which is generally intended to be a small amount.
+
+### Configuration Deployment and Remediation
+
+Configuration remediation and deployments of any of the attributes based on the configuration compliance object are calculated based on the last run of the `ConfigCompliance` job. After a configuration deployment to fix any of these attributes (remediation, intended, missing) a new `ConfigCompliance` job must be run before all the compliance results will be updated.
+
+
+### Manual ConfigPlans
+
+When generating a manual `ConfigPlan` the Jinja2 template render has access to Django ORM methods like `.all()`, this also means that methods like `.delete()` can be called, the `render_template` functionality used by Golden Config inherits a Jinja2 Sandbox exception that will block unsafe calls. Golden Config will simply re-raise the exception `jinja2.exceptions.SecurityError: > is not safely callable`.
+
+
+### Hidden Jobs and JobButtons
+
+The configuration deployment and plans features of Golden Config come packaged with Jobs and JobButtons to execute the functionality. In order to to provide a repeatable and consistent behavior these Jobs and JobButtons are designed to only be executed via specialized views. They're not intended to be executed manually from the Jobs/JobButtons menus.
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/docs/user/app_getting_started.md b/docs/user/app_getting_started.md
index 66f6718d..9ee193e9 100644
--- a/docs/user/app_getting_started.md
+++ b/docs/user/app_getting_started.md
@@ -4,6 +4,9 @@
- [Backup Configuration](#backup-configuration)
- [Intended Configuration](#intended-configuration)
- [Compliance](#compliance)
+- [Config Remediation](#config-remediation)
+- [Config Plans](#config-plans)
+- [Config Deploy](#config-deploy)
- [Load Properties from Git](#load-properties-from-git)
# Backup Configuration
@@ -122,6 +125,53 @@ Compliance requires Backups and Intended Configurations in order to be executed.
> For in-depth details see [Navigating Compliance](./app_feature_compliance.md)
+# Config Remediation
+
+Follow the steps below to get up and running for the configuration remediation element of the plugin.
+
+1. Navigate to `Golden Config -> Compliance Rules`.
+2. Select the rules in which you'd like to enable remediation on.
+3. Edit the `Compliance Rule` and turn on the `Remediation` toggle button.
+4. Run the `Compliance` job again which will generate the initial remediation plan for the feature.
+5. Navigate to `Golden Config -> Config Compliance`, select the device and notice a remediation section is not present for the compliance details for the feature.
+
+> For in-depth details see [Navigating Config Plans](./app_feature_remediation.md)
+
+# Config Plans
+
+Follow the steps below to get up and running for the configuration plans element of the plugin.
+
+1. Enable the feature in the `PLUGIN_SETTINGS`. The configuration should have `"enable_plan": True` set in the `PLUGINS_CONFIG` dictionary for `nautobot_golden_config`.
+2. Follow the steps in [Compliance](#compliance).
+ - Compliance is necessary if ConfigPlans will be generated utilizing any of the attributes provided by a Compliance object.
+ - This step may be skipped if only `manual` ConfigPlans are going to be generated.
+3. Create a ConfigPlan
+
+ 1. Navigate to `Golden Config -> Config Plans`
+ 2. Click on `ADD` button.
+ 3. Fill out the plan details and plan filters.
+ - The options dynamically change in the form based on the `plan type` selected.
+ - If the `plan type` is Intended, Remediation, Missing.
+ - Select the Compliance Features to use to generate the plan. If none are selected all features will be in scope.
+ - If the `plan type` is Manual.
+ - Create a manual plan to accomplish the goal. Note: Access to `obj` is available to dynamically populate fields via Jinja2 syntax.
+ 4. Click `Generate`
+
+> For in-depth details see [Navigating Config Plans](./app_feature_config_plans.md)
+
+# Config Deploy
+
+Follow the steps below to get up and running for the configuration deployment element of the plugin.
+
+1. Enable the feature in the `PLUGIN_SETTINGS`. The configuration should have `"enable_deploy": True` set in the `PLUGINS_CONFIG` dictionary for `nautobot_golden_config`.
+2. Follow the steps in [Config Plans](#config-plans).
+3. Navigate to the specific ConfigPlan to deploy, or multi-select them from the ConfigPlan list view.
+ - If deploying from a specific ConfigPlan object. Click `Deploy` button and accept the warnings.
+ - If deploying from the ConfigPlan list view. Click `Deploy Selected` button and accept the warnings.
+4. Interpret the results from the popup modal and navigate to the job result as needed for more details.
+
+> Config Deployments utilize the dispatchers from nornir-nautobot just like the other functionality of Golden Config. See [Troubleshooting Dispatchers](./troubleshooting/troubleshoot_dispatchers.md) for more details.
+
# Load Properties from Git
Golden Config properties include: Compliance Features, Compliance Rules, Config Removals, and Config Replacements. They can be created via the UI, API, or alternatively you can load these properties from a Git repository, defined in YAML files following the this directory structure (you can skip any of them if not apply):
diff --git a/docs/user/troubleshooting/troubleshoot_general.md b/docs/user/troubleshooting/troubleshoot_general.md
index 934921d5..1aefdd46 100755
--- a/docs/user/troubleshooting/troubleshoot_general.md
+++ b/docs/user/troubleshooting/troubleshoot_general.md
@@ -64,3 +64,7 @@ If none of these troubleshooting steps helped identify the problem please visit
- [Troubleshoot Credentials](./troubleshoot_credentials.md)
- [Troubleshoot Dispatchers](./troubleshoot_dispatchers.md)
+
+### Detailed Diagrams
+
+Golden config flow diagrams are available on the repositories [wiki](https://github.com/nautobot/nautobot-plugin-golden-config/wiki#diagrams).
diff --git a/mkdocs.yml b/mkdocs.yml
index 37eb0098..3ca814fe 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"
@@ -118,6 +120,7 @@ nav:
- Compatibility Matrix: "admin/compatibility_matrix.md"
- Release Notes:
- "admin/release_notes/index.md"
+ - v1.6: "admin/release_notes/version_1.6.md"
- v1.5: "admin/release_notes/version_1.5.md"
- v1.4: "admin/release_notes/version_1.4.md"
- v1.3: "admin/release_notes/version_1.3.md"
diff --git a/nautobot_golden_config/__init__.py b/nautobot_golden_config/__init__.py
index cf32a8c2..b65ff353 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
@@ -18,7 +19,7 @@ class GoldenConfig(PluginConfig):
version = __version__
author = "Network to Code, LLC"
author_email = "opensource@networktocode.com"
- description = "Nautobot Apps that embraces NetDevOps and automates configuration backups, performs configuration compliance, and generates intended configurations. Includes native Git integration and gives users the flexibility to mix and match the supported features."
+ description = "Nautobot Apps that embraces NetDevOps and automates configuration backups, performs configuration compliance, generates intended configurations, and has config remediation and deployment features. Includes native Git integration and gives users the flexibility to mix and match the supported features."
base_url = "golden-config"
min_version = "1.6.1"
max_version = "1.99"
@@ -28,6 +29,8 @@ class GoldenConfig(PluginConfig):
"enable_intended": True,
"enable_sotagg": True,
"enable_postprocessing": False,
+ "enable_plan": True,
+ "enable_deploy": True,
"postprocessing_callables": [],
"postprocessing_subscribed": [],
"per_feature_bar_width": 0.3,
@@ -45,7 +48,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/filters.py b/nautobot_golden_config/filters.py
index 43bf7c0d..f5316012 100644
--- a/nautobot_golden_config/filters.py
+++ b/nautobot_golden_config/filters.py
@@ -2,14 +2,12 @@
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 MultiValueDateTimeFilter, TagFilter, TreeNodeMultipleChoiceFilter
from nautobot_golden_config import models
@@ -363,3 +361,102 @@ class Meta:
model = models.GoldenConfigSetting
fields = ["id", "name", "slug", "weight", "backup_repository", "intended_repository", "jinja_repository"]
+
+
+class RemediationSettingFilterSet(NautobotFilterSet):
+ """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",
+ )
+
+ 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", "remediation_type"]
+
+
+class ConfigPlanFilterSet(NautobotFilterSet):
+ """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",
+ )
+ plan_result_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=JobResult.objects.filter(config_plan__isnull=False).distinct(),
+ label="Plan JobResult ID",
+ )
+ deploy_result_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=JobResult.objects.filter(config_plan__isnull=False).distinct(),
+ label="Deploy 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", "created", "change_control_id", "plan_type"]
diff --git a/nautobot_golden_config/forms.py b/nautobot_golden_config/forms.py
index 8539df8f..b4463dde 100644
--- a/nautobot_golden_config/forms.py
+++ b/nautobot_golden_config/forms.py
@@ -1,18 +1,19 @@
"""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 +124,8 @@ class Meta:
"config_type",
"match_config",
"custom_compliance",
+ "config_remediation",
+ "tags",
)
@@ -145,6 +148,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."""
@@ -174,7 +185,7 @@ class Meta:
"""Boilerplate form Meta data for compliance feature."""
model = models.ComplianceFeature
- fields = ("name", "slug", "description")
+ fields = ("name", "slug", "description", "tags")
class ComplianceFeatureFilterForm(NautobotFilterForm):
@@ -191,6 +202,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."""
@@ -225,6 +237,7 @@ class Meta:
"name",
"description",
"regex",
+ "tags",
)
@@ -244,6 +257,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."""
@@ -279,6 +293,7 @@ class Meta:
"description",
"regex",
"replace",
+ "tags",
)
@@ -309,6 +324,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."""
@@ -322,29 +338,14 @@ class Meta:
class GoldenConfigSettingForm(NautobotModelForm):
"""Filter Form for GoldenConfigSettingForm instances."""
- slug = SlugField()
dynamic_group = utilities_forms.DynamicModelChoiceField(queryset=DynamicGroup.objects.all(), required=False)
class Meta:
"""Filter Form Meta Data for GoldenConfigSettingForm instances."""
model = models.GoldenConfigSetting
- fields = (
- "name",
- "slug",
- "weight",
- "description",
- "backup_repository",
- "backup_path_template",
- "intended_repository",
- "intended_path_template",
- "jinja_repository",
- "jinja_path_template",
- "backup_test_connectivity",
- "dynamic_group",
- "sot_agg_query",
- "tags",
- )
+ fields = "__all__"
+ exclude = ["_custom_field_data"] # pylint: disable=modelform-uses-exclude
class GoldenConfigSettingFilterForm(NautobotFilterForm):
@@ -390,3 +391,212 @@ 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 = "__all__"
+ exclude = ["_custom_field_data"] # pylint: disable=modelform-uses-exclude
+
+
+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, widget=forms.Select(), 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."""
+
+ 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 }}"
+ ),
+ )
+
+ 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 = "__all__"
+ exclude = ["_custom_field_data"] # pylint: disable=modelform-uses-exclude
+
+
+class ConfigPlanUpdateForm(NautobotModelForm):
+ """Form for ConfigPlan instances."""
+
+ status = utilities_forms.DynamicModelChoiceField(
+ queryset=Status.objects.all(),
+ query_params={"content_types": models.ConfigPlan._meta.label_lower},
+ )
+ tags = 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",
+ "status",
+ "tags",
+ )
+
+
+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")
+ plan_result_id = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=JobResult.objects.all(),
+ query_params={"name": "plugins/nautobot_golden_config.jobs/GenerateConfigPlans"},
+ label="Plan Result",
+ required=False,
+ display_field="created",
+ )
+ deploy_result_id = utilities_forms.DynamicModelMultipleChoiceField(
+ queryset=JobResult.objects.all(),
+ query_params={"name": "plugins/nautobot_golden_config.jobs/DeployConfigPlans"},
+ label="Deploy Result",
+ required=False,
+ display_field="created",
+ )
+ 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..c4441a6d 100644
--- a/nautobot_golden_config/jobs.py
+++ b/nautobot_golden_config/jobs.py
@@ -5,13 +5,32 @@
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 import constant
+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.git import GitRepo
from nautobot_golden_config.utilities.helper import get_job_filter
@@ -80,26 +99,12 @@ class FormEntry: # pylint disable=too-few-public-method
class ComplianceJob(Job, FormEntry):
"""Job to to run the compliance engine."""
- 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
-
class Meta:
"""Meta object boilerplate for compliance."""
name = "Perform Configuration Compliance"
description = "Run configuration compliance on your network infrastructure."
+ has_sensitive_variables = False
@commit_check
def run(self, data, commit): # pylint: disable=too-many-branches
@@ -119,26 +124,12 @@ def run(self, data, commit): # pylint: disable=too-many-branches
class IntendedJob(Job, FormEntry):
"""Job to to run generation of intended configurations."""
- 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
-
class Meta:
"""Meta object boilerplate for intended."""
name = "Generate Intended Configurations"
description = "Generate the configuration for your intended state."
+ has_sensitive_variables = False
@commit_check
def run(self, data, commit):
@@ -167,26 +158,12 @@ def run(self, data, commit):
class BackupJob(Job, FormEntry):
"""Job to to run the backup job."""
- 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
-
class Meta:
"""Meta object boilerplate for backup configurations."""
name = "Backup Configurations"
description = "Backup the configurations of your network devices."
+ has_sensitive_variables = False
@commit_check
def run(self, data, commit):
@@ -221,59 +198,209 @@ class Meta:
name = "Execute All Golden Configuration Jobs - Single Device"
description = "Process to run all Golden Configuration jobs configured."
+ has_sensitive_variables = False
@commit_check
def run(self, data, commit):
"""Run all jobs."""
- if ENABLE_INTENDED:
+ if constant.ENABLE_INTENDED:
IntendedJob().run.__func__(self, data, True) # pylint: disable=too-many-function-args
- if ENABLE_BACKUP:
+ if constant.ENABLE_BACKUP:
BackupJob().run.__func__(self, data, True) # pylint: disable=too-many-function-args
- if ENABLE_COMPLIANCE:
+ if constant.ENABLE_COMPLIANCE:
ComplianceJob().run.__func__(self, data, True) # pylint: disable=too-many-function-args
class AllDevicesGoldenConfig(Job):
"""Job to to run all three jobs against multiple devices."""
- 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
-
class Meta:
"""Meta object boilerplate for all jobs to run against multiple devices."""
name = "Execute All Golden Configuration Jobs - Multiple Device"
description = "Process to run all Golden Configuration jobs configured against multiple devices."
+ has_sensitive_variables = False
@commit_check
def run(self, data, commit):
"""Run all jobs."""
- if ENABLE_INTENDED:
+ if constant.ENABLE_INTENDED:
IntendedJob().run.__func__(self, data, True) # pylint: disable=too-many-function-args
- if ENABLE_BACKUP:
+ if constant.ENABLE_BACKUP:
BackupJob().run.__func__(self, data, True) # pylint: disable=too-many-function-args
- if ENABLE_COMPLIANCE:
+ if constant.ENABLE_COMPLIANCE:
ComplianceJob().run.__func__(self, data, True) # pylint: disable=too-many-function-args
+class GenerateConfigPlans(Job, FormEntry):
+ """Job to generate config plans."""
+
+ # 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."
+ has_sensitive_variables = False
+ # 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,
+ plan_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,
+ plan_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."
+ has_sensitive_variables = False
+
+ def run(self, data, commit):
+ """Run config plan deployment process."""
+ self.log_debug("Starting config plan deployment job.")
+ config_deployment(self, data, commit)
+
+
+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)"
+ has_sensitive_variables = False
+
+ 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)
+
+
# Conditionally allow jobs based on whether or not turned on.
jobs = []
-if ENABLE_BACKUP:
+if constant.ENABLE_BACKUP:
jobs.append(BackupJob)
-if ENABLE_INTENDED:
+if constant.ENABLE_INTENDED:
jobs.append(IntendedJob)
-if ENABLE_COMPLIANCE:
+if constant.ENABLE_COMPLIANCE:
jobs.append(ComplianceJob)
-jobs.extend([AllGoldenConfig, AllDevicesGoldenConfig])
+if constant.ENABLE_PLAN:
+ jobs.append(GenerateConfigPlans)
+if constant.ENABLE_DEPLOY:
+ jobs.append(DeployConfigPlans)
+ jobs.append(DeployConfigPlanJobButtonReceiver)
+jobs.extend(
+ [
+ AllGoldenConfig,
+ AllDevicesGoldenConfig,
+ ]
+)
diff --git a/nautobot_golden_config/migrations/0002_custom_data.py b/nautobot_golden_config/migrations/0002_custom_data.py
index 1435fbd8..d96c71f0 100644
--- a/nautobot_golden_config/migrations/0002_custom_data.py
+++ b/nautobot_golden_config/migrations/0002_custom_data.py
@@ -1,6 +1,6 @@
# Generated by Django 3.1.3 on 2021-02-22 01:27
-from django.db import migrations, models
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/nautobot_golden_config/migrations/0015_convert_sotagg_queries_part2.py b/nautobot_golden_config/migrations/0015_convert_sotagg_queries_part2.py
index c0b4d6c1..a1244046 100644
--- a/nautobot_golden_config/migrations/0015_convert_sotagg_queries_part2.py
+++ b/nautobot_golden_config/migrations/0015_convert_sotagg_queries_part2.py
@@ -1,4 +1,4 @@
-from django.db import migrations, models
+from django.db import migrations
def save_existing_sotagg_queries(apps, schema_editor):
diff --git a/nautobot_golden_config/migrations/0016_convert_sotagg_queries_part3.py b/nautobot_golden_config/migrations/0016_convert_sotagg_queries_part3.py
index 0fc55074..93c96f4f 100644
--- a/nautobot_golden_config/migrations/0016_convert_sotagg_queries_part3.py
+++ b/nautobot_golden_config/migrations/0016_convert_sotagg_queries_part3.py
@@ -1,7 +1,5 @@
-from datetime import date
-
-from django.db import migrations, models
import django.db.models.deletion
+from django.db import migrations, models
class Migration(migrations.Migration):
diff --git a/nautobot_golden_config/migrations/0017_convert_sotagg_queries_part4.py b/nautobot_golden_config/migrations/0017_convert_sotagg_queries_part4.py
index c90d5b9c..1ba69ceb 100644
--- a/nautobot_golden_config/migrations/0017_convert_sotagg_queries_part4.py
+++ b/nautobot_golden_config/migrations/0017_convert_sotagg_queries_part4.py
@@ -1,7 +1,6 @@
-from datetime import date
import logging
+from datetime import date
-from django.core.validators import ValidationError
from django.db import migrations
logger = logging.getLogger("nautobot")
diff --git a/nautobot_golden_config/migrations/0020_convert_dynamicgroup_part_2.py b/nautobot_golden_config/migrations/0020_convert_dynamicgroup_part_2.py
index 639d82ca..03125222 100644
--- a/nautobot_golden_config/migrations/0020_convert_dynamicgroup_part_2.py
+++ b/nautobot_golden_config/migrations/0020_convert_dynamicgroup_part_2.py
@@ -1,8 +1,7 @@
# Generated by Django 3.2.14 on 2022-07-11 14:18
-from django.db import migrations, models
+from django.db import migrations
from django.utils.text import slugify
-import django.db.models.deletion
def create_dynamic_groups(apps, schedma_editor):
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..b2e6b12c
--- /dev/null
+++ b/nautobot_golden_config/migrations/0025_remediation_settings.py
@@ -0,0 +1,66 @@
+# Generated by Django 3.2.16 on 2023-07-07 09:21
+
+import uuid
+
+import django.core.serializers.json
+import django.db.models.deletion
+import nautobot.extras.models.mixins
+import taggit.managers
+from django.db import migrations, models
+
+
+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..5ca9c529
--- /dev/null
+++ b/nautobot_golden_config/migrations/0026_configplan.py
@@ -0,0 +1,75 @@
+# Generated by Django 3.2.20 on 2023-09-01 14:51
+
+import uuid
+
+import django.core.serializers.json
+import django.db.models.deletion
+import nautobot.extras.models.mixins
+import nautobot.extras.models.statuses
+import taggit.managers
+from django.db import migrations, models
+
+
+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/migrations/0027_auto_20230915_1657.py b/nautobot_golden_config/migrations/0027_auto_20230915_1657.py
new file mode 100644
index 00000000..aa5308e4
--- /dev/null
+++ b/nautobot_golden_config/migrations/0027_auto_20230915_1657.py
@@ -0,0 +1,40 @@
+# Generated by Django 3.2.20 on 2023-09-15 16:57
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("extras", "0058_jobresult_add_time_status_idxs"),
+ ("nautobot_golden_config", "0026_configplan"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="remediationsetting",
+ options={"ordering": ("platform", "remediation_type")},
+ ),
+ migrations.RenameField(
+ model_name="configplan",
+ old_name="job_result",
+ new_name="plan_result",
+ ),
+ migrations.AddField(
+ model_name="configplan",
+ name="deploy_result",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="config_plan_deploy_result",
+ to="extras.jobresult",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="configplan",
+ name="change_control_id",
+ field=models.CharField(blank=True, default="", max_length=50),
+ preserve_default=False,
+ ),
+ ]
diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py
index 5bcaf717..889c1716 100644
--- a/nautobot_golden_config/models.py
+++ b/nautobot_golden_config/models.py
@@ -10,16 +10,19 @@
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 +38,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 +137,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(get_platform(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 +254,13 @@ 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,
+ 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 +286,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 +304,7 @@ def to_csv(self):
self.match_config,
self.config_type,
self.custom_compliance,
+ self.config_remediation,
)
class Meta:
@@ -291,6 +347,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 +390,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 +408,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 +780,124 @@ def get_absolute_url(self):
def __str__(self):
"""Return a simple string if model is called."""
return self.name
+
+
+@extras_features(
+ "custom_fields",
+ "custom_links",
+ "custom_validators",
+ "export_templates",
+ "graphql",
+ "relationships",
+ "webhooks",
+)
+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",
+ )
+
+ 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,
+ )
+ plan_result = models.ForeignKey(
+ to="extras.JobResult",
+ on_delete=models.CASCADE,
+ related_name="config_plan",
+ verbose_name="Plan Result",
+ )
+ deploy_result = models.ForeignKey(
+ to="extras.JobResult",
+ on_delete=models.PROTECT,
+ related_name="config_plan_deploy_result",
+ verbose_name="Deploy Result",
+ blank=True,
+ null=True,
+ )
+ change_control_id = models.CharField(
+ max_length=50,
+ blank=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..4350f56e 100644
--- a/nautobot_golden_config/navigation.py
+++ b/nautobot_golden_config/navigation.py
@@ -1,93 +1,106 @@
"""Add the configuration compliance buttons to the Plugins Navigation."""
-from nautobot.core.apps import NavMenuGroup, NavMenuItem, NavMenuTab, NavMenuButton
-from nautobot.utilities.choices import ButtonColorChoices
-from nautobot_golden_config.utilities.constant import ENABLE_COMPLIANCE, ENABLE_BACKUP
+from nautobot.core.apps import NavMenuGroup, NavMenuItem, NavMenuTab, NavMenuAddButton
+from nautobot_golden_config.utilities.constant import ENABLE_COMPLIANCE, ENABLE_BACKUP, ENABLE_PLAN
-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",
- permissions=["nautobot_golden_config.view_configcompliance"],
- )
- )
- items.append(
- NavMenuItem(
- link="plugins:nautobot_golden_config:configcompliance_report",
- name="Compliance Report",
+ name="Config Compliance",
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",
permissions=["nautobot_golden_config.view_compliancerule"],
buttons=(
- NavMenuButton(
+ NavMenuAddButton(
link="plugins:nautobot_golden_config:compliancerule_add",
- title="Compliance Rules",
- icon_class="mdi mdi-plus-thick",
- button_class=ButtonColorChoices.GREEN,
permissions=["nautobot_golden_config.add_compliancerule"],
),
),
)
)
- items.append(
+
+if ENABLE_COMPLIANCE:
+ items_setup.append(
NavMenuItem(
link="plugins:nautobot_golden_config:compliancefeature_list",
name="Compliance Features",
permissions=["nautobot_golden_config.view_compliancefeature"],
buttons=(
- NavMenuButton(
+ NavMenuAddButton(
link="plugins:nautobot_golden_config:compliancefeature_add",
- title="Compliance Features",
- icon_class="mdi mdi-plus-thick",
- button_class=ButtonColorChoices.GREEN,
permissions=["nautobot_golden_config.add_compliancefeature"],
),
),
)
)
+
+if ENABLE_COMPLIANCE:
+ items_operate.append(
+ NavMenuItem(
+ link="plugins:nautobot_golden_config:configcompliance_report",
+ name="Compliance Report",
+ permissions=["nautobot_golden_config.view_configcompliance"],
+ )
+ )
+
+if ENABLE_PLAN:
+ items_operate.append(
+ NavMenuItem(
+ link="plugins:nautobot_golden_config:configplan_list",
+ name="Config Plans",
+ permissions=["nautobot_golden_config.view_configplan"],
+ buttons=(
+ NavMenuAddButton(
+ link="plugins:nautobot_golden_config:configplan_add",
+ 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",
permissions=["nautobot_golden_config.view_configremove"],
buttons=(
- NavMenuButton(
+ NavMenuAddButton(
link="plugins:nautobot_golden_config:configremove_add",
- title="Config Remove",
- icon_class="mdi mdi-plus-thick",
- button_class=ButtonColorChoices.GREEN,
permissions=["nautobot_golden_config.add_configremove"],
),
),
)
)
- items.append(
+
+if ENABLE_BACKUP:
+ items_setup.append(
NavMenuItem(
link="plugins:nautobot_golden_config:configreplace_list",
name="Config Replacements",
permissions=["nautobot_golden_config.view_configreplace"],
buttons=(
- NavMenuButton(
+ NavMenuAddButton(
link="plugins:nautobot_golden_config:configreplace_add",
- title="Config Replace",
- icon_class="mdi mdi-plus-thick",
- button_class=ButtonColorChoices.GREEN,
permissions=["nautobot_golden_config.add_configreplace"],
),
),
@@ -95,17 +108,29 @@
)
-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=(
+ NavMenuAddButton(
+ link="plugins:nautobot_golden_config:remediationsetting_add",
+ 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(
+ NavMenuAddButton(
link="plugins:nautobot_golden_config:goldenconfigsetting_add",
- title="Add",
- icon_class="mdi mdi-plus-thick",
- button_class=ButtonColorChoices.GREEN,
permissions=["nautobot_golden_config.change_goldenconfigsetting"],
),
),
@@ -117,6 +142,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_compliance.py b/nautobot_golden_config/nornir_plays/config_compliance.py
index 5ba24372..bb33f5b5 100644
--- a/nautobot_golden_config/nornir_plays/config_compliance.py
+++ b/nautobot_golden_config/nornir_plays/config_compliance.py
@@ -3,35 +3,31 @@
import difflib
import logging
import os
-
from collections import defaultdict
from datetime import datetime
-from netutils.config.compliance import parser_map, section_config, _open_file_config
+from nautobot_plugin_nornir.constants import NORNIR_SETTINGS
+from nautobot_plugin_nornir.plugins.inventory.nautobot_orm import NautobotORMInventory
+from netutils.config.compliance import _open_file_config, parser_map, section_config
from nornir import InitNornir
from nornir.core.plugins.inventory import InventoryPluginRegister
from nornir.core.task import Result, Task
-
from nornir_nautobot.exceptions import NornirNautobotException
from nornir_nautobot.utils.logger import NornirLogger
-from nautobot_plugin_nornir.plugins.inventory.nautobot_orm import NautobotORMInventory
-from nautobot_plugin_nornir.constants import NORNIR_SETTINGS
-
from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice
+from nautobot_golden_config.models import ComplianceRule, ConfigCompliance, GoldenConfig
+from nautobot_golden_config.nornir_plays.processor import ProcessGoldenConfig
from nautobot_golden_config.utilities.db_management import close_threaded_db_connections
-from nautobot_golden_config.models import ComplianceRule, ConfigCompliance, GoldenConfigSetting, GoldenConfig
from nautobot_golden_config.utilities.helper import (
get_device_to_settings_map,
get_job_filter,
- verify_settings,
- render_jinja_template,
get_json_config,
+ render_jinja_template,
+ verify_settings,
)
-from nautobot_golden_config.nornir_plays.processor import ProcessGoldenConfig
from nautobot_golden_config.utilities.utils import get_platform
-
InventoryPluginRegister.register("nautobot-inventory", NautobotORMInventory)
LOGGER = logging.getLogger(__name__)
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..f75c380f
--- /dev/null
+++ b/nautobot_golden_config/nornir_plays/config_deployment.py
@@ -0,0 +1,110 @@
+"""Nornir job for deploying configurations."""
+from datetime import datetime
+
+from nautobot.dcim.models import Device
+from nautobot.extras.models import Status
+from nautobot_plugin_nornir.constants import NORNIR_SETTINGS
+from nautobot_plugin_nornir.plugins.inventory.nautobot_orm import NautobotORMInventory
+from nautobot_plugin_nornir.utils import get_dispatcher
+from nornir import InitNornir
+from nornir.core.exceptions import NornirSubTaskError
+from nornir.core.plugins.inventory import InventoryPluginRegister
+from nornir.core.task import Result, Task
+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, deploy_job_result) -> Result:
+ """Deploy configurations to device."""
+ obj = task.host.data["obj"]
+ plans_to_deploy = config_plan_qs.filter(device=obj)
+ plans_to_deploy.update(deploy_result=deploy_job_result.job_result)
+ 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:
+ plans_to_deploy.update(status=Status.objects.get(slug="in-progress"))
+ try:
+ 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]
+ task_changed, task_result, task_failed = result.changed, result.result, result.failed
+ if task_changed and task_failed:
+ # means config_revert happened in `napalm_configure`
+ plans_to_deploy.update(status=Status.objects.get(slug="failed"))
+ logger.log_failure(obj=obj, message="Failed deployment to the device.")
+ elif not task_changed and not task_failed:
+ plans_to_deploy.update(status=Status.objects.get(slug="completed"))
+ logger.log_success(obj=obj, message="Nothing was deployed to the device.")
+ else:
+ if not task_failed:
+ logger.log_success(obj=obj, message="Successfully deployed configuration to device.")
+ plans_to_deploy.update(status=Status.objects.get(slug="completed"))
+ except NornirSubTaskError:
+ task_result = None
+ plans_to_deploy.update(status=Status.objects.get(slug="failed"))
+ logger.log_failure(obj=obj, message="Failed deployment to the device.")
+ else:
+ task_result = None
+ logger.log_info(obj=obj, message="Commit not enabled. Configuration not deployed to device.")
+
+ return Result(host=task.host, result=task_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():
+ message = "Cannot deploy configuration(s). One or more config plans are not approved."
+ logger.log_failure(obj=None, message=message)
+ raise ValueError(message)
+ if config_plan_qs.filter(status__slug="completed").exists():
+ message = "Cannot deploy configuration(s). One or more config plans are already completed."
+ logger.log_failure(obj=None, message=message)
+ raise ValueError(message)
+ 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,
+ deploy_job_result=job_result,
+ )
+ 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/nornir_plays/config_intended.py b/nautobot_golden_config/nornir_plays/config_intended.py
index be5f18e9..e00518e4 100644
--- a/nautobot_golden_config/nornir_plays/config_intended.py
+++ b/nautobot_golden_config/nornir_plays/config_intended.py
@@ -1,35 +1,32 @@
"""Nornir job for generating the intended config."""
# pylint: disable=relative-beyond-top-level
-import os
import logging
-
+import os
from datetime import datetime
-from nornir import InitNornir
-from nornir.core.plugins.inventory import InventoryPluginRegister
-from nornir.core.task import Result, Task
from django.template import engines
from jinja2.sandbox import SandboxedEnvironment
-
+from nautobot_plugin_nornir.constants import NORNIR_SETTINGS
+from nautobot_plugin_nornir.plugins.inventory.nautobot_orm import NautobotORMInventory
+from nautobot_plugin_nornir.utils import get_dispatcher
+from nornir import InitNornir
+from nornir.core.plugins.inventory import InventoryPluginRegister
+from nornir.core.task import Result, Task
from nornir_nautobot.exceptions import NornirNautobotException
from nornir_nautobot.plugins.tasks.dispatcher import dispatcher
from nornir_nautobot.utils.logger import NornirLogger
-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 nautobot_golden_config.models import GoldenConfig
+from nautobot_golden_config.nornir_plays.processor import ProcessGoldenConfig
from nautobot_golden_config.utilities.constant import PLUGIN_CFG
from nautobot_golden_config.utilities.db_management import close_threaded_db_connections
-from nautobot_golden_config.models import GoldenConfigSetting, GoldenConfig
+from nautobot_golden_config.utilities.graphql import graph_ql_query
from nautobot_golden_config.utilities.helper import (
get_device_to_settings_map,
get_job_filter,
- verify_settings,
render_jinja_template,
+ verify_settings,
)
-from nautobot_golden_config.utilities.graphql import graph_ql_query
-from nautobot_golden_config.nornir_plays.processor import ProcessGoldenConfig
InventoryPluginRegister.register("nautobot-inventory", NautobotORMInventory)
LOGGER = logging.getLogger(__name__)
diff --git a/nautobot_golden_config/signals.py b/nautobot_golden_config/signals.py
index d099c75f..43c873f4 100755
--- a/nautobot_golden_config/signals.py
+++ b/nautobot_golden_config/signals.py
@@ -1,11 +1,81 @@
"""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.utilities.choices import ColorChoices
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": ColorChoices.COLOR_GREEN,
+ },
+ },
+ {
+ "name": "Not Approved",
+ "slug": "not-approved",
+ "defaults": {
+ "description": "Config plan is not approved",
+ "color": ColorChoices.COLOR_RED,
+ },
+ },
+ {
+ "name": "In Progress",
+ "slug": "in-progress",
+ "defaults": {
+ "description": "Config deployment has started and not completed or failed",
+ "color": ColorChoices.COLOR_GREY,
+ },
+ },
+ {
+ "name": "Completed",
+ "slug": "completed",
+ "defaults": {
+ "description": "Config deploy has been successfully completed",
+ "color": ColorChoices.COLOR_DARK_GREY,
+ },
+ },
+ {
+ "name": "Failed",
+ "slug": "failed",
+ "defaults": {
+ "description": "Config deploy has failed",
+ "color": ColorChoices.COLOR_DARK_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..81e286d1
--- /dev/null
+++ b/nautobot_golden_config/static/run_job.js
@@ -0,0 +1,195 @@
+
+/**
+ * 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.
+ * @param {string} callBack - The promise function to return the success or failure message, leave `undefined` no callback is required.
+ */
+function startJob(jobClass, data, redirectUrlTemplate, callBack) {
+ var jobApi = `/api/extras/jobs/${jobClass}/run/`;
+
+ if (typeof callBack === "undefined") {
+ var callBack = getMessage;
+ }
+
+ $.ajax({
+ type: 'POST',
+ url: jobApi,
+ contentType: "application/json",
+ data: JSON.stringify(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, callBack);
+ 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, callBack) {
+$.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, callBack);
+ }, 1000); // Poll every 1 seconds
+ } else if (data.status.value == "completed") {
+ $("#loaderImg").hide();
+ $('#detailMessages').show();
+ callBack(data.id)
+ .then((message) => {
+ $('#redirectLink').show();
+ $('#detailMessages').attr('class', 'alert alert-success text-center');
+ $('#detailMessages').html(message)
+ })
+ .catch((message) => {
+ $('#detailMessages').attr('class', 'alert alert-warning text-center');
+ $('#detailMessages').html(message)
+ })
+ }
+ },
+ 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 getMessage(jobResultId) {
+return new Promise((resolve) => {
+ resolve("Job Completed Successfully.");
+});
+}
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..e4ca8425 100644
--- a/nautobot_golden_config/tables.py
+++ b/nautobot_golden_config/tables.py
@@ -4,19 +4,11 @@
from django.utils.html import format_html
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, TagColumn, ToggleColumn
-from nautobot.utilities.tables import (
- BaseTable,
- ToggleColumn,
-)
from nautobot_golden_config import models
-from nautobot_golden_config.utilities.constant import (
- ENABLE_BACKUP,
- ENABLE_COMPLIANCE,
- ENABLE_INTENDED,
- CONFIG_FEATURES,
-)
-
+from nautobot_golden_config.utilities.constant import CONFIG_FEATURES, ENABLE_BACKUP, ENABLE_COMPLIANCE, ENABLE_INTENDED
ALL_ACTIONS = """
{% if backup == True %}
@@ -85,6 +77,38 @@
{% endif %}
"""
+CONFIG_SET_BUTTON = """
+
+
+
+
+