diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aa29540 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +hier_config is a Python library that compares network device configurations (running vs intended) and generates minimal remediation commands. It parses config text into hierarchical trees and computes diffs respecting vendor-specific syntax rules. + +## Build & Test Commands + +All commands use **poetry** (not pip): + +```bash +# Full lint + test suite (what CI runs) +poetry run ./scripts/build.py lint-and-test + +# Lint only (ruff, mypy, pyright, pylint, yamllint, flynt — run in parallel) +poetry run ./scripts/build.py lint + +# Tests only (95% coverage required) +poetry run ./scripts/build.py pytest --coverage + +# Run a single test +poetry run pytest tests/test_driver_cisco_xr.py::test_multiple_groups_no_duplicate_child_error -v + +# Run a single test file +poetry run pytest tests/test_driver_cisco_xr.py -v + +# Auto-fix formatting +poetry run ruff format hier_config tests scripts +``` + +## Architecture + +Three-layer design: **Tree** (parse/represent config), **Driver** (platform-specific rules), **Workflow** (compute diffs/remediations). + +### Tree Layer + +- `HConfig` (root.py) — root node, owns the driver reference. Key methods: `config_to_get_to()`, `future()`, `difference()`, `dump_simple()`. +- `HConfigChild` (child.py) — tree node with `text`, `parent`, `children`, `tags`, `comments`. Provides `is_lineage_match()` for rule evaluation and `negate()` for negation logic. +- `HConfigBase` (base.py) — abstract base shared by both. Provides `add_child()`, `get_children_deep()`, `_config_to_get_to()` (left pass = negate missing, right pass = add new). +- `HConfigChildren` (children.py) — ordered collection with O(1) dict lookup by text. + +### Driver Layer (`platforms/`) + +Each platform driver subclasses `HConfigDriverBase` (driver_base.py) and overrides `_instantiate_rules()` to return `HConfigDriverRules` — a Pydantic model holding lists of typed rule objects: + +- **Rule models** are frozen Pydantic BaseModels defined in `models.py`. Each uses `match_rules: tuple[MatchRule, ...]` for lineage-based matching. +- **MatchRule** supports: `equals`, `startswith`, `endswith`, `contains`, `re_search`. Multiple fields = AND logic. +- `is_lineage_match()` on `HConfigChild` compares the full ancestry path against a tuple of MatchRules. + +Key rule types: `SectionalExitingRule`, `IdempotentCommandsRule`, `PerLineSubRule`, `NegationDefaultWithRule`, `OrderingRule`, `ParentAllowsDuplicateChildRule`, `IndentAdjustRule`. + +Platform drivers: `CISCO_IOS`, `CISCO_XR`, `CISCO_NXOS`, `ARISTA_EOS`, `FORTINET_FORTIOS`, `HP_PROCURVE`, `HP_COMWARE5`, `JUNIPER_JUNOS`, `VYOS`, `GENERIC`. + +### Workflow Layer + +- `WorkflowRemediation` (workflows.py) — primary API: `remediation_config` and `rollback_config` properties. +- Constructor functions in `constructors.py`: `get_hconfig()`, `get_hconfig_fast_load()`, `get_hconfig_driver()`. + +### Adding a New Driver Rule Type + +Pattern: add frozen Pydantic model in `models.py` → add default factory + field in `HConfigDriverRules` (driver_base.py) → consume in `child.py` or `root.py` → populate in platform driver's `_instantiate_rules()`. + +## Code Style + +- Strict type checking: pyright strict mode + mypy strict + pylint with pydantic plugin. +- Ruff handles formatting (line length 88) and most lint rules. +- Docstrings use raw strings (`r"""..."""`) when containing backslashes. +- Test coverage minimum: 95%. +- Python 3.10+ required. diff --git a/README.md b/README.md index d6cf4c4..ce4febb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Hierarchical Configuration has been used extensively on: - [x] Arista EOS - [x] Fortinet FortiOS - [x] HP Procurve (Aruba AOSS) +- [x] HP Comware5 / H3C In addition to the Cisco-style syntax, hier_config offers experimental support for Juniper-style configurations using set and delete commands. This allows users to remediate Junos configurations in native syntax. However, please note that Juniper syntax support is still in an experimental phase and has not been tested extensively. Use with caution in production environments. diff --git a/docs/architecture.md b/docs/architecture.md index 29d1f63..bc46d3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ A frozen Pydantic model holding lists of typed rule objects: |-------|-----------|--------| | `negate_with` | `NegationDefaultWithRule` | Replace negation with a fixed command | | `negation_default_when` | `NegationDefaultWhenRule` | Use `default` form instead of `no` | -| `sectional_exiting` | `SectionalExitingRule` | Emit an exit token at end of section | +| `sectional_exiting` | `SectionalExitingRule` | Emit an exit token at end of section (optionally at parent indent level) | | `sectional_overwrite` | `SectionalOverwriteRule` | Negate + re-create whole section | | `sectional_overwrite_no_negate` | `SectionalOverwriteNoNegateRule` | Re-create without prior negation | | `ordering` | `OrderingRule` | Assign integer weights for apply order | diff --git a/docs/drivers.md b/docs/drivers.md index 39aefac..9ac71ce 100644 --- a/docs/drivers.md +++ b/docs/drivers.md @@ -92,8 +92,8 @@ driver = get_hconfig_driver(Platform.ARISTA_EOS) Cisco IOS XR uses a commit-based configuration model with several syntax differences from classic IOS: - **[Sectional overwrite no-negate](glossary.md#sectional-overwrite-no-negate)**: `prefix-set`, `route-policy`, and similar blocks are replaced wholesale rather than line-by-line, because IOS XR does not support partial modification of these objects. -- **[Indent adjust](glossary.md#indent-adjust)**: inline templates in `router bgp` use a different indentation depth; the driver adjusts the tree depth between `!#` markers. -- **[Sectional exiting](glossary.md#sectional-exiting)**: route-policy blocks close with `end-policy`; prefix-set blocks close with `end-set`. +- **[Indent adjust](glossary.md#indent-adjust)**: `template` blocks use a different indentation depth; the driver adjusts the tree depth between `template` and `end-template` markers. +- **[Sectional exiting](glossary.md#sectional-exiting)**: route-policy blocks close with `end-policy`; prefix-set and community-set blocks close with `end-set`; template blocks close with `end-template`; group blocks close with `end-group`. All `end-*` exit text is rendered at the parent indentation level (`exit_text_parent_level=True`). - ACL sequence numbers are preserved for correct ordered access-list handling. Platform enum: `Platform.CISCO_XR` @@ -297,6 +297,7 @@ In Hier Config, the rules within a driver are organized into sections, each targ - **`SectionalExitingRule`**: - `match_rules`: A tuple of `MatchRule` objects defining the section's boundaries. - `exit_text`: The command used to exit the section. + - `exit_text_parent_level`: A boolean (default `False`). When `True`, the exit text is rendered at the parent's indentation level rather than the section's own level (e.g., IOS XR `end-policy` appears unindented). --- @@ -812,8 +813,13 @@ sectional_exiting=[ MatchRule(startswith="policy-map"), MatchRule(startswith="class"), ), - exit_text="exit" - ) + exit_text="exit", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="route-policy"),), + exit_text="end-policy", + exit_text_parent_level=True, # render at parent indentation + ), ] ``` diff --git a/hier_config/models.py b/hier_config/models.py index d122e46..45d15c4 100644 --- a/hier_config/models.py +++ b/hier_config/models.py @@ -96,7 +96,15 @@ class PerLineSubRule(BaseModel): class IdempotentCommandsRule(BaseModel): - """Rule declaring that a command family is idempotent (last value wins).""" + r"""Rule declaring that a command family is idempotent (last value wins). + + Use regex capture groups in ``MatchRule.re_search`` to parameterize + idempotency keys — e.g. ``r"^client (\S+) server-key"`` makes each + client IP independently idempotent. + + Prefer separate rules for unrelated command families rather than + combining them via a tuple ``startswith`` in a single ``MatchRule``. + """ match_rules: tuple[MatchRule, ...] diff --git a/tests/test_idempotent_commands.py b/tests/test_idempotent_commands.py new file mode 100644 index 0000000..63b3939 --- /dev/null +++ b/tests/test_idempotent_commands.py @@ -0,0 +1,132 @@ +from hier_config import get_hconfig_fast_load +from hier_config.models import ( + IdempotentCommandsRule, + MatchRule, + Platform, +) +from hier_config.platforms.driver_base import HConfigDriverRules +from hier_config.platforms.generic.driver import HConfigDriverGeneric + + +def _make_driver( + rules: list[IdempotentCommandsRule], +) -> HConfigDriverGeneric: + """Create a generic driver with custom idempotent rules.""" + driver = HConfigDriverGeneric() + driver.rules = HConfigDriverRules(idempotent_commands=rules) + return driver + + +def test_parameterized_regex_same_key_is_idempotent() -> None: + """Commands with the same regex capture group value are idempotent.""" + driver = _make_driver( + [ + IdempotentCommandsRule( + match_rules=(MatchRule(re_search=r"^client (\S+) server-key"),), + ), + ], + ) + running = get_hconfig_fast_load( + driver, + ("client 10.1.1.1 server-key KEY_OLD",), + ) + generated = get_hconfig_fast_load( + driver, + ("client 10.1.1.1 server-key KEY_NEW",), + ) + remediation = running.config_to_get_to(generated) + assert remediation.dump_simple() == ("client 10.1.1.1 server-key KEY_NEW",) + + +def test_parameterized_regex_different_key_not_idempotent() -> None: + """Commands with different regex capture group values are independent.""" + driver = _make_driver( + [ + IdempotentCommandsRule( + match_rules=(MatchRule(re_search=r"^client (\S+) server-key"),), + ), + ], + ) + running = get_hconfig_fast_load( + driver, + ( + "client 10.1.1.1 server-key KEY1", + "client 10.2.2.2 server-key KEY2", + ), + ) + generated = get_hconfig_fast_load( + driver, + ("client 10.1.1.1 server-key KEY1",), + ) + remediation = running.config_to_get_to(generated) + # 10.2.2.2 is removed because it's not in generated (not idempotent with 10.1.1.1) + assert remediation.dump_simple() == ("no client 10.2.2.2 server-key KEY2",) + + +def test_bgp_neighbor_regex_idempotent() -> None: + """BGP neighbor remote-as is idempotent per neighbor IP via regex capture group.""" + platform = Platform.CISCO_XR + running = get_hconfig_fast_load( + platform, + ( + "router bgp 1001", + " neighbor 40.0.0.0 remote-as 33001", + " neighbor 40.0.0.17 remote-as 1002", + " neighbor 40.0.0.8 remote-as 2002", + ), + ) + generated = get_hconfig_fast_load( + platform, + ( + "router bgp 1001", + " neighbor 40.0.0.0 remote-as 44001", + " neighbor 1000:: remote-as 2001", + " neighbor 1000::8 remote-as 2002", + ), + ) + remediation = running.config_to_get_to(generated) + lines = remediation.dump_simple() + # The changed ASN for 40.0.0.0 should appear + assert " neighbor 40.0.0.0 remote-as 44001" in lines + # New neighbors should be added + assert " neighbor 1000:: remote-as 2001" in lines + assert " neighbor 1000::8 remote-as 2002" in lines + # Removed neighbors should be negated + assert " no neighbor 40.0.0.17 remote-as 1002" in lines + assert " no neighbor 40.0.0.8 remote-as 2002" in lines + + +def test_startswith_rules_do_not_cross_contaminate() -> None: + """Separate idempotent rules with similar prefixes don't interfere.""" + driver = _make_driver( + [ + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="hardware access-list tcam region"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="hardware profile tcam region"),), + ), + ], + ) + running = get_hconfig_fast_load( + driver, + ( + "hardware access-list tcam region arp-ether 0", + "hardware profile tcam region racl 0", + ), + ) + generated = get_hconfig_fast_load( + driver, + ( + "hardware access-list tcam region arp-ether 256", + "hardware profile tcam region racl 512", + ), + ) + remediation = running.config_to_get_to(generated) + lines = remediation.dump_simple() + # Both should be updated independently (idempotent within their own rule) + assert "hardware access-list tcam region arp-ether 256" in lines + assert "hardware profile tcam region racl 512" in lines + # Old values should NOT be negated (they're idempotent) + assert "no hardware access-list tcam region arp-ether 0" not in lines + assert "no hardware profile tcam region racl 0" not in lines