Skip to content
Merged
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
71 changes: 71 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 10 additions & 4 deletions docs/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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).

---

Expand Down Expand Up @@ -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
),
]
```

Expand Down
10 changes: 9 additions & 1 deletion hier_config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...]

Expand Down
132 changes: 132 additions & 0 deletions tests/test_idempotent_commands.py
Original file line number Diff line number Diff line change
@@ -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
Loading