diff --git a/CHANGELOG.md b/CHANGELOG.md index 15eceb4..8c20cc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `HConfig.future_with_report()` returns the predicted future config together + with a frozen `FutureReport` listing unresolved negations (negations that + matched nothing in the running config) and idempotency-tracked negation + replacements, so change-validation pipelines can assert + `not report.unresolved_negations` instead of grepping the render for + `no ` lines (#285). - Migration guide for v3 → v4 upgrades (`docs/user/migrating-from-v3.md`): rename tables for constructors, methods, and utilities, the unified negation rule mapping, exception and config-view changes, and behavior diff --git a/docs/dev/api-reference.md b/docs/dev/api-reference.md index 43cb17d..cb30eff 100644 --- a/docs/dev/api-reference.md +++ b/docs/dev/api-reference.md @@ -58,6 +58,8 @@ Auto-generated reference documentation for the `hier_config` public API. Signatu ::: hier_config.ChangeDetail +::: hier_config.FutureReport + --- ## Driver System diff --git a/docs/user/future-config.md b/docs/user/future-config.md index cad8eff..9b494fe 100644 --- a/docs/user/future-config.md +++ b/docs/user/future-config.md @@ -32,7 +32,25 @@ If you receive a `DuplicateChildError` while calling `merge()`, consider whether - **Exact negation** — a `no ` whose positive form exists in the running config removes that command; neither line survives in the prediction. This is evaluated before the idempotency rules, so an idempotency rule that happens to match the negation text cannot accidentally keep it as a literal child. - **Shorthand negation** — a valueless negation such as `no description` removes the valued lines it matches (`description foo`), just as the device CLI does. - **Idempotency-tracked negated forms** — when a negated form is itself tracked by an idempotency rule (e.g. IOS `no logging console`), it replaces its counterpart and *persists* in the rendered future config, because the device stores it as explicit configuration. -- **Unmatched negations** — a negation that matches nothing in the running config is kept in the output as a signal that the change would not apply cleanly. +- **Unmatched negations** — a negation that matches nothing in the running config is kept in the output as a signal that the change would not apply cleanly. Use [`future_with_report()`](#auditing-negation-resolution) to detect these explicitly instead of scanning the render. + +## Auditing negation resolution + +`HConfig.future_with_report()` behaves exactly like `future()` but also returns a `FutureReport` describing how the change's negations resolved: + +```python +future_config, report = running_config.future_with_report(change_config) + +report.unresolved_negations # negations that matched nothing in the running config +report.idempotency_replacements # negations that displaced an idempotency-tracked line but persist +``` + +Change-validation pipelines can assert `not report.unresolved_negations` instead of grepping the rendered output for `no ` lines. Both fields hold `HConfigChild` nodes that live in the returned future config tree, so `path()` and `lineage()` give the surrounding context: + +```python +for negation in report.unresolved_negations: + print(" > ".join(negation.path())) +``` ## Pruning emptied sections diff --git a/docs/user/migrating-from-v3.md b/docs/user/migrating-from-v3.md index 29fffdb..f75edbb 100644 --- a/docs/user/migrating-from-v3.md +++ b/docs/user/migrating-from-v3.md @@ -177,6 +177,8 @@ Not required for migration, but these are the headline additions: [custom drivers](../dev/creating-drivers.md). - Built-in post-load callbacks are public functions removable by identity — see [Customizing Driver Rules](../admin/customizing-rules.md#customizing-post-load-callbacks). +- `HConfig.future_with_report()` — predict a future config and get a + `FutureReport` of [how the change's negations resolved](future-config.md#auditing-negation-resolution). ## Next steps diff --git a/hier_config/__init__.py b/hier_config/__init__.py index 41ac3a2..aeb1a06 100644 --- a/hier_config/__init__.py +++ b/hier_config/__init__.py @@ -26,6 +26,7 @@ ) from .reporting import RemediationReporter from .root import HConfig +from .tree_algorithms import FutureReport from .workflows import WorkflowRemediation __all__ = ( @@ -33,6 +34,7 @@ "ConfigViewInterfaceBase", "DriverNotFoundError", "DuplicateChildError", + "FutureReport", "HConfig", "HConfigChild", "HConfigDriverBase", diff --git a/hier_config/root.py b/hier_config/root.py index b5b6bad..9f93b92 100644 --- a/hier_config/root.py +++ b/hier_config/root.py @@ -7,8 +7,9 @@ from .child import HConfigChild from .models import Dump, DumpLine, Platform, ReferenceLocation from .tree_algorithms import ( + FutureReport, compute_difference, - compute_future, + compute_future_with_report, compute_remediation, compute_with_tags, prune_emptied_branches, @@ -297,11 +298,33 @@ def future( removed, matching devices that prune empty stanzas on commit; sections that were already empty are kept. """ + future_config, _ = self.future_with_report( + config, + prune_empty_branches=prune_empty_branches, + ) + return future_config + + def future_with_report( + self, + config: HConfig, + *, + prune_empty_branches: bool = False, + ) -> tuple[HConfig, FutureReport]: + """EXPERIMENTAL - like `future()`, but also report how negations resolved. + + Returns the predicted future config together with a `FutureReport` + whose `unresolved_negations` are negations that matched nothing in + self and `idempotency_replacements` are negations that displaced an + idempotency-tracked line but persist in the render. Both hold nodes + of the returned future config tree. Change-validation pipelines can + assert `not report.unresolved_negations` instead of grepping the + render for negation lines. + """ future_config = HConfig(self.driver) - compute_future(self, config, future_config) + report = compute_future_with_report(self, config, future_config) if prune_empty_branches: prune_emptied_branches(self, future_config) - return future_config + return future_config, report def with_tags(self, tags: Iterable[str]) -> HConfig: """Returns a new instance recursively containing children that only have a subset of tags.""" diff --git a/hier_config/tree_algorithms.py b/hier_config/tree_algorithms.py index 4bead11..f7e35c7 100644 --- a/hier_config/tree_algorithms.py +++ b/hier_config/tree_algorithms.py @@ -9,6 +9,7 @@ from __future__ import annotations +from dataclasses import dataclass, field from typing import TYPE_CHECKING, TypeVar if TYPE_CHECKING: @@ -19,6 +20,58 @@ _HConfigRootOrChildT = TypeVar("_HConfigRootOrChildT", bound=HConfig | HConfigChild) +@dataclass(frozen=True, slots=True) +class FutureReport: + """How `HConfig.future_with_report()` resolved a change's negations (#285). + + The nodes reference the returned future config tree, so `path()` and + `lineage()` give the surrounding context. + """ + + unresolved_negations: tuple[HConfigChild, ...] + idempotency_replacements: tuple[HConfigChild, ...] + + +def _new_child_list() -> list[HConfigChild]: + return [] + + +@dataclass(slots=True) +class _FutureReportBuilder: + """Mutable collector threaded through the `compute_future` recursion.""" + + unresolved_negations: list[HConfigChild] = field(default_factory=_new_child_list) + idempotency_replacements: list[HConfigChild] = field( + default_factory=_new_child_list, + ) + + def record_unresolved(self, node: HConfigChild) -> None: + """Record a kept negation that matched nothing in the source config.""" + self.unresolved_negations.append(node) + + def record_idempotency(self, node: HConfigChild, *, is_negation: bool) -> None: + """Record a persisting idempotency replacement when it is a negation.""" + if is_negation: + self.idempotency_replacements.append(node) + + def build(self) -> FutureReport: + return FutureReport( + unresolved_negations=tuple(self.unresolved_negations), + idempotency_replacements=tuple(self.idempotency_replacements), + ) + + +def compute_future_with_report( + source: HConfigBase, + config: HConfig | HConfigChild, + future_config: HConfig | HConfigChild, +) -> FutureReport: + """Compute the future config subtree and report how negations resolved.""" + report = _FutureReportBuilder() + compute_future(source, config, future_config, report=report) + return report.build() + + def compute_remediation( source: HConfigBase, target: _HConfigRootOrChildT, @@ -171,6 +224,8 @@ def compute_future( # ruff:ignore[complex-structure] source: HConfigBase, config: HConfig | HConfigChild, future_config: HConfig | HConfigChild, + *, + report: _FutureReportBuilder | None = None, ) -> None: """Recursively compute the future configuration subtree. @@ -185,6 +240,7 @@ def compute_future( # ruff:ignore[complex-structure] - Idempotent command avoid list - And likely other edge cases """ + report = report or _FutureReportBuilder() negated_or_recursed, config_children_ignore = _future_pre(source, config) for config_child in config.children: @@ -212,7 +268,10 @@ def compute_future( # ruff:ignore[complex-structure] config_child, source.children, ): - future_config.add_deep_copy_of(config_child) + report.record_idempotency( + future_config.add_deep_copy_of(config_child), + is_negation=is_negation, + ) negated_or_recursed.add(self_child.text) # Shorthand negation: `no description` removes `description foo`, as # devices do (#269). @@ -229,13 +288,13 @@ def compute_future( # ruff:ignore[complex-structure] # config_child is already in source elif self_child := source.get_child(equals=config_child.text): future_child = future_config.add_shallow_copy_of(self_child) - compute_future(self_child, config_child, future_child) + compute_future(self_child, config_child, future_child, report=report) negated_or_recursed.add(config_child.text) # A negation matching nothing is kept: it accounts for "no ..." lines # native to the running config and doubles as a did-not-apply-cleanly # signal for callers (#269). elif is_negation: - future_config.add_shallow_copy_of(config_child) + report.record_unresolved(future_config.add_shallow_copy_of(config_child)) # The negated form of config_child is in source.children elif self_child := source.get_child( equals=f"{source.driver.negation_prefix}{config_child.text}", diff --git a/tests/integration/test_remediation.py b/tests/integration/test_remediation.py index 7dffa29..3966f77 100644 --- a/tests/integration/test_remediation.py +++ b/tests/integration/test_remediation.py @@ -1,6 +1,11 @@ """Integration tests for remediation, future, difference, and sectional overwrite.""" +from dataclasses import FrozenInstanceError + +import pytest + from hier_config import ( + FutureReport, HConfig, HConfigChild, WorkflowRemediation, @@ -655,3 +660,169 @@ def test_future_prune_keeps_originally_empty_parents() -> None: "hostname r1", "interface GigabitEthernet0/0/0/0", ) + + +def test_future_with_report_flags_unresolved_negation() -> None: + """A negation matching nothing is reported as unresolved (#285).""" + running_config = HConfig.from_text( + Platform.ARISTA_EOS, "interface Ethernet1\n switchport access vlan 10\n" + ) + change = HConfig.from_text( + Platform.ARISTA_EOS, "interface Ethernet1\n no description\n" + ) + future_config, report = running_config.future_with_report(change) + + assert future_config.to_lines() == ( + "interface Ethernet1", + " no description", + " switchport access vlan 10", + ) + assert len(report.unresolved_negations) == 1 + assert tuple(report.unresolved_negations[0].path()) == ( + "interface Ethernet1", + "no description", + ) + assert not report.idempotency_replacements + + +def test_future_with_report_clean_change_is_empty() -> None: + """Exact-match and shorthand negations resolve without report entries (#285).""" + running_config = HConfig.from_text( + Platform.ARISTA_EOS, + "router bgp 65000\n" + " neighbor 10.0.0.1 peer group PEERS\n" + "interface Ethernet1\n" + " description foo\n" + " switchport access vlan 10\n", + ) + change = HConfig.from_text( + Platform.ARISTA_EOS, + "router bgp 65000\n" + " no neighbor 10.0.0.1 peer group PEERS\n" + "interface Ethernet1\n" + " no description\n", + ) + _, report = running_config.future_with_report(change) + + assert not report.unresolved_negations + assert not report.idempotency_replacements + + +def test_future_with_report_records_idempotency_replacement() -> None: + """A stale-valued negation that persists via idempotency is reported (#285).""" + running_config = HConfig.from_text( + Platform.ARISTA_EOS, + "router bgp 65000\n neighbor 10.0.0.1 description spine1\n", + ) + change = HConfig.from_text( + Platform.ARISTA_EOS, + "router bgp 65000\n no neighbor 10.0.0.1 description stale-value\n", + ) + future_config, report = running_config.future_with_report(change) + + bgp = future_config.get_child(equals="router bgp 65000") + assert bgp is not None + rendered = bgp.get_child(equals="no neighbor 10.0.0.1 description stale-value") + assert rendered is not None + assert len(report.idempotency_replacements) == 1 + assert report.idempotency_replacements[0] is rendered + assert not report.unresolved_negations + + +def test_future_with_report_ignores_positive_idempotent_replacement() -> None: + """A positive-form idempotent value update is not a signal (#285).""" + running_config = HConfig.from_text( + Platform.ARISTA_EOS, + "router bgp 65000\n neighbor 10.0.0.1 description spine1\n", + ) + change = HConfig.from_text( + Platform.ARISTA_EOS, + "router bgp 65000\n neighbor 10.0.0.1 description new-value\n", + ) + future_config, report = running_config.future_with_report(change) + + assert future_config.to_lines() == ( + "router bgp 65000", + " neighbor 10.0.0.1 description new-value", + ) + assert not report.unresolved_negations + assert not report.idempotency_replacements + + +def test_future_with_report_accumulates_across_sections() -> None: + """Unresolved negations are collected across recursed sections (#285).""" + running_config = HConfig.from_text( + Platform.ARISTA_EOS, + "interface Ethernet1\n" + " switchport access vlan 10\n" + "interface Ethernet2\n" + " switchport access vlan 20\n", + ) + change = HConfig.from_text( + Platform.ARISTA_EOS, + "interface Ethernet1\n no description\ninterface Ethernet2\n no shutdown\n", + ) + _, report = running_config.future_with_report(change) + + assert tuple( + tuple(negation.path()) for negation in report.unresolved_negations + ) == ( + ("interface Ethernet1", "no description"), + ("interface Ethernet2", "no shutdown"), + ) + + +def test_future_with_report_survives_pruning() -> None: + """Reported nodes remain live in the pruned future tree (#285).""" + running_config = HConfig.from_text( + Platform.ARISTA_EOS, "interface Ethernet1\n switchport access vlan 10\n" + ) + change = HConfig.from_text( + Platform.ARISTA_EOS, "interface Ethernet1\n no description\n" + ) + future_config, report = running_config.future_with_report( + change, prune_empty_branches=True + ) + + assert len(report.unresolved_negations) == 1 + interface = future_config.get_child(equals="interface Ethernet1") + assert interface is not None + assert report.unresolved_negations[0] is interface.get_child( + equals="no description" + ) + + +def test_future_with_report_output_matches_future() -> None: + """future_with_report() renders identically to future() (#285).""" + running_text = ( + "router bgp 65000\n" + " neighbor 10.0.0.1 peer group PEERS\n" + " neighbor 10.0.0.1 description spine1\n" + "interface Ethernet1\n" + " description foo\n" + ) + change_text = ( + "router bgp 65000\n" + " no neighbor 10.0.0.1 peer group PEERS\n" + " no neighbor 10.0.0.1 description stale-value\n" + "interface Ethernet1\n" + " no description\n" + " no shutdown\n" + ) + for prune in (False, True): + running_config = HConfig.from_text(Platform.ARISTA_EOS, running_text) + change = HConfig.from_text(Platform.ARISTA_EOS, change_text) + expected = running_config.future(change, prune_empty_branches=prune).to_lines() + future_config, _ = running_config.future_with_report( + change, prune_empty_branches=prune + ) + + assert future_config.to_lines() == expected + + +def test_future_report_is_frozen() -> None: + """FutureReport is immutable once built (#285).""" + report = FutureReport(unresolved_negations=(), idempotency_replacements=()) + + with pytest.raises(FrozenInstanceError): + report.unresolved_negations = () # type: ignore[misc]