Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/upstream_testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ jobs:
app_branch: "main"
- nautobot_branch: "ltm-2.4"
app_branch: "ltm-2.4"
# - nautobot_branch: "next-4.0"
# app_branch: "next-4.0"
uses: "nautobot/nautobot/.github/workflows/plugin_upstream_testing_base.yml@develop"
with: # Below could potentially be collapsed into a single argument if a concrete relationship between both is enforced
invoke_context_name: "NAUTOBOT_GOLDEN_CONFIG"
Expand Down
1 change: 1 addition & 0 deletions changes/421.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added configurable `empty_compliance_behavior` setting to `GoldenConfigSetting` to control how compliance is evaluated when configurations are empty.
1 change: 1 addition & 0 deletions changes/421.housekeeping
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Replaced `django_pivot` dependency with hand-built pivot query for the compliance overview table.
1 change: 1 addition & 0 deletions changes/421.removed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Removed `compliance_int` database field from `ConfigCompliance` model in favor of a backwards-compatible property. Custom compliance functions returning `compliance_int` will see a deprecation warning.
134 changes: 127 additions & 7 deletions docs/user/app_feature_compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,126 @@ In order to generate the intended configurations, a minimum of two repositories
3. The [intended_path_template](./app_use_cases.md#application-settings) configuration parameter.
4. The [backup_path_template](./app_use_cases.md#application-settings) configuration parameter.

## Common Pitfalls

Understanding how Golden Config compliance works is straightforward once you grasp its mental model, but it often differs from what many users expect. Many compliance engines ask questions like "we should have NTP servers," focusing on the presence of certain configuration lines. In contrast, Golden Config compliance asks, "Does the intended NTP server configuration exactly match the actual configuration?" This approach is further distinguished by separating data from syntax using Jinja2 templates, rather than relying on regex matching or similar techniques.

Because of this shift in perspective, new users commonly encounter the following pitfalls.

### Data-Driven Templates vs Hardcoded Logic

A common pitfall when getting started with configuration compliance is to hardcode configuration per group directly in templates:

```jinja
{# Avoid this pattern — hardcoding config per region #}
{% if obj.location.parent.name == 'Americas' %}
ntp server 10.1.1.1
ntp server 10.1.1.2
{% elif obj.location.parent.name == 'Europe' %}
ntp server 10.2.1.1
ntp server 10.2.1.2
{% endif %}
```

This may work at first, but does not scale. Every new region, tenant, or role requires a template change. The recommended approach is to separate data from the cli syntax:

```jinja
{# Recommended — data-driven config generation #}
{% for ntp_server in config_context.ntp_servers %}
ntp server {{ ntp_server }}
{% endfor %}
```

Different scopes produce different data, and the same template generates the correct configuration for each device. This means:

- Adding a new region does not require a template change — just assign the correct config context data.
- The same template works for all devices on the platform regardless of tenant, role, or site.
- The compliance rule remains one rule per platform per feature. Different devices produce different intended configurations because the *data* is different, not the rule or the template logic.

This same principle applies to the question of per-tenant, per-role, per-tag or literally any data point compliance rules. Different tenants using TACACS+ vs RADIUS do not need separate `aaa` compliance rules. They need one `aaa` rule and templates that generate the correct AAA configuration based on the device's data.

### Compliance Rules Define Sections, Not Content

Another common pitfall is to put specific configuration lines into the compliance rule's "Config to Match" field:

```
ntp server 10.2.1.1
ntp server 10.2.1.2
```

This conflates the role of the compliance rule with the role of the intended configuration. The "Config to Match" field is a **section matcher** — it identifies which section of the running configuration to extract for comparison. For NTP, the correct match config is:

```
ntp server
```

This captures all lines beginning with `ntp server` from both the backup and the intended configuration. The compliance engine then compares the two sets. The *specific NTP servers* that should be present are defined in the intended configuration, which is generated from your templates and data — not from the compliance rule.

Putting full configuration lines in the rule creates several problems:

- The rule becomes device-specific and cannot be shared across the platform.
- Changing an NTP server requires updating the rule and the template instead of updating data.
- It signals that this is how configurations are generated when it is not the case.


## Empty Compliance Behavior

A common point of confusion is what happens when a compliance rule exists for a platform, but not every device on that platform needs every feature. For example:

- A BGP compliance rule applies to all Cisco IOS devices, but campus access switches will never have BGP configuration.
- Multiple teams share a platform, but Team B hasn't built intended configurations for a feature that Team A already manages.
- Core routers need `dot1x` compliance, but access switches on the same platform do not.

In all of these cases, the compliance engine compares the intended configuration against the actual configuration. **If you are generating the correct intended configuration, the answer is already handled.** A campus switch that should never have BGP should generate an empty intended BGP configuration — and an empty actual BGP section is a valid match. This is by design.

However, this can create confusion when reviewing compliance results:

- A device shows "Compliant" for BGP even though BGP was never relevant to it.
- A device shows "Non-Compliant" with "extra" configuration because Team B hasn't built the intended config yet, but the feature already exists on the device.

### When Empty Results Are Misleading

The real problem arises when a feature is not yet relevant or will never be relevant to a device, and the empty-vs-empty comparison produces a "Compliant" result that is misleading — or when a device has actual configuration but no intended configuration has been generated yet, producing a "Non-Compliant" result prematurely.

The **Empty Compliance Behavior** setting addresses this. Navigate to `Golden Config -> Settings`, select a settings entry, and choose one of the three options:

| Behavior | Use Case | Intended Empty + Actual Empty | Intended Empty + Actual Populated | Intended Populated |
|:--|:--|:--|:--|:--|
| **Validated** (default) | Standard compliance — empty configs are a valid match | Compliant | Normal compliance check | Normal compliance check |
| **Empty Both** | Features that will never apply to certain devices (e.g., BGP on campus switches) | N/A | Normal compliance check | Normal compliance check |
| **Empty Intended** | Gradual rollout — intended config is not yet generated for all devices | N/A | N/A | Normal compliance check |

**Empty Both** is best when a feature permanently does not apply to a device. Neither the intended nor the actual configuration will ever exist, so there is nothing meaningful to validate. This removes the noise of "Compliant" results for features that were never relevant.

**Empty Intended** is best when you are progressively rolling out intended configurations across teams or groups. Some devices may already have actual configuration on the device for a feature, but the intended configuration has not been built yet. Without this setting, those devices would show as "Non-Compliant" with extra configuration — even though no one has defined what the intended state should be. With **Empty Intended**, compliance is deferred until the intended configuration is generated.

When a compliance record is marked as N/A:

- The device tab shows a gray "N/A" badge instead of Compliant/Non-Compliant.
- The compliance overview table shows a dash for that feature.
- N/A records are excluded from compliance percentage calculations, compliant counts, and non-compliant counts.
- Remediation configuration is not generated for N/A records.

### Practical Usage: Controlling Empty Configurations via Templates

The Empty Compliance Behavior setting works in conjunction with your Jinja2 templates. By conditionally omitting configuration in your intended templates, you control which devices produce empty intended configs for a given feature — and the setting determines how those empty results are handled.

For example, if you are gradually rolling out NTP compliance and only certain tenants are ready:

```jinja
{# Only generate NTP configuration for tenants that have been onboarded #}
{% if obj.tenant and obj.tenant.name in ['Wayne Enterprise', 'Acme Corp'] %}
ntp server 10.1.1.1
ntp server 10.2.2.2
ntp source Loopback0
{% endif %}
```

Devices belonging to tenants not in the list will produce an empty intended configuration for this feature. With **Empty Intended** selected, those devices will show N/A instead of being flagged as non-compliant. As you onboard additional tenants, simply add them to the template condition — their compliance will begin to be evaluated automatically.

!!! note "Why not add filters to compliance rules?"
Compliance rules are intentionally scoped to the platform level only. Adding tenant, role, site, tag or series of other filters directly to compliance rules would significantly increase the complexity of the solution, as rules would need to support multiple filter combinations, priority resolution, and overlapping scope. The Empty Compliance Behavior setting, combined with data-driven templates, provides the same outcome with far less complexity. The compliance rule stays simple (one rule per platform per feature), and the templates encode your business logic for which devices are in scope.

## Starting a Compliance Job

To start a compliance job manually:
Expand All @@ -50,15 +170,11 @@ Each configuration can be added and edits from this table. When editing/adding t
![Configuration Rule Edit](../images/ss1_ss_compliance-rule_light.png#only-light){ .on-glb }
![Configuration Rule Edit](../images/ss1_ss_compliance-rule_dark.png#only-dark){ .on-glb }

The platform must refer to a platform with a valid network_driver supported by the configuration compliance engine. While there is no enforcement of this data from
a database perspective, the job will never run successfully, rendering the additional configuration ineffective.
The platform must refer to a platform with a valid network_driver supported by the configuration compliance engine. While there is no enforcement of this data from a database perspective, the job will never run successfully, rendering the additional configuration ineffective.

The Feature is a unique identifier, that should prefer shorter names, as this effects the width of the compliance overview and thus it's readability as a
best practice.
The Feature is a unique identifier, that should prefer shorter names, as this effects the width of the compliance overview and thus it's readability as a best practice.

The "Config to Match" section represents the configuration root elements. This would be the parent most key only. Additionally, the match is based on
"Config Type", which could be JSON or CLI. For CLI based configs, the match is based on what a line starts with only. Meaning, there is an implicit greediness to the matching. All matches must start form the beginning of the line.
For JSON based configs, the match is based on JSON's structure top level key name.
The "Config to Match" section represents the configuration root elements. This would be the parent most key only. Additionally, the match is based on "Config Type", which could be JSON or CLI. For CLI based configs, the match is based on what a line starts with only. Meaning, there is an implicit greediness to the matching. All matches must start form the beginning of the line. For JSON based configs, the match is based on JSON's structure top level key name.

!!! note
"Config to Match" is mandatory for CLI configurations. If config to match is not defined for JSON, the complete JSON configuration will be compared. If the config to match is defined, comparison will take place only for defined keys.
Expand All @@ -69,9 +185,13 @@ For JSON based configs, the match is based on JSON's structure top level key nam
!!! note
The mapping of "network_os" as defined by netutils is provided via the app settings in your nautobot_config.py, and documented on the primary Readme.

!!! note
See [Compliance Rules Define Sections, Not Content](#compliance-rules-define-sections-not-content) for guidance on what belongs in Config to Match versus in your templates.

## Compliance View

The compliance overview will provide a per device and feature overview on the compliance of your network devices. From here you can navigate to the details view.

![Compliance Overview](../images/ss1_ss_compliance-overview_light.png#only-light){ .on-glb }
![Compliance Overview](../images/ss1_ss_compliance-overview_dark.png#only-dark){ .on-glb }

Expand Down
11 changes: 4 additions & 7 deletions docs/user/app_feature_compliancecustom.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,17 @@ The interface of contract provided to your custom function is based on the follo

### Outputs

- The function should return a single dictionary, with the keys of `compliance`, `compliance_int`, `ordered`, `missing`, and `extra`.
- The function should return a single dictionary, with the keys of `compliance`, `ordered`, `missing`, and `extra`.
- The `compliance` key should be a boolean with either True or False as acceptable responses, which determines if the config is compliant or not.
- The `compliance_int` key should be an integer with either 1 (when compliance is True) or 0 (when compliance is False) as acceptable responses. This is required to handle a counting use case where boolean does not suffice.
- The `ordered` key should be a boolean with either True or False as acceptable responses, which determines if the config is compliant and ordered or not.
- The `missing` key should be a string or json, empty when nothing is missing and appropriate string or json data when configuration is missing.
- The `extra` key should be a string or json, empty when nothing is extra and appropriate string or json data when there is extra configuration.

There is validation to ensure the data structure returned is compliant to the above assertions.

!!! warning "Deprecated"
The `compliance_int` key is deprecated and should no longer be returned. If present, a deprecation warning will be logged and the value will be ignored. The `compliance` boolean is the sole source of truth.

The function provided in string path format, must be installed in the same environment as nautobot and the workers.

## Configuration
Expand All @@ -73,14 +75,12 @@ To provide boiler plate code for any future use case, the following is provided
```python
def custom_compliance_func(obj):
# Modify with actual logic, this would always presume compliant.
compliance_int = 1
compliance = True
ordered = True
missing = ""
extra = ""
return {
"compliance": compliance,
"compliance_int": compliance_int,
"ordered": ordered,
"missing": missing,
"extra": extra,
Expand Down Expand Up @@ -116,20 +116,17 @@ def custom_compliance_func(obj):
neighbors = list(set(neighbors))
secrets = list(set(secrets))
if secrets != neighbors:
compliance_int = 0
compliance = False
ordered = False
missing = f"neighbors Found: {str(neighbors)}\nneigbors with secrets found: {str(secrets)}"
extra = ""
else:
compliance_int = 1
compliance = True
ordered = True
missing = ""
extra = ""
return {
"compliance": compliance,
"compliance_int": compliance_int,
"ordered": ordered,
"missing": missing,
"extra": extra,
Expand Down
1 change: 1 addition & 0 deletions docs/user/app_use_cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ To update existing settings click on one of the `Settings` name.
|Jinja Path|A Jinja template which defines the path (within the repository) and name of the Jinja template file. e.g. `{{obj.platform.network_driver}}/{{obj.role.name}}/main.j2`|
|Dynamic Group|The scope of devices on which Golden Config's jobs can operate. |
|GraphQL Query|A query that is evaluated and used to render the config. The query must start with `query ($device_id: ID!)`.|
|Empty Compliance Behavior|Controls how compliance is evaluated when configurations are empty. **Validated** (default) treats empty-matches-empty as compliant. **Empty Both** marks as N/A when both actual and intended are empty. **Empty Intended** marks as N/A when intended is empty, regardless of actual. See [Empty Compliance Behavior](./app_feature_compliance.md#empty-compliance-behavior) for details.|

!!! note
Each of these will be further detailed in their respective sections.
Expand Down
14 changes: 14 additions & 0 deletions nautobot_golden_config/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ class RemediationTypeChoice(ChoiceSet):
)


class EmptyComplianceBehaviorChoice(ChoiceSet):
"""Choiceset for how to handle compliance when configurations are empty."""

TYPE_VALIDATED = "validated"
TYPE_EMPTY_BOTH = "empty_both"
TYPE_EMPTY_INTENDED = "empty_intended"

CHOICES = (
(TYPE_VALIDATED, "Validated"),
(TYPE_EMPTY_BOTH, "Empty Both"),
(TYPE_EMPTY_INTENDED, "Empty Intended"),
)


class ConfigPlanTypeChoice(ChoiceSet):
"""Choiceset used by ConfigPlan."""

Expand Down
2 changes: 1 addition & 1 deletion nautobot_golden_config/details.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def hyperlinked_field_with_icon(url, title, icon_class="mdi mdi-text-box-check-o
label="General Settings",
section=ui.SectionChoices.LEFT_HALF,
weight=100,
fields=("weight", "description"),
fields=("weight", "description", "empty_compliance_behavior"),
),
ui.KeyValueTablePanel(
section=ui.SectionChoices.LEFT_HALF,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ def _generate_static_data(self, db):
device=device,
rule=rule,
compliance=is_compliant,
compliance_int=int(is_compliant),
intended=rule.match_config,
actual=rule.match_config if is_compliant else f"mismatch {rule.feature.name}",
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@ def handle(self, *args, **options):
device=device,
rule=rule,
compliance=is_compliant,
compliance_int=1 if is_compliant else 0,
actual=actual,
intended=intended,
missing=missing_config,
Expand Down
2 changes: 1 addition & 1 deletion nautobot_golden_config/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def metric_compliance_devices():
)
queryset = ConfigCompliance.objects.values("rule__feature__slug").annotate(
compliant=Count("rule__feature__slug", filter=Q(compliance=True)),
non_compliant=Count("rule__feature__slug", filter=~Q(compliance=True)),
non_compliant=Count("rule__feature__slug", filter=Q(compliance=False)),
Comment thread
itdependsnetworks marked this conversation as resolved.
)

counters = {item["rule__feature__slug"]: {"compliant": 0, "non_compliant": 0} for item in queryset}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-04-06 18:19

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("nautobot_golden_config", "0031_alter_configplan_change_control_url"),
]

operations = [
migrations.RemoveField(
model_name="configcompliance",
name="compliance_int",
),
migrations.AddField(
model_name="goldenconfigsetting",
name="empty_compliance_behavior",
field=models.CharField(default="validated", max_length=20),
),
migrations.AlterField(
model_name="configcompliance",
name="compliance",
field=models.BooleanField(blank=True, null=True),
),
]
Loading
Loading