Skip to content

Commit

Permalink
Feature: Add Support for XML Compliance (#708)
Browse files Browse the repository at this point in the history
* Add support for XML config type

---------

Co-authored-by: Justin Pettit <[email protected]>
Co-authored-by: Jeff Kala <[email protected]>
  • Loading branch information
3 people authored May 30, 2024
1 parent ab33c8c commit 49b0710
Show file tree
Hide file tree
Showing 16 changed files with 270 additions and 15 deletions.
1 change: 1 addition & 0 deletions changes/1501.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Support for XML Compliance
2 changes: 0 additions & 2 deletions development/docker-compose.mysql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ services:
- "development_mysql.env"
db:
image: "mysql:8"
command:
- "--max_connections=1000"
env_file:
- "development.env"
- "creds.env"
Expand Down
17 changes: 17 additions & 0 deletions docs/admin/troubleshooting/E3031.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# E3031 Details

## Message emitted:

`E3031: Invalid XPath expression.`

## Description:

This error occurs when an invalid XPath expression is used in a Compliance Job, causing a `NornirNautobotException` to be raised.

## Troubleshooting:

Review the exception message and worker logs to determine the cause of the failure.

## Recommendation:

Ensure that you are using a valid XPath expression in the "Config to Match" section of your Compliance Rule.
Binary file added docs/images/compliance-rule-xml.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/device-compliance-xml.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
54 changes: 54 additions & 0 deletions docs/user/app_feature_compliancexml.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Navigating Compliance Using XML

XML based compliance provides a mechanism to compliance check device configurations stored in XML format.

## Defining Compliance Rules

Compliance rules are defined as XML `config-type`.

The `config to match` field is used to specify an XPath query. This query is used to select specific nodes in the XML configurations for comparison. If the `config to match` field is left blank, all nodes in the configurations will be compared.

### XPath in Config to Match

XPath (XML Path Language) is a query language for selecting nodes from an XML document. In our application, XPath is used in the `config to match` field to specify which parts of the device configurations should be compared.

### Basic XPath Syntax

Here is a quick reference for basic XPath syntax:

| Expression | Description |
| --- | --- |
| `nodename` | Selects all nodes with the name "nodename" |
| `/` | Selects from the root node |
| `//` | Selects nodes in the document from the current node that match the selection no matter where they are |

For more detailed information on XPath syntax, you can refer to the [Supported XPath syntax](https://docs.python.org/3/library/xml.etree.elementtree.html#supported-xpath-syntax).

This NTC [blog](https://blog.networktocode.com/post/parsing-xml-with-python-and-ansible/) also covers XPath in more details.

Here are some examples of XPath queries that can be used in the `config to match` field:

![Example XML Compliance Rules](../images/compliance-rule-xml.png)

## Device Config Compliance View

![Config Compliance Device View](../images/device-compliance-xml.png)

## Interpreting Diff Output

The diff output shows the differences between the device configurations. Each line in the diff output represents a node in the XML configurations. The node is identified by its XPath, and the value of the node is shown after the comma.

Here's a sample 'missing' output:

```text
/config/system/aaa/user[1]/password[1], foo
/config/system/aaa/user[1]/role[1], admin
/config/system/aaa/radius/server[1]/host[1], 1.1.1.1
/config/system/aaa/radius/server[1]/secret[1], foopass
/config/system/aaa/radius/server[2]/host[1], 2.2.2.2
/config/system/aaa/radius/server[2]/secret[1], bazpass
```

This diff output represents the 'missing' portion when comparing the actual configuration to the intended configuration. Each line represents a node in the XML configuration that is presented in the intended configuration but is missing in the actual configuration.

For example, the line `/config/system/aaa/user[1]/password[1], foo` indicates that the password node of the first user node under `/config/system/aaa` is expected to have a value of `foo` in the actual configuration. If this line appears in the diff output, it means this value is missing in the actual configuration.
5 changes: 5 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ nav:
- E3024: "admin/troubleshooting/E3024.md"
- E3025: "admin/troubleshooting/E3025.md"
- E3026: "admin/troubleshooting/E3026.md"
- E3027: "admin/troubleshooting/E3027.md"
- E3028: "admin/troubleshooting/E3028.md"
- E3029: "admin/troubleshooting/E3029.md"
- E3030: "admin/troubleshooting/E3030.md"
- E3031: "admin/troubleshooting/E3031.md"
- Migrating To v2: "admin/migrating_to_v2.md"
- Release Notes:
- "admin/release_notes/index.md"
Expand Down
2 changes: 2 additions & 0 deletions nautobot_golden_config/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ class ComplianceRuleConfigTypeChoice(ChoiceSet):

TYPE_CLI = "cli"
TYPE_JSON = "json"
TYPE_XML = "xml"

CHOICES = (
(TYPE_CLI, "CLI"),
(TYPE_JSON, "JSON"),
(TYPE_XML, "XML"),
)


Expand Down
42 changes: 40 additions & 2 deletions nautobot_golden_config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from nautobot.extras.models.statuses import StatusField
from nautobot.extras.utils import extras_features
from netutils.config.compliance import feature_compliance
from xmldiff import main, actions


from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice, ConfigPlanTypeChoice, RemediationTypeChoice
from nautobot_golden_config.utilities.constant import ENABLE_SOTAGG, PLUGIN_CFG
Expand Down Expand Up @@ -118,6 +120,41 @@ def _normalize_diff(diff, path_to_diff):
}


def _get_xml_compliance(obj):
"""This function performs the actual compliance for xml serializable data."""

def _normalize_diff(diff):
"""Format the diff output to a list of nodes with values that have updated."""
formatted_diff = []
for operation in diff:
if isinstance(operation, actions.UpdateTextIn):
formatted_operation = f"{operation.node}, {operation.text}"
formatted_diff.append(formatted_operation)
return "\n".join(formatted_diff)

# Options for the diff operation. These are set to prefer updates over node insertions/deletions.
diff_options = {
"F": 0.1,
"fast_match": True,
}
missing = main.diff_texts(obj.actual, obj.intended, diff_options=diff_options)
extra = main.diff_texts(obj.intended, obj.actual, diff_options=diff_options)

compliance = not missing and not extra
compliance_int = int(compliance)
ordered = obj.ordered
missing = _null_to_empty(_normalize_diff(missing))
extra = _null_to_empty(_normalize_diff(extra))

return {
"compliance": compliance,
"compliance_int": compliance_int,
"ordered": ordered,
"missing": missing,
"extra": extra,
}


def _verify_get_custom_compliance_data(compliance_details):
"""This function verifies the data is as expected when a custom function is used."""
for val in ["compliance", "compliance_int", "ordered", "missing", "extra"]:
Expand Down Expand Up @@ -171,6 +208,7 @@ def _get_hierconfig_remediation(obj):
FUNC_MAPPER = {
ComplianceRuleConfigTypeChoice.TYPE_CLI: _get_cli_compliance,
ComplianceRuleConfigTypeChoice.TYPE_JSON: _get_json_compliance,
ComplianceRuleConfigTypeChoice.TYPE_XML: _get_xml_compliance,
RemediationTypeChoice.TYPE_HIERCONFIG: _get_hierconfig_remediation,
}
# The below conditionally add the custom provided compliance type
Expand Down Expand Up @@ -249,13 +287,13 @@ class ComplianceRule(PrimaryModel): # pylint: disable=too-many-ancestors
match_config = models.TextField(
blank=True,
verbose_name="Config to Match",
help_text="The config to match that is matched based on the parent most configuration. E.g.: For CLI `router bgp` or `ntp`. For JSON this is a top level key name.",
help_text="The config to match that is matched based on the parent most configuration. E.g.: For CLI `router bgp` or `ntp`. For JSON this is a top level key name. For XML this is a xpath query.",
)
config_type = models.CharField(
max_length=20,
default=ComplianceRuleConfigTypeChoice.TYPE_CLI,
choices=ComplianceRuleConfigTypeChoice,
help_text="Whether the configuration is in CLI or JSON/structured format.",
help_text="Whether the configuration is in CLI, JSON, or XML format.",
)
custom_compliance = models.BooleanField(
default=False, help_text="Whether this Compliance Rule is proceeded as custom."
Expand Down
29 changes: 27 additions & 2 deletions nautobot_golden_config/nornir_plays/config_compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
import os
from collections import defaultdict
from datetime import datetime

from django.utils.timezone import make_aware
from lxml import etree # nosec

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
Expand All @@ -20,8 +21,14 @@
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.utilities.helper import get_json_config, render_jinja_template, verify_settings
from nautobot_golden_config.utilities.logger import NornirLogger
from nautobot_golden_config.utilities.helper import (
get_json_config,
get_xml_config,
render_jinja_template,
verify_settings,
get_xml_subtree_with_full_path,
)

InventoryPluginRegister.register("nautobot-inventory", NautobotORMInventory)
LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -64,6 +71,24 @@ def get_config_element(rule, config, obj, logger):
else:
config_element = config_json

elif rule["obj"].config_type == ComplianceRuleConfigTypeChoice.TYPE_XML:
config_xml = get_xml_config(config)

if not config_xml:
error_msg = "`E3002:` Unable to interpret configuration as XML."
logger.error(error_msg, extra={"object": obj})
raise NornirNautobotException(error_msg)

if rule["obj"].match_config:
try:
config_element = get_xml_subtree_with_full_path(config_xml, rule["obj"].match_config)
except etree.XPathError as err:
error_msg = f"`E3031:` Invalid XPath expression - `{rule['obj'].match_config}`"
logger.error(error_msg, extra={"object": obj})
raise NornirNautobotException(error_msg) from err
else:
config_element = etree.tostring(config_xml, encoding="unicode", pretty_print=True)

elif rule["obj"].config_type == ComplianceRuleConfigTypeChoice.TYPE_CLI:
if obj.platform.network_driver_mappings["netutils_parser"] not in parser_map:
error_msg = f"`E3003:` There is currently no CLI-config parser support for platform network_driver `{obj.platform.network_driver}`, preemptively failed."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@
<tr>
<td style="width:250px">Configuration</td>
<td class="config_hover">
<span id="{{ item.rule|slugify }}_actual"><pre>{{ item.actual|placeholder|condition_render_json }}</pre></span>
{% if item.rule.config_type == "xml" %}
<span id="{{ item.rule|slugify }}_actual"><pre><code class="language-xml">{{ item.actual|placeholder }}</code></pre></span>
{% elif item.rule.config_type == "json" %}
<span id="{{ item.rule|slugify }}_actual"><pre><code class="language-json">{{ item.actual|placeholder|condition_render_json }}</code></pre></span>
{% else %}
<span id="{{ item.rule|slugify }}_actual"><pre>{{ item.actual|placeholder }}</pre></span>
{% endif %}
<span class="config_hover_button">
<button class="btn btn-inline btn-default hover_copy_button" data-clipboard-target="#{{ item.rule|slugify }}_actual">
<span class="mdi mdi-content-copy"></span>
Expand All @@ -123,7 +129,13 @@
<tr>
<td style="width:250px">Intended Configuration</td>
<td class="config_hover">
<span id="{{ item.rule|slugify }}_intended"><pre>{{ item.intended|placeholder|condition_render_json }}</pre></span>
{% if item.rule.config_type == "xml" %}
<span id="{{ item.rule|slugify }}_intended"><pre><code class="language-xml">{{ item.intended|placeholder }}</code></pre></span>
{% elif item.rule.config_type == "json" %}
<span id="{{ item.rule|slugify }}_intended"><pre><code class="language-json">{{ item.intended|placeholder|condition_render_json }}</code></pre></span>
{% else %}
<span id="{{ item.rule|slugify }}_intended"><pre>{{ item.intended|placeholder }}</pre></span>
{% endif %}
<span class="config_hover_button">
<button class="btn btn-inline btn-default hover_copy_button" data-clipboard-target="#{{ item.rule|slugify }}_intended">
<span class="mdi mdi-content-copy"></span>
Expand All @@ -134,7 +146,13 @@
<tr>
<td style="width:250px">Actual Configuration</td>
<td class="config_hover">
<span id="{{ item.rule|slugify }}_actual"><pre>{{ item.actual|placeholder|condition_render_json }}</pre></span>
{% if item.rule.config_type == "xml" %}
<span id="{{ item.rule|slugify }}_actual"><pre><code class="language-xml">{{ item.actual|placeholder }}</code></pre></span>
{% elif item.rule.config_type == "json" %}
<span id="{{ item.rule|slugify }}_actual"><pre><code class="language-json">{{ item.actual|placeholder|condition_render_json }}</code></pre></span>
{% else %}
<span id="{{ item.rule|slugify }}_actual"><pre>{{ item.actual|placeholder }}</pre></span>
{% endif %}
<span class="config_hover_button">
<button class="btn btn-inline btn-default hover_copy_button" data-clipboard-target="#{{ item.rule|slugify }}_actual">
<span class="mdi mdi-content-copy"></span>
Expand Down
27 changes: 27 additions & 0 deletions nautobot_golden_config/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,33 @@ def create_feature_rule_cli_with_remediation(device, feature="foo3", rule="cli")
return rule


def create_feature_rule_xml(device, feature="foo4", rule="xml"):
"""Creates a Feature/Rule Mapping and Returns the rule."""
feature_obj, _ = ComplianceFeature.objects.get_or_create(slug=feature, name=feature)
rule = ComplianceRule(
feature=feature_obj,
platform=device.platform,
config_type=ComplianceRuleConfigTypeChoice.TYPE_XML,
config_ordered=False,
)
rule.save()
return rule


def create_feature_rule_xml_with_remediation(device, feature="foo5", rule="xml"):
"""Creates a Feature/Rule Mapping with remediation enabled and Returns the rule."""
feature_obj, _ = ComplianceFeature.objects.get_or_create(slug=feature, name=feature)
rule = ComplianceRule(
feature=feature_obj,
platform=device.platform,
config_type=ComplianceRuleConfigTypeChoice.TYPE_XML,
config_ordered=False,
config_remediation=True,
)
rule.save()
return rule


def create_feature_rule_cli(device, feature="foo_cli"):
"""Creates a Feature/Rule Mapping and Returns the rule."""
feature_obj, _ = ComplianceFeature.objects.get_or_create(slug=feature, name=feature)
Expand Down
16 changes: 16 additions & 0 deletions nautobot_golden_config/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
create_config_compliance,
create_device,
create_feature_rule_json,
create_feature_rule_xml,
create_job_result,
create_saved_queries,
)
Expand All @@ -34,6 +35,7 @@ def setUp(self):
"""Set up base objects."""
self.device = create_device()
self.compliance_rule_json = create_feature_rule_json(self.device)
self.compliance_rule_xml = create_feature_rule_xml(self.device)

def test_create_config_compliance_success_json(self):
"""Successful."""
Expand All @@ -49,6 +51,20 @@ def test_create_config_compliance_success_json(self):
self.assertEqual(cc_obj.missing, ["root['foo']['bar-2']"])
self.assertEqual(cc_obj.extra, ["root['foo']['bar-1']"])

def test_create_config_compliance_success_xml(self):
"""Successful."""
actual = "<root><foo><bar-1>notbaz</bar-1></foo></root>"
intended = "<root><foo><bar-1>baz</bar-1></foo></root>"
cc_obj = create_config_compliance(
self.device, actual=actual, intended=intended, compliance_rule=self.compliance_rule_xml
)

self.assertFalse(cc_obj.compliance)
self.assertEqual(cc_obj.actual, "<root><foo><bar-1>notbaz</bar-1></foo></root>")
self.assertEqual(cc_obj.intended, "<root><foo><bar-1>baz</bar-1></foo></root>")
self.assertEqual(cc_obj.missing, "/root/foo/bar-1[1], baz")
self.assertEqual(cc_obj.extra, "/root/foo/bar-1[1], notbaz")

def test_create_config_compliance_unique_failure(self):
"""Raises error when attempting to create duplicate."""
ConfigCompliance.objects.create(
Expand Down
Loading

0 comments on commit 49b0710

Please sign in to comment.