diff --git a/.claude/skills/hier-config-new-driver/SKILL.md b/.claude/skills/hier-config-new-driver/SKILL.md index dd159417..6f79b9fc 100644 --- a/.claude/skills/hier-config-new-driver/SKILL.md +++ b/.claude/skills/hier-config-new-driver/SKILL.md @@ -5,7 +5,7 @@ description: Use when adding hier_config support for a new network platform or o # Build a New hier_config Platform Driver -Scaffold an in-tree platform driver the way this repo expects. The authoritative recipe is `docs/dev/extending.md`; this skill adds the concrete templates. For a driver that lives *outside* this repo (in user code), follow `docs/user/custom-drivers.md#creating-a-custom-driver` instead. +Scaffold an in-tree platform driver the way this repo expects. The authoritative recipe is `docs/dev/creating-drivers.md`; this skill adds the concrete templates. For a driver that lives *outside* this repo (in user code), follow `docs/admin/custom-drivers.md` instead. ## Step 1: Characterize the Platform @@ -14,7 +14,7 @@ Answer these before writing code — they determine which overrides and rules th | Question | Driver hook if non-default | |----------|---------------------------| | Negation prefix (`no `? `undo `? `delete `?) | `negation_prefix` property (default `"no "`) | -| Some commands reset with a different form? | `NegationDefaultWithRule` / override `swap_negation` | +| Some commands reset with a different form? | `NegationRule` (REPLACE/DEFAULT/REGEX_SUB strategy) / override `swap_negation` | | Sections closed with an exit token (`exit`, `quit`, `end-*`)? | `SectionalExitingRule` / override `sectional_exit` | | Last-write-wins commands (`hostname`, `description`, …)? | `IdempotentCommandsRule` | | Comment/banner lines to strip on load? | `PerLineSubRule` / `FullTextSubRule` | @@ -26,29 +26,29 @@ Reference implementations: `platforms/huawei_vrp/driver.py` (small, rule-based), ## Step 2: Write the Failing Test First (TDD) -Create `tests/test_driver_.py` before the driver exists — conventions in `docs/dev/testing.md`. Flat functions, full annotations, round-trip idiom: +Create `tests/integration/test_.py` before the driver exists — conventions in `docs/dev/testing.md`. Flat functions, full annotations, round-trip idiom: ```python -from hier_config import Platform, get_hconfig_fast_load +from hier_config import HConfig, Platform def test_negation_prefix() -> None: - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( Platform.ACME_OS, ("interface eth0", " shutdown") ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( Platform.ACME_OS, ("interface eth0",) ) - remediation = running_config.config_to_get_to(generated_config) - assert remediation.dump_simple() == ("interface eth0", " no shutdown") + remediation = running_config.remediation(generated_config) + assert remediation.to_lines() == ("interface eth0", " no shutdown") running_after = running_config.future(remediation) - rollback = running_after.config_to_get_to(running_config) + rollback = running_after.remediation(running_config) running_after_rollback = running_after.future(rollback) assert not tuple(running_config.unified_diff(running_after_rollback)) ``` -Run it and confirm it fails for the right reason (unknown platform), not an import error. Add realistic config fixtures to `tests/fixtures/` if tests need more than inline tuples. +Run it and confirm it fails for the right reason (unknown platform), not an import error. Add realistic config fixtures to `tests/integration/fixtures/` if tests need more than inline tuples. ## Step 3: Scaffold the Driver @@ -77,22 +77,24 @@ class HConfigDriverAcmeOS(HConfigDriverBase): ) ``` -Replace `#` in the `per_line_sub` regex with the platform's actual comment token, and keep the `^\s*` anchor so indented comments are stripped too. Rules take `match_rules: tuple[MatchRule, ...]` (immutable — never lists). A minimal driver returning bare `HConfigDriverRules()` is valid; only add rules the platform needs. Public classes require docstrings. +Replace `#` in the `per_line_sub` regex with the platform's actual comment token, and keep the `^\s*` anchor so indented comments are stripped too. Rules take `match_rules: tuple[MatchRule, ...]` (immutable — never lists), while the `HConfigDriverRules` *collection fields themselves* are intentionally `list[...]` as shown above (so built-in rules/callbacks can be removed by identity). A minimal driver returning bare `HConfigDriverRules()` is valid; only add rules the platform needs. Public classes require docstrings. ## Step 4: Register the Platform 1. Add the member to the `Platform` enum in `hier_config/models.py` (alphabetical position). Note the enum uses `auto()`, so inserting a member renumbers everything after it — fine for in-repo use, but never rely on `Platform.value` for serialization. -2. Add the mapping to the `platform_drivers` dict in `get_hconfig_driver` (`hier_config/constructors.py`) and import the driver class there. +2. Add the mapping to the `_BUILTIN_DRIVERS` dict in `hier_config/registry.py` and import the driver class there. The key must be the canonical uppercase name string — `Platform.ACME_OS.name` — not the enum member (`_normalize()` canonicalizes lookups to `.name`, so a `Platform`-member key would be silently unreachable). If the platform has a config view, set the `view_class` attribute on the driver. ## Step 5: Document and Log -- Add a driver section (behavior summary + activation snippet) and a platform-table row to `docs/user/drivers.md`. Mark the status `Experimental` for a new driver. +- Add driver-level unit tests in `tests/unit/platforms/test_.py` (every recent driver has one; see `tests/unit/platforms/test_aruba_aoscx.py`). If the driver ships a config view, add `tests/unit/platforms/views/test_.py` too. +- Add a driver section (behavior summary) and a platform-table row to `docs/admin/platforms.md`. Mark the status `Experimental` for a new driver. +- If you introduced a new *rule type* (not just rule instances), document it in `docs/dev/rule-reference.md`. - Add a `CHANGELOG.md` entry under `## [Unreleased]` → `### Added`. ## Step 6: Run the Gates ```bash -poetry run pytest tests/test_driver_.py -v +poetry run pytest tests/integration/test_.py -v poetry run ./scripts/build.py lint-and-test poetry run mkdocs build --strict ``` diff --git a/.claude/skills/hier-config-review/SKILL.md b/.claude/skills/hier-config-review/SKILL.md index ad24c284..f3e59b7e 100644 --- a/.claude/skills/hier-config-review/SKILL.md +++ b/.claude/skills/hier-config-review/SKILL.md @@ -9,8 +9,11 @@ Review the current change set against this repository's standards and report fin ## Step 1: Establish the Diff +Pick the base branch first: v4 work branches from `next`; only v3.x maintenance work branches from `master`. Diffing a `next`-based branch against `master` would include all of v4 and make the review meaningless. + ```bash -git diff master...HEAD --stat # on a branch +git diff "$(git merge-base origin/next HEAD)"...HEAD --stat # v4 branch (the usual case) +git diff "$(git merge-base origin/master HEAD)"...HEAD --stat # v3.x maintenance branch git diff HEAD --stat # fall back: uncommitted work git diff --staged --stat # fall back: staged only ``` @@ -26,12 +29,14 @@ poetry run ./scripts/build.py lint poetry run ./scripts/build.py pytest --coverage ``` -If docs/ or mkdocs.yml changed, also run: +Also run the docs build — CI runs it unconditionally on every push/PR, not just when docs change: ```bash poetry run mkdocs build --strict ``` +Remember CI's test matrix covers Python 3.10–3.14: flag syntax or stdlib usage newer than 3.10 even if local checks pass. + ## Step 3: Review by Category Read the referenced doc before judging that category — the docs are the standard, not your intuition. @@ -39,7 +44,7 @@ Read the referenced doc before judging that category — the docs are the standa ### Models & Typing — read `docs/dev/code-style.md` - New Pydantic models subclass the local `BaseModel` (`hier_config/models.py`), never `pydantic.BaseModel` directly. -- Model fields use `tuple`/`frozenset`, never `list`/`set`. Rule models use `match_rules: tuple[MatchRule, ...]`. +- Model fields use `tuple`/`frozenset`, never `list`/`set`. Rule models use `match_rules: tuple[MatchRule, ...]`. Exception: the rule-collection fields on `HConfigDriverRules` are intentionally `list[...]` (removal-by-identity, #286) — do not flag them. - No `Any`, no missing annotations, no unjustified `# type: ignore` / `# noqa`. - Lint/coverage/type-checking configuration was not loosened. @@ -47,14 +52,14 @@ Read the referenced doc before judging that category — the docs are the standa - Every library code change has corresponding tests. - Tests are flat functions with full annotations; no test classes (benchmarks excepted). -- Driver changes are tested in `tests/test_driver_.py`; view changes in `tests/config_view/`. -- Driver/rule behavior changes include the round-trip idiom: remediation asserted via `dump_simple()` tuple, rollback verified via no `unified_diff`. -- New fixtures live in `tests/fixtures/` with module-scoped accessors in `tests/conftest.py`. +- Driver changes are tested in `tests/integration/test_.py` (unit-level driver tests in `tests/unit/platforms/`); view changes in `tests/unit/platforms/views/`. +- Driver/rule behavior changes include the round-trip idiom: remediation asserted via `to_lines()` tuple, rollback verified via no `unified_diff`. +- New fixtures live in the sibling `fixtures/` directory with module-scoped accessors in the relevant `conftest.py`. -### Driver & Rule Changes — read `docs/dev/extending.md` +### Driver & Rule Changes — read `docs/dev/creating-drivers.md` and `docs/dev/rule-reference.md` -- New rule types: frozen model in `models.py` → named default factory + field on `HConfigDriverRules` → consumed in `child.py`/`root.py` → populated in drivers. -- New platforms: `Platform` enum member, `get_hconfig_driver` wiring, per-platform test file, and a driver section + table row in `docs/user/drivers.md`. +- New rule types: frozen model in `models.py` → named default factory + field on `HConfigDriverRules` → consumed in `child.py`/`root.py` → populated in drivers → documented in `docs/dev/rule-reference.md`. +- New platforms: `Platform` enum member, `_BUILTIN_DRIVERS` wiring in `hier_config/registry.py` **keyed on `Platform.X.name`** (a `Platform`-member key is silently unreachable — `_normalize()` canonicalizes to uppercase name strings), `view_class` on the driver if it has a config view, per-platform test file, and a driver section + table row in `docs/admin/platforms.md`. ### Changelog @@ -62,7 +67,7 @@ Read the referenced doc before judging that category — the docs are the standa ### Docs -- Public API or driver behavior changes are reflected in `docs/user/` (and `docs/user/api-reference.md` where relevant). +- Public API or driver behavior changes are reflected in `docs/user/` or `docs/admin/` (and `docs/dev/api-reference.md` where relevant). - New doc pages are in the `mkdocs.yml` nav; moved pages have a `redirect_maps` entry. ### Commits — read `CONTRIBUTING.md` (Commit Message Style) diff --git a/.claude/skills/hier-config-troubleshoot/SKILL.md b/.claude/skills/hier-config-troubleshoot/SKILL.md index 5ae2cc5e..fb6a898b 100644 --- a/.claude/skills/hier-config-troubleshoot/SKILL.md +++ b/.claude/skills/hier-config-troubleshoot/SKILL.md @@ -12,18 +12,18 @@ Diagnose why hier_config produced unexpected output. Work from a minimal reprodu Reduce the problem to the smallest config pair that shows it, using inline tuples — no fixture files needed: ```python -from hier_config import Platform, get_hconfig_fast_load +from hier_config import HConfig, Platform -running_config = get_hconfig_fast_load(Platform.CISCO_IOS, ("hostname foo",)) -generated_config = get_hconfig_fast_load(Platform.CISCO_IOS, ("hostname bar",)) -print("\n".join(running_config.config_to_get_to(generated_config).dump_simple())) +running_config = HConfig.from_lines(Platform.CISCO_IOS, ("hostname foo",)) +generated_config = HConfig.from_lines(Platform.CISCO_IOS, ("hostname bar",)) +print("\n".join(running_config.remediation(generated_config).to_lines())) ``` -Bisect: delete config lines until removing one more makes the symptom disappear. That line (and its ancestry) is where to look. If the report compares platforms ("works on X, broken on Y"), reproduce **both** platforms — claimed-working references are often wrong, and the platforms that actually differ tell you which rule is responsible. If the raw config parses differently than expected, compare `get_hconfig()` (full parse with preprocessing) against `get_hconfig_fast_load()` (no preprocessing) — a difference means a `per_line_sub`/`full_text_sub`/`config_preprocessor` or indentation issue. +Bisect: delete config lines until removing one more makes the symptom disappear. That line (and its ancestry) is where to look. If the report compares platforms ("works on X, broken on Y"), reproduce **both** platforms — claimed-working references are often wrong, and the platforms that actually differ tell you which rule is responsible. If the raw config parses differently than expected, compare `HConfig.from_text()` (full parse with preprocessing) against `HConfig.from_lines()` (no preprocessing) — a difference means a `per_line_sub`/`full_text_sub`/`config_preprocessor` or indentation issue. ## Step 2: Inspect the Tree, Not the Text -- `config.dump_simple()` — the parsed tree as indented lines; wrong nesting is visible immediately. +- `config.to_lines()` — the parsed tree as indented lines; wrong nesting is visible immediately. - `running_config.unified_diff(generated_config)` — structure-aware diff. - `config.driver.rules` — the live rule set; check what the platform driver actually matches. @@ -32,7 +32,7 @@ Bisect: delete config lines until removing one more makes the symptom disappear. | Symptom | Likely cause | Where to look | |---------|-------------|---------------| | Command emitted as `no X` + `Y` instead of just `Y` | Missing idempotency rule — the command is last-write-wins on the device but the driver doesn't know | `idempotent_commands` in the platform driver; add `IdempotentCommandsRule` | -| Negation has the wrong form (`no shutdown` vs `default shutdown` vs truncated args) | Negation rules | `negate_with` (`NegationDefaultWithRule`), `negation_default_when`, or the driver's `swap_negation` override | +| Negation has the wrong form (`no shutdown` vs `default shutdown` vs truncated args) | Negation rules | `NegationRule` (REPLACE/DEFAULT/REGEX_SUB strategy) in the driver's `negation` list, or the driver's `swap_negation` override | | Lines nested under the wrong parent; everything after line X collapses under it | Irregular indentation in vendor output; an `IndentAdjustRule` matching too broadly or missing | `indent_adjust` rules. Real cases: XR `template` blocks; Huawei `peer-public-key end` (see git log for #205, #268) | | `DuplicateChildError` | Platform legitimately repeats a child text under one parent | Add `ParentAllowsDuplicateChildRule` (see #266 for a real example) | | Section replaced wholesale (or should be, but isn't) | Sectional overwrite | `sectional_overwrite` / `sectional_overwrite_no_negate` (XR `route-policy` is the canonical case) | @@ -40,9 +40,12 @@ Bisect: delete config lines until removing one more makes the symptom disappear. | Commands in an order the device rejects | Ordering weights | `ordering` rules (lower weight applies first) | | Junk lines in the tree (banners, comments, timestamps) | Load-time substitutions | `per_line_sub` / `full_text_sub` | | `future()` or rollback doesn't match real device behavior | Known algorithm limitations | `docs/user/future-config.md#known-limitations` — duplicate children and order-dependent sections (ACLs need sequence numbers) are documented limits | +| Suspected unresolved negations or silent idempotent replacements in `future()` | Negation resolution ambiguity | Use `HConfig.future_with_report()` — the returned `FutureReport.unresolved_negations` / `.idempotency_replacements` name the exact nodes instead of you scanning the render | +| JSON/XML config raises on `from_text()` / parses as gibberish | Structured input fed to the text parser (rejected by design) | Use `HConfig.from_json()` / `HConfig.from_xml()`; text constructors deliberately reject structured formats | +| `InvalidConfigError: Attribute changes cannot be expressed as gNMI delete paths` (or the NETCONF equivalent) | Structured-rendering limitation on attribute-style (`@`-prefixed) changes | `hier_config/formats.py` (`hconfig_to_gnmi_json` / `hconfig_to_netconf_xml`); restructure the change as element updates | | Wrong platform behavior entirely | Wrong driver selected | Confirm the `Platform` enum member; `GENERIC` has almost no rules | -Rule semantics reference: `docs/user/custom-drivers.md#driver-rule-types`. Layer responsibilities: `docs/dev/architecture.md`. +Rule semantics reference: `docs/dev/rule-reference.md`. Layer responsibilities: `docs/dev/architecture.md`. ## Step 4: Confirm Which Rule Fires @@ -62,8 +65,8 @@ If a driver rule should match but doesn't, print the rule set (`config.driver.ru ## Step 5: Fix at the Source -- Driver rule gap (most common): add/adjust the rule in the platform driver's `_instantiate_rules()` — recipe in `docs/dev/extending.md`. -- Core algorithm (`base.py`, `root.py`, `child.py`): rare; read `docs/dev/architecture.md` first and check `git log` for related fixes before changing shared behavior. -- User-side workaround (can't wait for a release): customize the driver at runtime — `docs/user/custom-drivers.md#customizing-existing-drivers`. +- Driver rule gap (most common): add/adjust the rule in the platform driver's `_instantiate_rules()` — recipe in `docs/dev/creating-drivers.md`. +- Core algorithm (`base.py`, `root.py`, `child.py`, `tree_algorithms.py`): rare; read `docs/dev/architecture.md` first and check `git log` for related fixes before changing shared behavior. +- User-side workaround (can't wait for a release): customize the driver at runtime — `docs/admin/customizing-rules.md`. Every fix ships with a regression test that reproduces the original symptom (`docs/dev/testing.md`, round-trip idiom) and a `CHANGELOG.md` entry. Fixes to one platform must not leak: run the full suite (`poetry run ./scripts/build.py lint-and-test`), not just the platform's test file. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c3492498 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.claude +.venv +__pycache__ +*.pyc +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov +site +dist +.dockerignore +Dockerfile +docker-compose.yml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9a6e9d03..5eacdd16 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,22 +4,29 @@ hier_config is a Python library that compares network device configurations (running vs intended) and generates minimal remediation commands by parsing config text into hierarchical trees. Runtime dependencies are deliberately minimal (`pydantic` only). +## Branching + +`master` is the stable v3.x branch; `next` is the v4 development branch. All v4 features and breaking changes must target `next`, not `master`. + ## Build & Test All commands use poetry (not pip): ```bash -poetry run ./scripts/build.py lint-and-test # what CI runs -poetry run ./scripts/build.py pytest --coverage # 95% coverage floor +poetry run ./scripts/build.py lint # CI lint step +poetry run ./scripts/build.py pytest --coverage # CI test step, 95% coverage floor +poetry run ./scripts/build.py lint-and-test # both in one command ``` +CI also runs the test step across Python 3.10–3.14 (code must stay 3.10-compatible) and builds docs with `mkdocs build --strict` on every push/PR. + ## Rules to Enforce in Review - **Pydantic models must subclass the project-local `BaseModel`** from `hier_config/models.py` (it sets `frozen=True, extra="forbid"`). Direct use of `pydantic.BaseModel` is a defect. -- **Model fields use immutable collections only**: `tuple` and `frozenset`, never `list` or `set`. Rule models match config lineage with `match_rules: tuple[MatchRule, ...]`. +- **Model fields use immutable collections only**: `tuple` and `frozenset`, never `list` or `set`. Rule models match config lineage with `match_rules: tuple[MatchRule, ...]`. Deliberate exception: the rule-collection fields on `HConfigDriverRules` are intentionally `list[...]` so built-in rules/callbacks can be removed by identity (#286) — do not flag them. - **Strict typing**: flag `Any`, missing annotations (including in tests), and `# type: ignore` / `# noqa` comments without a justifying reason. mypy and pyright both run in strict mode. -- **Tests must accompany every code change** (the project follows TDD). Tests are flat functions — no test classes (benchmarks excepted). Driver behavior changes belong in `tests/test_driver_.py`; config view changes in `tests/config_view/`. -- **Driver/rule changes need round-trip assertions**: build running + intended configs, assert the exact remediation output (`dump_simple()`), and verify the rollback restores the original (no `unified_diff`). +- **Tests must accompany every code change** (the project follows TDD). Tests are flat functions — no test classes (benchmarks excepted). Driver behavior changes belong in `tests/integration/test_.py`; driver unit tests in `tests/unit/platforms/`; config view changes in `tests/unit/platforms/views/`. +- **Driver/rule changes need round-trip assertions**: build running + intended configs, assert the exact remediation output (`to_lines()`), and verify the rollback restores the original (no `unified_diff`). - **Fields on `HConfigDriverRules`** (`hier_config/platforms/driver_base.py`) use named module-level default factory functions, not lambdas. - **`CHANGELOG.md` must have an entry** under `## [Unreleased]` (Keep a Changelog categories: Added/Changed/Fixed/Removed, with an issue/PR reference like `(#209)`). - **Docs must be updated** when public API or driver behavior changes; new doc pages must be added to `mkdocs.yml` nav; moved pages need a `redirect_maps` entry. @@ -28,4 +35,4 @@ poetry run ./scripts/build.py pytest --coverage # 95% coverage floor ## Full Standards -See `AGENTS.md` at the repo root, `CONTRIBUTING.md`, and the developer docs under `docs/dev/` (architecture, extending, testing, code-style). +See `AGENTS.md` at the repo root, `CONTRIBUTING.md`, and the developer docs under `docs/dev/` (architecture, creating-drivers, rule-reference, testing, code-style). diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 26386392..3254cc43 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -2,9 +2,9 @@ name: hier_config build and test on: push: - branches: [master] + branches: [master, next] pull_request: - branches: [master] + branches: [master, next] jobs: build: diff --git a/.github/workflows/deploy-pypi.yml b/.github/workflows/deploy-pypi.yml index 599ae5e0..6161cb4a 100644 --- a/.github/workflows/deploy-pypi.yml +++ b/.github/workflows/deploy-pypi.yml @@ -1,8 +1,10 @@ name: deploy to pypi +# "published" (not "created") so publishing a draft release made by the +# prepare-release workflow also triggers deployment. on: release: - types: [created] + types: [published] jobs: deploy: diff --git a/.github/workflows/notify-ecosystem.yml b/.github/workflows/notify-ecosystem.yml new file mode 100644 index 00000000..be43d160 --- /dev/null +++ b/.github/workflows/notify-ecosystem.yml @@ -0,0 +1,27 @@ +name: notify ecosystem + +# When a hier_config release is published, notify the hier-config-ci +# orchestrator (repository_dispatch) so the app ecosystem — hier-config-gpt, +# hier-config-api, hier-config-mcp, hier-config-cli — is released against the +# new version. Requires the ECOSYSTEM_DISPATCH_TOKEN secret: a PAT belonging +# to an org admin, able to send repository_dispatch to netdevops/hier-config-ci. +on: + release: + types: [published] + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - name: Send repository_dispatch to hier-config-ci + env: + GH_TOKEN: ${{ secrets.ECOSYSTEM_DISPATCH_TOKEN }} + run: | + if [ -z "${GH_TOKEN}" ]; then + echo "ECOSYSTEM_DISPATCH_TOKEN secret is not set" >&2 + exit 1 + fi + gh api repos/netdevops/hier-config-ci/dispatches \ + -f event_type=hier-config-release \ + -F 'client_payload[version]=${{ github.event.release.tag_name }}' \ + -F 'client_payload[prerelease]=${{ github.event.release.prerelease }}' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 00000000..3e42dd64 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,88 @@ +name: prepare release + +# Admin-only, manually-run release preparation. The branch picked in the +# "Run workflow" dropdown is the branch the release is prepared from: the +# version-bump PR targets it and the draft release tags it once published. +on: + workflow_dispatch: + inputs: + bump: + description: Version bump type + required: true + type: choice + options: + - major + - minor + - patch + - prerelease + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + runs-on: ubuntu-latest + steps: + - name: Require repository admin + uses: actions/github-script@v8 + with: + script: | + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor, + }); + if (data.permission !== 'admin') { + core.setFailed(`${context.actor} is not a repository admin`); + } + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install poetry + uses: snok/install-poetry@v1 + with: + version: 1.5.1 + - name: Bump version + id: bump + run: | + poetry version "${{ inputs.bump }}" + echo "version=$(poetry version -s)" >> "$GITHUB_OUTPUT" + - name: Rotate changelog + if: inputs.bump != 'prerelease' + run: | + python scripts/rotate_changelog.py "${{ steps.bump.outputs.version }}" > release-notes.md + - name: Open release pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + version="${{ steps.bump.outputs.version }}" + branch="release/v${version}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "${branch}" + git add pyproject.toml CHANGELOG.md + git commit -m "chore(release): prepare ${version}" + git push origin "${branch}" + gh pr create \ + --base "${GITHUB_REF_NAME}" \ + --head "${branch}" \ + --title "chore(release): prepare ${version}" \ + --body "Automated ${{ inputs.bump }} release preparation. Merge this PR first, then publish the draft release v${version} to deploy to PyPI. Note: CI does not start automatically on this bot-created PR - close and reopen it (or push to the branch) to trigger checks." + - name: Create draft release + env: + GH_TOKEN: ${{ github.token }} + run: | + version="${{ steps.bump.outputs.version }}" + args=(--draft --target "${GITHUB_REF_NAME}" --title "v${version}") + if [[ "${version}" == *a* || "${version}" == *b* || "${version}" == *rc* || "${version}" == *dev* ]]; then + args+=(--prerelease) + fi + if [[ -s release-notes.md ]]; then + args+=(--notes-file release-notes.md) + else + args+=(--generate-notes) + fi + gh release create "v${version}" "${args[@]}" diff --git a/.standards.yml b/.standards.yml new file mode 100644 index 00000000..4935e0b1 --- /dev/null +++ b/.standards.yml @@ -0,0 +1,20 @@ +# Shared development standards for netdevops hier-config projects. +# +# This repository is the canonical source: the files listed below are owned +# here and synced into the downstream hier-config projects. Running +# `invoke sync-standards` here compares the working tree against what is +# published on the source ref, which previews what downstream repositories +# will receive on their next sync. +source: + repo: netdevops/hier_config + ref: master + +# Whole-word replacements applied to fetched file contents so package +# references match the consuming project. Empty in the canonical repository. +substitutions: {} + +files: + - scripts/build.py + - scripts/sync_standards.py + - .yamllint.yml + - .dockerignore diff --git a/AGENTS.md b/AGENTS.md index 184f8567..41b43113 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,18 @@ This file is the canonical quick reference for AI coding agents (and humans) wor 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. Runtime dependencies are deliberately minimal (`pydantic` only). +## Branching Strategy + +- `master` — stable branch for v3.x releases and maintenance. +- `next` — long-lived development branch for v4 work. All v4 features and breaking changes target this branch; base v4 branches on `next` and open PRs against `next`. +- `2.3-lts` — legacy LTS maintenance branch; only targeted fixes for 2.3.x land there. + ## Build & Test Commands All commands use **poetry** (not pip): ```bash -# Full lint + test suite (what CI runs) +# Full lint + test suite (equivalent to CI's lint + pytest --coverage steps) poetry run ./scripts/build.py lint-and-test # Lint only (ruff, mypy, pyright, pylint, yamllint, flynt — run in parallel) @@ -21,25 +27,37 @@ poetry run ./scripts/build.py lint poetry run ./scripts/build.py pytest --coverage # Run a single test -poetry run pytest tests/test_driver_cisco_xr.py::test_name -v +poetry run pytest tests/integration/test_cisco_xr.py::test_name -v # Run a single test file -poetry run pytest tests/test_driver_cisco_xr.py -v +poetry run pytest tests/integration/test_cisco_xr.py -v + +# Run only unit tests / only integration tests +poetry run pytest tests/unit/ -v +poetry run pytest tests/integration/ -v # Auto-fix formatting poetry run ruff format hier_config tests scripts -# Validate docs (required if docs/ or mkdocs.yml changed) +# Validate docs (CI runs this unconditionally on every push/PR) poetry run mkdocs build --strict + +# Benchmarks (deselected by default via the `benchmark` marker) +poetry run pytest -m benchmark -v -s ``` +CI facts that matter for changes: + +- **Python matrix**: CI tests on Python 3.10–3.14 and ruff targets `py310` — write 3.10-compatible syntax even though your local interpreter may be newer. +- **Docs job**: CI builds docs with `mkdocs build --strict` on every push/PR using `docs/requirements.txt` (pip, not poetry). Adding an mkdocs plugin requires updating **both** `pyproject.toml` and `docs/requirements.txt`. + ## Architecture in Brief Three-layer design — full detail in [docs/dev/architecture.md](docs/dev/architecture.md): -- **Tree** (`base.py`, `root.py`, `child.py`, `children.py`): `HConfig` root and `HConfigChild` nodes; key operations `config_to_get_to()`, `future()`, `unified_diff()`, `dump_simple()`. -- **Driver** (`platforms/`): each platform subclasses `HConfigDriverBase` and overrides `_instantiate_rules()` returning `HConfigDriverRules` — typed, frozen Pydantic rule models matched against config lineage via `MatchRule` tuples. -- **Workflow** (`workflows.py`, `reporting.py`): `WorkflowRemediation` exposes `remediation_config` / `rollback_config`; constructors live in `constructors.py` (`get_hconfig()`, `get_hconfig_fast_load()`, `get_hconfig_driver()`). +- **Tree** (`base.py`, `root.py`, `child.py`, `children.py`, `tree_algorithms.py`, `constructors.py`): `HConfig` root and `HConfigChild` nodes; key operations `remediation()`, `future()`, `future_with_report()`, `unified_diff()`, `to_lines()`. Constructors are classmethods: `HConfig.from_text()`, `HConfig.from_lines()`, `HConfig.from_dump()`, `HConfig.from_json()`, `HConfig.from_xml()`. Supporting modules: `formats.py` (JSON/XML ingestion and rendering, NETCONF `edit-config` XML, gNMI-style JSON via `GnmiRemediation`), `plugins.py` (`RemediationPlugin` extension point), `exceptions.py` (exception hierarchy under `HierConfigError`), `utils.py` (file/YAML rule loaders). +- **Driver** (`platforms/`): each platform subclasses `HConfigDriverBase` and overrides `_instantiate_rules()` returning `HConfigDriverRules` — typed, frozen Pydantic rule models matched against config lineage via `MatchRule` tuples. Drivers register in `registry.py` (`get_hconfig_driver()`, `register_driver()`, `unregister_driver()`, `get_registered_platforms()`) and expose their config view via the `view_class` attribute. Registry keys are canonicalized to uppercase platform names (`Platform.X.name`); string lookups are case-insensitive (#284/#295). +- **Workflow** (`workflows.py`, `reporting.py`): `WorkflowRemediation` exposes `remediation_config` / `rollback_config` plus structured renderings `remediation_netconf_xml()` / `remediation_json()`; `RemediationReporter` aggregates changes across devices. Supported platforms (`Platform` enum in `models.py`): ARISTA_EOS, ARUBA_AOSCX, CISCO_IOS, CISCO_NXOS, CISCO_XR, FORTINET_FORTIOS, GENERIC, HP_COMWARE5, HP_PROCURVE, HUAWEI_VRP, JUNIPER_JUNOS, NOKIA_SRL, VYOS. @@ -47,11 +65,11 @@ Supported platforms (`Platform` enum in `models.py`): ARISTA_EOS, ARUBA_AOSCX, C These are enforced by CI and by reviewers; violations block merges: -1. **Models**: always subclass the project-local `BaseModel` in `hier_config/models.py` (it sets `frozen=True, extra="forbid"`) — never `pydantic.BaseModel` directly. Model fields use immutable collections only (`tuple`, `frozenset`). Rule models match lineage with `match_rules: tuple[MatchRule, ...]`. +1. **Models**: always subclass the project-local `BaseModel` in `hier_config/models.py` (it sets `frozen=True, extra="forbid"`) — never `pydantic.BaseModel` directly. Model fields use immutable collections only (`tuple`, `frozenset`). Rule models match lineage with `match_rules: tuple[MatchRule, ...]`. **Deliberate exception**: the rule-collection fields on `HConfigDriverRules` (`platforms/driver_base.py`) are intentionally `list[...]` so built-in rules and callbacks can be removed by identity (e.g. `rules.post_load_callbacks.remove(...)`, #286) — do not convert them to tuples. 2. **Typing**: mypy strict + pyright strict. Full annotations everywhere, including tests. No `Any`, no unjustified `# type: ignore` or `# noqa`. 3. **Lint**: ruff `select = ["ALL"]` with preview, line length 88. Never loosen lint or coverage configuration to make a change pass. 4. **TDD**: write a failing test first, confirm it fails for the right reason, implement minimally, run the full suite. 95% coverage floor. -5. **Tests**: flat function-based (no classes except benchmarks); driver changes go in `tests/test_driver_.py`; fixtures are module-scoped in `tests/conftest.py` reading `tests/fixtures/`; the dominant idiom is fast_load → `config_to_get_to` → assert `dump_simple()` tuple → `future()` → rollback → assert no `unified_diff`. +5. **Tests**: flat function-based (no classes except benchmarks); unit tests mirror the source in `tests/unit/` (config views in `tests/unit/platforms/views/`), end-to-end driver scenarios go in `tests/integration/test_.py`; fixtures are module-scoped in the relevant `conftest.py` reading the sibling `fixtures/` directory; the dominant idiom is `HConfig.from_lines()` → `remediation()` → assert `to_lines()` tuple → `future()` → rollback → assert no `unified_diff()`. 6. **Rules containers**: fields on `HConfigDriverRules` use named module-level default factory functions, not lambdas. 7. **Changelog**: every PR adds an entry to `CHANGELOG.md` under `## [Unreleased]` (Keep a Changelog categories, `(#NNN)` reference). 8. **Commits**: imperative mood, subject ≤72 characters, body explains *why* (see [CONTRIBUTING.md](CONTRIBUTING.md)). @@ -62,12 +80,15 @@ These are enforced by CI and by reviewers; violations block merges: | Task | Read first | |------|-----------| -| Add a platform driver, rule type, or view property | [docs/dev/extending.md](docs/dev/extending.md) | +| Add a platform driver or rule type | [docs/dev/creating-drivers.md](docs/dev/creating-drivers.md), [docs/dev/rule-reference.md](docs/dev/rule-reference.md) | | Write or fix tests | [docs/dev/testing.md](docs/dev/testing.md) | | Understand lint/typing/model standards | [docs/dev/code-style.md](docs/dev/code-style.md) | | Understand the internals | [docs/dev/architecture.md](docs/dev/architecture.md) | | Dev environment setup, commit style, PR expectations | [CONTRIBUTING.md](CONTRIBUTING.md) | -| Driver behavior reference | [docs/user/drivers.md](docs/user/drivers.md), [docs/user/custom-drivers.md](docs/user/custom-drivers.md) | +| Driver behavior reference | [docs/admin/platforms.md](docs/admin/platforms.md), [docs/admin/custom-drivers.md](docs/admin/custom-drivers.md), [docs/admin/customizing-rules.md](docs/admin/customizing-rules.md) | +| Load rules/tags from YAML or JSON files | [docs/admin/rules-from-files.md](docs/admin/rules-from-files.md) | +| Release process, prerelease versioning | [docs/admin/releases.md](docs/admin/releases.md) | +| CI, Read the Docs, Renovate, redirect policy | [docs/admin/infrastructure.md](docs/admin/infrastructure.md) | ## Before Opening a PR @@ -77,4 +98,4 @@ These are enforced by CI and by reviewers; violations block merges: - [ ] Docs updated if public API or driver behavior changed; `mkdocs build --strict` passes if docs touched. - [ ] Commit messages follow CONTRIBUTING.md style. -Claude Code users: run the `hier-config-review` skill (in `.claude/skills/`) to check all of the above automatically. Two more repo skills cover common workflows: `hier-config-new-driver` (scaffold support for a new platform) and `hier-config-troubleshoot` (diagnose wrong remediation/parsing output). Other agents can follow the same workflows via the docs those skills reference (`docs/dev/extending.md` and the troubleshooting symptom table in the skill files, which are plain markdown). +Claude Code users: run the `hier-config-review` skill (in `.claude/skills/`) to check all of the above automatically. Two more repo skills cover common workflows: `hier-config-new-driver` (scaffold support for a new platform) and `hier-config-troubleshoot` (diagnose wrong remediation/parsing output). Other agents can follow the same workflows via the docs those skills reference ([docs/dev/creating-drivers.md](docs/dev/creating-drivers.md) and the troubleshooting symptom table in the skill files, which are plain markdown). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3974c520..ed257371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,20 +9,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- `future()` negation edge cases (#269): a negation whose positive form exists - in the running config now removes it without surviving as a literal `no ...` - child (evaluated before the idempotency rules, which can match the negation - line itself); shorthand negations (`no description`) remove the valued lines - they match, as devices do. Negations matching nothing are still kept as a - did-not-apply-cleanly signal, and idempotency-tracked negated forms (e.g. - IOS `no logging console`) still replace their counterpart and persist. -- Lint suppressions and property docstrings updated for ruff 0.15 (the - renovate toolchain bump left the tree failing its own lint gate). +v4 design decisions, for the record: + +- `HConfig.remediation()` stays public (#223): it was deliberately renamed + from `config_to_get_to()` in #216 as the tree-level primitive; + `WorkflowRemediation` remains the recommended workflow API and already + validates driver compatibility (`IncompatibleDriverError`). +- Drivers remain declaratively-configured with sanctioned imperative + extension points (#222): #220 removed the negation-related override needs; + `idempotent_for()`, `negate_with()`, and `config_preprocessor()` stay + overridable for logic that rules cannot express. +- Config trees stay mutable (#224): full immutability would break the + callback/plugin mutation model for marginal benefit. The remediation + algorithms are guaranteed (and now tested) not to mutate their input + configs. ### Added +- Shared development standards for the hier-config ecosystem: this repository + is now the canonical source for `scripts/build.py`, `scripts/sync_standards.py`, + `.yamllint.yml`, and `.dockerignore`. Downstream projects declare where their + standards come from in `.standards.yml` and pull changes in with + `invoke sync-standards`, which rewrites package names for the consuming + project. Documented in `docs/dev/shared-standards.md`. +- Docker development environment (`Dockerfile`, `docker-compose.yml`) driven by + invoke tasks (`tasks.py`), giving every hier-config project the same + `invoke build/docs/pytest/lint/lint-and-test/cli/sync-standards/destroy` + commands. +- `notify ecosystem` workflow (`.github/workflows/notify-ecosystem.yml`): on + release publish, sends a `repository_dispatch` to netdevops/hier-config-ci + so the downstream app ecosystem (hier-config-gpt, -api, -mcp, -cli) is + released against the new hier_config version automatically. +- Admin-only `prepare release` workflow (`.github/workflows/prepare-release.yml`): + run from any branch with a major/minor/patch/prerelease bump choice, it bumps + the version, rotates `CHANGELOG.md` (`scripts/rotate_changelog.py`, skipped + for prereleases), opens a `chore(release): prepare X.Y.Z` PR, and creates a + draft GitHub release. The PyPI deploy workflow now triggers on release + `published` (not `created`) so publishing a draft deploys it. +- `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 + changes to review. +- `HConfig.future(..., prune_empty_branches=True)` removes sections that a + change emptied out — matching devices that prune empty stanzas on commit — + while keeping sections that were already empty (#269). - Aruba AOS-CX platform support (`Platform.ARUBA_AOSCX`): a new driver and config view covering AOS-CX's Cisco/EOS-like hierarchical CLI. Because `vlan trunk allowed` is additive on AOS-CX rather than declarative, collapsed @@ -31,9 +67,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 VLAN with `vlan trunk allowed ` and removes an extra one with `no vlan trunk allowed `. All other sections, including `evpn` and `interface vxlan`, are remediated with the standard rule framework. (#289) -- `HConfig.future(..., prune_empty_branches=True)` removes sections that a - change emptied out — matching devices that prune empty stanzas on commit — - while keeping sections that were already empty (#269). - Guidance for AI-assisted contributions: `AGENTS.md` as the canonical statement of repo standards, a `hier-config-review` Claude Code skill (`.claude/skills/`) that self-reviews a change set against those standards, @@ -46,21 +79,197 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 symptom-to-rule table covering negation, idempotency, indentation, `DuplicateChildError`, sectional rules, ordering, and `future()` limits (#290). -- New developer and maintainer documentation: extending hier_config (in-tree - drivers, rule types, view properties), testing conventions, code style and - standards, the release process, and CI/infrastructure notes (#290). +- New developer and maintainer documentation: testing conventions, code style + and standards, the release process, and CI/infrastructure notes (#290). +- NETCONF `edit-config` remediation rendering (#232): + `WorkflowRemediation.remediation_netconf_xml()` (and + `hier_config.formats.hconfig_to_netconf_xml()`) render a remediation + between `HConfig.from_xml()` trees as a NETCONF payload — deletions become + `nc:operation="delete"` elements (keyed list entries delete by their key + leaf, resolved against the running config), additions use the default merge + operation, and attribute-level changes raise `InvalidConfigError`. +- gNMI-style JSON remediation rendering (#287): + `WorkflowRemediation.remediation_json()` (and + `hier_config.formats.hconfig_to_gnmi_json()`) render a remediation between + `HConfig.from_json()` trees as a gNMI-SetRequest-style structure — added + and changed values render into an `update` object (modified keyed list + entries keep their identity leaf), negations become xpath-ish `delete` + paths with `[key=value]` selectors resolved against the running config, + and attribute-level changes raise `InvalidConfigError`. + +- Structured config ingestion and rendering (#232): `HConfig.from_json()` / + `HConfig.from_xml()` build config trees from JSON (e.g. OpenConfig) and XML + (e.g. NETCONF payloads), with OpenConfig-style keyed lists identified via + `list_keys` (default `("name", "id")`). `HConfig.to_json()` / `to_xml()` + invert the mapping, so structured configs can be diffed, predicted with + `future()`, and rendered back in their source format. The format-detection + error now points at the new constructors. NETCONF `edit-config` operation + attributes are not yet given remediation semantics. + +- Interface view capability mixins (#227): `ConfigViewInterfaceBase` now + carries only the core interface properties (`name`, `description`, + `enabled`, `ipv4_interfaces`, `is_loopback`, `is_svi`, `number`, + `port_number`, `vrf`, plus concrete helpers `ipv4_interface`, + `is_subinterface`, `parent_name`, `subinterface_number`). Optional + capabilities moved to new ABC mixins in + `hier_config.platforms.view_base` — `InterfaceBundleViewMixin`, + `InterfaceVlanViewMixin` (owns the concrete `dot1q_mode`), + `InterfaceNACViewMixin`, and `InterfacePhysicalViewMixin` (owns a concrete + `module_number`) — all exported from the package root. Platform views + inherit only the mixins they support, and users check capability with + `isinstance(view, InterfaceVlanViewMixin)` instead of catching + `NotImplementedError`. `HConfigViewBase.bundle_interface_views` and + `module_numbers` are now capability-aware. +- Completed the Arista EOS, Cisco NX-OS, and Cisco IOS XR config views (#230): + all three now implement the full core interface property set plus the VLAN + and bundle mixins (EOS/NX-OS switchport, trunk, and channel-group parsing; + XR `encapsulation dot1q`, `ipv4 address x.x.x.x/nn | x.x.x.x y.y.y.y`, and + `Bundle-Ether`/`bundle id ` parsing), along with the root view + properties `interface_names_mentioned`, `ipv4_default_gw`, `location`, + `stack_members`, and `vlans`. +- Cisco IOS `bundle_member_interfaces` and HP ProCurve `bundle_id` are now + implemented (previously `NotImplementedError` stubs) (#230). + +- Driver registration system (#226): `register_driver()`, `unregister_driver()`, + and `get_registered_platforms()`. Custom platforms are registered by string + name (case-insensitive) and work anywhere a `Platform` is accepted; built-in + drivers can be overridden and later restored. `HConfigDriverBase` and + `HConfigDriverRules` are now exported as public API. +- View registration follows driver registration (#187, #229): drivers declare + their view via the `view_class` attribute, `get_hconfig_view()` resolves it + from the driver, and registered custom drivers get views without extra + wiring. +- `HConfig.from_text()`, `HConfig.from_lines()`, and `HConfig.from_dump()` + classmethod constructors (#218). +- `remediation_transform_callbacks` on `HConfigDriverRules` (#180): drivers can + transform the remediation config after diff computation, before it is + returned by `WorkflowRemediation.remediation_config`. +- `RemediationPlugin` ABC (`hier_config.plugins`) and a `plugins` parameter on + `WorkflowRemediation` (#181): users can package custom remediation + transforms outside hier_config and apply them per workflow. +- Root-level duplicate children (#215): a `ParentAllowsDuplicateChildRule` + with empty `match_rules` now applies to the root `HConfig`. +- `NegationRule` validates its per-strategy fields at construction time: + `REPLACE` requires `use` and `REGEX_SUB` requires `search` (#220). +- Structured config format detection (#232): `HConfig.from_text()` rejects XML + and JSON input with a clear `InvalidConfigError`; set-style configs remain + natively supported via the JunOS, VyOS, and Nokia SRL driver preprocessors. +- Custom exception hierarchy: `HierConfigError` base, `DriverNotFoundError`, + `InvalidConfigError`, `IncompatibleDriverError` (#219). `DuplicateChildError` + reparented under `HierConfigError`. ### Changed -- Documentation reorganized into User Guide, Developer Guide, and Maintainer - Guide sections (`docs/user/`, `docs/dev/`, `docs/admin/`); old - readthedocs.io URLs keep working via the mkdocs-redirects plugin, and - CONTRIBUTING.md now renders on the docs site. Duplicated content was - consolidated: the MatchRule reference (previously in three places), the - unified diff walkthrough (previously duplicated in Future Config and - orphaned from the nav), and the platform support table (previously in three - places, two of which were missing Huawei VRP). CLAUDE.md was slimmed to an - overlay that imports AGENTS.md, retiring its stale platform list (#290). +- Restructured the documentation into User, Administrator, and Developer + guides (`docs/user/`, `docs/admin/`, `docs/dev/`) with a rewritten landing + page, new pages for loading configurations and remediation workflows, and + content refreshed for the v4 API. +- Old readthedocs.io URLs (both the original flat layout and the 3.7 `user/` + layout) keep working via the mkdocs-redirects plugin; CLAUDE.md was slimmed + to an overlay that imports `AGENTS.md` (#290). +- Built-in driver post-load callbacks are now public functions exported from + their driver modules (e.g. `remove_ipv4_acl_remarks` in + `hier_config.platforms.cisco_ios.driver`), so a built-in callback can be + removed by identity with `rules.post_load_callbacks.remove(...)` (#286). +- The driver registry is keyed internally on canonical uppercase platform + names; `Platform` members are converted via their names at the boundary, so + a member and its name are fully interchangeable in `register_driver`, + `unregister_driver`, and `get_hconfig_driver`. `get_registered_platforms()` + returns `Platform` members for enum-known names and uppercase strings for + custom names (#284). +- Shared interface-view logic hoisted out of the five platform view files into + concrete defaults on `ConfigViewInterfaceBase`, the capability mixins + (parameterized by `_bundle_membership_prefix` / `_encapsulation_prefix` + hooks), `HConfigViewBase`, and a new `parse_ipv4_interface()` helper in + `hier_config.platforms.functions` — removing ~390 duplicated lines (#227). +- `WorkflowRemediation(plugins=...)` accepts any `Callable[[HConfig], None]`; + `RemediationPlugin` instances are now callable (#181). +- The structured-format guard (#232) also covers the raw-`str` form of + `HConfig.from_lines()`, inspects only a bounded prefix of the input, and + `from_lines()`/`from_dump()` no longer route empty-tree construction through + the full text-parsing pipeline. +- `HConfig` calls the `tree_algorithms` functions directly; the pass-through + delegation shims on `HConfigBase` were removed (#217). +- Negation rules are unified into a single `NegationRule` model with a + `NegationStrategy` enum — `REPLACE` (was `negate_with`), `DEFAULT` (was + `negation_default_when`), and `REGEX_SUB` (was `negation_sub`) — in one + ordered `negation` list on `HConfigDriverRules`; first matching rule wins + (#220). `load_driver_rules()` still accepts the v2 dict keys. +- Tree algorithms (difference, remediation, future, with_tags) extracted from + `HConfigBase` into `hier_config.tree_algorithms` as standalone functions; + `HConfigBase` retains thin delegating methods (#217). +- `_load_from_string_lines()` refactored into a stateful `_ConfigTextLoader` + parser class with focused banner/normalize/hierarchy methods (#186). +- Remediation right pass no longer allocates a probe `HConfigChild` for matched + leaf lines, where the delta subtree is provably empty. Speeds up remediation + of mostly-identical configs by ~30% and resolves the long-standing TODO in + `_remediation_right()` (#191). +- `HConfigBase.__len__()` now counts descendants with a generator instead of + materializing a tuple of every node, avoiding a large temporary allocation on + big configuration trees (#188). +- `dot1q_mode_from_vlans()` is now a concrete static method on `HConfigViewBase` + implementing the same semantics as `ConfigViewInterfaceBase.dot1q_mode` + (`tagged_all` → `TAGGED_ALL`, tagged VLANs → `TAGGED`, untagged only → + `ACCESS`); the per-platform `NotImplementedError` stubs were removed (#228). +- Changed `style` parameter on `indented_text()` and `RemediationReporter.to_text()` from `str` to `Literal["without_comments", "merged", "with_comments"]` via new `TextStyle` type alias (#189). +- Renamed `load_hconfig_v2_options` to `load_driver_rules` (#221). +- Renamed `load_hconfig_v2_tags` to `load_tag_rules` (#221). +- Renamed `tags_add()`/`tags_remove()` to `add_tags()`/`remove_tags()` (#216). +- Renamed `cisco_style_text()` to `indented_text()` (#216). +- Renamed `dump_simple()` to `to_lines()` (#216). +- Renamed `config_to_get_to()` to `remediation()` (#216). +- Converted `depth()` method to `depth` property (#216). + +### Removed + +- `get_hconfig()`, `get_hconfig_fast_load()`, `get_hconfig_from_dump()`, and + `get_hconfig_fast_generic_load()` — replaced by the `HConfig.from_*` + classmethods (#218). `get_hconfig_driver()` and `get_hconfig_view()` remain. +- `NegationDefaultWithRule`, `NegationDefaultWhenRule`, and `NegationSubRule` + models and their `HConfigDriverRules` fields (#220). +- `HConfigChild.use_default_for_negation()` — subsumed by the unified + negation rule evaluation (#220). +- Removed `HCONFIG_PLATFORM_V2_TO_V3_MAPPING` constant (#221). +- Removed `hconfig_v2_os_v3_platform_mapper()` function (#221). +- Removed `hconfig_v3_platform_v2_os_mapper()` function (#221). +- Removed `load_hconfig_v2_options_from_file()` function (#221). + +### Fixed + +- Documentation gap sweep: repaired doc examples that no longer ran or showed + wrong output (getting-started fixture path, tags filtering, the custom ACL + remediation `delete()` idiom, config-view and hierarchical-JunOS outputs); + removed the stale prerelease pin from the install page and added `--pre` to + the README install; propagated Aruba AOS-CX into the architecture and + config-view docs; documented the built-in post-load callbacks, the formats + module, `future_with_report()`, and the view data models in the API + reference and glossary; corrected agent instruction files (branching + strategy in `AGENTS.md`, the `HConfigDriverRules` mutable-list carve-out, + review-skill diff base, registry key format, CI Python matrix) and the + benchmarks per-file lint-ignore path (#297). +- Registering a driver under a `Platform` member's *value* string (e.g. `"3"`, + the value of `Platform.CISCO_IOS`) no longer silently overwrites that + platform's built-in registry entry, and value strings no longer resolve in + platform lookups — platforms are identified by name (#284). +- `future()` negation edge cases (#269): a negation whose positive form exists + in the running config now removes it without surviving as a literal `no ...` + child (evaluated before the idempotency rules, which can match the negation + line itself); shorthand negations (`no description`) remove the valued lines + they match, as devices do. Negations matching nothing are still kept as a + did-not-apply-cleanly signal, and idempotency-tracked negated forms (e.g. + IOS `no logging console`) still replace their counterpart and persist. +- XML ingestion keys an element whenever an identifying `list_keys` child + exists, not only when the tag repeats among siblings, so configs with + different list-entry counts diff surgically instead of deleting and + re-adding surviving entries (#232). +- `port_number` no longer raises `ValueError` on slash-less interface names + such as `Port-channel10`, `port-channel10`, `Bundle-Ether10`, and `Trk1`; + it now derives from the letter-stripped `number` property on all platform + views (IOS, EOS, NX-OS, XR, ProCurve). +- Fortinet FortiOS: hardened `swap_negation()` and `idempotent_for()` against + `IndexError` on degenerate single-word commands, and documented that dropping + parameters when negating (`set description "Port 1"` → `unset description`) is + intentional FortiOS semantics (#225). --- diff --git a/CLAUDE.md b/CLAUDE.md index 95f37192..0610c8fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Before opening or finalizing a PR, run the `hier-config-review` skill (`/hier-co ## Benchmarks -Performance benchmarks are in `tests/test_benchmarks.py` and are **skipped by default** via the `benchmark` pytest marker. They generate ~10,000-line configs and measure parsing, remediation, and iteration performance. +Performance benchmarks are in `tests/benchmarks/test_benchmarks.py` and are **deselected by default** (`addopts = "-m 'not benchmark'"` in `pyproject.toml`). They generate ~10,000-line configs and measure parsing, remediation, and iteration performance. ```bash # Run all benchmarks with timing output @@ -20,6 +20,6 @@ poetry run pytest -m benchmark -v -s poetry run pytest -m benchmark -k test_parse_large_ios_config -v -s ``` -Use `-s` to see printed timing results. Each benchmark reports the best time over 3 iterations and asserts an upper bound (e.g., `< 5s` for parsing, `< 10s` for remediation). If a benchmark fails its time threshold, investigate the relevant code path for performance regressions. +Use `-s` to see printed timing results. Each benchmark reports the best time over 3 iterations and asserts an upper bound (parsing `< 5s`; remediation `< 5s` small diff / `< 10s` large; iteration `< 2s`–`< 5s`). If a benchmark fails its time threshold, investigate the relevant code path for performance regressions. After running benchmarks, always display the results to the user in a table format summarizing each benchmark's config size and elapsed time. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5769460d..3e39bf13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,15 +18,18 @@ Set up your environment: ``` cd hier_config poetry install -poetry shell +poetry shell # Poetry 2.x: requires the shell plugin, or use `poetry run ` / `poetry env activate` ``` -Create a branch +Create a branch from the right base: v4 features and breaking changes branch from **`next`**; v3.x maintenance fixes branch from **`master`**. ``` +git checkout next git checkout -b YOUR-BRANCH ``` +Open your pull request against the same branch you based on (`next` for v4 work). + Make sure linters, type-checkers, and tests pass: ``` @@ -63,7 +66,7 @@ pytest Run a single test file: ```bash -pytest tests/test_driver_cisco_ios.py +pytest tests/integration/test_cisco_ios.py ``` Stop on the first failure: @@ -88,12 +91,16 @@ pytest --cov=hier_config ## Running Linters Individually +The build script runs all of these over `hier_config`, `tests`, and `scripts`: + ```bash ruff check . # style + lint ruff format --check . # formatting (no changes) -mypy hier_config/ # type checking -pyright hier_config/ # additional type checking -pylint hier_config/ # extended lint rules +mypy hier_config/ tests/ scripts/ # type checking +pyright hier_config/ tests/ scripts/ # additional type checking +pylint hier_config/ tests/ scripts/ # extended lint rules +yamllint . # YAML files +flynt -d -tc -f hier_config tests scripts # f-string conversion check ``` To auto-fix ruff issues: @@ -115,20 +122,22 @@ ruff format . Example: ``` -Add negation_negate_with support to load_hconfig_v2_options +Add negation_negate_with support to load_driver_rules -When migrating from v2 to v3, users may need to express custom negation -strings via the v2 option dict format. This change forwards that value -into the NegationDefaultWithRule model so that the behaviour is preserved -during migration. +When loading driver rules from a dict, users may need to express custom +negation strings. This change forwards that value into the +NegationDefaultWithRule model so that the behaviour is preserved. ``` --- ## PR Expectations -- **Tests required** — all new behaviour must be covered by unit tests. +- **Tests required** — all new behaviour must be covered by tests: unit tests in + `tests/unit/`, end-to-end driver scenarios in `tests/integration/test_.py`. - **Linting must pass** — `python scripts/build.py lint-and-test` must exit 0. +- **Changelog entry required** — every PR adds an entry to `CHANGELOG.md` under + `## [Unreleased]` (Keep a Changelog categories, with a `(#NNN)` reference). - **Docstrings for new public API** — any new public class, method, or function must have a docstring. - **No breaking changes without discussion** — open an issue first if you plan to @@ -145,10 +154,10 @@ Where do changes belong? | New platform support | `hier_config/platforms//driver.py` (subclass `HConfigDriverBase`) | | New rule type | `hier_config/models.py` (new `BaseModel` subclass) + `hier_config/platforms/driver_base.py` (`HConfigDriverRules` field) | | New utility function | `hier_config/utils.py` | -| New view property | `hier_config/platforms/view_base.py` (abstract) + each platform's `view.py` | +| New view property | `hier_config/platforms/view_base.py` (abstract) + the `view.py` of each platform that ships a view (currently 6 of 13 platforms) | | Core tree algorithm | `hier_config/base.py` (shared) or `hier_config/root.py` (`HConfig`-only) | -Read the [Architecture Overview](https://hier-config.readthedocs.io/en/latest/dev/architecture/) before making structural changes. +Read the [Architecture Overview](docs/dev/architecture.md) before making structural changes. --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8027ebd0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim AS development + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_CREATE=false + +RUN pip install --no-cache-dir "poetry>=2.0,<3.0" + +WORKDIR /app + +# Dependency metadata only, so the dependency layer caches across source edits +COPY pyproject.toml poetry.lock README.md ./ + +RUN poetry install --no-root --with dev + +COPY . . + +RUN poetry install --with dev + +CMD ["python", "scripts/build.py", "lint-and-test"] diff --git a/README.md b/README.md index 83f7df25..b3f2c9f4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ Hierarchical Configuration has been used extensively on: - [x] Cisco IOSXR - [x] Cisco NXOS - [x] Arista EOS -- [x] Aruba AOS-CX - [x] Fortinet FortiOS - [x] HP Procurve (Aruba AOSS) - [x] HP Comware5 / H3C @@ -20,7 +19,9 @@ In addition to the Cisco-style syntax, hier_config offers experimental support f - [x] Nokia SRL (Service Router Linux) - [x] VyOS -Hier Config is compatible with any NOS that utilizes a structured CLI syntax similar to Cisco IOS or Junos OS. The full platform support matrix is maintained in the [driver documentation](https://hier-config.readthedocs.io/en/latest/user/drivers/). +Newer drivers start life as **experimental** until they have seen wider production use — currently Aruba AOS-CX joins the list above in that status. See [Supported Platforms](https://hier-config.readthedocs.io/en/latest/admin/platforms/) for the authoritative per-platform status. + +Hier Config is compatible with any NOS that utilizes a structured CLI syntax similar to Cisco IOS or Junos OS. The code documentation can be found at: [Hier Config documentation](https://hier-config.readthedocs.io/en/latest/). @@ -30,11 +31,13 @@ Network devices continuously drift from their intended state — VLANs appear, A ## Highlights -- Predict the device state before deploying with [`future()`](https://hier-config.readthedocs.io/en/latest/user/future-config/) and generate accurate rollbacks that preserve distinct structural commands — BGP neighbor descriptions, for example, no longer collapse when multiple peers share a common prefix. -- Build remediation workflows with deterministic diffs across [Cisco-style and Junos-style](https://hier-config.readthedocs.io/en/latest/user/drivers/) configuration syntaxes. +- Predict the device state before deploying with [`future()`](https://hier-config.readthedocs.io/en/latest/user/future-config/) — and audit ambiguous negation resolution explicitly with `future_with_report()`. +- Build remediation workflows with deterministic diffs across [Cisco-style](https://hier-config.readthedocs.io/en/latest/admin/platforms/) and [Junos-style](https://hier-config.readthedocs.io/en/latest/user/set-style-platforms/) configuration syntaxes. +- Ingest and render structured configs: [JSON and XML loading](https://hier-config.readthedocs.io/en/latest/user/loading-configs/), NETCONF `edit-config` payloads, and gNMI-style JSON remediation. - Tag remediation lines and filter output with [tag-based rules](https://hier-config.readthedocs.io/en/latest/user/tags/) for phased or conditional deployment. +- Extend the pipeline with [`RemediationPlugin` transforms](https://hier-config.readthedocs.io/en/latest/user/remediation-workflows/) and register [custom platform drivers](https://hier-config.readthedocs.io/en/latest/admin/custom-drivers/) at runtime. - Aggregate and analyse changes across a fleet with [RemediationReporter](https://hier-config.readthedocs.io/en/latest/user/remediation-reporting/). -- Render structured, typed interface data with the [Config View](https://hier-config.readthedocs.io/en/latest/user/config-view/) abstraction. +- Render structured, typed interface data with the [Config View](https://hier-config.readthedocs.io/en/latest/user/config-views/) abstraction. See the [Architecture Overview](https://hier-config.readthedocs.io/en/latest/dev/architecture/) for how the tree, driver, and workflow layers fit together. @@ -42,18 +45,20 @@ See the [Architecture Overview](https://hier-config.readthedocs.io/en/latest/dev ### PIP -Install from PyPi: +Version 4 is currently published as a prerelease; pip skips prereleases by default, so pass `--pre`: ```shell -pip install hier-config +pip install --pre hier-config ``` +(The Quick Start below uses the v4 API. `pip install hier-config` without `--pre` installs the latest stable v3 release — see the [v3 documentation](https://hier-config.readthedocs.io/) for that API.) + ## Quick Start ### Step 1: Import Required Classes ```python -from hier_config import WorkflowRemediation, get_hconfig, Platform +from hier_config import WorkflowRemediation, HConfig, Platform from hier_config.utils import read_text_from_file ``` @@ -71,8 +76,8 @@ generated_config_text = read_text_from_file("./tests/fixtures/generated_config.c Specify the device platform (e.g., `Platform.CISCO_IOS`): ```python -running_config = get_hconfig(Platform.CISCO_IOS, running_config_text) -generated_config = get_hconfig(Platform.CISCO_IOS, generated_config_text) +running_config = HConfig.from_text(Platform.CISCO_IOS, running_config_text) +generated_config = HConfig.from_text(Platform.CISCO_IOS, generated_config_text) ``` ### Step 4: Initialize WorkflowRemediation @@ -87,7 +92,3 @@ print(workflow.remediation_config) ``` This guide gets you started with Hier Config in minutes! For more details, visit [Hier Config Documentation Site](https://hier-config.readthedocs.io/en/latest/). - -## Contributing - -Contributions are welcome — see the [contributing guide](https://github.com/netdevops/hier_config/blob/master/CONTRIBUTING.md) and the [developer documentation](https://hier-config.readthedocs.io/en/latest/dev/contributing/) for setup, standards, and testing conventions. Release history lives in the [changelog](https://github.com/netdevops/hier_config/blob/master/CHANGELOG.md). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..f99da4c8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + # Toolchain container for lint, type checking, and tests + dev: + build: + context: . + target: development + volumes: + - .:/app + + # Live-reloading documentation server; opt in with: docker compose --profile docs up + docs: + build: + context: . + target: development + profiles: + - docs + ports: + - "8001:8001" + volumes: + - .:/app + command: mkdocs serve --dev-addr 0.0.0.0:8001 diff --git a/docs/admin/custom-drivers.md b/docs/admin/custom-drivers.md new file mode 100644 index 00000000..3c1032be --- /dev/null +++ b/docs/admin/custom-drivers.md @@ -0,0 +1,174 @@ +# Custom Drivers and Registration + +This page covers the driver registry: creating a simple custom driver, registering it under a new platform name, overriding built-in drivers, and restoring defaults. Read it when hier_config does not ship a driver for your platform, or when you want your customized driver picked up everywhere a `Platform` is accepted. + +For a deep dive into everything a driver can do (rules, prefixes, preprocessors, views), see [Creating a Platform Driver](../dev/creating-drivers.md). + +## Creating a minimal custom driver + +Subclass `HConfigDriverBase` and implement `_instantiate_rules()`, which returns the driver's `HConfigDriverRules`: + +```python +from hier_config import HConfigDriverBase, HConfigDriverRules +from hier_config.models import ( + IdempotentCommandsRule, + MatchRule, + NegationRule, + NegationStrategy, + OrderingRule, + PerLineSubRule, + SectionalExitingRule, +) + + +class MyNOSDriver(HConfigDriverBase): + """Driver for a custom network operating system.""" + + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules( + negation=[ + NegationRule( + strategy=NegationStrategy.REPLACE, + match_rules=(MatchRule(startswith="ip route "),), + use="no ip route", + ) + ], + sectional_exiting=[ + SectionalExitingRule( + match_rules=( + MatchRule(startswith="policy-map"), + MatchRule(startswith="class"), + ), + exit_text="exit", + ) + ], + ordering=[ + OrderingRule( + match_rules=(MatchRule(startswith="interface"),), + weight=10, + ) + ], + per_line_sub=[ + PerLineSubRule(search="^!.*Generated by system.*$", replace="") + ], + idempotent_commands=[ + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="interface"),) + ) + ], + ) +``` + +An empty `HConfigDriverRules()` is also valid — that gives you the same behavior as the `GENERIC` platform (Cisco-style syntax, no special rules), which you can then extend. + +Optionally override the negation or declaration prefixes: + +```python + @property + def negation_prefix(self) -> str: + return "delete " + + @property + def declaration_prefix(self) -> str: + return "set " +``` + +## Registering the driver + +Custom drivers (and overrides of built-in drivers) plug into the standard API via the driver registry: + +```python +from hier_config import ( + HConfig, + Platform, + register_driver, + unregister_driver, +) + +# Register a new platform by name (case-insensitive) +register_driver("MY_NOS", MyNOSDriver) + +# The string platform now works anywhere a Platform is accepted: +config = HConfig.from_text("MY_NOS", config_text) +config = HConfig.from_text("my_nos", config_text) # same driver +``` + +Names are canonicalized to uppercase, and a `Platform` member is interchangeable with its name — `register_driver("cisco_ios", ...)` and `register_driver(Platform.CISCO_IOS, ...)` address the same entry. + +The registry is not synchronized — register drivers at application startup, before configs are parsed concurrently. + +### Overriding a built-in driver + +Passing an existing `Platform` member replaces the built-in driver for that platform process-wide: + +```python +register_driver(Platform.CISCO_IOS, MyCustomIOSDriver) + +# Every from_text(Platform.CISCO_IOS, ...) now uses MyCustomIOSDriver +``` + +This is the recommended way to deploy a [rules customization](customizing-rules.md) across an application without touching each call site. + +### Unregistering + +`unregister_driver()` removes a custom platform, or restores an overridden built-in to its default: + +```python +# Restore the built-in Cisco IOS driver +unregister_driver(Platform.CISCO_IOS) + +# Remove a custom platform entirely +unregister_driver("MY_NOS") +``` + +Unregistering a platform that is not registered (or a built-in that is not overridden) raises `DriverNotFoundError`. + +### Listing platforms + +```python +from hier_config import get_registered_platforms + +print(get_registered_platforms()) +# (, , ..., 'MY_NOS') +``` + +Names known to the `Platform` enum are returned as members; custom names are returned as canonical uppercase strings. + +## Using an unregistered driver instance + +Registration is optional. Every constructor also accepts a driver *instance* directly, which is convenient for one-off customizations: + +```python +driver = MyNOSDriver() +running = HConfig.from_text(driver, running_text) +generated = HConfig.from_text(driver, generated_text) + +from hier_config import WorkflowRemediation + +workflow = WorkflowRemediation(running, generated) +``` + +## Config views for custom drivers + +If the driver class sets a `view_class`, `get_hconfig_view()` resolves the view automatically for the registered platform: + +```python +from hier_config import get_hconfig_view + + +class MyNOSDriver(HConfigDriverBase): + view_class = MyNOSConfigView # a HConfigViewBase subclass + ... + + +view = get_hconfig_view(HConfig.from_text("MY_NOS", config_text)) +``` + +See [Creating a Platform Driver](../dev/creating-drivers.md#adding-a-config-view) for how to implement the view class itself. + +## Next steps + +- [Creating a Platform Driver](../dev/creating-drivers.md) — the full driver anatomy: preprocessors, imperative overrides, views. +- [Customizing Driver Rules](customizing-rules.md) — extend a built-in driver instead of writing one from scratch. +- [Loading Rules from Files](rules-from-files.md) — keep rule definitions in YAML. diff --git a/docs/admin/customizing-rules.md b/docs/admin/customizing-rules.md new file mode 100644 index 00000000..4329e251 --- /dev/null +++ b/docs/admin/customizing-rules.md @@ -0,0 +1,290 @@ +# Customizing Driver Rules + +This page shows how to extend or adjust the rules of an existing driver — the most common administrative task when a platform's default remediation is not quite right for your environment. It assumes familiarity with the driver concepts in [Supported Platforms](platforms.md). + +There are two approaches: + +1. **Subclassing** — recommended for reusable, modular extensions; combine with [`register_driver`](custom-drivers.md) so `HConfig.from_text(Platform.X, ...)` picks up your version everywhere. +2. **Dynamic modification** — append to `driver.rules.*` at runtime; useful when the driver instance is created by external code and subclassing is not feasible. + +For the full catalog of rule types and their fields, see the [Driver Rule Reference](../dev/rule-reference.md). + +## Example 1: Subclassing the driver to extend rules + +Create a new class that subclasses the base Cisco IOS driver and overrides its `_instantiate_rules()` method: + +```python +from hier_config.models import ( + MatchRule, + NegationRule, + NegationStrategy, + SectionalExitingRule, + OrderingRule, + PerLineSubRule, + IdempotentCommandsRule, +) +from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + +class ExtendedHConfigDriverCiscoIOS(HConfigDriverCiscoIOS): + @staticmethod + def _instantiate_rules(): + # Start with the base rules + base_rules = HConfigDriverCiscoIOS._instantiate_rules() + + # Extend negation rules + base_rules.negation.append( + NegationRule( + strategy=NegationStrategy.REPLACE, + match_rules=(MatchRule(startswith="ip route "),), + use="no ip route", + ) + ) + + # Extend sectional exiting rules + base_rules.sectional_exiting.append( + SectionalExitingRule( + match_rules=( + MatchRule(startswith="policy-map"), + MatchRule(startswith="class"), + ), + exit_text="exit", + ) + ) + + # Add additional ordering rules + base_rules.ordering.append( + OrderingRule( + match_rules=( + MatchRule(startswith="access-list"), + MatchRule(startswith="permit "), + ), + weight=50, + ) + ) + + # Add new per-line substitutions + base_rules.per_line_sub.append( + PerLineSubRule(search="^!.*Generated by system.*$", replace="") + ) + + # Add new idempotent commands + base_rules.idempotent_commands.append( + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="speed "), + ) + ) + ) + + return base_rules +``` + +Register the subclass so the whole application uses it transparently: + +```python +from hier_config import HConfig, Platform, register_driver + +register_driver(Platform.CISCO_IOS, ExtendedHConfigDriverCiscoIOS) + +# From here on, Platform.CISCO_IOS resolves to the extended driver: +config = HConfig.from_text(Platform.CISCO_IOS, config_text) +``` + +(You can also instantiate `ExtendedHConfigDriverCiscoIOS()` and pass the instance directly to `HConfig.from_text()` without registering it — see [Custom Drivers and Registration](custom-drivers.md).) + +## Example 2: Dynamically extending rules on an instantiated driver + +If you already have the driver instantiated, modify its rules by appending to the appropriate lists: + +```python +from hier_config import get_hconfig_driver, Platform +from hier_config.models import ( + MatchRule, + NegationRule, + NegationStrategy, + SectionalExitingRule, + OrderingRule, + PerLineSubRule, + IdempotentCommandsRule, +) + +# Instantiate the driver +driver = get_hconfig_driver(Platform.CISCO_IOS) + +# Dynamically extend negation rules +driver.rules.negation.append( + NegationRule( + strategy=NegationStrategy.REPLACE, + match_rules=(MatchRule(startswith="ip route "),), + use="no ip route", + ) +) + +# Dynamically extend sectional exiting rules +driver.rules.sectional_exiting.append( + SectionalExitingRule( + match_rules=( + MatchRule(startswith="policy-map"), + MatchRule(startswith="class"), + ), + exit_text="exit", + ) +) + +# Add additional ordering rules dynamically +driver.rules.ordering.append( + OrderingRule( + match_rules=( + MatchRule(startswith="access-list"), + MatchRule(startswith="permit "), + ), + weight=50, + ) +) + +# Add new per-line substitutions dynamically +driver.rules.per_line_sub.append( + PerLineSubRule(search="^!.*Generated by system.*$", replace="") +) + +# Add new idempotent commands dynamically +driver.rules.idempotent_commands.append( + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="speed "), + ) + ) +) + +# Use the customized driver instance directly: +# config = HConfig.from_text(driver, config_text) +``` + +> **Note:** `HConfigDriverRules` is a frozen Pydantic model — you cannot *reassign* its attributes (`driver.rules.negation = [...]` fails). The rule collections are mutable lists, however, so `append()`, `remove()`, and slice assignment (`rules.negation[:] = [...]`) all work. + +## Example 3: Adding unused object detection + +Unused object detection is not enabled in any driver by default — it must be explicitly configured. This ensures no unintended side-effects for users who are not expecting it. + +You can add unused object rules dynamically or via [`load_driver_rules`](rules-from-files.md): + +### Dynamic extension + +```python +from hier_config import HConfig, get_hconfig_driver, Platform +from hier_config.models import MatchRule, ReferenceLocation, UnusedObjectRule + +driver = get_hconfig_driver(Platform.CISCO_XR) + +# Detect unused IPv4 ACLs +driver.rules.unused_objects.append( + UnusedObjectRule( + match_rules=(MatchRule(startswith="ipv4 access-list "),), + name_re=r"^ipv4 access-list (?P\S+)", + reference_locations=( + ReferenceLocation( + match_rules=(MatchRule(startswith="interface "),), + reference_re=r"\bipv4 access-group {name}\b", + ), + ), + ) +) + +config = HConfig.from_text(driver, running_config_text) +for unused in config.unused_objects(): + print(f"Unused: {unused.text}") +``` + +### Via `load_driver_rules` + +```python +from hier_config import HConfig, Platform +from hier_config.utils import load_driver_rules + +options = { + "unused_objects": [ + { + "lineage": [{"startswith": "ipv4 access-list "}], + "name_re": r"^ipv4 access-list (?P\S+)", + "reference_locations": [ + { + "lineage": [{"startswith": "interface "}], + "reference_re": r"\bipv4 access-group {name}\b", + }, + ], + }, + ], +} +driver = load_driver_rules(options, Platform.CISCO_XR) +config = HConfig.from_text(driver, running_config_text) + +for unused in config.unused_objects(): + print(f"Unused: {unused.text}") +``` + +Each `UnusedObjectRule` requires: + +- `match_rules` — locates the object definition (e.g., `startswith="ipv4 access-list "`) +- `name_re` — regex with a `(?P...)` capture group to extract the object name +- `reference_locations` — a tuple of `ReferenceLocation` entries, each specifying where to search and what regex pattern (with `{name}` placeholder) to match + +## Example 4: Adding negation substitution + +Some platforms require negation commands to be truncated or transformed. Use a REGEX_SUB-strategy `NegationRule` for regex-based negation transformations — the regex is applied to the already-negated text: + +```python +from hier_config import get_hconfig_driver, Platform +from hier_config.models import MatchRule, NegationRule, NegationStrategy + +driver = get_hconfig_driver(Platform.CISCO_NXOS) + +# Truncate SNMP user negation after the username +driver.rules.negation.append( + NegationRule( + strategy=NegationStrategy.REGEX_SUB, + match_rules=(MatchRule(startswith="snmp-server user "),), + search=r"(no snmp-server user \S+).*", + replace=r"\1", + ) +) +``` + +`NegationRule` supports three strategies — `REPLACE` (substitute a fixed command via `use`), `DEFAULT` (rewrite to the `default ` form), and `REGEX_SUB` (regex-transform the negated text) — evaluated in list order with the first matching rule winning. See the [Driver Rule Reference](../dev/rule-reference.md#negation-rules) for details. + +## Customizing post-load callbacks + +Post-load callbacks are Python functions that a driver runs against the tree after parsing (`driver.rules.post_load_callbacks`). Sometimes you want to *remove* one of a built-in driver's callbacks — for example, Cisco IOS strips IPv4 ACL `remark` lines by default, and you may want to keep them so remarks participate in remediation. + +Built-in callbacks are public functions exported from their driver modules, so a callback can be removed by identity. Because `HConfigDriverRules` is frozen, mutate the callback list *in place* with `list.remove()` — which raises `ValueError` if the callback was already removed — rather than reassigning the attribute: + +```python +from hier_config import Platform, register_driver +from hier_config.platforms.cisco_ios.driver import ( + HConfigDriverCiscoIOS, + remove_ipv4_acl_remarks, +) + + +class HConfigDriverCiscoIOSKeepRemarks(HConfigDriverCiscoIOS): + """Cisco IOS driver that keeps ACL remarks in remediation.""" + + @staticmethod + def _instantiate_rules(): + rules = HConfigDriverCiscoIOS._instantiate_rules() + rules.post_load_callbacks.remove(remove_ipv4_acl_remarks) + return rules + + +register_driver(Platform.CISCO_IOS, HConfigDriverCiscoIOSKeepRemarks) +``` + +The same pattern works for adding callbacks (`rules.post_load_callbacks.append(my_callback)`) and for the remediation-stage equivalents in `rules.remediation_transform_callbacks` (see [Remediation Workflows](../user/remediation-workflows.md#the-remediation-transform-pipeline)). The full table of built-in post-load callbacks — which drivers ship them and what each one does — is in the [Driver Rule Reference](../dev/rule-reference.md#callbacks). + +## Next steps + +- [Custom Drivers and Registration](custom-drivers.md) — register your subclass, add new platforms, restore built-ins. +- [Loading Rules from Files](rules-from-files.md) — express rules as YAML instead of code. +- [Driver Rule Reference](../dev/rule-reference.md) — every rule model and its fields. diff --git a/docs/admin/infrastructure.md b/docs/admin/infrastructure.md index beed9fdd..64106985 100644 --- a/docs/admin/infrastructure.md +++ b/docs/admin/infrastructure.md @@ -9,7 +9,9 @@ This page is for project maintainers and describes the repository's automation. - **build** job: a Python 3.10–3.14 matrix that installs dependencies with poetry, then runs `scripts/build.py lint` (ruff, mypy, pyright, pylint, yamllint, flynt in parallel) and `scripts/build.py pytest --coverage` (95% coverage floor). - **docs** job: installs `docs/requirements.txt` with pip (mirroring what Read the Docs installs) and runs `mkdocs build --strict`, so broken links or nav entries fail the PR instead of shipping silently. -`.github/workflows/deploy-pypi.yml` publishes to PyPI when a GitHub release is created — see [Releases](releases.md). +`.github/workflows/prepare-release.yml` is an admin-only, manually-run workflow that bumps the version, rotates the changelog, opens the release PR, and creates a draft GitHub release; `.github/workflows/deploy-pypi.yml` publishes to PyPI when that release is published — see [Releases](releases.md). + +`.github/workflows/notify-ecosystem.yml` also runs when a release is published: it sends a `repository_dispatch` (event `hier-config-release`, payload `version` + `prerelease`) to [netdevops/hier-config-ci](https://github.com/netdevops/hier-config-ci), whose orchestrator releases the downstream apps (hier-config-gpt, -api, -mcp, -cli) against the new version. It requires the `ECOSYSTEM_DISPATCH_TOKEN` secret — a PAT from an org admin that can dispatch to hier-config-ci. ## Dependency Automation diff --git a/docs/admin/platforms.md b/docs/admin/platforms.md new file mode 100644 index 00000000..df39fada --- /dev/null +++ b/docs/admin/platforms.md @@ -0,0 +1,192 @@ +# Supported Platforms + +This page describes each built-in platform driver: its behaviors, quirks, and any platform-specific handling that affects remediation. Read it to understand what hier_config does for your platform out of the box — and what you may want to [customize](customizing-rules.md). + +## What is a driver? + +A driver encodes all operating-system-specific behavior for one network platform. It acts as a framework that encapsulates the rules, transformations, and behaviors required to process and normalize device configurations: + +1. **[Negation handling](../glossary.md#negation-rule)**: ensures commands are properly negated or reset according to the operating system's syntax and behavior. +2. **[Sectional exiting rules](../glossary.md#sectional-exiting)**: defines how to navigate in and out of hierarchical configuration sections so remediation output keeps its structural integrity. +3. **Command ordering**: establishes the sequence in which commands should be applied based on dependencies, preventing conflicts during deployment. +4. **Line substitutions**: cleans up unnecessary or temporary data in configurations, such as metadata, system-generated comments, or timestamp banners. +5. **[Idempotency management](../glossary.md#idempotent-command)**: identifies last-value-wins commands so remediation overwrites rather than negate-and-re-add. +6. **Post-processing callbacks**: performs additional adjustments after parsing, such as refining access control lists or splitting collapsed VLAN lists. + +By defining these rules in a reusable way, a driver lets hier_config adapt to different operating systems while keeping a consistent interface. Drivers are selected implicitly when you pass a `Platform` to `HConfig.from_text()`, or explicitly: + +```python +from hier_config import get_hconfig_driver, Platform + +driver = get_hconfig_driver(Platform.CISCO_IOS) +``` + +## Built-in platforms + +| Platform | `Platform` enum | Status | +|----------|-----------------|--------| +| Cisco IOS | `Platform.CISCO_IOS` | Fully supported | +| Arista EOS | `Platform.ARISTA_EOS` | Fully supported | +| Cisco IOS XR | `Platform.CISCO_XR` | Fully supported | +| Cisco NX-OS | `Platform.CISCO_NXOS` | Fully supported | +| Fortinet FortiOS | `Platform.FORTINET_FORTIOS` | Fully supported | +| HP ProCurve (Aruba AOSS) | `Platform.HP_PROCURVE` | Fully supported | +| HP Comware5 / H3C | `Platform.HP_COMWARE5` | Fully supported | +| Huawei VRP | `Platform.HUAWEI_VRP` | Fully supported | +| Aruba AOS-CX | `Platform.ARUBA_AOSCX` | Experimental | +| Juniper JunOS | `Platform.JUNIPER_JUNOS` | Experimental | +| Nokia SRL | `Platform.NOKIA_SRL` | Experimental | +| VyOS | `Platform.VYOS` | Experimental | +| Generic | `Platform.GENERIC` | Base for custom drivers | + +Every platform is used the same way — parse both configs with `HConfig.from_text(Platform.X, text)` and feed them to `WorkflowRemediation` (see [Getting Started](../user/getting-started.md)). The sections below describe what each driver does differently. + +--- + +### Cisco IOS + +Cisco IOS is hier_config's primary reference platform and the most thoroughly tested driver. The `CISCO_IOS` driver ships with a comprehensive set of rules covering common IOS configuration patterns: + +- **[Idempotent commands](../glossary.md#idempotent-command)**: `hostname`, `ip address`, `ip access-group`, `description`, `banner`, and many others are treated as last-write-wins — applying the same command twice leaves only the final value in place. +- **Negation**: standard `no ` [negation prefix](../glossary.md#negation-prefix). Several commands (such as `logging console`) use REPLACE-strategy [`NegationRule`](../glossary.md#negation-rule) overrides to emit a specific reset form. +- **[Sectional exiting](../glossary.md#sectional-exiting)**: BGP `peer-policy` and `peer-session` blocks require `exit-peer-policy` and `exit-peer-session` closure tokens. +- **Per-line substitutions**: strips `Building configuration…` banners and timestamp headers. +- **ACL normalization callbacks**: post-load callbacks remove IPv6 ACL sequence numbers, strip IPv4 ACL remarks, and add IPv4 ACL sequence numbers so entries diff cleanly. (See [Customizing Driver Rules](customizing-rules.md#customizing-post-load-callbacks) if you need to keep ACL remarks.) +- **VLAN id list splitting**: IOS can render unnamed VLANs collapsed onto a single comma/range line (e.g. `vlan 69,381`, `vlan 10-12`), depending on how the VLANs were created — named VLANs always get their own block, and the grouping shifts as VLANs are named or unnamed. When such a collapsed line is present, a post-load callback splits it into one `vlan ` block each so the VLANs diff block-to-block against an intended config that lists them separately — avoiding a destructive `no vlan 69,381`. + +--- + +### Arista EOS + +Arista EOS uses a Cisco IOS-like hierarchical CLI, so the `ARISTA_EOS` driver closely mirrors `CISCO_IOS`: + +- BGP peer-policy and peer-session blocks require `exit-peer-policy` and `exit-peer-session` closure tokens (same as IOS). +- Broad idempotency rules cover the most common EOS configuration patterns. +- [Negation prefix](../glossary.md#negation-prefix): `no ` (default). + +--- + +### Cisco IOS XR + +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)**: `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. + +--- + +### Cisco NX-OS + +Cisco NX-OS is similar to IOS in CLI structure but has NX-OS-specific idempotency requirements: + +- **TCAM region idempotency**: `hardware access-list tcam region` commands are treated as last-write-wins. +- Some BGP commands use different negation forms; the driver includes REPLACE-strategy `NegationRule` entries for affected commands. +- [Negation prefix](../glossary.md#negation-prefix): `no ` (default). + +--- + +### Fortinet FortiOS + +Fortinet firewalls model their CLI around `config` and `edit` blocks that are terminated with `next` and `end`. The `FORTINET_FORTIOS` driver captures those patterns and makes sure remediation output keeps the indentation and closure FortiOS expects. Highlights include: + +- Preserves the `set`/`unset` pairing by swapping declarations and negations automatically when hier_config determines a change is required. +- Treats sibling `config` blocks as duplicates when appropriate so that multiple objects such as policies or firewall addresses can be compared in a stable order. +- Normalizes bare `next` and `end` tokens into indented versions to match the format FortiOS emits on the device. +- Overrides idempotency matching to require that the same object name exists on both sides before a command is considered already present. + +--- + +### HP ProCurve (Aruba AOSS) + +HP ProCurve switches (sold as Aruba switches after the HP/Aruba merger) use a Cisco-style hierarchical CLI with `no` as the negation prefix. The `HP_PROCURVE` driver adds several post-load normalization callbacks that simplify diffing: + +- **VLAN membership** — moves `untagged`/`tagged` directives out of `vlan ` blocks and into per-interface blocks, matching the mental model that operators typically use when writing intended configs. +- **Port-access range expansion** — expands compact port ranges like `aaa port-access authenticator 1/15-1/20,1/26-1/40` into individual interface lines so that hier_config can apply idempotency rules per port. +- **Device-profile tagged-VLAN splitting** — splits comma-separated VLAN lists in `device-profile` blocks into one command per VLAN. + +The driver also extends idempotency and negation-replacement logic to handle ProCurve-specific command patterns such as `aaa port-access`, `radius-server`, and `tacacs-server` with variable-length key fields. + +--- + +### HP Comware5 / H3C + +HP Comware5 (and the compatible H3C platform) uses `undo` as the negation prefix rather than `no`. The `HP_COMWARE5` driver overrides `negation_prefix` accordingly. No additional platform-specific rules are configured by default; extend the driver if your environment requires them (see [Customizing Driver Rules](customizing-rules.md)). + +--- + +### Huawei VRP + +Huawei VRP (Versatile Routing Platform) uses `undo` as the negation prefix rather than `no`. The `HUAWEI_VRP` driver customizes negation handling for several command families: + +- **[Negation prefix](../glossary.md#negation-prefix)**: `undo ` (replaces `no `). +- **Smart negation**: `description` and `alias` commands are negated without their argument; `remark` commands strip the remark text; `snmp-agent community` commands truncate to the community name. +- **Sectional exiting**: section exit text `exit` is translated to `quit` as VRP requires. +- **Per-line substitutions**: strips `#` and `!` comment lines during parsing. + +--- + +### Aruba AOS-CX + +Aruba AOS-CX uses a Cisco IOS/EOS-like hierarchical CLI with `no ` as the [negation prefix](../glossary.md#negation-prefix), so the `ARUBA_AOSCX` driver reuses the standard IOS/EOS tree model and remediation. The one platform-specific behavior is how trunk VLAN membership is modeled: + +- `vlan trunk allowed` is *additive* on AOS-CX rather than declarative. The driver splits comma/range VLAN lists into one command per VLAN (on load and in the intended config), so remediation adds a missing VLAN with `vlan trunk allowed ` and removes an extra one with `no vlan trunk allowed `, rather than rewriting the whole list. +- Unnamed collapsed VLAN headers such as `vlan 1,10` or `vlan 100-102` are likewise split into individual `vlan ` sections. +- Structured sections such as `evpn` and `interface vxlan` are remediated like any other section: individual members (for example an EVPN `vlan`) are added or negated, while unchanged siblings such as `arp-suppression` are left untouched. As with Arista/Cisco, the intended config should list the members that must remain. +- Common one-value commands such as interface `description`, `ip address`, `vlan access`, `vlan trunk native`, and `vrf attach` are treated as idempotent replacements. +- BGP address-family blocks close with `exit-address-family`. +- Per-line substitutions strip comment lines and rendered `exit`/`end` markers during parsing. + +**Known limitation**: because trunk VLAN lists are modeled one VLAN per line, a very wide range (for example `vlan trunk allowed 1-4094`) expands to one command per VLAN internally, so remediation that creates such a trunk from scratch renders many lines instead of the single range the operator wrote. Only the *delta* is emitted for an existing trunk, so day-to-day changes stay minimal; the expansion only shows up when adding a wide range wholesale. + +--- + +### Juniper JunOS + +Juniper JunOS uses `set` and `delete` command syntax for its hierarchical configuration. + +> **Experimental:** JunOS support has not been tested extensively in production environments. Use with caution. + +- **[Declaration prefix](../glossary.md#declaration-prefix)**: `set ` (prepended to each positive command). +- **[Negation prefix](../glossary.md#negation-prefix)**: `delete ` (replaces `no `). +- **Config preprocessor**: native curly-brace configuration is flattened to `set` commands before parsing. + +For a worked example see [Set-Style Platforms](../user/set-style-platforms.md). + +--- + +### Nokia SRL (Service Router Linux) + +Nokia SR Linux uses `set` and `delete` command syntax, similar to VyOS and JunOS. The driver converts hierarchical SRL configuration (from `info` output) into flat `set`/`delete` commands via a preprocessor. + +> **Experimental:** Nokia SRL support has not been tested extensively in production environments. Use with caution. + +- **[Declaration prefix](../glossary.md#declaration-prefix)**: `set ` (prepended to each positive command). +- **[Negation prefix](../glossary.md#negation-prefix)**: `delete ` (replaces `no `). + +--- + +### VyOS + +VyOS uses `set` and `delete` command syntax rather than the `no`-prefix convention. + +> **Experimental:** VyOS support has not been tested extensively in production environments. Use with caution. + +- **[Declaration prefix](../glossary.md#declaration-prefix)**: `set ` (prepended to each positive command). +- **[Negation prefix](../glossary.md#negation-prefix)**: `delete ` (replaces `no `). +- **Config preprocessor**: native curly-brace configuration is flattened to `set` commands before parsing. + +--- + +### Generic + +The `GENERIC` driver contains no platform-specific rules. It is useful as a starting point for custom drivers or for platforms that follow standard Cisco-style syntax with few special cases. + +See [Custom Drivers and Registration](custom-drivers.md) for how to build on top of the generic driver. + +## Next steps + +- [Customizing Driver Rules](customizing-rules.md) — extend or adjust the rules of any built-in driver. +- [Custom Drivers and Registration](custom-drivers.md) — add a platform hier_config does not ship with. +- [Driver Rule Reference](../dev/rule-reference.md) — the full catalog of rule types and their fields. diff --git a/docs/admin/releases.md b/docs/admin/releases.md index 2e0d72f2..83917cdc 100644 --- a/docs/admin/releases.md +++ b/docs/admin/releases.md @@ -1,15 +1,45 @@ # Release Process -This page is for project maintainers. Releases are published to PyPI automatically when a GitHub release is created. +This page is for project maintainers. Releases are prepared by an admin-run +workflow and published to PyPI automatically when the GitHub release is +published. ## Steps -1. **Prepare the release PR** (conventionally titled `chore(release): prepare X.Y.Z`): - - Bump `version` in `pyproject.toml` following [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — major for breaking changes, minor for features, patch for fixes. - - In `CHANGELOG.md`, move the `## [Unreleased]` entries under a new `## [X.Y.Z] - YYYY-MM-DD` heading and start a fresh empty `## [Unreleased]` section. -2. **Merge to `master`** and confirm the [build-and-test workflow](infrastructure.md) passes. -3. **Create a GitHub release** targeting `master` with tag `vX.Y.Z`, pasting the changelog section as the release notes. -4. **Publishing happens automatically**: the `deploy to pypi` workflow (`.github/workflows/deploy-pypi.yml`) triggers on release creation and runs `poetry publish --build` using the `TWINE_API_KEY` repository secret. +1. **Run the prepare-release workflow**: Actions → *prepare release* → *Run + workflow*. Pick the branch to release from (`master` for stable releases, + `next` for v4 prereleases) and the bump type — `major`, `minor`, `patch`, + or `prerelease`. The workflow is restricted to repository admins. It: + - Bumps `version` in `pyproject.toml` with `poetry version `. + - For non-prerelease bumps, moves the `## [Unreleased]` entries in + `CHANGELOG.md` under a new `## [X.Y.Z] - YYYY-MM-DD` heading + (`scripts/rotate_changelog.py`) and starts a fresh empty + `## [Unreleased]` section. + - Opens a PR (`chore(release): prepare X.Y.Z`) against the chosen branch. + - Creates a **draft** GitHub release `vX.Y.Z` targeting the chosen + branch, with the rotated changelog section as the notes (generated + notes for prereleases), marked as a prerelease when the version is one. +2. **Merge the release PR** and confirm the + [build-and-test workflow](infrastructure.md) passes. Note: CI does not + start automatically on the bot-created PR — close and reopen it (or push + to the branch) to trigger checks. +3. **Publish the draft release**. The tag is created at the tip of the target + branch when the draft is published, so always merge the PR first. +4. **Publishing happens automatically**: the `deploy to pypi` workflow + (`.github/workflows/deploy-pypi.yml`) triggers when the release is + published and runs `poetry publish --build` using the `TWINE_API_KEY` + repository secret. + +## Ecosystem Fan-Out + +Publishing a hier_config release also triggers +`.github/workflows/notify-ecosystem.yml`, which dispatches to +[netdevops/hier-config-ci](https://github.com/netdevops/hier-config-ci). Its +orchestrator then releases the downstream apps (hier-config-gpt, -api, -mcp, +-cli): prerelease hier_config versions produce app prereleases from each +app's `next` branch; stable versions produce patch releases from each app's +default branch. See the hier-config-ci README for the required secrets and +manual-run instructions. ## Post-Release Checks diff --git a/docs/admin/rules-from-files.md b/docs/admin/rules-from-files.md new file mode 100644 index 00000000..7cb6b22b --- /dev/null +++ b/docs/admin/rules-from-files.md @@ -0,0 +1,137 @@ +# Loading Rules from Files + +This page covers the helpers in `hier_config.utils` that load driver rules and tag rules from YAML files (or plain dictionaries). Use them when you want rule definitions to live in configuration files — versioned and edited without touching Python code. + +> **Note:** post-load callbacks and remediation transform callbacks are Python code and are deliberately *not* loadable from YAML. Anything imperative belongs in a [driver subclass](customizing-rules.md) or a [plugin](../user/remediation-workflows.md#the-remediation-transform-pipeline). + +## `read_text_from_file` + +Reads the contents of a file into memory — a convenience for loading device configurations: + +```python +from hier_config.utils import read_text_from_file + +device_config = read_text_from_file("path/to/device_config.txt") +print(device_config) +``` + +## `load_driver_rules` + +Loads driver rules from a dictionary or a YAML file and returns a driver instance for the given platform with those rules appended to the platform defaults. + +**From a dictionary:** + +```python +from hier_config import Platform +from hier_config.utils import load_driver_rules + +options = { + "ordering": [{"lineage": [{"startswith": "ntp"}], "order": 700}], + "per_line_sub": [{"search": "^!.*Generated.*$", "replace": ""}], + "sectional_exiting": [ + {"lineage": [{"startswith": "router bgp"}], "exit_text": "exit"} + ], + "idempotent_commands": [{"lineage": [{"startswith": "interface"}]}], + "negation_negate_with": [ + { + "lineage": [ + {"startswith": "interface Ethernet"}, + {"startswith": "spanning-tree port type"}, + ], + "use": "no spanning-tree port type", + } + ], +} +driver = load_driver_rules(options, Platform.CISCO_IOS) +``` + +**From a YAML file:** + +```python +from hier_config import Platform +from hier_config.utils import load_driver_rules + +driver = load_driver_rules("/path/to/options.yml", Platform.CISCO_IOS) +``` + +Use the returned driver instance directly: `HConfig.from_text(driver, config_text)`. + +### Supported option keys + +Each entry uses a `lineage` list of match criteria (`startswith`, `endswith`, `contains`, `equals`, `re_search`) that maps onto [`MatchRule`](../glossary.md#match-rule) tuples. + +Two constraints to be aware of: + +- **One criterion per `lineage` entry.** The loader picks the first criterion it finds (checked in the order `startswith`, `endswith`, `contains`, `equals`, `re_search`) and silently ignores the rest — unlike a `MatchRule` built in Python, where multiple set fields AND together. Use `re_search` if a single entry needs compound matching. +- **Built-in platforms only.** `load_driver_rules()` takes a `Platform` enum member; it does not accept the name string of a custom driver registered via `register_driver()`. Extend a custom driver in Python instead ([Custom Drivers](custom-drivers.md)). + +| YAML key | Resulting rule | +|----------|----------------| +| `ordering` | `OrderingRule` (`order` value is offset by −500 to a weight) | +| `per_line_sub` | `PerLineSubRule` (`search` / `replace`) | +| `full_text_sub` | `FullTextSubRule` (`search` / `replace`) | +| `sectional_exiting` | `SectionalExitingRule` (`exit_text`) | +| `sectional_overwrite` | `SectionalOverwriteRule` | +| `sectional_overwrite_no_negate` | `SectionalOverwriteNoNegateRule` | +| `idempotent_commands` | `IdempotentCommandsRule` | +| `idempotent_commands_blacklist` | `IdempotentCommandsAvoidRule` | +| `parent_allows_duplicate_child` | `ParentAllowsDuplicateChildRule` | +| `indent_adjust` | `IndentAdjustRule` (`start_expression` / `end_expression`) | +| `negation_negate_with` | `NegationRule` with `strategy=REPLACE` (`use`) | +| `negation_default_when` | `NegationRule` with `strategy=DEFAULT` | +| `negation_sub` | `NegationRule` with `strategy=REGEX_SUB` (`search` / `replace`) | +| `unused_objects` | `UnusedObjectRule` (`name_re`, `reference_locations`) | + +The three `negation_*` keys all produce the unified [`NegationRule`](../glossary.md#negation-rule) model with the corresponding `NegationStrategy`. + +## `load_tag_rules` + +Loads [tag rules](../user/tags.md) from a list of dictionaries or a YAML file into `TagRule` objects. Each entry needs a `lineage` list and an `add_tags` string: + +```python +from hier_config.utils import load_tag_rules + +tags = load_tag_rules([ + { + "lineage": [{"startswith": ["ip name-server", "ntp"]}], + "add_tags": "ntp" + } +]) + +print(tags) +``` + +**From a YAML file:** + +```python +from hier_config.utils import load_tag_rules + +tags = load_tag_rules("path/to/tags.yml") +``` + +## `load_hier_config_tags` + +Parses a YAML file whose entries are already in the native `TagRule` shape (`match_rules` + `apply_tags`) and validates them into `TagRule` objects: + +```python +from hier_config.utils import load_hier_config_tags + +tag_rules = load_hier_config_tags("path/to/tag_rules.yml") +``` + +Example YAML for this format: + +```yaml +- match_rules: + - startswith: + - ip name-server + - ntp + apply_tags: [ntp] +``` + +Use `load_hier_config_tags` for the native `match_rules`/`apply_tags` format and `load_tag_rules` for the legacy `lineage`/`add_tags` format. + +## Next steps + +- [Working with Tags](../user/tags.md) — applying the loaded tag rules to a remediation. +- [Customizing Driver Rules](customizing-rules.md) — the same rules, expressed in Python. diff --git a/docs/dev/api-reference.md b/docs/dev/api-reference.md new file mode 100644 index 00000000..fd2b765f --- /dev/null +++ b/docs/dev/api-reference.md @@ -0,0 +1,205 @@ +# API Reference + +Auto-generated reference documentation for the `hier_config` public API. Signatures and docstrings are pulled directly from the source, so this page always reflects the installed version. + +--- + +## Constructors + +::: hier_config.HConfig.from_text + +::: hier_config.HConfig.from_lines + +::: hier_config.HConfig.from_dump + +::: hier_config.HConfig.from_json + +::: hier_config.HConfig.from_xml + +::: hier_config.get_hconfig_driver + +::: hier_config.get_hconfig_view + +--- + +## Driver Registry + +::: hier_config.register_driver + +::: hier_config.unregister_driver + +::: hier_config.get_registered_platforms + +::: hier_config.registry.resolve_driver + +--- + +## Core Classes + +::: hier_config.HConfig + +::: hier_config.HConfigChild + +::: hier_config.children.HConfigChildren + +--- + +## Future Config + +::: hier_config.HConfig.future + +::: hier_config.HConfig.future_with_report + +::: hier_config.FutureReport + +--- + +## Structured Formats + +JSON/XML ingestion and rendering, NETCONF `edit-config` payloads, and gNMI-style JSON remediation. The module docstring below documents the tree↔structure mapping and its caveats. + +::: hier_config.formats + +--- + +## Workflow + +::: hier_config.WorkflowRemediation + +::: hier_config.RemediationPlugin + +--- + +## Reporting + +::: hier_config.RemediationReporter + +::: hier_config.ReportSummary + +::: hier_config.ChangeDetail + +--- + +## Driver System + +::: hier_config.platforms.driver_base.HConfigDriverBase + +::: hier_config.platforms.driver_base.HConfigDriverRules + +### Built-in post-load callbacks + +Public functions shipped by the built-in drivers (removable by identity — see [Customizing Driver Rules](../admin/customizing-rules.md#customizing-post-load-callbacks)): + +::: hier_config.platforms.cisco_ios.driver.remove_ipv6_acl_sequence_numbers + +::: hier_config.platforms.cisco_ios.driver.remove_ipv4_acl_remarks + +::: hier_config.platforms.cisco_ios.driver.add_acl_sequence_numbers + +::: hier_config.platforms.utils.split_vlan_id_lists + +::: hier_config.platforms.cisco_xr.driver.fixup_xr_comments + +::: hier_config.platforms.hp_procurve.driver.fixup_hp_procurve_aaa_port_access + +::: hier_config.platforms.hp_procurve.driver.fixup_hp_procurve_device_profile + +::: hier_config.platforms.hp_procurve.driver.fixup_hp_procurve_vlan + +::: hier_config.platforms.aruba_aoscx.driver.split_interface_vlan_trunk_allowed + +--- + +## Config Views + +::: hier_config.HConfigViewBase + +::: hier_config.ConfigViewInterfaceBase + +::: hier_config.InterfaceBundleViewMixin + +::: hier_config.InterfaceVlanViewMixin + +::: hier_config.InterfaceNACViewMixin + +::: hier_config.InterfacePhysicalViewMixin + +### Typed view data models + +The value types returned by view properties: + +::: hier_config.platforms.models.Vlan + +::: hier_config.platforms.models.StackMember + +::: hier_config.platforms.models.InterfaceDot1qMode + +::: hier_config.platforms.models.InterfaceDuplex + +::: hier_config.platforms.models.NACHostMode + +--- + +## Models + +::: hier_config.models.Platform + +::: hier_config.models.TextStyle + +::: hier_config.models.MatchRule + +::: hier_config.models.TagRule + +::: hier_config.models.IdempotentCommandsRule + +::: hier_config.models.IdempotentCommandsAvoidRule + +::: hier_config.models.NegationRule + +::: hier_config.models.NegationStrategy + +::: hier_config.models.SectionalExitingRule + +::: hier_config.models.SectionalOverwriteRule + +::: hier_config.models.SectionalOverwriteNoNegateRule + +::: hier_config.models.OrderingRule + +::: hier_config.models.PerLineSubRule + +::: hier_config.models.FullTextSubRule + +::: hier_config.models.IndentAdjustRule + +::: hier_config.models.ParentAllowsDuplicateChildRule + +::: hier_config.models.UnusedObjectRule + +::: hier_config.models.ReferenceLocation + +::: hier_config.models.Instance + +::: hier_config.models.Dump + +::: hier_config.models.DumpLine + +--- + +## Exceptions + +::: hier_config.HierConfigError + +::: hier_config.DriverNotFoundError + +::: hier_config.DuplicateChildError + +::: hier_config.IncompatibleDriverError + +::: hier_config.InvalidConfigError + +--- + +## Utilities + +::: hier_config.utils diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 640b2f39..ea2db087 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -1,8 +1,6 @@ -# Architecture Overview +# Architecture -This document describes the internal design of hier_config v3, covering the three main layers: the hierarchical tree model, the driver system, and the workflow / reporting layer. - ---- +This page describes the internal design of hier_config for contributors and integrators: the hierarchical tree model, the tree algorithms, the driver system and registry, the structured-format layer, and the workflow / view / reporting layers. Users who only *consume* the library can usually stay in the [User Guide](../user/getting-started.md). ## Overview @@ -11,29 +9,28 @@ hier_config is built around a three-layer model: | Layer | Purpose | |-------|---------| | **Tree** | Parse and represent configuration text as a rooted tree of nodes | -| **Driver** | Encode all platform-specific behaviour (negation, ordering, idempotency, …) | +| **Driver** | Encode all platform-specific behavior (negation, ordering, idempotency, …) | | **Workflow** | Compute diffs, remediations, rollbacks, and reports against the tree | --- -## Core Tree Model +## Core tree model The tree layer lives in `hier_config/base.py`, `hier_config/root.py`, `hier_config/child.py`, and `hier_config/children.py`. ### `HConfig` (root node) -`HConfig` is the entry point of every configuration tree. It owns: +`HConfig` is the entry point of every configuration tree. It owns: - A reference to the **driver** for the platform. - An `HConfigChildren` collection of top-level `HConfigChild` nodes. -- High-level operations: `future()`, `config_to_get_to()`, `merge()`, `difference()`, `dump()`. - -Create an `HConfig` object via the constructor function: +- Constructors: `from_text()`, `from_lines()`, `from_dump()`, `from_json()`, `from_xml()`. +- High-level operations: `future()`, `remediation()`, `merge()`, `difference()`, `dump()`, `to_lines()`, `to_json()`, `to_xml()`, `unused_objects()`. ```python -from hier_config import get_hconfig, Platform +from hier_config import HConfig, Platform -hconfig = get_hconfig(Platform.CISCO_IOS, config_text) +hconfig = HConfig.from_text(Platform.CISCO_IOS, config_text) ``` ### `HConfigChild` (tree node) @@ -45,7 +42,7 @@ Each non-root node holds: - `children` — an `HConfigChildren` collection of its own children. - Metadata: `tags`, `comments`, `order_weight`, `new_in_config`, `instances`, `facts`. -`HConfigChild` inherits all tree-manipulation methods from `HConfigBase`. +Notable methods: `is_lineage_match()` (evaluate a tuple of `MatchRule`s against the node's ancestry, used by all rule evaluation), `negate()` (apply driver negation logic), `add_tags()` / `remove_tags()`, and `indented_text()`. `HConfigChild` inherits all tree-manipulation methods from `HConfigBase`. ### `HConfigChildren` (ordered collection) @@ -62,12 +59,22 @@ Both `HConfig` and `HConfigChild` inherit from `HConfigBase`, which provides: - Child manipulation: `add_child`, `add_children`, `add_deep_copy_of`, `add_shallow_copy_of`. - Searching: `get_child`, `get_children`, `get_child_deep`, `get_children_deep`. -- Diffing: `unified_diff`, `_config_to_get_to`, `_difference`. -- Future prediction: `_future`, `_future_pre`. +- Traversal: `all_children`, `all_children_sorted`. +- Diffing: `unified_diff`. + +### Tree algorithms (`hier_config/tree_algorithms.py`) + +The comparison algorithms are extracted into a standalone module operating on nodes through their public tree API: + +- `compute_remediation(source, target, delta)` — the remediation algorithm: a *left pass* (`_remediation_left`) negates children of `source` absent from `target`, then a *right pass* (`_remediation_right`) adds children of `target` absent from (or different in) `source`, applying sectional-overwrite and idempotency rules. +- `compute_difference(source, target, delta)` — config in `source` that is not in `target` (with ACL sequence-number awareness). +- `compute_future(source, config, future_config)` — recursively merges `config` on top of `source`, honoring sectional overwrites, idempotency, and negation resolution (see [Predicting Future Configs](../user/future-config.md)). +- `prune_emptied_branches(source, future_node)` — removes sections a change emptied out, for `future(..., prune_empty_branches=True)`. +- `compute_with_tags(source, tags, delta)` — tag-filtered deep copy. --- -## Driver System +## Driver system The driver layer lives in `hier_config/platforms/`. @@ -76,16 +83,17 @@ The driver layer lives in `hier_config/platforms/`. Every platform driver subclasses `HConfigDriverBase` (`hier_config/platforms/driver_base.py`) and overrides: - `_instantiate_rules()` — returns an `HConfigDriverRules` Pydantic model populated with the platform's rule sets. -- Optionally `negation_prefix`, `declaration_prefix`, `swap_negation`, `idempotent_for`, `negate_with`, `config_preprocessor`. +- Optionally `negation_prefix`, `declaration_prefix`, `swap_negation`, `idempotent_for`, `negate_with`, `config_preprocessor`, and the `view_class` class attribute. + +Idempotency matching derives a structural *idempotency key* from each command's lineage and its matching rule (`_idempotency_key`), so commands that differ only in attribute values (e.g. two BGP neighbor descriptions) are not conflated. ### `HConfigDriverRules` -A frozen Pydantic model holding lists of typed rule objects: +A frozen Pydantic model holding mutable lists of typed rule objects: | Field | Rule type | Effect | |-------|-----------|--------| -| `negate_with` | `NegationDefaultWithRule` | Replace negation with a fixed command | -| `negation_default_when` | `NegationDefaultWhenRule` | Use `default` form instead of `no` | +| `negation` | `NegationRule` | Unified negation: REPLACE a fixed command, use the DEFAULT form, or REGEX_SUB the negated text | | `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 | @@ -96,15 +104,59 @@ A frozen Pydantic model holding lists of typed rule objects: | `full_text_sub` | `FullTextSubRule` | Full-text regex substitution on load | | `indent_adjust` | `IndentAdjustRule` | Shift indentation at start/end markers | | `parent_allows_duplicate_child` | `ParentAllowsDuplicateChildRule` | Permit duplicate child text | +| `unused_objects` | `UnusedObjectRule` | Detect defined-but-unreferenced objects | | `post_load_callbacks` | `Callable[[HConfig], None]` | Run Python callbacks after parsing | +| `remediation_transform_callbacks` | `Callable[[HConfig], None]` | Run Python callbacks over computed remediations | +| `indentation` | `PositiveInt` | Spaces per indent level when rendering (default 2) | + +See the [Driver Rule Reference](rule-reference.md) for every model's fields. + +### Registry (`hier_config/registry.py`) + +Built-in drivers are registered at import time in a module-level registry keyed on canonical uppercase platform names — `Platform` members are converted via their `.name`, string names are uppercased, so a member and its name address the same entry (#284): + +- `register_driver(platform, driver_class)` — add a custom platform (string names, case-insensitive) or override a built-in. +- `unregister_driver(platform)` — remove a custom platform or restore an overridden built-in. +- `get_registered_platforms()` — list everything registered: `Platform` members for enum-known names, uppercase strings for custom names. +- `get_hconfig_driver(platform)` — instantiate the registered driver. +- `resolve_driver(platform_or_driver)` — accept a `Platform`, string, or driver instance (used by every constructor). + +### Built-in platform drivers + +| Platform enum | Driver class | Module | +|--------------|-------------|--------| +| `ARISTA_EOS` | `HConfigDriverAristaEOS` | `platforms/arista_eos/driver.py` | +| `ARUBA_AOSCX` | `HConfigDriverArubaAOSCX` | `platforms/aruba_aoscx/driver.py` | +| `CISCO_IOS` | `HConfigDriverCiscoIOS` | `platforms/cisco_ios/driver.py` | +| `CISCO_NXOS` | `HConfigDriverCiscoNXOS` | `platforms/cisco_nxos/driver.py` | +| `CISCO_XR` | `HConfigDriverCiscoIOSXR` | `platforms/cisco_xr/driver.py` | +| `FORTINET_FORTIOS` | `HConfigDriverFortinetFortiOS` | `platforms/fortinet_fortios/driver.py` | +| `GENERIC` | `HConfigDriverGeneric` | `platforms/generic/driver.py` | +| `HP_COMWARE5` | `HConfigDriverHPComware5` | `platforms/hp_comware5/driver.py` | +| `HP_PROCURVE` | `HConfigDriverHPProcurve` | `platforms/hp_procurve/driver.py` | +| `HUAWEI_VRP` | `HConfigDriverHuaweiVrp` | `platforms/huawei_vrp/driver.py` | +| `JUNIPER_JUNOS` | `HConfigDriverJuniperJUNOS` | `platforms/juniper_junos/driver.py` | +| `NOKIA_SRL` | `HConfigDriverNokiaSRL` | `platforms/nokia_srl/driver.py` | +| `VYOS` | `HConfigDriverVYOS` | `platforms/vyos/driver.py` | + +See [Supported Platforms](../admin/platforms.md) for behavior details and [Creating a Platform Driver](creating-drivers.md) for building new ones. + +--- + +## Structured formats (`hier_config/formats.py`) -### Built-in Platform Drivers +The formats module maps JSON (e.g. OpenConfig) and XML (e.g. NETCONF payloads) onto the same `HConfig` tree used by the rest of the library, so structured configs can be diffed and predicted like CLI text: -Each supported platform provides a driver in `hier_config/platforms//driver.py`. The canonical list of platforms and their support status lives in [Drivers](../user/drivers.md); see [Customizing and Creating Drivers](../user/custom-drivers.md) for how to customize or create drivers. +- `hconfig_from_json` / `hconfig_to_json` — invertible JSON mapping (keyed lists identified via `list_keys`). +- `hconfig_from_xml` / `hconfig_to_xml` — invertible XML mapping (attributes and text content become specially-encoded leaves). +- `hconfig_to_netconf_xml` — renders a remediation between `from_xml` trees as a NETCONF `edit-config` payload (deletions become `nc:operation="delete"` elements). +- `hconfig_to_gnmi_json` — renders a remediation between `from_json` trees as a gNMI-SetRequest-style dict (additions render into an `update` object, deletions become xpath-ish paths with `[key=value]` selectors). + +These are exposed on `HConfig` as `from_json` / `from_xml` / `to_json` / `to_xml`, and on `WorkflowRemediation` as `remediation_netconf_xml()` / `remediation_json()`. See [Loading Configurations](../user/loading-configs.md) for the mapping rules. --- -## Workflow Layer +## Workflow layer ### `WorkflowRemediation` @@ -116,49 +168,35 @@ remediation = workflow.remediation_config # what to apply rollback = workflow.rollback_config # how to revert ``` -Internally it calls `running_config.config_to_get_to(generated_config)` which traverses the tree and calls `_config_to_get_to_left` (what to negate) and `_config_to_get_to_right` (what to add). - -### `config_to_get_to()` - -This method computes the **minimal delta** between two configs: - -1. **Left pass** — find children in `self` that are absent from `target` and emit their negation. -2. **Right pass** — find children in `target` that are absent from or different in `self` and emit them as additions. - -Sectional-overwrite and idempotency rules are applied during the right pass. +Internally it calls `running_config.remediation(generated_config)` (which delegates to `compute_remediation`), then `set_order_weight()`, then runs the transform pipeline: the driver's `rules.remediation_transform_callbacks` first, followed by the user-supplied `plugins`. It validates at construction that both configs use the same driver class (`IncompatibleDriverError`). -### `future()` +### Plugins (`hier_config/plugins.py`) -`HConfig.future(config)` predicts the device state after `config` is applied on top of the current running config. It recursively merges the two trees, honouring: - -- Sectional overwrite / no-negate rules -- Idempotency rules (last value wins) -- Negation commands (`no ...` removes the corresponding positive command) - -See [Future Config](../user/future-config.md) for known limitations. +`RemediationPlugin` is an abstract base class for user-defined remediation transforms — organization policies, safety sequences, provisioning workflows — packaged outside the hier_config codebase and applied via `WorkflowRemediation(plugins=...)`. Instances are callable, so any `Callable[[HConfig], None]` position accepts them. Driver authors should prefer `remediation_transform_callbacks` on `HConfigDriverRules` for platform-level transforms. --- -## View Layer +## View layer The view layer (`hier_config/platforms/view_base.py` and platform-specific `view.py` files) provides structured, typed access to configuration elements without modifying the underlying tree. -- `HConfigViewBase` — abstract base; subclasses implement `interface_views` and `dot1q_mode_from_vlans`. -- `ConfigViewInterfaceBase` — abstract base for per-interface views; exposes properties like `ip_address`, `native_vlan`, `tagged_vlans`, `description`, `duplex`, `bundle_id`. +- `HConfigViewBase` — abstract device-level base; subclasses implement `hostname`, `interface_views`, `interfaces`, and `ipv4_default_gw` (`dot1q_mode_from_vlans` is a concrete static helper). +- `ConfigViewInterfaceBase` — abstract per-interface base; exposes core properties like `name`, `description`, `enabled`, `ipv4_interfaces`, and `vrf`. +- Optional capability mixins — `InterfaceBundleViewMixin` (`bundle_id`, `bundle_member_interfaces`, ...), `InterfaceVlanViewMixin` (`native_vlan`, `tagged_vlans`, `dot1q_mode`, ...), `InterfaceNACViewMixin` (`has_nac`, `nac_host_mode`, ...), and `InterfacePhysicalViewMixin` (`duplex`, `speed`, `poe`, `module_number`). Platform views inherit only the mixins they support; users check capability with `isinstance(view, InterfaceVlanViewMixin)`. -Instantiate a view with: +Views are resolved through the driver's `view_class` attribute: ```python -from hier_config.platforms.cisco_ios.view import HConfigViewCiscoIOS +from hier_config import get_hconfig_view -view = HConfigViewCiscoIOS(hconfig) +view = get_hconfig_view(hconfig) for iface in view.interface_views: - print(iface.description, iface.native_vlan) + print(iface.description) ``` --- -## Reporting Layer +## Reporting layer `RemediationReporter` (`hier_config/reporting.py`) aggregates remediation configs from multiple devices: @@ -171,34 +209,55 @@ summary = reporter.summary() reporter.to_json("report.json") ``` -See [Remediation Reporting](../user/remediation-reporting.md) for full API documentation. +See [Remediation Reporting](../user/remediation-reporting.md) for full documentation. --- -## Data Flow +## Exceptions (`hier_config/exceptions.py`) -``` -config text +All library errors derive from `HierConfigError`: + +- `DriverNotFoundError` — unknown platform or missing view. +- `DuplicateChildError` — strict `merge()` conflict or duplicate list identities in structured formats. +- `IncompatibleDriverError` — `WorkflowRemediation` given configs with different driver classes. +- `InvalidConfigError` — malformed or wrong-format input (JSON/XML detection, mapping violations). + +--- + +## Data flow + +```text +config text (or JSON / XML document) │ ▼ -per_line_sub / full_text_sub (driver preprocessing) +full_text_sub / per_line_sub (driver preprocessing) │ ▼ -config_preprocessor() (optional platform transform, e.g. JunOS → set commands) +config_preprocessor() (optional platform transform, e.g. JunOS → set commands) │ ▼ -HConfig tree (HConfigBase / HConfigChild nodes) +HConfig tree (HConfigBase / HConfigChild nodes) + │ + post_load_callbacks │ - ├──► HConfig.future() → predicted post-change HConfig + ├──► HConfig.future() → predicted post-change HConfig │ - ├──► HConfig.config_to_get_to() + ├──► HConfig.remediation() (tree_algorithms.compute_remediation) │ │ │ ▼ - │ delta HConfig (remediation commands) + │ delta HConfig (remediation commands) + │ │ + │ ▼ + │ remediation_transform_callbacks → plugins │ │ │ ▼ │ WorkflowRemediation.remediation_config │ WorkflowRemediation.rollback_config │ - └──► RemediationReporter (multi-device aggregation) + └──► RemediationReporter (multi-device aggregation) ``` + +## Next steps + +- [Driver Rule Reference](rule-reference.md) — every rule model in detail. +- [Creating a Platform Driver](creating-drivers.md) — apply this architecture to a new platform. +- [Contributing](contributing.md) — build, test, and submit changes. diff --git a/docs/dev/code-style.md b/docs/dev/code-style.md index 10467948..7dea3dca 100644 --- a/docs/dev/code-style.md +++ b/docs/dev/code-style.md @@ -2,6 +2,8 @@ All standards below are enforced by `poetry run ./scripts/build.py lint`, which runs ruff (format + check), mypy, pyright, pylint, yamllint, and flynt in parallel. CI fails if any tool reports an issue. +This repository is the canonical source of that tooling for every netdevops hier-config project. See [Shared Development Standards](shared-standards.md) for how the shared files are distributed and kept in sync. + ## Lint & Type Checking Stack | Tool | Configuration | @@ -18,7 +20,7 @@ The authoritative rule configuration lives in `pyproject.toml`. Do not add suppr ## Pydantic Model Conventions - **Always subclass the project-local `BaseModel`** defined in `hier_config/models.py` — never `pydantic.BaseModel` directly. The local base sets `ConfigDict(frozen=True, extra="forbid")`, making every model immutable and strict. -- **Immutable collections only** in model fields: `tuple[...]` for ordered data, `frozenset[...]` for sets. Never `list` or `set`. +- **Immutable collections only** in model fields: `tuple[...]` for ordered data, `frozenset[...]` for sets. Never `list` or `set`. Deliberate exception: the rule-collection fields on `HConfigDriverRules` are `list[...]` on purpose, so built-in rules and callbacks can be removed by identity (e.g. `rules.post_load_callbacks.remove(...)`) — do not convert them to tuples. - **Rule models** match configuration lineage with `match_rules: tuple[MatchRule, ...]`. - Fields on `HConfigDriverRules` use **named module-level default factory functions** (e.g., `_ordering_rules_default`) rather than lambdas, for strict-mode type checking. @@ -32,4 +34,4 @@ The authoritative rule configuration lives in `pyproject.toml`. Do not add suppr ## Changelog & Commits - Every PR updates `CHANGELOG.md` under `## [Unreleased]` using [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) categories (`Added`, `Changed`, `Fixed`, `Removed`), referencing the issue/PR number, e.g. `(#209)`. -- Commit messages follow the style in [Contributing](contributing.md#commit-message-style): imperative mood, subject ≤72 characters, body explains *why*. +- Commit messages follow the style in [Contributing](contributing.md#commit-messages-and-prs): imperative mood, subject ≤72 characters, body explains *why*. diff --git a/docs/dev/contributing.md b/docs/dev/contributing.md index 6803f7ad..fc4bb096 100644 --- a/docs/dev/contributing.md +++ b/docs/dev/contributing.md @@ -1,3 +1,102 @@ -{% - include-markdown "../../CONTRIBUTING.md" -%} +# Contributing + +This page summarizes how to set up a development environment, run the checks that CI runs, and meet the project's expectations for pull requests. The authoritative reference is [CONTRIBUTING.md](https://github.com/netdevops/hier_config/blob/next/CONTRIBUTING.md) in the repository root. + +## Development setup + +The project uses **Poetry** (not pip) for dependency management: + +```bash +# Fork on GitHub, then: +git clone git@github.com:YOUR-USERNAME/hier_config.git +cd hier_config +poetry install +git checkout -b YOUR-BRANCH +``` + +Python 3.10+ is required. + +## Build and test commands + +The single command that runs everything CI runs: + +```bash +# Full lint + test suite +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 + +# Auto-fix formatting +poetry run ruff format hier_config tests scripts +``` + +Useful pytest invocations: + +```bash +# Run a single test +poetry run pytest tests/unit/platforms/test_cisco_xr.py::test_name -v + +# Run only unit tests / integration tests +poetry run pytest tests/unit/ -v +poetry run pytest tests/integration/ -v +``` + +## Test-driven development + +The project follows TDD — all new features and bug fixes must have corresponding tests, written before or alongside the implementation: + +1. **Write a failing test first** that validates the expected behavior. +2. **Run the test to confirm it fails** for the right reason. +3. **Implement the minimal code** to make the test pass. +4. **Run the full test suite** to ensure no regressions. +5. **Refactor** if needed, keeping tests green. + +### Test layout + +Tests mirror the source structure and are split into categories: + +- **`tests/unit/`** — unit tests for individual classes and functions (tree layer, constructors, workflows, reporting, per-platform driver behavior under `platforms/`, config views under `platforms/views/`). +- **`tests/integration/`** — driver remediation scenarios (running config → generated config → remediation), cross-platform remediation/future/difference tests, and roundtrip workflow validation. +- **`tests/benchmarks/`** — performance benchmarks, skipped by default (run with `poetry run pytest -m benchmark -v -s`). + +Coverage must stay at or above **95%**. + +## Code quality expectations + +- **Strict type checking** — pyright strict mode, mypy strict, and pylint (with the pydantic plugin) all must pass. +- **Ruff** handles formatting (line length 88) and most lint rules. +- **Docstrings for new public API** — any new public class, method, or function must have a docstring. +- **No breaking changes without discussion** — open an issue first if you plan to change a public interface. + +## Changelog + +Update `CHANGELOG.md` under the `## [Unreleased]` section with every PR, using the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) categories (`Added`, `Changed`, `Fixed`, `Removed`) and referencing the GitHub issue number when applicable (e.g., `(#209)`). + +## Commit messages and PRs + +- Use the **imperative mood** in the subject line ("Add feature", not "Added feature"), 72 characters or fewer. +- Leave a blank line between subject and body; the body should explain *why*. +- v4 features and breaking changes target the `next` branch; v3.x maintenance targets `master`. +- Push to your fork and open a pull request — maintainers will review and may suggest changes. + +## Where do changes belong? + +| Change type | Location | +|-------------|----------| +| New platform support | `hier_config/platforms//driver.py` (subclass `HConfigDriverBase`) | +| New rule type | `hier_config/models.py` (new `BaseModel` subclass) + `hier_config/platforms/driver_base.py` (`HConfigDriverRules` field) | +| New utility function | `hier_config/utils.py` | +| New view property | `hier_config/platforms/view_base.py` (abstract) + each platform's `view.py` | +| Core tree algorithm | `hier_config/tree_algorithms.py` (comparison algorithms) or `hier_config/base.py` / `hier_config/root.py` (tree structure) | + +Read the [Architecture](architecture.md) page before making structural changes. + +## Next steps + +- [Architecture](architecture.md) — orientation before your first change. +- [Creating a Platform Driver](creating-drivers.md) — the most common kind of contribution. +- [API Reference](api-reference.md) — the public surface your change may affect. diff --git a/docs/dev/creating-drivers.md b/docs/dev/creating-drivers.md new file mode 100644 index 00000000..06915c84 --- /dev/null +++ b/docs/dev/creating-drivers.md @@ -0,0 +1,280 @@ +# Creating a Platform Driver + +This page is the full deep dive into building a platform driver: the anatomy of `HConfigDriverBase`, declaring rules, negation and declaration prefixes, config preprocessors, config views with the mixin model, and wiring everything into the registry. For a quick registration-focused overview, start with [Custom Drivers and Registration](../admin/custom-drivers.md). + +## Anatomy of `HConfigDriverBase` + +Every driver subclasses `HConfigDriverBase` (`hier_config/platforms/driver_base.py`). The base class encapsulates rule storage and rule-checking methods; a driver overrides only what its platform needs. + +**Required:** + +- `_instantiate_rules()` — static method returning the driver's `HConfigDriverRules` (called once in `__init__` and stored as `self.rules`). + +**Optional overrides:** + +- `negation_prefix` (property) — the string prepended to negate a command. Default `"no "`. +- `declaration_prefix` (property) — the string prepended to positive commands on set-style platforms. Default `""`. +- `config_preprocessor(config_text)` — static method transforming raw text before parsing (e.g. flattening JunOS curly-brace config into `set` commands). +- `negate_with(config)` — return a fixed replacement negation string for a child. The default implementation reads REPLACE-strategy rules from `rules.negation`; override for imperative negation logic. +- `swap_negation(child)` — toggle the negation of a child's text. The default adds/strips `negation_prefix`. +- `idempotent_for(config, other_children)` — find the child that an idempotent command overwrites. The default derives a structural idempotency key from the lineage and match rules. +- `view_class` (class attribute) — the `HConfigViewBase` subclass instantiated by `get_hconfig_view()`. `None` (default) means the platform has no config view. + +The simplest possible driver is the generic one: + +```python +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules + + +class HConfigDriverGeneric(HConfigDriverBase): + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules() +``` + +## Step 1: Subclass and declare rules + +Define the platform's rule sets in `_instantiate_rules()`. See the [Driver Rule Reference](rule-reference.md) for every rule model. + +```python +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules +from hier_config.models import ( + MatchRule, + NegationRule, + NegationStrategy, + SectionalExitingRule, + OrderingRule, + PerLineSubRule, + IdempotentCommandsRule, +) + + +class CustomHConfigDriver(HConfigDriverBase): + """Custom driver for a specific operating system.""" + + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + """Define the rules for this custom driver.""" + return HConfigDriverRules( + negation=[ + NegationRule( + strategy=NegationStrategy.REPLACE, + match_rules=(MatchRule(startswith="ip route "),), + use="no ip route", + ) + ], + sectional_exiting=[ + SectionalExitingRule( + match_rules=( + MatchRule(startswith="policy-map"), + MatchRule(startswith="class"), + ), + exit_text="exit", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="route-policy"),), + exit_text="end-policy", + exit_text_parent_level=True, # render at parent indentation + ), + ], + ordering=[ + OrderingRule( + match_rules=(MatchRule(startswith="interface"),), + weight=10, + ) + ], + per_line_sub=[ + PerLineSubRule( + search="^!.*Generated by system.*$", + replace="", + ) + ], + idempotent_commands=[ + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="interface"),) + ) + ], + ) +``` + +## Step 2: Set negation and declaration prefixes + +Cisco-style platforms can keep the defaults (`"no "` / `""`). Platforms with other conventions override the properties: + +```python + @property + def negation_prefix(self) -> str: + return "delete " + + @property + def declaration_prefix(self) -> str: + return "set " +``` + +Real-world reference points: + +| Platform family | `declaration_prefix` | `negation_prefix` | +|-----------------|----------------------|-------------------| +| Cisco IOS / EOS / NX-OS / XR | `""` | `"no "` | +| HP Comware5 / Huawei VRP | `""` | `"undo "` | +| JunOS / VyOS / Nokia SRL | `"set "` | `"delete "` | + +## Step 3: Add a config preprocessor (if needed) + +If the platform's native rendering is not indentation-hierarchical CLI text, transform it before parsing. The set-style drivers use this to flatten hierarchical output: + +```python + @staticmethod + def config_preprocessor(config_text: str) -> str: + """Convert the platform's native rendering into parseable lines.""" + return convert_to_set_commands(config_text) +``` + +The preprocessor runs inside `HConfig.from_text()` after full-text substitutions and before tree construction. + +## Step 4: Add imperative callbacks (if needed) + +For transformations that declarative rules cannot express, add plain functions to the rules model. Give them public (non-underscore) names — built-in callbacks are public API so users can remove them from the list by identity: + +```python +def split_collapsed_vlans(config: HConfig) -> None: + """Example post-load normalization.""" + ... + + # inside _instantiate_rules(): + return HConfigDriverRules( + ..., + post_load_callbacks=[split_collapsed_vlans], + remediation_transform_callbacks=[], + ) +``` + +- `post_load_callbacks` run once after parsing each config. +- `remediation_transform_callbacks` run over each computed remediation, before user plugins. + +## Step 5: Wire the driver into the registry + +Register the driver so it resolves anywhere a `Platform` is accepted, then use the standard workflow: + +```python +from hier_config import HConfig, WorkflowRemediation, register_driver + +register_driver("CUSTOM_NOS", CustomHConfigDriver) + +running_config = HConfig.from_text("CUSTOM_NOS", running_config_text) +generated_config = HConfig.from_text("CUSTOM_NOS", generated_config_text) + +workflow = WorkflowRemediation(running_config, generated_config) +print(workflow.remediation_config) +``` + +Alternatively, skip registration and pass an instance directly: `HConfig.from_text(CustomHConfigDriver(), config_text)`. + +## Key methods in `HConfigDriverBase` + +The rule-checking methods the tree calls during remediation: + +```python +def idempotent_for( + self, + config: HConfigChild, + other_children: Iterable[HConfigChild], +) -> HConfigChild | None: + """Return the child that `config` idempotently overwrites, if any.""" + +def negate_with(self, config: HConfigChild) -> str | None: + """Return a fixed replacement negation string for `config`, if any.""" + +def swap_negation(self, child: HConfigChild) -> HConfigChild: + """Toggle the negation of `child.text`.""" + +def sectional_exit(self, config: HConfigChild) -> str | None: + """Return the exit token to render at the end of a section.""" +``` + +Idempotency matching is structural: `idempotent_for` builds an *idempotency key* from the child's lineage and the rule's match criteria (prefix matched, regex capture groups, ...), so two commands are only considered interchangeable when their structural identities agree. Craft your `MatchRule`s to capture the identifying parts of a command (e.g. `re_search=r"^neighbor (\S+) description"`). + +## Adding a config view + +To give the platform a typed [config view](../user/config-views.md), implement the two view classes and point the driver at them. + +**1. Interface view** — subclass the capability mixins the platform genuinely supports (each mixin already subclasses `ConfigViewInterfaceBase`, so listing the base explicitly is redundant — the in-tree views don't): + +```python +from hier_config.platforms.view_base import ( + InterfaceBundleViewMixin, + InterfaceVlanViewMixin, +) + + +class ConfigViewInterfaceCustomNOS( + InterfaceBundleViewMixin, + InterfaceVlanViewMixin, +): + """Typed view over one `interface ...` block.""" + + # Implement the abstract properties, e.g.: + @property + def ipv4_interfaces(self): + for child in self.config.get_children(startswith="ip address "): + ... + + @property + def vrf(self) -> str: + ... + + # ...plus the abstract members required by each inherited mixin. +``` + +Inheriting a mixin is a contract: `isinstance(view, InterfaceVlanViewMixin)` tells users the capability exists, so only inherit mixins whose properties the platform can actually populate. + +**2. Device view** — subclass `HConfigViewBase` and implement its abstract members (`hostname`, `interface_views`, `interfaces`, `ipv4_default_gw`; `dot1q_mode_from_vlans` is a concrete static helper you can call, not implement): + +```python +from hier_config.platforms.view_base import HConfigViewBase + + +class HConfigViewCustomNOS(HConfigViewBase): + @property + def interfaces(self): + return self.config.get_children(startswith="interface ") + + @property + def interface_views(self): + for interface in self.interfaces: + yield ConfigViewInterfaceCustomNOS(interface) + + @property + def hostname(self) -> str | None: + if child := self.config.get_child(startswith="hostname "): + return child.text.split()[1].lower() + return None + + # ...remaining abstract members +``` + +**3. Declare it on the driver:** + +```python +class CustomHConfigDriver(HConfigDriverBase): + view_class = HConfigViewCustomNOS + ... +``` + +`get_hconfig_view(config)` now resolves the view automatically for the registered platform. + +## Contributing the driver upstream + +Built-in drivers live in `hier_config/platforms//driver.py` and are wired into: + +- the `Platform` enum in `hier_config/models.py`, +- the `_BUILTIN_DRIVERS` mapping in `hier_config/registry.py`, +- unit tests under `tests/unit/platforms/` and integration tests under `tests/integration/`. + +If your platform is broadly useful, please open a pull request — see [Contributing](contributing.md). + +## Next steps + +- [Driver Rule Reference](rule-reference.md) — every rule model and field. +- [Architecture](architecture.md) — how drivers plug into the tree and workflow layers. +- [Contributing](contributing.md) — test expectations for new drivers. diff --git a/docs/dev/extending.md b/docs/dev/extending.md deleted file mode 100644 index 475073a2..00000000 --- a/docs/dev/extending.md +++ /dev/null @@ -1,47 +0,0 @@ -# Extending hier_config - -This guide covers the three most common in-tree contributions: adding support for a new platform, adding a new driver rule type, and adding config view properties. For customizing drivers *outside* the library (in your own code), see [Customizing and Creating Drivers](../user/custom-drivers.md). - -Every change described here follows [TDD](testing.md): write the failing test first, then implement. - ---- - -## Adding an In-Tree Platform Driver - -1. **Create the driver package**: `hier_config/platforms//` containing `driver.py` with a class subclassing `HConfigDriverBase` (`hier_config/platforms/driver_base.py`). Override `_instantiate_rules()` to return an `HConfigDriverRules` model constructed with the platform's rules (see `platforms/huawei_vrp/driver.py` for a small example). -2. **Register the platform**: add a member to the `Platform` enum in `hier_config/models.py`. -3. **Wire the constructor**: map the new enum member to your driver class in the `platform_drivers` dict inside `get_hconfig_driver` (`hier_config/constructors.py`). -4. **Add tests**: create `tests/test_driver_.py` following the [testing conventions](testing.md). Add any config fixtures to `tests/fixtures/`. -5. **Document it**: add a driver section and a platform-table row to [Drivers](../user/drivers.md). -6. **Changelog**: add an entry under `## [Unreleased]` in `CHANGELOG.md`. - -Rule behavior available to drivers (negation, sectional exiting, ordering, idempotency, substitutions, etc.) is catalogued in [Driver Rule Types](../user/custom-drivers.md#driver-rule-types). - -## Adding a Driver Rule Type - -1. **Model**: add a frozen Pydantic model in `hier_config/models.py`. Subclass the project-local `BaseModel` (never `pydantic.BaseModel` directly — the local base enforces `frozen=True, extra="forbid"`). Lineage matching uses `match_rules: tuple[MatchRule, ...]`; collections must be immutable (`tuple` / `frozenset`). -2. **Rules container**: add a named module-level default factory function and a field to `HConfigDriverRules` in `hier_config/platforms/driver_base.py`. -3. **Consume the rule**: implement the behavior in `hier_config/child.py` and/or `hier_config/root.py` (typically evaluated via `HConfigChild.is_lineage_match()`). -4. **Populate**: add instances of the rule to the relevant platform drivers' `_instantiate_rules()`. -5. **Test, document, changelog**: failing test first; document the rule type in [Driver Rule Types](../user/custom-drivers.md#driver-rule-types) and add a [glossary](../user/glossary.md) entry; update `CHANGELOG.md`. - -## Adding Config View Properties - -1. **Abstract property**: declare it on `HConfigViewBase` or `ConfigViewInterfaceBase` in `hier_config/platforms/view_base.py`. -2. **Platform implementations**: implement the property in each platform's `view.py` (e.g., `hier_config/platforms/cisco_ios/view.py`). -3. **Test**: add coverage in `tests/config_view/` (per-platform files such as `test_view_cisco_ios.py`). -4. **Document**: add the property to [Config View](../user/config-view.md). - ---- - -## Where Changes Belong - -| Change type | Location | -|-------------|----------| -| New platform support | `hier_config/platforms//driver.py` | -| New rule type | `hier_config/models.py` + `hier_config/platforms/driver_base.py` | -| New utility function | `hier_config/utils.py` | -| New view property | `hier_config/platforms/view_base.py` + each platform's `view.py` | -| Core tree algorithm | `hier_config/base.py` (shared) or `hier_config/root.py` (`HConfig`-only) | - -Read the [Architecture Overview](architecture.md) before making structural changes. diff --git a/docs/dev/rule-reference.md b/docs/dev/rule-reference.md new file mode 100644 index 00000000..e9858232 --- /dev/null +++ b/docs/dev/rule-reference.md @@ -0,0 +1,230 @@ +# Driver Rule Reference + +This page catalogs every rule model that can appear in a driver's `HConfigDriverRules`, with its fields and purpose. It is the reference companion to [Customizing Driver Rules](../admin/customizing-rules.md) and [Creating a Platform Driver](creating-drivers.md). + +All rule models are frozen Pydantic models defined in `hier_config/models.py`. Most take a `match_rules: tuple[MatchRule, ...]` describing the full lineage path a configuration line must match — one `MatchRule` per level of hierarchy. + +--- + +## Match rules + +**Purpose**: provide a flexible way to define conditions for matching configuration lines. + +**`MatchRule`** fields (all optional; when multiple are set, every criterion must match): + +- `equals`: matches lines exactly equal to a string (or contained in a frozenset of strings). +- `startswith`: matches lines that start with the specified text or any of a tuple of texts. +- `endswith`: matches lines that end with the specified text or any of a tuple of texts. +- `contains`: matches lines that contain the specified text or any of a tuple of texts. +- `re_search`: matches lines using a regular expression. + +A tuple of MatchRules describes a lineage: `(MatchRule(startswith="interface "), MatchRule(startswith="description "))` matches `description` lines under `interface` sections. `HConfigChild.is_lineage_match()` performs the evaluation. + +--- + +## Negation rules + +**Purpose**: define how commands are negated or reset to a default state. + +**`NegationRule`** — a single unified model with a `NegationStrategy` enum. REPLACE rules are consulted first (via `driver.negate_with()`); the remaining rules are then evaluated in list order and the first matching rule wins. + +- `match_rules`: the conditions under which the rule applies. +- `strategy`: + - `NegationStrategy.REPLACE` — replace the negation with the fixed string in `use`. + - `NegationStrategy.DEFAULT` — rewrite the command to its `default ` form. + - `NegationStrategy.REGEX_SUB` — apply `re.sub(search, replace, ...)` to the *already-negated* text (negation prefix included); `replace` supports back-references such as `\1`. +- `use`: the replacement negation command (required for REPLACE). +- `search` / `replace`: the regex substitution (`search` required for REGEX_SUB). + +A model validator enforces the per-strategy required fields at construction time. + +```python +NegationRule( + strategy=NegationStrategy.REPLACE, + match_rules=(MatchRule(startswith="logging console "),), + use="logging console debugging", +) +``` + +--- + +## Sectional exiting + +**Purpose**: manage hierarchical configuration sections by defining the command that closes each section when rendering. + +**`SectionalExitingRule`**: + +- `match_rules`: defines the section's boundaries. +- `exit_text`: the command used to exit the section. +- `exit_text_parent_level`: 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). + +--- + +## Ordering + +**Purpose**: assign weights to commands to control the order of operations during configuration application. + +**`OrderingRule`**: + +- `match_rules`: defines the commands to be ordered. +- `weight`: an integer determining the order (lower weights are applied earlier). + +--- + +## Substitutions + +**Purpose**: modify or clean up configuration text at load time, before parsing. + +**`PerLineSubRule`** — applied to each line individually: + +- `search`: a string or regex to search for. +- `replace`: the replacement text. + +**`FullTextSubRule`** — same fields, but applied to the entire text block (useful for multi-line patterns). + +--- + +## Idempotent commands + +**Purpose**: identify last-value-wins commands so remediation overwrites instead of negating and re-adding, and so `future()` does not duplicate them. + +**`IdempotentCommandsRule`**: + +- `match_rules`: defines the idempotent command family. Use regex capture groups in `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`. + +**`IdempotentCommandsAvoidRule`**: + +- `match_rules`: commands that must *not* be treated as idempotent even if they would otherwise match (e.g. secondary IP addresses). + +--- + +## Sectional overwriting + +**Purpose**: replace whole sections instead of diffing line-by-line. + +**`SectionalOverwriteRule`**: + +- `match_rules`: sections that are negated and re-created wholesale during remediation. + +**`SectionalOverwriteNoNegateRule`**: + +- `match_rules`: sections re-created *without* prior negation (e.g. IOS XR `route-policy`, where re-entering the block replaces it). + +--- + +## Indentation adjustments + +**Purpose**: correct configuration blocks whose native rendering uses inconsistent indentation (e.g. IOS XR inline templates). + +**`IndentAdjustRule`**: + +- `start_expression`: regex marking the start of an adjustment. +- `end_expression`: regex marking the end of an adjustment. + +--- + +## Duplicate children + +**Purpose**: permit multiple children with identical text under one parent. + +**`ParentAllowsDuplicateChildRule`**: + +- `match_rules`: parents that may hold duplicate children (e.g. `endif` tokens in IOS XR route-policies). A rule with *empty* `match_rules` applies to the root, allowing duplicate top-level lines. + +--- + +## Unused object detection + +**Purpose**: find objects (ACLs, prefix lists, ...) that are defined but never referenced. Consumed by `HConfig.unused_objects()`; not enabled in any driver by default. + +**`UnusedObjectRule`**: + +- `match_rules`: locates the object definitions. +- `name_re`: regex with a `(?P...)` capture group extracting the object name. +- `reference_locations`: tuple of `ReferenceLocation` entries to search. + +**`ReferenceLocation`**: + +- `match_rules`: a lineage prefix that narrows the search scope. +- `reference_re`: regex containing `{name}`, interpolated with the (escaped) object name before matching. + +--- + +## Tagging + +**Purpose**: apply tags to configuration lines for filtering and reporting. Tag rules are applied via `WorkflowRemediation.apply_remediation_tag_rules()` or `RemediationReporter.apply_tag_rules()` rather than being stored on drivers. + +**`TagRule`**: + +- `match_rules`: defines the lines to tag. +- `apply_tags`: a frozenset of tags to apply. + +--- + +## Callbacks + +**Purpose**: apply imperative transformations that declarative rules cannot express. These are plain Python callables (`Callable[[HConfig], None]`), not Pydantic models, and therefore cannot be loaded from YAML. + +- `post_load_callbacks` — run against the tree immediately after parsing (e.g. IOS VLAN-list splitting, ProCurve VLAN membership normalization). +- `remediation_transform_callbacks` — run against each computed remediation, before user plugins (see [Remediation Workflows](../user/remediation-workflows.md#the-remediation-transform-pipeline)). + +Built-in driver callbacks are public functions exported from their driver modules (e.g. `hier_config.platforms.cisco_ios.driver.remove_ipv4_acl_remarks`), so they can be removed from the list by identity — see [Customizing Driver Rules](../admin/customizing-rules.md#customizing-post-load-callbacks). + +The complete set of built-in post-load callbacks: + +| Driver | Callback | What it does | +|--------|----------|--------------| +| Cisco IOS | `hier_config.platforms.cisco_ios.driver.remove_ipv6_acl_sequence_numbers` | Strips sequence numbers from IPv6 ACL entries so they diff by content | +| Cisco IOS | `hier_config.platforms.cisco_ios.driver.remove_ipv4_acl_remarks` | Removes `remark` lines from IPv4 ACLs | +| Cisco IOS | `hier_config.platforms.cisco_ios.driver.add_acl_sequence_numbers` | Adds sequence numbers to IPv4 ACL entries for valid negation | +| Cisco IOS, Aruba AOS-CX | `hier_config.platforms.utils.split_vlan_id_lists` | Expands `vlan 1,3-5`-style ID lists into one node per VLAN | +| Cisco XR | `hier_config.platforms.cisco_xr.driver.fixup_xr_comments` | Moves `!` comment lines into the next sibling's comments set | +| HP ProCurve | `hier_config.platforms.hp_procurve.driver.fixup_hp_procurve_aaa_port_access` | Expands the interface ranges in `aaa port-access` commands | +| HP ProCurve | `hier_config.platforms.hp_procurve.driver.fixup_hp_procurve_device_profile` | Separates `device-profile` tagged-vlans onto individual lines | +| HP ProCurve | `hier_config.platforms.hp_procurve.driver.fixup_hp_procurve_vlan` | Moves native/tagged VLAN membership onto the interface config | +| Aruba AOS-CX | `hier_config.platforms.aruba_aoscx.driver.split_interface_vlan_trunk_allowed` | Expands `vlan trunk allowed` lists into one node per VLAN | + +No built-in driver populates `remediation_transform_callbacks`; the list exists for customized and custom drivers. + +--- + +## Rendering + +- `indentation` (`PositiveInt`, default `2`) — spaces per depth level used by `indented_text()` and text rendering. + +--- + +## Metadata and serialization models + +These models support tree metadata and round-tripping rather than driver behavior: + +**`Instance`** — metadata snapshot for one device's occurrence of a line in a merged/reporting tree: + +- `id`: a unique positive integer identifier. +- `comments`: a frozenset of comments. +- `tags`: a frozenset of tags. + +**`DumpLine`** — one serialized line: + +- `depth`: hierarchy level of the line. +- `text`: the configuration text. +- `tags` / `comments`: frozensets associated with the line. +- `new_in_config`: boolean indicating whether the line is new. + +**`Dump`**: + +- `lines`: a tuple of `DumpLine` objects representing the whole tree (see [Loading Configurations](../user/loading-configs.md#serialization-round-trip-dump-and-from_dump)). + +--- + +## General rule-building patterns + +1. **Define matching conditions** with `MatchRule` for flexible, precise control over which configuration lines a rule applies to. +2. **Apply context-specific logic** with the specialized models (`SectionalExitingRule`, `IdempotentCommandsRule`, ...) for hierarchical or idempotency-related scenarios. +3. **Rely on immutability** — the models are frozen and validated by Pydantic, so a rule cannot be mutated after construction; extend drivers by appending new rules to the (mutable) rule lists. + +## Next steps + +- [Customizing Driver Rules](../admin/customizing-rules.md) — apply these models to a built-in driver. +- [Creating a Platform Driver](creating-drivers.md) — assemble a full rule set for a new platform. +- [API Reference](api-reference.md) — generated documentation for every model. diff --git a/docs/dev/shared-standards.md b/docs/dev/shared-standards.md new file mode 100644 index 00000000..3e1e1c9c --- /dev/null +++ b/docs/dev/shared-standards.md @@ -0,0 +1,105 @@ +# Shared Development Standards + +The hier-config projects in the [netdevops](https://github.com/netdevops) +organization — the `hier_config` library and the applications built on it +(`hier-config-api`, `hier-config-cli`, `hier-config-mcp`, `hier-config-gpt`) — +share one development model: the same lint, typing, and test tooling, the same +YAML style, and the same containerized development environment. + +This repository is the **canonical source** for those shared files. Downstream +projects pull them in rather than maintaining their own copies, so a change +made here propagates to every project. + +## Docker Development Environment + +Every hier-config project ships a Docker development environment driven by +[invoke](https://www.pyinvoke.org/) tasks in `tasks.py`, so the same commands +work in every repository: + +```bash +invoke build # build the development image +invoke docs # docs with live reload at http://localhost:8001 +invoke pytest # tests (--coverage for the coverage gate) +invoke lint # linters (--fix to apply auto-fixes) +invoke lint-and-test # full suite, same as CI +invoke cli # shell inside the container +invoke sync-standards # check drift against the canonical standards +invoke destroy # tear down containers +``` + +Projects that serve an application add a `serve` task for it; everything else +is expected to behave identically across repositories. + +## Synced Files + +The files below are owned by this repository and listed in each project's +`.standards.yml` manifest: + +| File | Purpose | +|------|---------| +| `scripts/build.py` | Lint, type-check, and test driver used by every project and by CI | +| `scripts/sync_standards.py` | The sync tool itself | +| `.yamllint.yml` | YAML style rules | +| `.dockerignore` | Build-context exclusions for the development image | + +`Dockerfile`, `docker-compose.yml`, and `tasks.py` are deliberately **not** +synced: each project's image and services differ (an application serves a +process, a library does not). They follow the conventions above, with this +repository's copies as the reference implementation. + +## The Manifest + +Each project declares where its standards come from in `.standards.yml`: + +```yaml +source: + repo: netdevops/hier_config + ref: master + +# Whole-word replacements so package references match the consuming project +substitutions: + hier_config: hier_config_api + +files: + - scripts/build.py + - scripts/sync_standards.py + - .yamllint.yml + - .dockerignore +``` + +Substitutions rewrite package names as files are fetched, which is what lets a +single `scripts/build.py` serve projects with different package names. Because +a substitution can push a line past the 88-character limit, Python files are +re-run through `ruff format` after substitution — without that step a synced +file would never converge, since `apply` would write content that the formatter +immediately rewrites. + +## Syncing + +```bash +# Report drift; exits non-zero when local files differ from canonical +invoke sync-standards + +# Pull the canonical versions into the local files +invoke sync-standards --apply +``` + +Downstream projects also run a scheduled GitHub Actions workflow that applies +the sync weekly and opens a pull request when anything changed, so CI validates +the update before it merges. + +Run in this repository, `sync-standards` compares the working tree against what +is published on the source ref. That previews what downstream projects will +receive on their next sync, and reports drift while a change to a shared file +is still unreleased on `master`. + +## Changing a Shared File + +1. Change the file here and open a pull request, as with any other change. +2. Once it merges to `master`, downstream projects pick it up on their next + scheduled sync, or immediately via `invoke sync-standards --apply`. + +Never edit a synced file directly in a downstream project: the next sync will +overwrite it. Keep tool version constraints compatible across projects too — a +downstream project pinned to an older linter may not accept canonical files +that rely on newer behavior. diff --git a/docs/dev/testing.md b/docs/dev/testing.md index b621271c..1b38a114 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -14,20 +14,23 @@ poetry run ./scripts/build.py lint-and-test poetry run ./scripts/build.py pytest --coverage # A single test -poetry run pytest tests/test_driver_cisco_ios.py::test_delete_sectional_exit_regression -v +poetry run pytest tests/integration/test_cisco_ios.py::test_delete_sectional_exit_regression -v # A single file -poetry run pytest tests/test_driver_cisco_ios.py -v +poetry run pytest tests/integration/test_cisco_ios.py -v + +# Only unit tests / only integration tests +poetry run pytest tests/unit/ -v +poetry run pytest tests/integration/ -v ``` Coverage must stay at or above **95%** (`--cov-fail-under=95`, enforced by `scripts/build.py` and CI). ## Conventions -- **Flat, function-based tests** — no test classes. (The only exception is the benchmark groupings in `tests/test_benchmarks.py`.) -- **One test file per platform driver**: `tests/test_driver_.py` (e.g., `test_driver_cisco_xr.py`). Driver behavior changes belong in the matching file. -- **Config view tests** live in `tests/config_view/` with per-platform files (`test_view_cisco_ios.py`). -- **Fixtures** are module-scoped, defined in `tests/conftest.py`, and read config text from `tests/fixtures/`. Add new sample configs there rather than embedding large configs inline. +- **Flat, function-based tests** — no test classes. (The only exceptions are the benchmark groupings in `tests/benchmarks/test_benchmarks.py` and the parametrized circular-workflow suite.) +- **Tests mirror the source layout**: unit tests for individual classes and functions live in `tests/unit/` (with driver unit tests in `tests/unit/platforms/` and config view tests in `tests/unit/platforms/views/`); end-to-end remediation scenarios live in `tests/integration/` with one file per platform (e.g., `test_cisco_xr.py`). Driver behavior changes belong in the matching file. +- **Fixtures** are module-scoped, defined in the relevant `conftest.py`, and read config text from the sibling `fixtures/` directory. Add new sample configs there rather than embedding large configs inline. - **Full type annotations** — test functions are annotated (`def test_x() -> None:`) and pass the same strict type checking as library code. ## The Dominant Test Idiom @@ -36,14 +39,14 @@ Most driver tests follow a round-trip assertion chain — build both configs, co ```python def test_example() -> None: - running_config = get_hconfig_fast_load(platform, ("interface Ethernet1", " shutdown")) - generated_config = get_hconfig_fast_load(platform, ("interface Ethernet1", " no shutdown")) + running_config = HConfig.from_lines(platform, ("interface Ethernet1", " shutdown")) + generated_config = HConfig.from_lines(platform, ("interface Ethernet1", " no shutdown")) - remediation = running_config.config_to_get_to(generated_config) - assert remediation.dump_simple() == ("interface Ethernet1", " no shutdown") + remediation = running_config.remediation(generated_config) + assert remediation.to_lines() == ("interface Ethernet1", " no shutdown") running_after = running_config.future(remediation) - rollback = running_after.config_to_get_to(running_config) + rollback = running_after.remediation(running_config) running_after_rollback = running_after.future(rollback) assert not tuple(running_config.unified_diff(running_after_rollback)) ``` @@ -52,7 +55,7 @@ When fixing a bug, add a regression test that would have caught it. ## Benchmarks -Performance benchmarks live in `tests/test_benchmarks.py` and are **skipped by default** via the `benchmark` pytest marker (`addopts = "-m 'not benchmark'"`). They generate ~10,000-line configs and assert upper time bounds. +Performance benchmarks live in `tests/benchmarks/test_benchmarks.py` and are **skipped by default** via the `benchmark` pytest marker (`addopts = "-m 'not benchmark'"`). They generate ~10,000-line configs and assert upper time bounds. ```bash # All benchmarks, with timing output diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 00000000..44b1c479 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,194 @@ +# Glossary + +This page defines hier_config-specific terminology used throughout the documentation and source code. + +--- + +## Declaration prefix + +The string that precedes a *positive* (enabling) command in platforms that use explicit declarations, such as JunOS (`set`) and VyOS (`set`). The driver's `declaration_prefix` property returns this string; `HConfigDriverBase` defaults to an empty string (Cisco-style platforms have no explicit declaration keyword). + +**Example:** In JunOS, `set interfaces ge-0/0/0 description uplink` — the declaration prefix is `"set "`. + +--- + +## Driver / HConfigDriverBase + +A Python class that encodes all operating-system-specific behaviour for one network platform. Every driver subclasses `HConfigDriverBase` and provides an `HConfigDriverRules` instance via `_instantiate_rules()`. Drivers are resolved through the driver registry by passing a `Platform` enum value (or registered platform name string) to `HConfig.from_text()` or `get_hconfig_driver()`. + +**Example:** `HConfigDriverCiscoIOS`, `HConfigDriverJuniperJUNOS`. + +--- + +## Driver registry / canonical platform name + +The runtime mapping from platform identifiers to driver classes (`hier_config/registry.py`): `register_driver()`, `unregister_driver()`, `get_registered_platforms()`, `get_hconfig_driver()`. Keys are canonicalized to the uppercase platform *name* (`Platform.CISCO_IOS.name` → `"CISCO_IOS"`), and string lookups are case-insensitive. Custom drivers registered under a string name coexist with the built-ins. + +--- + +## Future config / FutureReport + +`HConfig.future(config)` predicts the configuration a device will have after a change is applied — used to validate remediations and build rollbacks offline. `HConfig.future_with_report(config)` returns the same tree plus a `FutureReport` naming the nodes where negation resolution was ambiguous (`unresolved_negations`) or an idempotent command was silently replaced (`idempotency_replacements`). See [Predicting Future Configs](user/future-config.md). + +--- + +## Idempotent command + +A configuration command where only the *last* value applied takes effect — applying the same command twice with different values results in only the second value being active. Typical examples: `hostname`, `ip address`, `description`. + +hier_config uses `IdempotentCommandsRule` to identify these commands. During `remediation()`, when both the running and intended configs contain a command that matches an idempotency rule, the running value is **not** negated before the new value is applied (the new value simply overwrites it). + +**Example rule:** + +```python +IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="description "), + ) +) +``` + +--- + +## Idempotent command avoid list + +A set of `IdempotentCommandsAvoidRule` entries that *prevent* specific commands from being treated as idempotent even if they would otherwise match an `IdempotentCommandsRule`. Useful for commands like secondary IP addresses, where applying the same `startswith` prefix would incorrectly deduplicate distinct entries. + +**Example:** Avoiding idempotency for `ip address ... secondary` on Cisco NX-OS. + +--- + +## Indent adjust + +A pair of `IndentAdjustRule` entries (`start_expression` / `end_expression`) that temporarily shift the indentation level between the two markers. Used on Cisco IOS XR for inline templates whose body is indented differently from the surrounding context. + +--- + +## List keys (`list_keys`) + +The tuple of leaf names that identify entries in structured (JSON/XML) list data — default `("name", "id")`. Used by `HConfig.from_json()` / `from_xml()` to give keyed list entries stable identities, and by `remediation_netconf_xml()` / `remediation_json()` to render deletions by key selector. Pass `list_keys=` when your data model keys lists on something else. + +--- + +## Match rule + +A `MatchRule` Pydantic model that acts as a predicate on an `HConfigChild.text` value. All fields (`equals`, `startswith`, `endswith`, `contains`, `re_search`) are optional; when multiple are set every criterion must match. Match rules are composed into tuples to describe a full lineage path. + +**Example:** Match any `neighbor X.X.X.X description` line under a BGP section: + +```python +MatchRule(startswith="router bgp"), +MatchRule(re_search=r"neighbor \S+ description"), +``` + +--- + +## Negation prefix + +The string prepended to a command to negate (remove) it. `HConfigDriverBase.negation_prefix` defaults to `"no "` for Cisco-style platforms. Platforms that use a different convention override this property. + +| Platform | Negation prefix | +|----------|----------------| +| Cisco IOS / EOS / NX-OS | `"no "` | +| HP Comware5 / H3C / Huawei VRP | `"undo "` | +| JunOS / VyOS / Nokia SRL | `"delete "` | + +--- + +## Negation rule + +A `NegationRule` describes commands that cannot simply be prefixed with the negation prefix. Each rule carries a `NegationStrategy`: + +- **`NegationStrategy.REPLACE`** — replace the negation with the fixed command string in `use`. Used when a command has a dedicated reset form — for example, `logging console debugging` is the correct way to reset the console logging level rather than `no logging console`. +- **`NegationStrategy.DEFAULT`** — rewrite the command to its `default ` form. Some IOS and EOS commands behave differently when defaulted vs negated (e.g. `logging event link-status`). +- **`NegationStrategy.REGEX_SUB`** — apply `re.sub(search, replace, ...)` to the already-negated text, e.g. to truncate a negation after a keyword (`no snmp-server user bob ...` → `no snmp-server user bob`). + +Rules are evaluated in list order; the first matching rule wins. (`NegationRule` replaces the earlier `NegationDefaultWithRule`, `NegationDefaultWhenRule`, and `NegationSubRule` models.) + +--- + +## Parent allows duplicate child + +A `ParentAllowsDuplicateChildRule` that permits multiple `HConfigChild` objects with the same `text` value under a single parent. Required for constructs such as `address-family` blocks inside `router bgp` on some platforms, or `endif` tokens in IOS XR route-policies. A rule with empty `match_rules` applies to the root of the tree. + +--- + +## Per-line sub / full-text sub + +Regex substitution rules applied to configuration text at load time before it is parsed into the tree: + +- `PerLineSubRule` — applies the substitution to each line individually (useful for removing inline comments, `!`, timestamp headers). +- `FullTextSubRule` — applies the substitution across the entire text block (useful for multi-line patterns). + +**Example:** Strip `Building configuration...` banners: + +```python +PerLineSubRule(search="^Building configuration.*", replace="") +``` + +--- + +## Post-load / remediation-transform callbacks + +Two callback lists on `HConfigDriverRules`, each holding `Callable[[HConfig], None]` transforms that mutate a tree in place. `post_load_callbacks` run right after a config is parsed (built-in examples: `remove_ipv4_acl_remarks` on Cisco IOS, `fixup_xr_comments` on Cisco XR); the built-ins are public functions, so they can be removed by identity (`rules.post_load_callbacks.remove(...)`). `remediation_transform_callbacks` run over a freshly computed remediation — no built-in driver populates this list; it exists for customized drivers. See [Customizing Driver Rules](admin/customizing-rules.md). + +--- + +## RemediationPlugin + +The abstract base class (`hier_config/plugins.py`) for reusable, named remediation transforms. Subclasses implement `name`, `description`, and `transform(remediation)`; instances are callable, so they work anywhere a plain `Callable[[HConfig], None]` does — most commonly the `plugins=` argument of `WorkflowRemediation`. Plugins express user/workflow policy; driver-level fixups belong in `remediation_transform_callbacks` instead. See [Remediation Workflows](user/remediation-workflows.md). + +--- + +## Sectional exiting + +A `SectionalExitingRule` that instructs hier_config to emit a closing token at the end of a configuration section when rendering output. Different platforms require different exit syntax. + +| Platform / section | Exit token | +|-------------------|-----------| +| Cisco IOS BGP peer-policy | `exit-peer-policy` | +| Cisco IOS XR route-policy | `end-policy` | +| Cisco IOS XR prefix-set | `end-set` | +| Huawei VRP sections | `quit` | +| Most sections (default) | `exit` | + +--- + +## Sectional overwrite + +A `SectionalOverwriteRule` that tells `remediation()` to **negate the entire section** and then re-create it from the intended config rather than performing a line-by-line diff. Appropriate for configuration blocks where the order of entries matters globally or where partial changes are not supported by the OS. + +--- + +## Sectional overwrite no negate + +A `SectionalOverwriteNoNegateRule` similar to sectional overwrite, but the existing section is **deleted without negation** before the new version is written. Used for blocks like `prefix-set` and `route-policy` on Cisco IOS XR where issuing a `no` is not the correct removal mechanism. + +--- + +## Tag rules + +`TagRule` entries that apply named tags (`apply_tags`) to all `HConfigChild` nodes whose lineage matches `match_rules`. Tags are used by `WorkflowRemediation.apply_remediation_tag_rules()` to annotate the remediation config for selective filtering via `remediation_config_filtered_text()`, and by `RemediationReporter.apply_tag_rules()` for tag-based reporting. + +**Example use case:** Tag all interface changes as `"interfaces"` and all BGP changes as `"bgp"` so that changes can be deployed separately. + +--- + +## Unused object rule + +An `UnusedObjectRule` that identifies named object definitions (ACLs, prefix lists, ...) via `match_rules`, extracts each object's name with `name_re`, and searches the locations described by `reference_locations` for references. Objects with zero references are yielded by `HConfig.unused_objects()`. Not enabled in any driver by default. + +--- + +## WorkflowRemediation + +The primary user-facing class for computing the delta between a running and an intended configuration. Exposes: + +- `remediation_config` — the commands to apply to bring the device into compliance. +- `rollback_config` — the commands to revert the device back to its original state. +- `remediation_netconf_xml()` — the remediation rendered as a NETCONF `edit-config` payload (for XML-sourced configs). +- `remediation_json()` — the remediation rendered as a gNMI-SetRequest-style dict of update/delete sets (for JSON-sourced configs). +- `apply_remediation_tag_rules()` — annotate remediation lines with tags. +- `remediation_config_filtered_text()` — render a tagged subset of the remediation. +- `plugins` — user-supplied transforms applied to the computed remediation. diff --git a/docs/index.md b/docs/index.md index 61a2f646..a999036c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,63 +1,50 @@ # Hierarchical Configuration (hier_config) -`hier_config` is a Python library that compares a network device's running configuration against its intended configuration and generates the exact remediation commands needed to bring it into compliance — without connecting to any device. +`hier_config` is a Python library that compares a network device's running configuration against its intended configuration and generates the exact commands needed to bring the device into compliance — without ever connecting to it. Configurations are parsed into hierarchical trees, diffed with full awareness of vendor-specific syntax rules (negation, idempotency, section exiting, command ordering), and rendered back out as minimal, ready-to-apply remediation. -**New to hier_config?** → [Get started in 5 minutes](user/getting-started.md) +## Quick example ---- +```python +from hier_config import HConfig, Platform, WorkflowRemediation -## What can hier_config do? +running_config_text = """ +hostname old-name +interface Vlan2 + shutdown +""" -- **Compute remediation** — diff running vs intended config and produce the minimum set of commands to close the gap. See [Getting Started](user/getting-started.md). -- **Generate rollbacks** — automatically produce the inverse change so you can revert safely. See [Getting Started → Rollback](user/getting-started.md#generating-the-rollback-configuration). -- **Preview future state** — simulate what the running config will look like after a change set is applied. See [Future Config](user/future-config.md). -- **Tag-based filtering** — annotate remediation lines with tags and deploy only a subset of changes (e.g., interfaces only, or BGP only). See [Working with Tags](user/tags.md). -- **Structured config access** — query interface properties, VLANs, hostnames, and more through a typed Python API without writing regex. See [Config View](user/config-view.md). -- **Multi-device reporting** — aggregate remediation stats across a fleet and export to JSON or CSV. See [Remediation Reporting](user/remediation-reporting.md). +intended_config_text = """ +hostname new-name +interface Vlan2 + no shutdown +""" ---- +running = HConfig.from_text(Platform.CISCO_IOS, running_config_text) +intended = HConfig.from_text(Platform.CISCO_IOS, intended_config_text) -## Supported Platforms - -| Platform | `Platform` enum | Status | -|----------|-----------------|--------| -| Cisco IOS | `Platform.CISCO_IOS` | Fully supported | -| Arista EOS | `Platform.ARISTA_EOS` | Fully supported | -| Cisco IOS XR | `Platform.CISCO_XR` | Fully supported | -| Cisco NX-OS | `Platform.CISCO_NXOS` | Fully supported | -| Fortinet FortiOS | `Platform.FORTINET_FORTIOS` | Fully supported | -| HP ProCurve (Aruba AOSS) | `Platform.HP_PROCURVE` | Fully supported | -| HP Comware5 / H3C | `Platform.HP_COMWARE5` | Fully supported | -| Aruba AOS-CX | `Platform.ARUBA_AOSCX` | Experimental | -| Juniper JunOS | `Platform.JUNIPER_JUNOS` | Experimental | -| Nokia SRL | `Platform.NOKIA_SRL` | Experimental | -| VyOS | `Platform.VYOS` | Experimental | -| Generic | `Platform.GENERIC` | Base for custom drivers | +workflow = WorkflowRemediation(running, intended) +for line in workflow.remediation_config.all_children_sorted(): + print(line.indented_text()) +# no hostname old-name +# hostname new-name +# interface Vlan2 +# no shutdown +``` ---- +## Where to go next -## Quick Example +### I want to generate remediation → [User Guide](user/getting-started.md) -```python -from hier_config import WorkflowRemediation, get_hconfig, Platform +Install the library, walk through your first diff, and learn the everyday workflows: [loading configurations](user/loading-configs.md) from text, JSON, or XML; [remediation and rollback](user/remediation-workflows.md); [tag-based filtering](user/tags.md); [future-state prediction](user/future-config.md); [multi-device reporting](user/remediation-reporting.md); and [typed config views](user/config-views.md). -running = get_hconfig(Platform.CISCO_IOS, running_config_text) -intended = get_hconfig(Platform.CISCO_IOS, intended_config_text) -workflow = WorkflowRemediation(running, intended) +### I need to tune platform behavior → [Administrator Guide](admin/platforms.md) -for line in workflow.remediation_config.all_children_sorted(): - print(line.cisco_style_text()) -``` +Review the [supported platforms](admin/platforms.md) and their quirks, [customize driver rules](admin/customizing-rules.md) (idempotency, negation, ordering, post-load callbacks), [register custom drivers](admin/custom-drivers.md), or [load rules from YAML files](admin/rules-from-files.md). ---- +### I want to extend hier_config → [Developer Guide](dev/architecture.md) -## Where to go next +Understand the [architecture](dev/architecture.md) (tree, driver, and workflow layers), browse the [driver rule reference](dev/rule-reference.md), [create a new platform driver](dev/creating-drivers.md) from scratch, or [contribute](dev/contributing.md) to the project. The full [API reference](dev/api-reference.md) documents every public class and function. + +--- -| Goal | Page | -|------|------| -| Install the library | [Install](user/install.md) | -| Walk through a first diff | [Getting Started](user/getting-started.md) | -| Learn about platform drivers | [Drivers](user/drivers.md) | -| Understand the architecture | [Architecture](dev/architecture.md) | -| Browse the full API | [API Reference](user/api-reference.md) | -| Look up terminology | [Glossary](user/glossary.md) | +Unsure what a term means? Check the [Glossary](glossary.md). diff --git a/docs/user/api-reference.md b/docs/user/api-reference.md deleted file mode 100644 index 5f9b797e..00000000 --- a/docs/user/api-reference.md +++ /dev/null @@ -1,77 +0,0 @@ -# API Reference - -Auto-generated reference documentation for the `hier_config` public API. - ---- - -## Constructor Functions - -::: hier_config.get_hconfig - -::: hier_config.get_hconfig_driver - ---- - -## Core Classes - -::: hier_config.HConfig - -::: hier_config.HConfigChild - -::: hier_config.children.HConfigChildren - ---- - -## Workflow - -::: hier_config.WorkflowRemediation - ---- - -## Reporting - -::: hier_config.RemediationReporter - ---- - -## Driver System - -::: hier_config.platforms.driver_base.HConfigDriverBase - -::: hier_config.platforms.driver_base.HConfigDriverRules - ---- - -## Models - -::: hier_config.models.Platform - -::: hier_config.models.TextStyle - -::: hier_config.models.MatchRule - -::: hier_config.models.TagRule - -::: hier_config.models.IdempotentCommandsRule - -::: hier_config.models.NegationDefaultWithRule - -::: hier_config.models.NegationDefaultWhenRule - -::: hier_config.models.SectionalExitingRule - -::: hier_config.models.SectionalOverwriteRule - -::: hier_config.models.SectionalOverwriteNoNegateRule - -::: hier_config.models.OrderingRule - -::: hier_config.models.Dump - -::: hier_config.models.DumpLine - ---- - -## Utilities - -::: hier_config.utils diff --git a/docs/user/config-view.md b/docs/user/config-views.md similarity index 50% rename from docs/user/config-view.md rename to docs/user/config-views.md index ef7b766d..a8913368 100644 --- a/docs/user/config-view.md +++ b/docs/user/config-views.md @@ -1,20 +1,63 @@ -# Config View +# Config Views -A Config View is an abstraction layer for network device configurations. It provides a structured, Pythonic way to interact with and extract information from raw configuration data. Config Views are especially useful for standardizing how configuration elements are accessed across different platforms and devices. +This page covers config views — a typed, Pythonic layer for extracting structured data (hostnames, interfaces, VLANs, IP addresses) from a parsed configuration without writing regex. Use it when you need to *read* facts out of a config rather than remediate it. -The framework uses a combination of abstract base classes (e.g., `ConfigViewInterfaceBase`, `HConfigViewBase`) and platform-specific implementations (e.g., `ConfigViewInterfaceCiscoIOS`, `HConfigViewCiscoIOS`) to provide a unified interface for interacting with configurations while accounting for the unique syntax and semantics of each vendor or platform. +A config view wraps an `HConfig` tree and exposes Python properties in a platform-independent way: the framework combines abstract base classes (`HConfigViewBase`, `ConfigViewInterfaceBase`) with platform-specific implementations (e.g. `HConfigViewCiscoIOS`, `ConfigViewInterfaceCiscoIOS`) so the same code works across vendors. -## Why Use Config Views? +## Why use config views? -1. **Vendor Abstraction:** Network devices from different vendors (Cisco, Arista, Juniper, etc.) have varied configuration formats. Config Views standardize access, making it easier to work across platforms. +1. **Vendor abstraction:** devices from different vendors have varied configuration formats; views standardize access across platforms. +2. **Simplified interface:** structured properties instead of hand-rolled text parsing. +3. **Extensibility:** support new platforms by implementing platform-specific subclasses. +4. **Error reduction:** parsing logic is encapsulated and tested once. -2. **Simplified Interface:** Accessing configuration data becomes more intuitive through Python properties and methods rather than manually parsing text. +## Getting a view -3. **Extensibility:** Easily extendable to support new platforms or devices by implementing platform-specific subclasses. +Use `get_hconfig_view()`; it instantiates the view class declared by the config's driver: -4. **Error Reduction:** Encapsulates parsing logic, reducing the risk of errors due to configuration syntax differences. +```python +from hier_config import HConfig, Platform, get_hconfig_view + +raw_config = """ +hostname router1 +interface GigabitEthernet0/1 + description Uplink to Switch + switchport access vlan 10 + ip address 192.168.1.1 255.255.255.0 + shutdown +! +vlan 10 + name DATA +""" + +hconfig = HConfig.from_text(Platform.CISCO_IOS, raw_config) +config_view = get_hconfig_view(hconfig) +``` + +Platforms without a view raise `DriverNotFoundError`. Views are currently provided for Cisco IOS, Arista EOS, Cisco NX-OS, Cisco IOS XR, Aruba AOS-CX, and HP ProCurve. (You can also import a platform view class directly, e.g. `from hier_config.platforms.cisco_ios.view import HConfigViewCiscoIOS`.) + +## The capability mixin model + +`ConfigViewInterfaceBase` carries only the core interface properties that every platform supports. Optional capabilities are modeled as mixins that a platform view inherits *only when it genuinely supports them*: + +- `InterfaceBundleViewMixin` — bundle / port-channel properties (`bundle_id`, `bundle_name`, `bundle_member_interfaces`, `is_bundle`). +- `InterfaceVlanViewMixin` — 802.1Q VLAN properties (`native_vlan`, `tagged_vlans`, `tagged_all`, `dot1q_mode`). +- `InterfaceNACViewMixin` — NAC properties (`has_nac`, `nac_host_mode`, `nac_mab_first`, ...). +- `InterfacePhysicalViewMixin` — physical-layer properties (`duplex`, `speed`, `poe`, `module_number`). + +Check whether an interface view supports a capability with `isinstance()`: + +```python +from hier_config import InterfaceVlanViewMixin + +for interface_view in config_view.interface_views: + if isinstance(interface_view, InterfaceVlanViewMixin): + print(interface_view.name, interface_view.native_vlan) +``` + +Current platform capabilities: Cisco IOS, HP ProCurve, and Aruba AOS-CX inherit all four mixins; Arista EOS, Cisco NX-OS, and Cisco IOS XR inherit the bundle and VLAN mixins. -## Available Config Views +## Device-level view properties | **Property/Method** | **Type** | **Description** | |-----------------------------|--------------------------------|------------------------------------------------------------------------------| @@ -34,116 +77,65 @@ The framework uses a combination of abstract base classes (e.g., `ConfigViewInte | `vlan_ids` | `frozenset[int]` | Set of VLAN IDs configured. | | `vlans` | `Iterable[Vlan]` | Yields VLAN objects, including ID and name. | -## Available Config Interface Views - -### Basic Identity - -| **Property** | **Type** | **Description** | -|--------------|----------|-----------------| -| `name` | `str` | Returns the name of the interface (e.g. `GigabitEthernet0/1`). | -| `number` | `str` | Extracts the numeric portion of the interface name. | -| `description` | `str` | Returns the configured description of the interface. | -| `parent_name` | `Optional[str]` | Retrieves the name of the parent bundle interface, if any. | +## Interface view properties -### Operational State +### Core properties (`ConfigViewInterfaceBase` — all platforms) | **Property** | **Type** | **Description** | |--------------|----------|-----------------| +| `name` | `str` | The name of the interface (e.g. `GigabitEthernet0/1`). | +| `number` | `str` | The numeric portion of the interface name. | +| `description` | `str` | The configured description of the interface. | | `enabled` | `bool` | `True` if the interface is not shut down. | -| `poe` | `bool` | `True` if Power over Ethernet (PoE) is enabled on the interface. | - -### IP Addressing - -| **Property** | **Type** | **Description** | -|--------------|----------|-----------------| -| `ipv4_interface` | `Optional[IPv4Interface]` | Retrieves the first configured IPv4 address and prefix. | -| `ipv4_interfaces` | `Iterable[IPv4Interface]` | Lists all IPv4 addresses and prefixes configured on the interface. | -| `vrf` | `str` | Retrieves the VRF (Virtual Routing and Forwarding) associated with the interface. | - -### VLAN and 802.1Q - -| **Property** | **Type** | **Description** | -|--------------|----------|-----------------| -| `dot1q_mode` | `Optional[InterfaceDot1qMode]` | Determines the 802.1Q mode (`ACCESS`, `TAGGED`, `TRUNK`, etc.) based on VLAN tagging configuration. | -| `native_vlan` | `Optional[int]` | Retrieves the native VLAN of the interface. | -| `tagged_vlans` | `tuple[int, ...]` | Lists the VLANs that are tagged on the interface. | -| `tagged_all` | `bool` | `True` if all VLANs are tagged on the interface. | - -### Interface Type Flags - -| **Property** | **Type** | **Description** | -|--------------|----------|-----------------| -| `is_bundle` | `bool` | `True` if the interface is a bundle (port-channel / LAG). | +| `ipv4_interface` | `Optional[IPv4Interface]` | The first configured IPv4 address and prefix. | +| `ipv4_interfaces` | `Iterable[IPv4Interface]` | All IPv4 addresses and prefixes configured on the interface. | +| `vrf` | `str` | The VRF associated with the interface. | | `is_loopback` | `bool` | `True` if the interface is a loopback. | | `is_subinterface` | `bool` | `True` if the interface is a subinterface (e.g. `Gi0/1.100`). | | `is_svi` | `bool` | `True` if the interface is a switched virtual interface (SVI / VLAN interface). | +| `parent_name` | `Optional[str]` | The parent interface name of a subinterface. | +| `port_number` | `int` | The port number of the interface. | +| `subinterface_number` | `Optional[int]` | The subinterface number, if applicable. | -### Bundle / Port Channel +### Bundle / port-channel (`InterfaceBundleViewMixin`) | **Property** | **Type** | **Description** | |--------------|----------|-----------------| -| `bundle_id` | `Optional[str]` | Retrieves the bundle ID of the interface. | -| `bundle_name` | `Optional[str]` | Retrieves the name of the bundle to which the interface belongs. | -| `bundle_member_interfaces` | `Iterable[str]` | Lists the member interfaces of a bundle. | +| `is_bundle` | `bool` | `True` if the interface is a bundle (port-channel / LAG). | +| `bundle_id` | `Optional[str]` | The bundle ID of the interface. | +| `bundle_name` | `Optional[str]` | The name of the bundle to which the interface belongs. | +| `bundle_member_interfaces` | `Iterable[str]` | The member interfaces of a bundle. | -### Physical Layer +### VLAN and 802.1Q (`InterfaceVlanViewMixin`) | **Property** | **Type** | **Description** | |--------------|----------|-----------------| -| `duplex` | `InterfaceDuplex` | Determines the duplex mode of the interface (`FULL`, `HALF`, `AUTO`). | -| `speed` | `Optional[tuple[int, ...]]` | Lists the static speeds (in Mbps) at which the interface can operate. | -| `port_number` | `int` | Retrieves the port number of the interface. | -| `module_number` | `Optional[int]` | Retrieves the module number of the interface. | -| `subinterface_number` | `Optional[int]` | Retrieves the subinterface number, if applicable. | +| `dot1q_mode` | `Optional[InterfaceDot1qMode]` | The 802.1Q mode (`ACCESS`, `TAGGED`, `TAGGED_ALL`, ...) based on VLAN tagging configuration. | +| `native_vlan` | `Optional[int]` | The native VLAN of the interface. | +| `tagged_vlans` | `tuple[int, ...]` | The VLANs that are tagged on the interface. | +| `tagged_all` | `bool` | `True` if all VLANs are tagged on the interface. | -### NAC (Network Admission Control) +### NAC (`InterfaceNACViewMixin`) | **Property** | **Type** | **Description** | |--------------|----------|-----------------| | `has_nac` | `bool` | `True` if NAC is configured on the interface. | | `nac_control_direction_in` | `bool` | `True` if NAC is configured with `control direction in`. | -| `nac_host_mode` | `Optional[NACHostMode]` | Retrieves the NAC host mode (`SINGLE_HOST`, `MULTI_AUTH`, etc.). | -| `nac_mab_first` | `bool` | `True` if NAC is configured to try MAB (MAC Authentication Bypass) before 802.1X. | +| `nac_host_mode` | `Optional[NACHostMode]` | The NAC host mode (`SINGLE_HOST`, `MULTI_AUTH`, ...). | +| `nac_mab_first` | `bool` | `True` if NAC tries MAB (MAC Authentication Bypass) before 802.1X. | | `nac_max_dot1x_clients` | `int` | Maximum number of 802.1X clients allowed on the interface. | | `nac_max_mab_clients` | `int` | Maximum number of MAB clients allowed on the interface. | -## Example: Cisco IOS Config View - -### Step 1: Parse Configuration +### Physical layer (`InterfacePhysicalViewMixin`) -Assume we have a Cisco IOS configuration file as a string. - -```python -from hier_config import Platform, get_hconfig - - -raw_config = """ -hostname router1 -interface GigabitEthernet0/1 - description Uplink to Switch - switchport access vlan 10 - ip address 192.168.1.1 255.255.255.0 - shutdown -! -vlan 10 - name DATA -""" - -hconfig = get_hconfig(Platform.CISCO_IOS, raw_config) -``` - -### Step 2: Create Config View - -```python -from hier_config.platforms.cisco_ios.view import HConfigViewCiscoIOS - - -config_view = HConfigViewCiscoIOS(hconfig) -``` - -### Step 3: Access Configuration Details +| **Property** | **Type** | **Description** | +|--------------|----------|-----------------| +| `duplex` | `InterfaceDuplex` | The duplex mode of the interface (`FULL`, `HALF`, `AUTO`). | +| `speed` | `Optional[tuple[int, ...]]` | The static speeds (in Mbps) at which the interface can operate. | +| `poe` | `bool` | `True` if Power over Ethernet (PoE) is enabled on the interface. | +| `module_number` | `Optional[int]` | The module number of the interface. | -Access properties to interact with the configuration programmatically: +## Example: device-level access ```python # Get the hostname @@ -159,25 +151,18 @@ for interface_view in config_view.interface_views: # Get all VLANs for vlan in config_view.vlans: print(f"VLAN {vlan.id}: {vlan.name}") - ``` -## Example: Cisco IOS Config Interface View - -### Step 1: Parse Configuration - -Assume we have a Cisco IOS configuration file as a string. +## Example: interface-level access ```python -from hier_config import Platform, get_hconfig - +from hier_config import HConfig, Platform, get_hconfig_view raw_config = """ interface GigabitEthernet0/1 description Uplink to Switch switchport access vlan 10 switchport mode access - ip address 192.168.1.1 255.255.255.0 shutdown ! interface GigabitEthernet0/2 @@ -186,26 +171,9 @@ interface GigabitEthernet0/2 ! """ -hconfig = get_hconfig(Platform.CISCO_IOS, raw_config) -``` - -### Step 2: Create Config View and Access Interface Views - -```python -from hier_config.platforms.cisco_ios.view import HConfigViewCiscoIOS - -config_view = HConfigViewCiscoIOS(hconfig) -``` - -### Step 3: Access Interface Details - -Access properties and methods to interact with individual interface configurations programmatically: - -**Retrieve Interface Properties** - -#### Loop through all interface views and display their properties +hconfig = HConfig.from_text(Platform.CISCO_IOS, raw_config) +config_view = get_hconfig_view(hconfig) -```python for interface_view in config_view.interface_views: print(f"Interface Name: {interface_view.name}") print(f"Description: {interface_view.description}") @@ -218,20 +186,20 @@ for interface_view in config_view.interface_views: print("-" * 40) ``` -**Example Output:** +Example output: -``` +```text Interface Name: GigabitEthernet0/1 Description: Uplink to Switch Enabled: False Dot1Q Mode: InterfaceDot1qMode.ACCESS Native VLAN: 10 Tagged VLANs: () -IP Address: 192.168.1.1/24 +IP Address: None Is Subinterface: False ---------------------------------------- Interface Name: GigabitEthernet0/2 -Description: None +Description: Enabled: True Dot1Q Mode: InterfaceDot1qMode.TAGGED Native VLAN: None @@ -240,3 +208,10 @@ IP Address: None Is Subinterface: False ---------------------------------------- ``` + +Note that `description` returns an empty string (not `None`) when no description is configured, and an interface configured as a routed port (with an `ip address`) reports `dot1q_mode` and `native_vlan` as `None` even if stale `switchport` lines remain. + +## Next steps + +- [Creating a Platform Driver](../dev/creating-drivers.md#adding-a-config-view) — implement a view for a new platform with the mixin model. +- [API Reference](../dev/api-reference.md) — full view base-class documentation. diff --git a/docs/user/custom-drivers.md b/docs/user/custom-drivers.md deleted file mode 100644 index feea37d9..00000000 --- a/docs/user/custom-drivers.md +++ /dev/null @@ -1,665 +0,0 @@ -# Customizing and Creating Drivers - -Every driver is built from a common set of rule types. This guide explains those rule types, how to customize the built-in drivers, and how to create a custom driver for a platform that hier_config does not support out of the box. - -For an overview of drivers and a reference of the built-in platforms, see [Drivers](drivers.md). - -## Driver Rule Types - -In Hier Config, the rules within a driver are organized into sections, each targeting a specific aspect of device configuration processing. These sections use Pydantic models to define the behavior and ensure consistency. Here's a breakdown of each section and its associated models: - ---- - -### 1. Negation Rules - -**Purpose**: Define how to negate commands or reset them to a default state. - -- **Models**: - - **`NegationDefaultWithRule`**: - - `match_rules`: A tuple of `MatchRule` objects defining the conditions under which the rule applies. - - `use`: The text to use as the negation command. - - - **`NegationDefaultWhenRule`**: - - `match_rules`: A tuple of `MatchRule` objects for matching conditions where negation is default. - ---- - -### 2. Sectional Exiting - -**Purpose**: Manage hierarchical configuration sections by defining commands for properly exiting each section. - -- **Models**: - - **`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). - ---- - -### 3. Ordering - -**Purpose**: Assign weights to commands to control the order of operations during configuration application. - -- **Models**: - - **`OrderingRule`**: - - `match_rules`: A tuple of `MatchRule` objects defining the commands to be ordered. - - `weight`: An integer determining the order (lower weights are processed earlier). - ---- - -### 4. Per-Line Substitutions - -**Purpose**: Modify or clean up specific lines in the configuration. - -- **Models**: - - **`PerLineSubRule`**: - - `search`: A string or regex to search for. - - `replace`: The replacement text. - - - **`FullTextSubRule`**: - - Similar to `PerLineSubRule`, but applies to the entire text rather than individual lines. - ---- - -### 5. Idempotent Commands - -**Purpose**: Ensure commands are not repeated unnecessarily in the configuration. - -- **Models**: - - **`IdempotentCommandsRule`**: - - `match_rules`: A tuple of `MatchRule` objects defining idempotent commands. - - - **`IdempotentCommandsAvoidRule`**: - - `match_rules`: Specifies commands that should be avoided during idempotency checks. - ---- - -### 6. Post-Processing Callbacks - -**Purpose**: Apply additional transformations after initial configuration processing. - -- **Implementation**: - - A list of functions or methods called after the driver rules are applied, enabling custom logic specific to the platform. - ---- - -### 7. Tagging and Overwriting - -**Purpose**: Apply tags to configuration lines or define overwriting behavior for specific sections. - -- **Models**: - - **`TagRule`**: - - `match_rules`: A tuple of `MatchRule` objects defining the lines to tag. - - `apply_tags`: A frozenset of tags to apply. - - - **`SectionalOverwriteRule`**: - - `match_rules`: Defines sections that can be overwritten. - - - **`SectionalOverwriteNoNegateRule`**: - - Similar to `SectionalOverwriteRule`, but prevents negation. - ---- - -### 8. Indentation Adjustments - -**Purpose**: Define start and end points for adjusting indentation within configurations. - -- **Models**: - - **`IndentAdjustRule`**: - - `start_expression`: Regex or text marking the start of an adjustment. - - `end_expression`: Regex or text marking the end of an adjustment. - ---- - -### 9. Match Rules - -**Purpose**: Provide a flexible way to define conditions for matching configuration lines. - -`MatchRule` is the building block that every other rule type uses to target configuration lines by their lineage (`equals`, `startswith`, `endswith`, `contains`, `re_search`; multiple fields on one rule combine with AND logic). See [MatchRules](tags.md#matchrules) for the full reference and examples. - ---- - -### 10. Instance Metadata - -**Purpose**: Manage metadata for configuration instances, such as tags and comments. - -- **Models**: - - **`Instance`**: - - `id`: A unique positive integer identifier. - - `comments`: A frozenset of comments. - - `tags`: A frozenset of tags. - ---- - -### 11. Dumping Configuration - -**Purpose**: Represent and handle the output of processed configuration lines. - -- **Models**: - - **`DumpLine`**: - - `depth`: Indicates the hierarchy level of the line. - - `text`: The configuration text. - - `tags`: A frozenset of tags associated with the line. - - `comments`: A frozenset of comments associated with the line. - - `new_in_config`: A boolean indicating if the line is new. - - - **`Dump`**: - - `lines`: A tuple of `DumpLine` objects representing the processed configuration. - ---- - -### General Rule-Building Patterns - -1. **Define Matching Conditions**: - - Use `MatchRule` to specify conditions for each rule, ensuring flexible and precise control over which configuration lines a rule applies to. - -2. **Apply Context-Specific Logic**: - - Use specialized models like `SectionalExitingRule` or `IdempotentCommandsRule` to tailor behavior to hierarchical or idempotency-related scenarios. - -3. **Maintain Immutability**: - - All models use Pydantic’s immutability and validation to enforce the integrity of rules and configurations. - -This structure ensures that drivers are modular, extensible, and capable of handling diverse configuration scenarios across different platforms. - -## Customizing Existing Drivers - -This guide provides two examples of how to extend the rules for a Cisco IOS driver in Hier Config. The first example involves subclassing the driver to customize and add rules. The second example demonstrates extending the driver rules dynamically after the driver has already been instantiated. - ---- - -### Example 1: Subclassing the Driver to Extend Rules - -In this approach, you create a new class that subclasses the base Cisco IOS driver and overrides its `_instantiate_rules` method to customize the rules. - -```python -from hier_config.models import ( - MatchRule, - NegationDefaultWithRule, - SectionalExitingRule, - OrderingRule, - PerLineSubRule, - IdempotentCommandsRule, -) -from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - - -class ExtendedHConfigDriverCiscoIOS(HConfigDriverCiscoIOS): - @staticmethod - def _instantiate_rules(): - # Start with the base rules - base_rules = HConfigDriverCiscoIOS._instantiate_rules() - - # Extend negation rules - base_rules.negate_with.append( - NegationDefaultWithRule( - match_rules=(MatchRule(startswith="ip route "),), - use="no ip route" - ) - ) - - # Extend sectional exiting rules - base_rules.sectional_exiting.append( - SectionalExitingRule( - match_rules=( - MatchRule(startswith="policy-map"), - MatchRule(startswith="class"), - ), - exit_text="exit", - ) - ) - - # Add additional ordering rules - base_rules.ordering.append( - OrderingRule( - match_rules=( - MatchRule(startswith="access-list"), - MatchRule(startswith="permit "), - ), - weight=50, - ) - ) - - # Add new per-line substitutions - base_rules.per_line_sub.append( - PerLineSubRule( - search="^!.*Generated by system.*$", replace="" - ) - ) - - # Add new idempotent commands - base_rules.idempotent_commands.append( - IdempotentCommandsRule( - match_rules=( - MatchRule(startswith="interface "), - MatchRule(startswith="speed "), - ) - ) - ) - - return base_rules -``` - -#### Using the Subclassed Driver - -```python -from hier_config import Platform - -# Example function to activate the extended driver -def get_extended_hconfig_driver(platform: Platform): - if platform == Platform.CISCO_IOS: - return ExtendedHConfigDriverCiscoIOS() - raise ValueError(f"Unsupported platform: {platform}") - -# Activate the extended driver -driver = get_extended_hconfig_driver(Platform.CISCO_IOS) -``` - -### Example 2: Dynamically Extending Rules for an Instantiated Driver - -If you already have the driver instantiated, you can modify its rules dynamically by directly appending to the appropriate sections. - -```python -from hier_config import get_hconfig_driver, Platform -from hier_config.models import ( - MatchRule, - NegationDefaultWithRule, - SectionalExitingRule, - OrderingRule, - PerLineSubRule, - IdempotentCommandsRule, -) - -# Instantiate the driver -driver = get_hconfig_driver(Platform.CISCO_IOS) - -# Dynamically extend negation rules -driver.rules.negate_with.append( - NegationDefaultWithRule( - match_rules=(MatchRule(startswith="ip route "),), - use="no ip route" - ) -) - -# Dynamically extend sectional exiting rules -driver.rules.sectional_exiting.append( - SectionalExitingRule( - match_rules=( - MatchRule(startswith="policy-map"), - MatchRule(startswith="class"), - ), - exit_text="exit", - ) -) - -# Add additional ordering rules dynamically -driver.rules.ordering.append( - OrderingRule( - match_rules=( - MatchRule(startswith="access-list"), - MatchRule(startswith="permit "), - ), - weight=50, - ) -) - -# Add new per-line substitutions dynamically -driver.rules.per_line_sub.append( - PerLineSubRule( - search="^!.*Generated by system.*$", replace="" - ) -) - -# Add new idempotent commands dynamically -driver.rules.idempotent_commands.append( - IdempotentCommandsRule( - match_rules=( - MatchRule(startswith="interface "), - MatchRule(startswith="speed "), - ) - ) -) -``` - -#### Explanation - -- **Dynamic Rule Extension:** You directly modify the driver.rules attributes to append new rules dynamically. -- **Flexibility:** This approach is useful when the driver is instantiated by external code, and subclassing is not feasible. - -Both approaches allow you to extend the functionality of the Cisco IOS driver: - -1. **Subclassing:** Recommended for reusable, modular extensions where the driver logic can be encapsulated in a new class. -2. **Dynamic Modification:** Useful when the driver is instantiated dynamically, and you need to modify the rules at runtime. - -### Example 3: Adding Unused Object Detection - -Unused object detection is not enabled in any driver by default — it must be explicitly configured. This ensures no unintended side-effects for users who are not expecting it. - -You can add unused object rules dynamically or via `load_hconfig_v2_options`: - -#### Dynamic Extension - -```python -from hier_config import get_hconfig, get_hconfig_driver, Platform -from hier_config.models import MatchRule, ReferenceLocation, UnusedObjectRule - -driver = get_hconfig_driver(Platform.CISCO_XR) - -# Detect unused IPv4 ACLs -driver.rules.unused_objects.append( - UnusedObjectRule( - match_rules=(MatchRule(startswith="ipv4 access-list "),), - name_re=r"^ipv4 access-list (?P\S+)", - reference_locations=( - ReferenceLocation( - match_rules=(MatchRule(startswith="interface "),), - reference_re=r"\bipv4 access-group {name}\b", - ), - ), - ) -) - -config = get_hconfig(driver, running_config_text) -for unused in config.unused_objects(): - print(f"Unused: {unused.text}") -``` - -#### Via `load_hconfig_v2_options` - -```python -from hier_config import get_hconfig, Platform -from hier_config.utils import load_hconfig_v2_options - -options = { - "unused_objects": [ - { - "lineage": [{"startswith": "ipv4 access-list "}], - "name_re": r"^ipv4 access-list (?P\S+)", - "reference_locations": [ - { - "lineage": [{"startswith": "interface "}], - "reference_re": r"\bipv4 access-group {name}\b", - }, - ], - }, - ], -} -driver = load_hconfig_v2_options(options, Platform.CISCO_XR) -config = get_hconfig(driver, running_config_text) - -for unused in config.unused_objects(): - print(f"Unused: {unused.text}") -``` - -Each `UnusedObjectRule` requires: - -- `match_rules` — locates the object definition (e.g., `startswith="ipv4 access-list "`) -- `name_re` — regex with a `(?P...)` capture group to extract the object name -- `reference_locations` — a tuple of `ReferenceLocation` entries, each specifying where to search and what regex pattern (with `{name}` placeholder) to match - -### Example 4: Adding Negation Substitution - -Some platforms require negation commands to be truncated or transformed. Use `NegationSubRule` for regex-based negation transformations: - -```python -from hier_config import get_hconfig_driver, Platform -from hier_config.models import MatchRule, NegationSubRule - -driver = get_hconfig_driver(Platform.CISCO_NXOS) - -# Truncate SNMP user negation after the username -driver.rules.negation_sub.append( - NegationSubRule( - match_rules=(MatchRule(startswith="snmp-server user "),), - search=r"(no snmp-server user \S+).*", - replace=r"\1", - ) -) -``` - -## Creating a Custom Driver - -This guide walks you through the process of creating a custom driver using the `HConfigDriverBase` class from the `hier_config.platforms.driver_base` module. Custom drivers allow you to define operating system-specific rules and behaviors for managing device configurations. - ---- - -### Overview of `HConfigDriverBase` - -The `HConfigDriverBase` class provides a foundation for defining driver-specific rules and behaviors. It encapsulates configuration rules and methods for handling idempotency, negation, and more. You will extend this class to create a new driver. - -Key Components: - -1. **`HConfigDriverRules`**: A collection of rules for handling configuration logic. -1. **Methods to Override**: Define custom behavior by overriding the `_instantiate_rules` method. -1. **Properties**: Adjust behavior for negation and declaration prefixes. - ---- - -### Steps to Create a Custom Driver - -#### Step 1: Subclass `HConfigDriverBase` - -Begin by subclassing `HConfigDriverBase` to define a new driver. - -```python -from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules -from hier_config.models import ( - MatchRule, - NegationDefaultWithRule, - SectionalExitingRule, - OrderingRule, - PerLineSubRule, - IdempotentCommandsRule, -) - - -class CustomHConfigDriver(HConfigDriverBase): - """Custom driver for a specific operating system.""" - - @staticmethod - def _instantiate_rules() -> HConfigDriverRules: - """Define the rules for this custom driver.""" - return HConfigDriverRules( - negate_with=[ - NegationDefaultWithRule( - match_rules=(MatchRule(startswith="ip route "),), - use="no ip route" - ) - ], - sectional_exiting=[ - SectionalExitingRule( - match_rules=( - MatchRule(startswith="policy-map"), - MatchRule(startswith="class"), - ), - exit_text="exit" - ) - ], - ordering=[ - OrderingRule( - match_rules=(MatchRule(startswith="interface"),), - weight=10 - ) - ], - per_line_sub=[ - PerLineSubRule( - search="^!.*Generated by system.*$", - replace="" - ) - ], - idempotent_commands=[ - IdempotentCommandsRule( - match_rules=(MatchRule(startswith="interface"),) - ) - ], - ) -``` - -#### Step 2: Customize Negation or Declaration Prefixes (Optional) - -Override the `negation_prefix` or `declaration_prefix` properties to customize their behavior. - -```python - @property - def negation_prefix(self) -> str: - """Customize the negation prefix.""" - return "disable " - - @property - def declaration_prefix(self) -> str: - """Customize the declaration prefix.""" - return "enable " -``` - -#### Step 3: Use the Custom Driver - -This section describes how to use the custom driver by extending the `get_hconfig_driver` function and adding a new platform to the `Platform` model. It also covers how to load the driver into Hier Config and utilize it for remediation workflows. - ---- - -##### 1. Extend `get_hconfig_driver` to Include the Custom Driver - -First, modify the `get_hconfig_driver` function to include the new custom driver: - -```python -from hier_config.platforms.driver_base import HConfigDriverBase -from hier_config import get_hconfig_driver -from .custom_driver import CustomHConfigDriver # Import your custom driver -from hier_config.models import Platform - -def get_custom_hconfig_driver(platform: Union[CustomPlatform,Platform]) -> HConfigDriverBase: - """Create base options on an OS level.""" - if platform == CustomPlatform.CUSTOM_DRIVER: - return CustomHConfigDriver() - return get_hconfig_driver(platform) -``` - -##### 2. Create a custom `Platform` to Include the Custom Driver - -```python -from enum import Enum, auto - -class CustomPlatform(str, Enum): - CUSTOM_PLATFORM = auto() -``` - -##### 3. Load the Driver into `HConfig` - -```python -from .custom_platform import CustomPlatform -from hier_config import get_hconfig -from hier_config.utils import read_text_from_file - -# Load running and intended configurations from files -running_config_text = read_text_from_file("./tests/fixtures/running_config.conf") -generated_config_text = read_text_from_file("./tests/fixtures/remediation_config.conf") - -# Create HConfig objects for running and intended configurations -running_config = get_hconfig(CustomPlatform.CUSTOM_DRIVER, running_config_text) -generated_config = get_hconfig(CustomPlatform.CUSTOM_DRIVER, generated_config_text) -``` - -##### 4. Instantiate a `WorkflowRemediation` - -```python -from hier_config import WorkflowRemediation - -# Instantiate the remediation workflow -workflow = WorkflowRemediation(running_config, generated_config) -``` - - -### Key Methods in HConfigDriverBase - -1. `idempotent_for`: - - Matches configurations against idempotent rules to prevent duplication. - -```python -def idempotent_for( - self, - config: HConfigChild, - other_children: Iterable[HConfigChild], -) -> Optional[HConfigChild]: - ... -``` - -1. `negate_with`: - - Provides a negation command based on rules. - -```python -def negate_with(self, config: HConfigChild) -> Optional[str]: - ... -``` - -1. `swap_negation`: - - Toggles the negation of a command. - -```python -def swap_negation(self, child: HConfigChild) -> HConfigChild: - ... -``` - -1. Properties: - - `negation_prefix`: Default is `"no "`. - - `declaration_prefix`: Default is `""`. - -### Example Rule Definitions - -#### Negation Rules - -Define commands that require specific negation handling: - -```python -negate_with=[ - NegationDefaultWithRule( - match_rules=(MatchRule(startswith="ip route "),), - use="no ip route" - ) -] -``` - -#### Sectional Exiting - -Define how to exit specific configuration sections: - -```python -sectional_exiting=[ - SectionalExitingRule( - match_rules=( - MatchRule(startswith="policy-map"), - MatchRule(startswith="class"), - ), - exit_text="exit", - ), - SectionalExitingRule( - match_rules=(MatchRule(startswith="route-policy"),), - exit_text="end-policy", - exit_text_parent_level=True, # render at parent indentation - ), -] -``` - -#### Command Ordering - -Set the execution order of specific commands: - -```python -ordering=[ - OrderingRule( - match_rules=(MatchRule(startswith="interface"),), - weight=10 - ) -] -``` - -#### Per-Line Substitution - -Clean up unwanted lines in the configuration: - -```python -per_line_sub=[ - PerLineSubRule( - search="^!.*Generated by system.*$", - replace="" - ) -] -``` diff --git a/docs/user/custom-workflows.md b/docs/user/custom-workflows.md deleted file mode 100644 index 4cd3500a..00000000 --- a/docs/user/custom-workflows.md +++ /dev/null @@ -1,223 +0,0 @@ -# Creating Custom Workflows - -Certain scenarios demand remediation strategies that go beyond the standard [negation](glossary.md#negation-prefix) and [idempotency](glossary.md#idempotent-command) workflows Hier Config is designed to handle. To address these edge cases, Hier Config allows for custom remediation workflows that integrate seamlessly with the existing remediation process. - ----- - -## Building a Remediation Workflow - -1. Importing Modules and Loading Configurations - -Start by importing the necessary modules and loading the running and intended configurations for comparison. - -```python -from hier_config import WorkflowRemediation, get_hconfig, Platform -from hier_config.utils import read_text_from_file -``` - -Load the configurations from files: - -```python -running_config = read_text_from_file("./tests/fixtures/running_config_acl.conf") -generated_config = read_text_from_file("./tests/fixtures/generated_config_acl.conf") -``` - -These configurations represent the current and desired states of the device. - ----- - -2. Initializing the Workflow Remediation Object: - -Initialize the WorkflowRemediation object for a Cisco IOS platform: - - -```python -wfr = WorkflowRemediation( - running_config=get_hconfig(Platform.CISCO_IOS, running_config), - generated_config=get_hconfig(Platform.CISCO_IOS, generated_config) -) -``` -This object manages the remediation workflow between the running and generated configurations. - ----- - -## Extracting and Analyzing Remediation Sections - -### Example: Access-List Custom Remediation - -**Current (Running) Configuration** - -```python -print(wfr.running_config.get_child(startswith="ip access-list")) -``` - -Output: - -``` -ip access-list extended TEST - 12 permit ip 10.0.0.0 0.0.0.7 any - exit -``` - -**Intended (Generated) Configuration** - -```python -print(wfr.generated_config.get_child(startswith="ip access-list")) -``` - -Output: - -``` -ip access-list extended TEST - 10 permit ip 10.0.1.0 0.0.0.255 any - 20 permit ip 10.0.0.0 0.0.0.7 any - exit -``` - -**Default Remediation Configuration** - -```python -print(wfr.remediation_config.get_child(startswith="ip access-list")) -``` - -Output: - -``` -ip access-list extended TEST - no 12 permit ip 10.0.0.0 0.0.0.7 any - 10 permit ip 10.0.1.0 0.0.0.255 any - 20 permit ip 10.0.0.0 0.0.0.7 any - exit -``` - -#### Issues with the Default Remediation: - -1. **Invalid Command:** `no 12 permit ip 10.0.0.0 0.0.0.7 any` is invalid in Cisco IOS. The valid command is `no 12`. -2. **Risk of Lockout:** Removing a line currently matched by traffic could cause a connectivity outage. -3. **Unnecessary Changes:** `permit ip 10.0.0.0 0.0.0.7 any` is a valid line aside from sequence numbers. In large ACLs, this might be unnecessary to delete and re-add. - ----- - -#### Goals for Safe Access-List Remediation - -To avoid outages during production changes: - -1. **Resequence the ACL:** Adjust sequence numbers using the ip access-list resequence command. - * For demonstration, resequence to align 12 to 20. -1. **Temporary Allow-All:** Add a temporary rule (1 permit ip any any) to prevent lockouts. -1. **Cleanup:** Remove the temporary rule (no 1) after applying the changes. - ----- - -## Building the Custom Remediation - -1. Create a Custom `HConfig` Object - -```python -from hier_config import HConfig - -custom_remediation = HConfig(wfr.running_config.driver) -``` - -2. Add Resequencing and Extract ACL Remediation - -```python -custom_remediation.add_child("ip access-list resequence TEST 10 10") -custom_remediation.add_child("ip access-list extended TEST") -remediation = wfr.remediation_config.get_child(equals="ip access-list extended TEST") -``` - -3. Build the Custom ACL Remediation - -```python -acl = custom_remediation.get_child(equals="ip access-list extended TEST") -acl.add_child("1 permit ip any any") # Temporary allow-all - -for line in remediation.all_children(): - if line.text.startswith("no "): - # Adjust invalid sequence negation - parts = line.text.split() - rounded_number = round(int(parts[1]), -1) - acl.add_child(f"{parts[0]} {rounded_number}") - else: - acl.add_child(line.text) - -acl.add_child("no 1") # Cleanup temporary rule -``` - -### Output of Custom Remediation - -```python -print(custom_remediation) -``` - -Output: - -``` -ip access-list resequence TEST 10 10 -ip access-list extended TEST - 1 permit ip any any - no 10 - 10 permit ip 10.0.1.0 0.0.0.255 any - 20 permit ip 10.0.0.0 0.0.0.7 any - no 1 - exit -``` - -## Applying the Custom Remediation - -### Remove Invalid Remediation - -```python -invalid_remediation = wfr.remediation_config.get_child(equals="ip access-list extended TEST") -wfr.remediation_config.delete_child(invalid_remediation) -``` - -### Add Custom Remediation - -```python -wfr.remediation_config.merge(custom_remediation) -``` - -> **Note:** `merge()` is intentionally strict. If any child already exists under the same parent in the target tree, Hier Config raises `hier_config.exceptions.DuplicateChildError`. This guards against accidentally overwriting commands when combining remediation fragments. When you need to layer one configuration onto another and allow overlapping sections, use [`future()`](future-config.md) instead. - -### Output of Updated Remediation - -```python -print(wfr.remediation_config) -``` - -Output: - -```text -vlan 3 - name switch_mgmt_10.0.3.0/24 - exit -vlan 4 - name switch_mgmt_10.0.4.0/24 - exit -interface Vlan2 - mtu 9000 - ip access-group TEST in - no shutdown - exit -interface Vlan3 - description switch_mgmt_10.0.3.0/24 - ip address 10.0.3.1 255.255.0.0 - exit -interface Vlan4 - mtu 9000 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown - exit -ip access-list resequence TEST 10 10 -ip access-list extended TEST - 1 permit ip any any - no 10 - 10 permit ip 10.0.1.0 0.0.0.255 any - 20 permit ip 10.0.0.0 0.0.0.7 any - no 1 - exit -``` diff --git a/docs/user/drivers.md b/docs/user/drivers.md deleted file mode 100644 index 64503fb5..00000000 --- a/docs/user/drivers.md +++ /dev/null @@ -1,368 +0,0 @@ -# Drivers in Hier Config - -Drivers represent a modern approach to handling operating system-specific options within Hier Config. Prior to version 3, Hier Config utilized `options` or `hconfig_options`, which were defined as dictionaries, to specify OS-specific parameters. Starting with version 3, these options have been replaced by drivers, which are implemented as Pydantic models and loaded as Python classes, offering improved structure and validation. - -> **Note:** Many of the options available in the Hier Config version 3 driver format are similar to those in the version 2 options format. However, some options have been removed because they are no longer used in version 3, or their names have been updated for consistency or clarity. - -## What is a Driver? - -A driver in Hier Config defines a structured and systematic approach to managing operating system-specific configurations for network devices. It acts as a framework that encapsulates the rules, transformations, and behaviors required to process and normalize device configurations. - -Drivers provide a consistent way to handle configurations by applying a set of specialized logic, including: - -1. **[Negation Handling](glossary.md#negation-prefix)**: Ensures commands are properly negated or reset according to the operating system's syntax and behavior, maintaining consistency in enabling or disabling features. - -2. **[Sectional Exiting Rules](glossary.md#sectional-exiting)**: Defines how to navigate in and out of hierarchical configuration sections, ensuring commands are logically grouped and the configuration maintains its structural integrity. - -3. **Command Ordering**: Establishes the sequence in which commands should be applied based on dependencies or importance, preventing conflicts or misconfigurations during deployment. - -4. **Line Substitutions**: Cleans up unnecessary or temporary data in configurations, such as metadata, system-generated comments, or obsolete commands, resulting in a streamlined and standardized output. - -5. **[Idempotency Management](glossary.md#idempotent-command)**: Identifies and enforces commands that should not be duplicated, ensuring repeated application of the configuration does not lead to redundant or conflicting entries. - -6. **Post-Processing Callbacks**: Performs additional adjustments or enhancements after the initial configuration is processed, such as refining access control lists or applying custom transformations specific to the device's operating system. - -By defining these rules and behaviors in a reusable way, a driver enables Hier Config to adapt seamlessly to different operating systems while maintaining a consistent interface for configuration management. This abstraction allows users to work with configurations in a predictable and efficient manner, regardless of the underlying system-specific requirements. - ---- - -## Built-In Drivers in Hier Config - -The following drivers are included in Hier Config: - -| Platform | `Platform` enum | Status | -|----------|-----------------|--------| -| Cisco IOS | `Platform.CISCO_IOS` | Fully supported | -| Arista EOS | `Platform.ARISTA_EOS` | Fully supported | -| Cisco IOS XR | `Platform.CISCO_XR` | Fully supported | -| Cisco NX-OS | `Platform.CISCO_NXOS` | Fully supported | -| Fortinet FortiOS | `Platform.FORTINET_FORTIOS` | Fully supported | -| HP ProCurve (Aruba AOSS) | `Platform.HP_PROCURVE` | Fully supported | -| HP Comware5 / H3C | `Platform.HP_COMWARE5` | Fully supported | -| Huawei VRP | `Platform.HUAWEI_VRP` | Fully supported | -| Aruba AOS-CX | `Platform.ARUBA_AOSCX` | Experimental | -| Juniper JunOS | `Platform.JUNIPER_JUNOS` | Experimental | -| Nokia SRL | `Platform.NOKIA_SRL` | Experimental | -| VyOS | `Platform.VYOS` | Experimental | -| Generic | `Platform.GENERIC` | Base for custom drivers | - -To activate a driver, use the `get_hconfig_driver` utility provided by Hier Config: - -```python -from hier_config import get_hconfig_driver, Platform - -# Example: Activating the CISCO_IOS driver -driver = get_hconfig_driver(Platform.CISCO_IOS) -``` - -### Cisco IOS Driver - -Cisco IOS is hier_config's primary reference platform and the most thoroughly tested driver. The `CISCO_IOS` driver ships with a comprehensive set of rules covering common IOS configuration patterns: - -- **[Idempotent commands](glossary.md#idempotent-command)**: `hostname`, `ip address`, `ip access-group`, `description`, `banner`, and many others are treated as last-write-wins — applying the same command twice leaves only the final value in place. -- **Negation**: standard `no ` [negation prefix](glossary.md#negation-prefix). Several commands (such as `logging console`) use [`NegationDefaultWithRule`](glossary.md#negation-negate-with) overrides to emit a specific reset form. -- **[Sectional exiting](glossary.md#sectional-exiting)**: BGP `peer-policy` and `peer-session` blocks require `exit-peer-policy` and `exit-peer-session` closure tokens. -- **Per-line substitutions**: strips `Building configuration…` banners and timestamp headers. -- **VLAN id list splitting**: IOS can render unnamed VLANs collapsed onto a single comma/range line (e.g. `vlan 69,381`, `vlan 10-12`), depending on how the VLANs were created — named VLANs always get their own block, and the grouping shifts as VLANs are named or unnamed. When such a collapsed line is present, a post-load callback splits it into one `vlan ` block each so the VLANs diff block-to-block against an intended config that lists them separately — avoiding a destructive `no vlan 69,381`. - -Platform enum: `Platform.CISCO_IOS` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.CISCO_IOS) -``` - ---- - -### Arista EOS Driver - -Arista EOS uses a Cisco IOS-like hierarchical CLI, so the `ARISTA_EOS` driver closely mirrors `CISCO_IOS`: - -- BGP peer-policy and peer-session blocks require `exit-peer-policy` and `exit-peer-session` closure tokens (same as IOS). -- Broad idempotency rules cover the most common EOS configuration patterns. -- [Negation prefix](glossary.md#negation-prefix): `no ` (default). - -Platform enum: `Platform.ARISTA_EOS` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.ARISTA_EOS) -``` - ---- - -### Aruba AOS-CX Driver - -Aruba AOS-CX uses a Cisco IOS/EOS-like hierarchical CLI with `no ` as the [negation prefix](glossary.md#negation-prefix), so the `ARUBA_AOSCX` driver reuses the standard IOS/EOS tree model and remediation. The one platform-specific behavior is how trunk VLAN membership is modeled: - -- `vlan trunk allowed` is *additive* on AOS-CX rather than declarative. The driver splits comma/range VLAN lists into one command per VLAN (on load and in the intended config), so remediation adds a missing VLAN with `vlan trunk allowed ` and removes an extra one with `no vlan trunk allowed `, rather than rewriting the whole list. -- Unnamed collapsed VLAN headers such as `vlan 1,10` or `vlan 100-102` are likewise split into individual `vlan ` sections. -- Structured sections such as `evpn` and `interface vxlan` are remediated like any other section: individual members (for example an EVPN `vlan`) are added or negated, while unchanged siblings such as `arp-suppression` are left untouched. As with Arista/Cisco, the intended config should list the members that must remain. -- Common one-value commands such as interface `description`, `ip address`, `vlan access`, `vlan trunk native`, and `vrf attach` are treated as idempotent replacements. -- BGP address-family blocks close with `exit-address-family`. -- Per-line substitutions strip comment lines and rendered `exit`/`end` markers during parsing. - -**Known limitation**: because trunk VLAN lists are modeled one VLAN per line, a very wide range (for example `vlan trunk allowed 1-4094`) expands to one command per VLAN internally, so remediation that creates such a trunk from scratch renders many lines instead of the single range the operator wrote. Only the *delta* is emitted for an existing trunk, so day-to-day changes stay minimal; the expansion only shows up when adding a wide range wholesale. - -Platform enum: `Platform.ARUBA_AOSCX` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.ARUBA_AOSCX) -``` - ---- - -### Cisco IOS XR Driver - -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)**: `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` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.CISCO_XR) -``` - ---- - -### Cisco NX-OS Driver - -Cisco NX-OS is similar to IOS in CLI structure but has NX-OS-specific idempotency requirements: - -- **TCAM region idempotency**: `hardware access-list tcam region` commands are treated as last-write-wins. -- Some BGP commands use different negation forms; the driver includes `NegationDefaultWithRule` entries for affected commands. -- [Negation prefix](glossary.md#negation-prefix): `no ` (default). - -Platform enum: `Platform.CISCO_NXOS` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.CISCO_NXOS) -``` - ---- - -### VyOS Driver - -VyOS uses `set` and `delete` command syntax rather than the `no`-prefix convention. - -> **Experimental:** VyOS support has not been tested extensively in production environments. Use with caution. - -- **[Declaration prefix](glossary.md#declaration-prefix)**: `set ` (prepended to each positive command). -- **[Negation prefix](glossary.md#negation-prefix)**: `delete ` (replaces `no `). - -Platform enum: `Platform.VYOS` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.VYOS) -``` - ---- - -### Nokia SRL (Service Router Linux) Driver - -Nokia SR Linux uses `set` and `delete` command syntax, similar to VyOS and JunOS. The driver converts hierarchical SRL configuration (from `info` output) into flat `set`/`delete` commands via a preprocessor. - -> **Experimental:** Nokia SRL support has not been tested extensively in production environments. Use with caution. - -- **[Declaration prefix](glossary.md#declaration-prefix)**: `set ` (prepended to each positive command). -- **[Negation prefix](glossary.md#negation-prefix)**: `delete ` (replaces `no `). - -Platform enum: `Platform.NOKIA_SRL` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.NOKIA_SRL) -``` - -**Remediation example:** - -```python -from hier_config import WorkflowRemediation, get_hconfig, Platform - -running = get_hconfig(Platform.NOKIA_SRL, running_text) -intended = get_hconfig(Platform.NOKIA_SRL, intended_text) -workflow = WorkflowRemediation(running, intended) - -for line in workflow.remediation_config.all_children_sorted(): - print(line.cisco_style_text()) -``` - ---- - -### Generic Driver - -The `GENERIC` driver contains no platform-specific rules. It is useful as a starting point for custom drivers or for platforms that follow standard Cisco-style syntax with few special cases. - -Platform enum: `Platform.GENERIC` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.GENERIC) -``` - -See [Creating a Custom Driver](custom-drivers.md#creating-a-custom-driver) for how to build on top of the generic driver. - ---- - -### Juniper JunOS Driver - -Juniper JunOS uses `set` and `delete` command syntax for its hierarchical configuration. - -> **Experimental:** JunOS support has not been tested extensively in production environments. Use with caution. - -- **[Declaration prefix](glossary.md#declaration-prefix)**: `set ` (prepended to each positive command). -- **[Negation prefix](glossary.md#negation-prefix)**: `delete ` (replaces `no `). - -For a worked example see [JunOS Style Syntax Remediation](junos-style-syntax-remediation.md). - -Platform enum: `Platform.JUNIPER_JUNOS` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.JUNIPER_JUNOS) -``` - ---- - -### HP ProCurve (Aruba AOSS) Driver - -HP ProCurve switches (sold as Aruba switches after the HP/Aruba merger) use a Cisco-style hierarchical CLI with `no` as the negation prefix. The `HP_PROCURVE` driver adds several post-load normalisation callbacks that simplify diffing: - -- **VLAN membership** — moves `untagged`/`tagged` directives out of `vlan ` blocks and into per-interface blocks, matching the mental model that operators typically use when writing intended configs. -- **Port-access range expansion** — expands compact port ranges like `aaa port-access authenticator 1/15-1/20,1/26-1/40` into individual interface lines so that hier_config can apply idempotency rules per port. -- **Device-profile tagged-VLAN splitting** — splits comma-separated VLAN lists in `device-profile` blocks into one command per VLAN. - -The driver also extends idempotency and negation-with logic to handle ProCurve-specific command patterns such as `aaa port-access`, `radius-server`, and `tacacs-server` with variable-length key fields. - -Platform enum: `Platform.HP_PROCURVE` - -Activate the driver: - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.HP_PROCURVE) -``` - -**Remediation example:** - -```python -from hier_config import WorkflowRemediation, get_hconfig, Platform - -running = get_hconfig(Platform.HP_PROCURVE, running_text) -intended = get_hconfig(Platform.HP_PROCURVE, intended_text) -workflow = WorkflowRemediation(running, intended) - -for line in workflow.remediation_config.all_children_sorted(): - print(line.cisco_style_text()) -``` - ---- - -### HP Comware5 Driver - -HP Comware5 (and the compatible H3C platform) uses `undo` as the negation prefix rather than `no`. The `HP_COMWARE5` driver overrides `negation_prefix` accordingly. No additional platform-specific rules are configured by default; extend the driver if your environment requires them (see [Customising Existing Drivers](custom-drivers.md#customizing-existing-drivers)). - -Platform enum: `Platform.HP_COMWARE5` - -Activate the driver: - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.HP_COMWARE5) -``` - -**Remediation example:** - -```python -from hier_config import WorkflowRemediation, get_hconfig, Platform - -running = get_hconfig(Platform.HP_COMWARE5, running_text) -intended = get_hconfig(Platform.HP_COMWARE5, intended_text) -workflow = WorkflowRemediation(running, intended) - -for line in workflow.remediation_config.all_children_sorted(): - print(line.cisco_style_text()) -``` - ---- - -### Huawei VRP Driver - -Huawei VRP (Versatile Routing Platform) uses `undo` as the negation prefix rather than `no`. The `HUAWEI_VRP` driver customises negation handling for several command families: - -- **[Negation prefix](glossary.md#negation-prefix)**: `undo ` (replaces `no `). -- **Smart negation**: `description` and `alias` commands are negated without their argument; `remark` commands strip the remark text; `snmp-agent community` commands truncate to the community name. -- **Sectional exiting**: section exit text `exit` is translated to `quit` as VRP requires. -- **Per-line substitutions**: strips `#` and `!` comment lines during parsing. - -Platform enum: `Platform.HUAWEI_VRP` - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.HUAWEI_VRP) -``` - -**Remediation example:** - -```python -from hier_config import WorkflowRemediation, get_hconfig, Platform - -running = get_hconfig(Platform.HUAWEI_VRP, running_text) -intended = get_hconfig(Platform.HUAWEI_VRP, intended_text) -workflow = WorkflowRemediation(running, intended) - -for line in workflow.remediation_config.all_children_sorted(): - print(line.cisco_style_text()) -``` - ---- - -### Fortinet FortiOS Driver - -Fortinet firewalls model their CLI around `config` and `edit` blocks that are -terminated with `next` and `end`. The `FORTINET_FORTIOS` driver captures those -patterns and makes sure remediation output keeps the indentation and closure -FortiOS expects. Highlights include: - -- Preserves the `set`/`unset` pairing by swapping declarations and negations - automatically when hier_config determines a change is required. -- Treats sibling `config` blocks as duplicates when appropriate so that - multiple objects such as policies or firewall addresses can be compared in - a stable order. -- Normalizes bare `next` and `end` tokens into indented versions to match the - format FortiOS emits on the device. -- Overrides idempotency matching to require that the same object name exists on - both sides before a command is considered already present. - -Activate the driver with the standard helper: - -```python -from hier_config import Platform, get_hconfig_driver - -driver = get_hconfig_driver(Platform.FORTINET_FORTIOS) -``` - - ---- - -To learn how these drivers are built, how to customize them, or how to create your own, see [Customizing and Creating Drivers](custom-drivers.md). diff --git a/docs/user/future-config.md b/docs/user/future-config.md index 5cea4b85..9b494fef 100644 --- a/docs/user/future-config.md +++ b/docs/user/future-config.md @@ -1,76 +1,111 @@ -# Future Config +# Predicting Future Configs -The Future Config feature, introduced in version 2.2.0, attempts to predict the state of the running configuration after a change is applied. +This page covers `HConfig.future()`, which predicts what the running configuration will look like after a change is applied. Use it to validate deployments, feed post-change state to analysis tools, or build rollbacks for chained changes. -This feature is useful in scenarios where you need to determine the anticipated configuration state following a change, such as: +Typical scenarios: -- Verifying that a configuration change was successfully applied to a device - - For example, checking if the post-change configuration matches the predicted future configuration -- Generating a future-state configuration that can be analyzed by tools like Batfish to assess the potential impact of a change -- Building rollback configurations: once the future configuration state is known, a rollback configuration can be generated by simply creating the remediation in reverse `(rollback = future.config_to_get_to(running))`. - - When building rollbacks for a series of configuration changes, you can use the future configuration from each change as input for the subsequent change. For example, use the future configuration after Change 1 as the input for determining the future configuration after Change 2, and so on. +- Verifying that a configuration change was successfully applied to a device — for example, checking whether the post-change configuration matches the predicted future configuration. +- Generating a future-state configuration that can be analyzed by tools like Batfish to assess the potential impact of a change. +- Building rollback configurations: once the future configuration state is known, a rollback configuration can be generated by simply creating the remediation in reverse (`rollback = future.remediation(running)`). + When building rollbacks for a series of configuration changes, use the future configuration from each change as the input for the next: + +```python +post_change_1_config = running_config.future(change_1_config) +change_1_rollback_config = post_change_1_config.remediation(running_config) +post_change_2_config = post_change_1_config.future(change_2_config) +change_2_rollback_config = post_change_2_config.remediation(post_change_1_config) +``` ## `merge()` versus `future()` `HConfig.merge()` and `HConfig.future()` both combine configuration data, but they serve different purposes: -- `merge()` expects non-overlapping sections. If the same parent/child path already exists, Hier Config raises `hier_config.exceptions.DuplicateChildError` to highlight the conflict. This behavior is helpful when you are stitching together independent remediation fragments (AAA, logging, SNMP, and so on) and need deterministic, fail-fast validation. +- `merge()` expects non-overlapping sections. If the same parent/child path already exists, hier_config raises `hier_config.exceptions.DuplicateChildError` to highlight the conflict. This behavior is helpful when you are stitching together independent remediation fragments (AAA, logging, SNMP, and so on) and need deterministic, fail-fast validation. - `future()` mirrors how a device processes configuration input. Shared sections are merged recursively, later commands overwrite earlier leaf nodes, and the result reflects the post-change running configuration. Use `future()` when you want to preview layered changes or intentionally override existing settings. If you receive a `DuplicateChildError` while calling `merge()`, consider whether your use case aligns better with `future()`. +## How negations are resolved + +`future()` resolves negation lines the way devices do: + +- **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. 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 -post_change_1_config = running_config.future(change_1_config) -change_1_rollback_config = post_change_1_config.config_to_get_to(running_config) -post_change_2_config = post_change_1_config.future(change_2_config) -change_2_rollback_config = post_change_2_config.config_to_get_to(post_change_1_config) +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 + +Many devices remove a section that no longer has any content when the change is committed. Pass `prune_empty_branches=True` to model that behavior: + +```python +future = running_config.future(change_config, prune_empty_branches=True) ``` +Only sections that the change *emptied out* are removed; sections that were already empty in the running config (or newly added empty) are kept. The pruning cascades upward, so a parent whose last child section was emptied is removed too. -## Known Limitations +## Known limitations -The `future()` algorithm is a best-effort simulation. The following cases are not yet handled: +The `future()` algorithm is a best-effort simulation. The following cases are not yet handled: 1. **Negating a numbered ACL when removing a single entry.** Removing one line from a numbered ACL (e.g. `no access-list 10 deny 10.0.0.0 0.255.255.255`) requires the OS to track sequence numbers, which hier_config does not currently model. - *Workaround:* Use extended named ACLs with sequence numbers so that hier_config can + *Workaround:* use extended named ACLs with sequence numbers so that hier_config can treat each entry independently via the ACL sequence-number stripping logic. 2. **Sectional exiting.** - The `future()` algorithm does not emit [sectional-exit](glossary.md#sectional-exiting) tokens (e.g. `exit-peer-policy`) - when reconstructing the predicted configuration. The output is structurally correct but + The `future()` algorithm does not emit [sectional-exit](../glossary.md#sectional-exiting) tokens (e.g. `exit-peer-policy`) + when reconstructing the predicted configuration. The output is structurally correct but may be missing closure tokens if you render it verbatim. -3. **Negate-with rules.** - When a command has a custom [negation string](glossary.md#negation-negate-with) defined by the driver (e.g. - `NegationDefaultWithRule`), the `future()` algorithm may not apply that string when +3. **Negation replacement rules.** + When a command has a custom [negation string](../glossary.md#negation-rule) defined by the driver (e.g. + a REPLACE-strategy `NegationRule`), `future()` may not apply that string when processing removal of that command. 4. **Idempotent command avoid list.** - Commands matched by [`IdempotentCommandsAvoidRule`](glossary.md#idempotent-command-avoid-list) should be excluded from idempotency + Commands matched by [`IdempotentCommandsAvoidRule`](../glossary.md#idempotent-command-avoid-list) should be excluded from idempotency comparisons, but `future()` does not currently consult this list. 5. **ACL idempotency check.** The extended ACL idempotency logic (sequence-number based matching) that drives - `config_to_get_to()` is not replicated in `future()`. + `remediation()` is not replicated in `future()`. 6. **And likely others.** Complex platform-specific interactions (e.g. VRF-aware BGP sections, route-policy inline templates on IOS XR) may produce imperfect results. > **Tip:** If you discover a gap, please open an issue and include a minimal reproducible -> example (running config + change config + expected future config). Contributions that -> extend `_future()` to cover new cases are welcome. +> example (running config + change config + expected future config). Contributions that +> extend the future-config algorithm to cover new cases are welcome. -## Structural Idempotency Matching +## Structural idempotency matching -Starting in version 3.4.0, Hier Config derives a structural "idempotency key" from -each command’s lineage when evaluating [`IdempotentCommandsRule`](glossary.md#idempotent-command). +hier_config derives a structural "idempotency key" from +each command's lineage when evaluating [`IdempotentCommandsRule`](../glossary.md#idempotent-command). This prevents unrelated lines that happen to share a prefix from being treated as duplicates during `future()` predictions. For example, distinct BGP neighbor descriptions such as `neighbor 2.2.2.2 description neighbor2` and -`neighbor 3.3.3.3 description neighbor3` now remain in the predicted future +`neighbor 3.3.3.3 description neighbor3` remain in the predicted future configuration because their identities differ within the `neighbor` hierarchy. > **Tip:** When creating driver rules, ensure your `MatchRule` definitions capture @@ -79,67 +114,22 @@ configuration because their identities differ within the `neighbor` hierarchy. > idempotency engine to distinguish between commands that only vary by attributes > such as IP address or description text. -```bash ->>> from hier_config import get_hconfig, Platform +## Worked example + +```python +>>> from hier_config import HConfig, Platform >>> from hier_config.utils import read_text_from_file >>> - >>> running_config_text = read_text_from_file("./tests/fixtures/running_config.conf") ->>> generated_config_text = read_text_from_file("./tests/fixtures/remediation_config_without_tags.conf") +>>> remediation_config_text = read_text_from_file("./tests/fixtures/remediation_config_without_tags.conf") >>> ->>> running_config = get_hconfig(Platform.CISCO_IOS, running_config_text) ->>> remediation_config = get_hconfig(Platform.CISCO_IOS, remediation_config_text) ->>> ->>> print("Running Config") -Running Config ->>> for line in running_config.all_children(): -... print(line.cisco_style_text()) -... -hostname aggr-example.rtr -ip access-list extended TEST - 10 permit ip 10.0.0.0 0.0.0.7 any -vlan 2 - name switch_mgmt_10.0.2.0/24 -vlan 3 - name switch_mgmt_10.0.4.0/24 -interface Vlan2 - descripton switch_10.0.2.0/24 - ip address 10.0.2.1 255.255.255.0 - shutdown -interface Vlan3 - mtu 9000 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown ->>> ->>> print("Remediation Config") -Remediation Config ->>> for line in remediation_config.all_children(): -... print(line.cisco_style_text()) -... -vlan 3 - name switch_mgmt_10.0.3.0/24 -vlan 4 - name switch_mgmt_10.0.4.0/24 -interface Vlan2 - mtu 9000 - ip access-group TEST in - no shutdown -interface Vlan3 - description switch_mgmt_10.0.3.0/24 - ip address 10.0.3.1 255.255.0.0 -interface Vlan4 - mtu 9000 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown +>>> running_config = HConfig.from_text(Platform.CISCO_IOS, running_config_text) +>>> remediation_config = HConfig.from_text(Platform.CISCO_IOS, remediation_config_text) >>> >>> print("Future Config") Future Config >>> for line in running_config.future(remediation_config).all_children(): -... print(line.cisco_style_text()) +... print(line.indented_text()) ... vlan 3 name switch_mgmt_10.0.3.0/24 @@ -170,8 +160,7 @@ vlan 2 >>> ``` ---- - -## Unified Diff +## Next steps -`HConfig.unified_diff()` provides output similar to `difflib.unified_diff()` but with added awareness of out-of-order lines and parent-child relationships in the hier_config tree model. It shares the same limitations as `future()` regarding duplicate children and order-dependent sections. See [Unified Diff](unified-diff.md) for the full walkthrough. +- [Unified Diffs](unified-diff.md) — compare two configurations without generating remediation. +- [Remediation Workflows](remediation-workflows.md) — turn diffs into deployable changes. diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index 70443e8b..d38245db 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -1,54 +1,55 @@ -# Getting Started with hier_config +# Getting Started -Hier Config is a Python library that assists with remediating network configurations by comparing a device's current configuration (running config) with its intended configuration (generated config). Hier Config v3 processes configuration data without connecting to devices, enabling configuration analysis and remediation. +This page walks you through your first remediation: loading a running and an intended configuration, computing the commands that close the gap, and generating a rollback. It is the best starting point if you are new to hier_config. -## Step 1: Import Required Classes +hier_config compares a device's current configuration (the *running config*) with its intended configuration (the *generated config*) and produces the minimal set of commands needed to bring the device into compliance. Everything happens offline — no device connection is required. -To use `WorkflowRemediation`, you’ll import it along with `get_hconfig` (for generating configuration objects) and `Platform` (for specifying the operating system driver). +## Step 1: Import the required classes + +You need `HConfig` (the configuration tree), `Platform` (the operating-system selector), and `WorkflowRemediation` (the comparison workflow): ```python ->>> from hier_config import WorkflowRemediation, get_hconfig, Platform +>>> from hier_config import WorkflowRemediation, HConfig, Platform >>> from hier_config.utils import read_text_from_file >>> ``` -With these imports, you can create HConfig objects and compare them. - -## Step 2: Creating HConfig Objects for Configurations +## Step 2: Create HConfig objects for both configurations -Use `get_hconfig` to create HConfig objects for both the running and intended configurations. Specify the platform with `Platform.CISCO_IOS`, `Platform.CISCO_NXOS`, etc., based on the device type. +Use `HConfig.from_text()` to parse each configuration. The first argument selects the platform driver (`Platform.CISCO_IOS`, `Platform.ARISTA_EOS`, and so on — see [Supported Platforms](../admin/platforms.md)): ```python -# Define running and intended configurations as strings +# Load running and intended configurations from files >>> running_config_text = read_text_from_file("./tests/fixtures/running_config.conf") ->>> generated_config_text = read_text_from_file("./tests/fixtures/remediation_config.conf") +>>> generated_config_text = read_text_from_file("./tests/fixtures/generated_config.conf") >>> # Create HConfig objects for running and intended configurations ->>> running_config = get_hconfig(Platform.CISCO_IOS, running_config_text) ->>> generated_config = get_hconfig(Platform.CISCO_IOS, generated_config_text) +>>> running_config = HConfig.from_text(Platform.CISCO_IOS, running_config_text) +>>> generated_config = HConfig.from_text(Platform.CISCO_IOS, generated_config_text) >>> ``` -## Step 3: Initializing WorkflowRemediation and Generating Remediation +`from_text()` also accepts a `pathlib.Path` directly, and there are additional constructors for pre-split lines, JSON, and XML — see [Loading Configurations](loading-configs.md). + +## Step 3: Initialize WorkflowRemediation -With the HConfig objects created, initialize `WorkflowRemediation` to calculate the required remediation steps. +With both trees built, initialize `WorkflowRemediation` to compute the required changes: ```python -# Initialize WorkflowRemediation with the running and intended configurations >>> workflow = WorkflowRemediation(running_config, generated_config) >>> ``` -## Generating the Remediation Configuration +## Generating the remediation configuration -The `remediation_config` attribute generates the configuration needed to apply the intended changes to the device. Use `all_children_sorted()` to display the configuration in a readable format: +The `remediation_config` property holds the commands that transform the running configuration into the intended one. Use `all_children_sorted()` with `indented_text()` to render it: ```python >>> print("Remediation configuration:") Remediation configuration: >>> for line in workflow.remediation_config.all_children_sorted(): -... print(line.cisco_style_text()) +... print(line.indented_text()) ... vlan 3 name switch_mgmt_10.0.3.0/24 @@ -70,16 +71,16 @@ interface Vlan4 >>> ``` -## Generating the Rollback Configuration +## Generating the rollback configuration -Similarly, the `rollback_config` attribute generates a configuration that can revert the changes, restoring the device to its original state. +The `rollback_config` property generates the inverse change — the commands that revert the device to its original state after the remediation has been applied: ```python # Generate and display the rollback configuration >>> print("Rollback configuration:") Rollback configuration: >>> for line in workflow.rollback_config.all_children_sorted(): -... print(line.cisco_style_text()) +... print(line.indented_text()) ... no vlan 4 no interface Vlan4 @@ -93,4 +94,11 @@ interface Vlan3 description switch_mgmt_10.0.4.0/24 ip address 10.0.4.1 255.255.0.0 >>> -``` \ No newline at end of file +``` + +## Next steps + +- [Loading Configurations](loading-configs.md) — every way to build an `HConfig`, including JSON and XML. +- [Remediation Workflows](remediation-workflows.md) — customize the remediation with transforms and plugins. +- [Working with Tags](tags.md) — deploy only a subset of the remediation. +- [Predicting Future Configs](future-config.md) — preview the post-change configuration. diff --git a/docs/user/glossary.md b/docs/user/glossary.md deleted file mode 100644 index 7981d9cd..00000000 --- a/docs/user/glossary.md +++ /dev/null @@ -1,147 +0,0 @@ -# Glossary - -This page defines hier_config-specific terminology used throughout the documentation and source code. - ---- - -## Declaration prefix - -The string that precedes a *positive* (enabling) command in platforms that use explicit declarations, such as JunOS (`set`) and VyOS (`set`). The driver's `declaration_prefix` property returns this string; `HConfigDriverBase` defaults to an empty string (Cisco-style platforms have no explicit declaration keyword). - -**Example:** In JunOS, `set interfaces ge-0/0/0 description uplink` — the declaration prefix is `"set "`. - ---- - -## Driver / HConfigDriverBase - -A Python class that encodes all operating-system-specific behaviour for one network platform. Every driver subclasses `HConfigDriverBase` and provides an `HConfigDriverRules` instance via `_instantiate_rules()`. Drivers are selected by passing a `Platform` enum value to `get_hconfig()` or `get_hconfig_driver()`. - -**Example:** `HConfigDriverCiscoIOS`, `HConfigDriverJuniperJUNOS`. - ---- - -## Idempotent command - -A configuration command where only the *last* value applied takes effect — applying the same command twice with different values results in only the second value being active. Typical examples: `hostname`, `ip address`, `description`. - -hier_config uses `IdempotentCommandsRule` to identify these commands. During `config_to_get_to()`, when both the running and intended configs contain a command that matches an idempotency rule, the running value is **not** negated before the new value is applied (the new value simply overwrites it). - -**Example rule:** - -```python -IdempotentCommandsRule( - match_rules=( - MatchRule(startswith="interface "), - MatchRule(startswith="description "), - ) -) -``` - ---- - -## Idempotent command avoid list - -A set of `IdempotentCommandsAvoidRule` entries that *prevent* specific commands from being treated as idempotent even if they would otherwise match an `IdempotentCommandsRule`. Useful for commands like secondary IP addresses, where applying the same `startswith` prefix would incorrectly deduplicate distinct entries. - -**Example:** Avoiding idempotency for `ip address ... secondary` on Cisco NX-OS. - ---- - -## Indent adjust - -A pair of `IndentAdjustRule` entries (`start_expression` / `end_expression`) that temporarily shift the indentation level between the two markers. Used on Cisco IOS XR for inline templates whose body is indented differently from the surrounding context. - ---- - -## Match rule - -A `MatchRule` Pydantic model that acts as a predicate on an `HConfigChild.text` value; match rules are composed into tuples to describe a full lineage path. See [MatchRules](tags.md#matchrules) for the full reference and examples. - ---- - -## Negation prefix - -The string prepended to a command to negate (remove) it. `HConfigDriverBase.negation_prefix` defaults to `"no "` for Cisco-style platforms. Platforms that use a different convention override this property. - -| Platform | Negation prefix | -|----------|----------------| -| Cisco IOS / EOS / NX-OS | `"no "` | -| HP Comware5 / H3C | `"undo "` | -| JunOS / VyOS / Nokia SRL | `"delete "` | - ---- - -## Negation — default when - -A `NegationDefaultWhenRule` that causes hier_config to use the `default ` form of negation instead of `no `. Some IOS and EOS commands behave differently when defaulted vs negated (e.g. `logging event link-status`). - ---- - -## Negation — negate with - -A `NegationDefaultWithRule` that replaces the standard negation with a fixed command string. Used when a command cannot be simply prepended with `"no "` — for example, `logging console debugging` is the correct way to reset the console logging level rather than `no logging console`. - ---- - -## Parent allows duplicate child - -A `ParentAllowsDuplicateChildRule` that permits multiple `HConfigChild` objects with the same `text` value under a single parent. Required for constructs such as `address-family` blocks inside `router bgp` on some platforms, or `endif` tokens in IOS XR route-policies. - ---- - -## Per-line sub / full-text sub - -Regex substitution rules applied to configuration text at load time before it is parsed into the tree: - -- `PerLineSubRule` — applies the substitution to each line individually (useful for removing inline comments, `!`, timestamp headers). -- `FullTextSubRule` — applies the substitution across the entire text block (useful for multi-line patterns). - -**Example:** Strip `Building configuration...` banners: - -```python -PerLineSubRule(search="^Building configuration.*", replace="") -``` - ---- - -## Sectional exiting - -A `SectionalExitingRule` that instructs hier_config to emit a closing token at the end of a configuration section when rendering output. Different platforms require different exit syntax. - -| Platform / section | Exit token | -|-------------------|-----------| -| Cisco IOS BGP peer-policy | `exit-peer-policy` | -| Cisco IOS XR route-policy | `end-policy` | -| Cisco IOS XR prefix-set | `end-set` | -| Most sections (default) | `exit` | - ---- - -## Sectional overwrite - -A `SectionalOverwriteRule` that tells `config_to_get_to()` to **negate the entire section** and then re-create it from the intended config rather than performing a line-by-line diff. Appropriate for configuration blocks where the order of entries matters globally or where partial changes are not supported by the OS. - ---- - -## Sectional overwrite no negate - -A `SectionalOverwriteNoNegateRule` similar to sectional overwrite, but the existing section is **deleted without negation** before the new version is written. Used for blocks like `prefix-set` and `route-policy` on Cisco IOS XR where issuing a `no` is not the correct removal mechanism. - ---- - -## Tag rules - -`TagRule` entries that apply a named tag (`apply_tags`) to all `HConfigChild` nodes whose lineage matches `match_rules`. Tags are used by `WorkflowRemediation.apply_remediation_tag_rules()` to annotate the remediation config for selective filtering via `remediation_config_filtered_text()`. - -**Example use case:** Tag all interface changes as `"interfaces"` and all BGP changes as `"bgp"` so that changes can be deployed separately. - ---- - -## WorkflowRemediation - -The primary user-facing class for computing the delta between a running and an intended configuration. Exposes: - -- `remediation_config` — the commands to apply to bring the device into compliance. -- `rollback_config` — the commands to revert the device back to its original state. -- `apply_remediation_tag_rules()` — annotate remediation lines with tags. -- `remediation_config_filtered_text()` — render tagged subset of the remediation. diff --git a/docs/user/install.md b/docs/user/install.md index 235084c8..69713f2b 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -1,13 +1,36 @@ -# Install hier_config +# Installation -> Hierarchical Configuration requires a minimum Python version of 3.10. +This page covers installing hier_config from PyPI or from source. It applies to anyone using the library. -Hierarchical Configuration can be installed directly from GitHub or with pip: +> hier_config requires Python 3.10 or later. -## Pip -1. Install from PyPI: `pip install hier-config` +## Install from PyPI + +```bash +pip install hier-config +``` + +### Installing a prerelease (v4) + +Version 4 is currently published as a prerelease. Pip skips prereleases by default, so pass `--pre` to install it: + +```bash +pip install --pre hier-config +``` + +Or pin an exact version (see the [release history on PyPI](https://pypi.org/project/hier-config/#history) for the current prerelease): + +```bash +pip install hier-config== +``` + +## Install from source -## Github 1. [Install Poetry](https://python-poetry.org/docs/#installation) -2. Clone the Repository: `git clone git@github.com:netdevops/hier_config.git` -3. Install hier_config: `cd hier_config && poetry install` +2. Clone the repository: `git clone git@github.com:netdevops/hier_config.git` +3. Install the project: `cd hier_config && poetry install` + +## Next steps + +- [Getting Started](getting-started.md) — walk through your first remediation. +- [Loading Configurations](loading-configs.md) — all the ways to build an `HConfig` tree. diff --git a/docs/user/loading-configs.md b/docs/user/loading-configs.md new file mode 100644 index 00000000..1f082b34 --- /dev/null +++ b/docs/user/loading-configs.md @@ -0,0 +1,135 @@ +# Loading Configurations + +This page covers every way to build an `HConfig` tree: from raw text, from pre-split lines, from a serialized dump, and from structured JSON or XML documents. Read it when plain `HConfig.from_text()` is not enough — or when you want to round-trip configurations through serialization. + +## Choosing a platform + +Every constructor takes a platform (or driver) as its first argument. Three forms are accepted: + +```python +from hier_config import HConfig, Platform, get_hconfig_driver + +# 1. A Platform enum member +config = HConfig.from_text(Platform.CISCO_IOS, config_text) + +# 2. A platform name string (case-insensitive; includes custom registered platforms) +config = HConfig.from_text("cisco_ios", config_text) + +# 3. A driver instance (useful when you have customized the driver's rules) +driver = get_hconfig_driver(Platform.CISCO_IOS) +config = HConfig.from_text(driver, config_text) +``` + +See [Supported Platforms](../admin/platforms.md) for the full platform list and [Custom Drivers and Registration](../admin/custom-drivers.md) for registering your own platform names. + +## From raw text: `from_text()` + +The primary constructor. Accepts a string of configuration text or a `pathlib.Path` to a file: + +```python +from pathlib import Path + +from hier_config import HConfig, Platform + +# From a string +config = HConfig.from_text(Platform.CISCO_IOS, config_text) + +# From a file path +config = HConfig.from_text(Platform.CISCO_IOS, Path("running_config.conf")) +``` + +`from_text()` runs the full driver pipeline: full-text and per-line substitutions, the platform's `config_preprocessor` (e.g. JunOS curly-brace flattening), tree construction, and post-load callbacks. + +## From pre-split lines: `from_lines()` + +If your configuration is already split into lines — for example, streamed from an API — `from_lines()` skips the text-splitting step and loads faster: + +```python +config = HConfig.from_lines(Platform.CISCO_IOS, ["hostname router1", "interface Vlan2", " no shutdown"]) +``` + +It accepts a list of strings, a tuple of strings, or a single string. + +## Serialization round-trip: `dump()` and `from_dump()` + +`HConfig.dump()` serializes a tree — including per-line tags, comments, and `new_in_config` flags — into a `Dump` Pydantic model that can be stored as JSON and reconstructed later: + +```python +from hier_config.models import Dump + +# Serialize +dump = config.dump() +serialized = dump.model_dump_json() + +# ... store, transmit, etc. ... + +# Reconstruct +restored = HConfig.from_dump( + Platform.CISCO_IOS, + Dump.model_validate_json(serialized), +) +``` + +This is the recommended way to persist a tree with its metadata (e.g. an already-tagged remediation) between processes. + +## Structured formats: JSON and XML + +Structured configurations — OpenConfig-style JSON or NETCONF-style XML — can be mapped onto the same tree model, so they can be diffed and predicted exactly like CLI text. + +### `from_json()` / `to_json()` + +```python +config = HConfig.from_json(Platform.GENERIC, json_text_or_dict) +print(config.to_json(indent=2)) +``` + +Mapping rules: + +- object key + scalar → leaf `key ` +- object key + object → node `key` with the object's members as children +- object key + list of scalars → one leaf per item +- object key + list of objects → one node per entry, identified by a key leaf (see below) + +### `from_xml()` / `to_xml()` + +```python +config = HConfig.from_xml(Platform.GENERIC, xml_text) +print(config.to_xml()) +``` + +XML elements become nodes; attributes and text content become specially-encoded leaves. Treat the trees as opaque between `from_xml()` and `to_xml()` — the internal line encoding may change. + +### The `list_keys` concept + +Lists of objects (JSON) and repeated sibling elements (XML) need a member that identifies each entry — OpenConfig-style keyed lists. By default, hier_config looks for a member named `name` or `id`. If your data uses different key names, pass them via `list_keys`: + +```python +config = HConfig.from_json( + Platform.GENERIC, + data, + list_keys=("interface-name", "name", "id"), +) +``` + +Entries without any of the named keys raise `InvalidConfigError`. + +Both mappings are invertible via `to_json()` / `to_xml()`, with a few caveats (documented in `hier_config.formats`): a single-item scalar list renders back as a bare scalar, and empty lists are dropped. + +Remediation between two `from_xml()` trees can also be rendered as a NETCONF `edit-config` payload, and remediation between two `from_json()` trees as a gNMI-style JSON payload — see [Remediation Workflows](remediation-workflows.md#netconf-remediation-payloads) and [gNMI-style JSON payloads](remediation-workflows.md#gnmi-style-json-remediation-payloads). + +## Format detection errors + +`from_text()` guards against being fed structured data. If the text looks like XML or JSON, it raises `InvalidConfigError` and points you at the right constructor: + +```python +>>> HConfig.from_text(Platform.GENERIC, '{"interfaces": {}}') +Traceback (most recent call last): +... +hier_config.exceptions.InvalidConfigError: The config appears to be JSON. Use HConfig.from_xml() or HConfig.from_json() for structured formats, ... +``` + +## Next steps + +- [Remediation Workflows](remediation-workflows.md) — compute remediation between the trees you loaded. +- [Set-Style Platforms](set-style-platforms.md) — how JunOS/VyOS/Nokia SRL configs are preprocessed on load. +- [API Reference](../dev/api-reference.md) — full constructor signatures. diff --git a/docs/user/migrating-from-v3.md b/docs/user/migrating-from-v3.md new file mode 100644 index 00000000..421903ef --- /dev/null +++ b/docs/user/migrating-from-v3.md @@ -0,0 +1,190 @@ +# Migrating from v3 to v4 + +This page is for existing hier_config 3.x users upgrading to 4.x. Version 4 renames +most entry points, unifies the negation rule system, and removes the v2 +compatibility utilities — but the concepts (trees, drivers, remediation) are +unchanged, and most migrations are mechanical find-and-replace. + +If you are new to hier_config, skip this page and start with +[Getting Started](getting-started.md). + +## Quick reference + +### Constructors + +| v3 | v4 | +|---|---| +| `get_hconfig(platform, text)` | `HConfig.from_text(platform, text)` | +| `get_hconfig_fast_load(platform, lines)` | `HConfig.from_lines(platform, lines)` | +| `get_hconfig_from_dump(platform, dump)` | `HConfig.from_dump(platform, dump)` | +| `get_hconfig_fast_generic_load(lines)` | `HConfig.from_lines(Platform.GENERIC, lines)` | +| `get_hconfig_driver(platform)` | unchanged (now also accepts platform name strings) | +| `get_hconfig_view(config)` | unchanged (now resolved from the driver's `view_class`) | + +```python +# v3 +from hier_config import get_hconfig +config = get_hconfig(Platform.CISCO_IOS, config_text) + +# v4 +from hier_config import HConfig +config = HConfig.from_text(Platform.CISCO_IOS, config_text) +``` + +### Methods and properties + +| v3 | v4 | +|---|---| +| `config.config_to_get_to(target)` | `config.remediation(target)` | +| `config.dump_simple()` | `config.to_lines()` | +| `child.cisco_style_text()` | `child.indented_text()` | +| `child.tags_add(...)` / `child.tags_remove(...)` | `child.add_tags(...)` / `child.remove_tags(...)` | +| `child.depth()` (method) | `child.depth` (property) | + +### Utility functions + +| v3 | v4 | +|---|---| +| `load_hconfig_v2_options(options, platform)` | `load_driver_rules(options, platform)` | +| `load_hconfig_v2_tags(tags)` | `load_tag_rules(tags)` | +| `load_hconfig_v2_options_from_file(path, platform)` | removed — read the file yourself and call `load_driver_rules()` | +| `HCONFIG_PLATFORM_V2_TO_V3_MAPPING` | removed | +| `hconfig_v2_os_v3_platform_mapper(os)` | removed | +| `hconfig_v3_platform_v2_os_mapper(platform)` | removed | + +The dict format accepted by `load_driver_rules()` is unchanged, including the +`negation_negate_with`, `negation_default_when`, and `negation_sub` keys — they +are mapped onto the unified rule model for you. + +## Negation rules + +The three v3 negation rule models collapse into a single `NegationRule` with a +strategy enum, held in one ordered `negation` list on `HConfigDriverRules`: + +| v3 | v4 | +|---|---| +| `NegationDefaultWithRule(match_rules=..., use=...)` | `NegationRule(strategy=NegationStrategy.REPLACE, match_rules=..., use=...)` | +| `NegationDefaultWhenRule(match_rules=...)` | `NegationRule(strategy=NegationStrategy.DEFAULT, match_rules=...)` | +| `NegationSubRule(match_rules=..., search=..., replace=...)` | `NegationRule(strategy=NegationStrategy.REGEX_SUB, match_rules=..., search=..., replace=...)` | +| `rules.negate_with` / `rules.negation_default_when` / `rules.negation_sub` | `rules.negation` (single list) | + +```python +# v3 +driver.rules.negate_with.append( + NegationDefaultWithRule( + match_rules=(MatchRule(startswith="logging console "),), + use="logging console debugging", + ) +) + +# v4 +from hier_config.models import NegationRule, NegationStrategy + +driver.rules.negation.append( + NegationRule( + strategy=NegationStrategy.REPLACE, + match_rules=(MatchRule(startswith="logging console "),), + use="logging console debugging", + ) +) +``` + +`REPLACE` rules are consulted first (via `driver.negate_with()`, which +imperative driver overrides also hook into); remaining rules evaluate in list +order, first match wins. See the +[driver rule reference](../dev/rule-reference.md) for details. + +## Exceptions + +v3 raised generic `ValueError`/`TypeError` from constructors and workflows. v4 +raises typed exceptions under a common base — update any `except` clauses: + +| Condition | v4 exception | +|---|---| +| Unknown platform | `DriverNotFoundError` | +| Unparseable/rejected config input | `InvalidConfigError` | +| Running/generated driver mismatch | `IncompatibleDriverError` | +| Duplicate child where not allowed | `DuplicateChildError` (now under the base) | +| Any of the above | `HierConfigError` | + +```python +from hier_config import HierConfigError + +try: + config = HConfig.from_text(platform, config_text) +except HierConfigError as exc: + ... +``` + +## Config views + +`ConfigViewInterfaceBase` no longer declares every property abstract with +per-platform `NotImplementedError` stubs. Core properties (`name`, +`description`, `enabled`, `ipv4_interfaces`, ...) are always available; +optional capabilities live on mixins, and you check support with +`isinstance()` instead of catching `NotImplementedError`: + +```python +# v3 +try: + vlans = interface_view.tagged_vlans +except NotImplementedError: + vlans = () + +# v4 +from hier_config import InterfaceVlanViewMixin + +if isinstance(interface_view, InterfaceVlanViewMixin): + vlans = interface_view.tagged_vlans +``` + +See [Config Views](config-views.md) for the mixin catalog. + +## Behavior changes to review + +- **`future()` negation resolution** — negations that match an existing line + (exactly or by shorthand prefix) now remove it instead of surviving as a + literal `no ...` child; see + [Predicting Future Configs](future-config.md) for the resolution order and + the new `prune_empty_branches` option. +- **Structured input rejection** — `HConfig.from_text()` (and the string form + of `from_lines()`) raises `InvalidConfigError` when given XML or JSON + instead of silently building a garbage tree. Use `HConfig.from_xml()` / + `HConfig.from_json()` for those formats + ([Loading Configurations](loading-configs.md)). +- **Custom driver wiring** — subclassed drivers previously required a local + constructor function; v4 registers them with + [`register_driver()`](../admin/custom-drivers.md), which also makes them + work with every constructor and carries their config view via `view_class`. + +## New in v4 (worth adopting) + +Not required for migration, but these are the headline additions: + +- [Driver registry](../admin/custom-drivers.md) — `register_driver()`, + `unregister_driver()`, `get_registered_platforms()`; override built-ins or + add custom platforms by string name. +- [JSON and XML configs](loading-configs.md) — `HConfig.from_json()` / + `from_xml()` / `to_json()` / `to_xml()`, plus + [NETCONF remediation payloads](remediation-workflows.md). +- [Remediation plugins](remediation-workflows.md) — + `WorkflowRemediation(plugins=...)` and driver-level + `remediation_transform_callbacks`. +- [Root-level duplicate children](../dev/rule-reference.md) — a + `ParentAllowsDuplicateChildRule` with empty `match_rules` applies to the + root. +- `HConfigDriverBase` and `HConfigDriverRules` are public API for + [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 + +- [Getting Started](getting-started.md) — the v4 workflow end to end. +- [Customizing Driver Rules](../admin/customizing-rules.md) — if you carried + v3 driver customizations. +- Full change list: the Unreleased section of the + [CHANGELOG](https://github.com/netdevops/hier_config/blob/next/CHANGELOG.md) + (it becomes the 4.0.0 section at release). diff --git a/docs/user/remediation-reporting.md b/docs/user/remediation-reporting.md index dcfaa201..bcb5239e 100644 --- a/docs/user/remediation-reporting.md +++ b/docs/user/remediation-reporting.md @@ -1,35 +1,31 @@ # Remediation Reporting -hier_config provides powerful reporting capabilities for aggregating and analyzing remediation configurations from multiple network devices. This enables network engineers to understand the scope of changes across their infrastructure, prioritize work, and generate reports for change management. +This page covers `RemediationReporter`, which aggregates remediation configurations from many devices into a single report. Use it to understand the scope of changes across a fleet, prioritize work, and produce change-management artifacts. It assumes you can already generate a per-device remediation with [`WorkflowRemediation`](remediation-workflows.md). -## Overview - -The `RemediationReporter` class allows you to: +The `RemediationReporter` class lets you: - **Merge multiple device remediations** into a single hierarchical tree - **Track instances** of each configuration change across devices - **Generate statistics** about remediation scope and impact - **Filter by tags** to create category-specific reports -- **Export reports** in multiple formats (JSON, CSV, Markdown, Text) +- **Export reports** in multiple formats (JSON, CSV, Markdown, text) - **Query specific changes** to understand their impact -## Quick Start - -### Basic Usage +## Quick start ```python from hier_config import ( RemediationReporter, WorkflowRemediation, - get_hconfig, + HConfig, Platform, ) # Generate remediations for each device devices_remediations = [] for device in devices: - running = get_hconfig(Platform.CISCO_IOS, device.running_config) - generated = get_hconfig(Platform.CISCO_IOS, device.generated_config) + running = HConfig.from_text(Platform.CISCO_IOS, device.running_config) + generated = HConfig.from_text(Platform.CISCO_IOS, device.generated_config) wfr = WorkflowRemediation(running, generated) devices_remediations.append(wfr.remediation_config) @@ -45,9 +41,9 @@ print(f"Unique changes: {summary.total_unique_changes}") print(reporter.summary_text()) ``` -### Output Example +Example output: -``` +```text Remediation Summary ================================================== Total devices: 150 @@ -64,52 +60,40 @@ Top 10 Most Common Changes: ... ``` -## Creating a Reporter +## Creating a reporter -### Method 1: From Remediations (Recommended) +Three construction styles are available: ```python +# 1. From an iterable of remediations (recommended) reporter = RemediationReporter.from_remediations([ device1_remediation, device2_remediation, device3_remediation, ]) -``` - -### Method 2: Add Incrementally -```python +# 2. Incrementally reporter = RemediationReporter() - -# Add one at a time for device_remediation in device_remediations: reporter.add_remediation(device_remediation) +reporter.add_remediations(more_remediations) # or several at once -# Or add multiple at once -reporter.add_remediations(more_remediations) -``` - -### Method 3: From Pre-Merged Config - -```python -# If you already have a merged configuration -merged = get_hconfig(Platform.CISCO_IOS) +# 3. From a pre-merged HConfig +merged = HConfig.from_text(Platform.CISCO_IOS) merged.merge([device1, device2, device3]) - reporter = RemediationReporter.from_merged_config(merged) ``` -## Querying Changes +## Querying changes -### Count Devices Affected by a Change +### Count devices affected by a change ```python -# How many devices need this specific change? count = reporter.get_device_count("line vty 0 4") print(f"{count} devices need this change") ``` -### Get Detailed Information +### Get detailed information ```python detail = reporter.get_change_detail("ntp server 10.2.2.2") @@ -125,7 +109,7 @@ for instance in detail.instances: print(f"Device {instance.id}: {instance.tags}") ``` -### Find High-Impact Changes +### Filter by impact threshold ```python # Changes affecting at least 50 devices @@ -135,13 +119,10 @@ for change in high_impact: print(f"{change.text}: {len(change.instances)} devices") # Changes affecting between 1-5 devices -isolated = reporter.get_changes_by_threshold( - min_devices=1, - max_devices=5, -) +isolated = reporter.get_changes_by_threshold(min_devices=1, max_devices=5) ``` -### Get Top N Most Common Changes +### Get the top N most common changes ```python top_10 = reporter.get_top_changes(10) @@ -150,7 +131,7 @@ for child, count in top_10: print(f"{child.text}: {count} devices") ``` -### Pattern Matching +### Pattern matching ```python # Find all VLAN interface changes @@ -163,16 +144,15 @@ ntp_changes = reporter.get_changes_matching(r"ntp") acl_changes = reporter.get_changes_matching(r"access-list \d+ (permit|deny)") ``` -## Tag-Based Reporting +## Tag-based reporting -Tags allow you to categorize changes and generate filtered reports. +Tags categorize changes so you can generate filtered reports — by risk level, functional area, or deployment phase. The same [`TagRule`/`MatchRule`](tags.md) model used for remediation filtering applies here. -### Apply Tag Rules +### Apply tag rules ```python from hier_config import TagRule, MatchRule -# Define tag rules tag_rules = [ TagRule( match_rules=(MatchRule(startswith="ntp"),), @@ -192,20 +172,16 @@ tag_rules = [ ), ] -# Apply tags to the merged configuration reporter.apply_tag_rules(tag_rules) ``` -### Filter by Tags +### Filter by tags ```python -# Get only NTP changes +# Only NTP changes ntp_changes = reporter.get_all_changes(include_tags=["ntp"]) -# Get only security-related changes -security_changes = reporter.get_all_changes(include_tags=["security"]) - -# Get all changes except those tagged as "critical" +# All changes except those tagged as "critical" safe_changes = reporter.get_all_changes(exclude_tags=["critical"]) # Combine filters: security changes that are not critical @@ -215,42 +191,22 @@ moderate_security = reporter.get_all_changes( ) ``` -### Tag-Based Summary +### Tag-based summary ```python -# Get summary breakdown by tags tag_summary = reporter.summary_by_tags(["security", "ntp", "snmp"]) for tag, stats in tag_summary.items(): print(f"\n{tag.upper()} Changes:") print(f" Devices affected: {stats['device_count']}") print(f" Total changes: {stats['change_count']}") - print(f" Changes:") for change in stats['changes'][:5]: # Show first 5 print(f" - {change}") ``` -Output: -``` -SECURITY Changes: - Devices affected: 145 - Total changes: 23 - Changes: - - line vty 0 4 - - enable secret 5 ... - - username admin privilege 15 - -NTP Changes: - Devices affected: 132 - Total changes: 8 - Changes: - - ntp server 10.2.2.2 - - ntp authenticate -``` - -## Analysis and Statistics +## Analysis and statistics -### Summary Statistics +### Summary statistics ```python summary = reporter.summary() @@ -258,29 +214,23 @@ summary = reporter.summary() print(f"Total devices: {summary.total_devices}") print(f"Unique changes: {summary.total_unique_changes}") -# Top changes for line, count in summary.most_common_changes[:5]: print(f"{line}: {count} devices") -# Changes by tag for tag, count in summary.changes_by_tag.items(): print(f"{tag}: {count} changes") ``` -### Impact Distribution +### Impact distribution ```python -# Get distribution of changes by device impact -distribution = reporter.get_impact_distribution( - bins=[1, 10, 25, 50, 100] -) +distribution = reporter.get_impact_distribution(bins=[1, 10, 25, 50, 100]) for range_label, count in distribution.items(): print(f"{range_label} devices: {count} changes") ``` -Output: -``` +```text 1-10 devices: 15 changes 10-25 devices: 8 changes 25-50 devices: 5 changes @@ -288,7 +238,7 @@ Output: 100+ devices: 2 changes ``` -### Tag Distribution +### Tag distribution ```python tag_dist = reporter.get_tag_distribution() @@ -297,10 +247,9 @@ for tag, count in sorted(tag_dist.items(), key=lambda x: x[1], reverse=True): print(f"{tag}: {count} occurrences") ``` -### Group by Parent +### Group by parent ```python -# Group changes by their parent configuration context grouped = reporter.group_by_parent() for parent, children in grouped.items(): @@ -309,8 +258,7 @@ for parent, children in grouped.items(): print(f" - {child.text} ({len(child.instances)} devices)") ``` -Output: -``` +```text interface Vlan2: - ip address 10.0.0.2 255.255.255.0 (15 devices) - description Updated (12 devices) @@ -320,54 +268,34 @@ line vty 0 4: - exec-timeout 5 0 (145 devices) ``` -## Exporting Reports +## Exporting reports -### Export to Text +All exporters accept `include_tags` / `exclude_tags` filters, so any of the exports below can be scoped to a category (e.g. `include_tags=["security"]`). + +### Text ```python -# Export with merged style (shows instance counts) +# Merged style shows instance counts per line reporter.to_text("remediation.txt", style="merged") -# Export with comments -reporter.to_text("remediation_comments.txt", style="with_comments") - -# Export without comments (standard config format) +# Or "with_comments" / "without_comments" (standard config format) reporter.to_text("remediation_clean.txt", style="without_comments") - -# Export only security changes -reporter.to_text( - "security_remediation.txt", - style="merged", - include_tags=["security"], -) ``` -**Text Output Example (`style="merged"`):** -``` +```text interface Vlan2 !15 instances ip address 10.0.0.2 255.255.255.0 !15 instances line vty 0 4 !145 instances transport input ssh !145 instances - exec-timeout 5 0 !145 instances ntp server 10.2.2.2 !132 instances ``` -### Export to JSON +### JSON ```python -reporter.to_json("remediation_report.json") - -# With tag filters -reporter.to_json( - "ntp_report.json", - include_tags=["ntp"], -) - -# Custom indentation reporter.to_json("remediation_report.json", indent=4) ``` -**JSON Output Structure:** ```json { "summary": { @@ -378,129 +306,67 @@ reporter.to_json("remediation_report.json", indent=4) { "line": "line vty 0 4", "device_count": 145, - "device_ids": [1, 2, 3, ...], + "device_ids": [1, 2, 3], "tags": ["security", "access", "critical"], "comments": ["Update VTY settings"], "instances": [ - { - "id": 1, - "tags": ["security"], - "comments": ["Update VTY settings"] - }, - ... + {"id": 1, "tags": ["security"], "comments": ["Update VTY settings"]} ] } ] } ``` -### Export to CSV +### CSV ```python reporter.to_csv("remediation_report.csv") - -# With tag filters -reporter.to_csv( - "security_report.csv", - include_tags=["security"], -) ``` -**CSV Output:** ```csv line,device_count,percentage,tags,comments,device_ids "line vty 0 4",145,96.7,"security,access,critical","Update VTY settings","1,2,3,..." "ntp server 10.2.2.2",132,88.0,"ntp,safe","Update NTP server","1,2,3,..." ``` -### Export to Markdown +### Markdown ```python reporter.to_markdown("remediation_report.md", top_n=20) - -# With tag filters -reporter.to_markdown( - "security_report.md", - include_tags=["security"], - top_n=10, -) ``` -**Markdown Output:** -```markdown -# Remediation Report - -## Summary - -- **Total Devices**: 150 -- **Unique Changes**: 87 - -## Top 20 Changes by Impact - -| # | Configuration Line | Device Count | Percentage | -|---|-------------------|--------------|------------| -| 1 | `line vty 0 4` | 145 | 96.7% | -| 2 | `ntp server 10.2.2.2` | 132 | 88.0% | -... - -## Changes by Tag - -| Tag | Count | -|-----|-------| -| security | 45 | -| ntp | 32 | -``` +Produces a summary section, a "Top N Changes by Impact" table, and a "Changes by Tag" table — suitable for management-facing summaries. -### Export All Formats +### All formats at once ```python -# Export all formats at once reporter.export_all( output_dir="reports/", formats=["json", "csv", "markdown", "text"], ) - -# With tag filters -reporter.export_all( - output_dir="reports/security/", - formats=["json", "csv", "markdown"], - include_tags=["security"], -) ``` -This creates: -- `reports/remediation_report.json` -- `reports/remediation_report.csv` -- `reports/remediation_report.md` -- `reports/remediation_report.txt` +This creates `remediation_report.json`, `.csv`, `.md`, and `.txt` in `reports/`. -## Real-World Use Cases +## Common workflows -### Use Case 1: Impact Analysis +### Impact analysis -**Question**: *"How many devices will be affected if I push NTP server changes?"* +*"How many devices will be affected if I push NTP server changes?"* ```python -from hier_config import RemediationReporter - -reporter = RemediationReporter.from_remediations(all_device_remediations) - ntp_count = reporter.get_device_count("ntp server 10.2.2.2") print(f"NTP change will affect {ntp_count} devices") -# Get the specific device IDs detail = reporter.get_change_detail("ntp server 10.2.2.2") print(f"Affected device IDs: {sorted(detail.device_ids)}") ``` -### Use Case 2: Risk-Based Change Management +### Risk-based change management -**Scenario**: Separate changes into risk categories +Tag changes by risk, then generate a separate change window per risk level: ```python -from hier_config import TagRule, MatchRule - -# Define risk-based tagging tag_rules = [ TagRule( match_rules=( @@ -524,25 +390,17 @@ tag_rules = [ reporter.apply_tag_rules(tag_rules) -# Generate separate change windows -reporter.to_text("low_risk_changes.txt", include_tags=["low-risk"]) -reporter.to_text("high_risk_changes.txt", include_tags=["high-risk"]) -reporter.to_text("medium_risk_changes.txt", include_tags=["medium-risk"]) - -# Get statistics -low_risk_changes = reporter.get_all_changes(include_tags=["low-risk"]) -high_risk_changes = reporter.get_all_changes(include_tags=["high-risk"]) - -print(f"Low risk: {len(low_risk_changes)} change types") -print(f"High risk: {len(high_risk_changes)} change types") +for risk in ["low-risk", "medium-risk", "high-risk"]: + reporter.to_text(f"{risk}_changes.txt", include_tags=[risk]) + changes = reporter.get_all_changes(include_tags=[risk]) + print(f"{risk}: {len(changes)} change types") ``` -### Use Case 3: Compliance Reporting +### Compliance reporting -**Scenario**: Generate audit reports for security compliance +Tag security-related changes and export an audit trail: ```python -# Tag security-related changes security_tags = [ TagRule( match_rules=(MatchRule(contains="password"),), @@ -560,53 +418,36 @@ security_tags = [ reporter.apply_tag_rules(security_tags) -# Generate compliance report security_summary = reporter.summary_by_tags(["security"]) - -print("Security Compliance Remediation Report") -print("=" * 50) for tag, stats in security_summary.items(): - print(f"\nCategory: {tag}") + print(f"Category: {tag}") print(f"Devices requiring changes: {stats['device_count']}") print(f"Total change items: {stats['change_count']}") -# Export for audit trail -reporter.to_markdown( - "security_compliance_report.md", - include_tags=["security"], -) +reporter.to_markdown("security_compliance_report.md", include_tags=["security"]) ``` -### Use Case 4: Prioritization by Scope +### Prioritization by scope -**Scenario**: Focus on changes affecting the most devices +Focus on the changes affecting the most devices first: ```python -# Get the top 10 most widespread changes top_changes = reporter.get_top_changes(10) - -print("Top 10 Changes by Device Count") -print("=" * 60) for i, (change, count) in enumerate(top_changes, 1): percentage = (count / reporter.device_count) * 100 - print(f"{i}. {change.text}") - print(f" Affects {count} devices ({percentage:.1f}%)") - print() + print(f"{i}. {change.text}: {count} devices ({percentage:.1f}%)") -# Focus on changes affecting >80% of devices +# Changes affecting >80% of devices high_impact = reporter.get_changes_by_threshold( min_devices=int(reporter.device_count * 0.8) ) - -print(f"\nFound {len(high_impact)} changes affecting >80% of devices") ``` -### Use Case 5: Category-Based Rollout +### Phased rollout by category -**Scenario**: Deploy changes in stages by category +Tag by deployment phase and export a per-phase package: ```python -# Tag by functional category category_tags = [ TagRule( match_rules=(MatchRule(startswith="ntp"),), @@ -628,232 +469,39 @@ category_tags = [ reporter.apply_tag_rules(category_tags) -# Generate phase-specific remediations for phase in ["phase-1", "phase-2", "phase-3"]: reporter.export_all( output_dir=f"rollout/{phase}/", formats=["json", "csv", "text"], include_tags=[phase], ) - - # Get statistics for each phase - phase_changes = reporter.get_all_changes(include_tags=[phase]) - print(f"{phase}: {len(phase_changes)} change types") ``` -### Use Case 6: Exception Reporting +### Exception reporting -**Scenario**: Find devices with unique or uncommon changes +Find devices with unique or uncommon changes: ```python -# Find changes affecting only 1-3 devices (potential exceptions) -exceptions = reporter.get_changes_by_threshold( - min_devices=1, - max_devices=3, -) - -print(f"Found {len(exceptions)} exceptional changes") -print("\nUncommon Configuration Changes:") -print("=" * 60) +exceptions = reporter.get_changes_by_threshold(min_devices=1, max_devices=3) for change in exceptions: detail = reporter.get_change_detail(change.text) - print(f"\n{change.text}") + print(f"{change.text}") print(f" Affects {detail.device_count} device(s): {sorted(detail.device_ids)}") if detail.comments: print(f" Comments: {', '.join(detail.comments)}") ``` -## Best Practices - -### 1. Apply Tags for Better Organization - -Always apply tags to enable flexible filtering and reporting: - -```python -tag_rules = [ - # By function - TagRule( - match_rules=(MatchRule(startswith="ntp"),), - apply_tags=frozenset({"ntp", "infrastructure"}), - ), - # By risk - TagRule( - match_rules=(MatchRule(contains="password"),), - apply_tags=frozenset({"critical", "security"}), - ), - # By deployment phase - TagRule( - match_rules=(MatchRule(startswith="logging"),), - apply_tags=frozenset({"phase-1"}), - ), -] - -reporter.apply_tag_rules(tag_rules) -``` - -### 2. Use Multiple Report Formats - -Different stakeholders need different formats: - -```python -# Technical team: detailed JSON -reporter.to_json("detailed_report.json") - -# Management: summary markdown -reporter.to_markdown("executive_summary.md", top_n=10) - -# Analysis: CSV for Excel -reporter.to_csv("analysis_data.csv") - -# Implementation: text config -reporter.to_text("deployment_config.txt", style="without_comments") -``` - -### 3. Validate Scope Before Deployment - -Always check impact before pushing changes: - -```python -# Check high-impact changes -high_impact = reporter.get_changes_by_threshold(min_devices=100) - -if high_impact: - print("WARNING: The following changes affect >100 devices:") - for change in high_impact: - print(f" - {change.text}: {len(change.instances)} devices") - - # Require manual approval - approval = input("Proceed? (yes/no): ") - if approval.lower() != "yes": - print("Deployment cancelled") - exit(1) -``` - -### 4. Track Changes Over Time - -Export reports with timestamps for historical tracking: - -```python -from datetime import datetime - -timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") -output_dir = f"reports/{timestamp}/" - -reporter.export_all(output_dir, formats=["json", "markdown", "csv"]) -print(f"Reports saved to {output_dir}") -``` - -### 5. Combine with Existing Workflows - -Integrate reporting into your existing remediation workflow: - -```python -from hier_config import WorkflowRemediation, RemediationReporter - -# Generate remediations -remediations = [] -for device in inventory: - running = get_device_config(device) - generated = generate_desired_config(device) - - wfr = WorkflowRemediation(running, generated) - remediations.append(wfr.remediation_config) - -# Create reports -reporter = RemediationReporter.from_remediations(remediations) - -# Apply your organization's tag rules -reporter.apply_tag_rules(company_tag_rules) - -# Generate required reports -reporter.export_all("reports/current/") - -# Print summary for quick review -print(reporter.summary_text()) -``` - -## API Reference - -### RemediationReporter Class - -#### Constructor - -```python -reporter = RemediationReporter() -``` - -#### Class Methods +## Best practices -- `from_remediations(remediations)` - Create from iterable of HConfig objects -- `from_merged_config(merged_config)` - Create from pre-merged HConfig - -#### Instance Methods - -**Adding Data:** -- `add_remediation(remediation)` - Add single remediation -- `add_remediations(remediations)` - Add multiple remediations - -**Tagging:** -- `apply_tag_rules(tag_rules)` - Apply TagRule sequence - -**Querying:** -- `get_all_changes(include_tags=[], exclude_tags=[])` - Get all changes -- `get_change_detail(line, tag=None)` - Get detailed info about a line -- `get_device_count(line, tag=None)` - Count devices needing a change -- `get_changes_by_threshold(min_devices=0, max_devices=None, ...)` - Filter by impact -- `get_top_changes(n=10, ...)` - Get N most common changes -- `get_changes_matching(pattern, ...)` - Get changes matching regex - -**Analysis:** -- `summary()` - Get ReportSummary object -- `summary_text(top_n=10)` - Get human-readable summary -- `summary_by_tags(tags=None)` - Get breakdown by tags -- `group_by_parent()` - Group changes by parent line -- `get_impact_distribution(bins=...)` - Get distribution of changes by impact -- `get_tag_distribution()` - Get tag occurrence counts - -**Exporting:** -- `to_text(file_path, style="merged", ...)` - Export to text file -- `to_json(file_path, indent=2, ...)` - Export to JSON -- `to_csv(file_path, ...)` - Export to CSV -- `to_markdown(file_path, top_n=20, ...)` - Export to Markdown -- `export_all(output_dir, formats=[], ...)` - Export all formats - -#### Properties - -- `merged_config` - The merged HConfig object -- `device_count` - Number of unique devices - -### Models - -#### ReportSummary - -```python -class ReportSummary: - total_devices: int - total_unique_changes: int - most_common_changes: tuple[tuple[str, int], ...] - changes_by_tag: dict[str, int] -``` - -#### ChangeDetail - -```python -class ChangeDetail: - line: str - full_path: tuple[str, ...] - device_count: int - device_ids: frozenset[int] - tags: frozenset[str] - comments: frozenset[str] - instances: tuple[Instance, ...] - children: tuple[ChangeDetail, ...] -``` +- **Apply tags early** — tagging enables every filtered query and export that follows. +- **Match the format to the audience** — JSON for tooling, Markdown for management summaries, CSV for spreadsheet analysis, plain text for deployment. +- **Validate scope before deployment** — check `get_changes_by_threshold()` for unexpectedly widespread changes and require approval for them. +- **Timestamp exported reports** (e.g. `reports/20260718_120000/`) to keep a history of remediation scope over time. ## Troubleshooting -### Issue: No changes showing up +### No changes showing up ```python # Check if remediations were added @@ -865,7 +513,7 @@ for i, remediation in enumerate(remediations): print(f"Remediation {i}: {change_count} changes") ``` -### Issue: Tags not working +### Tags not working ```python # Verify tags were applied @@ -877,7 +525,7 @@ for change in all_changes[:5]: print(reporter.get_tag_distribution()) ``` -### Issue: Device count seems wrong +### Device count seems wrong ```python # Check unique device IDs @@ -891,8 +539,8 @@ print(f"Unique device IDs found: {len(all_device_ids)}") print(f"Reporter device count: {reporter.device_count}") ``` -## See Also +## Next steps -- [Tags](tags.md) - Learn more about tagging configuration lines -- [Custom Workflows](custom-workflows.md) - Integrate reporting into your workflows -- [Getting Started](getting-started.md) - Basic hier_config usage +- [Working with Tags](tags.md) — the tagging model in detail. +- [Remediation Workflows](remediation-workflows.md) — generating the per-device remediations that feed the reporter. +- [API Reference](../dev/api-reference.md) — full `RemediationReporter`, `ReportSummary`, and `ChangeDetail` signatures. diff --git a/docs/user/remediation-workflows.md b/docs/user/remediation-workflows.md new file mode 100644 index 00000000..edb56bb2 --- /dev/null +++ b/docs/user/remediation-workflows.md @@ -0,0 +1,223 @@ +# Remediation Workflows + +This page covers `WorkflowRemediation` in depth: remediation and rollback generation, hand-crafting custom remediation for tricky sections, extending the pipeline with transform callbacks and plugins, and rendering NETCONF payloads. Read it once you are comfortable with the basics from [Getting Started](getting-started.md). + +## WorkflowRemediation + +`WorkflowRemediation` compares a running and a generated (intended) configuration: + +```python +from hier_config import WorkflowRemediation, HConfig, Platform +from hier_config.utils import read_text_from_file + +running_config = read_text_from_file("./tests/fixtures/running_config_acl.conf") +generated_config = read_text_from_file("./tests/fixtures/generated_config_acl.conf") + +wfr = WorkflowRemediation( + running_config=HConfig.from_text(Platform.CISCO_IOS, running_config), + generated_config=HConfig.from_text(Platform.CISCO_IOS, generated_config), +) +``` + +It exposes two cached properties: + +- `wfr.remediation_config` — the commands that bring the device to the intended state. +- `wfr.rollback_config` — the commands that revert the device to its original state. + +Both configurations must be built with the same driver class; otherwise the constructor raises `hier_config.exceptions.IncompatibleDriverError`. This guards against accidentally diffing, say, an IOS tree against an NX-OS tree. + +## Custom remediation for special sections + +Certain scenarios demand remediation strategies beyond the standard [negation](../glossary.md#negation-prefix) and [idempotency](../glossary.md#idempotent-command) handling. hier_config lets you extract, replace, and merge remediation sections to handle these edge cases. + +### Example: access-list remediation + +**Current (running) configuration:** + +```python +print(wfr.running_config.get_child(startswith="ip access-list")) +``` + +```text +ip access-list extended TEST + 12 permit ip 10.0.0.0 0.0.0.7 any + exit +``` + +**Intended (generated) configuration:** + +```python +print(wfr.generated_config.get_child(startswith="ip access-list")) +``` + +```text +ip access-list extended TEST + 10 permit ip 10.0.1.0 0.0.0.255 any + 20 permit ip 10.0.0.0 0.0.0.7 any + exit +``` + +**Default remediation:** + +```python +print(wfr.remediation_config.get_child(startswith="ip access-list")) +``` + +```text +ip access-list extended TEST + no 12 permit ip 10.0.0.0 0.0.0.7 any + 10 permit ip 10.0.1.0 0.0.0.255 any + 20 permit ip 10.0.0.0 0.0.0.7 any + exit +``` + +The default remediation has three issues: + +1. **Invalid command:** `no 12 permit ip 10.0.0.0 0.0.0.7 any` is invalid in Cisco IOS; the valid form is `no 12`. +2. **Risk of lockout:** removing a line currently matched by traffic could cause an outage. +3. **Unnecessary churn:** the entry only differs by sequence number; in large ACLs re-adding it is wasteful. + +### Building a safer remediation + +The plan: resequence the ACL, add a temporary allow-all entry to prevent lockout, apply the changes, and clean up the temporary entry. + +**1. Create a custom `HConfig` object** (reuse the running config's driver): + +```python +from hier_config import HConfig + +custom_remediation = HConfig(wfr.running_config.driver) +``` + +**2. Add resequencing and extract the ACL remediation:** + +```python +custom_remediation.add_child("ip access-list resequence TEST 10 10") +custom_remediation.add_child("ip access-list extended TEST") +remediation = wfr.remediation_config.get_child(equals="ip access-list extended TEST") +``` + +**3. Build the custom ACL remediation:** + +```python +acl = custom_remediation.get_child(equals="ip access-list extended TEST") +acl.add_child("1 permit ip any any") # Temporary allow-all + +for line in remediation.all_children(): + if line.text.startswith("no "): + # Adjust invalid sequence negation + parts = line.text.split() + rounded_number = round(int(parts[1]), -1) + acl.add_child(f"{parts[0]} {rounded_number}") + else: + acl.add_child(line.text) + +acl.add_child("no 1") # Cleanup temporary rule +``` + +```python +print(custom_remediation) +``` + +```text +ip access-list resequence TEST 10 10 +ip access-list extended TEST + 1 permit ip any any + no 10 + 10 permit ip 10.0.1.0 0.0.0.255 any + 20 permit ip 10.0.0.0 0.0.0.7 any + no 1 + exit +``` + +**4. Swap it into the workflow's remediation:** + +```python +invalid_remediation = wfr.remediation_config.get_child(equals="ip access-list extended TEST") +invalid_remediation.delete() +wfr.remediation_config.merge(custom_remediation) +``` + +> **Note:** `merge()` is intentionally strict. If any child already exists under the same parent in the target tree, hier_config raises `hier_config.exceptions.DuplicateChildError`. This guards against accidentally overwriting commands when combining remediation fragments. When you need to layer one configuration onto another and allow overlapping sections, use [`future()`](future-config.md) instead. + +## The remediation transform pipeline + +When `remediation_config` is first computed, hier_config runs two ordered sets of transforms over the result, each receiving the remediation `HConfig` and mutating it in place: + +1. **Driver-level transforms** — `driver.rules.remediation_transform_callbacks`, a hook for platform-wide fixups. No built-in driver populates it today; it exists for customized and custom drivers. +2. **User plugins** — the `plugins` argument of `WorkflowRemediation`, for organization policies and per-workflow behavior. + +### Plain-callable plugins + +Any `Callable[[HConfig], None]` works as a plugin: + +```python +from hier_config import WorkflowRemediation, HConfig + +def add_change_banner(remediation: HConfig) -> None: + if remediation.children: + first = next(iter(remediation.children)) + first.comments.add("change window CHG0012345") + +wfr = WorkflowRemediation(running, generated, plugins=[add_change_banner]) +``` + +### `RemediationPlugin` classes + +For reusable, named transforms, subclass `RemediationPlugin`. Instances are callable, so they work anywhere a plain callable does: + +```python +from hier_config import HConfig, RemediationPlugin, WorkflowRemediation + + +class ForbidShutdownPlugin(RemediationPlugin): + """Drop bare `shutdown` commands from remediations.""" + + @property + def name(self) -> str: + return "forbid-shutdown" + + @property + def description(self) -> str: + return "Removes interface shutdown commands to avoid accidental outages." + + def transform(self, remediation: HConfig) -> None: + for child in tuple(remediation.all_children()): + if child.text == "shutdown": + child.delete() + + +wfr = WorkflowRemediation(running, generated, plugins=[ForbidShutdownPlugin()]) +``` + +Driver authors should prefer `remediation_transform_callbacks` on `HConfigDriverRules` for platform-level transforms (see [Customizing Driver Rules](../admin/customizing-rules.md)); plugins are for user- and workflow-level policy. + +## NETCONF remediation payloads + +When both configurations were built with [`HConfig.from_xml()`](loading-configs.md#structured-formats-json-and-xml), the remediation can be rendered as a NETCONF `edit-config` payload: + +```python +payload = wfr.remediation_netconf_xml() +``` + +Deletions become elements with `nc:operation="delete"`; additions use the NETCONF default merge operation. Keyed list-entry deletions are expressed by their key leaf, resolved against the running config — pass `list_keys=` if your data does not use the default `name`/`id` keys. Attribute-level changes cannot be expressed as NETCONF operations and raise `InvalidConfigError`. + +## gNMI-style JSON remediation payloads + +When both configurations were built with [`HConfig.from_json()`](loading-configs.md#structured-formats-json-and-xml), the remediation can be rendered as a gNMI-SetRequest-style dict of update and delete sets: + +```python +result = wfr.remediation_json() +# { +# "update": {"system": {"config": {"hostname": "new"}}}, +# "delete": ["interfaces/interface[name=eth1]"], +# } +``` + +Added and changed values render into the `update` object using the same JSON mapping as `to_json()` (a modified keyed list entry keeps its identity leaf, so the update stays valid OpenConfig). Deletions become xpath-ish paths: keyed list entries get a `[key=value]` selector resolved against the running config — pass `list_keys=` if your data does not use the default `name`/`id` keys — while scalar leaves delete by their bare path (e.g. `system/config/hostname`). Backslashes and `]` inside selector values are escaped with a backslash. Element names themselves are not escaped, so keys containing `/` or `[` produce ambiguous paths. + +## Next steps + +- [Working with Tags](tags.md) — filter the remediation for phased deployment. +- [Remediation Reporting](remediation-reporting.md) — aggregate remediations across a fleet. +- [Customizing Driver Rules](../admin/customizing-rules.md) — fix incorrect remediation at the driver level instead of patching it per-workflow. diff --git a/docs/user/junos-style-syntax-remediation.md b/docs/user/set-style-platforms.md similarity index 55% rename from docs/user/junos-style-syntax-remediation.md rename to docs/user/set-style-platforms.md index 1601f205..dcc790b1 100644 --- a/docs/user/junos-style-syntax-remediation.md +++ b/docs/user/set-style-platforms.md @@ -1,5 +1,29 @@ -# JunOS-style Syntax Remediation -Operating systems that use "set"-based syntax can now be remediated experimentally. Below is an example of a JunOS-style remediation. +# Set-Style Platforms + +This page covers remediation for platforms whose CLI uses `set` / `delete` command syntax — Juniper JunOS, VyOS, and Nokia SR Linux — rather than the Cisco-style `no` prefix. Read it if you work with any of these platforms. + +> **Experimental:** set-style platform support has not been tested extensively in production environments. Use with caution. + +## How set-style platforms work + +All three drivers share the same model: + +- **[Declaration prefix](../glossary.md#declaration-prefix)** `set ` — prepended to each positive command. +- **[Negation prefix](../glossary.md#negation-prefix)** `delete ` — replaces the Cisco-style `no `. +- **A config preprocessor** — each driver's `config_preprocessor` converts the platform's hierarchical native rendering into flat `set` commands before parsing: + - JunOS: curly-brace configuration (`show configuration` output) is flattened. + - VyOS: curly-brace configuration is flattened. + - Nokia SRL: hierarchical `info` output is flattened. + +You can therefore feed either flat `set`-style text or the hierarchical native format into `HConfig.from_text()` — both parse to the same tree, and remediation is always emitted as `set` / `delete` commands. + +| Platform | `Platform` enum | Native hierarchical input | +|----------|-----------------|---------------------------| +| Juniper JunOS | `Platform.JUNIPER_JUNOS` | curly-brace config | +| VyOS | `Platform.VYOS` | curly-brace config | +| Nokia SR Linux | `Platform.NOKIA_SRL` | `info` output | + +## Example: JunOS remediation from flat set-style config ```bash $ cat ./tests/fixtures/running_config_flat_junos.conf @@ -22,24 +46,20 @@ set interfaces irb unit 3 family inet address 10.0.4.1/16 set interfaces irb unit 3 family inet filter input TEST set interfaces irb unit 3 family inet mtu 9000 set interfaces irb unit 3 family inet description "switch_mgmt_10.0.4.0/24" +``` - -$ python3 ->>> from hier_config import WorkflowRemediation, get_hconfig, Platform +```python +>>> from hier_config import WorkflowRemediation, HConfig, Platform >>> from hier_config.utils import read_text_from_file >>> >>> running_config_text = read_text_from_file("./tests/fixtures/running_config_flat_junos.conf") >>> generated_config_text = read_text_from_file("./tests/fixtures/generated_config_flat_junos.conf") -# Create HConfig objects for the running and generated configurations using JunOS syntax ->>> running_config = get_hconfig(Platform.JUNIPER_JUNOS, running_config_text) ->>> generated_config = get_hconfig(Platform.JUNIPER_JUNOS, generated_config_text) >>> -# Initialize WorkflowRemediation with the running and generated configurations +>>> running_config = HConfig.from_text(Platform.JUNIPER_JUNOS, running_config_text) +>>> generated_config = HConfig.from_text(Platform.JUNIPER_JUNOS, generated_config_text) +>>> >>> workflow = WorkflowRemediation(running_config, generated_config) >>> -# Generate and display the remediation configuration ->>> print("Remediation configuration:") -Remediation configuration: >>> print(str(workflow.remediation_config)) delete vlans switch_mgmt_10.0.4.0/24 vlan-id 3 delete vlans switch_mgmt_10.0.4.0/24 l3-interface irb.3 @@ -61,10 +81,12 @@ set interfaces irb unit 4 family inet description "switch_mgmt_10.0.4.0/24" >>> ``` -Configurations loaded into Hier Config with Juniper-style syntax are converted to a flat, `set`-based format. Remediation steps are then generated using this `set` syntax. +## Example: JunOS remediation from hierarchical config + +The same workflow accepts native curly-brace configuration — the preprocessor flattens it to `set` commands automatically: ```bash -$ cat ./tests/fixtures/running_config_junos.conf +$ cat ./tests/fixtures/running_config_junos.conf system { host-name aggr-example.rtr; } @@ -116,23 +138,12 @@ interfaces { } } } +``` -$ python3 ->>> from hier_config import WorkflowRemediation, get_hconfig, Platform ->>> from hier_config.utils import read_text_from_file ->>> ->>> running_config_text = read_text_from_file("./tests/fixtures/running_config_junos.conf") ->>> generated_config_text = read_text_from_file("./tests/fixtures/generated_config_junos.conf") -# Create HConfig objects for the running and generated configurations using JunOS syntax ->>> running_config = get_hconfig(Platform.JUNIPER_JUNOS, running_config_text) ->>> generated_config = get_hconfig(Platform.JUNIPER_JUNOS, generated_config_text) ->>> -# Initialize WorkflowRemediation with the running and generated configurations +```python +>>> running_config = HConfig.from_text(Platform.JUNIPER_JUNOS, read_text_from_file("./tests/fixtures/running_config_junos.conf")) +>>> generated_config = HConfig.from_text(Platform.JUNIPER_JUNOS, read_text_from_file("./tests/fixtures/generated_config_junos.conf")) >>> workflow = WorkflowRemediation(running_config, generated_config) ->>> -# Generate and display the remediation configuration ->>> print("Remediation configuration:") -Remediation configuration: >>> print(str(workflow.remediation_config)) delete vlans switch_mgmt_10.0.4.0/24 vlan-id 3 delete vlans switch_mgmt_10.0.4.0/24 l3-interface irb.3 @@ -153,5 +164,31 @@ set interfaces irb unit 4 family inet address 10.0.4.1/16 set interfaces irb unit 4 family inet filter input TEST set interfaces irb unit 4 family inet mtu 9000 set interfaces irb unit 4 family inet description "switch_mgmt_10.0.4.0/24" +set interfaces xe-0/0/0 description "bb01.lax01:Ethernet2; ID:YT661812121" +set interfaces xe-0/0/0 mtu 9160 +set interfaces xe-0/0/0 unit 0 family iso +set interfaces xe-0/0/0 unit 0 family mpls +set interfaces xe-0/0/0 unit 0 family inet address 10.0.5.0/31 +set interfaces xe-0/0/0 unit 0 family inet6 address 2001:db8:5695::1/64 >>> -``` \ No newline at end of file +``` + +## VyOS and Nokia SRL + +VyOS (`Platform.VYOS`) and Nokia SR Linux (`Platform.NOKIA_SRL`) work identically — build both configs with `HConfig.from_text()` and pass them to `WorkflowRemediation`. Remediation output uses `set` / `delete` syntax for all three platforms. + +```python +from hier_config import WorkflowRemediation, HConfig, Platform + +running = HConfig.from_text(Platform.NOKIA_SRL, running_text) +intended = HConfig.from_text(Platform.NOKIA_SRL, intended_text) +workflow = WorkflowRemediation(running, intended) + +for line in workflow.remediation_config.all_children_sorted(): + print(line.indented_text()) +``` + +## Next steps + +- [Supported Platforms](../admin/platforms.md) — details and quirks for every built-in platform. +- [Creating a Platform Driver](../dev/creating-drivers.md) — how `config_preprocessor` and prefixes are implemented, if you need to support another set-style OS. diff --git a/docs/user/tags.md b/docs/user/tags.md index bd62879e..17f24002 100644 --- a/docs/user/tags.md +++ b/docs/user/tags.md @@ -1,8 +1,10 @@ # Working with Tags +This page shows how to tag sections of a remediation and filter the output by tag — useful for deploying low-risk changes first or isolating high-risk changes for review. It builds on the basics from [Getting Started](getting-started.md). + ## MatchRules -[MatchRules](glossary.md#match-rule), written in YAML, help users identify either highly specific sections or more generalized lines within a configuration. For instance, if you want to target interface descriptions, you could set up MatchRules as follows: +[MatchRules](../glossary.md#match-rule), written in YAML, help you identify either highly specific sections or more generalized lines within a configuration. For instance, if you want to target interface descriptions, you could set up MatchRules as follows: ```yaml - match_rules: @@ -10,7 +12,7 @@ - startswith: description ``` -This setup directs hier_config to search for configuration lines that begin with `interface` and, under each interface, locate lines that start with `description`​​. +This setup directs hier_config to search for configuration lines that begin with `interface` and, under each interface, locate lines that start with `description`. With MatchRules, you can specify the level of detail needed, whether focusing on general configuration lines or diving into specific subsections. For example, to check for the presence or absence of HTTP, SSH, SNMP, and logging commands in a configuration, you could use a single rule as follows: @@ -27,7 +29,7 @@ With MatchRules, you can specify the level of detail needed, whether focusing on - no logging ``` -This rule will look for configuration lines that start with any of the listed keywords​. +This rule will look for configuration lines that start with any of the listed keywords. To check whether BGP IPv4 AFIs (Address Family Identifiers) are activated, you can use the following rule: @@ -39,13 +41,14 @@ To check whether BGP IPv4 AFIs (Address Family Identifiers) are activated, you c ``` In this example, the `activate` keyword is used to identify active BGP neighbors. Available keywords for MatchRules include: -- startswith -- endswith -- contains -- equals -- re_search (for regular expressions) -These options allow you to target configuration lines with precision based on the desired pattern​. +- `startswith` +- `endswith` +- `contains` +- `equals` +- `re_search` (for regular expressions) + +When multiple fields are set on a single MatchRule, every criterion must match (AND logic). These options allow you to target configuration lines with precision based on the desired pattern. You can also combine the previous examples into a single set of MatchRules, like this: @@ -69,38 +72,29 @@ You can also combine the previous examples into a single set of MatchRules, like - endswith: activate ``` -When `hier_config` processes MatchRules, it treats each as a separate rule, evaluating them individually to match the specified configuration patterns​. +When hier_config processes MatchRules, it treats each as a separate rule, evaluating them individually to match the specified configuration patterns. -## Working with Tags +## Tagging remediation sections -With a solid understanding of MatchRules, you can unlock more advanced capabilities in `hier_config`, such as tagging specific configuration sections to control remediation output based on tags. See [Tag rules](glossary.md#tag-rules) for a conceptual overview. This feature is particularly useful during maintenance, allowing you to focus on low-risk changes or isolate high-risk changes for detailed inspection. +With a solid understanding of MatchRules, you can unlock more advanced capabilities, such as tagging specific configuration sections to control remediation output based on tags. See [Tag rules](../glossary.md#tag-rules) for a conceptual overview. This feature is particularly useful during maintenance, allowing you to focus on low-risk changes or isolate high-risk changes for detailed inspection. Tagging builds on MatchRules by adding the **apply_tags** keyword to target specific configurations. -For example, suppose your running configuration contains an NTP server setup like this: - -```text -ntp server 192.0.2.1 prefer version 2 -``` - -But your intended configuration uses publicly available NTP servers: - -```text -ip name-server 1.1.1.1 -ip name-server 8.8.8.8 -ntp server time.nist.gov -``` - -You can create a MatchRule to tag this specific remediation with "ntp" as follows: +For example, the repository's `tests/fixtures/tag_rules_ios.yml` labels low-risk changes (VLAN declarations, interface descriptions) with a `safe` tag and riskier changes (ACLs, IP addressing, MTU, shutdown state) with a `manual` tag. The `safe` rules look like this: ```yaml - match_rules: + - equals: + - no ip http secure-server + - no ip http server + - vlan + - no vlan + apply_tags: [safe] +- match_rules: + - startswith: interface Vlan - startswith: - - ip name-server - - no ip name-server - - ntp - - no ntp - apply_tags: [ntp] + - description + apply_tags: [safe] ``` With the tags loaded, you can create a targeted remediation based on those tags as follows: @@ -108,8 +102,7 @@ With the tags loaded, you can create a targeted remediation based on those tags ```python #!/usr/bin/env python3 -# Import necessary libraries -from hier_config import WorkflowRemediation, get_hconfig, Platform +from hier_config import WorkflowRemediation, HConfig, Platform from hier_config.utils import read_text_from_file, load_hier_config_tags # Load the running and generated configurations from files @@ -121,25 +114,44 @@ tags = load_hier_config_tags("./tests/fixtures/tag_rules_ios.yml") # Initialize a WorkflowRemediation object with the running and intended configurations wfr = WorkflowRemediation( - running_config=get_hconfig(Platform.CISCO_IOS, running_config), - generated_config=get_hconfig(Platform.CISCO_IOS, generated_config) + running_config=HConfig.from_text(Platform.CISCO_IOS, running_config), + generated_config=HConfig.from_text(Platform.CISCO_IOS, generated_config) ) -# Apply the tag rules to filter remediation steps by tags +# Apply the tag rules to label matching remediation sections wfr.apply_remediation_tag_rules(tags) -# Generate the remediation steps -wfr.remediation_config - -# Display remediation steps filtered to include only the "ntp" tag -print(wfr.remediation_config_filtered_text(include_tags={"ntp"}, exclude_tags={})) +# Display remediation steps filtered to include only the "safe" tag +print(wfr.remediation_config_filtered_text(include_tags={"safe"}, exclude_tags=set())) ``` -The resulting remediation output appears as follows: +The resulting remediation output contains only the low-risk changes: ```text -no ntp server 192.0.2.1 prefer version 2 -ip name-server 1.1.1.1 -ip name-server 8.8.8.8 -ntp server time.nist.gov -``` \ No newline at end of file +interface Vlan3 + description switch_mgmt_10.0.3.0/24 +interface Vlan4 + description switch_mgmt_10.0.4.0/24 +``` + +`remediation_config_filtered_text()` accepts both `include_tags` and `exclude_tags`, so you can also render everything *except* a tag (for example, hold back `critical` changes). + +## Tagging lines directly + +Tag rules are the declarative path, but you can also manipulate tags on individual nodes with `HConfigChild.add_tags()` and `HConfigChild.remove_tags()`: + +```python +from hier_config import MatchRule + +for child in wfr.remediation_config.get_children_deep( + (MatchRule(startswith="interface"),) +): + child.add_tags("interfaces") +``` + +Both methods accept a single tag string or any iterable of tags. + +## Next steps + +- [Remediation Reporting](remediation-reporting.md) — tag-based reporting across many devices. +- [Loading Rules from Files](../admin/rules-from-files.md) — the YAML formats for tag and driver rules. diff --git a/docs/user/unified-diff.md b/docs/user/unified-diff.md index 1e0001a3..50531196 100644 --- a/docs/user/unified-diff.md +++ b/docs/user/unified-diff.md @@ -1,20 +1,21 @@ -# Unified diff +# Unified Diffs -The Unified Diff feature, introduced in version 2.1.0, provides output similar to `difflib.unified_diff()` but with added awareness of out-of-order lines and parent-child relationships in the Hier Config model of configurations being compared. +This page covers `HConfig.unified_diff()`, which produces `difflib`-style diff output with awareness of out-of-order lines and parent-child relationships. Use it to compare two configurations for reporting or validation — without generating remediation commands. -This feature is particularly useful when comparing configurations from two network devices, such as redundant pairs, or when validating differences between running and intended configurations. +This is particularly useful when comparing configurations from two network devices, such as redundant pairs, or when validating differences between running and intended configurations. -Currently, the algorithm does not account for duplicate child entries (e.g., multiple `endif` statements in an IOS-XR route-policy) or enforce command order in sections where it may be critical, such as Access Control Lists (ACLs). For accurate ordering in ACLs, sequence numbers should be used if command order is important. +> **Note:** The algorithm does not account for duplicate child entries (e.g., multiple `endif` statements in an IOS XR route-policy) or enforce command order in sections where it may be critical, such as access control lists (ACLs). For accurate ordering in ACLs, use sequence numbers. -```bash ->>> from hier_config import get_hconfig, Platform +```python +>>> from hier_config import HConfig, Platform +>>> from hier_config.utils import read_text_from_file >>> from pprint import pprint >>> >>> running_config_text = read_text_from_file("./tests/fixtures/running_config.conf") >>> generated_config_text = read_text_from_file("./tests/fixtures/generated_config.conf") >>> ->>> running_config = get_hconfig(Platform.CISCO_IOS, running_config_text) ->>> generated_config = get_hconfig(Platform.CISCO_IOS, generated_config_text) +>>> running_config = HConfig.from_text(Platform.CISCO_IOS, running_config_text) +>>> generated_config = HConfig.from_text(Platform.CISCO_IOS, generated_config_text) >>> >>> pprint(list(running_config.unified_diff(generated_config))) ['vlan 3', @@ -39,4 +40,11 @@ Currently, the algorithm does not account for duplicate child entries (e.g., mul ' + ip access-group TEST in', ' + no shutdown'] >>> -``` \ No newline at end of file +``` + +Lines prefixed with `+` are present in `generated_config` but not in `running_config`; lines prefixed with `-` are present in `running_config` but not in `generated_config`. Parent lines without a prefix are shown as context only. + +## Next steps + +- [Remediation Workflows](remediation-workflows.md) — turn differences into deployable commands. +- [Predicting Future Configs](future-config.md) — simulate the post-change configuration instead of diffing. diff --git a/docs/user/utilities.md b/docs/user/utilities.md deleted file mode 100644 index 7087fe53..00000000 --- a/docs/user/utilities.md +++ /dev/null @@ -1,189 +0,0 @@ -# Utilities - -## read_text_from_file - -**Description**: -Reads the contents of a file and loads its contents into memory. - -**Arguments**: - - `file_path (str)`: The path to the device configuration file. - -**Returns**: - - `str`: The contents of the file as a string. - -**Example**: -```python -from hier_config.utils import read_text_from_file - -device_config = read_text_from_file("path/to/device_config.txt") -print(device_config) -``` - -## load_hier_config_tags - -**Description**: -Parses a YAML file containing configuration tags and converts them into a format compatible with Hier Config. - -**Arguments**: - - `file_path (str)`: The path to the YAML file containing tag rules. - -**Returns**: - - `List[Dict[str, Any]]`: A list of dictionaries representing tag rules. - -**Example**: -```python -from hier_config.utils import load_hier_config_tags - -tag_rules = load_hier_config_tags("path/to/tag_rules.yml") - -print(tag_rules) -``` - -## Hier Config V2 to V3 Migration Utilities - -Hier Config version 3 introduces breaking changes compared to version 2. These utilities are designed to help you transition seamlessly by enabling the continued use of version 2 configurations while you update your tooling to support the new version. - -### hconfig_v2_os_v3_platform_mapper -**Description**: -Maps a Hier Config v2 OS name to a v3 Platform enumeration. - -**Arguments**: - - `os_name (str)`: The name of the OS as defined in Hier Config v2. - -**Returns**: - - `Platform`: The corresponding Platform enumeration for Hier Config v3. - -**Raises**: - - `ValueError`: If the provided OS name is not supported in v2. - -**Example**: -```python -from hier_config.utils import hconfig_v2_os_v3_platform_mapper - -platform = hconfig_v2_os_v3_platform_mapper("ios") - -print(platform) # Output: -``` - -### hconfig_v3_platform_v2_os_mapper -**Description**: -Maps a Hier Config v3 Platform enumeration to a v2 OS name. - -**Arguments**: - - `platform (Platform)`: A Platform enumeration from Hier Config v3. - -**Returns**: - - `str`: The corresponding OS name for Hier Config v2. - -**Raises**: - - `ValueError`: If the provided Platform is not supported in v3. - -**Example**: -```python -from hier_config.utils import hconfig_v3_platform_v2_os_mapper - -os_name = hconfig_v3_platform_v2_os_mapper(Platform.CISCO_IOS) -print(os_name) # Output: "ios" -``` - -### load_hconfig_v2_options -**Description**: -Loads v2-style configuration options into a v3-compatible driver. - -**Arguments**: - - `v2_options (Dict[str, Any])`: A dictionary of v2-style options. - `platform (Platform)`: A Platform enumeration from Hier Config v3. - -**Returns**: - - `HConfigDriverBase`: Hier Config Platform Driver. - -**Example loading options from a dictionary**: -```python -from hier_config import Platform -from hier_config.utils import load_hconfig_v2_options - -v2_options = { - "negation": "no", - "ordering": [{"lineage": [{"startswith": "ntp"}], "order": 700}], - "per_line_sub": [{"search": "^!.*Generated.*$", "replace": ""}], - "sectional_exiting": [ - {"lineage": [{"startswith": "router bgp"}], "exit_text": "exit"} - ], - "idempotent_commands": [{"lineage": [{"startswith": "interface"}]}], - "negation_negate_with": [ - { - "lineage": [ - {"startswith": "interface Ethernet"}, - {"startswith": "spanning-tree port type"}, - ], - "use": "no spanning-tree port type", - } - ], -} -platform = Platform.CISCO_IOS -driver = load_hconfig_v2_options(v2_options, platform) - -print(driver) -``` - -*Output*: -``` -print(driver.rules) -full_text_sub=[] idempotent_commands=[IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='vlan', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='name', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='description ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='ip address ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='switchport mode ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='authentication host-mode ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='authentication event server dead action authorize vlan ', endswith=None, contains=None, re_search=None))), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='errdisable recovery interval ', endswith=None, contains=None, re_search=None),)), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith=None, endswith=None, contains=None, re_search='^(no )?logging console.*'),)), IdempotentCommandsRule(match_rules=(MatchRule(equals=None, startswith='interface', endswith=None, contains=None, re_search=None),))] idempotent_commands_avoid=[] indent_adjust=[] indentation=2 negation_default_when=[] negate_with=[NegationDefaultWithRule(match_rules=(MatchRule(equals=None, startswith='logging console ', endswith=None, contains=None, re_search=None),), use='logging console debugging'), NegationDefaultWithRule(match_rules=(MatchRule(equals=None, startswith='', endswith=None, contains=None, re_search=None),), use='no')] ordering=[OrderingRule(match_rules=(MatchRule(equals=None, startswith='interface', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='switchport mode ', endswith=None, contains=None, re_search=None)), weight=-10), OrderingRule(match_rules=(MatchRule(equals=None, startswith='no vlan filter', endswith=None, contains=None, re_search=None),), weight=200), OrderingRule(match_rules=(MatchRule(equals=None, startswith='interface', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='no shutdown', endswith=None, contains=None, re_search=None)), weight=200), OrderingRule(match_rules=(MatchRule(equals=None, startswith='aaa group server tacacs+ ', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='no server ', endswith=None, contains=None, re_search=None)), weight=10), OrderingRule(match_rules=(MatchRule(equals=None, startswith='no tacacs-server ', endswith=None, contains=None, re_search=None),), weight=10), OrderingRule(match_rules=(MatchRule(equals=None, startswith='ntp', endswith=None, contains=None, re_search=None),), weight=700)] parent_allows_duplicate_child=[] per_line_sub=[PerLineSubRule(search='^Building configuration.*', replace=''), PerLineSubRule(search='^Current configuration.*', replace=''), PerLineSubRule(search='^! Last configuration change.*', replace=''), PerLineSubRule(search='^! NVRAM config last updated.*', replace=''), PerLineSubRule(search='^ntp clock-period .*', replace=''), PerLineSubRule(search='^version.*', replace=''), PerLineSubRule(search='^ logging event link-status$', replace=''), PerLineSubRule(search='^ logging event subif-link-status$', replace=''), PerLineSubRule(search='^\\s*ipv6 unreachables disable$', replace=''), PerLineSubRule(search='^end$', replace=''), PerLineSubRule(search='^\\s*[#!].*', replace=''), PerLineSubRule(search='^ no ip address', replace=''), PerLineSubRule(search='^ exit-peer-policy', replace=''), PerLineSubRule(search='^ exit-peer-session', replace=''), PerLineSubRule(search='^ exit-address-family', replace=''), PerLineSubRule(search='^crypto key generate rsa general-keys.*$', replace=''), PerLineSubRule(search='^!.*Generated.*$', replace='')] post_load_callbacks=[, , ] sectional_exiting=[SectionalExitingRule(match_rules=(MatchRule(equals=None, startswith='router bgp', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='template peer-policy', endswith=None, contains=None, re_search=None)), exit_text='exit-peer-policy'), SectionalExitingRule(match_rules=(MatchRule(equals=None, startswith='router bgp', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='template peer-session', endswith=None, contains=None, re_search=None)), exit_text='exit-peer-session'), SectionalExitingRule(match_rules=(MatchRule(equals=None, startswith='router bgp', endswith=None, contains=None, re_search=None), MatchRule(equals=None, startswith='address-family', endswith=None, contains=None, re_search=None)), exit_text='exit-address-family'), SectionalExitingRule(match_rules=(MatchRule(equals=None, startswith='router bgp', endswith=None, contains=None, re_search=None),), exit_text='exit')] sectional_overwrite=[] sectional_overwrite_no_negate=[] -``` - -**Example loading options from a file**: -```python -from hier_config import Platform -from hier_config.utils import load_hconfig_v2_options_from_file - -platform = Platform.CISCO_IOS -driver = load_hconfig_v2_options("/path/to/options.yml", platform) -``` - -### load_hconfig_v2_tags -**Description**: -Converts v2-style tags into a tuple of TagRule Pydantic objects compatible with Hier Config v3. - -**Arguments**: - - `v2_tags (List[Dict[str, Any]])`: A list of dictionaries representing v2-style tags. - -**Returns**: - - `Tuple[TagRule, ...]`: A tuple of TagRule Pydantic objects. - -**Example loading tags from a dictionary**: -```python -from hier_config.utils import load_hconfig_v2_tags - -v3_tags = load_hconfig_v2_tags([ - { - "lineage": [{"startswith": ["ip name-server", "ntp"]}], - "add_tags": "ntp" - } -]) - -print(v3_tags) # Output: (TagRule(match_rules=(MatchRule(equals=None, startswith=('ip name-server', 'ntp'), endswith=None, contains=None, re_search=None),), apply_tags=frozenset({'ntp'})),) -``` - -**Example loading tags from a file**: -```python -from hier_config.utils import load_hconfig_v2_tags_from_file - -v3_tags = load_hconfig_v2_tags("path/to/v2_tags.yml") - -print(v3_tags) -``` \ No newline at end of file diff --git a/hier_config/__init__.py b/hier_config/__init__.py index c0f20031..aeb1a06e 100644 --- a/hier_config/__init__.py +++ b/hier_config/__init__.py @@ -1,30 +1,63 @@ from .child import HConfigChild -from .constructors import ( - get_hconfig, - get_hconfig_driver, - get_hconfig_fast_load, - get_hconfig_from_dump, - get_hconfig_view, +from .constructors import get_hconfig_view +from .exceptions import ( + DriverNotFoundError, + DuplicateChildError, + HierConfigError, + IncompatibleDriverError, + InvalidConfigError, ) from .models import ChangeDetail, MatchRule, Platform, ReportSummary, TagRule, TextStyle +from .platforms.driver_base import HConfigDriverBase, HConfigDriverRules +from .platforms.view_base import ( + ConfigViewInterfaceBase, + HConfigViewBase, + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, +) +from .plugins import RemediationPlugin +from .registry import ( + get_hconfig_driver, + get_registered_platforms, + register_driver, + unregister_driver, +) from .reporting import RemediationReporter from .root import HConfig +from .tree_algorithms import FutureReport from .workflows import WorkflowRemediation __all__ = ( "ChangeDetail", + "ConfigViewInterfaceBase", + "DriverNotFoundError", + "DuplicateChildError", + "FutureReport", "HConfig", "HConfigChild", + "HConfigDriverBase", + "HConfigDriverRules", + "HConfigViewBase", + "HierConfigError", + "IncompatibleDriverError", + "InterfaceBundleViewMixin", + "InterfaceNACViewMixin", + "InterfacePhysicalViewMixin", + "InterfaceVlanViewMixin", + "InvalidConfigError", "MatchRule", "Platform", + "RemediationPlugin", "RemediationReporter", "ReportSummary", "TagRule", "TextStyle", "WorkflowRemediation", - "get_hconfig", "get_hconfig_driver", - "get_hconfig_fast_load", - "get_hconfig_from_dump", "get_hconfig_view", + "get_registered_platforms", + "register_driver", + "unregister_driver", ) diff --git a/hier_config/base.py b/hier_config/base.py index 1abe3565..2ea3c4d6 100644 --- a/hier_config/base.py +++ b/hier_config/base.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from itertools import chain from logging import getLogger -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from .children import HConfigChildren from .exceptions import DuplicateChildError @@ -16,7 +16,6 @@ from .platforms.driver_base import HConfigDriverBase from .root import HConfig - _HConfigRootOrChildT = TypeVar("_HConfigRootOrChildT", bound=HConfig | HConfigChild) logger = getLogger(__name__) @@ -26,8 +25,8 @@ class HConfigBase(ABC): # ruff:ignore[too-many-public-methods] Both `HConfig` (the root) and `HConfigChild` (individual nodes) inherit from this class. It provides the shared tree-manipulation API: adding, searching, - and diffing children, as well as the `_future` / `_config_to_get_to` algorithms - that power `WorkflowRemediation`. + and diffing children. The diff/remediation/future algorithms live in + `hier_config.tree_algorithms` and are invoked from `HConfig`. """ __slots__ = ("children",) @@ -36,7 +35,7 @@ def __init__(self) -> None: self.children = HConfigChildren() def __len__(self) -> int: - return len(tuple(self.all_children())) + return sum(1 for _ in self.all_children()) def __bool__(self) -> bool: return True @@ -51,20 +50,21 @@ def __iter__(self) -> Iterator[HConfigChild]: @property @abstractmethod def root(self) -> HConfig: - pass + """The `HConfig` object at the base of the tree.""" @property @abstractmethod def driver(self) -> HConfigDriverBase: - pass + """The platform driver associated with this tree.""" @abstractmethod def lineage(self) -> Iterator[HConfigChild]: - pass + """Yield the lineage of parent objects, up to but excluding the root.""" + @property @abstractmethod def depth(self) -> int: - pass + """The distance to the root HConfig object i.e. indent level.""" def add_children(self, lines: Iterable[str]) -> None: """Add child instances of HConfigChild.""" @@ -98,6 +98,7 @@ def add_child( return new_child def path(self) -> Iterator[str]: # ruff:ignore[no-self-use] + """Yield the text of each lineage node; the root itself yields nothing.""" yield from () def add_deep_copy_of( @@ -231,7 +232,7 @@ def add_shallow_copy_of( new_child.comments.update(child_to_add.comments) new_child.order_weight = child_to_add.order_weight if child_to_add.is_leaf: - new_child.tags_add(child_to_add.tags) + new_child.add_tags(child_to_add.tags) return new_child @@ -273,261 +274,13 @@ def unified_diff(self, target: HConfig | HConfigChild) -> Iterator[str]: for c in target_child.all_children_sorted() ) - def _future_pre(self, config: HConfig | HConfigChild) -> tuple[set[str], set[str]]: - negated_or_recursed: set[str] = set() - config_children_ignore: set[str] = set() - for self_child in self.children: - # Is the command effectively negating a command in self.children? - if (negation_text := self.root.driver.negate_with(self_child)) and ( - config_child := config.get_child(equals=negation_text) - ): - negated_or_recursed.add(self_child.text) - config_children_ignore.add(config_child.text) - return negated_or_recursed, config_children_ignore - - def _future( # ruff:ignore[complex-structure] - self, - config: HConfig | HConfigChild, - future_config: HConfig | HConfigChild, - ) -> None: - """Recursively compute the future configuration subtree. - - Called by :meth:`HConfig.future` to walk the config tree and merge - ``config`` on top of ``self``, applying driver-specific rules for - sectional overwrite, idempotency, and negation. The result is written - into ``future_config``. - - Known gaps (not yet accounted for): - - - Negating a numbered ACL when removing a single entry - - Idempotent command avoid list - - And likely other edge cases - """ - negated_or_recursed, config_children_ignore = self._future_pre(config) - - for config_child in config.children: - if config_child.text in config_children_ignore: - continue - is_negation = config_child.text.startswith(self.driver.negation_prefix) - # sectional_overwrite - # sectional_overwrite_no_negate - if ( - config_child.use_sectional_overwrite() - or config_child.use_sectional_overwrite_without_negation() - ): - future_config.add_deep_copy_of(config_child) - # A negation whose positive form exists removes it; neither line - # survives. Evaluated before the idempotency rules, which can - # match the negation line itself and keep it as a literal child - # (#269). - elif is_negation and ( - exact := self.get_child(equals=config_child.text_without_negation) - ): - negated_or_recursed.add(exact.text) - # Idempotent commands: interchangeable forms of one setting - # replace each other. This deliberately covers negated forms - # tracked by a rule (e.g. IOS `no logging console`), which - # persist in the render. - elif self_child := self.root.driver.idempotent_for( - config_child, - self.children, - ): - future_config.add_deep_copy_of(config_child) - negated_or_recursed.add(self_child.text) - # Shorthand negation: `no description` removes `description foo`, - # as devices do (#269). - elif is_negation and ( - prefix_matches := [ - child - for child in self.children - if child.text.startswith( - f"{config_child.text_without_negation} ", - ) - ] - ): - negated_or_recursed.update(child.text for child in prefix_matches) - # config_child is already in self - elif self_child := self.get_child(equals=config_child.text): - future_child = future_config.add_shallow_copy_of(self_child) - self_child._future(config_child, future_child) # ruff:ignore[private-member-access] - 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) - # The negated form of config_child is in self.children - elif self_child := self.get_child( - equals=f"{self.driver.negation_prefix}{config_child.text}", - ): - negated_or_recursed.add(self_child.text) - # config_child is not in self and doesn't match a special case - else: - future_config.add_deep_copy_of(config_child) - - for self_child in self.children: - # self_child matched an above special case and should be ignored - if self_child.text in negated_or_recursed: - continue - # self_child was not modified above and should be present in the future config - future_config.add_deep_copy_of(self_child) - - def _prune_emptied_branches(self, future_node: HConfigBase) -> None: - """Remove branches that a change emptied out, as devices do (#269). - - Only prunes nodes whose counterpart in ``self`` (the running config) - had children; sections that were already empty (or are newly added - empty) are kept. Cascades upward via post-order traversal. - """ - for child in tuple(future_node.children): - source_child = self.get_child(equals=child.text) - if source_child is not None: - source_child._prune_emptied_branches(child) # ruff:ignore[private-member-access] - if not child.children and source_child.children: - child.delete() - @abstractmethod def instantiate_child(self, text: str) -> HConfigChild: - pass + """Create a new `HConfigChild` with self as the parent. + + The child is not appended to `self.children`; use `add_child` for that. + """ @abstractmethod def _is_duplicate_child_allowed(self) -> bool: pass - - def _with_tags( - self, - tags: frozenset[str], - new_instance: _HConfigRootOrChildT, - ) -> _HConfigRootOrChildT: - """Adds children recursively that have a subset of tags.""" - for child in self.children: - if tags.issubset(child.tags): - new_child = new_instance.add_shallow_copy_of(child) - child._with_tags(tags, new_instance=new_child) # ruff:ignore[private-member-access] - - return new_instance - - def _config_to_get_to( - self, - target: _HConfigRootOrChildT, - delta: _HConfigRootOrChildT, - ) -> _HConfigRootOrChildT: - """Figures out what commands need to be executed to transition from self to target. - self is the source data structure(i.e. the running_config), - target is the destination(i.e. generated_config). - - """ - self._config_to_get_to_left(target, delta) - self._config_to_get_to_right(target, delta) - - return delta - - @staticmethod - def _strip_acl_sequence_number(hier_child: HConfigChild) -> str: - words = hier_child.text.split() - if words[0].isdecimal(): - words.pop(0) - return " ".join(words) - - def _difference( - self, - target: _HConfigRootOrChildT, - delta: _HConfigRootOrChildT, - target_acl_children: dict[str, HConfigChild] | None = None, - *, - in_acl: bool = False, - ) -> _HConfigRootOrChildT: - acl_sw_matches = tuple(f"ip{x} access-list " for x in ("", "v4", "v6")) - - for self_child in self.children: - # Not dealing with negations and defaults for now - if self_child.text.startswith((self.driver.negation_prefix, "default ")): - continue - - if in_acl: - # Ignore ACL sequence numbers - if target_acl_children is None: - message = "target_acl_children cannot be None" - raise TypeError(message) - target_child = target_acl_children.get( - self._strip_acl_sequence_number(self_child), - ) - else: - target_child = target.get_child(equals=self_child.text) - - if target_child is None: - delta.add_deep_copy_of(self_child) - else: - delta_child = delta.add_child(self_child.text) - if self_child.text.startswith(acl_sw_matches): - self_child._difference( # ruff:ignore[private-member-access] - target_child, - delta_child, - target_acl_children={ - self._strip_acl_sequence_number(c): c - for c in target_child.children - }, - in_acl=True, - ) - else: - self_child._difference(target_child, delta_child) # ruff:ignore[private-member-access] - if not delta_child.children: - delta_child.delete() - - return delta - - def _config_to_get_to_left( - self, - target: HConfig | HConfigChild, - delta: HConfig | HConfigChild, - ) -> None: - # find self.children that are not in target.children - # i.e. what needs to be negated or defaulted - # Also, find out if another command in self.children will overwrite - # i.e. be idempotent - for self_child in self.children: - if self_child.text in target.children: - continue - if self_child.is_idempotent_command(target.children): - continue - - # in other but not self - # add this node but not any children - negated = delta.add_child(self_child.text).negate() - if self_child.children: - negated.comments.add(f"removes {len(self_child.children) + 1} lines") - - def _config_to_get_to_right( - self, - target: HConfig | HConfigChild, - delta: HConfig | HConfigChild, - ) -> None: - # Find what would need to be added to source_config to get to self - for target_child in target.children: - # If the child exist, recurse into its children - if self_child := self.children.get(target_child.text): - # Do we need to rewrite the child and its children as well? - if self_child.use_sectional_overwrite(): - self_child.overwrite_with(target_child, delta) - continue - if self_child.use_sectional_overwrite_without_negation(): - self_child.overwrite_with(target_child, delta, negate=False) - continue - # This creates a new HConfigChild object just in case there are some delta children. - # This is not very efficient, think of a way to not do this. - subtree = delta.instantiate_child(target_child.text) - self_child._config_to_get_to(target_child, subtree) # ruff:ignore[private-member-access] - if subtree.children: - delta.children.append(subtree) - # The child is absent, add it. - else: - # If the target_child is already in the delta, that means it was negated in the target config - if target_child.text in delta.children: - continue - new_item = delta.add_deep_copy_of(target_child) - # Mark the new item and all of its children as new_in_config. - new_item.new_in_config = True - for child in new_item.all_children(): - child.new_in_config = True - if new_item.children: - new_item.comments.add("new section") diff --git a/hier_config/child.py b/hier_config/child.py index 6890e16b..6dc07c3c 100644 --- a/hier_config/child.py +++ b/hier_config/child.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any from .base import HConfigBase -from .models import Instance, MatchRule, SetLikeOfStr, TextStyle +from .models import Instance, MatchRule, NegationStrategy, SetLikeOfStr, TextStyle if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -90,10 +90,12 @@ def __ne__(self, other: object) -> bool: @property def driver(self) -> HConfigDriverBase: + """The platform driver, inherited from the root HConfig object.""" return self.root.driver @property def text(self) -> str: + """The configuration text of this node, stripped of surrounding whitespace.""" return self._text @text.setter @@ -106,6 +108,7 @@ def text(self, value: str) -> None: @property def text_without_negation(self) -> str: + """The text with the driver's negation prefix removed, if present.""" return self.text.removeprefix(self.driver.negation_prefix) @property @@ -114,24 +117,33 @@ def root(self) -> HConfig: return self.parent.root def lines(self, *, sectional_exiting: bool = False) -> Iterable[str]: - yield self.cisco_style_text() + """Yield the indented config lines of self and its children. + + With `sectional_exiting`, the driver's exit token is appended after + each section that requires one. + """ + yield self.indented_text() for child in sorted(self.children): yield from child.lines(sectional_exiting=sectional_exiting) if sectional_exiting and (exit_text := self.sectional_exit): depth = ( - self.depth() - 1 - if self.sectional_exit_text_parent_level - else self.depth() + self.depth - 1 if self.sectional_exit_text_parent_level else self.depth ) yield " " * self.driver.rules.indentation * depth + exit_text @property def sectional_exit(self) -> str | None: + """The driver-determined exit token for this section, if any.""" return self.driver.sectional_exit(self) @property def sectional_exit_text_parent_level(self) -> bool: + """Whether the exit token renders at the parent's indentation level. + + Determined by the first matching sectional-exiting rule; defaults + to False. + """ for rule in self.driver.rules.sectional_exiting: if self.is_lineage_match(rule.match_rules): return rule.exit_text_parent_level @@ -139,6 +151,11 @@ def sectional_exit_text_parent_level(self) -> bool: return False def delete_sectional_exit(self) -> None: + """Remove the last child if it matches this section's exit token. + + Used after parsing so that stored trees do not retain explicit exit + lines. + """ try: potential_exit = self.children[-1] except IndexError: @@ -147,9 +164,10 @@ def delete_sectional_exit(self) -> None: if (exit_text := self.sectional_exit) and exit_text == potential_exit.text: potential_exit.delete() + @property def depth(self) -> int: """The distance to the root HConfig object i.e. indent level.""" - return self.parent.depth() + 1 + return self.parent.depth + 1 def move(self, new_parent: HConfig | HConfigChild) -> None: """Move one HConfigChild object to different HConfig parent object. @@ -179,12 +197,12 @@ def path(self) -> Iterator[str]: for child in self.lineage(): yield child.text - def cisco_style_text( + def indented_text( self, style: TextStyle = "without_comments", tag: str | None = None, ) -> str: - """Return a Cisco style formated line i.e. indentation_level + text ! comments.""" + """Return an indented text line i.e. indentation_level + text ! comments.""" comments: list[str] = [] if style == "without_comments": pass @@ -210,27 +228,28 @@ def cisco_style_text( @property def indentation(self) -> str: - return " " * self.driver.rules.indentation * (self.depth() - 1) + """The leading whitespace rendered before this node's text.""" + return " " * self.driver.rules.indentation * (self.depth - 1) def delete(self) -> None: """Delete the current object from its parent.""" self.parent.children.delete(self) - def tags_add(self, tag: str | Iterable[str]) -> None: + def add_tags(self, tag: str | Iterable[str]) -> None: """Add a tag to self._tags on all leaf nodes.""" if self.is_branch: for child in self.children: - child.tags_add(tag) + child.add_tags(tag) elif isinstance(tag, str): self._tags.add(tag) else: self._tags.update(tag) - def tags_remove(self, tag: str | Iterable[str]) -> None: + def remove_tags(self, tag: str | Iterable[str]) -> None: """Remove a tag from self._tags on all leaf nodes.""" if self.is_branch: for child in self.children: - child.tags_remove(tag) + child.remove_tags(tag) elif isinstance(tag, str): self._tags.remove(tag) else: @@ -239,16 +258,15 @@ def tags_remove(self, tag: str | Iterable[str]) -> None: def negate(self) -> HConfigChild: """Negate self.text using driver-specific negation rules. - Negation is resolved in the following priority order: + Negation is resolved via the driver's unified ``negation`` rule list + (#220). ``driver.negate_with()`` is consulted first (REPLACE-strategy + rules plus any imperative driver override), then the remaining rules + are evaluated in list order — first match wins: - 1. ``negate_with`` rule — replaces ``self.text`` with a custom - negation string defined in the driver (e.g. ``no ip route``). - 2. ``negation_default_when`` rule — rewrites the command to its - ``default`` form (e.g. ``no shutdown`` → ``default shutdown``). - 3. ``negation_sub`` rule — applies a regex substitution to the - negated text (e.g. truncating after a specific token). - 4. ``swap_negation`` — toggles the negation prefix/declaration - prefix (e.g. ``shutdown`` ↔ ``no shutdown``). + - ``DEFAULT`` — rewrites the command to its ``default`` form. + - ``REGEX_SUB`` — applies a regex substitution to the negated text. + + Falls back to ``swap_negation`` (e.g. ``shutdown`` ↔ ``no shutdown``). Returns self so that callers can chain further operations. """ @@ -256,11 +274,12 @@ def negate(self) -> HConfigChild: self.text = negate_with return self - if self.use_default_for_negation(self): - return self._default() - - for rule in self.driver.rules.negation_sub: + for rule in self.driver.rules.negation: + if rule.strategy is NegationStrategy.REPLACE: + continue if self.is_lineage_match(rule.match_rules): + if rule.strategy is NegationStrategy.DEFAULT: + return self._default() self.text = sub( rule.search, rule.replace, @@ -270,12 +289,6 @@ def negate(self) -> HConfigChild: return self.driver.swap_negation(self) - def use_default_for_negation(self, config: HConfigChild) -> bool: - return any( - config.is_lineage_match(rule.match_rules) - for rule in self.driver.rules.negation_default_when - ) - @property def is_leaf(self) -> bool: """True if there are no children and is not an instance of HConfig.""" @@ -348,7 +361,7 @@ def overwrite_with( comment is attached to the new entry, and a ``"dropping section"`` comment is added to the negated entry when applicable. - Used by :meth:`_config_to_get_to_right` when a sectional-overwrite + Used by :meth:`_remediation_right` when a sectional-overwrite rule is active for ``self.text``. """ if self.children != target.children: @@ -386,6 +399,10 @@ def line_inclusion_test( @property def instance(self) -> Instance: + """The `Instance` record for this node (root config id, comments, and tags). + + Used to track the origin of a child when merging multiple configs. + """ return Instance( id=id(self.root), comments=frozenset(self.comments), @@ -501,6 +518,7 @@ def _default(self) -> HConfigChild: return self def instantiate_child(self, text: str) -> HConfigChild: + """Create a new `HConfigChild` with self as the parent.""" return HConfigChild(self, text) def _is_duplicate_child_allowed(self) -> bool: diff --git a/hier_config/children.py b/hier_config/children.py index 54be16c7..22aa6514 100644 --- a/hier_config/children.py +++ b/hier_config/children.py @@ -90,6 +90,11 @@ def append( *, update_mapping: bool = True, ) -> HConfigChild: + """Append a child and return it. + + With `update_mapping=False`, the child is added to the ordered list + only (used for allowed duplicate children). + """ self._data.append(child) if update_mapping: self._mapping.setdefault(child.text, child) @@ -120,9 +125,11 @@ def extend(self, children: Iterable[HConfigChild]) -> None: self._mapping.setdefault(child.text, child) def get(self, key: str, default: _D | None = None) -> HConfigChild | _D | None: + """Return the child whose text equals `key`, or `default` if not found.""" return self._mapping.get(key, default) def index(self, child: HConfigChild) -> int: + """Return the position of `child` in the ordered list.""" return self._data.index(child) def rebuild_mapping(self) -> None: diff --git a/hier_config/constructors.py b/hier_config/constructors.py index c557e5a4..9abc66d9 100644 --- a/hier_config/constructors.py +++ b/hier_config/constructors.py @@ -1,5 +1,6 @@ from contextlib import suppress from itertools import islice +from json import JSONDecodeError, loads from logging import getLogger from pathlib import Path from re import search, sub @@ -7,89 +8,71 @@ from hier_config.platforms.driver_base import HConfigDriverBase from .child import HConfigChild +from .exceptions import DriverNotFoundError, InvalidConfigError from .models import Dump, Platform -from .platforms.arista_eos.driver import HConfigDriverAristaEOS -from .platforms.arista_eos.view import HConfigViewAristaEOS -from .platforms.aruba_aoscx.driver import HConfigDriverArubaAOSCX -from .platforms.aruba_aoscx.view import HConfigViewArubaAOSCX -from .platforms.cisco_ios.driver import HConfigDriverCiscoIOS -from .platforms.cisco_ios.view import HConfigViewCiscoIOS -from .platforms.cisco_nxos.driver import HConfigDriverCiscoNXOS -from .platforms.cisco_nxos.view import HConfigViewCiscoNXOS -from .platforms.cisco_xr.driver import HConfigDriverCiscoIOSXR -from .platforms.cisco_xr.view import HConfigViewCiscoIOSXR -from .platforms.fortinet_fortios.driver import HConfigDriverFortinetFortiOS -from .platforms.generic.driver import HConfigDriverGeneric -from .platforms.hp_comware5.driver import HConfigDriverHPComware5 -from .platforms.hp_procurve.driver import HConfigDriverHPProcurve -from .platforms.hp_procurve.view import HConfigViewHPProcurve -from .platforms.huawei_vrp.driver import HConfigDriverHuaweiVrp -from .platforms.juniper_junos.driver import HConfigDriverJuniperJUNOS -from .platforms.nokia_srl.driver import HConfigDriverNokiaSRL from .platforms.view_base import HConfigViewBase -from .platforms.vyos.driver import HConfigDriverVYOS +from .registry import resolve_driver from .root import HConfig logger = getLogger(__name__) -def get_hconfig_driver(platform: Platform) -> HConfigDriverBase: - """Create base options on an OS level.""" - platform_drivers: dict[Platform, type[HConfigDriverBase]] = { - Platform.ARISTA_EOS: HConfigDriverAristaEOS, - Platform.ARUBA_AOSCX: HConfigDriverArubaAOSCX, - Platform.CISCO_IOS: HConfigDriverCiscoIOS, - Platform.CISCO_NXOS: HConfigDriverCiscoNXOS, - Platform.CISCO_XR: HConfigDriverCiscoIOSXR, - Platform.FORTINET_FORTIOS: HConfigDriverFortinetFortiOS, - Platform.GENERIC: HConfigDriverGeneric, - Platform.HP_PROCURVE: HConfigDriverHPProcurve, - Platform.HP_COMWARE5: HConfigDriverHPComware5, - Platform.HUAWEI_VRP: HConfigDriverHuaweiVrp, - Platform.JUNIPER_JUNOS: HConfigDriverJuniperJUNOS, - Platform.NOKIA_SRL: HConfigDriverNokiaSRL, - Platform.VYOS: HConfigDriverVYOS, - } - driver_cls = platform_drivers.get(platform) - - if driver_cls is None: - message = f"Unsupported platform: {platform}" - raise ValueError(message) - - return driver_cls() +def get_hconfig_view(config: HConfig) -> HConfigViewBase: + """Instantiates the HConfigView declared by the config's driver. + Drivers declare their view via the `view_class` attribute, so a custom + driver can register its own view by setting `view_class` on the subclass. + """ + if view_class := config.driver.view_class: + return view_class(config) -def get_hconfig_view(config: HConfig) -> HConfigViewBase: - """Instantiates the appropriate HConfigView. + message = f"No view registered for driver: {config.driver.__class__.__name__}" + raise DriverNotFoundError(message) + + +def _detect_structured_format(config_text: str) -> str | None: + """Detect structured config formats that the text parser cannot ingest (#232). - If you implement your own HConfigView, you will likely need to create a function like this one locally. + Guards the raw-text entry points (from_text() and the str form of + from_lines()); pre-split lines are assumed to be CLI text. """ - driver = config.driver - if isinstance(driver, HConfigDriverAristaEOS): - return HConfigViewAristaEOS(config) - if isinstance(driver, HConfigDriverArubaAOSCX): - return HConfigViewArubaAOSCX(config) - if isinstance(driver, HConfigDriverCiscoIOS): - return HConfigViewCiscoIOS(config) - if isinstance(driver, HConfigDriverCiscoNXOS): - return HConfigViewCiscoNXOS(config) - if isinstance(driver, HConfigDriverCiscoIOSXR): - return HConfigViewCiscoIOSXR(config) - if isinstance(driver, HConfigDriverHPProcurve): - return HConfigViewHPProcurve(config) - - message = f"Unsupported platform: {config.driver.__class__.__name__}" - raise ValueError(message) - - -def get_hconfig( - platform_or_driver: Platform | HConfigDriverBase, + prefix = config_text[:64].lstrip() + if prefix.startswith("<"): + return "XML" + if prefix.startswith(("{", "[")): + with suppress(JSONDecodeError): + loads(config_text) + return "JSON" + return None + + +def _reject_structured_format(config_text: str) -> None: + if detected := _detect_structured_format(config_text): + message = ( + f"The config appears to be {detected}. Use HConfig.from_xml() or" + " HConfig.from_json() for structured formats, or convert to the" + " platform's indented CLI text (set-style configs are supported" + " natively by the Juniper JunOS, VyOS, and Nokia SRL drivers)." + ) + raise InvalidConfigError(message) + + +def hconfig_from_text( + platform_or_driver: Platform | str | HConfigDriverBase, config_raw: Path | str = "", ) -> HConfig: + """Create an HConfig from raw configuration text (or a Path to it). + + Applies the driver's full-text substitutions, parses the text into a + tree (including banner handling), strips sectional-exit lines, and runs + post-load callbacks. + """ if isinstance(config_raw, Path): config_raw = config_raw.read_text(encoding="utf8") - config = HConfig(_get_driver(platform_or_driver)) + _reject_structured_format(config_raw) + + config = HConfig(resolve_driver(platform_or_driver)) for rule in config.driver.rules.full_text_sub: config_raw = sub(rule.search, rule.replace, config_raw) @@ -104,21 +87,21 @@ def get_hconfig( return config -def get_hconfig_from_dump( - platform_or_driver: Platform | HConfigDriverBase, dump: Dump +def hconfig_from_dump( + platform_or_driver: Platform | str | HConfigDriverBase, dump: Dump ) -> HConfig: """Load an HConfig dump.""" - config = get_hconfig(_get_driver(platform_or_driver)) + config = HConfig(resolve_driver(platform_or_driver)) last_item: HConfig | HConfigChild = config for item in dump.lines: # parent is the root if item.depth == 1: parent: HConfig | HConfigChild = config # has the same parent - elif last_item.depth() == item.depth: + elif last_item.depth == item.depth: parent = last_item.parent # is a child object - elif last_item.depth() + 1 == item.depth: + elif last_item.depth + 1 == item.depth: parent = last_item # has a parent somewhere closer to the root but not the root else: @@ -132,19 +115,20 @@ def get_hconfig_from_dump( return config -def get_hconfig_fast_generic_load( +def hconfig_from_lines( + platform_or_driver: Platform | str | HConfigDriverBase, lines: list[str] | tuple[str, ...] | str, ) -> HConfig: - return get_hconfig_fast_load(Platform.GENERIC, lines) + """Create an HConfig from pre-split configuration lines (fast load). - -def get_hconfig_fast_load( - platform_or_driver: Platform | HConfigDriverBase, - lines: list[str] | tuple[str, ...] | str, -) -> HConfig: - driver = _get_driver(platform_or_driver) - config = get_hconfig(driver) + Applies per-line substitutions and indentation analysis but skips the + full-text substitutions, config preprocessor, and banner handling of + `hconfig_from_text`. + """ + driver = resolve_driver(platform_or_driver) + config = HConfig(driver) if isinstance(lines, str): + _reject_structured_format(lines) lines = lines.splitlines() current_section: HConfig | HConfigChild = config @@ -180,14 +164,6 @@ def get_hconfig_fast_load( return config -def _get_driver( - platform_or_driver: Platform | HConfigDriverBase, -) -> HConfigDriverBase: - if isinstance(platform_or_driver, Platform): - return get_hconfig_driver(platform_or_driver) - return platform_or_driver - - def _analyze_indent( most_recent_item: HConfig | HConfigChild, current_section: HConfig | HConfigChild, @@ -232,86 +208,103 @@ def _config_from_string_lines_end_of_banner_test( return any(c in config_line for c in banner_end_contains) -def _load_from_string_lines(config: HConfig, config_text: str) -> None: # ruff:ignore[complex-structure] - config_text = config.driver.config_preprocessor(config_text) - current_section: HConfig | HConfigChild = config - most_recent_item: HConfig | HConfigChild = current_section - indent_adjust = 0 - end_indent_adjust: list[str] = [] - temp_banner: list[str] = [] - banner_end_lines = {"EOF", "%", "!"} - banner_end_contains: list[str] = [] - in_banner = False - - for line in config_text.splitlines(): - # Process banners in configuration into one line - if in_banner: - if line != "!": - temp_banner.append(line) - - # Test if this line is the end of a banner - if _config_from_string_lines_end_of_banner_test( - line, - frozenset(banner_end_lines), - banner_end_contains, - ): - in_banner = False - most_recent_item = config.add_child( - "\n".join(temp_banner), - ) - most_recent_item.real_indent_level = 0 - current_section = config - temp_banner = [] - continue +class _ConfigTextLoader: # pylint: disable=too-many-instance-attributes,too-few-public-methods + """Stateful parser turning raw config text into an HConfig tree (#186). - # Test if this line is the start of a banner and not an empty banner - # Empty banners matching the below expression have been seen on NX-OS - if line.startswith("banner ") and line != "banner motd ##": - in_banner = True - temp_banner.append(line) - banner_words = line.split() - with suppress(IndexError): - banner_end_contains.append(banner_words[2]) - # Handle banner on ArubaOS-Switch - if banner_words[2].startswith('"'): - banner_end_contains.append('"') - banner_end_lines.add(banner_words[2][:1]) - banner_end_lines.add(banner_words[2][:2]) + Splits the three responsibilities of the former monolithic loader into + focused methods: banner detection/aggregation, line normalization, and + indentation-based hierarchy construction. + """ - continue + def __init__(self, config: HConfig) -> None: + self.config = config + self.current_section: HConfig | HConfigChild = config + self.most_recent_item: HConfig | HConfigChild = config + self.indent_adjust = 0 + self.end_indent_adjust: list[str] = [] + self.temp_banner: list[str] = [] + self.banner_end_lines = {"EOF", "%", "!"} + self.banner_end_contains: list[str] = [] + self.in_banner = False + + def load(self, config_text: str) -> None: + config_text = self.config.driver.config_preprocessor(config_text) + for line in config_text.splitlines(): + if self.in_banner: + self._process_banner_line(line) + elif not self._detect_banner_start(line): + self._process_config_line(line) + if self.in_banner: + message = "we are still in a banner for some reason" + raise InvalidConfigError(message) + + def _process_banner_line(self, line: str) -> None: + """Aggregate banner content until the end marker, then emit one child.""" + if line != "!": + self.temp_banner.append(line) + + if _config_from_string_lines_end_of_banner_test( + line, + frozenset(self.banner_end_lines), + self.banner_end_contains, + ): + self.in_banner = False + self.most_recent_item = self.config.add_child("\n".join(self.temp_banner)) + self.most_recent_item.real_indent_level = 0 + self.current_section = self.config + self.temp_banner = [] + + def _detect_banner_start(self, line: str) -> bool: + """Detect banner start markers and record the expected end markers.""" + # Empty banners matching the below expression have been seen on NX-OS + if not line.startswith("banner ") or line == "banner motd ##": + return False + self.in_banner = True + self.temp_banner.append(line) + banner_words = line.split() + with suppress(IndexError): + self.banner_end_contains.append(banner_words[2]) + # Handle banner on ArubaOS-Switch + if banner_words[2].startswith('"'): + self.banner_end_contains.append('"') + self.banner_end_lines.add(banner_words[2][:1]) + self.banner_end_lines.add(banner_words[2][:2]) + return True + def _normalize_line(self, line: str) -> str: + """Collapse repeated whitespace and apply per-line substitutions.""" actual_indent = len(line) - len(line.lstrip()) - line = " " * actual_indent + " ".join(line.split()) # ruff:ignore[redefined-loop-name] - for rule in config.driver.rules.per_line_sub: - line = sub(rule.search, rule.replace, line) # ruff:ignore[redefined-loop-name] - line = line.rstrip() # ruff:ignore[redefined-loop-name] - - # If line is now empty, move to the next + line = " " * actual_indent + " ".join(line.split()) + for rule in self.config.driver.rules.per_line_sub: + line = sub(rule.search, rule.replace, line) + return line.rstrip() + + def _process_config_line(self, line: str) -> None: + """Attach a normalized config line to the correct place in the tree.""" + line = self._normalize_line(line) if not line: - continue + return # Determine indentation level (after per_line_sub rules are applied) - this_indent = len(line) - len(line.lstrip()) + indent_adjust + this_indent = len(line) - len(line.lstrip()) + self.indent_adjust + line = line.lstrip() - line = line.lstrip() # ruff:ignore[redefined-loop-name] - - # Determine parent in hierarchy - most_recent_item, current_section = _analyze_indent( - most_recent_item, - current_section, + self.most_recent_item, self.current_section = _analyze_indent( + self.most_recent_item, + self.current_section, this_indent, line, ) - indent_adjust, end_indent_adjust = _adjust_indent( - config.driver, + self.indent_adjust, self.end_indent_adjust = _adjust_indent( + self.config.driver, line, - indent_adjust, - end_indent_adjust, + self.indent_adjust, + self.end_indent_adjust, ) + if self.end_indent_adjust and search(self.end_indent_adjust[0], line): + self.indent_adjust -= 1 + self.end_indent_adjust.pop(0) + - if end_indent_adjust and search(end_indent_adjust[0], line): - indent_adjust -= 1 - end_indent_adjust.pop(0) - if in_banner: - message = "we are still in a banner for some reason" - raise ValueError(message) +def _load_from_string_lines(config: HConfig, config_text: str) -> None: + _ConfigTextLoader(config).load(config_text) diff --git a/hier_config/exceptions.py b/hier_config/exceptions.py index 82d93df2..e4b4ccf2 100644 --- a/hier_config/exceptions.py +++ b/hier_config/exceptions.py @@ -1,2 +1,18 @@ -class DuplicateChildError(Exception): +class HierConfigError(Exception): + """Base exception for all hier_config errors.""" + + +class DuplicateChildError(HierConfigError): """Raised when attempting to add a duplicate child.""" + + +class DriverNotFoundError(HierConfigError): + """Raised when a platform driver cannot be found.""" + + +class InvalidConfigError(HierConfigError): + """Raised for malformed configuration text.""" + + +class IncompatibleDriverError(HierConfigError): + """Raised when configs with mismatched drivers are used together.""" diff --git a/hier_config/formats.py b/hier_config/formats.py new file mode 100644 index 00000000..10a2edff --- /dev/null +++ b/hier_config/formats.py @@ -0,0 +1,552 @@ +"""Structured config format ingestion and rendering (#232). + +Maps JSON (e.g. OpenConfig) and XML (e.g. NETCONF payloads) onto the same +`HConfig` tree used by the rest of the library, so structured configs can be +diffed and predicted like CLI text, and renders trees back to those formats. + +Mapping rules (JSON): + +- object key + scalar -> leaf ``key `` +- object key + object -> node ``key`` with the object's members as children +- object key + list of scalars -> one leaf per item, ``key `` +- object key + list of objects -> one node per entry, ``key ``, + where the identity is the value of the first ``list_keys`` member present in + the entry (OpenConfig-style keyed lists); all entry members, including the + identity leaf, become children. + +Mapping rules (XML): + +- element -> node ``tag``, or ``tag `` when the tag repeats + among its siblings (identity from the first ``list_keys`` child element) +- attribute -> leaf ``@name `` +- text content -> leaf ``tag `` for a childless, attribute-less + element, otherwise a ``#text `` child leaf +- empty element -> single-word leaf ``tag`` + +The ``@``/``#text`` line encoding is an implementation detail of the XML +mapping and may change in a future release; treat the trees as opaque between +``hconfig_from_xml`` and ``hconfig_to_xml``. + +Both mappings are invertible via ``hconfig_to_json`` / ``hconfig_to_xml``. +Known caveats: a JSON list of scalars with exactly one item renders back as a +bare scalar; empty JSON lists are dropped (the tree has no way to represent +them); duplicate list items or duplicate list-entry identities raise +``DuplicateChildError``. + +Remediation between ``hconfig_from_xml`` trees can be rendered as a NETCONF +``edit-config`` payload via ``hconfig_to_netconf_xml`` (deletions become +``nc:operation="delete"`` elements; additions use the default merge +operation). Attribute-level changes cannot be expressed as NETCONF +operations and raise ``InvalidConfigError``. + +Remediation between ``hconfig_from_json`` trees can be rendered as a +gNMI-SetRequest-style structure via ``hconfig_to_gnmi_json`` (deletions +become xpath-ish paths with ``[key=value]`` selectors for keyed list +entries; additions render into an ``update`` object using the JSON +mapping above). +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET # ruff:ignore[suspicious-xml-etree-import] +from collections import Counter +from json import JSONDecodeError, dumps, loads +from typing import TYPE_CHECKING, Any, NamedTuple, TypeAlias, TypedDict, cast + +from .exceptions import InvalidConfigError +from .registry import resolve_driver +from .root import HConfig + +if TYPE_CHECKING: + from .base import HConfigBase + from .child import HConfigChild + from .models import Platform + from .platforms.driver_base import HConfigDriverBase + +DEFAULT_LIST_KEYS = ("name", "id") + +NETCONF_BASE_NS = "urn:ietf:params:xml:ns:netconf:base:1.0" + +JsonValue: TypeAlias = ( + "str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None" +) + + +class GnmiRemediation(TypedDict): + """gNMI-SetRequest-style remediation: an update tree and delete paths.""" + + update: dict[str, JsonValue] + delete: list[str] + + +def hconfig_from_json( + platform_or_driver: Platform | str | HConfigDriverBase, + data: str | dict[str, Any], + *, + list_keys: tuple[str, ...] | None = None, +) -> HConfig: + """Create an HConfig from a JSON object (or JSON text).""" + if isinstance(data, str): + try: + data = loads(data) + except JSONDecodeError as exc: + message = f"The config is not valid JSON: {exc}" + raise InvalidConfigError(message) from exc + if not isinstance(data, dict): + message = "The top-level JSON value must be an object" + raise InvalidConfigError(message) + + config = HConfig(resolve_driver(platform_or_driver)) + _json_into( + config, + cast("dict[str, JsonValue]", data), + list_keys or DEFAULT_LIST_KEYS, + ) + return config + + +def hconfig_to_json(config: HConfig, *, indent: int | None = 2) -> str: + """Render an HConfig built by `hconfig_from_json` back to JSON text.""" + return dumps(_node_to_json_object(config), indent=indent) + + +def hconfig_from_xml( + platform_or_driver: Platform | str | HConfigDriverBase, + source: str, + *, + list_keys: tuple[str, ...] | None = None, +) -> HConfig: + """Create an HConfig from an XML document.""" + try: + root_element = ET.fromstring(source) # ruff:ignore[suspicious-xml-element-tree-usage] + except ET.ParseError as exc: + message = f"The config is not valid XML: {exc}" + raise InvalidConfigError(message) from exc + + config = HConfig(resolve_driver(platform_or_driver)) + _xml_element_into(config, root_element, list_keys or DEFAULT_LIST_KEYS) + return config + + +def hconfig_to_xml(config: HConfig) -> str: + """Render an HConfig built by `hconfig_from_xml` back to XML text.""" + if len(config.children) != 1: + message = "XML rendering requires a single root node" + raise InvalidConfigError(message) + root_node = next(iter(config.children)) + element = _node_to_xml_element(root_node) + ET.indent(element) + return ET.tostring(element, encoding="unicode") + + +def _json_key(key: object) -> str: + if not isinstance(key, str) or not key or any(char.isspace() for char in key): + message = f"Unsupported JSON key: {key!r} (keys must be non-empty strings without whitespace)" + raise InvalidConfigError(message) + return key + + +def _json_into( + parent: HConfigBase, + mapping: dict[str, JsonValue], + list_keys: tuple[str, ...], +) -> None: + for raw_key, value in mapping.items(): + key = _json_key(raw_key) + if isinstance(value, dict): + _json_into(parent.add_child(key), value, list_keys) + elif isinstance(value, list): + _json_list_into(parent, key, value, list_keys) + else: + parent.add_child(f"{key} {dumps(value)}") + + +def _json_list_into( + parent: HConfigBase, + key: str, + items: list[JsonValue], + list_keys: tuple[str, ...], +) -> None: + for item in items: + if isinstance(item, dict): + identity_key = next((k for k in list_keys if k in item), None) + if identity_key is None: + message = ( + f"List entries under {key!r} need one of {list_keys} to" + " identify them; pass list_keys= to name the identifying" + " member" + ) + raise InvalidConfigError(message) + entry = parent.add_child(f"{key} {dumps(item[identity_key])}") + _json_into(entry, item, list_keys) + elif isinstance(item, list): + message = f"Nested JSON arrays are not supported (under {key!r})" + raise InvalidConfigError(message) + else: + parent.add_child(f"{key} {dumps(item)}") + + +def _leaf_value(raw: str) -> JsonValue: + try: + return cast("JsonValue", loads(raw)) + except JSONDecodeError: + return raw + + +def _store_json_member( + result: dict[str, JsonValue], + key: str, + value: JsonValue, + *, + force_list: bool, +) -> None: + if key in result: + existing = result[key] + if isinstance(existing, list): + existing.append(value) + else: + result[key] = [existing, value] + elif force_list: + result[key] = [value] + else: + result[key] = value + + +def _node_to_json_object(node: HConfigBase) -> dict[str, JsonValue]: + result: dict[str, JsonValue] = {} + for child in node.children: + words = child.text.split(maxsplit=1) + key = words[0] + if child.children: + # A multi-word branch is a keyed list entry; grouped into a list. + _store_json_member( + result, + key, + _node_to_json_object(child), + force_list=len(words) > 1, + ) + elif len(words) > 1: + _store_json_member(result, key, _leaf_value(words[1]), force_list=False) + else: + # from_json produces a single-word childless node only for an + # empty object (scalar leaves always carry a value word). + _store_json_member(result, key, {}, force_list=False) + return result + + +def _xml_identity_suffix( + element: ET.Element, + list_keys: tuple[str, ...], + *, + required: bool, +) -> str: + for key in list_keys: + if (identity := element.find(key)) is not None and identity.text: + return f" {dumps(identity.text.strip())}" + if required: + message = ( + f"Repeated <{element.tag}> elements need a child element named one" + f" of {list_keys} to identify them; pass list_keys= to name the" + " identifying element" + ) + raise InvalidConfigError(message) + return "" + + +def _xml_element_into( + parent: HConfigBase, + element: ET.Element, + list_keys: tuple[str, ...], + *, + node_suffix: str = "", +) -> None: + node = parent.add_child(f"{element.tag}{node_suffix}") + for name, value in element.attrib.items(): + node.add_child(f"@{name} {dumps(value)}") + if text := (element.text or "").strip(): + node.add_child(f"#text {dumps(text)}") + + tag_counts = Counter(child.tag for child in element) + for child in element: + if not (len(child) or child.attrib): + child_text = (child.text or "").strip() + node.add_child( + f"{child.tag} {dumps(child_text)}" if child_text else child.tag + ) + else: + # Key the node whenever an identifying child exists so entries get + # the same text regardless of sibling count - configs with + # different entry counts must still diff surgically. An identity + # is only mandatory when the tag actually repeats. + suffix = _xml_identity_suffix( + child, + list_keys, + required=tag_counts[child.tag] > 1, + ) + _xml_element_into(node, child, list_keys, node_suffix=suffix) + + +def _node_to_xml_element(node: HConfigChild) -> ET.Element: + words = node.text.split(maxsplit=1) + element = ET.Element(words[0]) + if not node.children: + if len(words) > 1: + element.text = _xml_text(words[1]) + return element + for child in node.children: + if child.children: + element.append(_node_to_xml_element(child)) + elif child.text.startswith("@"): + name, _, raw = child.text.partition(" ") + element.set(name[1:], str(_leaf_value(raw))) + elif child.text.startswith("#text "): + element.text = str(_leaf_value(child.text[len("#text ") :])) + else: + element.append(_node_to_xml_element(child)) + return element + + +def _xml_text(raw: str) -> str: + value = _leaf_value(raw) + return value if isinstance(value, str) else raw + + +def hconfig_to_netconf_xml( + remediation: HConfig, + *, + running: HConfig | None = None, + list_keys: tuple[str, ...] | None = None, +) -> str: + """Render a remediation between `hconfig_from_xml` trees as NETCONF XML. + + Negated nodes become elements with ``nc:operation="delete"``; everything + else uses the NETCONF default merge operation. When `running` is given, + deletions of keyed list entries are expressed by their key leaf (found + via `list_keys`); without it, deletions fall back to value-bearing leaf + elements. + """ + if len(remediation.children) != 1: + message = "XML rendering requires a single root node" + raise InvalidConfigError(message) + root_node = next(iter(remediation.children)) + running_root = ( + running.get_child(equals=root_node.text) if running is not None else None + ) + element = _netconf_element( + root_node, + remediation.driver.negation_prefix, + running_root, + list_keys or DEFAULT_LIST_KEYS, + ) + element.set("xmlns:nc", NETCONF_BASE_NS) + ET.indent(element) + return ET.tostring(element, encoding="unicode") + + +def _netconf_element( + node: HConfigChild, + negation_prefix: str, + running_node: HConfigChild | None, + list_keys: tuple[str, ...], +) -> ET.Element: + if node.text.startswith(negation_prefix): + return _netconf_delete_element( + node.text.removeprefix(negation_prefix), + running_node, + list_keys, + ) + words = node.text.split(maxsplit=1) + element = ET.Element(words[0]) + if not node.children: + if len(words) > 1: + element.text = _xml_text(words[1]) + return element + for child in node.children: + if not child.children and child.text.startswith("@"): + name, _, raw = child.text.partition(" ") + element.set(name[1:], str(_leaf_value(raw))) + elif not child.children and child.text.startswith("#text "): + element.text = _xml_text(child.text[len("#text ") :]) + else: + # A negated child is looked up in the running parent by + # _netconf_delete_element, so it receives the parent context. + child_running = ( + running_node + if child.text.startswith(negation_prefix) + else running_node.get_child(equals=child.text) + if running_node + else None + ) + element.append( + _netconf_element(child, negation_prefix, child_running, list_keys) + ) + return element + + +def _netconf_delete_element( + positive_text: str, + running_parent: HConfigChild | None, + list_keys: tuple[str, ...], +) -> ET.Element: + words = positive_text.split(maxsplit=1) + if words[0].startswith("@"): + message = ( + "Attribute changes cannot be expressed as NETCONF operations:" + f" {positive_text!r}" + ) + raise InvalidConfigError(message) + element = ET.Element(words[0]) + element.set("nc:operation", "delete") + if len(words) == 1: + return element + # A keyed list entry (branch in the running config) deletes by key leaf. + key = _running_entry_key(running_parent, positive_text, words[1], list_keys) + if key is not None: + ET.SubElement(element, key).text = _xml_text(words[1]) + return element + element.text = _xml_text(words[1]) + return element + + +def _matching_list_key( + entry: HConfigBase, + raw_value: str, + list_keys: tuple[str, ...], +) -> str | None: + for key in list_keys: + if entry.get_child(equals=f"{key} {raw_value}") is not None: + return key + return None + + +def _running_entry_key( + running_parent: HConfigBase | None, + positive_text: str, + raw_value: str, + list_keys: tuple[str, ...], +) -> str | None: + """Key leaf identifying `positive_text` as a keyed list entry, if any.""" + if running_parent is None: + return None + running_entry = running_parent.get_child(equals=positive_text) + if running_entry is None or not running_entry.children: + return None + return _matching_list_key(running_entry, raw_value, list_keys) + + +def hconfig_to_gnmi_json( + remediation: HConfig, + *, + running: HConfig | None = None, + list_keys: tuple[str, ...] | None = None, +) -> GnmiRemediation: + """Render a remediation between `hconfig_from_json` trees as gNMI-style sets. + + Negated nodes become xpath-ish delete paths; everything else renders + into the `update` object via the JSON mapping. When `running` is given, + deletions of keyed list entries get `[key=value]` selectors (keys found + via `list_keys`); without it, deletions fall back to bare leaf paths. + """ + result: GnmiRemediation = {"update": {}, "delete": []} + context = _GnmiContext( + delete=result["delete"], + negation_prefix=remediation.driver.negation_prefix, + list_keys=list_keys or DEFAULT_LIST_KEYS, + ) + _gnmi_into(remediation, result["update"], (), running, context) + return result + + +class _GnmiContext(NamedTuple): + delete: list[str] + negation_prefix: str + list_keys: tuple[str, ...] + + +def _gnmi_into( + node: HConfigBase, + update: dict[str, JsonValue], + path: tuple[str, ...], + running_node: HConfigBase | None, + context: _GnmiContext, +) -> None: + for child in node.children: + if child.text.startswith(context.negation_prefix): + # A negated child is resolved against the parent's running node. + context.delete.append( + _gnmi_delete_path( + path, + child.text.removeprefix(context.negation_prefix), + running_node, + context.list_keys, + ) + ) + continue + words = child.text.split(maxsplit=1) + if not child.children: + value: JsonValue = _leaf_value(words[1]) if len(words) > 1 else {} + _store_json_member(update, words[0], value, force_list=False) + continue + running_child = ( + running_node.get_child(equals=child.text) if running_node else None + ) + segment = words[0] + key_name: str | None = None + if len(words) > 1: + key_name = _gnmi_identity_key( + child, running_child, words[1], context.list_keys + ) + segment = ( + f"{words[0]}[{key_name or context.list_keys[0]}" + f"={_gnmi_selector_value(words[1])}]" + ) + child_update: dict[str, JsonValue] = {} + _gnmi_into(child, child_update, (*path, segment), running_child, context) + if not child_update: + # The branch contained only deletions. + continue + if key_name is not None and key_name not in child_update: + child_update = {key_name: _leaf_value(words[1]), **child_update} + _store_json_member(update, words[0], child_update, force_list=len(words) > 1) + + +def _gnmi_identity_key( + entry: HConfigChild, + running_entry: HConfigBase | None, + raw_value: str, + list_keys: tuple[str, ...], +) -> str | None: + for source in (entry, running_entry): + if source is None: + continue + key = _matching_list_key(source, raw_value, list_keys) + if key is not None: + return key + return None + + +def _gnmi_selector_value(raw: str) -> str: + return _xml_text(raw).replace("\\", "\\\\").replace("]", "\\]") + + +def _gnmi_delete_path( + parent_path: tuple[str, ...], + positive_text: str, + running_parent: HConfigBase | None, + list_keys: tuple[str, ...], +) -> str: + words = positive_text.split(maxsplit=1) + if words[0].startswith("@"): + message = ( + "Attribute changes cannot be expressed as gNMI delete paths:" + f" {positive_text!r}" + ) + raise InvalidConfigError(message) + segment = words[0] + # A keyed list entry (branch in the running config) deletes by selector; + # a scalar leaf deletes by its bare path (the value is dropped). + if len(words) > 1: + key = _running_entry_key(running_parent, positive_text, words[1], list_keys) + if key is not None: + segment = f"{words[0]}[{key}={_gnmi_selector_value(words[1])}]" + return "/".join((*parent_path, segment)) diff --git a/hier_config/models.py b/hier_config/models.py index 26ee47b9..109e47a8 100644 --- a/hier_config/models.py +++ b/hier_config/models.py @@ -2,7 +2,7 @@ from typing import Literal from pydantic import BaseModel as PydanticBaseModel -from pydantic import ConfigDict, NonNegativeInt, PositiveInt +from pydantic import ConfigDict, NonNegativeInt, PositiveInt, model_validator TextStyle = Literal["without_comments", "merged", "with_comments"] @@ -126,34 +126,47 @@ class Instance(BaseModel): tags: frozenset[str] -class NegationDefaultWhenRule(BaseModel): - """Rule specifying when negation should use the ``default`` form.""" - - match_rules: tuple[MatchRule, ...] +class NegationStrategy(str, Enum): + """How a matching command is negated (#220).""" + #: Replace the command with the fixed string in ``use``. + REPLACE = "replace" + #: Rewrite the command to its ``default`` form. + DEFAULT = "default" + #: Apply ``re.sub(search, replace, ...)`` to the already-negated text. + REGEX_SUB = "regex_sub" -class NegationDefaultWithRule(BaseModel): - """Rule replacing negation with a fixed custom command string.""" - - match_rules: tuple[MatchRule, ...] - use: str +class NegationRule(BaseModel): + r"""Unified negation rule (#220). -class NegationSubRule(BaseModel): - r"""Regex substitution applied to a command during negation. + Replaces the former ``NegationDefaultWithRule`` (``strategy=REPLACE``), + ``NegationDefaultWhenRule`` (``strategy=DEFAULT``), and ``NegationSubRule`` + (``strategy=REGEX_SUB``). REPLACE rules are consulted first (via + ``driver.negate_with()``, which imperative driver overrides also hook + into); the remaining rules are then evaluated in list order and the + first matching rule wins. - When a negated command matches ``match_rules``, ``re.sub(search, replace, text)`` - is applied to transform the negation line. Useful when a platform requires - truncated or reformatted negation commands — e.g. NX-OS SNMP user removal - must drop everything after the username. - - The regex is applied to the **already-negated** text (with ``no `` prepended). - ``replace`` supports back-references such as ``\1``. + For ``REGEX_SUB``, the regex is applied to the **already-negated** text + (with the negation prefix prepended) and ``replace`` supports + back-references such as ``\1``. """ match_rules: tuple[MatchRule, ...] - search: str - replace: str + strategy: NegationStrategy + use: str = "" + search: str = "" + replace: str = "" + + @model_validator(mode="after") + def _validate_strategy_fields(self) -> "NegationRule": + if self.strategy is NegationStrategy.REPLACE and not self.use: + message = "REPLACE strategy requires `use`" + raise ValueError(message) + if self.strategy is NegationStrategy.REGEX_SUB and not self.search: + message = "REGEX_SUB strategy requires `search`" + raise ValueError(message) + return self class ReferenceLocation(BaseModel): diff --git a/hier_config/platforms/arista_eos/driver.py b/hier_config/platforms/arista_eos/driver.py index bf570c0c..67f811da 100644 --- a/hier_config/platforms/arista_eos/driver.py +++ b/hier_config/platforms/arista_eos/driver.py @@ -1,10 +1,12 @@ from hier_config.models import ( IdempotentCommandsRule, MatchRule, - NegationDefaultWhenRule, + NegationRule, + NegationStrategy, PerLineSubRule, SectionalExitingRule, ) +from hier_config.platforms.arista_eos.view import HConfigViewAristaEOS from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules @@ -17,6 +19,8 @@ class HConfigDriverAristaEOS(HConfigDriverBase): commands. Platform enum: ``Platform.ARISTA_EOS``. """ + view_class = HConfigViewAristaEOS + @staticmethod def _instantiate_rules() -> HConfigDriverRules: return HConfigDriverRules( @@ -228,8 +232,9 @@ def _instantiate_rules() -> HConfigDriverRules: ), ), ], - negation_default_when=[ - NegationDefaultWhenRule( + negation=[ + NegationRule( + strategy=NegationStrategy.DEFAULT, match_rules=( MatchRule(startswith="interface"), MatchRule(equals="logging event link-status"), diff --git a/hier_config/platforms/arista_eos/view.py b/hier_config/platforms/arista_eos/view.py index 79747431..d7822313 100644 --- a/hier_config/platforms/arista_eos/view.py +++ b/hier_config/platforms/arista_eos/view.py @@ -2,172 +2,50 @@ from ipaddress import IPv4Address, IPv4Interface from hier_config.child import HConfigChild -from hier_config.platforms.models import ( - InterfaceDot1qMode, - InterfaceDuplex, - NACHostMode, - StackMember, - Vlan, -) +from hier_config.platforms.functions import parse_ipv4_interface from hier_config.platforms.view_base import ( - ConfigViewInterfaceBase, HConfigViewBase, + InterfaceBundleViewMixin, + InterfaceVlanViewMixin, ) -class ConfigViewInterfaceAristaEOS(ConfigViewInterfaceBase): # ruff:ignore[too-many-public-methods] +class ConfigViewInterfaceAristaEOS( + InterfaceBundleViewMixin, + InterfaceVlanViewMixin, +): """Interface config view for Arista EOS.""" - @property - def bundle_id(self) -> str | None: - raise NotImplementedError - - @property - def bundle_member_interfaces(self) -> Iterable[str]: - raise NotImplementedError - - @property - def bundle_name(self) -> str | None: - raise NotImplementedError - - @property - def description(self) -> str: - raise NotImplementedError - - @property - def duplex(self) -> InterfaceDuplex: - raise NotImplementedError - - @property - def enabled(self) -> bool: - raise NotImplementedError - - @property - def has_nac(self) -> bool: - """Determine if the interface has NAC configured.""" - raise NotImplementedError + _bundle_membership_prefix = "channel-group " + _encapsulation_prefix = "encapsulation dot1q vlan " @property def ipv4_interfaces(self) -> Iterable[IPv4Interface]: - raise NotImplementedError - - @property - def is_bundle(self) -> bool: - raise NotImplementedError - - @property - def is_loopback(self) -> bool: - raise NotImplementedError - - @property - def is_subinterface(self) -> bool: - return "." in self.name - - @property - def is_svi(self) -> bool: - raise NotImplementedError - - @property - def module_number(self) -> int | None: - raise NotImplementedError - - @property - def nac_control_direction_in(self) -> bool: - """Determine if the interface has NAC control direction in configured.""" - raise NotImplementedError - - @property - def nac_host_mode(self) -> NACHostMode | None: - """Determine the NAC host mode.""" - raise NotImplementedError - - @property - def nac_mab_first(self) -> bool: - """Determine if the interface has NAC configured for MAB first.""" - raise NotImplementedError - - @property - def nac_max_dot1x_clients(self) -> int: - """Determine the max dot1x clients.""" - raise NotImplementedError - - @property - def nac_max_mab_clients(self) -> int: - """Determine the max mab clients.""" - raise NotImplementedError - - @property - def name(self) -> str: - raise NotImplementedError - - @property - def native_vlan(self) -> int | None: - raise NotImplementedError - - @property - def number(self) -> str: - raise NotImplementedError - - @property - def parent_name(self) -> str | None: - raise NotImplementedError - - @property - def poe(self) -> bool: - raise NotImplementedError - - @property - def port_number(self) -> int: - return int(self.name.split("/")[-1].split(".")[0]) - - @property - def speed(self) -> tuple[int, ...] | None: - raise NotImplementedError - - @property - def subinterface_number(self) -> int | None: - raise NotImplementedError - - @property - def tagged_all(self) -> bool: - raise NotImplementedError - - @property - def tagged_vlans(self) -> tuple[int, ...]: - raise NotImplementedError + for ipv4_address_obj in self.config.get_children(startswith="ip address "): + if interface := parse_ipv4_interface(ipv4_address_obj.text.split()[2:]): + yield interface @property def vrf(self) -> str: - raise NotImplementedError + if vrf := self.config.get_child(startswith="vrf "): + words = vrf.text.split() + return words[2] if words[1] == "forwarding" else words[1] + return "" @property def _bundle_prefix(self) -> str: - raise NotImplementedError + return "Port-Channel" class HConfigViewAristaEOS(HConfigViewBase): """Full-tree config view for Arista EOS.""" - def dot1q_mode_from_vlans( - self, - untagged_vlan: int | None = None, - tagged_vlans: tuple[int, ...] = (), - *, - tagged_all: bool = False, - ) -> InterfaceDot1qMode | None: - raise NotImplementedError - @property def hostname(self) -> str | None: if child := self.config.get_child(startswith="hostname "): return child.text.split()[1].lower() return None - @property - def interface_names_mentioned(self) -> frozenset[str]: - """A set with all the interface names mentioned in the config.""" - raise NotImplementedError - @property def interface_views(self) -> Iterable[ConfigViewInterfaceAristaEOS]: for interface in self.interfaces: @@ -179,16 +57,6 @@ def interfaces(self) -> Iterable[HConfigChild]: @property def ipv4_default_gw(self) -> IPv4Address | None: - raise NotImplementedError - - @property - def location(self) -> str: - raise NotImplementedError - - @property - def stack_members(self) -> Iterable[StackMember]: - raise NotImplementedError - - @property - def vlans(self) -> Iterable[Vlan]: - raise NotImplementedError + if gateway := self.config.get_child(startswith="ip route 0.0.0.0/0 "): + return IPv4Address(gateway.text.split()[3]) + return None diff --git a/hier_config/platforms/aruba_aoscx/driver.py b/hier_config/platforms/aruba_aoscx/driver.py index 312699cc..8558c605 100644 --- a/hier_config/platforms/aruba_aoscx/driver.py +++ b/hier_config/platforms/aruba_aoscx/driver.py @@ -5,13 +5,14 @@ PerLineSubRule, SectionalExitingRule, ) +from hier_config.platforms.aruba_aoscx.view import HConfigViewArubaAOSCX from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules from hier_config.platforms.functions import expand_range from hier_config.platforms.utils import split_vlan_id_lists from hier_config.root import HConfig -def _split_interface_vlan_trunk_allowed(config: HConfig) -> None: +def split_interface_vlan_trunk_allowed(config: HConfig) -> None: """Split AOS-CX additive trunk VLAN lists into one VLAN per line. ``vlan trunk allowed`` is additive on AOS-CX rather than declarative, so @@ -58,6 +59,8 @@ class HConfigDriverArubaAOSCX(HConfigDriverBase): way. Platform enum: ``Platform.ARUBA_AOSCX``. """ + view_class = HConfigViewArubaAOSCX + @staticmethod def _instantiate_rules() -> HConfigDriverRules: return HConfigDriverRules( @@ -179,6 +182,6 @@ def _instantiate_rules() -> HConfigDriverRules: ], post_load_callbacks=[ split_vlan_id_lists, - _split_interface_vlan_trunk_allowed, + split_interface_vlan_trunk_allowed, ], ) diff --git a/hier_config/platforms/aruba_aoscx/view.py b/hier_config/platforms/aruba_aoscx/view.py index 98d0ab1f..fba79654 100644 --- a/hier_config/platforms/aruba_aoscx/view.py +++ b/hier_config/platforms/aruba_aoscx/view.py @@ -5,15 +5,16 @@ from hier_config.child import HConfigChild from hier_config.platforms.functions import expand_range from hier_config.platforms.models import ( - InterfaceDot1qMode, InterfaceDuplex, NACHostMode, - StackMember, Vlan, ) from hier_config.platforms.view_base import ( - ConfigViewInterfaceBase, HConfigViewBase, + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, ) @@ -30,40 +31,33 @@ def _safe_expand_range(spec: str) -> tuple[int, ...]: return () -class ConfigViewInterfaceArubaAOSCX(ConfigViewInterfaceBase): # ruff:ignore[too-many-public-methods] +class ConfigViewInterfaceArubaAOSCX( + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, +): """Interface config view for Aruba AOS-CX.""" + _bundle_membership_prefix = "lag " + @property def bundle_id(self) -> str | None: if self.is_bundle: # Names look like "lag 1" or "lag 1 multi-chassis"; the id is the # token after the "lag " prefix, not the last word. return self.name.split()[1] - if lag := self.config.get_child(startswith="lag "): - return lag.text.split()[1] - return None + return super().bundle_id @property def bundle_member_interfaces(self) -> Iterable[str]: if not self.is_bundle or not self.bundle_id: return lag_text = f"lag {self.bundle_id}" - for interface in self.config.root.get_children(startswith="interface "): + for interface in self.config.parent.get_children(startswith="interface "): if interface.get_child(equals=lag_text): yield interface.text.split(maxsplit=1)[1] - @property - def bundle_name(self) -> str | None: - if self.bundle_id: - return f"{self._bundle_prefix}{self.bundle_id}" - return None - - @property - def description(self) -> str: - if child := self.config.get_child(startswith="description "): - return child.text.split(maxsplit=1)[1] - return "" - @property def duplex(self) -> InterfaceDuplex: if duplex := self.config.get_child(startswith="duplex "): @@ -91,25 +85,6 @@ def ipv4_interfaces(self) -> Iterable[IPv4Interface]: except AddressValueError: continue - @property - def is_bundle(self) -> bool: - return self.name.lower().startswith(self._bundle_prefix) - - @property - def is_loopback(self) -> bool: - return self.name.lower().startswith("loopback") - - @property - def is_svi(self) -> bool: - return self.name.lower().startswith("vlan") - - @property - def module_number(self) -> int | None: - words = self.number.split("/", 1) - if len(words) == 1: - return None - return int(words[0]) - @property def nac_control_direction_in(self) -> bool: return False @@ -158,10 +133,6 @@ def parent_name(self) -> str | None: def poe(self) -> bool: return not self.config.get_child(equals="no power-over-ethernet") - @property - def port_number(self) -> int: - return int(self.name.split("/")[-1].split(".")[0]) - @property def speed(self) -> tuple[int, ...] | None: if speed := self.config.get_child(startswith="speed "): @@ -170,10 +141,6 @@ def speed(self) -> tuple[int, ...] | None: return (int(speed.text.split()[1]),) return None - @property - def subinterface_number(self) -> int | None: - return int(self.name.split(".")[-1]) if self.is_subinterface else None - @property def tagged_all(self) -> bool: return bool(self.config.get_child(equals="vlan trunk allowed all")) @@ -201,31 +168,12 @@ def _bundle_prefix(self) -> str: class HConfigViewArubaAOSCX(HConfigViewBase): """Full-tree config view for Aruba AOS-CX.""" - def dot1q_mode_from_vlans( # ruff:ignore[no-self-use] - self, - untagged_vlan: int | None = None, - tagged_vlans: tuple[int, ...] = (), - *, - tagged_all: bool = False, - ) -> InterfaceDot1qMode | None: - if tagged_all: - return InterfaceDot1qMode.TAGGED_ALL - if tagged_vlans: - return InterfaceDot1qMode.TAGGED - if untagged_vlan: - return InterfaceDot1qMode.ACCESS - return None - @property def hostname(self) -> str | None: if child := self.config.get_child(startswith="hostname "): return child.text.split()[1].lower() return None - @property - def interface_names_mentioned(self) -> frozenset[str]: - return frozenset(model.name for model in self.interface_views) - @property def interface_views(self) -> Iterable[ConfigViewInterfaceArubaAOSCX]: for interface in self.interfaces: @@ -241,18 +189,14 @@ def ipv4_default_gw(self) -> IPv4Address | None: return IPv4Address(gateway.text.split()[3]) return None - @property - def location(self) -> str: - if location := self.config.get_child(startswith="snmp-server location "): - return location.text.split(maxsplit=2)[2].replace('"', "") - return "" - - @property - def stack_members(self) -> Iterable[StackMember]: - return () - @property def vlans(self) -> Iterable[Vlan]: + """Determine the configured VLANs. + + Uses tolerant range expansion (a malformed collapsed header is skipped, + matching the driver's post-load behaviour) and also yields unnamed + VLANs that only appear as tagged members on interfaces. + """ yielded_vlans: set[int] = set() for child in self.config.get_children(re_search=r"^vlan [0-9,-]+$"): vlan_name = None diff --git a/hier_config/platforms/cisco_ios/driver.py b/hier_config/platforms/cisco_ios/driver.py index 6dcab990..8dd679ba 100644 --- a/hier_config/platforms/cisco_ios/driver.py +++ b/hier_config/platforms/cisco_ios/driver.py @@ -3,12 +3,14 @@ from hier_config.models import ( IdempotentCommandsRule, MatchRule, - NegationDefaultWithRule, + NegationRule, + NegationStrategy, OrderingRule, ParentAllowsDuplicateChildRule, PerLineSubRule, SectionalExitingRule, ) +from hier_config.platforms.cisco_ios.view import HConfigViewCiscoIOS from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules from hier_config.platforms.utils import split_vlan_id_lists from hier_config.root import HConfig @@ -16,7 +18,7 @@ logger = getLogger(__name__) -def _rm_ipv6_acl_sequence_numbers(config: HConfig) -> None: +def remove_ipv6_acl_sequence_numbers(config: HConfig) -> None: """If there are sequence numbers in the IPv6 ACL, remove them.""" for acl in config.get_children(startswith="ipv6 access-list "): for entry in acl.children: @@ -24,14 +26,15 @@ def _rm_ipv6_acl_sequence_numbers(config: HConfig) -> None: entry.text = " ".join(entry.text.split()[2:]) -def _remove_ipv4_acl_remarks(config: HConfig) -> None: +def remove_ipv4_acl_remarks(config: HConfig) -> None: + """Remove remark lines from IPv4 ACLs so they do not participate in diffs.""" for acl in config.get_children(startswith="ip access-list "): for entry in tuple(acl.children): if entry.text.startswith("remark"): entry.delete() -def _add_acl_sequence_numbers(config: HConfig) -> None: +def add_acl_sequence_numbers(config: HConfig) -> None: """Add ACL sequence numbers.""" ipv4_acl_sw = "ip access-list" acl_line_sw: tuple[str, ...] = ("permit", "deny") @@ -55,11 +58,14 @@ class HConfigDriverCiscoIOS(HConfigDriverBase): ``Platform.CISCO_IOS``. """ + view_class = HConfigViewCiscoIOS + @staticmethod def _instantiate_rules() -> HConfigDriverRules: return HConfigDriverRules( - negate_with=[ - NegationDefaultWithRule( + negation=[ + NegationRule( + strategy=NegationStrategy.REPLACE, match_rules=(MatchRule(startswith="logging console "),), use="logging console debugging", ), @@ -195,9 +201,9 @@ def _instantiate_rules() -> HConfigDriverRules: ), ], post_load_callbacks=[ - _rm_ipv6_acl_sequence_numbers, - _remove_ipv4_acl_remarks, - _add_acl_sequence_numbers, + remove_ipv6_acl_sequence_numbers, + remove_ipv4_acl_remarks, + add_acl_sequence_numbers, split_vlan_id_lists, ], ) diff --git a/hier_config/platforms/cisco_ios/view.py b/hier_config/platforms/cisco_ios/view.py index 2be4fcca..b29ff025 100644 --- a/hier_config/platforms/cisco_ios/view.py +++ b/hier_config/platforms/cisco_ios/view.py @@ -1,46 +1,32 @@ from collections.abc import Iterable -from ipaddress import AddressValueError, IPv4Address, IPv4Interface -from re import sub +from ipaddress import IPv4Address, IPv4Interface from hier_config.child import HConfigChild -from hier_config.platforms.functions import expand_range +from hier_config.platforms.functions import parse_ipv4_interface from hier_config.platforms.models import ( - InterfaceDot1qMode, InterfaceDuplex, NACHostMode, StackMember, - Vlan, ) from hier_config.platforms.view_base import ( - ConfigViewInterfaceBase, HConfigViewBase, + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, ) -class ConfigViewInterfaceCiscoIOS(ConfigViewInterfaceBase): # ruff:ignore[too-many-public-methods] +class ConfigViewInterfaceCiscoIOS( + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, +): """Interface config view for Cisco IOS / IOS-XE.""" - @property - def bundle_id(self) -> str | None: - if channel_group := self.config.get_child(startswith="channel-group"): - return channel_group.text.split()[1] - return None - - @property - def bundle_member_interfaces(self) -> Iterable[str]: - raise NotImplementedError - - @property - def bundle_name(self) -> str | None: - if self.bundle_id: - return f"{self._bundle_prefix}{self.bundle_id}" - return None - - @property - def description(self) -> str: - if child := self.config.get_child(startswith="description "): - return child.text.split(maxsplit=1)[1] - return "" + _bundle_membership_prefix = "channel-group " + _encapsulation_prefix = "encapsulation dot1Q " @property def duplex(self) -> InterfaceDuplex: @@ -48,10 +34,6 @@ def duplex(self) -> InterfaceDuplex: return InterfaceDuplex(duplex.text.split()[1]) return InterfaceDuplex.AUTO - @property - def enabled(self) -> bool: - return not self.config.get_child(equals="shutdown") - @property def has_nac(self) -> bool: return any( @@ -62,41 +44,11 @@ def has_nac(self) -> bool: ) ) - @property - def ipv4_interface(self) -> IPv4Interface | None: - return next(iter(self.ipv4_interfaces), None) - @property def ipv4_interfaces(self) -> Iterable[IPv4Interface]: for ipv4_address_obj in self.config.get_children(startswith="ip address "): - ipv4_address = ipv4_address_obj.text.split() - try: - yield IPv4Interface("/".join(ipv4_address[2:4])) - except AddressValueError: - continue - - @property - def is_bundle(self) -> bool: - return self.name.lower().startswith(self._bundle_prefix) - - @property - def is_loopback(self) -> bool: - return self.name.lower().startswith("loopback") - - @property - def is_subinterface(self) -> bool: - return "." in self.name - - @property - def is_svi(self) -> bool: - return self.name.lower().startswith("vlan") - - @property - def module_number(self) -> int | None: - words = self.number.split("/", 1) - if len(words) == 1: - return None - return int(words[0]) + if interface := parse_ipv4_interface(ipv4_address_obj.text.split()[2:]): + yield interface @property def nac_control_direction_in(self) -> bool: @@ -138,61 +90,10 @@ def nac_max_mab_clients(self) -> int: """Determine the max mab clients.""" raise NotImplementedError - @property - def name(self) -> str: - return self.config.text.split()[1] - - @property - def native_vlan(self) -> int | None: - # It's configured as a sub-interface - if self.is_subinterface and ( - vlan := self.config.get_child(startswith="encapsulation dot1Q ") - ): - return int(vlan.text.split()[2]) - - # It's not a switchport - if ( - self.config.get_child(equals="no switchport") - or self.config.get_child(startswith="ip address ") - or self.is_loopback - or self.is_svi - ): - return None - - # It's configured as a trunk - if self.config.get_child(equals="switchport mode trunk"): - if vlan := self.config.get_child( - startswith="switchport trunk native vlan ", - ): - return int(vlan.text.split()[4]) - - return None - - # It's either dynamic or configured as an access port - if vlan := self.config.get_child(startswith="switchport access vlan "): - return int(vlan.text.split()[3]) - - # Default VLAN - return 1 - - @property - def number(self) -> str: - return sub(r"^[a-zA-Z-]+", "", self.name) - - @property - def parent_name(self) -> str | None: - if self.is_subinterface: - return self.name.split(".")[0] - return None - @property def poe(self) -> bool: return not self.config.get_child(equals="power inline never") - @property - def port_number(self) -> int: - return int(self.name.split("/")[-1].split(".")[0]) - @property def speed(self) -> tuple[int, ...] | None: if speed := self.config.get_child(startswith="speed "): @@ -201,25 +102,6 @@ def speed(self) -> tuple[int, ...] | None: return (int(speed.text.split()[1]),) return None - @property - def subinterface_number(self) -> int | None: - return int(self.name.split(".")[0 - 1]) if self.is_subinterface else None - - @property - def tagged_all(self) -> bool: - return bool( - self.config.get_child(equals="switchport mode trunk") - and not self.tagged_vlans, - ) - - @property - def tagged_vlans(self) -> tuple[int, ...]: - if child := self.config.get_child( - re_search="^switchport trunk allowed vlan [0-9,-]+$", - ): - return expand_range(child.text.split()[4]) - return () - @property def vrf(self) -> str: if vrf := self.config.get_child(startswith="ip vrf forwarding "): @@ -234,26 +116,12 @@ def _bundle_prefix(self) -> str: class HConfigViewCiscoIOS(HConfigViewBase): """Full-tree config view for Cisco IOS / IOS-XE.""" - def dot1q_mode_from_vlans( - self, - untagged_vlan: int | None = None, - tagged_vlans: tuple[int, ...] = (), - *, - tagged_all: bool = False, - ) -> InterfaceDot1qMode | None: - raise NotImplementedError - @property def hostname(self) -> str | None: if child := self.config.get_child(startswith="hostname "): return child.text.split()[1].lower() return None - @property - def interface_names_mentioned(self) -> frozenset[str]: - """A set with all the interface names mentioned in the config.""" - return frozenset(model.name for model in self.interface_views) - @property def interface_views(self) -> Iterable[ConfigViewInterfaceCiscoIOS]: for interface in self.interfaces: @@ -269,12 +137,6 @@ def ipv4_default_gw(self) -> IPv4Address | None: return IPv4Address(gateway.text.split()[2]) return None - @property - def location(self) -> str: - if location := self.config.get_child(startswith="snmp-server location "): - return location.text.split(maxsplit=2)[2].replace('"', "") - return "" - @property def stack_members(self) -> Iterable[StackMember]: """Stacking @@ -293,31 +155,3 @@ def stack_members(self) -> Iterable[StackMember]: mac_address=None, model=words[3], ) - - @property - def vlans(self) -> Iterable[Vlan]: - yielded_vlans: set[int] = set() - - # Yield explicitly defined VLANs - for child in self.config.get_children(re_search="^vlan [0-9,-]+$"): - vlan_name = None - if name := child.get_child(startswith="name "): - _, vlan_name = name.text.split(maxsplit=1) - vlan_name = vlan_name.replace('"', "") - for vlan_id in expand_range(child.text.split()[1]): - yielded_vlans.add(vlan_id) - yield Vlan( - id=vlan_id, - name=vlan_name or None, - ) - - # Yield any remaining unnamed VLANs mentioned on interfaces - for interface_view in self.interface_views: - if ( - native_vlan := interface_view.native_vlan - ) and native_vlan not in yielded_vlans: - yielded_vlans.add(native_vlan) - yield Vlan( - id=native_vlan, - name=None, - ) diff --git a/hier_config/platforms/cisco_nxos/driver.py b/hier_config/platforms/cisco_nxos/driver.py index eb86331a..52a8fae2 100644 --- a/hier_config/platforms/cisco_nxos/driver.py +++ b/hier_config/platforms/cisco_nxos/driver.py @@ -2,10 +2,11 @@ IdempotentCommandsAvoidRule, IdempotentCommandsRule, MatchRule, - NegationDefaultWhenRule, - NegationDefaultWithRule, + NegationRule, + NegationStrategy, PerLineSubRule, ) +from hier_config.platforms.cisco_nxos.view import HConfigViewCiscoNXOS from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules @@ -18,6 +19,8 @@ class HConfigDriverCiscoNXOS(HConfigDriverBase): Platform enum: ``Platform.CISCO_NXOS``. """ + view_class = HConfigViewCiscoNXOS + @staticmethod def _instantiate_rules() -> HConfigDriverRules: return HConfigDriverRules( @@ -359,8 +362,9 @@ def _instantiate_rules() -> HConfigDriverRules: ), ), ], - negation_default_when=[ - NegationDefaultWhenRule( + negation=[ + NegationRule( + strategy=NegationStrategy.DEFAULT, match_rules=( MatchRule(startswith="interface"), MatchRule( @@ -369,7 +373,8 @@ def _instantiate_rules() -> HConfigDriverRules: ), ), ), - NegationDefaultWhenRule( + NegationRule( + strategy=NegationStrategy.DEFAULT, match_rules=( MatchRule(startswith="router bgp"), MatchRule(startswith="neighbor"), @@ -377,21 +382,22 @@ def _instantiate_rules() -> HConfigDriverRules: MatchRule(equals="send-community"), ), ), - NegationDefaultWhenRule( + NegationRule( + strategy=NegationStrategy.DEFAULT, match_rules=( MatchRule(startswith="interface"), MatchRule(contains="ip ospf passive-interface"), ), ), - NegationDefaultWhenRule( + NegationRule( + strategy=NegationStrategy.DEFAULT, match_rules=( MatchRule(startswith="interface"), MatchRule(contains="ospfv3 passive-interface"), ), ), - ], - negate_with=[ - NegationDefaultWithRule( + NegationRule( + strategy=NegationStrategy.REPLACE, match_rules=( MatchRule(startswith="router bgp"), MatchRule(startswith="address-family"), @@ -399,7 +405,8 @@ def _instantiate_rules() -> HConfigDriverRules: ), use="default maximum-paths ibgp", ), - NegationDefaultWithRule( + NegationRule( + strategy=NegationStrategy.REPLACE, match_rules=( MatchRule(startswith="router bgp"), MatchRule(startswith="vrf"), @@ -408,7 +415,8 @@ def _instantiate_rules() -> HConfigDriverRules: ), use="default maximum-paths ibgp", ), - NegationDefaultWithRule( + NegationRule( + strategy=NegationStrategy.REPLACE, match_rules=( MatchRule(equals="line vty"), MatchRule(startswith="session-limit"), diff --git a/hier_config/platforms/cisco_nxos/view.py b/hier_config/platforms/cisco_nxos/view.py index 58297cfa..db55978b 100644 --- a/hier_config/platforms/cisco_nxos/view.py +++ b/hier_config/platforms/cisco_nxos/view.py @@ -1,161 +1,34 @@ from collections.abc import Iterable -from ipaddress import AddressValueError, IPv4Address, IPv4Interface -from re import sub +from ipaddress import IPv4Address, IPv4Interface from hier_config.child import HConfigChild -from hier_config.platforms.models import ( - InterfaceDot1qMode, - InterfaceDuplex, - NACHostMode, - StackMember, - Vlan, -) +from hier_config.platforms.functions import parse_ipv4_interface from hier_config.platforms.view_base import ( - ConfigViewInterfaceBase, HConfigViewBase, + InterfaceBundleViewMixin, + InterfaceVlanViewMixin, ) -class ConfigViewInterfaceCiscoNXOS(ConfigViewInterfaceBase): # ruff:ignore[too-many-public-methods] +class ConfigViewInterfaceCiscoNXOS( + InterfaceBundleViewMixin, + InterfaceVlanViewMixin, +): """Interface config view for Cisco NX-OS.""" - @property - def bundle_id(self) -> str | None: - raise NotImplementedError - - @property - def bundle_member_interfaces(self) -> Iterable[str]: - raise NotImplementedError - - @property - def bundle_name(self) -> str | None: - raise NotImplementedError - - @property - def description(self) -> str: - if child := self.config.get_child(startswith="description "): - return child.text.split(maxsplit=1)[1] - return "" - - @property - def duplex(self) -> InterfaceDuplex: - raise NotImplementedError - - @property - def enabled(self) -> bool: - raise NotImplementedError - - @property - def has_nac(self) -> bool: - """Determine if the interface has NAC configured.""" - raise NotImplementedError - - @property - def ipv4_interface(self) -> IPv4Interface | None: - return next(iter(self.ipv4_interfaces), None) + _bundle_membership_prefix = "channel-group " @property def ipv4_interfaces(self) -> Iterable[IPv4Interface]: for ipv4_address_obj in self.config.get_children(startswith="ip address "): - ipv4_address = ipv4_address_obj.text.split() - try: - yield IPv4Interface("/".join(ipv4_address[2:4])) - except AddressValueError: - continue - - @property - def is_bundle(self) -> bool: - return self.name.lower().startswith(self._bundle_prefix) - - @property - def is_loopback(self) -> bool: - return self.name.lower().startswith("loopback") - - @property - def is_subinterface(self) -> bool: - return "." in self.name - - @property - def is_svi(self) -> bool: - return self.name.lower().startswith("vlan") - - @property - def module_number(self) -> int | None: - words = self.number.split("/", 1) - if len(words) == 1: - return None - return int(words[0]) - - @property - def nac_control_direction_in(self) -> bool: - """Determine if the interface has NAC control direction in configured.""" - raise NotImplementedError - - @property - def nac_host_mode(self) -> NACHostMode | None: - """Determine the NAC host mode.""" - raise NotImplementedError - - @property - def nac_mab_first(self) -> bool: - """Determine if the interface has NAC configured for MAB first.""" - raise NotImplementedError - - @property - def nac_max_dot1x_clients(self) -> int: - """Determine the max dot1x clients.""" - raise NotImplementedError - - @property - def nac_max_mab_clients(self) -> int: - """Determine the max mab clients.""" - raise NotImplementedError - - @property - def name(self) -> str: - return self.config.text.split()[1] - - @property - def native_vlan(self) -> int | None: - raise NotImplementedError - - @property - def number(self) -> str: - return sub(r"^[a-zA-Z-]+", "", self.name) - - @property - def parent_name(self) -> str | None: - if self.is_subinterface: - return self.name.split(".")[0] - return None - - @property - def poe(self) -> bool: - raise NotImplementedError - - @property - def port_number(self) -> int: - return int(self.name.split("/")[-1].split(".")[0]) - - @property - def speed(self) -> tuple[int, ...] | None: - raise NotImplementedError - - @property - def subinterface_number(self) -> int | None: - return int(self.name.split(".")[0 - 1]) if self.is_subinterface else None - - @property - def tagged_all(self) -> bool: - raise NotImplementedError - - @property - def tagged_vlans(self) -> tuple[int, ...]: - raise NotImplementedError + if interface := parse_ipv4_interface(ipv4_address_obj.text.split()[2:]): + yield interface @property def vrf(self) -> str: - raise NotImplementedError + if vrf := self.config.get_child(startswith="vrf member "): + return vrf.text.split()[2] + return "" @property def _bundle_prefix(self) -> str: @@ -165,26 +38,12 @@ def _bundle_prefix(self) -> str: class HConfigViewCiscoNXOS(HConfigViewBase): """Full-tree config view for Cisco NX-OS.""" - def dot1q_mode_from_vlans( - self, - untagged_vlan: int | None = None, - tagged_vlans: tuple[int, ...] = (), - *, - tagged_all: bool = False, - ) -> InterfaceDot1qMode | None: - raise NotImplementedError - @property def hostname(self) -> str | None: if child := self.config.get_child(startswith="hostname "): return child.text.split()[1].lower() return None - @property - def interface_names_mentioned(self) -> frozenset[str]: - """A set with all the interface names mentioned in the config.""" - raise NotImplementedError - @property def interface_views(self) -> Iterable[ConfigViewInterfaceCiscoNXOS]: for interface in self.interfaces: @@ -196,16 +55,6 @@ def interfaces(self) -> Iterable[HConfigChild]: @property def ipv4_default_gw(self) -> IPv4Address | None: - raise NotImplementedError - - @property - def location(self) -> str: - raise NotImplementedError - - @property - def stack_members(self) -> Iterable[StackMember]: - raise NotImplementedError - - @property - def vlans(self) -> Iterable[Vlan]: - raise NotImplementedError + if gateway := self.config.get_child(startswith="ip route 0.0.0.0/0 "): + return IPv4Address(gateway.text.split()[3]) + return None diff --git a/hier_config/platforms/cisco_xr/driver.py b/hier_config/platforms/cisco_xr/driver.py index 84e617bd..1489d2e6 100644 --- a/hier_config/platforms/cisco_xr/driver.py +++ b/hier_config/platforms/cisco_xr/driver.py @@ -14,6 +14,7 @@ SectionalOverwriteNoNegateRule, SectionalOverwriteRule, ) +from hier_config.platforms.cisco_xr.view import HConfigViewCiscoIOSXR from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules if TYPE_CHECKING: @@ -22,7 +23,7 @@ from hier_config.root import HConfig -def _fixup_xr_comments(config: HConfig) -> None: +def fixup_xr_comments(config: HConfig) -> None: """Move ``!`` comment lines into the next sibling's comments set.""" for parent in (config, *config.all_children()): siblings = list(parent.children) @@ -50,6 +51,8 @@ class HConfigDriverCiscoIOSXR(HConfigDriverBase): # pylint: disable=too-many-in Platform enum: ``Platform.CISCO_XR``. """ + view_class = HConfigViewCiscoIOSXR + def idempotent_for( self, config: HConfigChild, @@ -184,7 +187,7 @@ def _instantiate_rules() -> HConfigDriverRules: PerLineSubRule(search="^\\s*#.*", replace=""), PerLineSubRule(search="^\\s*!\\s*$", replace=""), ], - post_load_callbacks=[_fixup_xr_comments], + post_load_callbacks=[fixup_xr_comments], idempotent_commands=[ IdempotentCommandsRule( match_rules=( diff --git a/hier_config/platforms/cisco_xr/view.py b/hier_config/platforms/cisco_xr/view.py index 625fe207..dc7d57f5 100644 --- a/hier_config/platforms/cisco_xr/view.py +++ b/hier_config/platforms/cisco_xr/view.py @@ -1,180 +1,60 @@ from collections.abc import Iterable -from ipaddress import AddressValueError, IPv4Address, IPv4Interface -from re import sub +from ipaddress import IPv4Address, IPv4Interface from hier_config.child import HConfigChild -from hier_config.platforms.models import ( - InterfaceDot1qMode, - InterfaceDuplex, - NACHostMode, - StackMember, - Vlan, -) +from hier_config.platforms.functions import parse_ipv4_interface from hier_config.platforms.view_base import ( - ConfigViewInterfaceBase, HConfigViewBase, + InterfaceBundleViewMixin, + InterfaceVlanViewMixin, ) -class ConfigViewInterfaceCiscoIOSXR(ConfigViewInterfaceBase): # ruff:ignore[too-many-public-methods] - """Interface config view for Cisco IOS XR.""" - - @property - def _bundle_prefix(self) -> str: - return "Bundle-Ether" - - @property - def bundle_id(self) -> str | None: - raise NotImplementedError - - @property - def bundle_member_interfaces(self) -> Iterable[str]: - raise NotImplementedError - - @property - def bundle_name(self) -> str | None: - if self.bundle_id: - return f"{self._bundle_prefix}{self.bundle_id}" - return None - - @property - def description(self) -> str: - if child := self.config.get_child(startswith="description "): - return child.text.split(maxsplit=1)[1] - return "" - - @property - def duplex(self) -> InterfaceDuplex: - raise NotImplementedError - - @property - def enabled(self) -> bool: - raise NotImplementedError +class ConfigViewInterfaceCiscoIOSXR( + InterfaceBundleViewMixin, + InterfaceVlanViewMixin, +): + """Interface config view for Cisco IOS XR. - @property - def has_nac(self) -> bool: - """Determine if the interface has NAC configured.""" - raise NotImplementedError + IOS XR has no switchports, so the VLAN view only reports sub-interface + dot1q encapsulations; ``tagged_all`` is always False and ``tagged_vlans`` + is always empty. + """ - @property - def ipv4_interface(self) -> IPv4Interface | None: - return next(iter(self.ipv4_interfaces), None) + _bundle_membership_prefix = "bundle id " @property def ipv4_interfaces(self) -> Iterable[IPv4Interface]: for ipv4_address_obj in self.config.get_children(startswith="ipv4 address "): - ipv4_address = ipv4_address_obj.text.split() - try: - yield IPv4Interface("/".join(ipv4_address[2:4])) - except AddressValueError: - continue - - @property - def is_bundle(self) -> bool: - return self.name.lower().startswith(self._bundle_prefix) - - @property - def is_loopback(self) -> bool: - return self.name.lower().startswith("loopback") - - @property - def is_subinterface(self) -> bool: - return "." in self.name - - @property - def is_svi(self) -> bool: - return self.name.lower().startswith("vlan") - - @property - def module_number(self) -> int | None: - words = self.number.split("/", 1) - if len(words) == 1: - return None - return int(words[0]) - - @property - def nac_control_direction_in(self) -> bool: - """Determine if the interface has NAC control direction in configured.""" - raise NotImplementedError - - @property - def nac_host_mode(self) -> NACHostMode | None: - """Determine the NAC host mode.""" - raise NotImplementedError - - @property - def nac_mab_first(self) -> bool: - """Determine if the interface has NAC configured for MAB first.""" - raise NotImplementedError - - @property - def nac_max_dot1x_clients(self) -> int: - """Determine the max dot1x clients.""" - raise NotImplementedError - - @property - def nac_max_mab_clients(self) -> int: - """Determine the max mab clients.""" - raise NotImplementedError - - @property - def name(self) -> str: - return self.config.text.split()[1] + if interface := parse_ipv4_interface(ipv4_address_obj.text.split()[2:]): + yield interface @property def native_vlan(self) -> int | None: - raise NotImplementedError - - @property - def number(self) -> str: - return sub(r"^[a-zA-Z-]+", "", self.name) - - @property - def parent_name(self) -> str | None: - if self.is_subinterface: - return self.name.split(".")[0] + # VLANs only exist on sub-interfaces (e.g. `encapsulation dot1q 100`) + if self.is_subinterface and ( + vlan := self.config.get_child(startswith="encapsulation dot1q ") + ): + return int(vlan.text.split()[2]) return None @property - def poe(self) -> bool: - raise NotImplementedError - - @property - def port_number(self) -> int: - return int(self.name.split("/")[-1].split(".")[0]) - - @property - def speed(self) -> tuple[int, ...] | None: - raise NotImplementedError - - @property - def subinterface_number(self) -> int | None: - return int(self.name.split(".")[0 - 1]) if self.is_subinterface else None - - @property - def tagged_all(self) -> bool: - raise NotImplementedError - - @property - def tagged_vlans(self) -> tuple[int, ...]: - raise NotImplementedError + def vrf(self) -> str: + if vrf := self.config.get_child(startswith="vrf "): + return vrf.text.split()[1] + return "" @property - def vrf(self) -> str: - raise NotImplementedError + def _bundle_prefix(self) -> str: + return "Bundle-Ether" class HConfigViewCiscoIOSXR(HConfigViewBase): - """Full-tree config view for Cisco IOS XR.""" + """Full-tree config view for Cisco IOS XR. - def dot1q_mode_from_vlans( - self, - untagged_vlan: int | None = None, - tagged_vlans: tuple[int, ...] = (), - *, - tagged_all: bool = False, - ) -> InterfaceDot1qMode | None: - raise NotImplementedError + VLANs are derived from sub-interface encapsulations; IOS XR does not + support stacking. + """ @property def hostname(self) -> str | None: @@ -182,10 +62,6 @@ def hostname(self) -> str | None: return child.text.split()[1].lower() return None - @property - def interface_names_mentioned(self) -> frozenset[str]: - raise NotImplementedError - @property def interface_views(self) -> Iterable[ConfigViewInterfaceCiscoIOSXR]: for interface in self.interfaces: @@ -197,16 +73,14 @@ def interfaces(self) -> Iterable[HConfigChild]: @property def ipv4_default_gw(self) -> IPv4Address | None: - raise NotImplementedError - - @property - def location(self) -> str: - raise NotImplementedError - - @property - def stack_members(self) -> Iterable[StackMember]: - raise NotImplementedError - - @property - def vlans(self) -> Iterable[Vlan]: - raise NotImplementedError + if ( + (router_static := self.config.get_child(equals="router static")) + and ( + address_family := router_static.get_child( + equals="address-family ipv4 unicast", + ) + ) + and (route := address_family.get_child(startswith="0.0.0.0/0 ")) + ): + return IPv4Address(route.text.split()[1]) + return None diff --git a/hier_config/platforms/driver_base.py b/hier_config/platforms/driver_base.py index 6bab8262..cb82b3c7 100644 --- a/hier_config/platforms/driver_base.py +++ b/hier_config/platforms/driver_base.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Iterable from re import Match, search +from typing import TYPE_CHECKING, ClassVar from pydantic import Field, PositiveInt @@ -12,9 +13,8 @@ IdempotentCommandsRule, IndentAdjustRule, MatchRule, - NegationDefaultWhenRule, - NegationDefaultWithRule, - NegationSubRule, + NegationRule, + NegationStrategy, OrderingRule, ParentAllowsDuplicateChildRule, PerLineSubRule, @@ -25,6 +25,9 @@ ) from hier_config.root import HConfig +if TYPE_CHECKING: + from hier_config.platforms.view_base import HConfigViewBase + def _full_text_sub_rules_default() -> list[FullTextSubRule]: return [] @@ -42,11 +45,7 @@ def _indent_adjust_rules_default() -> list[IndentAdjustRule]: return [] -def _negation_default_when_rules_default() -> list[NegationDefaultWhenRule]: - return [] - - -def _negate_with_rules_default() -> list[NegationDefaultWithRule]: +def _negation_rules_default() -> list[NegationRule]: return [] @@ -82,10 +81,6 @@ def _sectional_overwrite_no_negate_rules_default() -> list[ return [] -def _negation_sub_rules_default() -> list[NegationSubRule]: - return [] - - def _unused_object_rules_default() -> list[UnusedObjectRule]: return [] @@ -111,12 +106,7 @@ class HConfigDriverRules(BaseModel): # pylint: disable=too-many-instance-attrib default_factory=_indent_adjust_rules_default ) indentation: PositiveInt = 2 - negation_default_when: list[NegationDefaultWhenRule] = Field( - default_factory=_negation_default_when_rules_default - ) - negate_with: list[NegationDefaultWithRule] = Field( - default_factory=_negate_with_rules_default - ) + negation: list[NegationRule] = Field(default_factory=_negation_rules_default) ordering: list[OrderingRule] = Field(default_factory=_ordering_rules_default) parent_allows_duplicate_child: list[ParentAllowsDuplicateChildRule] = Field( default_factory=_parent_allows_duplicate_child_rules_default @@ -127,6 +117,9 @@ class HConfigDriverRules(BaseModel): # pylint: disable=too-many-instance-attrib post_load_callbacks: list[Callable[[HConfig], None]] = Field( default_factory=_post_load_callbacks_default ) + remediation_transform_callbacks: list[Callable[[HConfig], None]] = Field( + default_factory=_post_load_callbacks_default + ) sectional_exiting: list[SectionalExitingRule] = Field( default_factory=_sectional_exiting_rules_default ) @@ -136,9 +129,6 @@ class HConfigDriverRules(BaseModel): # pylint: disable=too-many-instance-attrib sectional_overwrite_no_negate: list[SectionalOverwriteNoNegateRule] = Field( default_factory=_sectional_overwrite_no_negate_rules_default ) - negation_sub: list[NegationSubRule] = Field( - default_factory=_negation_sub_rules_default - ) unused_objects: list[UnusedObjectRule] = Field( default_factory=_unused_object_rules_default ) @@ -149,6 +139,11 @@ class HConfigDriverBase(ABC): Override methods as needed. """ + #: View class instantiated by ``get_hconfig_view()``. ``None`` means the + #: platform has no config view. Set this on a driver subclass to register + #: a view for a custom platform or to override a built-in view. + view_class: ClassVar[type["HConfigViewBase"] | None] = None + def __init__(self) -> None: self.rules = self._instantiate_rules() @@ -157,6 +152,12 @@ def idempotent_for( config: HConfigChild, other_children: Iterable[HConfigChild], ) -> HConfigChild | None: + """Return the child in `other_children` that `config` idempotently overwrites. + + The default implementation derives a structural idempotency key from + the lineage and the `idempotent_commands` match rules. Override for + imperative idempotency logic. + """ for rule in self.rules.idempotent_commands: if not config.is_lineage_match(rule.match_rules): continue @@ -173,12 +174,25 @@ def idempotent_for( return None def negate_with(self, config: HConfigChild) -> str | None: - for with_rule in self.rules.negate_with: - if config.is_lineage_match(with_rule.match_rules): - return with_rule.use + """Return a fixed replacement negation string for `config`, if any. + + Reads REPLACE-strategy rules from the unified `negation` rule list. + Drivers may override this method for imperative negation logic. + """ + for rule in self.rules.negation: + if rule.strategy is NegationStrategy.REPLACE and config.is_lineage_match( + rule.match_rules + ): + return rule.use return None def sectional_exit(self, config: HConfigChild) -> str | None: + """Return the exit token to render at the end of `config`'s section. + + Sectional-exiting rules are consulted first; a matching rule without + `exit_text` suppresses the token. Otherwise, sections with children + default to `exit` and leaves to None. + """ for exit_rule in self.rules.sectional_exiting: if config.is_lineage_match(exit_rule.match_rules): if exit_text := exit_rule.exit_text: @@ -435,14 +449,31 @@ def _normalize_regex_key(pattern: str, value: str, match: Match[str]) -> str: @property def declaration_prefix(self) -> str: + """The string prepended to positive commands on set-style platforms. + + Defaults to an empty string; set-style drivers override this with + e.g. `set `. + """ return "" @property def negation_prefix(self) -> str: + """The string prepended to a command to negate it. + + Defaults to `no `; drivers override this with e.g. `undo ` or + `delete `. + """ return "no " @staticmethod def config_preprocessor(config_text: str) -> str: + """Transform raw config text before parsing. + + The default is a no-op. Override to convert a platform's native + rendering into parseable lines (e.g. flattening JunOS curly-brace + config into `set` commands). Runs inside `HConfig.from_text()` after + full-text substitutions and before tree construction. + """ return config_text @staticmethod diff --git a/hier_config/platforms/fortinet_fortios/driver.py b/hier_config/platforms/fortinet_fortios/driver.py index 3ac6d9be..1973d49b 100644 --- a/hier_config/platforms/fortinet_fortios/driver.py +++ b/hier_config/platforms/fortinet_fortios/driver.py @@ -40,11 +40,18 @@ def _instantiate_rules() -> HConfigDriverRules: ) def swap_negation(self, child: HConfigChild) -> HConfigChild: - """Swap negation of a `self.text`.""" + """Swap negation of a `self.text`. + + FortiOS resets an attribute to its default with ``unset ``; + the value is never part of the command, so parameters after the + attribute name are intentionally dropped when negating. + """ if child.text.startswith(self.negation_prefix): child.text = f"{self.declaration_prefix}{child.text_without_negation}" - elif child.text.startswith(self.declaration_prefix): - child.text = f"{self.negation_prefix}{child.text.removeprefix(self.declaration_prefix).split()[0]}" + elif child.text.startswith(self.declaration_prefix) and ( + tokens := child.text.removeprefix(self.declaration_prefix).split() + ): + child.text = f"{self.negation_prefix}{tokens[0]}" return child @@ -52,15 +59,18 @@ def idempotent_for( self, config: HConfigChild, other_children: Iterable[HConfigChild] ) -> HConfigChild | None: """Override idempotent_for to only consider a config idempotent - if the same command exists in the other set. + if a `set` command for the same attribute exists in the other set. """ - for other_child in other_children: - if ( - config.text.startswith(self.declaration_prefix) - and other_child.text.startswith(self.declaration_prefix) - and config.text.split()[1] == other_child.text.split()[1] - ): - return other_child + config_tokens = config.text.split() + if config.text.startswith(self.declaration_prefix) and len(config_tokens) > 1: + for other_child in other_children: + other_tokens = other_child.text.split() + if ( + other_child.text.startswith(self.declaration_prefix) + and len(other_tokens) > 1 + and config_tokens[1] == other_tokens[1] + ): + return other_child return super().idempotent_for(config, other_children) @property diff --git a/hier_config/platforms/functions.py b/hier_config/platforms/functions.py index 9637be0c..31ee095b 100644 --- a/hier_config/platforms/functions.py +++ b/hier_config/platforms/functions.py @@ -1,3 +1,31 @@ +from ipaddress import IPv4Interface + + +def parse_ipv4_interface(words: list[str]) -> IPv4Interface | None: + """Parse the address words of an interface IPv4 address command. + + Handles the three common forms: + + - a single CIDR word: ``10.0.0.1/24`` + - an address and a slash-prefixed length: ``10.0.0.1 /24`` + - an address and a netmask: ``10.0.0.1 255.255.255.0`` + + Returns None when the words do not form a valid IPv4 interface. + """ + if not words: + return None + if len(words) == 1 or "/" in words[0]: + address = words[0] + elif words[1].startswith("/"): + address = f"{words[0]}{words[1]}" + else: + address = f"{words[0]}/{words[1]}" + try: + return IPv4Interface(address) + except ValueError: + return None + + def expand_range(number_range_str: str) -> tuple[int, ...]: """Expand ranges like ``2-5,8,22-45`` into a de-duplicated, ordered tuple. diff --git a/hier_config/platforms/hp_procurve/driver.py b/hier_config/platforms/hp_procurve/driver.py index d19ef975..ad4ebc66 100644 --- a/hier_config/platforms/hp_procurve/driver.py +++ b/hier_config/platforms/hp_procurve/driver.py @@ -5,16 +5,18 @@ from hier_config.models import ( IdempotentCommandsRule, MatchRule, - NegationDefaultWithRule, + NegationRule, + NegationStrategy, OrderingRule, PerLineSubRule, ) from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules from hier_config.platforms.hp_procurve.functions import hp_procurve_expand_range +from hier_config.platforms.hp_procurve.view import HConfigViewHPProcurve from hier_config.root import HConfig -def _fixup_hp_procurve_aaa_port_access_fixup(config: HConfig) -> None: +def fixup_hp_procurve_aaa_port_access(config: HConfig) -> None: """Expands the interface ranges present in aaa port-access commands. aaa port-access authenticator 1/15-1/20,1/26-1/40,2/14-2/20,2/25-2/28,2/30-2/44,3/8-3/44,4/1-4/2,4/8-4/44,5/1-5/2,5/8-5/15,5/17-5/28,5/30-5/44 @@ -38,7 +40,7 @@ def _fixup_hp_procurve_aaa_port_access_fixup(config: HConfig) -> None: aaa_port_access.delete() -def _fixup_hp_procurve_vlan(config: HConfig) -> None: +def fixup_hp_procurve_vlan(config: HConfig) -> None: """Move native/tagged vlan config to the interface config for easier modeling and remediation. vlan 1 @@ -88,7 +90,7 @@ def _fixup_hp_procurve_vlan(config: HConfig) -> None: no_untagged_interfaces.delete() -def _fixup_hp_procurve_device_profile(config: HConfig) -> None: +def fixup_hp_procurve_device_profile(config: HConfig) -> None: """Separates the device-profile tagged-vlans onto individual lines. device-profile name "phone" @@ -121,6 +123,8 @@ class HConfigDriverHPProcurve(HConfigDriverBase): Platform enum: ``Platform.HP_PROCURVE``. """ + view_class = HConfigViewHPProcurve + def idempotent_for( self, config: HConfigChild, @@ -223,15 +227,17 @@ def _negation_negate_with_helper( @staticmethod def _instantiate_rules() -> HConfigDriverRules: return HConfigDriverRules( - negate_with=[ - NegationDefaultWithRule( + negation=[ + NegationRule( + strategy=NegationStrategy.REPLACE, match_rules=( MatchRule(startswith="interface "), MatchRule(equals="disable"), ), use="enable", ), - NegationDefaultWithRule( + NegationRule( + strategy=NegationStrategy.REPLACE, match_rules=( MatchRule(startswith="interface "), MatchRule(startswith="name "), @@ -329,8 +335,8 @@ def _instantiate_rules() -> HConfigDriverRules: ), ], post_load_callbacks=[ - _fixup_hp_procurve_aaa_port_access_fixup, - _fixup_hp_procurve_device_profile, - _fixup_hp_procurve_vlan, + fixup_hp_procurve_aaa_port_access, + fixup_hp_procurve_device_profile, + fixup_hp_procurve_vlan, ], ) diff --git a/hier_config/platforms/hp_procurve/view.py b/hier_config/platforms/hp_procurve/view.py index 9884448c..bfb948a9 100644 --- a/hier_config/platforms/hp_procurve/view.py +++ b/hier_config/platforms/hp_procurve/view.py @@ -6,26 +6,33 @@ from hier_config.platforms.functions import expand_range from hier_config.platforms.hp_procurve.functions import hp_procurve_expand_range from hier_config.platforms.models import ( - InterfaceDot1qMode, InterfaceDuplex, NACHostMode, StackMember, Vlan, ) from hier_config.platforms.view_base import ( - ConfigViewInterfaceBase, HConfigViewBase, + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, ) -class ConfigViewInterfaceHPProcurve( # ruff:ignore[too-many-public-methods] pylint: disable=abstract-method - ConfigViewInterfaceBase, +class ConfigViewInterfaceHPProcurve( + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, ): """Interface config view for HP ProCurve / Aruba AOSS.""" @property def bundle_id(self) -> str | None: - raise NotImplementedError + if bundle_name := self.bundle_name: + return bundle_name.lower().removeprefix(self._bundle_prefix.lower()) + return None @property def bundle_member_interfaces(self) -> Iterable[str]: @@ -81,10 +88,6 @@ def has_nac(self) -> bool: ) ) - @property - def ipv4_interface(self) -> IPv4Interface | None: - return next(iter(self.ipv4_interfaces), None) - @property def ipv4_interfaces(self) -> Iterable[IPv4Interface]: for ipv4_address_obj in self.config.get_children(startswith="ip address "): @@ -94,29 +97,6 @@ def ipv4_interfaces(self) -> Iterable[IPv4Interface]: except AddressValueError: continue - @property - def is_bundle(self) -> bool: - return self.name.lower().startswith(self._bundle_prefix) - - @property - def is_loopback(self) -> bool: - return self.name.lower().startswith("loopback") - - @property - def is_subinterface(self) -> bool: - return "." in self.name - - @property - def is_svi(self) -> bool: - return self.name.lower().startswith("vlan") - - @property - def module_number(self) -> int | None: - words = self.number.split("/", 1) - if len(words) == 1: - return None - return int(words[0]) - @property def nac_control_direction_in(self) -> bool: """Determine if the interface has NAC control direction in configured.""" @@ -170,34 +150,16 @@ def native_vlan(self) -> int | None: return int(vlan.text.split()[2]) return None - @property - def number(self) -> str: - return re.sub(r"^[a-zA-Z-]+", "", self.name) - - @property - def parent_name(self) -> str | None: - if self.is_subinterface: - return self.name.split(".")[0] - return None - @property def poe(self) -> bool: return not self.config.get_child(equals="no power-over-ethernet") - @property - def port_number(self) -> int: - return int(self.name.split("/")[-1].split(".")[0]) - @property def speed(self) -> tuple[int, ...] | None: if speed := self.config.get_child(startswith="speed-duplex "): return _speed_from_speed_duplex(speed.text) return None - @property - def subinterface_number(self) -> int | None: - return int(self.name.split(".")[0 - 1]) if self.is_subinterface else None - @property def tagged_all(self) -> bool: return False @@ -237,15 +199,6 @@ def _duplex_from_speed_duplex(speed_duplex: str) -> InterfaceDuplex: class HConfigViewHPProcurve(HConfigViewBase): """Full-tree config view for HP ProCurve / Aruba AOSS.""" - def dot1q_mode_from_vlans( - self, - untagged_vlan: int | None = None, - tagged_vlans: tuple[int, ...] = (), - *, - tagged_all: bool = False, - ) -> InterfaceDot1qMode | None: - raise NotImplementedError - @property def hostname(self) -> str | None: if child := self.config.get_child(startswith="hostname "): @@ -287,12 +240,6 @@ def ipv4_default_gw(self) -> IPv4Address | None: return IPv4Address(gateway.text.split()[2]) return None - @property - def location(self) -> str: - if location := self.config.get_child(startswith="snmp-server location "): - return location.text.split(maxsplit=2)[2].replace('"', "") - return "" - @property def stack_members(self) -> Iterable[StackMember]: """Stacking diff --git a/hier_config/platforms/view_base.py b/hier_config/platforms/view_base.py index 784f5587..58b7cf77 100644 --- a/hier_config/platforms/view_base.py +++ b/hier_config/platforms/view_base.py @@ -1,8 +1,10 @@ from abc import ABC, abstractmethod from collections.abc import Iterable from ipaddress import IPv4Address, IPv4Interface +from re import sub from hier_config.child import HConfigChild +from hier_config.platforms.functions import expand_range from hier_config.platforms.models import ( InterfaceDot1qMode, InterfaceDuplex, @@ -13,62 +15,34 @@ from hier_config.root import HConfig -class ConfigViewInterfaceBase: # ruff:ignore[too-many-public-methods] +class ConfigViewInterfaceBase(ABC): """Abstract base providing a typed view over a single interface config node. Subclasses parse the child tree of one ``interface ...`` block and expose - structured properties (IP address, duplex, VLAN membership, bundle state, - etc.) in a platform-independent way. + structured properties (IP address, description, enabled state, etc.) in a + platform-independent way. + + Optional capabilities (bundles, VLANs, NAC, physical-layer settings) are + modeled as mixins (:class:`InterfaceBundleViewMixin`, + :class:`InterfaceVlanViewMixin`, :class:`InterfaceNACViewMixin`, + :class:`InterfacePhysicalViewMixin`). Platform views inherit the mixins + they genuinely support, and users check capability with ``isinstance()``. """ def __init__(self, config: HConfigChild) -> None: self.config = config @property - @abstractmethod - def bundle_id(self) -> str | None: - """Determine the bundle ID.""" - - @property - @abstractmethod - def bundle_member_interfaces(self) -> Iterable[str]: - """Determine the member interfaces of a bundle.""" - - @property - @abstractmethod - def bundle_name(self) -> str | None: - """Determine the bundle name of a bundle member.""" - - @property - @abstractmethod def description(self) -> str: """Determine the interface's description.""" + if child := self.config.get_child(startswith="description "): + return child.text.split(maxsplit=1)[1] + return "" @property - def dot1q_mode(self) -> InterfaceDot1qMode | None: - """Derive the configured 802.1Q mode.""" - if self.tagged_all: - return InterfaceDot1qMode.TAGGED_ALL - if self.tagged_vlans: - return InterfaceDot1qMode.TAGGED - if self.native_vlan and not self.is_svi: - return InterfaceDot1qMode.ACCESS - return None - - @property - @abstractmethod - def duplex(self) -> InterfaceDuplex: - """Determine the configured Duplex of the interface.""" - - @property - @abstractmethod def enabled(self) -> bool: """Determines if the interface is enabled.""" - - @property - @abstractmethod - def has_nac(self) -> bool: - """Determine if the interface has NAC configured.""" + return not self.config.get_child(equals="shutdown") @property def ipv4_interface(self) -> IPv4Interface | None: @@ -81,14 +55,9 @@ def ipv4_interfaces(self) -> Iterable[IPv4Interface]: """Determine the configured IPv4Interface, address/prefix, objects.""" @property - @abstractmethod - def is_bundle(self) -> bool: - """Determine if the interface is a bundle.""" - - @property - @abstractmethod def is_loopback(self) -> bool: """Determine if the interface is a loopback.""" + return self.name.lower().startswith("loopback") @property def is_subinterface(self) -> bool: @@ -96,139 +65,282 @@ def is_subinterface(self) -> bool: return "." in self.name @property - @abstractmethod def is_svi(self) -> bool: """Determine if the interface is an SVI.""" + return self.name.lower().startswith("vlan") @property - @abstractmethod - def module_number(self) -> int | None: - """Determine the module number of the interface.""" + def name(self) -> str: + """Determine the name of the interface.""" + return self.config.text.split()[1] @property - @abstractmethod - def nac_control_direction_in(self) -> bool: - """Determine if the interface has NAC 'control direction in' configured.""" + def number(self) -> str: + """Remove letters from the interface name, leaving just numbers and symbols.""" + return sub(r"^[a-zA-Z-]+", "", self.name) @property - @abstractmethod - def nac_host_mode(self) -> NACHostMode | None: - """Determine the NAC host mode.""" + def parent_name(self) -> str | None: + """Determine the parent interface name of a subinterface.""" + if self.is_subinterface: + return self.name.split(".")[0] + return None @property - @abstractmethod - def nac_mab_first(self) -> bool: - """Determine if the interface has NAC configured for MAB first.""" + def port_number(self) -> int: + """Determine the interface port number.""" + return int(self.number.split("/")[-1].split(".")[0]) @property - @abstractmethod - def nac_max_dot1x_clients(self) -> int: - """Determine the max dot1x clients.""" + def subinterface_number(self) -> int | None: + """Determine the sub-interface number.""" + return int(self.name.split(".")[-1]) if self.is_subinterface else None @property @abstractmethod - def nac_max_mab_clients(self) -> int: - """Determine the max mab clients.""" + def vrf(self) -> str: + """Determine the VRF.""" + + +class InterfaceBundleViewMixin(ConfigViewInterfaceBase, ABC): + """Mixin for platforms that support bundle (LAG/port-channel) interfaces. + + ``_bundle_membership_prefix`` is the child command prefix that assigns an + interface to a bundle, e.g. ``"channel-group "`` / ``"bundle id "``. It is + required by the default ``bundle_id``/``bundle_member_interfaces`` + implementations; the bundle id is the word at index + ``len(_bundle_membership_prefix.split())`` of the matching child. + """ + + _bundle_membership_prefix: str = "" @property - @abstractmethod - def name(self) -> str: - """Determine the name of the interface.""" + def bundle_id(self) -> str | None: + """Determine the bundle ID.""" + if not self._bundle_membership_prefix: + return None + if membership := self.config.get_child( + startswith=self._bundle_membership_prefix, + ): + return membership.text.split()[len(self._bundle_membership_prefix.split())] + return None + + @property + def bundle_member_interfaces(self) -> Iterable[str]: + """Determine the member interfaces of a bundle.""" + if not (self._bundle_membership_prefix and self.is_bundle): + return + id_index = len(self._bundle_membership_prefix.split()) + number = self.number + for interface in self.config.parent.get_children(startswith="interface "): + if ( + membership := interface.get_child( + startswith=self._bundle_membership_prefix, + ) + ) and membership.text.split()[id_index] == number: + yield interface.text.split()[1] + + @property + def bundle_name(self) -> str | None: + """Determine the bundle name of a bundle member.""" + if self.bundle_id: + return f"{self._bundle_prefix}{self.bundle_id}" + return None + + @property + def is_bundle(self) -> bool: + """Determine if the interface is a bundle.""" + return self.name.lower().startswith(self._bundle_prefix.lower()) @property @abstractmethod + def _bundle_prefix(self) -> str: + """Determine the platform's bundle interface name prefix.""" + + +class InterfaceVlanViewMixin(ConfigViewInterfaceBase, ABC): + """Mixin for platforms that support 802.1Q VLAN interface configuration. + + ``_encapsulation_prefix`` is the subinterface dot1q encapsulation command + prefix used by the default ``native_vlan`` implementation; the VLAN id is + the word at index ``len(_encapsulation_prefix.split())`` of the matching + child. + """ + + _encapsulation_prefix: str = "encapsulation dot1q " + + @property + def dot1q_mode(self) -> InterfaceDot1qMode | None: + """Derive the configured 802.1Q mode.""" + if self.tagged_all: + return InterfaceDot1qMode.TAGGED_ALL + if self.tagged_vlans: + return InterfaceDot1qMode.TAGGED + if self.native_vlan and not self.is_svi: + return InterfaceDot1qMode.ACCESS + return None + + @property def native_vlan(self) -> int | None: """Determine the native VLAN.""" + # It's configured as a sub-interface + if self.is_subinterface and ( + vlan := self.config.get_child(startswith=self._encapsulation_prefix) + ): + return int(vlan.text.split()[len(self._encapsulation_prefix.split())]) + + # It's not a switchport + if ( + self.config.get_child(equals="no switchport") + or self.config.get_child(startswith="ip address ") + or self.is_loopback + or self.is_svi + ): + return None + + # It's configured as a trunk + if self.config.get_child(equals="switchport mode trunk"): + if vlan := self.config.get_child( + startswith="switchport trunk native vlan ", + ): + return int(vlan.text.split()[4]) + + return None + + # It's either dynamic or configured as an access port + if vlan := self.config.get_child(startswith="switchport access vlan "): + return int(vlan.text.split()[3]) + + # Default VLAN + return 1 @property - @abstractmethod - def number(self) -> str: - """Remove letters from the interface name, leaving just numbers and symbols.""" + def tagged_all(self) -> bool: + """Determine if all the VLANs are tagged.""" + return bool( + self.config.get_child(equals="switchport mode trunk") + and not self.tagged_vlans, + ) + + @property + def tagged_vlans(self) -> tuple[int, ...]: + """Determine the tagged VLANs.""" + if child := self.config.get_child( + re_search="^switchport trunk allowed vlan [0-9,-]+$", + ): + return expand_range(child.text.split()[4]) + return () + + +class InterfaceNACViewMixin(ConfigViewInterfaceBase, ABC): + """Mixin for platforms that support NAC (802.1X/MAB) interface configuration.""" @property @abstractmethod - def parent_name(self) -> str | None: - """Determine the parent bundle interface name.""" + def has_nac(self) -> bool: + """Determine if the interface has NAC configured.""" @property @abstractmethod - def poe(self) -> bool: - """Determine if PoE is enabled.""" + def nac_control_direction_in(self) -> bool: + """Determine if the interface has NAC 'control direction in' configured.""" @property @abstractmethod - def port_number(self) -> int: - """Determine the interface port number.""" + def nac_host_mode(self) -> NACHostMode | None: + """Determine the NAC host mode.""" @property @abstractmethod - def speed(self) -> tuple[int, ...] | None: - """Determine the statically allowed speeds the interface can operate at. In Mbps.""" + def nac_mab_first(self) -> bool: + """Determine if the interface has NAC configured for MAB first.""" @property @abstractmethod - def subinterface_number(self) -> int | None: - """Determine the sub-interface number.""" + def nac_max_dot1x_clients(self) -> int: + """Determine the max dot1x clients.""" @property @abstractmethod - def tagged_all(self) -> bool: - """Determine if all the VLANs are tagged.""" + def nac_max_mab_clients(self) -> int: + """Determine the max mab clients.""" + + +class InterfacePhysicalViewMixin(ConfigViewInterfaceBase, ABC): + """Mixin for platforms that expose physical-layer interface settings.""" @property @abstractmethod - def tagged_vlans(self) -> tuple[int, ...]: - """Determine the tagged VLANs.""" + def duplex(self) -> InterfaceDuplex: + """Determine the configured Duplex of the interface.""" + + @property + def module_number(self) -> int | None: + """Determine the module number of the interface.""" + words = self.number.split("/", 1) + if len(words) == 1: + return None + return int(words[0]) @property @abstractmethod - def vrf(self) -> str: - """Determine the VRF.""" + def poe(self) -> bool: + """Determine if PoE is enabled.""" @property @abstractmethod - def _bundle_prefix(self) -> str: - pass + def speed(self) -> tuple[int, ...] | None: + """Determine the statically allowed speeds the interface can operate at. In Mbps.""" class HConfigViewBase(ABC): """Abstract base providing a structured view over a full HConfig tree. Platform-specific subclasses (e.g. ``HConfigViewCiscoIOS``) implement - ``interface_views`` to yield :class:`ConfigViewInterfaceBase` objects and - ``dot1q_mode_from_vlans`` to interpret 802.1Q mode from VLAN data. + ``interface_views`` to yield :class:`ConfigViewInterfaceBase` objects. """ def __init__(self, config: HConfig) -> None: self.config = config @property - def bundle_interface_views(self) -> Iterable[ConfigViewInterfaceBase]: + def bundle_interface_views(self) -> Iterable[InterfaceBundleViewMixin]: + """The interface views that represent bundle (LAG) interfaces.""" for interface_view in self.interface_views: - if interface_view.is_bundle: + if ( + isinstance(interface_view, InterfaceBundleViewMixin) + and interface_view.is_bundle + ): yield interface_view - @abstractmethod + @staticmethod def dot1q_mode_from_vlans( - self, untagged_vlan: int | None = None, tagged_vlans: tuple[int, ...] = (), *, tagged_all: bool = False, ) -> InterfaceDot1qMode | None: - pass + """Derive the 802.1Q mode implied by the given VLAN membership data.""" + if tagged_all: + return InterfaceDot1qMode.TAGGED_ALL + if tagged_vlans: + return InterfaceDot1qMode.TAGGED + if untagged_vlan is not None: + return InterfaceDot1qMode.ACCESS + return None @property @abstractmethod def hostname(self) -> str | None: - pass + """Determine the configured hostname, or None if not set.""" @property - @abstractmethod def interface_names_mentioned(self) -> frozenset[str]: - """A set with all the interface names mentioned in the config.""" + """Determine all the interface names mentioned in the config.""" + return frozenset(model.name for model in self.interface_views) def interface_view_by_name(self, name: str) -> ConfigViewInterfaceBase | None: + """Return the interface view for the given interface name, if any.""" for interface_view in self.interface_views: if interface_view.name == name: return interface_view @@ -237,32 +349,38 @@ def interface_view_by_name(self, name: str) -> ConfigViewInterfaceBase | None: @property @abstractmethod def interface_views(self) -> Iterable[ConfigViewInterfaceBase]: - pass + """A platform-specific interface view for each interface in the config.""" @property @abstractmethod def interfaces(self) -> Iterable[HConfigChild]: - pass + """The config children defining the device's interfaces.""" @property def interfaces_names(self) -> Iterable[str]: + """The name of each interface.""" for interface_view in self.interface_views: yield interface_view.name @property @abstractmethod def ipv4_default_gw(self) -> IPv4Address | None: - pass + """Determine the IPv4 default gateway address, if configured.""" @property - @abstractmethod def location(self) -> str: - pass + """Determine the SNMP location.""" + if location := self.config.get_child(startswith="snmp-server location "): + return location.text.split(maxsplit=2)[2].replace('"', "") + return "" @property def module_numbers(self) -> Iterable[int]: + """The unique module numbers of physical interfaces, in order seen.""" seen: set[int] = set() for interface_view in self.interface_views: + if not isinstance(interface_view, InterfacePhysicalViewMixin): + continue if module_number := interface_view.module_number: if module_number in seen: continue @@ -270,9 +388,12 @@ def module_numbers(self) -> Iterable[int]: yield module_number @property - @abstractmethod def stack_members(self) -> Iterable[StackMember]: - """Determine the configured stack members.""" + """Determine the configured stack members. + + Defaults to an empty tuple for platforms without stacking support. + """ + return () @property def vlan_ids(self) -> frozenset[int]: @@ -280,6 +401,37 @@ def vlan_ids(self) -> frozenset[int]: return frozenset(vlan.id for vlan in self.vlans) @property - @abstractmethod def vlans(self) -> Iterable[Vlan]: - """Determine the configured VLANs.""" + """Determine the configured VLANs. + + Yields explicitly defined VLAN blocks first, then any remaining + unnamed VLANs mentioned on interfaces (e.g. subinterface + encapsulations or switchport membership). + """ + yielded_vlans: set[int] = set() + + # Yield explicitly defined VLANs + for child in self.config.get_children(re_search="^vlan [0-9,-]+$"): + vlan_name = None + if name := child.get_child(startswith="name "): + _, vlan_name = name.text.split(maxsplit=1) + vlan_name = vlan_name.replace('"', "") + for vlan_id in expand_range(child.text.split()[1]): + yielded_vlans.add(vlan_id) + yield Vlan( + id=vlan_id, + name=vlan_name or None, + ) + + # Yield any remaining unnamed VLANs mentioned on interfaces + for interface_view in self.interface_views: + if not isinstance(interface_view, InterfaceVlanViewMixin): + continue + if ( + native_vlan := interface_view.native_vlan + ) and native_vlan not in yielded_vlans: + yielded_vlans.add(native_vlan) + yield Vlan( + id=native_vlan, + name=None, + ) diff --git a/hier_config/plugins.py b/hier_config/plugins.py new file mode 100644 index 00000000..175e95e3 --- /dev/null +++ b/hier_config/plugins.py @@ -0,0 +1,45 @@ +"""User-extensible remediation plugin system (#181). + +Plugins let users package custom remediation transforms — organization +policies, safety sequences, provisioning workflows — outside the hier_config +codebase and apply them via ``WorkflowRemediation(plugins=...)``. Driver +authors should prefer ``remediation_transform_callbacks`` on +``HConfigDriverRules`` (#180) for platform-level transforms. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .root import HConfig + + +class RemediationPlugin(ABC): + """Base class for user-defined remediation plugins. + + Subclasses implement ``transform()``, which receives the computed + remediation config and may mutate it in place (add safety commands, + reorder sections, drop disallowed changes, etc.). Instances are + callable, so anywhere a plain ``Callable[[HConfig], None]`` transform + is accepted, a plugin works too. + """ + + def __call__(self, remediation: HConfig) -> None: + """Apply this plugin's transform.""" + self.transform(remediation) + + @property + @abstractmethod + def name(self) -> str: + """Unique identifier for this plugin.""" + + @property + def description(self) -> str: + """Human-readable description of what this plugin does.""" + return "" + + @abstractmethod + def transform(self, remediation: HConfig) -> None: + """Transform the remediation config in place.""" diff --git a/hier_config/registry.py b/hier_config/registry.py new file mode 100644 index 00000000..a12ff614 --- /dev/null +++ b/hier_config/registry.py @@ -0,0 +1,116 @@ +"""Driver registration system (#226). + +Built-in drivers are registered at import time. Users can register drivers for +custom platforms (by string name), override built-in drivers, and restore +built-in defaults by unregistering the override. + +Entries are keyed on canonical uppercase platform names (#284): `Platform` +members are converted via their names at the boundary, and string names are +uppercased, so a member and its name address the same entry. + +The registry is not synchronized; register drivers at application startup, +before configs are parsed concurrently. +""" + +from hier_config.exceptions import DriverNotFoundError +from hier_config.models import Platform +from hier_config.platforms.arista_eos.driver import HConfigDriverAristaEOS +from hier_config.platforms.aruba_aoscx.driver import HConfigDriverArubaAOSCX +from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS +from hier_config.platforms.cisco_nxos.driver import HConfigDriverCiscoNXOS +from hier_config.platforms.cisco_xr.driver import HConfigDriverCiscoIOSXR +from hier_config.platforms.driver_base import HConfigDriverBase +from hier_config.platforms.fortinet_fortios.driver import HConfigDriverFortinetFortiOS +from hier_config.platforms.generic.driver import HConfigDriverGeneric +from hier_config.platforms.hp_comware5.driver import HConfigDriverHPComware5 +from hier_config.platforms.hp_procurve.driver import HConfigDriverHPProcurve +from hier_config.platforms.huawei_vrp.driver import HConfigDriverHuaweiVrp +from hier_config.platforms.juniper_junos.driver import HConfigDriverJuniperJUNOS +from hier_config.platforms.nokia_srl.driver import HConfigDriverNokiaSRL +from hier_config.platforms.vyos.driver import HConfigDriverVYOS + +_BUILTIN_DRIVERS: dict[str, type[HConfigDriverBase]] = { + Platform.ARISTA_EOS.name: HConfigDriverAristaEOS, + Platform.ARUBA_AOSCX.name: HConfigDriverArubaAOSCX, + Platform.CISCO_IOS.name: HConfigDriverCiscoIOS, + Platform.CISCO_NXOS.name: HConfigDriverCiscoNXOS, + Platform.CISCO_XR.name: HConfigDriverCiscoIOSXR, + Platform.FORTINET_FORTIOS.name: HConfigDriverFortinetFortiOS, + Platform.GENERIC.name: HConfigDriverGeneric, + Platform.HP_PROCURVE.name: HConfigDriverHPProcurve, + Platform.HP_COMWARE5.name: HConfigDriverHPComware5, + Platform.HUAWEI_VRP.name: HConfigDriverHuaweiVrp, + Platform.JUNIPER_JUNOS.name: HConfigDriverJuniperJUNOS, + Platform.NOKIA_SRL.name: HConfigDriverNokiaSRL, + Platform.VYOS.name: HConfigDriverVYOS, +} + +_registry: dict[str, type[HConfigDriverBase]] = dict(_BUILTIN_DRIVERS) + + +def _normalize(platform: Platform | str) -> str: + # Platform must be checked first: it subclasses str, and its str content + # is the enum value, not the platform name. + if isinstance(platform, Platform): + return platform.name + return platform.upper() + + +def register_driver( + platform: Platform | str, + driver_class: type[HConfigDriverBase], +) -> None: + """Register a driver for a platform. + + Passing a string registers a custom platform usable anywhere a `Platform` + is accepted; names are canonicalized to uppercase, so registration and + lookup are case-insensitive. Passing an existing `Platform` member (or its + name — the two are interchangeable) overrides the built-in driver for + that platform. + """ + _registry[_normalize(platform)] = driver_class + + +def unregister_driver(platform: Platform | str) -> None: + """Remove a custom platform, or restore an overridden built-in driver.""" + name = _normalize(platform) + if name not in _registry: + message = f"Unsupported platform: {platform}" + raise DriverNotFoundError(message) + builtin = _BUILTIN_DRIVERS.get(name) + if builtin is None: + del _registry[name] + elif _registry[name] is builtin: + # Format the canonical name: pre-3.11 f-strings render a str-Enum + # member as its meaningless value string. + message = f"Built-in platform {name} is not overridden" + raise DriverNotFoundError(message) + else: + _registry[name] = builtin + + +def get_registered_platforms() -> tuple[Platform | str, ...]: + """Return all registered platforms, built-in and custom. + + Names matching a `Platform` member are returned as members; custom names + are returned as canonical uppercase strings. + """ + return tuple(Platform.__members__.get(name, name) for name in _registry) + + +def resolve_driver( + platform_or_driver: Platform | str | HConfigDriverBase, +) -> HConfigDriverBase: + """Return the driver for a platform, platform name, or driver instance.""" + if isinstance(platform_or_driver, HConfigDriverBase): + return platform_or_driver + return get_hconfig_driver(platform_or_driver) + + +def get_hconfig_driver(platform: Platform | str) -> HConfigDriverBase: + """Instantiate the driver registered for a platform.""" + driver_class = _registry.get(_normalize(platform)) + if driver_class is None: + message = f"Unsupported platform: {platform}" + raise DriverNotFoundError(message) + return driver_class() diff --git a/hier_config/reporting.py b/hier_config/reporting.py index 26b78b1e..a870a380 100644 --- a/hier_config/reporting.py +++ b/hier_config/reporting.py @@ -133,7 +133,7 @@ def from_merged_config(cls, merged_config: HConfig) -> "RemediationReporter": Example: ```python - merged = get_hconfig(Platform.CISCO_IOS) + merged = HConfig.from_text(Platform.CISCO_IOS) merged.merge([device1, device2]) reporter = RemediationReporter.from_merged_config(merged) ``` @@ -173,7 +173,7 @@ def apply_tag_rules(self, tag_rules: Sequence[TagRule]) -> None: """ for tag_rule in tag_rules: for child in self.merged_config.get_children_deep(tag_rule.match_rules): - child.tags_add(tag_rule.apply_tags) + child.add_tags(tag_rule.apply_tags) def get_all_changes( self, @@ -660,7 +660,7 @@ def to_text( exclude_tags=exclude_tags, ) - lines = [child.cisco_style_text(style=style) for child in changes] + lines = [child.indented_text(style=style) for child in changes] output_path = Path(file_path) output_path.write_text("\n".join(lines), encoding="utf-8") diff --git a/hier_config/root.py b/hier_config/root.py index 1d825281..e2fa926d 100644 --- a/hier_config/root.py +++ b/hier_config/root.py @@ -1,14 +1,23 @@ from __future__ import annotations from logging import getLogger -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from .base import HConfigBase from .child import HConfigChild -from .models import Dump, DumpLine, ReferenceLocation +from .models import Dump, DumpLine, Platform, ReferenceLocation +from .tree_algorithms import ( + FutureReport, + compute_difference, + compute_future_with_report, + compute_remediation, + compute_with_tags, + prune_emptied_branches, +) if TYPE_CHECKING: from collections.abc import Iterable, Iterator + from pathlib import Path from hier_config.platforms.driver_base import HConfigDriverBase @@ -29,11 +38,106 @@ def __init__(self, driver: HConfigDriverBase) -> None: super().__init__() self._driver = driver + @classmethod + def from_text( + cls, + platform_or_driver: Platform | str | HConfigDriverBase, + config_text: Path | str = "", + ) -> HConfig: + """Create an HConfig from raw configuration text (or a Path to it).""" + from .constructors import ( + hconfig_from_text, + ) + + return hconfig_from_text(platform_or_driver, config_text) + + @classmethod + def from_lines( + cls, + platform_or_driver: Platform | str | HConfigDriverBase, + lines: list[str] | tuple[str, ...] | str, + ) -> HConfig: + """Create an HConfig from pre-split configuration lines (fast load).""" + from .constructors import ( + hconfig_from_lines, + ) + + return hconfig_from_lines(platform_or_driver, lines) + + @classmethod + def from_dump( + cls, + platform_or_driver: Platform | str | HConfigDriverBase, + dump: Dump, + ) -> HConfig: + """Reconstruct an HConfig from a serialized Dump.""" + from .constructors import ( + hconfig_from_dump, + ) + + return hconfig_from_dump(platform_or_driver, dump) + + @classmethod + def from_json( + cls, + platform_or_driver: Platform | str | HConfigDriverBase, + data: str | dict[str, Any], + *, + list_keys: tuple[str, ...] | None = None, + ) -> HConfig: + """Create an HConfig from a JSON object or JSON text. + + See `hier_config.formats` for the tree mapping rules. `list_keys` + names the members that identify entries of keyed lists + (OpenConfig-style); None means `formats.DEFAULT_LIST_KEYS`. + """ + from .formats import hconfig_from_json + + return hconfig_from_json(platform_or_driver, data, list_keys=list_keys) + + @classmethod + def from_xml( + cls, + platform_or_driver: Platform | str | HConfigDriverBase, + source: str, + *, + list_keys: tuple[str, ...] | None = None, + ) -> HConfig: + """Create an HConfig from an XML document. + + See `hier_config.formats` for the tree mapping rules. `list_keys` + names the child elements that identify repeated sibling elements; + None means `formats.DEFAULT_LIST_KEYS`. + """ + from .formats import hconfig_from_xml + + return hconfig_from_xml(platform_or_driver, source, list_keys=list_keys) + + def to_json(self, *, indent: int | None = 2) -> str: + """Render a tree built by `from_json` back to JSON text. + + Output is undefined for trees built by other constructors. + """ + from .formats import hconfig_to_json + + return hconfig_to_json(self, indent=indent) + + def to_xml(self) -> str: + """Render a tree built by `from_xml` back to XML text. + + Output is undefined for trees built by other constructors. + """ + from .formats import hconfig_to_xml + + return hconfig_to_xml(self) + def __str__(self) -> str: return "\n".join(str(c) for c in sorted(self.children)) def __repr__(self) -> str: - return f"HConfig(driver={self.driver.__class__.__name__}, lines={self.dump_simple()})" + return ( + f"HConfig(driver={self.driver.__class__.__name__}, lines={self.to_lines()})" + ) def __hash__(self) -> int: return hash(*self.children) @@ -46,14 +150,17 @@ def __eq__(self, other: object) -> bool: @property def driver(self) -> HConfigDriverBase: + """The platform driver this config was created with.""" return self._driver @property def real_indent_level(self) -> int: + """The indentation level used during parsing; always -1 for the root.""" return -1 @property def parent(self) -> HConfig: + """The root is its own parent.""" return self @property @@ -72,6 +179,7 @@ def is_branch(self) -> bool: return True def instantiate_child(self, text: str) -> HConfigChild: + """Create a new `HConfigChild` with self as the parent.""" return HConfigChild(self, text) @property @@ -113,10 +221,16 @@ def lineage(self) -> Iterator[HConfigChild]: # ruff:ignore[no-self-use] yield from () def lines(self, *, sectional_exiting: bool = False) -> Iterable[str]: + """Yield the indented config lines of the tree, sorted at each level. + + With `sectional_exiting`, the driver's exit token is appended after + each section that requires one. + """ for child in sorted(self.children): yield from child.lines(sectional_exiting=sectional_exiting) - def dump_simple(self, *, sectional_exiting: bool = False) -> tuple[str, ...]: + def to_lines(self, *, sectional_exiting: bool = False) -> tuple[str, ...]: + """Return the rendered config lines as a tuple.""" return tuple(self.lines(sectional_exiting=sectional_exiting)) def dump(self) -> Dump: @@ -124,7 +238,7 @@ def dump(self) -> Dump: return Dump( lines=tuple( DumpLine( - depth=c.depth(), + depth=c.depth, text=c.text, tags=frozenset(c.tags), comments=frozenset(c.comments), @@ -134,15 +248,16 @@ def dump(self) -> Dump: ), ) - def depth(self) -> int: # ruff:ignore[no-self-use] + @property + def depth(self) -> int: """The distance to the root HConfig object i.e. indent level.""" return 0 def difference(self, target: HConfig) -> HConfig: """Creates a new HConfig object with the config from self that is not in target.""" - return self._difference(target, HConfig(self.driver)) + return compute_difference(self, target, HConfig(self.driver)) - def config_to_get_to( + def remediation( self, target: HConfig, delta: HConfig | None = None, @@ -154,7 +269,7 @@ def config_to_get_to( if delta is None: delta = HConfig(self.driver) - return self._config_to_get_to(target, delta) + return compute_remediation(self, target, delta) def add_ancestor_copy_of( self, @@ -193,15 +308,37 @@ 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) - self._future(config, future_config) + report = compute_future_with_report(self, config, future_config) if prune_empty_branches: - self._prune_emptied_branches(future_config) - return future_config + prune_emptied_branches(self, 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.""" - return self._with_tags(frozenset(tags), HConfig(self.driver)) + return compute_with_tags(self, frozenset(tags), HConfig(self.driver)) def all_children_sorted_by_tags( self, @@ -226,7 +363,7 @@ def unused_objects(self) -> Iterator[HConfigChild]: extract their names, and search for references across the config tree. Objects with zero references are yielded. """ - from re import search as _re_search # ruff:ignore[import-outside-top-level] + from re import search as _re_search for rule in self.driver.rules.unused_objects: seen_names: set[str] = set() @@ -248,8 +385,8 @@ def _is_object_referenced( reference_locations: tuple[ReferenceLocation, ...], ) -> bool: """Return True if *name* is found in any reference location.""" - from re import escape as _re_escape # ruff:ignore[import-outside-top-level] - from re import search as _re_search # ruff:ignore[import-outside-top-level] + from re import escape as _re_escape + from re import search as _re_search for ref_location in reference_locations: pattern = ref_location.reference_re.format(name=_re_escape(name)) @@ -258,6 +395,14 @@ def _is_object_referenced( return True return False - def _is_duplicate_child_allowed(self) -> bool: # ruff:ignore[no-self-use] - """Determine if duplicate(identical text) children are allowed under the parent.""" - return False + def _is_duplicate_child_allowed(self) -> bool: + """Determine if duplicate(identical text) children are allowed at the root. + + A `ParentAllowsDuplicateChildRule` with empty `match_rules` applies to + the root (#215); children can never match an empty rule because lineage + matching requires equal lengths. + """ + return any( + not rule.match_rules + for rule in self.driver.rules.parent_allows_duplicate_child + ) diff --git a/hier_config/tree_algorithms.py b/hier_config/tree_algorithms.py new file mode 100644 index 00000000..88c2aa69 --- /dev/null +++ b/hier_config/tree_algorithms.py @@ -0,0 +1,349 @@ +"""Tree comparison algorithms extracted from HConfigBase (#217). + +These functions implement diffing (`compute_difference`), remediation +(`compute_remediation`), future-config prediction (`compute_future`), and +tag-filtered copying (`compute_with_tags`) over configuration trees. They +operate on `HConfig` / `HConfigChild` nodes through their public tree API, +so they can be tested and extended independently of the tree structure. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from .base import HConfigBase + from .child import HConfigChild + from .root import HConfig + + _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, ...] + """Kept negation lines whose positive form matched nothing in the source + config — the change did not apply cleanly.""" + + idempotency_replacements: tuple[HConfigChild, ...] + """Negation lines that persisted by replacing an idempotency-tracked + counterpart (e.g. IOS `no logging console`).""" + + +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, + delta: _HConfigRootOrChildT, +) -> _HConfigRootOrChildT: + """Compute the commands needed to transition from source to target. + + source is the running_config, target is the generated_config; the result + is written into delta (left pass negates missing, right pass adds new). + """ + _remediation_left(source, target, delta) + _remediation_right(source, target, delta) + + return delta + + +def _remediation_left( + source: HConfigBase, + target: HConfig | HConfigChild, + delta: HConfig | HConfigChild, +) -> None: + # find source.children that are not in target.children + # i.e. what needs to be negated or defaulted + # Also, find out if another command in source.children will overwrite + # i.e. be idempotent + for self_child in source.children: + if self_child.text in target.children: + continue + if self_child.is_idempotent_command(target.children): + continue + + # in other but not self + # add this node but not any children + negated = delta.add_child(self_child.text).negate() + if self_child.children: + negated.comments.add(f"removes {len(self_child.children) + 1} lines") + + +def _remediation_right( + source: HConfigBase, + target: HConfig | HConfigChild, + delta: HConfig | HConfigChild, +) -> None: + # Find what would need to be added to source_config to get to self + for target_child in target.children: + # If the child exist, recurse into its children + if self_child := source.children.get(target_child.text): + # Do we need to rewrite the child and its children as well? + if self_child.use_sectional_overwrite(): + self_child.overwrite_with(target_child, delta) + continue + if self_child.use_sectional_overwrite_without_negation(): + self_child.overwrite_with(target_child, delta, negate=False) + continue + # Matched leaves can never produce delta lines - neither pass + # has children to visit - so skip the subtree allocation (#191). + if not (self_child.children or target_child.children): + continue + subtree = delta.instantiate_child(target_child.text) + compute_remediation(self_child, target_child, subtree) + if subtree.children: + delta.children.append(subtree) + # The child is absent, add it. + else: + # If the target_child is already in the delta, that means it was negated in the target config + if target_child.text in delta.children: + continue + new_item = delta.add_deep_copy_of(target_child) + # Mark the new item and all of its children as new_in_config. + new_item.new_in_config = True + for child in new_item.all_children(): + child.new_in_config = True + if new_item.children: + new_item.comments.add("new section") + + +def _strip_acl_sequence_number(hier_child: HConfigChild) -> str: + words = hier_child.text.split() + if words[0].isdecimal(): + words.pop(0) + return " ".join(words) + + +def compute_difference( + source: HConfigBase, + target: _HConfigRootOrChildT, + delta: _HConfigRootOrChildT, + target_acl_children: dict[str, HConfigChild] | None = None, + *, + in_acl: bool = False, +) -> _HConfigRootOrChildT: + """Compute the config from source that is not in target, writing into delta.""" + acl_sw_matches = tuple(f"ip{x} access-list " for x in ("", "v4", "v6")) + + for self_child in source.children: + # Not dealing with negations and defaults for now + if self_child.text.startswith((source.driver.negation_prefix, "default ")): + continue + + if in_acl: + # Ignore ACL sequence numbers + if target_acl_children is None: + message = "target_acl_children cannot be None" + raise TypeError(message) + target_child = target_acl_children.get( + _strip_acl_sequence_number(self_child), + ) + else: + target_child = target.get_child(equals=self_child.text) + + if target_child is None: + delta.add_deep_copy_of(self_child) + else: + delta_child = delta.add_child(self_child.text) + if self_child.text.startswith(acl_sw_matches): + compute_difference( + self_child, + target_child, + delta_child, + target_acl_children={ + _strip_acl_sequence_number(c): c for c in target_child.children + }, + in_acl=True, + ) + else: + compute_difference(self_child, target_child, delta_child) + if not delta_child.children: + delta_child.delete() + + return delta + + +def _future_pre( + source: HConfigBase, + config: HConfig | HConfigChild, +) -> tuple[set[str], set[str]]: + negated_or_recursed: set[str] = set() + config_children_ignore: set[str] = set() + for self_child in source.children: + # Is the command effectively negating a command in source.children? + if (negation_text := source.root.driver.negate_with(self_child)) and ( + config_child := config.get_child(equals=negation_text) + ): + negated_or_recursed.add(self_child.text) + config_children_ignore.add(config_child.text) + return negated_or_recursed, config_children_ignore + + +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. + + Called by :meth:`HConfig.future` to walk the config tree and merge + ``config`` on top of ``source``, applying driver-specific rules for + sectional overwrite, idempotency, and negation. The result is written + into ``future_config``. + + Known gaps (not yet accounted for): + + - Negating a numbered ACL when removing a single entry + - 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: + if config_child.text in config_children_ignore: + continue + is_negation = config_child.text.startswith(source.driver.negation_prefix) + # sectional_overwrite + # sectional_overwrite_no_negate + if ( + config_child.use_sectional_overwrite() + or config_child.use_sectional_overwrite_without_negation() + ): + future_config.add_deep_copy_of(config_child) + # A negation whose positive form exists removes it; neither line + # survives. Evaluated before the idempotency rules, which can match + # the negation line itself and keep it as a literal child (#269). + elif is_negation and ( + exact := source.get_child(equals=config_child.text_without_negation) + ): + negated_or_recursed.add(exact.text) + # Idempotent commands: interchangeable forms of one setting replace + # each other. This deliberately covers negated forms tracked by a + # rule (e.g. IOS `no logging console`), which persist in the render. + elif self_child := source.root.driver.idempotent_for( + config_child, + source.children, + ): + 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). + elif is_negation and ( + prefix_matches := [ + child + for child in source.children + if child.text.startswith( + f"{config_child.text_without_negation} ", + ) + ] + ): + negated_or_recursed.update(child.text for child in prefix_matches) + # 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, 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: + 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}", + ): + negated_or_recursed.add(self_child.text) + # config_child is not in source and doesn't match a special case + else: + future_config.add_deep_copy_of(config_child) + + for self_child in source.children: + # self_child matched an above special case and should be ignored + if self_child.text in negated_or_recursed: + continue + # self_child was not modified above and should be present in the future config + future_config.add_deep_copy_of(self_child) + + +def compute_with_tags( + source: HConfigBase, + tags: frozenset[str], + new_instance: _HConfigRootOrChildT, +) -> _HConfigRootOrChildT: + """Add children recursively that have a subset of tags.""" + for child in source.children: + if tags.issubset(child.tags): + new_child = new_instance.add_shallow_copy_of(child) + compute_with_tags(child, tags, new_child) + + return new_instance + + +def prune_emptied_branches( + source: HConfigBase, + future_node: HConfigBase, +) -> None: + """Remove branches that a change emptied out, as devices do (#269). + + Only prunes nodes whose counterpart in the running config had children; + sections that were already empty (or are newly added empty) are kept. + Cascades upward via post-order traversal. + """ + for child in tuple(future_node.children): + source_child = source.get_child(equals=child.text) if source else None + if source_child is not None: + prune_emptied_branches(source_child, child) + if not child.children and source_child.children: + child.delete() diff --git a/hier_config/utils.py b/hier_config/utils.py index 7e1c3308..37ad556b 100644 --- a/hier_config/utils.py +++ b/hier_config/utils.py @@ -12,9 +12,8 @@ IdempotentCommandsRule, IndentAdjustRule, MatchRule, - NegationDefaultWhenRule, - NegationDefaultWithRule, - NegationSubRule, + NegationRule, + NegationStrategy, OrderingRule, ParentAllowsDuplicateChildRule, PerLineSubRule, @@ -27,23 +26,6 @@ ) from hier_config.platforms.driver_base import HConfigDriverBase -HCONFIG_PLATFORM_V2_TO_V3_MAPPING = { - # netutils sets this platform's network_driver_mappings["hier_config"] to - # "aruba_aoscx", and nautobot-golden-config resolves the driver by feeding - # that string through this mapper, so the entry is required for AOS-CX to - # resolve instead of falling back to GENERIC. - "aruba_aoscx": Platform.ARUBA_AOSCX, - "ios": Platform.CISCO_IOS, - "iosxe": Platform.CISCO_IOS, - "iosxr": Platform.CISCO_XR, - "nxos": Platform.CISCO_NXOS, - "eos": Platform.ARISTA_EOS, - "junos": Platform.JUNIPER_JUNOS, - "vyos": Platform.VYOS, - "huawei_vrp": Platform.HUAWEI_VRP, - "nokia_srl": Platform.NOKIA_SRL, -} - def _set_match_rule(lineage: dict[str, Any]) -> MatchRule | None: if startswith := lineage.get("startswith"): @@ -98,74 +80,28 @@ def load_hier_config_tags(tags_file: str) -> tuple[TagRule, ...]: return TypeAdapter(tuple[TagRule, ...]).validate_python(tags_data) -def hconfig_v2_os_v3_platform_mapper(os_name: str) -> Platform: - """Map a Hier Config v2 operating system name to a v3 Platform enumeration. - - Surrounding whitespace is stripped before lookup: consumers such as - nautobot-golden-config pass ``platform.network_driver_mappings["hier_config"]`` - straight in, and a stray trailing space there would otherwise miss the table - and silently fall back to ``Platform.GENERIC`` -- producing a wrong (often - destructive) remediation with no error. Case is left untouched. - - Args: - os_name (str): The name of the OS as defined in Hier Config v2. - - Returns: - Platform: The corresponding Platform enumeration for Hier Config v3. - - Example: - >>> hconfig_v2_os_v3_platform_mapper("CISCO_IOS") - - - """ - return HCONFIG_PLATFORM_V2_TO_V3_MAPPING.get(os_name.strip(), Platform.GENERIC) - - -def hconfig_v3_platform_v2_os_mapper(platform: Platform) -> str: - """Map a Hier Config v3 Platform enumeration to a v2 operating system name. - - Args: - platform (Platform): A Platform enumeration from Hier Config v3. - - Returns: - str: The corresponding OS name for Hier Config v2. - - Example: - >>> hconfig_v3_platform_v2_os_mapper(Platform.CISCO_IOS) - "ios" - - """ - for os_name, plat in HCONFIG_PLATFORM_V2_TO_V3_MAPPING.items(): - if plat == platform: - return os_name - - return "generic" - - def _process_simple_rules( - v2_options: dict[str, Any], + options: dict[str, Any], key: str, rule_class: type[Any], append_to: Callable[[Any], None], ) -> None: - """Process v2 rules that only need match_rules.""" - for rule in v2_options.get(key, ()): + """Process rules that only need match_rules.""" + for rule in options.get(key, ()): match_rules = _collect_match_rules(rule.get("lineage", [])) append_to(rule_class(match_rules=match_rules)) -def _process_custom_rules( - v2_options: dict[str, Any], driver: HConfigDriverBase -) -> None: - """Process v2 rules that require custom handling.""" - for rule in v2_options.get("ordering", ()): +def _process_custom_rules(options: dict[str, Any], driver: HConfigDriverBase) -> None: + """Process rules that require custom handling.""" + for rule in options.get("ordering", ()): match_rules = _collect_match_rules(rule.get("lineage", [])) weight = rule.get("order", 500) - 500 driver.rules.ordering.append( OrderingRule(match_rules=match_rules, weight=weight), ) - for rule in v2_options.get("indent_adjust", ()): + for rule in options.get("indent_adjust", ()): driver.rules.indent_adjust.append( IndentAdjustRule( start_expression=rule.get("start_expression"), @@ -173,7 +109,7 @@ def _process_custom_rules( ) ) - for rule in v2_options.get("sectional_exiting", ()): + for rule in options.get("sectional_exiting", ()): match_rules = _collect_match_rules(rule.get("lineage", [])) driver.rules.sectional_exiting.append( SectionalExitingRule( @@ -181,37 +117,42 @@ def _process_custom_rules( ), ) - for rule in v2_options.get("full_text_sub", ()): + for rule in options.get("full_text_sub", ()): driver.rules.full_text_sub.append( FullTextSubRule( search=rule.get("search", ""), replace=rule.get("replace", "") ) ) - for rule in v2_options.get("per_line_sub", ()): + for rule in options.get("per_line_sub", ()): driver.rules.per_line_sub.append( PerLineSubRule( search=rule.get("search", ""), replace=rule.get("replace", "") ) ) - for rule in v2_options.get("negation_negate_with", ()): + for rule in options.get("negation_negate_with", ()): match_rules = _collect_match_rules(rule.get("lineage", [])) - driver.rules.negate_with.append( - NegationDefaultWithRule(match_rules=match_rules, use=rule.get("use", "")), + driver.rules.negation.append( + NegationRule( + match_rules=match_rules, + strategy=NegationStrategy.REPLACE, + use=rule.get("use", ""), + ), ) - for rule in v2_options.get("negation_sub", ()): + for rule in options.get("negation_sub", ()): match_rules = _collect_match_rules(rule.get("lineage", [])) - driver.rules.negation_sub.append( - NegationSubRule( + driver.rules.negation.append( + NegationRule( match_rules=match_rules, + strategy=NegationStrategy.REGEX_SUB, search=rule.get("search", ""), replace=rule.get("replace", ""), ), ) - for rule in v2_options.get("unused_objects", ()): + for rule in options.get("unused_objects", ()): match_rules = _collect_match_rules(rule.get("lineage", [])) ref_locations = tuple( ReferenceLocation( @@ -229,25 +170,25 @@ def _process_custom_rules( ) -def load_hconfig_v2_options( - v2_options: dict[str, Any] | str, platform: Platform +def load_driver_rules( + options: dict[str, Any] | str, platform: Platform ) -> HConfigDriverBase: - """Load Hier Config v2 options to v3 driver format from either a dictionary or a file. + """Load driver rules from a dictionary or YAML file. Args: - v2_options (Union[dict, str]): Either a dictionary containing v2 options or - a file path to a YAML file containing the v2 options. - platform (Platform): The Hier Config v3 Platform enum for the target platform. + options: Either a dictionary containing driver rule options or + a file path to a YAML file containing the options. + platform: The Platform enum for the target platform. Returns: - HConfigDriverBase: A v3 driver instance with the migrated rules. + HConfigDriverBase: A driver instance with the loaded rules. """ - if isinstance(v2_options, str): - v2_options = yaml.safe_load(read_text_from_file(file_path=v2_options)) + if isinstance(options, str): + options = yaml.safe_load(read_text_from_file(file_path=options)) - if not isinstance(v2_options, dict): - msg = "v2_options must be a dictionary or a valid file path." + if not isinstance(options, dict): + msg = "options must be a dictionary or a valid file path." raise TypeError(msg) driver = get_hconfig_driver(platform) @@ -279,82 +220,55 @@ def load_hconfig_v2_options( IdempotentCommandsRule, driver.rules.idempotent_commands.append, ), - ( - "negation_default_when", - NegationDefaultWhenRule, - driver.rules.negation_default_when.append, - ), ) for key, rule_class, append_to in simple_rules: - _process_simple_rules(v2_options, key, rule_class, append_to) + _process_simple_rules(options, key, rule_class, append_to) + + for rule in options.get("negation_default_when", ()): + match_rules = _collect_match_rules(rule.get("lineage", [])) + driver.rules.negation.append( + NegationRule(match_rules=match_rules, strategy=NegationStrategy.DEFAULT), + ) # Process rules that require custom handling - _process_custom_rules(v2_options, driver) + _process_custom_rules(options, driver) return driver -def load_hconfig_v2_options_from_file( - options_file: str, platform: Platform -) -> HConfigDriverBase: - """Load Hier Config v2 options file to v3 driver format. - - Args: - options_file (str): The v2 options file. - platform (Platform): The Hier Config v3 Platform enum for the target platform. - - Returns: - HConfigDriverBase: A v3 driver instance with the migrated rules. - - """ - hconfig_options = yaml.safe_load(read_text_from_file(file_path=options_file)) - return load_hconfig_v2_options(v2_options=hconfig_options, platform=platform) - - -def load_hconfig_v2_tags( - v2_tags: list[dict[str, Any]] | str, -) -> tuple["TagRule"] | tuple["TagRule", ...]: - """Convert v2-style tags into v3-style TagRule Pydantic objects for Hier Config. +def load_tag_rules( + tags: list[dict[str, Any]] | str, +) -> tuple[TagRule, ...]: + """Load tag rules from a list of dictionaries or a YAML file. Args: - v2_tags (Union[list[dict[str, Any]], str]): - Either a list of dictionaries representing v2-style tags or a file path - to a YAML file containing the v2-style tags. - - If a list is provided, each dictionary should contain: + tags: Either a list of dictionaries or a file path to a YAML file. + Each dictionary should contain: - `lineage`: A list of dictionaries with rules (e.g., `startswith`, `endswith`). - `add_tags`: A string representing the tag to add. - - If a file path is provided, it will be read and parsed as YAML. Returns: - Tuple[TagRule]: A tuple of TagRule Pydantic objects representing v3-style tags. + A tuple of TagRule objects. """ - # Load tags from a file if a string is provided - if isinstance(v2_tags, str): - v2_tags = yaml.safe_load(read_text_from_file(file_path=v2_tags)) + if isinstance(tags, str): + tags = yaml.safe_load(read_text_from_file(file_path=tags)) - # Ensure v2_tags is a list - if not isinstance(v2_tags, list): - msg = "v2_tags must be a list of dictionaries or a valid file path." + if not isinstance(tags, list): + msg = "tags must be a list of dictionaries or a valid file path." raise TypeError(msg) - v3_tags: list[TagRule] = [] + result: list[TagRule] = [] - for v2_tag in v2_tags: - if "lineage" in v2_tag and "add_tags" in v2_tag: - # Extract the v2 fields - lineage_rules = v2_tag["lineage"] - tags = v2_tag["add_tags"] + for tag in tags: + if "lineage" in tag and "add_tags" in tag: + lineage_rules = tag["lineage"] + tag_name = tag["add_tags"] - # Convert to MatchRule objects - match_rules = tuple( - match_rule - for lineage in lineage_rules - if (match_rule := _set_match_rule(lineage)) is not None - ) + match_rules = _collect_match_rules(lineage_rules) - # Create the TagRule object - v3_tag = TagRule(match_rules=match_rules, apply_tags=frozenset([tags])) - v3_tags.append(v3_tag) + result.append( + TagRule(match_rules=match_rules, apply_tags=frozenset([tag_name])) + ) - return tuple(v3_tags) + return tuple(result) diff --git a/hier_config/workflows.py b/hier_config/workflows.py index fe24130b..9c489046 100644 --- a/hier_config/workflows.py +++ b/hier_config/workflows.py @@ -1,9 +1,17 @@ -from collections.abc import Iterable +from __future__ import annotations + from logging import getLogger +from typing import TYPE_CHECKING -from .models import TagRule +from .exceptions import IncompatibleDriverError from .root import HConfig +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from .formats import GnmiRemediation + from .models import TagRule + logger = getLogger(__name__) @@ -17,19 +25,21 @@ class WorkflowRemediation: generated_config (HConfig): The target configuration for the network device. Raises: - ValueError: If `running_config` and `generated_config` have different drivers. + IncompatibleDriverError: If `running_config` and `generated_config` have + different drivers. Example: Initialize `WorkflowRemediation` with the running and generated configurations and generate remediation and rollback configurations. ```python - from hier_config import WorkflowRemediation, get_hconfig - from hier_config.model import Platform + from hier_config import HConfig, Platform, WorkflowRemediation # Create running and generated configurations as HConfig objects - running_config = get_hconfig(Platform.CISCO_IOS, "running_config_text") - generated_config = get_hconfig(Platform.CISCO_IOS, "generated_config_text") + running_config = HConfig.from_text(Platform.CISCO_IOS, "running_config_text") + generated_config = HConfig.from_text( + Platform.CISCO_IOS, "generated_config_text" + ) # Initialize WorkflowRemediation with running and generated configurations workflow = WorkflowRemediation(running_config, generated_config) @@ -38,13 +48,13 @@ class WorkflowRemediation: remediation_config = workflow.remediation_config print("Remediation configuration:") for line in remediation_config.all_children_sorted(): - print(line.cisco_style_text()) + print(line.indented_text()) # Generate the rollback configuration to revert back to the running configuration rollback_config = workflow.rollback_config print("Rollback configuration:") for line in rollback_config.all_children_sorted(): - print(line.cisco_style_text()) + print(line.indented_text()) ``` """ @@ -53,13 +63,15 @@ def __init__( self, running_config: HConfig, generated_config: HConfig, + plugins: Iterable[Callable[[HConfig], None]] = (), ) -> None: self.running_config = running_config self.generated_config = generated_config + self.plugins = tuple(plugins) if running_config.driver.__class__ is not generated_config.driver.__class__: message = "The running and generated configs must use the same driver." - raise ValueError(message) + raise IncompatibleDriverError(message) self._remediation_config: HConfig | None = None self._rollback_config: HConfig | None = None @@ -79,10 +91,16 @@ def remediation_config(self) -> HConfig: if self._remediation_config: return self._remediation_config - remediation_config = self.running_config.config_to_get_to( + remediation_config = self.running_config.remediation( self.generated_config ).set_order_weight() + # Driver-level transforms (#180), then user plugins (#181). + for callback in remediation_config.driver.rules.remediation_transform_callbacks: + callback(remediation_config) + for plugin in self.plugins: + plugin(remediation_config) + self._remediation_config = remediation_config return self._remediation_config @@ -102,7 +120,7 @@ def rollback_config(self) -> HConfig: if self._rollback_config: return self._rollback_config - rollback_config = self.generated_config.config_to_get_to( + rollback_config = self.generated_config.remediation( self.running_config, HConfig(self.running_config.driver) ).set_order_weight() @@ -110,6 +128,46 @@ def rollback_config(self) -> HConfig: return rollback_config + def remediation_netconf_xml( + self, + *, + list_keys: tuple[str, ...] | None = None, + ) -> str: + """Render the remediation as a NETCONF edit-config payload. + + Requires running and generated configs built by `HConfig.from_xml()`. + Keyed list-entry deletions are expressed by their key leaf, resolved + against the running config via `list_keys`. + """ + from .formats import hconfig_to_netconf_xml + + return hconfig_to_netconf_xml( + self.remediation_config, + running=self.running_config, + list_keys=list_keys, + ) + + def remediation_json( + self, + *, + list_keys: tuple[str, ...] | None = None, + ) -> GnmiRemediation: + """Render the remediation as a gNMI-SetRequest-style dict. + + Requires running and generated configs built by `HConfig.from_json()`. + Returns `{"update": ..., "delete": [...]}` — added/changed values as a + JSON tree and deletions as xpath-ish paths. Keyed list-entry deletions + get `[key=value]` selectors, resolved against the running config via + `list_keys`. + """ + from .formats import hconfig_to_gnmi_json + + return hconfig_to_gnmi_json( + self.remediation_config, + running=self.running_config, + list_keys=list_keys, + ) + def apply_remediation_tag_rules(self, tag_rules: tuple[TagRule, ...]) -> None: """Applies tag rules to selectively label parts of the remediation configuration. @@ -125,7 +183,7 @@ def apply_remediation_tag_rules(self, tag_rules: tuple[TagRule, ...]) -> None: for child in self.remediation_config.get_children_deep( tag_rule.match_rules ): - child.tags_add(tag_rule.apply_tags) + child.add_tags(tag_rule.apply_tags) def remediation_config_filtered_text( self, @@ -153,4 +211,4 @@ def remediation_config_filtered_text( if include_tags or exclude_tags else self.remediation_config.all_children_sorted() ) - return "\n".join(c.cisco_style_text() for c in children) + return "\n".join(c.indented_text() for c in children) diff --git a/mkdocs.yml b/mkdocs.yml index 5bf14d55..c0037244 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,42 +17,56 @@ plugins: redirect_maps: install.md: user/install.md getting-started.md: user/getting-started.md - utilities.md: user/utilities.md - drivers.md: user/drivers.md + utilities.md: admin/rules-from-files.md + drivers.md: admin/platforms.md future-config.md: user/future-config.md unified-diff.md: user/unified-diff.md tags.md: user/tags.md - custom-workflows.md: user/custom-workflows.md + custom-workflows.md: user/remediation-workflows.md remediation-reporting.md: user/remediation-reporting.md - junos-style-syntax-remediation.md: user/junos-style-syntax-remediation.md - config-view.md: user/config-view.md - api-reference.md: user/api-reference.md - glossary.md: user/glossary.md + junos-style-syntax-remediation.md: user/set-style-platforms.md + config-view.md: user/config-views.md + api-reference.md: dev/api-reference.md architecture.md: dev/architecture.md + user/utilities.md: admin/rules-from-files.md + user/drivers.md: admin/platforms.md + user/custom-drivers.md: admin/custom-drivers.md + user/custom-workflows.md: user/remediation-workflows.md + user/junos-style-syntax-remediation.md: user/set-style-platforms.md + user/config-view.md: user/config-views.md + user/api-reference.md: dev/api-reference.md + user/glossary.md: glossary.md + dev/extending.md: dev/creating-drivers.md nav: - Home: index.md - User Guide: - - Install: user/install.md + - Installation: user/install.md - Getting Started: user/getting-started.md - - Drivers: user/drivers.md - - Customizing and Creating Drivers: user/custom-drivers.md - - Future Config: user/future-config.md - - Unified Diff: user/unified-diff.md + - Migrating from v3: user/migrating-from-v3.md + - Loading Configurations: user/loading-configs.md + - Remediation Workflows: user/remediation-workflows.md - Working with Tags: user/tags.md - - Custom Workflows: user/custom-workflows.md + - Predicting Future Configs: user/future-config.md + - Unified Diffs: user/unified-diff.md - Remediation Reporting: user/remediation-reporting.md - - JunOS Style Syntax Remediation: user/junos-style-syntax-remediation.md - - Config View: user/config-view.md - - Utilities: user/utilities.md - - API Reference: user/api-reference.md - - Glossary: user/glossary.md + - Config Views: user/config-views.md + - Set-Style Platforms: user/set-style-platforms.md + - Administrator Guide: + - Supported Platforms: admin/platforms.md + - Customizing Driver Rules: admin/customizing-rules.md + - Custom Drivers and Registration: admin/custom-drivers.md + - Loading Rules from Files: admin/rules-from-files.md - Developer Guide: - - Contributing: dev/contributing.md - Architecture: dev/architecture.md - - Extending hier_config: dev/extending.md + - Driver Rule Reference: dev/rule-reference.md + - Creating a Platform Driver: dev/creating-drivers.md - Testing Conventions: dev/testing.md - Code Style & Standards: dev/code-style.md + - Shared Development Standards: dev/shared-standards.md + - Contributing: dev/contributing.md + - API Reference: dev/api-reference.md - Maintainer Guide: - Releases: admin/releases.md - CI & Infrastructure: admin/infrastructure.md + - Glossary: glossary.md diff --git a/poetry.lock b/poetry.lock index 8f6e47b9..75a45ecb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -24,6 +24,26 @@ files = [ {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, ] +[[package]] +name = "anyio" +version = "4.14.2" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"}, + {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + [[package]] name = "ast-serialize" version = "0.6.0" @@ -95,6 +115,18 @@ files = [ {file = "bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4"}, ] +[[package]] +name = "certifi" +version = "2026.7.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, +] + [[package]] name = "click" version = "8.4.2" @@ -343,6 +375,80 @@ files = [ [package.extras] pypi = ["pip (>=24.0)", "platformdirs (>=4.2)", "wheel (>=0.42)"] +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.18" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, +] + +[package.extras] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "iniconfig" version = "2.3.0" @@ -355,6 +461,18 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "invoke" +version = "3.0.3" +description = "Pythonic task execution" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053"}, + {file = "invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"}, +] + [[package]] name = "isort" version = "8.0.1" @@ -909,14 +1027,14 @@ files = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, - {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, ] [[package]] @@ -1753,4 +1871,4 @@ dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "ruff", "sph [metadata] lock-version = "2.1" python-versions = ">=3.10.0,<4.0" -content-hash = "a505991f239a9022d3c25980a1661c095596ebacd36d83d508269fe5d17e107f" +content-hash = "bdccddb9c9c27a3d9fa844eb66c77fccfa3f8039b56b5ab2e21e422c2b220787" diff --git a/pyproject.toml b/pyproject.toml index 1641644a..ad6247f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hier-config" -version = "3.7.0" +version = "4.0.0b2" description = "A network configuration query and comparison library, used to build remediation configurations." packages = [ { include="hier_config", from="."}, @@ -50,6 +50,9 @@ ruff = "*" typer = "*" types-pyyaml = "*" yamllint = "*" +invoke = "^3.0.3" +httpx = "^0.28.1" +pyyaml = "^6.0.3" [build-system] @@ -81,6 +84,7 @@ load-plugins = [ ] disable = [ "consider-alternative-union-syntax", + "cyclic-import", # Only lazy (function-level) cycles remain: HConfig classmethod constructors defer their loader imports to call time "duplicate-code", # Enable this at some point in the future "fixme", # Covered by ruff FIX002 "import-outside-toplevel", # Covered by ruff PLC0415 @@ -149,7 +153,12 @@ parametrize-values-type = "tuple" [tool.ruff.lint.per-file-ignores] "**/tests/*" = ["PLC2701", "S101"] -"tests/test_benchmarks.py" = ["T201", "PLR6301", "PERF401"] +"tests/benchmarks/test_benchmarks.py" = ["T201", "PLR6301", "PERF401"] +# HConfig's classmethod constructors and WorkflowRemediation's format +# renderers defer their loader imports to call time to avoid circular +# imports and keep `import hier_config` light. +"hier_config/root.py" = ["PLC0415"] +"hier_config/workflows.py" = ["PLC0415"] [tool.pytest.ini_options] markers = [ diff --git a/scripts/rotate_changelog.py b/scripts/rotate_changelog.py new file mode 100755 index 00000000..1b03f010 --- /dev/null +++ b/scripts/rotate_changelog.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Rotate CHANGELOG.md's Unreleased section into a dated release section. + +Used by .github/workflows/prepare-release.yml. Prints the rotated section +body to stdout so the workflow can reuse it as the draft release notes. +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from pathlib import Path + +UNRELEASED_HEADING = "## [Unreleased]" + + +def rotate(changelog_path: Path, version: str, date: str) -> str: + text = changelog_path.read_text(encoding="utf-8") + start = text.find(UNRELEASED_HEADING) + if start == -1: + message = f"{changelog_path} has no '{UNRELEASED_HEADING}' section" + raise ValueError(message) + + body_start = start + len(UNRELEASED_HEADING) + next_heading = text.find("\n## [", body_start) + body_end = next_heading if next_heading != -1 else len(text) + # The trailing "---" thematic break belongs to the section separator, + # not to the release notes themselves. + section_body = text[body_start:body_end].strip("\n").removesuffix("---").strip("\n") + if not section_body: + message = "the Unreleased section is empty; nothing to release" + raise ValueError(message) + + released = f"## [{version}] - {date}\n\n{section_body}\n" + rotated = f"{text[:start]}{UNRELEASED_HEADING}\n\n{released}" + remainder = text[body_end:] + if remainder: + rotated += f"\n---\n{remainder}" + changelog_path.write_text(rotated, encoding="utf-8") + return section_body + + +def main() -> None: + if len(sys.argv) != 2: + sys.exit(f"usage: {sys.argv[0]} ") + date = datetime.now(tz=timezone.utc).date().isoformat() + try: + notes = rotate(Path("CHANGELOG.md"), sys.argv[1], date) + except (OSError, ValueError) as exc: + sys.exit(str(exc)) + print(notes) # ruff:ignore[print] + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_standards.py b/scripts/sync_standards.py new file mode 100755 index 00000000..536c8f0f --- /dev/null +++ b/scripts/sync_standards.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Sync shared development-standard files from the canonical netdevops repository. + +The `.standards.yml` manifest at the repository root declares the canonical +source repository and the files it owns. `check` reports drift between the +local copies and the canonical versions; `apply` overwrites the local copies +with the canonical versions. + +Package-name substitutions can push a canonical line past the formatter's +line-length limit, so Python files are re-formatted with `ruff format` after +substitution. Without this the synced file would never converge: `apply` +would write a file that `ruff format` immediately rewrites, and the next +`check` would report drift again. +""" + +from __future__ import annotations + +import difflib +import re +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] +import sys +from http import HTTPStatus +from pathlib import Path + +import httpx +import yaml +from pydantic import BaseModel, Field +from typer import Typer + +app = Typer() + +_REPO_ROOT = Path(__file__).parent.parent +_MANIFEST_PATH = _REPO_ROOT / ".standards.yml" + + +class Source(BaseModel): + """Canonical repository holding the standard files.""" + + repo: str + ref: str + + +class Manifest(BaseModel): + """Parsed representation of the `.standards.yml` manifest.""" + + source: Source + substitutions: dict[str, str] = Field(default_factory=dict) + files: tuple[str, ...] + + +@app.callback() +def callback() -> None: + """Sync shared development standards from the canonical repository.""" + + +@app.command() +def check() -> None: + """Report drift between local files and the canonical standards.""" + if _sync(write=False): + sys.exit(1) + + +@app.command() +def apply() -> None: + """Overwrite local files with the canonical standards.""" + _sync(write=True) + + +def _sync(*, write: bool) -> list[str]: + manifest = _load_manifest() + drifted: list[str] = [] + for file_path in manifest.files: + canonical = _canonical_content(manifest, file_path) + if canonical is None: + source = manifest.source + print( # ruff: ignore[print] + f"{file_path}: not published on {source.repo}@{source.ref} — " + "the manifest lists a file the canonical source does not have yet", + ) + drifted.append(file_path) + continue + local_path = _REPO_ROOT / file_path + local = local_path.read_text(encoding="utf-8") if local_path.is_file() else None + if local == canonical: + print(f"{file_path}: in sync") # ruff: ignore[print] + continue + drifted.append(file_path) + if write: + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_text(canonical, encoding="utf-8") + print(f"{file_path}: updated from canonical") # ruff: ignore[print] + else: + print(f"{file_path}: drifted from canonical") # ruff: ignore[print] + _print_diff(file_path, local or "", canonical) + return drifted + + +def _load_manifest() -> Manifest: + data = yaml.safe_load(_MANIFEST_PATH.read_text(encoding="utf-8")) + return Manifest.model_validate(data) + + +def _fetch(source: Source, file_path: str) -> str | None: + """Return the canonical file contents, or None when the source lacks the file.""" + url = f"https://raw.githubusercontent.com/{source.repo}/{source.ref}/{file_path}" + response = httpx.get(url, timeout=30.0, follow_redirects=True) + if response.status_code == HTTPStatus.NOT_FOUND: + return None + response.raise_for_status() + return response.text + + +def _canonical_content(manifest: Manifest, file_path: str) -> str | None: + fetched = _fetch(manifest.source, file_path) + if fetched is None: + return None + content = _apply_substitutions(fetched, manifest.substitutions) + if file_path.endswith(".py"): + content = _ruff_format(content, file_path) + return content + + +def _apply_substitutions(content: str, substitutions: dict[str, str]) -> str: + for old, new in substitutions.items(): + content = re.sub(rf"\b{re.escape(old)}\b", new, content) + return content + + +def _ruff_format(content: str, file_path: str) -> str: + ruff = shutil.which("ruff") + if ruff is None: + message = "ruff is not installed; run this from the dev environment" + raise RuntimeError(message) + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] + [ruff, "format", "--stdin-filename", file_path, "-"], + check=True, + capture_output=True, + text=True, + input=content, + ) + return result.stdout + + +def _print_diff(file_path: str, local: str, canonical: str) -> None: + diff = difflib.unified_diff( + local.splitlines(keepends=True), + canonical.splitlines(keepends=True), + fromfile=f"local/{file_path}", + tofile=f"canonical/{file_path}", + ) + sys.stdout.writelines(diff) + + +if __name__ == "__main__": + app() diff --git a/tasks.py b/tasks.py new file mode 100644 index 00000000..24be07eb --- /dev/null +++ b/tasks.py @@ -0,0 +1,69 @@ +"""Invoke tasks for the Docker development environment.""" + +from typing import TYPE_CHECKING + +from invoke.context import Context + +if TYPE_CHECKING: + from collections.abc import Callable + + # Typed stand-in for invoke's partially typed task decorator + def task(_func: Callable[..., None]) -> Callable[..., None]: ... + +else: + from invoke.tasks import task + +_RUN = "docker compose run --rm dev" + + +@task +def build(context: Context) -> None: + """Build the Docker development image.""" + context.run("docker compose build", pty=True) + + +@task +def docs(context: Context) -> None: + """Serve the documentation with live reload at http://localhost:8001.""" + context.run("docker compose --profile docs up docs", pty=True) + + +@task +def pytest(context: Context, *, coverage: bool = False) -> None: + """Run the test suite inside the development container.""" + command = "python scripts/build.py pytest --coverage" if coverage else "pytest" + context.run(f"{_RUN} {command}", pty=True) + + +@task +def lint(context: Context, *, fix: bool = False) -> None: + """Run all linters and type checkers inside the development container.""" + context.run( + f"{_RUN} python scripts/build.py lint{' --fix' if fix else ''}", + pty=True, + ) + + +@task +def lint_and_test(context: Context) -> None: + """Run the full lint + test suite (what CI runs) inside the development container.""" + context.run(f"{_RUN} python scripts/build.py lint-and-test", pty=True) + + +@task +def cli(context: Context) -> None: + """Open a shell inside the development container.""" + context.run(f"{_RUN} bash", pty=True) + + +@task +def sync_standards(context: Context, *, apply: bool = False) -> None: + """Check drift against the published canonical standards (--apply to update).""" + action = "apply" if apply else "check" + context.run(f"{_RUN} python scripts/sync_standards.py {action}", pty=True) + + +@task +def destroy(context: Context) -> None: + """Stop and remove the development containers.""" + context.run("docker compose --profile docs down --remove-orphans", pty=True) diff --git a/tests/config_view/__init__.py b/tests/benchmarks/__init__.py similarity index 100% rename from tests/config_view/__init__.py rename to tests/benchmarks/__init__.py diff --git a/tests/test_benchmarks.py b/tests/benchmarks/test_benchmarks.py similarity index 75% rename from tests/test_benchmarks.py rename to tests/benchmarks/test_benchmarks.py index 076b9657..cfe102fc 100644 --- a/tests/test_benchmarks.py +++ b/tests/benchmarks/test_benchmarks.py @@ -9,7 +9,7 @@ import pytest -from hier_config import get_hconfig, get_hconfig_fast_load +from hier_config import HConfig from hier_config.models import Platform pytestmark = pytest.mark.benchmark @@ -54,8 +54,10 @@ def _generate_large_ios_config(num_interfaces: int = 1000) -> str: " auto-cost reference-bandwidth 100000", ] ) - for i in range(num_interfaces): - lines.append(f" network 10.{i // 256}.{i % 256}.0 0.0.0.3 area 0") + lines.extend( + f" network 10.{i // 256}.{i % 256}.0 0.0.0.3 area 0" + for i in range(num_interfaces) + ) lines.extend( [ "!", @@ -125,8 +127,10 @@ def _generate_large_xr_config(num_interfaces: int = 1000) -> str: " router-id 10.0.0.1", ] ) - for i in range(num_interfaces): - lines.append(f" area 0 interface GigabitEthernet0/0/0/{i} cost 100") + lines.extend( + f" area 0 interface GigabitEthernet0/0/0/{i} cost 100" + for i in range(num_interfaces) + ) lines.append("!") return "\n".join(lines) @@ -145,40 +149,44 @@ def _time_fn(fn: Callable[[], object], iterations: int = 3) -> float: class TestParsingBenchmarks: """Benchmarks for config parsing.""" - def test_parse_large_ios_config(self) -> None: + @staticmethod + def test_parse_large_ios_config() -> None: """Parse a ~10k line IOS config via get_hconfig.""" config_text = _generate_large_ios_config() - elapsed = _time_fn(lambda: get_hconfig(Platform.CISCO_IOS, config_text)) + elapsed = _time_fn(lambda: HConfig.from_text(Platform.CISCO_IOS, config_text)) line_count = config_text.count("\n") print(f"\nget_hconfig: {line_count} lines in {elapsed:.4f}s") assert elapsed < 5.0, f"Parsing took {elapsed:.2f}s, expected < 5s" - def test_parse_large_xr_config(self) -> None: + @staticmethod + def test_parse_large_xr_config() -> None: """Parse a ~10k line XR config via get_hconfig.""" config_text = _generate_large_xr_config() - elapsed = _time_fn(lambda: get_hconfig(Platform.CISCO_XR, config_text)) + elapsed = _time_fn(lambda: HConfig.from_text(Platform.CISCO_XR, config_text)) line_count = config_text.count("\n") print(f"\nget_hconfig (XR): {line_count} lines in {elapsed:.4f}s") assert elapsed < 5.0, f"Parsing took {elapsed:.2f}s, expected < 5s" - def test_fast_load_large_ios_config(self) -> None: + @staticmethod + def test_fast_load_large_ios_config() -> None: """Parse a ~10k line IOS config via get_hconfig_fast_load.""" config_text = _generate_large_ios_config() config_lines = tuple(config_text.splitlines()) elapsed = _time_fn( - lambda: get_hconfig_fast_load(Platform.CISCO_IOS, config_lines), + lambda: HConfig.from_lines(Platform.CISCO_IOS, config_lines), ) print(f"\nget_hconfig_fast_load: {len(config_lines)} lines in {elapsed:.4f}s") assert elapsed < 5.0, f"Fast load took {elapsed:.2f}s, expected < 5s" - def test_fast_load_vs_get_hconfig(self) -> None: + @staticmethod + def test_fast_load_vs_get_hconfig() -> None: """get_hconfig_fast_load should be faster than get_hconfig.""" config_text = _generate_large_ios_config() config_lines = tuple(config_text.splitlines()) - time_full = _time_fn(lambda: get_hconfig(Platform.CISCO_IOS, config_text)) + time_full = _time_fn(lambda: HConfig.from_text(Platform.CISCO_IOS, config_text)) time_fast = _time_fn( - lambda: get_hconfig_fast_load(Platform.CISCO_IOS, config_lines), + lambda: HConfig.from_lines(Platform.CISCO_IOS, config_lines), ) ratio = time_full / time_fast if time_fast > 0 else float("inf") print( @@ -193,38 +201,41 @@ def test_fast_load_vs_get_hconfig(self) -> None: class TestRemediationBenchmarks: - """Benchmarks for config_to_get_to remediation.""" + """Benchmarks for remediation remediation.""" - def test_remediation_small_diff(self) -> None: + @staticmethod + def test_remediation_small_diff() -> None: """Remediation with ~5% of interfaces changed.""" running_text = _generate_large_ios_config() - running = get_hconfig(Platform.CISCO_IOS, running_text) + running = HConfig.from_text(Platform.CISCO_IOS, running_text) # Modify 50 interfaces in generated config generated_text = running_text.replace( " ip ospf cost 100", " ip ospf cost 200", 50 ) - generated = get_hconfig(Platform.CISCO_IOS, generated_text) + generated = HConfig.from_text(Platform.CISCO_IOS, generated_text) - elapsed = _time_fn(lambda: running.config_to_get_to(generated)) + elapsed = _time_fn(lambda: running.remediation(generated)) print(f"\nRemediation (10% diff): {elapsed:.4f}s") assert elapsed < 5.0, f"Remediation took {elapsed:.2f}s, expected < 5s" - def test_remediation_large_diff(self) -> None: + @staticmethod + def test_remediation_large_diff() -> None: """Remediation with ~100% of interfaces changed.""" - running = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config()) + running = HConfig.from_text(Platform.CISCO_IOS, _generate_large_ios_config()) generated_text = _generate_large_ios_config().replace( " ip ospf cost 100", " ip ospf cost 200" ) - generated = get_hconfig(Platform.CISCO_IOS, generated_text) + generated = HConfig.from_text(Platform.CISCO_IOS, generated_text) - elapsed = _time_fn(lambda: running.config_to_get_to(generated)) + elapsed = _time_fn(lambda: running.remediation(generated)) print(f"\nRemediation (100% diff): {elapsed:.4f}s") assert elapsed < 10.0, f"Remediation took {elapsed:.2f}s, expected < 10s" - def test_remediation_completely_different(self) -> None: + @staticmethod + def test_remediation_completely_different() -> None: """Remediation between two entirely different configs.""" - running = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config(500)) + running = HConfig.from_text(Platform.CISCO_IOS, _generate_large_ios_config(500)) # Generate a completely different config lines = ["hostname OTHER-ROUTER"] for i in range(500): @@ -235,9 +246,9 @@ def test_remediation_completely_different(self) -> None: f" ip address 192.168.{i // 256}.{i % 256} 255.255.255.255", ] ) - generated = get_hconfig(Platform.CISCO_IOS, "\n".join(lines)) + generated = HConfig.from_text(Platform.CISCO_IOS, "\n".join(lines)) - elapsed = _time_fn(lambda: running.config_to_get_to(generated)) + elapsed = _time_fn(lambda: running.remediation(generated)) print(f"\nRemediation (completely different): {elapsed:.4f}s") assert elapsed < 10.0, f"Remediation took {elapsed:.2f}s, expected < 10s" @@ -245,27 +256,30 @@ def test_remediation_completely_different(self) -> None: class TestIterationBenchmarks: """Benchmarks for tree traversal and iteration.""" - def test_all_children_sorted(self) -> None: + @staticmethod + def test_all_children_sorted() -> None: """Iterate all_children_sorted on a large config.""" - config = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config()) + config = HConfig.from_text(Platform.CISCO_IOS, _generate_large_ios_config()) elapsed = _time_fn(lambda: list(config.all_children_sorted())) child_count = len(list(config.all_children())) print(f"\nall_children_sorted: {child_count} nodes in {elapsed:.4f}s") assert elapsed < 2.0, f"Iteration took {elapsed:.2f}s, expected < 2s" - def test_dump_simple(self) -> None: + @staticmethod + def test_to_lines() -> None: """Dump a large config to simple text.""" - config = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config()) + config = HConfig.from_text(Platform.CISCO_IOS, _generate_large_ios_config()) - elapsed = _time_fn(config.dump_simple) - line_count = len(config.dump_simple()) - print(f"\ndump_simple: {line_count} lines in {elapsed:.4f}s") - assert elapsed < 2.0, f"dump_simple took {elapsed:.2f}s, expected < 2s" + elapsed = _time_fn(config.to_lines) + line_count = len(config.to_lines()) + print(f"\nto_lines: {line_count} lines in {elapsed:.4f}s") + assert elapsed < 2.0, f"to_lines took {elapsed:.2f}s, expected < 2s" - def test_deep_copy(self) -> None: + @staticmethod + def test_deep_copy() -> None: """Deep copy a large config tree.""" - config = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config()) + config = HConfig.from_text(Platform.CISCO_IOS, _generate_large_ios_config()) elapsed = _time_fn(config.deep_copy) print(f"\ndeep_copy: {elapsed:.4f}s") diff --git a/tests/circular/__init__.py b/tests/circular/__init__.py deleted file mode 100644 index 8162f58a..00000000 --- a/tests/circular/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Circular config workflow tests.""" diff --git a/tests/config_view/test_view_arista_eos.py b/tests/config_view/test_view_arista_eos.py deleted file mode 100644 index a95e3321..00000000 --- a/tests/config_view/test_view_arista_eos.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Tests for Arista EOS view.py ConfigViewInterfaceAristaEOS and HConfigViewAristaEOS classes.""" - -import pytest - -from hier_config import Platform, get_hconfig, get_hconfig_view - - -def test_dot1q_mode_from_vlans_not_implemented() -> None: - """Test dot1q_mode_from_vlans raises NotImplementedError (covers line 154).""" - config = get_hconfig(Platform.ARISTA_EOS) - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - view.dot1q_mode_from_vlans(untagged_vlan=10) - - -def test_hostname() -> None: - """Test hostname returns hostname (covers lines 158-160).""" - config = get_hconfig(Platform.ARISTA_EOS) - config.add_child("hostname ARISTA-LEAF-01") - - view = get_hconfig_view(config) - assert view.hostname == "arista-leaf-01" - - -def test_hostname_none() -> None: - """Test hostname returns None (covers line 160).""" - config = get_hconfig(Platform.ARISTA_EOS) - - view = get_hconfig_view(config) - assert view.hostname is None - - -def test_interface_names_mentioned_not_implemented() -> None: - """Test interface_names_mentioned raises NotImplementedError (covers line 165).""" - config = get_hconfig(Platform.ARISTA_EOS) - config.add_child("interface Ethernet1") - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = view.interface_names_mentioned - - -def test_interface_views() -> None: - """Test interface_views yields interface views (covers lines 169-170).""" - config = get_hconfig(Platform.ARISTA_EOS) - config.add_child("interface Ethernet1") - config.add_child("interface Ethernet2") - config.add_child("interface Management1") - - view = get_hconfig_view(config) - interface_views = list(view.interface_views) - - assert len(interface_views) == 3 - - -def test_interfaces() -> None: - """Test interfaces returns interface children (covers line 174).""" - config = get_hconfig(Platform.ARISTA_EOS) - config.add_child("interface Ethernet1") - config.add_child("interface Ethernet2") - - view = get_hconfig_view(config) - interfaces = list(view.interfaces) - - assert len(interfaces) == 2 - - -def test_ipv4_default_gw_not_implemented() -> None: - """Test ipv4_default_gw raises NotImplementedError (covers line 178).""" - config = get_hconfig(Platform.ARISTA_EOS) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = view.ipv4_default_gw - - -def test_location_not_implemented() -> None: - """Test location raises NotImplementedError (covers line 182).""" - config = get_hconfig(Platform.ARISTA_EOS) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = view.location - - -def test_stack_members_not_implemented() -> None: - """Test stack_members raises NotImplementedError (covers line 186).""" - config = get_hconfig(Platform.ARISTA_EOS) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = list(view.stack_members) - - -def test_vlans_not_implemented() -> None: - """Test vlans raises NotImplementedError (covers line 190).""" - config = get_hconfig(Platform.ARISTA_EOS) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = list(view.vlans) diff --git a/tests/config_view/test_view_cisco_nxos.py b/tests/config_view/test_view_cisco_nxos.py deleted file mode 100644 index 7d4237f1..00000000 --- a/tests/config_view/test_view_cisco_nxos.py +++ /dev/null @@ -1,606 +0,0 @@ -"""Tests for Cisco NX-OS view.py ConfigViewInterfaceCiscoNXOS and HConfigViewCiscoNXOS classes.""" - -from ipaddress import IPv4Interface - -import pytest - -from hier_config import Platform, get_hconfig, get_hconfig_view - - -def test_bundle_id_not_implemented() -> None: - """Test bundle_id raises NotImplementedError (covers line 22).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface port-channel1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("port-channel1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.bundle_id - - -def test_bundle_member_interfaces_not_implemented() -> None: - """Test bundle_member_interfaces raises NotImplementedError (covers line 26).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = list(interface_view.bundle_member_interfaces) - - -def test_bundle_name_not_implemented() -> None: - """Test bundle_name raises NotImplementedError (covers line 30).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.bundle_name - - -def test_description() -> None: - """Test description returns description text (covers lines 34-36).""" - config = get_hconfig(Platform.CISCO_NXOS) - interface = config.add_child("interface Ethernet1/1") - interface.add_child("description Uplink to Core") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - assert interface_view.description == "Uplink to Core" - - -def test_description_empty() -> None: - """Test description returns empty string (covers line 36).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - assert not interface_view.description - - -def test_duplex_not_implemented() -> None: - """Test duplex raises NotImplementedError (covers line 40).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.duplex - - -def test_enabled_not_implemented() -> None: - """Test enabled raises NotImplementedError (covers line 44).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.enabled - - -def test_has_nac_not_implemented() -> None: - """Test has_nac raises NotImplementedError (covers line 49).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.has_nac - - -def test_ipv4_interface_none() -> None: - """Test ipv4_interface returns None (covers line 53).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - assert interface_view.ipv4_interface is None - - -def test_ipv4_interfaces() -> None: - """Test ipv4_interfaces returns IP addresses (covers lines 57-62).""" - config = get_hconfig(Platform.CISCO_NXOS) - interface = config.add_child("interface Ethernet1/1") - interface.add_child("ip address 10.1.1.1 255.255.255.0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - ips = list(interface_view.ipv4_interfaces) - assert len(ips) == 1 - assert ips[0] == IPv4Interface("10.1.1.1/24") - - -def test_ipv4_interfaces_invalid() -> None: - """Test ipv4_interfaces skips invalid addresses (covers line 62).""" - config = get_hconfig(Platform.CISCO_NXOS) - interface = config.add_child("interface Ethernet1/1") - interface.add_child("ip address dhcp") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - ips = list(interface_view.ipv4_interfaces) - assert len(ips) == 0 - - -def test_is_bundle_true() -> None: - """Test is_bundle returns True (covers line 66).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface port-channel10") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("port-channel10") - assert interface_view is not None - assert interface_view.is_bundle is True - - -def test_is_bundle_false() -> None: - """Test is_bundle returns False (covers line 66).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - assert interface_view.is_bundle is False - - -def test_is_loopback_true() -> None: - """Test is_loopback returns True (covers line 70).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface loopback0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("loopback0") - assert interface_view is not None - assert interface_view.is_loopback is True - - -def test_is_loopback_false() -> None: - """Test is_loopback returns False (covers line 70).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - assert interface_view.is_loopback is False - - -def test_is_subinterface_true() -> None: - """Test is_subinterface returns True (covers line 74).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1.100") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1.100") - assert interface_view is not None - assert interface_view.is_subinterface is True - - -def test_is_subinterface_false() -> None: - """Test is_subinterface returns False (covers line 74).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - assert interface_view.is_subinterface is False - - -def test_is_svi_true() -> None: - """Test is_svi returns True (covers line 78).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface vlan100") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("vlan100") - assert interface_view is not None - assert interface_view.is_svi is True - - -def test_is_svi_false() -> None: - """Test is_svi returns False (covers line 78).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - assert interface_view.is_svi is False - - -def test_module_number() -> None: - """Test module_number returns module (covers lines 82-85).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet2/15") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet2/15") - assert interface_view is not None - assert interface_view.module_number == 2 - - -def test_module_number_none() -> None: - """Test module_number returns None (covers lines 84-85).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface loopback0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("loopback0") - assert interface_view is not None - assert interface_view.module_number is None - - -def test_nac_control_direction_in_not_implemented() -> None: - """Test nac_control_direction_in raises NotImplementedError (covers line 90).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_control_direction_in - - -def test_nac_host_mode_not_implemented() -> None: - """Test nac_host_mode raises NotImplementedError (covers line 95).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_host_mode - - -def test_nac_mab_first_not_implemented() -> None: - """Test nac_mab_first raises NotImplementedError (covers line 100).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_mab_first - - -def test_nac_max_dot1x_clients_not_implemented() -> None: - """Test nac_max_dot1x_clients raises NotImplementedError (covers line 105).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_max_dot1x_clients - - -def test_nac_max_mab_clients_not_implemented() -> None: - """Test nac_max_mab_clients raises NotImplementedError (covers line 110).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_max_mab_clients - - -def test_name() -> None: - """Test name returns interface name (covers line 114).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/10") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/10") - assert interface_view is not None - assert interface_view.name == "Ethernet1/10" - - -def test_native_vlan_not_implemented() -> None: - """Test native_vlan raises NotImplementedError (covers line 118).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.native_vlan - - -def test_number() -> None: - """Test number returns interface number (covers line 122).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet3/25") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet3/25") - assert interface_view is not None - assert interface_view.number == "3/25" - - -def test_parent_name() -> None: - """Test parent_name returns parent interface (covers lines 126-128).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1.200") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1.200") - assert interface_view is not None - assert interface_view.parent_name == "Ethernet1/1" - - -def test_parent_name_none() -> None: - """Test parent_name returns None (covers line 128).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - assert interface_view.parent_name is None - - -def test_poe_not_implemented() -> None: - """Test poe raises NotImplementedError (covers line 132).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.poe - - -def test_port_number() -> None: - """Test port_number returns port number (covers line 136).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet2/48") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet2/48") - assert interface_view is not None - assert interface_view.port_number == 48 - - -def test_port_number_with_subinterface() -> None: - """Test port_number with subinterface (covers line 136).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/5.300") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/5.300") - assert interface_view is not None - assert interface_view.port_number == 5 - - -def test_speed_not_implemented() -> None: - """Test speed raises NotImplementedError (covers line 140).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.speed - - -def test_subinterface_number() -> None: - """Test subinterface_number returns number (covers line 144).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1.999") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1.999") - assert interface_view is not None - assert interface_view.subinterface_number == 999 - - -def test_subinterface_number_none() -> None: - """Test subinterface_number returns None (covers line 144).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - assert interface_view.subinterface_number is None - - -def test_tagged_all_not_implemented() -> None: - """Test tagged_all raises NotImplementedError (covers line 148).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.tagged_all - - -def test_tagged_vlans_not_implemented() -> None: - """Test tagged_vlans raises NotImplementedError (covers line 152).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.tagged_vlans - - -def test_vrf_not_implemented() -> None: - """Test vrf raises NotImplementedError (covers line 156).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Ethernet1/1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.vrf - - -def test_bundle_prefix() -> None: - """Test _bundle_prefix returns 'port-channel' (covers line 160).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface port-channel1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("port-channel1") - assert interface_view is not None - assert interface_view.is_bundle - - -def test_dot1q_mode_from_vlans_not_implemented() -> None: - """Test dot1q_mode_from_vlans raises NotImplementedError (covers line 171).""" - config = get_hconfig(Platform.CISCO_NXOS) - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - view.dot1q_mode_from_vlans(untagged_vlan=10) - - -def test_hostname() -> None: - """Test hostname returns hostname (covers lines 175-177).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("hostname NEXUS-CORE-01") - - view = get_hconfig_view(config) - assert view.hostname == "nexus-core-01" - - -def test_hostname_none() -> None: - """Test hostname returns None (covers line 177).""" - config = get_hconfig(Platform.CISCO_NXOS) - - view = get_hconfig_view(config) - assert view.hostname is None - - -def test_interface_names_mentioned_not_implemented() -> None: - """Test interface_names_mentioned raises NotImplementedError (covers line 182).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = view.interface_names_mentioned - - -def test_interface_views() -> None: - """Test interface_views yields interface views (covers lines 186-187).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - config.add_child("interface Ethernet1/2") - config.add_child("interface loopback0") - - view = get_hconfig_view(config) - interface_views = list(view.interface_views) - - assert len(interface_views) == 3 - assert any(iv.name == "Ethernet1/1" for iv in interface_views) - assert any(iv.name == "Ethernet1/2" for iv in interface_views) - assert any(iv.name == "loopback0" for iv in interface_views) - - -def test_interfaces() -> None: - """Test interfaces returns interface children (covers line 191).""" - config = get_hconfig(Platform.CISCO_NXOS) - config.add_child("interface Ethernet1/1") - config.add_child("interface Ethernet1/2") - config.add_child("interface port-channel1") - - view = get_hconfig_view(config) - interfaces = list(view.interfaces) - - assert len(interfaces) == 3 - - -def test_ipv4_default_gw_not_implemented() -> None: - """Test ipv4_default_gw raises NotImplementedError (covers line 195).""" - config = get_hconfig(Platform.CISCO_NXOS) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = view.ipv4_default_gw - - -def test_location_not_implemented() -> None: - """Test location raises NotImplementedError (covers line 199).""" - config = get_hconfig(Platform.CISCO_NXOS) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = view.location - - -def test_stack_members_not_implemented() -> None: - """Test stack_members raises NotImplementedError (covers line 203).""" - config = get_hconfig(Platform.CISCO_NXOS) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = list(view.stack_members) - - -def test_vlans_not_implemented() -> None: - """Test vlans raises NotImplementedError (covers line 207).""" - config = get_hconfig(Platform.CISCO_NXOS) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = list(view.vlans) diff --git a/tests/config_view/test_view_cisco_xr.py b/tests/config_view/test_view_cisco_xr.py deleted file mode 100644 index d897f426..00000000 --- a/tests/config_view/test_view_cisco_xr.py +++ /dev/null @@ -1,593 +0,0 @@ -"""Tests for Cisco IOS-XR view.py ConfigViewInterfaceCiscoIOSXR and HConfigViewCiscoIOSXR classes.""" - -from ipaddress import IPv4Interface - -import pytest - -from hier_config import Platform, get_hconfig, get_hconfig_view - - -def test_bundle_id_not_implemented() -> None: - """Test bundle_id raises NotImplementedError (covers line 26).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface Bundle-Ether1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Bundle-Ether1") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.bundle_id - - -def test_bundle_member_interfaces_not_implemented() -> None: - """Test bundle_member_interfaces raises NotImplementedError (covers line 30).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = list(interface_view.bundle_member_interfaces) - - -def test_bundle_name_with_bundle_id() -> None: - """Test bundle_name returns formatted name (covers lines 34-36).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface Bundle-Ether1") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Bundle-Ether1") - assert interface_view is not None - with pytest.raises(NotImplementedError): - _ = interface_view.bundle_name - - -def test_bundle_name_none() -> None: - """Test bundle_name returns None when not bundle (covers line 36).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - with pytest.raises(NotImplementedError): - _ = interface_view.bundle_name - - -def test_description() -> None: - """Test description returns description text (covers lines 40-42).""" - config = get_hconfig(Platform.CISCO_XR) - interface = config.add_child("interface GigabitEthernet0/0/0/0") - interface.add_child("description Uplink to Core") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - assert interface_view.description == "Uplink to Core" - - -def test_description_empty() -> None: - """Test description returns empty string (covers line 42).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - assert not interface_view.description - - -def test_duplex_not_implemented() -> None: - """Test duplex raises NotImplementedError (covers line 46).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.duplex - - -def test_enabled_not_implemented() -> None: - """Test enabled raises NotImplementedError (covers line 50).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.enabled - - -def test_has_nac_not_implemented() -> None: - """Test has_nac raises NotImplementedError (covers line 55).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.has_nac - - -def test_ipv4_interface_none() -> None: - """Test ipv4_interface returns None (covers line 59).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - assert interface_view.ipv4_interface is None - - -def test_ipv4_interfaces() -> None: - """Test ipv4_interfaces returns IP addresses (covers lines 63-68).""" - config = get_hconfig(Platform.CISCO_XR) - interface = config.add_child("interface GigabitEthernet0/0/0/0") - interface.add_child("ipv4 address 192.168.1.1 255.255.255.0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - ips = list(interface_view.ipv4_interfaces) - assert len(ips) == 1 - assert ips[0] == IPv4Interface("192.168.1.1/24") - - -def test_ipv4_interfaces_invalid() -> None: - """Test ipv4_interfaces skips invalid addresses (covers line 68).""" - config = get_hconfig(Platform.CISCO_XR) - interface = config.add_child("interface GigabitEthernet0/0/0/0") - interface.add_child("ipv4 address dhcp") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - ips = list(interface_view.ipv4_interfaces) - assert len(ips) == 0 - - -def test_is_bundle_false() -> None: - """Test is_bundle returns False (covers line 72).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - assert interface_view.is_bundle is False - - -def test_is_loopback_true() -> None: - """Test is_loopback returns True (covers line 76).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface Loopback0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Loopback0") - assert interface_view is not None - assert interface_view.is_loopback is True - - -def test_is_loopback_false() -> None: - """Test is_loopback returns False (covers line 76).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - assert interface_view.is_loopback is False - - -def test_is_subinterface_true() -> None: - """Test is_subinterface returns True (covers line 80).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0.100") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0.100") - assert interface_view is not None - assert interface_view.is_subinterface is True - - -def test_is_subinterface_false() -> None: - """Test is_subinterface returns False (covers line 80).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - assert interface_view.is_subinterface is False - - -def test_is_svi_true() -> None: - """Test is_svi returns True (covers line 84).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface vlan100") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("vlan100") - assert interface_view is not None - assert interface_view.is_svi is True - - -def test_is_svi_false() -> None: - """Test is_svi returns False (covers line 84).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - assert interface_view.is_svi is False - - -def test_module_number() -> None: - """Test module_number returns module (covers lines 88-91).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/2/0/5") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/2/0/5") - assert interface_view is not None - assert interface_view.module_number == 0 - - -def test_module_number_none() -> None: - """Test module_number returns None (covers lines 90-91).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface Loopback0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Loopback0") - assert interface_view is not None - assert interface_view.module_number is None - - -def test_nac_control_direction_in_not_implemented() -> None: - """Test nac_control_direction_in raises NotImplementedError (covers line 96).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_control_direction_in - - -def test_nac_host_mode_not_implemented() -> None: - """Test nac_host_mode raises NotImplementedError (covers line 101).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_host_mode - - -def test_nac_mab_first_not_implemented() -> None: - """Test nac_mab_first raises NotImplementedError (covers line 106).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_mab_first - - -def test_nac_max_dot1x_clients_not_implemented() -> None: - """Test nac_max_dot1x_clients raises NotImplementedError (covers line 111).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_max_dot1x_clients - - -def test_nac_max_mab_clients_not_implemented() -> None: - """Test nac_max_mab_clients raises NotImplementedError (covers line 116).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.nac_max_mab_clients - - -def test_name() -> None: - """Test name returns interface name (covers line 120).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/10") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/10") - assert interface_view is not None - assert interface_view.name == "GigabitEthernet0/0/0/10" - - -def test_native_vlan_not_implemented() -> None: - """Test native_vlan raises NotImplementedError (covers line 124).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.native_vlan - - -def test_number() -> None: - """Test number returns interface number (covers line 128).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/1/2/15") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/1/2/15") - assert interface_view is not None - assert interface_view.number == "0/1/2/15" - - -def test_parent_name() -> None: - """Test parent_name returns parent interface (covers lines 132-134).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0.100") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0.100") - assert interface_view is not None - assert interface_view.parent_name == "GigabitEthernet0/0/0/0" - - -def test_parent_name_none() -> None: - """Test parent_name returns None (covers line 134).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - assert interface_view.parent_name is None - - -def test_poe_not_implemented() -> None: - """Test poe raises NotImplementedError (covers line 138).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.poe - - -def test_port_number() -> None: - """Test port_number returns port number (covers line 142).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/2/1/25") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/2/1/25") - assert interface_view is not None - assert interface_view.port_number == 25 - - -def test_port_number_with_subinterface() -> None: - """Test port_number with subinterface (covers line 142).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/5.200") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/5.200") - assert interface_view is not None - assert interface_view.port_number == 5 - - -def test_speed_not_implemented() -> None: - """Test speed raises NotImplementedError (covers line 146).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.speed - - -def test_subinterface_number() -> None: - """Test subinterface_number returns number (covers line 150).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0.500") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0.500") - assert interface_view is not None - assert interface_view.subinterface_number == 500 - - -def test_subinterface_number_none() -> None: - """Test subinterface_number returns None (covers line 150).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - assert interface_view.subinterface_number is None - - -def test_tagged_all_not_implemented() -> None: - """Test tagged_all raises NotImplementedError (covers line 154).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.tagged_all - - -def test_tagged_vlans_not_implemented() -> None: - """Test tagged_vlans raises NotImplementedError (covers line 158).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.tagged_vlans - - -def test_vrf_not_implemented() -> None: - """Test vrf raises NotImplementedError (covers line 162).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("GigabitEthernet0/0/0/0") - assert interface_view is not None - - with pytest.raises(NotImplementedError): - _ = interface_view.vrf - - -def test_dot1q_mode_from_vlans_not_implemented() -> None: - """Test dot1q_mode_from_vlans raises NotImplementedError (covers line 173).""" - config = get_hconfig(Platform.CISCO_XR) - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - view.dot1q_mode_from_vlans(untagged_vlan=10) - - -def test_hostname() -> None: - """Test hostname returns hostname (covers lines 177-179).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("hostname CORE-ROUTER-01") - - view = get_hconfig_view(config) - assert view.hostname == "core-router-01" - - -def test_hostname_none() -> None: - """Test hostname returns None (covers line 179).""" - config = get_hconfig(Platform.CISCO_XR) - - view = get_hconfig_view(config) - assert view.hostname is None - - -def test_interface_names_mentioned_not_implemented() -> None: - """Test interface_names_mentioned raises NotImplementedError (covers line 183).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = view.interface_names_mentioned - - -def test_interface_views() -> None: - """Test interface_views yields interface views (covers lines 187-188).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - config.add_child("interface GigabitEthernet0/0/0/1") - - view = get_hconfig_view(config) - interface_views = list(view.interface_views) - - assert len(interface_views) == 2 - assert any(iv.name == "GigabitEthernet0/0/0/0" for iv in interface_views) - assert any(iv.name == "GigabitEthernet0/0/0/1" for iv in interface_views) - - -def test_interfaces() -> None: - """Test interfaces returns interface children (covers line 192).""" - config = get_hconfig(Platform.CISCO_XR) - config.add_child("interface GigabitEthernet0/0/0/0") - config.add_child("interface GigabitEthernet0/0/0/1") - config.add_child("interface Loopback0") - - view = get_hconfig_view(config) - interfaces = list(view.interfaces) - - assert len(interfaces) == 3 - - -def test_ipv4_default_gw_not_implemented() -> None: - """Test ipv4_default_gw raises NotImplementedError (covers line 196).""" - config = get_hconfig(Platform.CISCO_XR) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = view.ipv4_default_gw - - -def test_location_not_implemented() -> None: - """Test location raises NotImplementedError (covers line 200).""" - config = get_hconfig(Platform.CISCO_XR) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = view.location - - -def test_stack_members_not_implemented() -> None: - """Test stack_members raises NotImplementedError (covers line 204).""" - config = get_hconfig(Platform.CISCO_XR) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = list(view.stack_members) - - -def test_vlans_not_implemented() -> None: - """Test vlans raises NotImplementedError (covers line 208).""" - config = get_hconfig(Platform.CISCO_XR) - - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - _ = list(view.vlans) diff --git a/tests/conftest.py b/tests/conftest.py index 27ddc70c..b71ad1e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ from pathlib import Path -from typing import Any import pytest import yaml @@ -80,45 +79,6 @@ def tags_file_path() -> str: return "./tests/fixtures/tag_rules_ios.yml" -@pytest.fixture(scope="module") -def v2_options() -> dict[str, Any]: - return { - "negation": "no", - "sectional_overwrite": [{"lineage": [{"startswith": "template"}]}], - "sectional_overwrite_no_negate": [{"lineage": [{"startswith": "as-path-set"}]}], - "ordering": [{"lineage": [{"startswith": "ntp"}], "order": 700}], - "indent_adjust": [ - {"start_expression": "^\\s*template", "end_expression": "^\\s*end-template"} - ], - "parent_allows_duplicate_child": [ - {"lineage": [{"startswith": "route-policy"}]} - ], - "sectional_exiting": [ - {"lineage": [{"startswith": "router bgp"}], "exit_text": "exit"} - ], - "full_text_sub": [{"search": "banner motd # replace me #", "replace": ""}], - "per_line_sub": [{"search": "^!.*Generated.*$", "replace": ""}], - "idempotent_commands_blacklist": [ - { - "lineage": [ - {"startswith": "interface"}, - {"re_search": "ip address.*secondary"}, - ] - } - ], - "idempotent_commands": [{"lineage": [{"startswith": "interface"}]}], - "negation_negate_with": [ - { - "lineage": [ - {"startswith": "interface Ethernet"}, - {"startswith": "spanning-tree port type"}, - ], - "use": "no spanning-tree port type", - } - ], - } - - def _fixture_file_read(filename: str) -> str: return str( Path(__file__) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/circular/conftest.py b/tests/integration/conftest.py similarity index 100% rename from tests/circular/conftest.py rename to tests/integration/conftest.py diff --git a/tests/circular/fixtures/aruba_aoscx_generated.conf b/tests/integration/fixtures/aruba_aoscx_generated.conf similarity index 100% rename from tests/circular/fixtures/aruba_aoscx_generated.conf rename to tests/integration/fixtures/aruba_aoscx_generated.conf diff --git a/tests/circular/fixtures/aruba_aoscx_remediation.conf b/tests/integration/fixtures/aruba_aoscx_remediation.conf similarity index 100% rename from tests/circular/fixtures/aruba_aoscx_remediation.conf rename to tests/integration/fixtures/aruba_aoscx_remediation.conf diff --git a/tests/circular/fixtures/aruba_aoscx_rollback.conf b/tests/integration/fixtures/aruba_aoscx_rollback.conf similarity index 100% rename from tests/circular/fixtures/aruba_aoscx_rollback.conf rename to tests/integration/fixtures/aruba_aoscx_rollback.conf diff --git a/tests/circular/fixtures/aruba_aoscx_running.conf b/tests/integration/fixtures/aruba_aoscx_running.conf similarity index 100% rename from tests/circular/fixtures/aruba_aoscx_running.conf rename to tests/integration/fixtures/aruba_aoscx_running.conf diff --git a/tests/circular/fixtures/comware5_generated.conf b/tests/integration/fixtures/comware5_generated.conf similarity index 100% rename from tests/circular/fixtures/comware5_generated.conf rename to tests/integration/fixtures/comware5_generated.conf diff --git a/tests/circular/fixtures/comware5_remediation.conf b/tests/integration/fixtures/comware5_remediation.conf similarity index 100% rename from tests/circular/fixtures/comware5_remediation.conf rename to tests/integration/fixtures/comware5_remediation.conf diff --git a/tests/circular/fixtures/comware5_rollback.conf b/tests/integration/fixtures/comware5_rollback.conf similarity index 100% rename from tests/circular/fixtures/comware5_rollback.conf rename to tests/integration/fixtures/comware5_rollback.conf diff --git a/tests/circular/fixtures/comware5_running.conf b/tests/integration/fixtures/comware5_running.conf similarity index 100% rename from tests/circular/fixtures/comware5_running.conf rename to tests/integration/fixtures/comware5_running.conf diff --git a/tests/circular/fixtures/eos_generated.conf b/tests/integration/fixtures/eos_generated.conf similarity index 100% rename from tests/circular/fixtures/eos_generated.conf rename to tests/integration/fixtures/eos_generated.conf diff --git a/tests/circular/fixtures/eos_remediation.conf b/tests/integration/fixtures/eos_remediation.conf similarity index 100% rename from tests/circular/fixtures/eos_remediation.conf rename to tests/integration/fixtures/eos_remediation.conf diff --git a/tests/circular/fixtures/eos_rollback.conf b/tests/integration/fixtures/eos_rollback.conf similarity index 100% rename from tests/circular/fixtures/eos_rollback.conf rename to tests/integration/fixtures/eos_rollback.conf diff --git a/tests/circular/fixtures/eos_running.conf b/tests/integration/fixtures/eos_running.conf similarity index 100% rename from tests/circular/fixtures/eos_running.conf rename to tests/integration/fixtures/eos_running.conf diff --git a/tests/circular/fixtures/fortios_generated.conf b/tests/integration/fixtures/fortios_generated.conf similarity index 100% rename from tests/circular/fixtures/fortios_generated.conf rename to tests/integration/fixtures/fortios_generated.conf diff --git a/tests/circular/fixtures/fortios_remediation.conf b/tests/integration/fixtures/fortios_remediation.conf similarity index 100% rename from tests/circular/fixtures/fortios_remediation.conf rename to tests/integration/fixtures/fortios_remediation.conf diff --git a/tests/circular/fixtures/fortios_rollback.conf b/tests/integration/fixtures/fortios_rollback.conf similarity index 100% rename from tests/circular/fixtures/fortios_rollback.conf rename to tests/integration/fixtures/fortios_rollback.conf diff --git a/tests/circular/fixtures/fortios_running.conf b/tests/integration/fixtures/fortios_running.conf similarity index 100% rename from tests/circular/fixtures/fortios_running.conf rename to tests/integration/fixtures/fortios_running.conf diff --git a/tests/circular/fixtures/ios_generated.conf b/tests/integration/fixtures/ios_generated.conf similarity index 100% rename from tests/circular/fixtures/ios_generated.conf rename to tests/integration/fixtures/ios_generated.conf diff --git a/tests/circular/fixtures/ios_remediation.conf b/tests/integration/fixtures/ios_remediation.conf similarity index 100% rename from tests/circular/fixtures/ios_remediation.conf rename to tests/integration/fixtures/ios_remediation.conf diff --git a/tests/circular/fixtures/ios_rollback.conf b/tests/integration/fixtures/ios_rollback.conf similarity index 100% rename from tests/circular/fixtures/ios_rollback.conf rename to tests/integration/fixtures/ios_rollback.conf diff --git a/tests/circular/fixtures/ios_running.conf b/tests/integration/fixtures/ios_running.conf similarity index 100% rename from tests/circular/fixtures/ios_running.conf rename to tests/integration/fixtures/ios_running.conf diff --git a/tests/circular/fixtures/iosxr_generated.conf b/tests/integration/fixtures/iosxr_generated.conf similarity index 100% rename from tests/circular/fixtures/iosxr_generated.conf rename to tests/integration/fixtures/iosxr_generated.conf diff --git a/tests/circular/fixtures/iosxr_remediation.conf b/tests/integration/fixtures/iosxr_remediation.conf similarity index 100% rename from tests/circular/fixtures/iosxr_remediation.conf rename to tests/integration/fixtures/iosxr_remediation.conf diff --git a/tests/circular/fixtures/iosxr_rollback.conf b/tests/integration/fixtures/iosxr_rollback.conf similarity index 100% rename from tests/circular/fixtures/iosxr_rollback.conf rename to tests/integration/fixtures/iosxr_rollback.conf diff --git a/tests/circular/fixtures/iosxr_running.conf b/tests/integration/fixtures/iosxr_running.conf similarity index 100% rename from tests/circular/fixtures/iosxr_running.conf rename to tests/integration/fixtures/iosxr_running.conf diff --git a/tests/circular/fixtures/junos_generated.conf b/tests/integration/fixtures/junos_generated.conf similarity index 100% rename from tests/circular/fixtures/junos_generated.conf rename to tests/integration/fixtures/junos_generated.conf diff --git a/tests/circular/fixtures/junos_remediation.conf b/tests/integration/fixtures/junos_remediation.conf similarity index 100% rename from tests/circular/fixtures/junos_remediation.conf rename to tests/integration/fixtures/junos_remediation.conf diff --git a/tests/circular/fixtures/junos_rollback.conf b/tests/integration/fixtures/junos_rollback.conf similarity index 100% rename from tests/circular/fixtures/junos_rollback.conf rename to tests/integration/fixtures/junos_rollback.conf diff --git a/tests/circular/fixtures/junos_running.conf b/tests/integration/fixtures/junos_running.conf similarity index 100% rename from tests/circular/fixtures/junos_running.conf rename to tests/integration/fixtures/junos_running.conf diff --git a/tests/circular/fixtures/nxos_generated.conf b/tests/integration/fixtures/nxos_generated.conf similarity index 100% rename from tests/circular/fixtures/nxos_generated.conf rename to tests/integration/fixtures/nxos_generated.conf diff --git a/tests/circular/fixtures/nxos_remediation.conf b/tests/integration/fixtures/nxos_remediation.conf similarity index 100% rename from tests/circular/fixtures/nxos_remediation.conf rename to tests/integration/fixtures/nxos_remediation.conf diff --git a/tests/circular/fixtures/nxos_rollback.conf b/tests/integration/fixtures/nxos_rollback.conf similarity index 100% rename from tests/circular/fixtures/nxos_rollback.conf rename to tests/integration/fixtures/nxos_rollback.conf diff --git a/tests/circular/fixtures/nxos_running.conf b/tests/integration/fixtures/nxos_running.conf similarity index 100% rename from tests/circular/fixtures/nxos_running.conf rename to tests/integration/fixtures/nxos_running.conf diff --git a/tests/circular/fixtures/procurve_generated.conf b/tests/integration/fixtures/procurve_generated.conf similarity index 100% rename from tests/circular/fixtures/procurve_generated.conf rename to tests/integration/fixtures/procurve_generated.conf diff --git a/tests/circular/fixtures/procurve_remediation.conf b/tests/integration/fixtures/procurve_remediation.conf similarity index 100% rename from tests/circular/fixtures/procurve_remediation.conf rename to tests/integration/fixtures/procurve_remediation.conf diff --git a/tests/circular/fixtures/procurve_rollback.conf b/tests/integration/fixtures/procurve_rollback.conf similarity index 100% rename from tests/circular/fixtures/procurve_rollback.conf rename to tests/integration/fixtures/procurve_rollback.conf diff --git a/tests/circular/fixtures/procurve_running.conf b/tests/integration/fixtures/procurve_running.conf similarity index 100% rename from tests/circular/fixtures/procurve_running.conf rename to tests/integration/fixtures/procurve_running.conf diff --git a/tests/circular/fixtures/vyos_generated.conf b/tests/integration/fixtures/vyos_generated.conf similarity index 100% rename from tests/circular/fixtures/vyos_generated.conf rename to tests/integration/fixtures/vyos_generated.conf diff --git a/tests/circular/fixtures/vyos_remediation.conf b/tests/integration/fixtures/vyos_remediation.conf similarity index 100% rename from tests/circular/fixtures/vyos_remediation.conf rename to tests/integration/fixtures/vyos_remediation.conf diff --git a/tests/circular/fixtures/vyos_rollback.conf b/tests/integration/fixtures/vyos_rollback.conf similarity index 100% rename from tests/circular/fixtures/vyos_rollback.conf rename to tests/integration/fixtures/vyos_rollback.conf diff --git a/tests/circular/fixtures/vyos_running.conf b/tests/integration/fixtures/vyos_running.conf similarity index 100% rename from tests/circular/fixtures/vyos_running.conf rename to tests/integration/fixtures/vyos_running.conf diff --git a/tests/test_driver_aruba_aoscx.py b/tests/integration/test_aruba_aoscx.py similarity index 88% rename from tests/test_driver_aruba_aoscx.py rename to tests/integration/test_aruba_aoscx.py index 355f8ec0..0f0a8283 100644 --- a/tests/test_driver_aruba_aoscx.py +++ b/tests/integration/test_aruba_aoscx.py @@ -1,16 +1,12 @@ -from hier_config import WorkflowRemediation, get_hconfig +from hier_config import HConfig from hier_config.models import Platform def _remediation_text(running: str, intended: str) -> str: - workflow = WorkflowRemediation( - get_hconfig(Platform.ARUBA_AOSCX, running), - get_hconfig(Platform.ARUBA_AOSCX, intended), - ) - return "\n".join( - line.cisco_style_text() - for line in workflow.remediation_config.all_children_sorted() + remediation = HConfig.from_text(Platform.ARUBA_AOSCX, running).remediation( + HConfig.from_text(Platform.ARUBA_AOSCX, intended), ) + return "\n".join(line.indented_text() for line in remediation.all_children_sorted()) def test_interface_vlan_trunk_allowed_is_additive() -> None: @@ -73,7 +69,7 @@ def test_interface_vlan_trunk_allowed_emits_minimal_delta() -> None: def test_aruba_aoscx_splits_top_level_vlan_lists() -> None: - config = get_hconfig( + config = HConfig.from_text( Platform.ARUBA_AOSCX, """ vlan 1,10 @@ -81,7 +77,7 @@ def test_aruba_aoscx_splits_top_level_vlan_lists() -> None: """, ) - assert tuple(line.cisco_style_text() for line in config.all_children_sorted()) == ( + assert tuple(line.indented_text() for line in config.all_children_sorted()) == ( "vlan 1", "vlan 10", "vlan 100", @@ -106,7 +102,7 @@ def test_aruba_aoscx_collapsed_vlan_header_with_children_is_left_untouched() -> # A collapsed range that carries configuration is left as-is rather than # fanning a non-unique `name` onto every expanded VLAN (which the device # rejects). Real collapsed ranges are unnamed/childless. - config = get_hconfig( + config = HConfig.from_text( Platform.ARUBA_AOSCX, """ vlan 10-12 @@ -114,7 +110,7 @@ def test_aruba_aoscx_collapsed_vlan_header_with_children_is_left_untouched() -> """, ) - assert tuple(line.cisco_style_text() for line in config.all_children_sorted()) == ( + assert tuple(line.indented_text() for line in config.all_children_sorted()) == ( "vlan 10-12", " name USERS", ) @@ -138,9 +134,9 @@ def test_aruba_aoscx_trunk_overlapping_spec_is_not_destructive() -> None: def test_aruba_aoscx_leaves_unparseable_vlan_range_untouched() -> None: - config = get_hconfig(Platform.ARUBA_AOSCX, "vlan 10-\n") + config = HConfig.from_text(Platform.ARUBA_AOSCX, "vlan 10-\n") - assert tuple(line.cisco_style_text() for line in config.all_children_sorted()) == ( + assert tuple(line.indented_text() for line in config.all_children_sorted()) == ( "vlan 10-", ) @@ -148,7 +144,7 @@ def test_aruba_aoscx_leaves_unparseable_vlan_range_untouched() -> None: def test_aruba_aoscx_leaves_empty_trunk_spec_untouched() -> None: # A trunk line whose spec expands to nothing must not be deleted, otherwise # the interface looks like it has no allowed VLANs at all. - config = get_hconfig( + config = HConfig.from_text( Platform.ARUBA_AOSCX, """ interface 1/1/1 @@ -189,7 +185,7 @@ def test_aruba_aoscx_top_level_vlan_lists_remove_only_extra_vlans() -> None: def test_aruba_aoscx_strips_terminal_prompt_lines() -> None: - config = get_hconfig( + config = HConfig.from_text( Platform.ARUBA_AOSCX, """ cx-switch# show run @@ -201,7 +197,7 @@ def test_aruba_aoscx_strips_terminal_prompt_lines() -> None: """, ) - assert tuple(line.cisco_style_text() for line in config.all_children_sorted()) == ( + assert tuple(line.indented_text() for line in config.all_children_sorted()) == ( "hostname cx-switch", "interface 1/1/1", " description #P3# Test", diff --git a/tests/circular/test_config_workflows.py b/tests/integration/test_circular_workflows.py similarity index 86% rename from tests/circular/test_config_workflows.py rename to tests/integration/test_circular_workflows.py index a9e5a198..78171268 100644 --- a/tests/circular/test_config_workflows.py +++ b/tests/integration/test_circular_workflows.py @@ -14,7 +14,7 @@ import pytest -from hier_config import WorkflowRemediation, get_hconfig +from hier_config import HConfig, WorkflowRemediation from hier_config.models import Platform @@ -67,22 +67,22 @@ def test_circular_workflow( # pylint: disable=too-many-locals # ruff:ignore[to ) # Step 1: Load running config and assert it matches the file - running_config = get_hconfig(platform, running_config_text) + running_config = HConfig.from_text(platform, running_config_text) assert running_config is not None assert running_config.children loaded_running_text = "\n".join( - line.cisco_style_text() for line in running_config.all_children_sorted() + line.indented_text() for line in running_config.all_children_sorted() ) assert loaded_running_text.strip() == running_config_text.strip(), ( "Loaded running config does not match the file" ) # Step 2: Load generated config and assert it matches the file - generated_config = get_hconfig(platform, generated_config_text) + generated_config = HConfig.from_text(platform, generated_config_text) assert generated_config is not None assert generated_config.children loaded_generated_text = "\n".join( - line.cisco_style_text() for line in generated_config.all_children_sorted() + line.indented_text() for line in generated_config.all_children_sorted() ) assert loaded_generated_text.strip() == generated_config_text.strip(), ( "Loaded generated config does not match the file" @@ -95,7 +95,7 @@ def test_circular_workflow( # pylint: disable=too-many-locals # ruff:ignore[to remediation_config = workflow.remediation_config assert remediation_config is not None remediation_text = "\n".join( - line.cisco_style_text() for line in remediation_config.all_children_sorted() + line.indented_text() for line in remediation_config.all_children_sorted() ) assert remediation_text.strip() == expected_remediation_text.strip(), ( "Generated remediation config does not match expected" @@ -111,14 +111,14 @@ def test_circular_workflow( # pylint: disable=too-many-locals # ruff:ignore[to # remove deleted sections, so we verify that all generated lines are present (subset check) # rather than exact equality future_lines = { - line.cisco_style_text() + line.indented_text() for line in future_config.all_children_sorted() - if not line.cisco_style_text().strip().startswith(("no ", "delete ")) + if not line.indented_text().strip().startswith(("no ", "delete ")) } generated_lines = { - line.cisco_style_text() + line.indented_text() for line in generated_config.all_children_sorted() - if not line.cisco_style_text().strip().startswith(("no ", "delete ")) + if not line.indented_text().strip().startswith(("no ", "delete ")) } # Check that all generated lines are present in future (subset check) missing_lines = generated_lines - future_lines @@ -131,7 +131,7 @@ def test_circular_workflow( # pylint: disable=too-many-locals # ruff:ignore[to rollback_config = workflow.rollback_config assert rollback_config is not None rollback_text = "\n".join( - line.cisco_style_text() for line in rollback_config.all_children_sorted() + line.indented_text() for line in rollback_config.all_children_sorted() ) assert rollback_text.strip() == expected_rollback_text.strip(), ( "Generated rollback config does not match expected" @@ -147,14 +147,14 @@ def test_circular_workflow( # pylint: disable=too-many-locals # ruff:ignore[to # remove deleted sections, so we verify that all running lines are present (subset check) # rather than exact equality rollback_future_lines = { - line.cisco_style_text() + line.indented_text() for line in rollback_future_config.all_children_sorted() - if not line.cisco_style_text().strip().startswith(("no ", "delete ")) + if not line.indented_text().strip().startswith(("no ", "delete ")) } running_lines = { - line.cisco_style_text() + line.indented_text() for line in running_config.all_children_sorted() - if not line.cisco_style_text().strip().startswith(("no ", "delete ")) + if not line.indented_text().strip().startswith(("no ", "delete ")) } # Check that all running lines are present in rollback_future (subset check) missing_lines = running_lines - rollback_future_lines diff --git a/tests/test_driver_cisco_ios.py b/tests/integration/test_cisco_ios.py similarity index 54% rename from tests/test_driver_cisco_ios.py rename to tests/integration/test_cisco_ios.py index 44b316bc..12269f24 100644 --- a/tests/test_driver_cisco_ios.py +++ b/tests/integration/test_cisco_ios.py @@ -1,18 +1,17 @@ -from hier_config import get_hconfig_fast_load -from hier_config.constructors import get_hconfig +from hier_config import HConfig from hier_config.models import Platform def test_logging_console_emergencies_scenario_1() -> None: platform = Platform.CISCO_IOS - running_config = get_hconfig_fast_load(platform, ("no logging console",)) - generated_config = get_hconfig_fast_load(platform, ("logging console emergencies",)) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ("logging console emergencies",) + running_config = HConfig.from_lines(platform, ("no logging console",)) + generated_config = HConfig.from_lines(platform, ("logging console emergencies",)) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ("logging console emergencies",) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ("logging console emergencies",) - rollback = future_config.config_to_get_to(running_config) - assert rollback.dump_simple() == ("no logging console",) + assert future_config.to_lines() == ("logging console emergencies",) + rollback = future_config.remediation(running_config) + assert rollback.to_lines() == ("no logging console",) running_after_rollback = future_config.future(rollback) assert not tuple(running_config.unified_diff(running_after_rollback)) @@ -20,14 +19,14 @@ def test_logging_console_emergencies_scenario_1() -> None: def test_logging_console_emergencies_scenario_2() -> None: platform = Platform.CISCO_IOS - running_config = get_hconfig_fast_load(platform, ("logging console",)) - generated_config = get_hconfig_fast_load(platform, ("logging console emergencies",)) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ("logging console emergencies",) + running_config = HConfig.from_lines(platform, ("logging console",)) + generated_config = HConfig.from_lines(platform, ("logging console emergencies",)) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ("logging console emergencies",) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ("logging console emergencies",) - rollback = future_config.config_to_get_to(running_config) - assert rollback.dump_simple() == ("logging console",) + assert future_config.to_lines() == ("logging console emergencies",) + rollback = future_config.remediation(running_config) + assert rollback.to_lines() == ("logging console",) running_after_rollback = future_config.future(rollback) assert not tuple(running_config.unified_diff(running_after_rollback)) @@ -35,14 +34,14 @@ def test_logging_console_emergencies_scenario_2() -> None: def test_logging_console_emergencies_scenario_3() -> None: platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - generated_config = get_hconfig_fast_load(platform, ("logging console emergencies",)) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ("logging console emergencies",) + running_config = HConfig.from_text(platform) + generated_config = HConfig.from_lines(platform, ("logging console emergencies",)) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ("logging console emergencies",) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ("logging console emergencies",) - rollback = future_config.config_to_get_to(running_config) - assert rollback.dump_simple() == ("logging console debugging",) + assert future_config.to_lines() == ("logging console emergencies",) + rollback = future_config.remediation(running_config) + assert rollback.to_lines() == ("logging console debugging",) running_after_rollback = future_config.future(rollback) assert not tuple(running_config.unified_diff(running_after_rollback)) @@ -50,7 +49,7 @@ def test_logging_console_emergencies_scenario_3() -> None: def test_duplicate_child_router() -> None: platform = Platform.CISCO_IOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "router eigrp EIGRP_INSTANCE", @@ -72,7 +71,7 @@ def test_duplicate_child_router() -> None: " exit-address-family", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "router eigrp EIGRP_INSTANCE", @@ -94,8 +93,8 @@ def test_duplicate_child_router() -> None: " exit-address-family", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "router eigrp EIGRP_INSTANCE", " address-family ipv4 unicast autonomous-system 10000", " topology base", @@ -104,48 +103,9 @@ def test_duplicate_child_router() -> None: ) -def test_rm_ipv6_acl_sequence_numbers() -> None: - """Test post-load callback that removes IPv6 ACL sequence numbers (covers lines 21-23).""" - platform = Platform.CISCO_IOS - config_text = "ipv6 access-list TEST_IPV6_ACL\n sequence 10 permit tcp any any eq 443\n sequence 20 deny ipv6 any any" - config = get_hconfig(platform, config_text) - acl = config.get_child(equals="ipv6 access-list TEST_IPV6_ACL") - - assert acl is not None - assert acl.get_child(equals="permit tcp any any eq 443") is not None - assert acl.get_child(equals="deny ipv6 any any") is not None - assert acl.get_child(startswith="sequence") is None - - -def test_remove_ipv4_acl_remarks() -> None: - """Test post-load callback that removes IPv4 ACL remarks (covers line 30).""" - platform = Platform.CISCO_IOS - config_text = "ip access-list extended TEST_ACL\n remark Allow HTTPS traffic\n permit tcp any any eq 443\n remark Block all other traffic\n deny ip any any" - config = get_hconfig(platform, config_text) - acl = config.get_child(equals="ip access-list extended TEST_ACL") - - assert acl is not None - assert acl.get_child(equals="10 permit tcp any any eq 443") is not None - assert acl.get_child(equals="20 deny ip any any") is not None - assert acl.get_child(startswith="remark") is None - - -def test_add_acl_sequence_numbers() -> None: - """Test post-load callback that adds sequence numbers to IPv4 ACLs (covers lines 42-43).""" - platform = Platform.CISCO_IOS - config_text = "ip access-list extended TEST_ACL\n permit tcp any any eq 443\n permit tcp any any eq 80\n deny ip any any" - config = get_hconfig(platform, config_text) - acl = config.get_child(equals="ip access-list extended TEST_ACL") - - assert acl is not None - assert acl.get_child(equals="10 permit tcp any any eq 443") is not None - assert acl.get_child(equals="20 permit tcp any any eq 80") is not None - assert acl.get_child(equals="30 deny ip any any") is not None - - def test_vlan_id_list_split_on_load() -> None: """The post-load callback expands 'vlan 69,381' into one block per VLAN id.""" - config = get_hconfig(Platform.CISCO_IOS, "vlan 69,381\n") + config = HConfig.from_text(Platform.CISCO_IOS, "vlan 69,381\n") assert config.get_child(equals="vlan 69") is not None assert config.get_child(equals="vlan 381") is not None assert config.get_child(equals="vlan 69,381") is None @@ -153,13 +113,13 @@ def test_vlan_id_list_split_on_load() -> None: def test_vlan_id_range_split_on_load() -> None: """The post-load callback expands ranges into individual VLAN ids.""" - config = get_hconfig(Platform.CISCO_IOS, "vlan 10-12\n") + config = HConfig.from_text(Platform.CISCO_IOS, "vlan 10-12\n") assert [c.text for c in config.children] == ["vlan 10", "vlan 11", "vlan 12"] def test_single_vlan_not_split_on_load() -> None: """A single VLAN id is left untouched (no comma/range separator).""" - config = get_hconfig(Platform.CISCO_IOS, "vlan 44\n name servers\n") + config = HConfig.from_text(Platform.CISCO_IOS, "vlan 44\n name servers\n") vlan = config.get_child(equals="vlan 44") assert vlan is not None assert vlan.get_child(equals="name servers") is not None @@ -168,7 +128,7 @@ def test_single_vlan_not_split_on_load() -> None: def test_non_vlan_id_line_not_split_on_load() -> None: """Lines like 'vlan internal allocation policy ...' are not VLAN id lists.""" text = "vlan internal allocation policy ascending\n" - config = get_hconfig(Platform.CISCO_IOS, text) + config = HConfig.from_text(Platform.CISCO_IOS, text) assert ( config.get_child(equals="vlan internal allocation policy ascending") is not None ) @@ -176,27 +136,29 @@ def test_non_vlan_id_line_not_split_on_load() -> None: def test_vlan_id_list_with_overlap_splits_without_negation() -> None: """An overlapping collapsed list (vlan 10,10-12) de-duplicates and splits cleanly.""" - running_config = get_hconfig(Platform.CISCO_IOS, "vlan 10,10-12\n") + running_config = HConfig.from_text(Platform.CISCO_IOS, "vlan 10,10-12\n") assert [c.text for c in running_config.children] == [ "vlan 10", "vlan 11", "vlan 12", ] - generated_config = get_hconfig(Platform.CISCO_IOS, "vlan 10\nvlan 11\nvlan 12\n") - remediation = running_config.config_to_get_to(generated_config).dump_simple() + generated_config = HConfig.from_text( + Platform.CISCO_IOS, "vlan 10\nvlan 11\nvlan 12\n" + ) + remediation = running_config.remediation(generated_config).to_lines() assert not any(line.lstrip().startswith("no vlan") for line in remediation) def test_malformed_vlan_range_left_untouched() -> None: """A malformed range (vlan 1-2-3) is left collapsed rather than silently truncated.""" - config = get_hconfig(Platform.CISCO_IOS, "vlan 1-2-3\n") + config = HConfig.from_text(Platform.CISCO_IOS, "vlan 1-2-3\n") assert config.get_child(equals="vlan 1-2-3") is not None assert config.get_child(equals="vlan 1") is None def test_reversed_vlan_range_left_untouched() -> None: """A reversed range (vlan 5-3,7) must not silently drop the reversed segment.""" - config = get_hconfig(Platform.CISCO_IOS, "vlan 5-3,7\n") + config = HConfig.from_text(Platform.CISCO_IOS, "vlan 5-3,7\n") assert config.get_child(equals="vlan 5-3,7") is not None assert config.get_child(equals="vlan 7") is None @@ -205,7 +167,7 @@ def test_cisco_ios_trunk_allowed_vlan_not_split() -> None: """Cisco trunk membership is declarative, not additive: the trunk line stays a single command. Only AOS-CX splits `vlan trunk allowed` one VLAN per line. """ - config = get_hconfig( + config = HConfig.from_text( Platform.CISCO_IOS, "interface GigabitEthernet0/1\n switchport trunk allowed vlan 150-166\n", ) @@ -218,45 +180,45 @@ def test_cisco_ios_trunk_allowed_vlan_not_split() -> None: def test_vlan_id_list_rename_is_not_destructive() -> None: """Renaming one VLAN in a collapsed list must not negate the whole list.""" - running_config = get_hconfig(Platform.CISCO_IOS, "vlan 69,381\n") - generated_config = get_hconfig( + running_config = HConfig.from_text(Platform.CISCO_IOS, "vlan 69,381\n") + generated_config = HConfig.from_text( Platform.CISCO_IOS, "vlan 69\n name newname\nvlan 381\n" ) - remediation = running_config.config_to_get_to(generated_config).dump_simple() + remediation = running_config.remediation(generated_config).to_lines() assert remediation == ("vlan 69", " name newname") assert not any(line.lstrip().startswith("no vlan") for line in remediation) def test_vlan_id_list_naming_middle_vlan_regroups_cleanly() -> None: """Naming a VLAN regroups IOS commas (69,70,71 -> 69,71 + 70); diff stays surgical.""" - running_config = get_hconfig(Platform.CISCO_IOS, "vlan 69,70,71\n") - generated_config = get_hconfig( + running_config = HConfig.from_text(Platform.CISCO_IOS, "vlan 69,70,71\n") + generated_config = HConfig.from_text( Platform.CISCO_IOS, "vlan 69,71\nvlan 70\n name MIDDLE\n" ) - remediation = running_config.config_to_get_to(generated_config).dump_simple() + remediation = running_config.remediation(generated_config).to_lines() assert remediation == ("vlan 70", " name MIDDLE") assert not any(line.lstrip().startswith("no vlan") for line in remediation) def test_vlan_id_list_partial_removal_is_surgical() -> None: """Removing only some VLANs from a collapsed list negates just those ids.""" - running_config = get_hconfig(Platform.CISCO_IOS, "vlan 69,381,400\n") - generated_config = get_hconfig(Platform.CISCO_IOS, "vlan 69\nvlan 381\n") - remediation = running_config.config_to_get_to(generated_config).dump_simple() + running_config = HConfig.from_text(Platform.CISCO_IOS, "vlan 69,381,400\n") + generated_config = HConfig.from_text(Platform.CISCO_IOS, "vlan 69\nvlan 381\n") + remediation = running_config.remediation(generated_config).to_lines() assert remediation == ("no vlan 400",) def test_vlan_id_list_pure_add_emits_one_line_per_vlan() -> None: """Adding a collapsed VLAN list emits one (non-destructive) line per VLAN.""" - running_config = get_hconfig(Platform.CISCO_IOS, "") - generated_config = get_hconfig(Platform.CISCO_IOS, "vlan 10-12,20\n") - remediation = running_config.config_to_get_to(generated_config).dump_simple() + running_config = HConfig.from_text(Platform.CISCO_IOS, "") + generated_config = HConfig.from_text(Platform.CISCO_IOS, "vlan 10-12,20\n") + remediation = running_config.remediation(generated_config).to_lines() assert remediation == ("vlan 10", "vlan 11", "vlan 12", "vlan 20") def test_vlan_id_list_no_change_is_idempotent() -> None: """Identical collapsed VLAN lists produce no remediation.""" - running_config = get_hconfig(Platform.CISCO_IOS, "vlan 69,381\n") - generated_config = get_hconfig(Platform.CISCO_IOS, "vlan 69,381\n") - remediation = running_config.config_to_get_to(generated_config).dump_simple() + running_config = HConfig.from_text(Platform.CISCO_IOS, "vlan 69,381\n") + generated_config = HConfig.from_text(Platform.CISCO_IOS, "vlan 69,381\n") + remediation = running_config.remediation(generated_config).to_lines() assert remediation == () diff --git a/tests/test_driver_cisco_nxos.py b/tests/integration/test_cisco_nxos.py similarity index 80% rename from tests/test_driver_cisco_nxos.py rename to tests/integration/test_cisco_nxos.py index 44c5080f..50062e68 100644 --- a/tests/test_driver_cisco_nxos.py +++ b/tests/integration/test_cisco_nxos.py @@ -1,6 +1,6 @@ -from hier_config import get_hconfig_fast_load +from hier_config import HConfig from hier_config.models import Platform -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules def test_line_console_terminal_settings_negation_negate_with() -> None: @@ -9,7 +9,7 @@ def test_line_console_terminal_settings_negation_negate_with() -> None: NX-OS does not accept 'no terminal length ' or 'no terminal width '. The correct remediation is to reset to platform defaults via negation_negate_with. """ - driver = load_hconfig_v2_options( + driver = load_driver_rules( { "negation_negate_with": [ { @@ -31,7 +31,7 @@ def test_line_console_terminal_settings_negation_negate_with() -> None: Platform.CISCO_NXOS, ) - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( driver, ( "line console", @@ -40,7 +40,7 @@ def test_line_console_terminal_settings_negation_negate_with() -> None: " terminal width 160", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( driver, ( "line console", @@ -48,9 +48,9 @@ def test_line_console_terminal_settings_negation_negate_with() -> None: ), ) - remediation = running_config.config_to_get_to(generated_config) + remediation = running_config.remediation(generated_config) - assert remediation.dump_simple() == ( + assert remediation.to_lines() == ( "line console", " terminal length 24", " terminal width 80", diff --git a/tests/test_driver_cisco_xr.py b/tests/integration/test_cisco_xr.py similarity index 52% rename from tests/test_driver_cisco_xr.py rename to tests/integration/test_cisco_xr.py index 263524c2..009852bb 100644 --- a/tests/test_driver_cisco_xr.py +++ b/tests/integration/test_cisco_xr.py @@ -1,34 +1,11 @@ -from hier_config import get_hconfig, get_hconfig_fast_load +from hier_config import HConfig from hier_config.models import Platform -def test_multiple_groups_no_duplicate_child_error() -> None: - """Test that multiple group blocks don't raise DuplicateChildError (issue #209).""" - platform = Platform.CISCO_XR - config_text = """\ -hostname router1 -group core - interface 'Bundle-Ether.*' - mtu 9188 - ! -end-group -group edge - interface 'Bundle-Ether.*' - mtu 9092 - ! -end-group -""" - hconfig = get_hconfig(platform, config_text) - children = [child.text for child in hconfig.children] - assert "hostname router1" in children - assert "group core" in children - assert "group edge" in children - - def test_multiple_groups_remediation() -> None: """Test remediation between configs with multiple group blocks.""" platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "hostname router1", @@ -44,7 +21,7 @@ def test_multiple_groups_remediation() -> None: "end-group", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "hostname router1", @@ -55,13 +32,13 @@ def test_multiple_groups_remediation() -> None: "end-group", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ("no group edge",) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ("no group edge",) def test_duplicate_child_route_policy() -> None: platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "route-policy SET_COMMUNITY_AND_PERMIT", @@ -83,7 +60,7 @@ def test_duplicate_child_route_policy() -> None: "end-policy", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "route-policy SET_COMMUNITY_AND_PERMIT", @@ -97,8 +74,8 @@ def test_duplicate_child_route_policy() -> None: "", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "no route-policy SET_LOCAL_PREF_AND_PASS", ) @@ -106,7 +83,7 @@ def test_duplicate_child_route_policy() -> None: def test_nested_if_endif_route_policy() -> None: """Test nested if/endif blocks in route-policy don't raise DuplicateChildError.""" platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "route-policy EXAMPLE-POLICY", @@ -123,7 +100,7 @@ def test_nested_if_endif_route_policy() -> None: "end-policy", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "route-policy EXAMPLE-POLICY", @@ -140,8 +117,8 @@ def test_nested_if_endif_route_policy() -> None: "end-policy", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "route-policy EXAMPLE-POLICY", " if (community matches-any COMM-SET-A) then", " if (community matches-any COMM-SET-B) then", @@ -163,7 +140,7 @@ def test_nested_if_endif_route_policy() -> None: def test_flow_exporter_template_indent_adjust() -> None: """Test that 'template timeout' inside flow exporter-map doesn't corrupt indentation.""" platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "flow exporter-map EXPORTER1", @@ -183,7 +160,7 @@ def test_flow_exporter_template_indent_adjust() -> None: "end-policy", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "flow exporter-map EXPORTER1", @@ -203,8 +180,8 @@ def test_flow_exporter_template_indent_adjust() -> None: "end-policy", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "route-policy POLICY1", " if (destination in PREFIX-SET1) then", " drop", @@ -228,7 +205,7 @@ def test_flow_exporter_template_data_options_timeout_indent_adjust() -> None: arriving, every subsequent line in the config was nested under them. """ platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "flow exporter-map EXPORTER1", @@ -259,7 +236,7 @@ def test_flow_exporter_template_data_options_timeout_indent_adjust() -> None: assert version.get_child(equals="template data timeout 15") is not None assert version.get_child(equals="template options timeout 15") is not None - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "flow exporter-map EXPORTER1", @@ -279,8 +256,8 @@ def test_flow_exporter_template_data_options_timeout_indent_adjust() -> None: " remote-as 65001", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "router bgp 65000", " no neighbor 10.0.0.2", " neighbor 10.0.0.3", @@ -293,7 +270,7 @@ def test_flow_exporter_template_data_options_timeout_indent_adjust() -> None: def test_template_block_indent_adjust() -> None: """Test that template blocks still parse correctly with the indent_adjust rule.""" platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "template ACCESS-PORT", @@ -319,7 +296,7 @@ def test_template_block_indent_adjust() -> None: "!", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "template ACCESS-PORT", @@ -345,8 +322,8 @@ def test_template_block_indent_adjust() -> None: "!", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "no template UPLINK-PORT", "template UPLINK-PORT", " description Uplink - Core Facing", @@ -366,7 +343,7 @@ def test_template_block_indent_adjust() -> None: def test_ipv4_acl_sequence_number_idempotent() -> None: """Test IPv4 ACL sequence number idempotency (covers lines 25-31).""" platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "ipv4 access-list TEST_ACL", @@ -375,7 +352,7 @@ def test_ipv4_acl_sequence_number_idempotent() -> None: " 30 deny ipv4 any any", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "ipv4 access-list TEST_ACL", @@ -384,9 +361,9 @@ def test_ipv4_acl_sequence_number_idempotent() -> None: " 30 deny ipv4 any any", ), ) - remediation_config = running_config.config_to_get_to(generated_config) + remediation_config = running_config.remediation(generated_config) - assert remediation_config.dump_simple() == ( + assert remediation_config.to_lines() == ( "ipv4 access-list TEST_ACL", " 20 permit tcp any any eq 22", ) @@ -395,7 +372,7 @@ def test_ipv4_acl_sequence_number_idempotent() -> None: def test_ipv6_acl_sequence_number_idempotent() -> None: """Test IPv6 ACL sequence number idempotency (covers lines 25-31).""" platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "ipv6 access-list TEST_IPV6_ACL", @@ -403,7 +380,7 @@ def test_ipv6_acl_sequence_number_idempotent() -> None: " 20 deny ipv6 any any", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "ipv6 access-list TEST_IPV6_ACL", @@ -411,9 +388,9 @@ def test_ipv6_acl_sequence_number_idempotent() -> None: " 20 deny ipv6 any any", ), ) - remediation_config = running_config.config_to_get_to(generated_config) + remediation_config = running_config.remediation(generated_config) - assert remediation_config.dump_simple() == ( + assert remediation_config.to_lines() == ( "ipv6 access-list TEST_IPV6_ACL", " 10 permit tcp any any eq 22", ) @@ -422,7 +399,7 @@ def test_ipv6_acl_sequence_number_idempotent() -> None: def test_ipv4_acl_sequence_number_addition() -> None: """Test adding new IPv4 ACL entries with sequence numbers.""" platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "ipv4 access-list TEST_ACL", @@ -430,7 +407,7 @@ def test_ipv4_acl_sequence_number_addition() -> None: " 30 deny ipv4 any any", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "ipv4 access-list TEST_ACL", @@ -439,319 +416,18 @@ def test_ipv4_acl_sequence_number_addition() -> None: " 30 deny ipv4 any any", ), ) - remediation_config = running_config.config_to_get_to(generated_config) + remediation_config = running_config.remediation(generated_config) - assert remediation_config.dump_simple() == ( + assert remediation_config.to_lines() == ( "ipv4 access-list TEST_ACL", " 20 permit tcp any any eq 22", ) -def test_sectional_exit_text_parent_level_route_policy() -> None: - """Test that route-policy exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "route-policy TEST", - " set local-preference 200", - " pass", - ), - ) - - route_policy = config.get_child(equals="route-policy TEST") - assert route_policy is not None - assert route_policy.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "route-policy TEST", - " set local-preference 200", - " pass", - "end-policy", - ) - - -def test_sectional_exit_text_parent_level_prefix_set() -> None: - """Test that prefix-set exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "prefix-set TEST_PREFIX", - " 192.0.2.0/24", - " 198.51.100.0/24", - ), - ) - - prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") - assert prefix_set is not None - assert prefix_set.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "prefix-set TEST_PREFIX", - " 192.0.2.0/24", - " 198.51.100.0/24", - "end-set", - ) - - -def test_sectional_exit_text_parent_level_policy_map() -> None: - """Test that policy-map exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "policy-map TEST_POLICY", - " class TEST_CLASS", - " set precedence 5", - ), - ) - - policy_map = config.get_child(equals="policy-map TEST_POLICY") - assert policy_map is not None - assert policy_map.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "policy-map TEST_POLICY", - " class TEST_CLASS", - " set precedence 5", - " exit", - "end-policy-map", - ) - - -def test_sectional_exit_text_parent_level_class_map() -> None: - """Test that class-map exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "class-map match-any TEST_CLASS", - " match access-group TEST_ACL", - ), - ) - - class_map = config.get_child(equals="class-map match-any TEST_CLASS") - assert class_map is not None - assert class_map.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "class-map match-any TEST_CLASS", - " match access-group TEST_ACL", - "end-class-map", - ) - - -def test_sectional_exit_text_parent_level_community_set() -> None: - """Test that community-set exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "community-set TEST_COMM", - " 65001:100", - " 65001:200", - ), - ) - - community_set = config.get_child(equals="community-set TEST_COMM") - assert community_set is not None - assert community_set.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "community-set TEST_COMM", - " 65001:100", - " 65001:200", - "end-set", - ) - - -def test_sectional_exit_text_parent_level_extcommunity_set() -> None: - """Test that extcommunity-set exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "extcommunity-set rt TEST_RT", - " 1:100", - " 2:200", - ), - ) - - extcommunity_set = config.get_child(equals="extcommunity-set rt TEST_RT") - assert extcommunity_set is not None - assert extcommunity_set.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "extcommunity-set rt TEST_RT", - " 1:100", - " 2:200", - "end-set", - ) - - -def test_sectional_exit_text_parent_level_template() -> None: - """Test that template exit text appears at parent level (no indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "template TEST_TEMPLATE", - " description test template", - ), - ) - - template = config.get_child(equals="template TEST_TEMPLATE") - assert template is not None - assert template.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "template TEST_TEMPLATE", - " description test template", - "end-template", - ) - - -def test_sectional_exit_text_current_level_interface() -> None: - """Test that interface exit text appears at current level (with indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "interface GigabitEthernet0/0/0/0", - " description test interface", - " ipv4 address 192.0.2.1 255.255.255.0", - ), - ) - - interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") - assert interface is not None - assert interface.sectional_exit_text_parent_level is False - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "interface GigabitEthernet0/0/0/0", - " description test interface", - " ipv4 address 192.0.2.1 255.255.255.0", - " root", - ) - - -def test_sectional_exit_text_current_level_router_bgp() -> None: - """Test that router bgp exit text appears at current level (with indentation).""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "router bgp 65000", - " bgp router-id 192.0.2.1", - " address-family ipv4 unicast", - ), - ) - - router_bgp = config.get_child(equals="router bgp 65000") - assert router_bgp is not None - assert router_bgp.sectional_exit_text_parent_level is False - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "router bgp 65000", - " bgp router-id 192.0.2.1", - " address-family ipv4 unicast", - " root", - ) - - -def test_sectional_exit_text_multiple_sections() -> None: - """Test multiple sections with different exit text level behaviors.""" - platform = Platform.CISCO_XR - config = get_hconfig_fast_load( - platform, - ( - "route-policy TEST1", - " pass", - "!", - "interface GigabitEthernet0/0/0/0", - " description test", - "!", - "prefix-set TEST_PREFIX", - " 192.0.2.0/24", - ), - ) - - route_policy = config.get_child(equals="route-policy TEST1") - assert route_policy is not None - assert route_policy.sectional_exit_text_parent_level is True - - interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") - assert interface is not None - assert interface.sectional_exit_text_parent_level is False - - prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") - assert prefix_set is not None - assert prefix_set.sectional_exit_text_parent_level is True - - output = config.dump_simple(sectional_exiting=True) - assert output == ( - "route-policy TEST1", - " pass", - "end-policy", - "interface GigabitEthernet0/0/0/0", - " description test", - " root", - "prefix-set TEST_PREFIX", - " 192.0.2.0/24", - "end-set", - ) - - -def test_indented_bang_section_separators_no_duplicate_child_error() -> None: - """Test that indented ! section separators don't raise DuplicateChildError (issue #231).""" - platform = Platform.CISCO_XR - config_text = """\ -telemetry model-driven - destination-group DEST-GROUP-1 - address-family ipv4 10.0.0.1 port 57000 - encoding self-describing-gpb - protocol tcp - ! - ! - destination-group DEST-GROUP-2 - address-family ipv4 10.0.0.2 port 57000 - encoding self-describing-gpb - protocol tcp - ! - ! - sensor-group SENSOR-1 - sensor-path openconfig-platform:components/component/cpu - sensor-path openconfig-platform:components/component/memory - ! - sensor-group SENSOR-2 - sensor-path openconfig-interfaces:interfaces/interface/state/counters - ! -! -""" - hconfig = get_hconfig(platform, config_text) - telemetry = hconfig.get_child(equals="telemetry model-driven") - assert telemetry is not None - child_texts = [child.text for child in telemetry.children] - assert "destination-group DEST-GROUP-1" in child_texts - assert "destination-group DEST-GROUP-2" in child_texts - assert "sensor-group SENSOR-1" in child_texts - assert "sensor-group SENSOR-2" in child_texts - - def test_running_with_bang_separators_intended_without_no_remediation() -> None: """Running config with indented ! separators and intended without produces no remediation.""" platform = Platform.CISCO_XR - running = get_hconfig( + running = HConfig.from_text( platform, """\ telemetry model-driven @@ -769,7 +445,7 @@ def test_running_with_bang_separators_intended_without_no_remediation() -> None: ! """, ) - intended = get_hconfig( + intended = HConfig.from_text( platform, """\ telemetry model-driven @@ -783,13 +459,13 @@ def test_running_with_bang_separators_intended_without_no_remediation() -> None: protocol tcp """, ) - assert running.config_to_get_to(intended).dump_simple() == () + assert running.remediation(intended).to_lines() == () def test_intended_with_bang_comments_running_without_no_remediation() -> None: """Intended config with ! comment lines and running without produces no remediation.""" platform = Platform.CISCO_XR - running = get_hconfig( + running = HConfig.from_text( platform, """\ telemetry model-driven @@ -803,7 +479,7 @@ def test_intended_with_bang_comments_running_without_no_remediation() -> None: protocol tcp """, ) - intended = get_hconfig( + intended = HConfig.from_text( platform, """\ telemetry model-driven @@ -819,13 +495,13 @@ def test_intended_with_bang_comments_running_without_no_remediation() -> None: protocol tcp """, ) - assert running.config_to_get_to(intended).dump_simple() == () + assert running.remediation(intended).to_lines() == () def test_differing_bang_comment_text_produces_no_remediation() -> None: """Differing ! comment text between running and intended produces no remediation.""" platform = Platform.CISCO_XR - running = get_hconfig( + running = HConfig.from_text( platform, """\ router isis backbone @@ -833,7 +509,7 @@ def test_differing_bang_comment_text_produces_no_remediation() -> None: net 49.0001.1921.2022.0222.00 """, ) - intended = get_hconfig( + intended = HConfig.from_text( platform, """\ router isis backbone @@ -841,4 +517,4 @@ def test_differing_bang_comment_text_produces_no_remediation() -> None: net 49.0001.1921.2022.0222.00 """, ) - assert running.config_to_get_to(intended).dump_simple() == () + assert running.remediation(intended).to_lines() == () diff --git a/tests/test_driver_fortinet_fortios.py b/tests/integration/test_fortinet_fortios.py similarity index 65% rename from tests/test_driver_fortinet_fortios.py rename to tests/integration/test_fortinet_fortios.py index a168d734..ec8f4456 100644 --- a/tests/test_driver_fortinet_fortios.py +++ b/tests/integration/test_fortinet_fortios.py @@ -1,13 +1,10 @@ -from hier_config import get_hconfig_fast_load -from hier_config.child import HConfigChild -from hier_config.constructors import get_hconfig +from hier_config import HConfig from hier_config.models import Platform -from hier_config.platforms.fortinet_fortios.driver import HConfigDriverFortinetFortiOS def test_swap_negation() -> None: platform = Platform.FORTINET_FORTIOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "config system interface", @@ -22,7 +19,7 @@ def test_swap_negation() -> None: "end", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "config system interface", @@ -35,8 +32,8 @@ def test_swap_negation() -> None: "end", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "config system interface", " edit port1", " unset description", @@ -50,7 +47,7 @@ def test_swap_negation() -> None: def test_idempotent_for() -> None: platform = Platform.FORTINET_FORTIOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "config system interface", @@ -65,7 +62,7 @@ def test_idempotent_for() -> None: "end", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "config system interface", @@ -80,8 +77,8 @@ def test_idempotent_for() -> None: "end", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple(sectional_exiting=True) == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines(sectional_exiting=True) == ( "config system interface", " edit port1", " set description 'New Description'", @@ -95,8 +92,8 @@ def test_idempotent_for() -> None: def test_future() -> None: platform = Platform.FORTINET_FORTIOS - running_config = get_hconfig(platform) - remediation_config = get_hconfig_fast_load( + running_config = HConfig.from_text(platform) + remediation_config = HConfig.from_lines( platform, ( "config system interface", @@ -109,17 +106,3 @@ def test_future() -> None: ) future_config = running_config.future(remediation_config) assert not tuple(remediation_config.unified_diff(future_config)) - - -def test_swap_negation_direct() -> None: - """Test swap_negation method directly to cover set-to-unset conversion (covers line 45).""" - driver = HConfigDriverFortinetFortiOS() - config = get_hconfig(Platform.FORTINET_FORTIOS) - child = HConfigChild(config, "set description 'test value'") - result = driver.swap_negation(child) - assert result.text == "unset description" - - child2 = HConfigChild(config, "unset description") - result2 = driver.swap_negation(child2) - - assert result2.text == "set description" diff --git a/tests/test_driver_generic.py b/tests/integration/test_generic.py similarity index 85% rename from tests/test_driver_generic.py rename to tests/integration/test_generic.py index 417a08cb..6e998504 100644 --- a/tests/test_driver_generic.py +++ b/tests/integration/test_generic.py @@ -1,16 +1,15 @@ import pytest -from hier_config import get_hconfig_fast_load from hier_config.exceptions import DuplicateChildError from hier_config.models import Platform from hier_config.root import HConfig -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules def test_generic_snmp_scenario_1() -> None: platform = Platform.GENERIC - running_config = get_hconfig_fast_load(platform, ("snmp-server community public",)) - generated_config = get_hconfig_fast_load( + running_config = HConfig.from_lines(platform, ("snmp-server community public",)) + generated_config = HConfig.from_lines( platform, ( "snmp-server community examplekey1", @@ -20,8 +19,8 @@ def test_generic_snmp_scenario_1() -> None: "snmp-server host 192.2.0.3 trap version v2c community examplekey3", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "no snmp-server community public", "snmp-server community examplekey1", "snmp-server community examplekey2", @@ -33,7 +32,7 @@ def test_generic_snmp_scenario_1() -> None: def test_generic_snmp_scenario_2() -> None: platform = Platform.GENERIC - driver = load_hconfig_v2_options( + driver = load_driver_rules( { "parent_allows_duplicate_child": [ {"lineage": [{"startswith": ["snmp-server community"]}]}, @@ -42,7 +41,7 @@ def test_generic_snmp_scenario_2() -> None: platform, ) with pytest.raises(DuplicateChildError): - get_hconfig_fast_load( + HConfig.from_lines( driver, ( "snmp-server community ", @@ -56,7 +55,7 @@ def test_generic_snmp_scenario_2() -> None: def test_generic_aaa_scenario_1() -> None: platform = Platform.GENERIC - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "aaa group server tacacs TACACS_GROUP1", @@ -66,7 +65,7 @@ def test_generic_aaa_scenario_1() -> None: " server 192.2.0.121", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "aaa group server tacacs TACACS_GROUP2", @@ -76,8 +75,8 @@ def test_generic_aaa_scenario_1() -> None: " server 192.2.0.121", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "no aaa group server tacacs TACACS_GROUP1", "no aaa group server radius RADIUS_GROUP1", "aaa group server tacacs TACACS_GROUP2", @@ -90,7 +89,7 @@ def test_generic_aaa_scenario_1() -> None: def test_generic_aaa_scenario_2() -> None: platform = Platform.GENERIC - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "aaa group server tacacs TACACS_GROUP1", @@ -100,7 +99,7 @@ def test_generic_aaa_scenario_2() -> None: " server 192.2.0.121", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "aaa group server tacacs TACACS_GROUP2", @@ -111,7 +110,7 @@ def test_generic_aaa_scenario_2() -> None: ), ) # Create a driver with ordering rules for aaa group server management - driver = load_hconfig_v2_options( + driver = load_driver_rules( { "ordering": [ {"lineage": [{"startswith": "aaa group server radius "}], "order": 520}, @@ -128,7 +127,7 @@ def test_generic_aaa_scenario_2() -> None: Platform.GENERIC, ) - base_remediation = running_config.config_to_get_to(generated_config) + base_remediation = running_config.remediation(generated_config) remediation_config = HConfig(driver) for child in base_remediation.children: @@ -149,7 +148,7 @@ def test_generic_aaa_scenario_2() -> None: remediation_config.set_order_weight() - assert remediation_config.dump_simple() == ( + assert remediation_config.to_lines() == ( "aaa group server tacacs TACACS_GROUP1", " no server 192.2.0.3", " no server 192.2.0.7", diff --git a/tests/integration/test_hp_procurve.py b/tests/integration/test_hp_procurve.py new file mode 100644 index 00000000..88ba73bd --- /dev/null +++ b/tests/integration/test_hp_procurve.py @@ -0,0 +1,125 @@ +from hier_config import HConfig +from hier_config.models import Platform + + +def test_negate_with() -> None: + platform = Platform.HP_PROCURVE + running_config = HConfig.from_lines( + platform, + ( + "aaa port-access authenticator 1/1 tx-period 3", + "aaa port-access authenticator 1/1 supplicant-timeout 3", + "aaa port-access authenticator 1/1 client-limit 4", + "aaa port-access mac-based 1/1 addr-limit 4", + "aaa port-access mac-based 1/1 logoff-period 3", + 'aaa port-access 1/1 critical-auth user-role "allowall"', + ), + ) + generated_config = HConfig.from_text(platform) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( + "aaa port-access authenticator 1/1 tx-period 30", + "aaa port-access authenticator 1/1 supplicant-timeout 30", + "no aaa port-access authenticator 1/1 client-limit", + "aaa port-access mac-based 1/1 addr-limit 1", + "aaa port-access mac-based 1/1 logoff-period 300", + "no aaa port-access 1/1 critical-auth user-role", + ) + + +def test_idempotent_for() -> None: + platform = Platform.HP_PROCURVE + running_config = HConfig.from_lines( + platform, + ( + "aaa port-access authenticator 1/1 tx-period 3", + "aaa port-access authenticator 1/1 supplicant-timeout 3", + "aaa port-access authenticator 1/1 client-limit 4", + "aaa port-access mac-based 1/1 addr-limit 4", + "aaa port-access mac-based 1/1 logoff-period 3", + 'aaa port-access 1/1 critical-auth user-role "allowall"', + ), + ) + generated_config = HConfig.from_lines( + platform, + ( + "aaa port-access authenticator 1/1 tx-period 4", + "aaa port-access authenticator 1/1 supplicant-timeout 4", + "aaa port-access authenticator 1/1 client-limit 5", + "aaa port-access mac-based 1/1 addr-limit 5", + "aaa port-access mac-based 1/1 logoff-period 4", + 'aaa port-access 1/1 critical-auth user-role "allownone"', + ), + ) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( + "aaa port-access authenticator 1/1 tx-period 4", + "aaa port-access authenticator 1/1 supplicant-timeout 4", + "aaa port-access authenticator 1/1 client-limit 5", + "aaa port-access mac-based 1/1 addr-limit 5", + "aaa port-access mac-based 1/1 logoff-period 4", + 'aaa port-access 1/1 critical-auth user-role "allownone"', + ) + + +def test_future() -> None: + platform = Platform.HP_PROCURVE + running_config = HConfig.from_text(platform) + remediation_config = HConfig.from_lines( + platform, + ( + "aaa port-access authenticator 3/34", + "aaa port-access authenticator 3/34 tx-period 10", + "aaa port-access authenticator 3/34 supplicant-timeout 10", + "aaa port-access authenticator 3/34 client-limit 2", + "aaa port-access mac-based 3/34", + "aaa port-access mac-based 3/34 addr-limit 2", + 'aaa port-access 3/34 critical-auth user-role "allowall"', + ), + ) + future_config = running_config.future(remediation_config) + assert not tuple(remediation_config.unified_diff(future_config)) + + +def test_negate_with_child_config() -> None: + """Test negate_with returns None for non-root config without special rule (covers line 166).""" + platform = Platform.HP_PROCURVE + running_config = HConfig.from_lines( + platform, + ( + "interface 1/1", + " speed-duplex auto", + ), + ) + generated_config = HConfig.from_lines( + platform, + ("interface 1/1",), + ) + remediation_config = running_config.remediation(generated_config) + + assert remediation_config.to_lines() == ( + "interface 1/1", + " no speed-duplex auto", + ) + + +def test_negate_with_from_base_driver() -> None: + """Test negate_with uses parent driver rule when applicable (covers line 163).""" + platform = Platform.HP_PROCURVE + running_config = HConfig.from_lines( + platform, + ( + "interface 1/1", + " disable", + ), + ) + generated_config = HConfig.from_lines( + platform, + ("interface 1/1",), + ) + remediation_config = running_config.remediation(generated_config) + + assert remediation_config.to_lines() == ( + "interface 1/1", + " enable", + ) diff --git a/tests/test_driver_huawei_vrp.py b/tests/integration/test_huawei_vrp.py similarity index 64% rename from tests/test_driver_huawei_vrp.py rename to tests/integration/test_huawei_vrp.py index d3a1860c..c51bb7d1 100644 --- a/tests/test_driver_huawei_vrp.py +++ b/tests/integration/test_huawei_vrp.py @@ -1,30 +1,27 @@ -from hier_config import get_hconfig_fast_load -from hier_config.constructors import get_hconfig +from hier_config import HConfig from hier_config.models import Platform def test_merge_with_undo() -> None: platform = Platform.HUAWEI_VRP - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("test_for_undo", "undo test_for_redo") ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ("undo test_for_undo", "test_for_redo") ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ("undo test_for_undo", "test_for_redo") + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ("undo test_for_undo", "test_for_redo") def test_negate_description() -> None: platform = Platform.HUAWEI_VRP - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("interface GigabitEthernet0/0/0", " description some old blabla") ) - generated_config = get_hconfig_fast_load( - platform, ("interface GigabitEthernet0/0/0",) - ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + generated_config = HConfig.from_lines(platform, ("interface GigabitEthernet0/0/0",)) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "interface GigabitEthernet0/0/0", " undo description", ) @@ -32,12 +29,12 @@ def test_negate_description() -> None: def test_negate_remark() -> None: platform = Platform.HUAWEI_VRP - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("acl number 2000", " rule 5 remark some old remark") ) - generated_config = get_hconfig_fast_load(platform, ("acl number 2000",)) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + generated_config = HConfig.from_lines(platform, ("acl number 2000",)) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "acl number 2000", " undo rule 5 remark", ) @@ -45,14 +42,12 @@ def test_negate_remark() -> None: def test_negate_alias() -> None: platform = Platform.HUAWEI_VRP - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("interface GigabitEthernet0/0/0", " alias some old alias") ) - generated_config = get_hconfig_fast_load( - platform, ("interface GigabitEthernet0/0/0",) - ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + generated_config = HConfig.from_lines(platform, ("interface GigabitEthernet0/0/0",)) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "interface GigabitEthernet0/0/0", " undo alias", ) @@ -60,19 +55,19 @@ def test_negate_alias() -> None: def test_negate_snmp_agent_community() -> None: platform = Platform.HUAWEI_VRP - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("snmp-agent community read cipher %^%#blabla%^%# acl 2000",) ) - generated_config = get_hconfig(platform) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + generated_config = HConfig.from_text(platform) + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "undo snmp-agent community read cipher %^%#blabla%^%#", ) def test_comments_stripped() -> None: platform = Platform.HUAWEI_VRP - config = get_hconfig_fast_load( + config = HConfig.from_lines( platform, ( "#", @@ -83,7 +78,7 @@ def test_comments_stripped() -> None: "! yet another comment", ), ) - assert config.dump_simple() == ( + assert config.to_lines() == ( "interface GigabitEthernet0/0/0", " description test", ) @@ -91,7 +86,7 @@ def test_comments_stripped() -> None: def test_multiple_peer_public_keys_no_duplicate_child_error() -> None: platform = Platform.HUAWEI_VRP - config = get_hconfig( + config = HConfig.from_text( platform, "rsa peer-public-key user1 encoding-type openssh\n" " public-key-code begin\n" @@ -107,7 +102,7 @@ def test_multiple_peer_public_keys_no_duplicate_child_error() -> None: "peer-public-key end\n" "#\n", ) - assert config.dump_simple() == ( + assert config.to_lines() == ( "rsa peer-public-key user1 encoding-type openssh", " public-key-code begin", " AAAAB3Nza1", @@ -124,14 +119,14 @@ def test_multiple_peer_public_keys_no_duplicate_child_error() -> None: def test_sectional_exit_is_quit() -> None: platform = Platform.HUAWEI_VRP - config = get_hconfig_fast_load( + config = HConfig.from_lines( platform, ( "interface GigabitEthernet0/0/0", " description test", ), ) - assert config.dump_simple(sectional_exiting=True) == ( + assert config.to_lines(sectional_exiting=True) == ( "interface GigabitEthernet0/0/0", " description test", " quit", diff --git a/tests/test_idempotent_commands.py b/tests/integration/test_idempotent_commands.py similarity index 82% rename from tests/test_idempotent_commands.py rename to tests/integration/test_idempotent_commands.py index 63b3939d..d6bb0b17 100644 --- a/tests/test_idempotent_commands.py +++ b/tests/integration/test_idempotent_commands.py @@ -1,4 +1,4 @@ -from hier_config import get_hconfig_fast_load +from hier_config import HConfig from hier_config.models import ( IdempotentCommandsRule, MatchRule, @@ -26,16 +26,16 @@ def test_parameterized_regex_same_key_is_idempotent() -> None: ), ], ) - running = get_hconfig_fast_load( + running = HConfig.from_lines( driver, ("client 10.1.1.1 server-key KEY_OLD",), ) - generated = get_hconfig_fast_load( + generated = HConfig.from_lines( 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",) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("client 10.1.1.1 server-key KEY_NEW",) def test_parameterized_regex_different_key_not_idempotent() -> None: @@ -47,26 +47,26 @@ def test_parameterized_regex_different_key_not_idempotent() -> None: ), ], ) - running = get_hconfig_fast_load( + running = HConfig.from_lines( driver, ( "client 10.1.1.1 server-key KEY1", "client 10.2.2.2 server-key KEY2", ), ) - generated = get_hconfig_fast_load( + generated = HConfig.from_lines( driver, ("client 10.1.1.1 server-key KEY1",), ) - remediation = running.config_to_get_to(generated) + remediation = running.remediation(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",) + assert remediation.to_lines() == ("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( + running = HConfig.from_lines( platform, ( "router bgp 1001", @@ -75,7 +75,7 @@ def test_bgp_neighbor_regex_idempotent() -> None: " neighbor 40.0.0.8 remote-as 2002", ), ) - generated = get_hconfig_fast_load( + generated = HConfig.from_lines( platform, ( "router bgp 1001", @@ -84,8 +84,8 @@ def test_bgp_neighbor_regex_idempotent() -> None: " neighbor 1000::8 remote-as 2002", ), ) - remediation = running.config_to_get_to(generated) - lines = remediation.dump_simple() + remediation = running.remediation(generated) + lines = remediation.to_lines() # 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 @@ -108,22 +108,22 @@ def test_startswith_rules_do_not_cross_contaminate() -> None: ), ], ) - running = get_hconfig_fast_load( + running = HConfig.from_lines( driver, ( "hardware access-list tcam region arp-ether 0", "hardware profile tcam region racl 0", ), ) - generated = get_hconfig_fast_load( + generated = HConfig.from_lines( 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() + remediation = running.remediation(generated) + lines = remediation.to_lines() # 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 diff --git a/tests/test_driver_juniper_junos.py b/tests/integration/test_juniper_junos.py similarity index 63% rename from tests/test_driver_juniper_junos.py rename to tests/integration/test_juniper_junos.py index fa2ae3f4..1f1d2199 100644 --- a/tests/test_driver_juniper_junos.py +++ b/tests/integration/test_juniper_junos.py @@ -1,11 +1,5 @@ -import pytest - -from hier_config import WorkflowRemediation, get_hconfig, get_hconfig_fast_load -from hier_config.child import HConfigChild +from hier_config import HConfig, WorkflowRemediation from hier_config.models import Platform -from hier_config.platforms.juniper_junos.driver import HConfigDriverJuniperJUNOS - -# Tests moved from test_juniper_syntax.py def test_junos_basic_remediation() -> None: @@ -15,8 +9,8 @@ def test_junos_basic_remediation() -> None: remediation_str = "delete vlans switch_mgmt_10.0.2.0/24 vlan-id 2\nset vlans switch_mgmt_10.0.3.0/24 vlan-id 3" workflow_remediation = WorkflowRemediation( - get_hconfig_fast_load(platform, running_config_str), - get_hconfig_fast_load(platform, generated_config_str), + HConfig.from_lines(platform, running_config_str), + HConfig.from_lines(platform, generated_config_str), ) assert workflow_remediation.remediation_config_filtered_text() == remediation_str @@ -29,8 +23,8 @@ def test_junos_convert_to_set( ) -> None: platform = Platform.JUNIPER_JUNOS workflow_remediation = WorkflowRemediation( - get_hconfig(platform, running_config_junos), - get_hconfig(platform, generated_config_junos), + HConfig.from_text(platform, running_config_junos), + HConfig.from_text(platform, generated_config_junos), ) assert ( @@ -46,8 +40,8 @@ def test_flat_junos_remediation( ) -> None: platform = Platform.JUNIPER_JUNOS workflow_remediation = WorkflowRemediation( - get_hconfig_fast_load(platform, running_config_flat_junos), - get_hconfig_fast_load(platform, generated_config_flat_junos), + HConfig.from_lines(platform, running_config_flat_junos), + HConfig.from_lines(platform, generated_config_flat_junos), ) remediation_list = remediation_config_flat_junos.splitlines() @@ -55,70 +49,17 @@ def test_flat_junos_remediation( assert line in remediation_list -# New comprehensive driver tests for 100% coverage - - -def test_swap_negation_delete_to_set() -> None: - """Test swapping from 'delete' to 'set' prefix (covers line 9-11).""" - platform = Platform.JUNIPER_JUNOS - driver = HConfigDriverJuniperJUNOS() - root = get_hconfig(platform) - - # Create a child with 'delete' prefix - child = HConfigChild(root, "delete vlans test_vlan vlan-id 100") - - # Swap negation should convert to 'set' - result = driver.swap_negation(child) - - assert result.text == "set vlans test_vlan vlan-id 100" - assert result.text.startswith("set ") - - -def test_swap_negation_set_to_delete() -> None: - """Test swapping from 'set' to 'delete' prefix (covers lines 10, 12).""" - platform = Platform.JUNIPER_JUNOS - driver = HConfigDriverJuniperJUNOS() - root = get_hconfig(platform) - - # Create a child with 'set' prefix - child = HConfigChild(root, "set vlans test_vlan vlan-id 100") - - # Swap negation should convert to 'delete' - result = driver.swap_negation(child) - - assert result.text == "delete vlans test_vlan vlan-id 100" - assert result.text.startswith("delete ") - - -def test_swap_negation_invalid_prefix() -> None: - """Test ValueError when text has neither 'set' nor 'delete' prefix (covers lines 14-15).""" - platform = Platform.JUNIPER_JUNOS - driver = HConfigDriverJuniperJUNOS() - root = get_hconfig(platform) - - # Create a child without proper prefix - child = HConfigChild(root, "vlans test_vlan vlan-id 100") - - # Should raise ValueError - with pytest.raises(ValueError, match="did not start with") as exc_info: - driver.swap_negation(child) - - assert "did not start with" in str(exc_info.value) - assert "delete " in str(exc_info.value) - assert "set " in str(exc_info.value) - - def test_vlan_addition_scenario() -> None: """Test adding a new VLAN to the configuration.""" platform = Platform.JUNIPER_JUNOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set vlans switch_mgmt_10.0.2.0/24 vlan-id 2", "set vlans switch_mgmt_10.0.2.0/24 l3-interface irb.2", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set vlans switch_mgmt_10.0.2.0/24 vlan-id 2", @@ -127,8 +68,8 @@ def test_vlan_addition_scenario() -> None: "set vlans switch_mgmt_10.0.3.0/24 l3-interface irb.3", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set vlans switch_mgmt_10.0.3.0/24 vlan-id 3", "set vlans switch_mgmt_10.0.3.0/24 l3-interface irb.3", ) @@ -137,7 +78,7 @@ def test_vlan_addition_scenario() -> None: def test_vlan_removal_scenario() -> None: """Test removing a VLAN from the configuration.""" platform = Platform.JUNIPER_JUNOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set vlans switch_mgmt_10.0.2.0/24 vlan-id 2", @@ -146,15 +87,15 @@ def test_vlan_removal_scenario() -> None: "set vlans switch_mgmt_10.0.3.0/24 l3-interface irb.3", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set vlans switch_mgmt_10.0.2.0/24 vlan-id 2", "set vlans switch_mgmt_10.0.2.0/24 l3-interface irb.2", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete vlans switch_mgmt_10.0.3.0/24 vlan-id 3", "delete vlans switch_mgmt_10.0.3.0/24 l3-interface irb.3", ) @@ -163,11 +104,11 @@ def test_vlan_removal_scenario() -> None: def test_interface_unit_configuration_scenario() -> None: """Test configuring interface unit parameters.""" platform = Platform.JUNIPER_JUNOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("set interfaces irb unit 2 family inet address 10.0.2.1/24",), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set interfaces irb unit 2 family inet address 10.0.2.1/24", @@ -176,8 +117,8 @@ def test_interface_unit_configuration_scenario() -> None: "set interfaces irb unit 2 family inet description switch_mgmt_10.0.2.0/24", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set interfaces irb unit 2 family inet filter input TEST", "set interfaces irb unit 2 family inet mtu 9000", "set interfaces irb unit 2 family inet description switch_mgmt_10.0.2.0/24", @@ -187,16 +128,16 @@ def test_interface_unit_configuration_scenario() -> None: def test_interface_address_change_scenario() -> None: """Test changing an interface IP address.""" platform = Platform.JUNIPER_JUNOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("set interfaces irb unit 3 family inet address 10.0.4.1/16",), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ("set interfaces irb unit 3 family inet address 10.0.3.1/16",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces irb unit 3 family inet address 10.0.4.1/16", "set interfaces irb unit 3 family inet address 10.0.3.1/16", ) @@ -205,19 +146,19 @@ def test_interface_address_change_scenario() -> None: def test_interface_disable_enable_scenario() -> None: """Test disabling and enabling an interface.""" platform = Platform.JUNIPER_JUNOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set interfaces irb unit 2 family inet address 10.0.2.1/24", "set interfaces irb unit 2 family inet disable", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ("set interfaces irb unit 2 family inet address 10.0.2.1/24",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces irb unit 2 family inet disable", ) @@ -225,14 +166,14 @@ def test_interface_disable_enable_scenario() -> None: def test_firewall_filter_configuration_scenario() -> None: """Test configuring firewall filter rules.""" platform = Platform.JUNIPER_JUNOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set firewall family inet filter TEST term 1 from source-address 10.0.0.0/29", "set firewall family inet filter TEST term 1 then accept", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set firewall family inet filter TEST term 1 from source-address 10.0.0.0/29", @@ -241,8 +182,8 @@ def test_firewall_filter_configuration_scenario() -> None: "set firewall family inet filter TEST term 2 then reject", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set firewall family inet filter TEST term 2 from destination-address 192.168.1.0/24", "set firewall family inet filter TEST term 2 then reject", ) @@ -251,8 +192,8 @@ def test_firewall_filter_configuration_scenario() -> None: def test_physical_interface_configuration_scenario() -> None: """Test configuring physical interface with multiple families.""" platform = Platform.JUNIPER_JUNOS - running_config = get_hconfig(platform) - generated_config = get_hconfig_fast_load( + running_config = HConfig.from_text(platform) + generated_config = HConfig.from_lines( platform, ( "set interfaces xe-0/0/0 description bb01.lax01:Ethernet2; ID:YT661812121", @@ -263,8 +204,8 @@ def test_physical_interface_configuration_scenario() -> None: "set interfaces xe-0/0/0 unit 0 family inet6 address 2001:db8:5695::1/64", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set interfaces xe-0/0/0 description bb01.lax01:Ethernet2; ID:YT661812121", "set interfaces xe-0/0/0 mtu 9160", "set interfaces xe-0/0/0 unit 0 family iso", @@ -273,7 +214,7 @@ def test_physical_interface_configuration_scenario() -> None: "set interfaces xe-0/0/0 unit 0 family inet6 address 2001:db8:5695::1/64", ) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ( + assert future_config.to_lines() == ( "set interfaces xe-0/0/0 description bb01.lax01:Ethernet2; ID:YT661812121", "set interfaces xe-0/0/0 mtu 9160", "set interfaces xe-0/0/0 unit 0 family iso", @@ -286,16 +227,16 @@ def test_physical_interface_configuration_scenario() -> None: def test_system_hostname_change_scenario() -> None: """Test changing system hostname.""" platform = Platform.JUNIPER_JUNOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("set system host-name old-router.example.com",), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ("set system host-name new-router.example.com",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete system host-name old-router.example.com", "set system host-name new-router.example.com", ) diff --git a/tests/test_negate_with_undo.py b/tests/integration/test_negate_with_undo.py similarity index 53% rename from tests/test_negate_with_undo.py rename to tests/integration/test_negate_with_undo.py index 3ae4d922..fef16808 100644 --- a/tests/test_negate_with_undo.py +++ b/tests/integration/test_negate_with_undo.py @@ -1,16 +1,12 @@ -from hier_config import WorkflowRemediation, get_hconfig_fast_load +from hier_config import HConfig, WorkflowRemediation from hier_config.models import Platform def test_merge_with_undo() -> None: platform = Platform.HP_COMWARE5 - running_config = get_hconfig_fast_load( - platform, "test_for_undo\nundo test_for_redo" - ) - generated_config = get_hconfig_fast_load( - platform, "undo test_for_undo\ntest_for_redo" - ) - expected_remediation_config = get_hconfig_fast_load( + running_config = HConfig.from_lines(platform, "test_for_undo\nundo test_for_redo") + generated_config = HConfig.from_lines(platform, "undo test_for_undo\ntest_for_redo") + expected_remediation_config = HConfig.from_lines( platform, "undo test_for_undo\ntest_for_redo" ) workflow_remediation = WorkflowRemediation(running_config, generated_config) diff --git a/tests/test_negation_sub.py b/tests/integration/test_negation_sub.py similarity index 59% rename from tests/test_negation_sub.py rename to tests/integration/test_negation_sub.py index c5033ed1..d6ba6855 100644 --- a/tests/test_negation_sub.py +++ b/tests/integration/test_negation_sub.py @@ -1,20 +1,21 @@ -from hier_config import get_hconfig_fast_load +from hier_config import HConfig from hier_config.models import ( MatchRule, - NegationSubRule, + NegationRule, + NegationStrategy, Platform, ) from hier_config.platforms.driver_base import HConfigDriverRules from hier_config.platforms.generic.driver import HConfigDriverGeneric -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules def _make_driver( - rules: list[NegationSubRule], + rules: list[NegationRule], ) -> HConfigDriverGeneric: """Create a generic driver with custom negation_sub rules.""" driver = HConfigDriverGeneric() - driver.rules = HConfigDriverRules(negation_sub=rules) + driver.rules = HConfigDriverRules(negation=rules) return driver @@ -22,91 +23,95 @@ def test_negation_sub_truncates_snmp_user() -> None: """SNMP user negation is truncated after the username.""" driver = _make_driver( [ - NegationSubRule( + NegationRule( + strategy=NegationStrategy.REGEX_SUB, match_rules=(MatchRule(startswith="snmp-server user "),), search=r"(no snmp-server user \S+).*", replace=r"\1", ), ], ) - running = get_hconfig_fast_load( + running = HConfig.from_lines( driver, ("snmp-server user admin auth sha secret",), ) - generated = get_hconfig_fast_load(driver, ()) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no snmp-server user admin",) + generated = HConfig.from_lines(driver, ()) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no snmp-server user admin",) def test_negation_sub_truncates_prefix_list() -> None: """Prefix-list negation is truncated after the sequence number.""" driver = _make_driver( [ - NegationSubRule( + NegationRule( + strategy=NegationStrategy.REGEX_SUB, match_rules=(MatchRule(startswith="ipv6 prefix-list "),), search=r"(no ipv6 prefix-list \S+ seq \d+).*", replace=r"\1", ), ], ) - running = get_hconfig_fast_load( + running = HConfig.from_lines( driver, ("ipv6 prefix-list PL seq 1 permit 2801::/64 ge 65",), ) - generated = get_hconfig_fast_load(driver, ()) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no ipv6 prefix-list PL seq 1",) + generated = HConfig.from_lines(driver, ()) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no ipv6 prefix-list PL seq 1",) def test_negation_sub_no_match_uses_normal_negation() -> None: """Commands not matching any negation_sub rule get normal swap_negation.""" driver = _make_driver( [ - NegationSubRule( + NegationRule( + strategy=NegationStrategy.REGEX_SUB, match_rules=(MatchRule(startswith="snmp-server user "),), search=r"(no snmp-server user \S+).*", replace=r"\1", ), ], ) - running = get_hconfig_fast_load( + running = HConfig.from_lines( driver, ("hostname router1",), ) - generated = get_hconfig_fast_load(driver, ()) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no hostname router1",) + generated = HConfig.from_lines(driver, ()) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no hostname router1",) def test_negation_sub_full_remediation() -> None: """Full remediation: removed entry uses truncated negation, kept entry unchanged.""" driver = _make_driver( [ - NegationSubRule( + NegationRule( + strategy=NegationStrategy.REGEX_SUB, match_rules=(MatchRule(startswith="snmp-server user "),), search=r"(no snmp-server user \S+).*", replace=r"\1", ), ], ) - running = get_hconfig_fast_load( + running = HConfig.from_lines( driver, ( "snmp-server user admin auth sha secret", "snmp-server user monitor auth sha secret2", ), ) - generated = get_hconfig_fast_load( + generated = HConfig.from_lines( driver, ("snmp-server user monitor auth sha secret2",), ) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no snmp-server user admin",) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no snmp-server user admin",) -def test_negation_sub_via_v2_options() -> None: - """Negation sub rules loaded via load_hconfig_v2_options work correctly.""" - v2_options: dict[str, object] = { +def test_negation_sub_via_load_driver_rules() -> None: + """Negation sub rules loaded via load_driver_rules work correctly.""" + options: dict[str, object] = { "negation_sub": [ { "lineage": [{"startswith": "snmp-server user "}], @@ -115,11 +120,11 @@ def test_negation_sub_via_v2_options() -> None: }, ], } - driver = load_hconfig_v2_options(v2_options, Platform.GENERIC) - running = get_hconfig_fast_load( + driver = load_driver_rules(options, Platform.GENERIC) + running = HConfig.from_lines( driver, ("snmp-server user admin auth sha secret",), ) - generated = get_hconfig_fast_load(driver, ()) - remediation = running.config_to_get_to(generated) - assert remediation.dump_simple() == ("no snmp-server user admin",) + generated = HConfig.from_lines(driver, ()) + remediation = running.remediation(generated) + assert remediation.to_lines() == ("no snmp-server user admin",) diff --git a/tests/test_driver_nokia_srl.py b/tests/integration/test_nokia_srl.py similarity index 54% rename from tests/test_driver_nokia_srl.py rename to tests/integration/test_nokia_srl.py index 01358a95..4e258c59 100644 --- a/tests/test_driver_nokia_srl.py +++ b/tests/integration/test_nokia_srl.py @@ -1,7 +1,5 @@ -from hier_config import WorkflowRemediation, get_hconfig, get_hconfig_fast_load -from hier_config.child import HConfigChild +from hier_config import HConfig, WorkflowRemediation from hier_config.models import Platform -from hier_config.platforms.nokia_srl.driver import HConfigDriverNokiaSRL def test_nokia_srl_basic_remediation() -> None: @@ -12,119 +10,29 @@ def test_nokia_srl_basic_remediation() -> None: remediation_str = "delete interface ethernet-1/1 subinterface 0 ipv4 admin-state enable address 192.168.1.1/24\nset interface ethernet-1/1 subinterface 0 ipv4 admin-state enable address 192.168.2.1/24" workflow_remediation = WorkflowRemediation( - get_hconfig_fast_load(platform, running_config_str), - get_hconfig_fast_load(platform, generated_config_str), + HConfig.from_lines(platform, running_config_str), + HConfig.from_lines(platform, generated_config_str), ) assert workflow_remediation.remediation_config_filtered_text() == remediation_str -def test_swap_negation_delete_to_set() -> None: - """Test swapping from 'delete' to 'set' prefix.""" - platform = Platform.NOKIA_SRL - driver = HConfigDriverNokiaSRL() - root = get_hconfig(platform) - - child = HConfigChild( - root, "delete interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" - ) - result = driver.swap_negation(child) - - assert ( - result.text - == "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" - ) - assert result.text.startswith("set ") - - -def test_swap_negation_set_to_delete() -> None: - """Test swapping from 'set' to 'delete' prefix.""" - platform = Platform.NOKIA_SRL - driver = HConfigDriverNokiaSRL() - root = get_hconfig(platform) - - child = HConfigChild( - root, "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" - ) - result = driver.swap_negation(child) - - assert ( - result.text - == "delete interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" - ) - assert result.text.startswith("delete ") - - -def test_swap_negation_no_prefix() -> None: - """Test swap_negation when text has neither prefix.""" - driver = HConfigDriverNokiaSRL() - root = get_hconfig(Platform.NOKIA_SRL) - - child = HConfigChild( - root, "interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" - ) - original_text = child.text - - result = driver.swap_negation(child) - assert result.text == original_text - - -def test_declaration_prefix() -> None: - """Test declaration_prefix property.""" - driver = HConfigDriverNokiaSRL() - assert driver.declaration_prefix == "set " - - -def test_negation_prefix() -> None: - """Test negation_prefix property.""" - driver = HConfigDriverNokiaSRL() - assert driver.negation_prefix == "delete " - - -def test_config_preprocessor() -> None: - """Test config_preprocessor with hierarchical SRL config.""" - hierarchical_config = """interface { - ethernet-1/1 { - subinterface 0 { - ipv4 { - admin-state enable - address 192.168.1.1/24 - } - } - } -} -system { - name { - host-name srl-router - } -}""" - - result = HConfigDriverNokiaSRL.config_preprocessor(hierarchical_config) - - assert "set interface ethernet-1/1 subinterface 0 ipv4 admin-state enable" in result - assert ( - "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" - in result - ) - assert "set system name host-name srl-router" in result - - def test_interface_address_addition() -> None: """Test adding an interface address.""" platform = Platform.NOKIA_SRL - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24",), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24", "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.2/24", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.2/24", ) @@ -132,22 +40,22 @@ def test_interface_address_addition() -> None: def test_interface_description_modification() -> None: """Test modifying interface description.""" platform = Platform.NOKIA_SRL - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set interface ethernet-1/1 description Old Description", "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set interface ethernet-1/1 description New Description", "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interface ethernet-1/1 description Old Description", "set interface ethernet-1/1 description New Description", ) @@ -156,19 +64,19 @@ def test_interface_description_modification() -> None: def test_interface_removal() -> None: """Test removing an interface configuration.""" platform = Platform.NOKIA_SRL - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24", "set interface ethernet-1/2 subinterface 0 ipv4 address 10.0.0.1/24", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ("set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interface ethernet-1/2 subinterface 0 ipv4 address 10.0.0.1/24", ) @@ -176,14 +84,14 @@ def test_interface_removal() -> None: def test_network_instance_remediation() -> None: """Test network-instance (VRF) block handling.""" platform = Platform.NOKIA_SRL - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set network-instance default router-id 10.0.0.1", "set network-instance default interface ethernet-1/1.0", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set network-instance default router-id 10.0.0.2", @@ -191,8 +99,8 @@ def test_network_instance_remediation() -> None: "set network-instance mgmt interface mgmt0.0", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete network-instance default router-id 10.0.0.1", "set network-instance default router-id 10.0.0.2", "set network-instance mgmt interface mgmt0.0", @@ -202,14 +110,14 @@ def test_network_instance_remediation() -> None: def test_system_configuration() -> None: """Test system configuration changes.""" platform = Platform.NOKIA_SRL - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set system name host-name old-srl-router", "set system dns network-instance mgmt", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set system name host-name new-srl-router", @@ -217,8 +125,8 @@ def test_system_configuration() -> None: "set system ntp network-instance mgmt", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete system name host-name old-srl-router", "set system name host-name new-srl-router", "set system ntp network-instance mgmt", @@ -228,21 +136,21 @@ def test_system_configuration() -> None: def test_empty_to_basic_config() -> None: """Test building configuration from empty state.""" platform = Platform.NOKIA_SRL - running_config = get_hconfig(platform) - generated_config = get_hconfig_fast_load( + running_config = HConfig.from_text(platform) + generated_config = HConfig.from_lines( platform, ( "set system name host-name srl-router", "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set system name host-name srl-router", "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24", ) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ( + assert future_config.to_lines() == ( "set system name host-name srl-router", "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24", ) @@ -251,19 +159,19 @@ def test_empty_to_basic_config() -> None: def test_routing_policy_configuration() -> None: """Test routing-policy configuration changes.""" platform = Platform.NOKIA_SRL - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("set routing-policy policy accept-all default-action policy-result accept",), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set routing-policy policy accept-all default-action policy-result accept", "set routing-policy policy deny-all default-action policy-result reject", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set routing-policy policy deny-all default-action policy-result reject", ) @@ -271,16 +179,16 @@ def test_routing_policy_configuration() -> None: def test_ipv6_address_configuration() -> None: """Test configuring IPv6 addresses on interfaces.""" platform = Platform.NOKIA_SRL - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("set interface ethernet-1/1 subinterface 0 ipv6 address 2001:db8:1::1/64",), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ("set interface ethernet-1/1 subinterface 0 ipv6 address 2001:db8:2::1/64",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interface ethernet-1/1 subinterface 0 ipv6 address 2001:db8:1::1/64", "set interface ethernet-1/1 subinterface 0 ipv6 address 2001:db8:2::1/64", ) diff --git a/tests/integration/test_remediation.py b/tests/integration/test_remediation.py new file mode 100644 index 00000000..3966f775 --- /dev/null +++ b/tests/integration/test_remediation.py @@ -0,0 +1,828 @@ +"""Integration tests for remediation, future, difference, and sectional overwrite.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from hier_config import ( + FutureReport, + HConfig, + HConfigChild, + WorkflowRemediation, + get_hconfig_driver, +) +from hier_config.models import Platform + + +def test_remediation(platform_a: Platform) -> None: + running_config_hier = HConfig.from_text(platform_a) + interface = running_config_hier.add_child("interface Vlan2") + interface.add_child("ip address 192.168.1.1/24") + generated_config_hier = HConfig.from_text(platform_a) + generated_config_hier.add_child("interface Vlan3") + remediation_config_hier = running_config_hier.remediation( + generated_config_hier, + ) + assert len(tuple(remediation_config_hier.all_children())) == 2 + + +def test_remediation2(platform_a: Platform) -> None: + running_config_hier = HConfig.from_text(platform_a) + running_config_hier.add_child("do not add me") + generated_config_hier = HConfig.from_text(platform_a) + generated_config_hier.add_child("do not add me") + generated_config_hier.add_child("add me") + delta = HConfig.from_text(platform_a) + running_config_hier.remediation( + generated_config_hier, + delta, + ) + assert "do not add me" not in delta.children + assert "add me" in delta.children + + +def test_future_config(platform_a: Platform) -> None: + running_config = HConfig.from_text(platform_a) + running_config.add_children_deep(("a", "aa", "aaa", "aaaa")) + running_config.add_children_deep(("a", "ab", "aba", "abaa")) + config = HConfig.from_text(platform_a) + config.add_children_deep(("a", "ac")) + config.add_children_deep(("a", "no ab")) + config.add_children_deep(("a", "no az")) + + future_config = running_config.future(config) + assert tuple(c.indented_text() for c in future_config.all_children()) == ( + "a", + " ac", # config lines are added first + " no az", + " aa", # self lines not in config are added last + " aaa", + " aaaa", + ) + + +def test_future_preserves_bgp_neighbor_description() -> None: + """Validate Arista BGP neighbors keep untouched descriptions across future/rollback. + + This regression asserts that applying a candidate config via ``future()`` retains + existing neighbor descriptions and the subsequent ``remediation`` rollback only + negates the new commands. + """ + platform = Platform.ARISTA_EOS + running_raw = """router bgp 1 + neighbor 2.2.2.2 description neighbor2 + neighbor 2.2.2.2 remote-as 2 + ! +""" + change_raw = """router bgp 1 + neighbor 3.3.3.3 description neighbor3 + neighbor 3.3.3.3 remote-as 3 +""" + + running_config = HConfig.from_text(platform, running_raw) + change_config = HConfig.from_text(platform, change_raw) + + future_config = running_config.future(change_config) + expected_future = ( + "router bgp 1", + " neighbor 3.3.3.3 description neighbor3", + " neighbor 3.3.3.3 remote-as 3", + " neighbor 2.2.2.2 description neighbor2", + " neighbor 2.2.2.2 remote-as 2", + " exit", + ) + assert future_config.to_lines(sectional_exiting=True) == expected_future + + rollback_config = future_config.remediation(running_config) + expected_rollback = ( + "router bgp 1", + " no neighbor 3.3.3.3 description neighbor3", + " no neighbor 3.3.3.3 remote-as 3", + " exit", + ) + assert rollback_config.to_lines(sectional_exiting=True) == expected_rollback + + +def test_idempotent_commands() -> None: + platform = Platform.HP_PROCURVE + config_a = HConfig.from_text(platform) + config_b = HConfig.from_text(platform) + interface_name = "interface 1/1" + config_a.add_children_deep((interface_name, "untagged vlan 1")) + config_b.add_children_deep((interface_name, "untagged vlan 2")) + interface = config_a.remediation(config_b).get_child(equals=interface_name) + assert interface is not None + assert interface.get_child(equals="untagged vlan 2") + assert len(interface.children) == 1 + + +def test_idempotent_commands2() -> None: + platform = Platform.CISCO_IOS + config_a = HConfig.from_text(platform) + config_b = HConfig.from_text(platform) + interface_name = "interface 1/1" + config_a.add_children_deep((interface_name, "authentication host-mode multi-auth")) + config_b.add_children_deep( + (interface_name, "authentication host-mode multi-domain"), + ) + interface = config_a.remediation(config_b).get_child(equals=interface_name) + assert interface is not None + assert interface.get_child(equals="authentication host-mode multi-domain") + assert len(interface.children) == 1 + + +def test_future_config_no_command_in_source() -> None: + platform = Platform.HP_PROCURVE + running_config = HConfig.from_text(platform) + generated_config = HConfig.from_text(platform) + generated_config.add_child("no service dhcp") + + remediation_config = running_config.remediation(generated_config) + future_config = running_config.future(remediation_config) + assert len(future_config.children) == 1 + assert future_config.get_child(equals="no service dhcp") + assert not tuple(future_config.unified_diff(generated_config)) + rollback_config = future_config.remediation(running_config) + assert len(rollback_config.children) == 1 + assert rollback_config.get_child(equals="service dhcp") + calculated_running_config = future_config.future(rollback_config) + assert not calculated_running_config.children + assert not tuple(calculated_running_config.unified_diff(running_config)) + + +def test_sectional_overwrite() -> None: + platform = Platform.CISCO_XR + # There is a sectional_overwrite rules in the CISCO_XR driver for "template". + running_config = HConfig.from_lines(platform, "template test\n a\n b") + generated_config = HConfig.from_lines(platform, "template test\n a") + expected_remediation_config = HConfig.from_lines( + platform, "no template test\ntemplate test\n a" + ) + workflow_remediation = WorkflowRemediation(running_config, generated_config) + remediation_config = workflow_remediation.remediation_config + assert remediation_config == expected_remediation_config + + +def test_sectional_overwrite_no_negate() -> None: + platform = Platform.CISCO_XR + running_config = HConfig.from_lines(platform, "as-path-set test\n a\n b") + generated_config = HConfig.from_lines(platform, "as-path-set test\n a") + expected_remediation_config = HConfig.from_lines(platform, "as-path-set test\n a") + workflow_remediation = WorkflowRemediation(running_config, generated_config) + remediation_config = workflow_remediation.remediation_config + assert remediation_config == expected_remediation_config + + +def test_sectional_overwrite_no_negate2() -> None: + platform = Platform.CISCO_XR + running_config = HConfig.from_lines( + platform, + "route-policy test\n duplicate\n not_duplicate1\n duplicate\n not_duplicate2", + ) + generated_config = HConfig.from_lines( + platform, "route-policy test\n duplicate\n not_duplicate1" + ) + expected_remediation_config = HConfig.from_lines( + platform, "route-policy test\n duplicate\n not_duplicate1" + ) + workflow_remediation = WorkflowRemediation(running_config, generated_config) + remediation_config = workflow_remediation.remediation_config + assert remediation_config == expected_remediation_config + + +def test_overwrite_with_negate() -> None: + platform = Platform.CISCO_XR + running_config = HConfig.from_lines( + platform, "route-policy test\n duplicate\n not_duplicate\n duplicate" + ) + generated_config = HConfig.from_lines( + platform, "route-policy test\n duplicate\n not_duplicate" + ) + expected_config = HConfig.from_lines( + platform, + "no route-policy test\nroute-policy test\n duplicate\n not_duplicate", + ) + delta_config = HConfig.from_text(platform) + running_config.children["route-policy test"].overwrite_with( + generated_config.children["route-policy test"], delta_config + ) + assert delta_config == expected_config + + +def test_overwrite_with_no_negate() -> None: + platform = Platform.CISCO_XR + running_config = HConfig.from_lines( + platform, + "route-policy test\n duplicate\n not-duplicate\n duplicate\n duplicate", + ) + generated_config = HConfig.from_lines( + platform, "route-policy test\n duplicate\n not-duplicate\n duplicate" + ) + expected_config = HConfig.from_lines( + platform, + "route-policy test\n duplicate\n not-duplicate\n duplicate", + ) + delta_config = HConfig.from_text(platform) + running_config.children["route-policy test"].overwrite_with( + generated_config.children["route-policy test"], delta_config, negate=False + ) + assert delta_config == expected_config + + +def test_remediation_parent_identity() -> None: + interface_vlan2 = "interface Vlan2" + platform = Platform.CISCO_IOS + running_config_hier = HConfig.from_text(platform) + running_config_hier.add_children_deep( + (interface_vlan2, "ip address 192.168.1.1/24") + ) + generated_config_hier = HConfig.from_text(platform) + generated_config_hier.add_child(interface_vlan2) + remediation_config_hier = running_config_hier.remediation( + generated_config_hier, + ) + remediation_config_interface = remediation_config_hier.get_child( + equals=interface_vlan2 + ) + assert remediation_config_interface + assert id(remediation_config_interface.parent) == id(remediation_config_hier) + assert id(remediation_config_interface.root) == id(remediation_config_hier) + + +def test_difference1(platform_a: Platform) -> None: + rc = ("a", " a1", " a2", " a3", "b") + step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") + rc_hier = HConfig.from_text(get_hconfig_driver(platform_a), "\n".join(rc)) + + difference = HConfig.from_text( + get_hconfig_driver(platform_a), "\n".join(step) + ).difference(rc_hier) + difference_children = tuple( + c.indented_text() for c in difference.all_children_sorted() + ) + + assert len(difference_children) == 6 + assert "c" in difference.children + assert "d" in difference.children + difference_a = difference.get_child(equals="a") + assert isinstance(difference_a, HConfigChild) + assert "a4" in difference_a.children + assert "a5" in difference_a.children + difference_d = difference.get_child(equals="d") + assert isinstance(difference_d, HConfigChild) + assert "d1" in difference_d.children + + +def test_difference2() -> None: + platform = Platform.CISCO_IOS + rc = ("a", " a1", " a2", " a3", "b") + step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") + rc_hier = HConfig.from_text(get_hconfig_driver(platform), "\n".join(rc)) + step_hier = HConfig.from_text(get_hconfig_driver(platform), "\n".join(step)) + + difference_children = tuple( + c.indented_text() for c in step_hier.difference(rc_hier).all_children_sorted() + ) + assert len(difference_children) == 6 + + +def test_difference3() -> None: + platform = Platform.CISCO_IOS + rc = ("ip access-list extended test", " 10 a", " 20 b") + step = ("ip access-list extended test", " 10 a", " 20 b", " 30 c") + rc_hier = HConfig.from_text(get_hconfig_driver(platform), "\n".join(rc)) + step_hier = HConfig.from_text(get_hconfig_driver(platform), "\n".join(step)) + + difference_children = tuple( + c.indented_text() for c in step_hier.difference(rc_hier).all_children_sorted() + ) + assert difference_children == ("ip access-list extended test", " 30 c") + + +def test_difference_with_acl_none_target() -> None: + """Test _difference with ACL when target_acl_children is None.""" + platform = Platform.CISCO_IOS + running_config = HConfig.from_text(platform) + + acl = running_config.add_child("ip access-list extended test") + acl.add_child("10 permit ip any any") + target_config = HConfig.from_text(platform) + difference = running_config.difference(target_config) + + assert difference.get_child(equals="ip access-list extended test") is not None + + +def test_difference_with_negation() -> None: + """Test _difference with negation prefix.""" + platform = Platform.CISCO_IOS + running_config = HConfig.from_text(platform) + running_config.add_child("interface GigabitEthernet0/0") + running_config.add_child("logging console") + generated_config = HConfig.from_text(platform) + generated_config.add_child("interface GigabitEthernet0/0") + difference = running_config.difference(generated_config) + + assert difference.get_child(equals="logging console") is not None + + +def test_difference_with_default_prefix() -> None: + """Test _difference skips lines with 'default' prefix.""" + platform = Platform.CISCO_IOS + running_config = HConfig.from_text(platform) + running_config.add_child("interface GigabitEthernet0/0") + running_config.add_child("default interface GigabitEthernet0/1") + generated_config = HConfig.from_text(platform) + generated_config.add_child("interface GigabitEthernet0/0") + difference = running_config.difference(generated_config) + + assert difference.get_child(startswith="default") is None + + +def test_future_with_negated_command_in_config() -> None: + """Test _future with negated command.""" + platform = Platform.CISCO_IOS + running_config = HConfig.from_text(platform) + running_config.add_child("interface GigabitEthernet0/0") + remediation_config = HConfig.from_text(platform) + remediation_config.add_child("no interface GigabitEthernet0/0") + future_config = running_config.future(remediation_config) + + assert future_config.get_child(equals="interface GigabitEthernet0/0") is None + + +def test_future_with_negation_prefix_match() -> None: + """Test _future when negated form exists.""" + platform = Platform.CISCO_IOS + running_config = HConfig.from_text(platform) + running_config.add_child("no logging console") + remediation_config = HConfig.from_text(platform) + remediation_config.add_child("logging console") + future_config = running_config.future(remediation_config) + + assert future_config.get_child(equals="logging console") is not None + assert future_config.get_child(equals="no logging console") is None + + +def test_future_with_negation_prefix() -> None: + """Test _future with negation prefix in self.""" + platform = Platform.CISCO_IOS + running_config = HConfig.from_text(platform) + running_config.add_child("no ip routing") + remediation_config = HConfig.from_text(platform) + remediation_config.add_child("ip routing") + future_config = running_config.future(remediation_config) + + assert future_config.get_child(equals="ip routing") is None + assert future_config.get_child(equals="no ip routing") is None + + +def test_future_self_child_not_in_negated_or_recursed() -> None: + """Test _future when self_child is not in negated_or_recursed.""" + platform = Platform.CISCO_IOS + running_config = HConfig.from_text(platform) + running_config.add_child("hostname router1") + running_config.add_child("interface GigabitEthernet0/0") + remediation_config = HConfig.from_text(platform) + remediation_config.add_child("hostname router2") + future_config = running_config.future(remediation_config) + + assert future_config.get_child(equals="hostname router2") is not None + assert future_config.get_child(equals="interface GigabitEthernet0/0") is not None + + +def test_future_with_idempotent_command() -> None: + """Test _future with idempotent command.""" + platform = Platform.HP_PROCURVE + running_config = HConfig.from_text(platform) + interface = running_config.add_child("interface 1/1") + interface.add_child("untagged vlan 1") + remediation_config = HConfig.from_text(platform) + remediation_interface = remediation_config.add_child("interface 1/1") + remediation_interface.add_child("untagged vlan 2") + future_config = running_config.future(remediation_config) + future_interface = future_config.get_child(equals="interface 1/1") + + assert future_interface is not None + assert future_interface.get_child(equals="untagged vlan 2") is not None + + +def test_sectional_exit_text_parent_level_cisco_xr() -> None: + """Test sectional_exit_text_parent_level returns True for Cisco XR configs with parent-level exit text.""" + platform = Platform.CISCO_XR + config = HConfig.from_text(platform) + + # Test route-policy which has exit_text_parent_level=True + route_policy = config.add_child("route-policy TEST") + assert route_policy.sectional_exit_text_parent_level is True + + # Test prefix-set which has exit_text_parent_level=True + prefix_set = config.add_child("prefix-set TEST") + assert prefix_set.sectional_exit_text_parent_level is True + + # Test policy-map which has exit_text_parent_level=True + policy_map = config.add_child("policy-map TEST") + assert policy_map.sectional_exit_text_parent_level is True + + # Test class-map which has exit_text_parent_level=True + class_map = config.add_child("class-map TEST") + assert class_map.sectional_exit_text_parent_level is True + + # Test community-set which has exit_text_parent_level=True + community_set = config.add_child("community-set TEST") + assert community_set.sectional_exit_text_parent_level is True + + # Test extcommunity-set which has exit_text_parent_level=True + extcommunity_set = config.add_child("extcommunity-set TEST") + assert extcommunity_set.sectional_exit_text_parent_level is True + + # Test template which has exit_text_parent_level=True + template = config.add_child("template TEST") + assert template.sectional_exit_text_parent_level is True + + +def test_sectional_exit_text_parent_level_cisco_xr_false() -> None: + """Test sectional_exit_text_parent_level returns False for Cisco XR configs without parent-level exit text.""" + platform = Platform.CISCO_XR + config = HConfig.from_text(platform) + + # Test interface which has exit_text_parent_level=False (default) + interface = config.add_child("interface GigabitEthernet0/0/0/0") + assert interface.sectional_exit_text_parent_level is False + + # Test router bgp which has exit_text_parent_level=False (default) + router_bgp = config.add_child("router bgp 65000") + assert router_bgp.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_cisco_ios() -> None: + """Test sectional_exit_text_parent_level returns False for standard Cisco IOS configs.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + + # Cisco IOS interfaces don't have exit_text_parent_level=True + interface = config.add_child("interface GigabitEthernet0/0") + assert interface.sectional_exit_text_parent_level is False + + # Cisco IOS router configurations don't have exit_text_parent_level=True + router = config.add_child("router ospf 1") + assert router.sectional_exit_text_parent_level is False + + # Standard configuration sections + line = config.add_child("line vty 0 4") + assert line.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_no_match() -> None: + """Test sectional_exit_text_parent_level returns False when no rules match.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + + # A child that doesn't match any sectional_exiting rules + hostname = config.add_child("hostname TEST") + assert hostname.sectional_exit_text_parent_level is False + + # A simple config line without children + ntp = config.add_child("ntp server 10.0.0.1") + assert ntp.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_with_nested_children() -> None: + """Test sectional_exit_text_parent_level with nested child configurations.""" + platform = Platform.CISCO_XR + config = HConfig.from_text(platform) + + # Create a route-policy with nested children + route_policy = config.add_child("route-policy TEST") + if_statement = route_policy.add_child("if destination in (192.0.2.0/24) then") + + # Parent (route-policy) should have exit_text_parent_level=True + assert route_policy.sectional_exit_text_parent_level is True + + # Nested child should not match the sectional_exiting rule for route-policy + assert if_statement.sectional_exit_text_parent_level is False + + +def test_sectional_exit_text_parent_level_indentation_in_lines() -> None: + """Test that sectional_exit_text_parent_level affects indentation in lines output.""" + platform = Platform.CISCO_XR + config = HConfig.from_text(platform) + + # Create a route-policy with children - exit text should be at parent level (depth - 1) + route_policy = config.add_child("route-policy TEST") + route_policy.add_child("set local-preference 200") + route_policy.add_child("pass") + + # Get lines with sectional_exiting=True + lines = list(config.lines(sectional_exiting=True)) + + # The last line should be "end-policy" at depth 0 (parent level) + # route-policy is at depth 1, so exit text at depth 0 means no indentation + assert lines[-1] == "end-policy" + assert not lines[-1].startswith(" ") + + +def test_sectional_exit_text_parent_level_generic_platform() -> None: + """Test sectional_exit_text_parent_level with generic platform.""" + platform = Platform.GENERIC + config = HConfig.from_text(platform) + + # Generic platform has no specific sectional_exiting rules with parent_level=True + section = config.add_child("section test") + assert section.sectional_exit_text_parent_level is False + + +def test_remediation_does_not_mutate_inputs() -> None: + """remediation() must not modify the running or generated configs (#224).""" + running_text = ( + "hostname old\ninterface GigabitEthernet0/0\n description keep\n shutdown\n" + ) + generated_text = ( + "hostname new\n" + "interface GigabitEthernet0/0\n" + " description keep\n" + " ip address 10.0.0.1 255.255.255.0\n" + ) + running_config = HConfig.from_text(Platform.CISCO_IOS, running_text) + generated_config = HConfig.from_text(Platform.CISCO_IOS, generated_text) + running_before = running_config.to_lines() + generated_before = generated_config.to_lines() + + running_config.remediation(generated_config) + + assert running_config.to_lines() == running_before + assert generated_config.to_lines() == generated_before + + +def test_future_value_carrying_negation_on_idempotent_line() -> None: + """A negation matching an existing line removes it and does not survive (#269).""" + running_config = HConfig.from_text( + Platform.ARISTA_EOS, + "router bgp 65000\n" + " neighbor 10.0.0.1 peer group PEERS\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 peer group PEERS\n" + " no neighbor 10.0.0.1 description spine1\n", + ) + future_config = running_config.future(change) + + assert future_config.to_lines() == ("router bgp 65000",) + + +def test_future_value_differing_negation_replaces_via_idempotency() -> None: + """A stale-valued negation displaces the tracked line but stays visible. + + Idempotency rules declare interchangeable forms of one setting, so the + negation replaces the matched line; keeping it in the render preserves + the did-not-apply-cleanly signal (#269). + """ + 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 = running_config.future(change) + + assert future_config.to_lines() == ( + "router bgp 65000", + " no neighbor 10.0.0.1 description stale-value", + ) + + +def test_future_bare_shorthand_negation_removes_valued_line() -> None: + """`no description` removes `description foo` as devices do (#269).""" + running_config = HConfig.from_text( + Platform.ARISTA_EOS, + "interface Ethernet1\n description foo\n switchport access vlan 10\n", + ) + change = HConfig.from_text( + Platform.ARISTA_EOS, "interface Ethernet1\n no description\n" + ) + future_config = running_config.future(change) + + assert future_config.to_lines() == ( + "interface Ethernet1", + " switchport access vlan 10", + ) + + +def test_future_unmatched_negation_is_kept_as_signal() -> None: + """A negation matching nothing still surfaces in the render (#269).""" + 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 = running_config.future(change) + + assert future_config.to_lines() == ( + "interface Ethernet1", + " no description", + " switchport access vlan 10", + ) + + +def test_future_prune_emptied_parents() -> None: + """Removing the last child can prune the emptied ancestors (#269).""" + running_config = HConfig.from_text( + Platform.CISCO_XR, + "router static\n address-family ipv4 unicast\n 192.0.2.0/24 Null0\n", + ) + change = HConfig.from_text( + Platform.CISCO_XR, + "router static\n address-family ipv4 unicast\n no 192.0.2.0/24 Null0\n", + ) + + assert not running_config.future(change, prune_empty_branches=True).to_lines() + # Default keeps the emptied parents (spurious-diff behavior is opt-out only). + assert running_config.future(change).to_lines() == ( + "router static", + " address-family ipv4 unicast", + ) + + +def test_future_prune_keeps_originally_empty_parents() -> None: + """Pruning only removes parents that had children in the running config (#269).""" + running_config = HConfig.from_text( + Platform.CISCO_XR, "interface GigabitEthernet0/0/0/0\n" + ) + change = HConfig.from_text(Platform.CISCO_XR, "hostname r1\n") + future_config = running_config.future(change, prune_empty_branches=True) + + assert future_config.to_lines() == ( + "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] diff --git a/tests/test_unused_objects.py b/tests/integration/test_unused_objects.py similarity index 91% rename from tests/test_unused_objects.py rename to tests/integration/test_unused_objects.py index f784bdb0..3b91d2d4 100644 --- a/tests/test_unused_objects.py +++ b/tests/integration/test_unused_objects.py @@ -1,4 +1,4 @@ -from hier_config import get_hconfig_fast_load +from hier_config import HConfig from hier_config.models import ( MatchRule, Platform, @@ -7,7 +7,7 @@ ) from hier_config.platforms.driver_base import HConfigDriverRules from hier_config.platforms.generic.driver import HConfigDriverGeneric -from hier_config.utils import load_hconfig_v2_options +from hier_config.utils import load_driver_rules def _make_driver( @@ -35,7 +35,7 @@ def test_unused_acl_detected() -> None: ), ], ) - config = get_hconfig_fast_load( + config = HConfig.from_lines( driver, ( "ipv4 access-list USED_ACL", @@ -66,7 +66,7 @@ def test_used_acl_not_detected() -> None: ), ], ) - config = get_hconfig_fast_load( + config = HConfig.from_lines( driver, ( "ipv4 access-list MY_ACL", @@ -81,7 +81,7 @@ def test_used_acl_not_detected() -> None: def test_no_unused_object_rules_yields_nothing() -> None: """A driver with no unused_objects rules yields nothing.""" - config = get_hconfig_fast_load( + config = HConfig.from_lines( Platform.GENERIC, ("hostname router1",), ) @@ -109,7 +109,7 @@ def test_multiple_reference_locations() -> None: ), ], ) - config = get_hconfig_fast_load( + config = HConfig.from_lines( driver, ( "route-policy USED_IN_BGP", @@ -125,9 +125,9 @@ def test_multiple_reference_locations() -> None: assert unused == ["route-policy UNUSED_POLICY"] -def test_unused_objects_via_v2_options() -> None: - """Test unused object detection loaded via load_hconfig_v2_options.""" - v2_options: dict[str, object] = { +def test_unused_objects_via_load_driver_rules() -> None: + """Test unused object detection loaded via load_driver_rules.""" + options: dict[str, object] = { "unused_objects": [ { "lineage": [{"startswith": "ipv4 access-list "}], @@ -141,8 +141,8 @@ def test_unused_objects_via_v2_options() -> None: }, ], } - driver = load_hconfig_v2_options(v2_options, Platform.CISCO_XR) - config = get_hconfig_fast_load( + driver = load_driver_rules(options, Platform.CISCO_XR) + config = HConfig.from_lines( driver, ( "ipv4 access-list APPLIED_ACL", @@ -174,7 +174,7 @@ def test_name_re_without_match_skips_definition() -> None: ), ], ) - config = get_hconfig_fast_load( + config = HConfig.from_lines( driver, ( "ipv4 access-list MY_ACL", diff --git a/tests/test_various.py b/tests/integration/test_various.py similarity index 65% rename from tests/test_various.py rename to tests/integration/test_various.py index 3c0257b2..d25b2b5b 100644 --- a/tests/test_various.py +++ b/tests/integration/test_various.py @@ -1,4 +1,4 @@ -from hier_config import get_hconfig, get_hconfig_driver +from hier_config import HConfig, get_hconfig_driver from hier_config.models import Platform @@ -12,9 +12,11 @@ def test_issue104() -> None: ) platform = Platform.CISCO_NXOS - running_config = get_hconfig(get_hconfig_driver(platform), running_config_raw) - generated_config = get_hconfig(get_hconfig_driver(platform), generated_config_raw) - remediation_config = running_config.config_to_get_to(generated_config) + running_config = HConfig.from_text(get_hconfig_driver(platform), running_config_raw) + generated_config = HConfig.from_text( + get_hconfig_driver(platform), generated_config_raw + ) + remediation_config = running_config.remediation(generated_config) expected_rem_lines = { "no tacacs-server deadtime 3", "no tacacs-server host 192.168.1.99 key 7 Test12345", @@ -22,6 +24,6 @@ def test_issue104() -> None: "tacacs-server host 192.168.100.98 key 0 test135 timeout 3", } remediation_lines = { - line.cisco_style_text() for line in remediation_config.all_children() + line.indented_text() for line in remediation_config.all_children() } assert expected_rem_lines == remediation_lines diff --git a/tests/test_driver_vyos.py b/tests/integration/test_vyos.py similarity index 53% rename from tests/test_driver_vyos.py rename to tests/integration/test_vyos.py index 3b1af754..2d8c99dd 100644 --- a/tests/test_driver_vyos.py +++ b/tests/integration/test_vyos.py @@ -1,7 +1,5 @@ -from hier_config import WorkflowRemediation, get_hconfig, get_hconfig_fast_load -from hier_config.child import HConfigChild +from hier_config import HConfig, WorkflowRemediation from hier_config.models import Platform -from hier_config.platforms.vyos.driver import HConfigDriverVYOS def test_vyos_basic_remediation() -> None: @@ -12,110 +10,29 @@ def test_vyos_basic_remediation() -> None: remediation_str = "delete interfaces ethernet eth0 address 192.168.1.1/24\nset interfaces ethernet eth0 address 192.168.2.1/24" workflow_remediation = WorkflowRemediation( - get_hconfig_fast_load(platform, running_config_str), - get_hconfig_fast_load(platform, generated_config_str), + HConfig.from_lines(platform, running_config_str), + HConfig.from_lines(platform, generated_config_str), ) assert workflow_remediation.remediation_config_filtered_text() == remediation_str -def test_swap_negation_delete_to_set() -> None: - """Test swapping from 'delete' to 'set' prefix (covers lines 9-11).""" - platform = Platform.VYOS - driver = HConfigDriverVYOS() - root = get_hconfig(platform) - - # Create a child with 'delete' prefix - child = HConfigChild(root, "delete interfaces ethernet eth0 address 192.168.1.1/24") - - # Swap negation should convert to 'set' - result = driver.swap_negation(child) - - assert result.text == "set interfaces ethernet eth0 address 192.168.1.1/24" - assert result.text.startswith("set ") - - -def test_swap_negation_set_to_delete() -> None: - """Test swapping from 'set' to 'delete' prefix (covers lines 10, 12).""" - platform = Platform.VYOS - driver = HConfigDriverVYOS() - root = get_hconfig(platform) - - # Create a child with 'set' prefix - child = HConfigChild(root, "set interfaces ethernet eth0 address 192.168.1.1/24") - - # Swap negation should convert to 'delete' - result = driver.swap_negation(child) - - assert result.text == "delete interfaces ethernet eth0 address 192.168.1.1/24" - assert result.text.startswith("delete ") - - -def test_swap_negation_no_prefix() -> None: - """Test swap_negation behavior when text has neither prefix (covers VyOS-specific behavior).""" - platform = Platform.VYOS - driver = HConfigDriverVYOS() - root = get_hconfig(platform) - - # Create a child without proper prefix - child = HConfigChild(root, "interfaces ethernet eth0 address 192.168.1.1/24") - original_text = child.text - - # VyOS driver doesn't raise an error, it just returns the child unchanged - result = driver.swap_negation(child) - - # Text should remain unchanged since neither if/elif matched - assert result.text == original_text - - -def test_declaration_prefix() -> None: - """Test declaration_prefix property (covers line 18).""" - driver = HConfigDriverVYOS() - assert driver.declaration_prefix == "set " - - -def test_negation_prefix() -> None: - """Test negation_prefix property (covers line 22).""" - driver = HConfigDriverVYOS() - assert driver.negation_prefix == "delete " - - -def test_config_preprocessor() -> None: - """Test config_preprocessor with hierarchical VyOS config (covers line 26).""" - hierarchical_config = """interfaces { - ethernet eth0 { - address 192.168.1.1/24 - description "WAN Interface" - } -} -system { - host-name vyos-router -}""" - - result = HConfigDriverVYOS.config_preprocessor(hierarchical_config) - - # Should convert to set commands - assert "set interfaces ethernet eth0 address 192.168.1.1/24" in result - assert "set interfaces ethernet eth0 description" in result - assert "set system host-name vyos-router" in result - - def test_interface_address_addition_scenario() -> None: """Test adding an interface address.""" platform = Platform.VYOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("set interfaces ethernet eth0 address 192.168.1.1/24",), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set interfaces ethernet eth0 address 192.168.1.1/24", "set interfaces ethernet eth0 address 192.168.1.2/24", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set interfaces ethernet eth0 address 192.168.1.2/24", ) @@ -123,22 +40,22 @@ def test_interface_address_addition_scenario() -> None: def test_interface_description_modification_scenario() -> None: """Test modifying interface description.""" platform = Platform.VYOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set interfaces ethernet eth0 address 192.168.1.1/24", "set interfaces ethernet eth0 description Old Description", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set interfaces ethernet eth0 address 192.168.1.1/24", "set interfaces ethernet eth0 description New Description", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces ethernet eth0 description Old Description", "set interfaces ethernet eth0 description New Description", ) @@ -147,7 +64,7 @@ def test_interface_description_modification_scenario() -> None: def test_interface_removal_scenario() -> None: """Test removing an interface configuration.""" platform = Platform.VYOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set interfaces ethernet eth0 address 192.168.1.1/24", @@ -155,15 +72,15 @@ def test_interface_removal_scenario() -> None: "set interfaces ethernet eth1 address 10.0.0.1/24", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set interfaces ethernet eth0 address 192.168.1.1/24", "set interfaces ethernet eth0 description WAN Interface", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces ethernet eth1 address 10.0.0.1/24", ) @@ -171,14 +88,14 @@ def test_interface_removal_scenario() -> None: def test_system_configuration_scenario() -> None: """Test system configuration changes.""" platform = Platform.VYOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set system host-name old-vyos-router", "set system domain-name example.com", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set system host-name new-vyos-router", @@ -186,8 +103,8 @@ def test_system_configuration_scenario() -> None: "set system time-zone America/New_York", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete system host-name old-vyos-router", "set system host-name new-vyos-router", "set system time-zone America/New_York", @@ -197,21 +114,21 @@ def test_system_configuration_scenario() -> None: def test_empty_to_basic_config_scenario() -> None: """Test building configuration from empty state.""" platform = Platform.VYOS - running_config = get_hconfig(platform) - generated_config = get_hconfig_fast_load( + running_config = HConfig.from_text(platform) + generated_config = HConfig.from_lines( platform, ( "set system host-name test-router", "set interfaces ethernet eth0 address 192.168.1.1/24", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set system host-name test-router", "set interfaces ethernet eth0 address 192.168.1.1/24", ) future_config = running_config.future(remediation_config) - assert future_config.dump_simple() == ( + assert future_config.to_lines() == ( "set system host-name test-router", "set interfaces ethernet eth0 address 192.168.1.1/24", ) @@ -220,7 +137,7 @@ def test_empty_to_basic_config_scenario() -> None: def test_nat_configuration_scenario() -> None: """Test NAT configuration changes.""" platform = Platform.VYOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set nat source rule 10 outbound-interface eth0", @@ -228,7 +145,7 @@ def test_nat_configuration_scenario() -> None: "set nat source rule 10 translation address masquerade", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set nat source rule 10 outbound-interface eth0", @@ -236,8 +153,8 @@ def test_nat_configuration_scenario() -> None: "set nat source rule 10 translation address masquerade", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete nat source rule 10 source address 192.168.1.0/24", "set nat source rule 10 source address 192.168.2.0/24", ) @@ -246,7 +163,7 @@ def test_nat_configuration_scenario() -> None: def test_firewall_rule_scenario() -> None: """Test firewall rule configuration.""" platform = Platform.VYOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ( "set firewall name WAN_LOCAL default-action drop", @@ -254,7 +171,7 @@ def test_firewall_rule_scenario() -> None: "set firewall name WAN_LOCAL rule 10 state established enable", ), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ( "set firewall name WAN_LOCAL default-action drop", @@ -263,8 +180,8 @@ def test_firewall_rule_scenario() -> None: "set firewall name WAN_LOCAL rule 10 state related enable", ), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "set firewall name WAN_LOCAL rule 10 state related enable", ) @@ -272,16 +189,16 @@ def test_firewall_rule_scenario() -> None: def test_ipv6_address_configuration_scenario() -> None: """Test configuring IPv6 addresses on interfaces.""" platform = Platform.VYOS - running_config = get_hconfig_fast_load( + running_config = HConfig.from_lines( platform, ("set interfaces ethernet eth0 address 2001:db8:1::1/64",), ) - generated_config = get_hconfig_fast_load( + generated_config = HConfig.from_lines( platform, ("set interfaces ethernet eth0 address 2001:db8:2::1/64",), ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( + remediation_config = running_config.remediation(generated_config) + assert remediation_config.to_lines() == ( "delete interfaces ethernet eth0 address 2001:db8:1::1/64", "set interfaces ethernet eth0 address 2001:db8:2::1/64", ) diff --git a/tests/test_hier_config.py b/tests/test_hier_config.py deleted file mode 100644 index a42acbdb..00000000 --- a/tests/test_hier_config.py +++ /dev/null @@ -1,2307 +0,0 @@ -"""Tests for hier_config functionality.""" -# pylint: disable=too-many-lines - -import tempfile -import types -from pathlib import Path - -import pytest - -from hier_config import ( - HConfigChild, - WorkflowRemediation, - get_hconfig, - get_hconfig_driver, - get_hconfig_fast_load, - get_hconfig_from_dump, -) -from hier_config.exceptions import DuplicateChildError -from hier_config.models import IdempotentCommandsRule, Instance, MatchRule, Platform -from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS - - -def test_bool(platform_a: Platform) -> None: - config = get_hconfig(platform_a) - assert config - - -def test_hash(platform_a: Platform) -> None: - config = get_hconfig_fast_load(platform_a, ("interface 1/1", " untagged vlan 5")) - assert hash(config) - - -def test_merge(platform_a: Platform, platform_b: Platform) -> None: - hier1 = get_hconfig(platform_a) - hier1.add_child("interface Vlan2") - hier2 = get_hconfig(platform_b) - hier2.add_child("interface Vlan3") - - assert len(tuple(hier1.all_children())) == 1 - assert len(tuple(hier2.all_children())) == 1 - - hier1.merge(hier2) - - assert len(tuple(hier1.all_children())) == 2 - - -def test_load_from_file(platform_a: Platform) -> None: - config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" - - with tempfile.NamedTemporaryFile( - mode="r+", - delete=False, - encoding="utf8", - ) as myfile: - myfile.file.write(config) - myfile.file.flush() - myfile.close() - hier = get_hconfig(get_hconfig_driver(platform_a), Path(myfile.name)) - Path(myfile.name).unlink() - - assert len(tuple(hier.all_children())) == 2 - - -def test_load_from_config_text(platform_a: Platform) -> None: - config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" - hier = get_hconfig(get_hconfig_driver(platform_a), config) - assert len(tuple(hier.all_children())) == 2 - - -def test_dump_and_load_from_dump_and_compare(platform_a: Platform) -> None: - hier_pre_dump = get_hconfig(platform_a) - b2 = hier_pre_dump.add_children_deep(("a1", "b2")) - - b2.order_weight = 400 - b2.tags_add("test") - b2.comments.add("test comment") - b2.new_in_config = True - - dump = hier_pre_dump.dump() - hier_post_dump = get_hconfig_from_dump(hier_pre_dump.driver, dump) - - assert hier_pre_dump == hier_post_dump - - -def test_add_ancestor_copy_of(platform_a: Platform) -> None: - source_config = get_hconfig(platform_a) - ipv4_address = source_config.add_children_deep( - ("interface Vlan2", "ip address 192.168.1.0/24") - ) - destination_config = get_hconfig(platform_a) - destination_config.add_ancestor_copy_of(ipv4_address) - - assert len(tuple(destination_config.all_children())) == 2 - assert isinstance(destination_config.all_children(), types.GeneratorType) - - -def test_depth(platform_a: Platform) -> None: - ip_address = get_hconfig(platform_a).add_children_deep( - ("interface Vlan2", "ip address 192.168.1.1 255.255.255.0"), - ) - assert ip_address.depth() == 2 - - -def test_get_child(platform_a: Platform) -> None: - hier = get_hconfig(platform_a) - hier.add_child("interface Vlan2") - child = hier.get_child(equals="interface Vlan2") - assert child is not None - assert child.text == "interface Vlan2" - - -def test_get_child_deep(platform_a: Platform) -> None: - hier = get_hconfig(platform_a) - interface1 = hier.add_child("interface Vlan1") - interface1.add_children( - ("ip address 192.168.1.1 255.255.255.0", "description asdf1"), - ) - interface2 = hier.add_child("interface Vlan2") - interface2.add_children( - ("ip address 192.168.2.1 255.255.255.0", "description asdf2"), - ) - interface3 = hier.add_child("interface Vlan3") - interface3.add_children( - ("ip address 192.168.3.1 255.255.255.0", "description asdf3"), - ) - - # search all 'interface vlan' interfaces for 'ip address' - children = tuple( - hier.get_children_deep( - ( - MatchRule(startswith="interface Vlan"), - MatchRule(startswith="ip address "), - ), - ), - ) - assert len(children) == 3 - children = tuple( - hier.get_children_deep( - ( - MatchRule(startswith="interface Vlan1"), - MatchRule(startswith="ip address "), - ), - ), - ) - assert len(children) == 1 - children = tuple( - hier.get_children_deep( - ( - MatchRule(equals="interface Vlan2"), - MatchRule(equals="ip address 192.168.2.1 255.255.255.0"), - ), - ), - ) - assert len(children) == 1 - - -def test_child_deep2() -> None: - config = get_hconfig(Platform.CISCO_IOS) - - config.add_children_deep(("a", "b")) - config.add_children_deep(("a", "b1")) - config.add_children_deep(("a", "b2")) - - assert ( - len( - tuple( - config.get_children_deep( - (MatchRule(startswith="a"), MatchRule(startswith="b")), - ), - ), - ) - == 3 - ) - - assert ( - len( - tuple( - config.get_children_deep( - (MatchRule(equals="a"), MatchRule(startswith="b2")), - ), - ), - ) - == 1 - ) - - -def test_get_children(platform_a: Platform) -> None: - hier = get_hconfig(platform_a) - hier.add_child("interface Vlan2") - hier.add_child("interface Vlan3") - children = tuple(hier.get_children(startswith="interface")) - assert len(children) == 2 - for child in children: - assert child.text.startswith("interface Vlan") - - -def test_move(platform_a: Platform, platform_b: Platform) -> None: - hier1 = get_hconfig(platform_a) - interface1 = hier1.add_child("interface Vlan2") - interface1.add_child("192.168.0.1/30") - - assert len(tuple(hier1.all_children())) == 2 - - hier2 = get_hconfig(platform_b) - - assert not tuple(hier2.all_children()) - - interface1.move(hier2) - - assert not tuple(hier1.all_children()) - assert len(tuple(hier2.all_children())) == 2 - - -def test_del_child_by_text(platform_a: Platform) -> None: - hier = get_hconfig(platform_a) - hier.add_child("interface Vlan2") - hier.children.delete("interface Vlan2") - - assert not tuple(hier.all_children()) - - -def test_del_child(platform_a: Platform) -> None: - hier1 = get_hconfig(platform_a) - hier1.add_child("interface Vlan2") - - assert len(tuple(hier1.all_children())) == 1 - - child_to_delete = hier1.get_child(startswith="interface") - assert child_to_delete is not None - hier1.children.delete(child_to_delete) - - assert not tuple(hier1.all_children()) - - -def test_rebuild_children_dict(platform_a: Platform) -> None: - hier1 = get_hconfig(platform_a) - interface = hier1.add_child("interface Vlan2") - interface.add_children( - ("description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"), - ) - delta_a = hier1 - hier1.children.rebuild_mapping() - delta_b = hier1 - - assert tuple(delta_a.all_children()) == tuple(delta_b.all_children()) - - -def test_add_children(platform_a: Platform) -> None: - interface_items1 = ( - "description switch-mgmt 192.168.1.0/24", - "ip address 192.168.1.1/24", - ) - hier1 = get_hconfig(platform_a) - interface1 = hier1.add_child("interface Vlan2") - interface1.add_children(interface_items1) - - assert len(tuple(hier1.all_children())) == 3 - - interface_items2 = ("description switch-mgmt 192.168.1.0/24",) - hier2 = get_hconfig(platform_a) - interface2 = hier2.add_child("interface Vlan2") - interface2.add_children(interface_items2) - - assert len(tuple(hier2.all_children())) == 2 - - -def test_add_child(platform_a: Platform) -> None: - config = get_hconfig(platform_a) - interface = config.add_child("interface Vlan2") - assert interface.depth() == 1 - assert interface.text == "interface Vlan2" - with pytest.raises(DuplicateChildError): - config.add_child("interface Vlan2") - assert config.children.get("interface Vlan2") is interface - - -def test_add_deep_copy_of(platform_a: Platform, platform_b: Platform) -> None: - interface1 = get_hconfig(platform_a).add_child("interface Vlan2") - interface1.add_children( - ("description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"), - ) - - hier2 = get_hconfig(platform_b) - hier2.add_deep_copy_of(interface1) - - assert len(tuple(hier2.all_children())) == 3 - assert isinstance(hier2.all_children(), types.GeneratorType) - - -def test_path(platform_a: Platform) -> None: - config_aaa = get_hconfig(platform_a).add_children_deep(("a", "aa", "aaa")) - assert tuple(config_aaa.path()) == ("a", "aa", "aaa") - - -def test_cisco_style_text(platform_a: Platform) -> None: - ip_address = ( - get_hconfig(platform_a) - .add_child("interface Vlan2") - .add_child("ip address 192.168.1.1 255.255.255.0") - ) - assert ip_address.cisco_style_text() == " ip address 192.168.1.1 255.255.255.0" - assert isinstance(ip_address.cisco_style_text(), str) - assert not isinstance(ip_address.cisco_style_text(), list) - - -def test_all_children_sorted_by_tags(platform_a: Platform) -> None: - config = get_hconfig(platform_a) - config_a = config.add_child("a") - config_aa = config_a.add_child("aa") - config_a.add_child("ab") - config_aaa = config_aa.add_child("aaa") - config_aab = config_aa.add_child("aab") - config_aaa.tags_add("aaa") - config_aab.tags_add("aab") - - case_1_matches = [ - c.text - for c in config.all_children_sorted_by_tags(frozenset(("aaa",)), frozenset()) - ] - assert case_1_matches == ["a", "aa", "aaa"] - case_2_matches = [ - c.text - for c in config.all_children_sorted_by_tags(frozenset(), frozenset(("aab",))) - ] - assert case_2_matches == ["a", "aa", "aaa", "ab"] - case_3_matches = [ - c.text - for c in config.all_children_sorted_by_tags( - frozenset(("aaa",)), - frozenset(("aab",)), - ) - ] - assert case_3_matches == ["a", "aa", "aaa"] - - -def test_all_children_sorted(platform_a: Platform) -> None: - hier = get_hconfig(platform_a) - interface = hier.add_child("interface Vlan2") - interface.add_child("standby 1 ip 10.15.11.1") - assert len(tuple(hier.all_children_sorted())) == 2 - - -def test_all_children(platform_a: Platform) -> None: - hier = get_hconfig(platform_a) - interface = hier.add_child("interface Vlan2") - interface.add_child("standby 1 ip 10.15.11.1") - assert len(tuple(hier.all_children())) == 2 - - -def test_delete(platform_a: Platform) -> None: - hier = get_hconfig(platform_a) - config_a = hier.add_child("a") - config_a.delete() - assert not hier.children - - -def test_set_order_weight(platform_a: Platform) -> None: - hier = get_hconfig(platform_a) - child = hier.add_child("no vlan filter") - hier.set_order_weight() - assert child.order_weight == 200 - - -def test_tags_add(platform_a: Platform) -> None: - interface = get_hconfig(platform_a).add_child("interface Vlan2") - ip_address = interface.add_child("ip address 192.168.1.1/24") - assert not interface.tags - assert not ip_address.tags - ip_address.tags_add("a") - assert "a" in interface.tags - assert "a" in ip_address.tags - assert "b" not in interface.tags - assert "b" not in ip_address.tags - interface.tags_add("c") - assert "c" in ip_address.tags - interface.tags_remove("c") - assert "c" not in ip_address.tags - - -def test_append_tags(platform_a: Platform) -> None: - config = get_hconfig(platform_a) - interface = config.add_child("interface Vlan2") - ip_address = interface.add_child("ip address 192.168.1.1/24") - ip_address.tags_add("test_tag") - assert "test_tag" in config.tags - assert "test_tag" in interface.tags - assert "test_tag" in ip_address.tags - - -def test_remove_tags(platform_a: Platform) -> None: - config = get_hconfig(platform_a) - interface = config.add_child("interface Vlan2") - ip_address = interface.add_child("ip address 192.168.1.1/24") - ip_address.tags_add("test_tag") - assert "test_tag" in config.tags - assert "test_tag" in interface.tags - assert "test_tag" in ip_address.tags - ip_address.tags_remove("test_tag") - assert "test_tag" not in config.tags - assert "test_tag" not in interface.tags - assert "test_tag" not in ip_address.tags - - -def test_negate(platform_a: Platform) -> None: - config = get_hconfig(platform_a) - interface = config.add_child("interface Vlan2") - interface.negate() - assert interface.text == "no interface Vlan2" - assert config.children.get("no interface Vlan2") is interface - - -def test_config_to_get_to(platform_a: Platform) -> None: - running_config_hier = get_hconfig(platform_a) - interface = running_config_hier.add_child("interface Vlan2") - interface.add_child("ip address 192.168.1.1/24") - generated_config_hier = get_hconfig(platform_a) - generated_config_hier.add_child("interface Vlan3") - remediation_config_hier = running_config_hier.config_to_get_to( - generated_config_hier, - ) - assert len(tuple(remediation_config_hier.all_children())) == 2 - - -def test_config_to_get_to2(platform_a: Platform) -> None: - running_config_hier = get_hconfig(platform_a) - running_config_hier.add_child("do not add me") - generated_config_hier = get_hconfig(platform_a) - generated_config_hier.add_child("do not add me") - generated_config_hier.add_child("add me") - delta = get_hconfig(platform_a) - running_config_hier.config_to_get_to( - generated_config_hier, - delta, - ) - assert "do not add me" not in delta.children - assert "add me" in delta.children - - -def test_add_shallow_copy_of(platform_a: Platform) -> None: - base_config = get_hconfig(platform_a) - - interface_a = get_hconfig(platform_a).add_child("interface Vlan2") - interface_a.tags_add(frozenset(("ta", "tb"))) - interface_a.comments.add("ca") - interface_a.order_weight = 200 - - copied_interface = base_config.add_shallow_copy_of(interface_a, merged=True) - assert copied_interface.tags == frozenset(("ta", "tb")) - assert copied_interface.comments == frozenset(("ca",)) - assert copied_interface.order_weight == 200 - assert copied_interface.instances == [ - Instance( - id=id(interface_a.root), - comments=frozenset(interface_a.comments), - tags=interface_a.tags, - ), - ] - - -def test_line_inclusion_test(platform_a: Platform) -> None: - ip_address_ab = get_hconfig(platform_a).add_children_deep( - ("interface Vlan2", "ip address 192.168.2.1/24"), - ) - ip_address_ab.tags_add(frozenset(("a", "b"))) - - assert not ip_address_ab.line_inclusion_test(frozenset(("a",)), frozenset(("b",))) - assert not ip_address_ab.line_inclusion_test(frozenset(), frozenset(("a",))) - assert ip_address_ab.line_inclusion_test(frozenset(("a",)), frozenset()) - assert not ip_address_ab.line_inclusion_test(frozenset(), frozenset()) - - -def test_future_config(platform_a: Platform) -> None: - running_config = get_hconfig(platform_a) - running_config.add_children_deep(("a", "aa", "aaa", "aaaa")) - running_config.add_children_deep(("a", "ab", "aba", "abaa")) - config = get_hconfig(platform_a) - config.add_children_deep(("a", "ac")) - config.add_children_deep(("a", "no ab")) - config.add_children_deep(("a", "no az")) - - future_config = running_config.future(config) - assert tuple(c.cisco_style_text() for c in future_config.all_children()) == ( - "a", - " ac", # config lines are added first - " no az", - " aa", # self lines not in config are added last - " aaa", - " aaaa", - ) - - -def test_future_preserves_bgp_neighbor_description() -> None: - """Validate Arista BGP neighbors keep untouched descriptions across future/rollback. - - This regression asserts that applying a candidate config via ``future()`` retains - existing neighbor descriptions and the subsequent ``config_to_get_to`` rollback only - negates the new commands. - """ - platform = Platform.ARISTA_EOS - running_raw = """router bgp 1 - neighbor 2.2.2.2 description neighbor2 - neighbor 2.2.2.2 remote-as 2 - ! -""" - change_raw = """router bgp 1 - neighbor 3.3.3.3 description neighbor3 - neighbor 3.3.3.3 remote-as 3 -""" - - running_config = get_hconfig(platform, running_raw) - change_config = get_hconfig(platform, change_raw) - - future_config = running_config.future(change_config) - expected_future = ( - "router bgp 1", - " neighbor 3.3.3.3 description neighbor3", - " neighbor 3.3.3.3 remote-as 3", - " neighbor 2.2.2.2 description neighbor2", - " neighbor 2.2.2.2 remote-as 2", - " exit", - ) - assert future_config.dump_simple(sectional_exiting=True) == expected_future - - rollback_config = future_config.config_to_get_to(running_config) - expected_rollback = ( - "router bgp 1", - " no neighbor 3.3.3.3 description neighbor3", - " no neighbor 3.3.3.3 remote-as 3", - " exit", - ) - assert rollback_config.dump_simple(sectional_exiting=True) == expected_rollback - - -def test_idempotency_key_with_equals_string() -> None: - """Test idempotency key generation with equals constraint as string.""" - driver = HConfigDriverCiscoIOS() - # Add a rule with equals as string - driver.rules.idempotent_commands.append( - IdempotentCommandsRule( - match_rules=(MatchRule(equals="logging console"),), - ) - ) - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Test the idempotency with equals string - key = driver._idempotency_key(child, (MatchRule(equals="logging console"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("equals|logging console",) - - -def test_idempotency_key_with_equals_frozenset() -> None: - """Test idempotency key generation with equals constraint as frozenset.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Test the idempotency with equals frozenset (should fall back to text) - key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - child, (MatchRule(equals=frozenset(["logging console", "other"])),) - ) - assert key == ("equals|logging console",) - - -def test_idempotency_key_no_match_rules() -> None: - """Test idempotency key falls back to text when no match rules apply.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """some command -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Empty MatchRule should fall back to text - key = driver._idempotency_key(child, (MatchRule(),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("text|some command",) - - -def test_idempotency_key_prefix_no_match() -> None: - """Test idempotency key when prefix doesn't match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Prefix that doesn't match should fall back to text - key = driver._idempotency_key(child, (MatchRule(startswith="interface"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("text|logging console",) - - -def test_idempotency_key_suffix_no_match() -> None: - """Test idempotency key when suffix doesn't match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Suffix that doesn't match should fall back to text - key = driver._idempotency_key(child, (MatchRule(endswith="emergency"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("text|logging console",) - - -def test_idempotency_key_contains_no_match() -> None: - """Test idempotency key when contains doesn't match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Contains that doesn't match should fall back to text - key = driver._idempotency_key(child, (MatchRule(contains="interface"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("text|logging console",) - - -def test_idempotency_key_regex_no_match() -> None: - """Test idempotency key when regex doesn't match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Regex that doesn't match should fall back to text - key = driver._idempotency_key(child, (MatchRule(re_search="^interface"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("text|logging console",) - - -def test_idempotency_key_prefix_tuple_no_match() -> None: - """Test idempotency key with tuple of prefixes that don't match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Tuple of prefixes that don't match should fall back to text - key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - child, (MatchRule(startswith=("interface", "router", "vlan")),) - ) - assert key == ("text|logging console",) - - -def test_idempotency_key_prefix_tuple_match() -> None: - """Test idempotency key with tuple of prefixes that match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Tuple of prefixes with one matching - should return longest match - key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - child, (MatchRule(startswith=("log", "logging", "logging console")),) - ) - assert key == ("startswith|logging console",) - - -def test_idempotency_key_suffix_tuple_no_match() -> None: - """Test idempotency key with tuple of suffixes that don't match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Tuple of suffixes that don't match should fall back to text - key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - child, (MatchRule(endswith=("emergency", "alert", "critical")),) - ) - assert key == ("text|logging console",) - - -def test_idempotency_key_suffix_tuple_match() -> None: - """Test idempotency key with tuple of suffixes that match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Tuple of suffixes with one matching - should return longest match - key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - child, (MatchRule(endswith=("ole", "sole", "console")),) - ) - assert key == ("endswith|console",) - - -def test_idempotency_key_contains_tuple_no_match() -> None: - """Test idempotency key with tuple of contains that don't match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Tuple of contains that don't match should fall back to text - key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - child, (MatchRule(contains=("interface", "router", "vlan")),) - ) - assert key == ("text|logging console",) - - -def test_idempotency_key_contains_tuple_match() -> None: - """Test idempotency key with tuple of contains that match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Tuple of contains with matches - should return longest match - key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - child, (MatchRule(contains=("log", "console", "logging console")),) - ) - assert key == ("contains|logging console",) - - -def test_idempotency_key_regex_with_groups() -> None: - """Test idempotency key with regex capture groups.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """router bgp 1 - neighbor 10.1.1.1 description peer1 -""" - config = get_hconfig(driver, config_raw) - bgp_child = next(iter(config.children)) - neighbor_child = next(iter(bgp_child.children)) - - # Regex with capture groups should use groups - key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - neighbor_child, - ( - MatchRule(startswith="router bgp"), - MatchRule(re_search=r"neighbor (\S+) description"), - ), - ) - assert key == ("startswith|router bgp", "re|10.1.1.1") - - -def test_idempotency_key_regex_with_empty_groups() -> None: - """Test idempotency key with regex that has empty capture groups.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Regex with empty/None groups should fall back to match result - key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - child, (MatchRule(re_search=r"logging ()?(console)"),) - ) - # Group 1 is empty, group 2 has "console", so should use groups - assert "re|" in key[0] - - -def test_idempotency_key_regex_greedy_pattern() -> None: - """Test idempotency key with greedy regex pattern (.* or .+).""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console emergency -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Regex with .* should be trimmed - key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("re|logging console",) - - -def test_idempotency_key_regex_greedy_pattern_with_dollar() -> None: - """Test idempotency key with greedy regex pattern with $ anchor.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console emergency -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Regex with .*$ should be trimmed - key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*$"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("re|logging console",) - - -def test_idempotency_key_regex_only_greedy() -> None: - """Test idempotency key with regex that is only greedy pattern.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Regex that is only .* should not trim to empty - key = driver._idempotency_key(child, (MatchRule(re_search=r".*"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - # Should use the full match result - assert key == ("re|logging console",) - - -def test_idempotency_key_lineage_mismatch() -> None: - """Test idempotency key when lineage length doesn't match rules length.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """interface GigabitEthernet1/1 - description test -""" - config = get_hconfig(driver, config_raw) - interface_child = next(iter(config.children)) - desc_child = next(iter(interface_child.children)) - - # Try to match with wrong number of rules (desc has 2 lineage levels, only 1 rule) - key = driver._idempotency_key(desc_child, (MatchRule(startswith="description"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - # Should return empty tuple when lineage length != match_rules length - assert not key - - -def test_idempotency_key_negated_command() -> None: - """Test idempotency key with negated command.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """no logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Negated command should strip 'no ' prefix for matching - key = driver._idempotency_key(child, (MatchRule(startswith="logging"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("startswith|logging",) - - -def test_idempotency_key_regex_fallback_to_original() -> None: - """Test idempotency key regex matching fallback to original text.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """no logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Regex that matches original but not normalized (tests lines 328-329) - key = driver._idempotency_key(child, (MatchRule(re_search=r"^no logging"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert "re|no logging" in key[0] - - -def test_idempotency_key_suffix_single_match() -> None: - """Test idempotency key with single suffix that matches (not tuple).""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Single suffix that matches (tests line 359) - key = driver._idempotency_key(child, (MatchRule(endswith="console"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("endswith|console",) - - -def test_idempotency_key_contains_single_match() -> None: - """Test idempotency key with single contains that matches (not tuple).""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console emergency -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Single contains that matches (tests line 372) - key = driver._idempotency_key(child, (MatchRule(contains="console"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - assert key == ("contains|console",) - - -def test_idempotency_key_regex_greedy_with_plus() -> None: - """Test idempotency key with greedy regex using .+ suffix.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """interface GigabitEthernet1 -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Regex with .+ should be trimmed similar to .* - # Tests the .+ branch in line 389 - key = driver._idempotency_key(child, (MatchRule(re_search=r"interface .+"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - # Should trim to just "interface " and use that - assert key == ("re|interface",) - - -def test_idempotency_key_regex_trimmed_to_no_match() -> None: - """Test idempotency key when trimmed regex doesn't match.""" - driver = HConfigDriverCiscoIOS() - - config_raw = """logging console -""" - config = get_hconfig(driver, config_raw) - child = next(iter(config.children)) - - # Regex "interface.*" matches nothing, but after trimming .* we get "interface" - # which also doesn't match "logging console", so we fall back to full match result - # This should hit the break at line 399 because trimmed_match is None - key = driver._idempotency_key(child, (MatchRule(re_search=r"interface.*"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - # Since "interface.*" doesn't match "logging console", should fall back to text - assert key == ("text|logging console",) - - -def test_difference1(platform_a: Platform) -> None: - rc = ("a", " a1", " a2", " a3", "b") - step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") - rc_hier = get_hconfig(get_hconfig_driver(platform_a), "\n".join(rc)) - - difference = get_hconfig( - get_hconfig_driver(platform_a), "\n".join(step) - ).difference(rc_hier) - difference_children = tuple( - c.cisco_style_text() for c in difference.all_children_sorted() - ) - - assert len(difference_children) == 6 - assert "c" in difference.children - assert "d" in difference.children - difference_a = difference.get_child(equals="a") - assert isinstance(difference_a, HConfigChild) - assert "a4" in difference_a.children - assert "a5" in difference_a.children - difference_d = difference.get_child(equals="d") - assert isinstance(difference_d, HConfigChild) - assert "d1" in difference_d.children - - -def test_difference2() -> None: - platform = Platform.CISCO_IOS - rc = ("a", " a1", " a2", " a3", "b") - step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") - rc_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(rc)) - step_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(step)) - - difference_children = tuple( - c.cisco_style_text() - for c in step_hier.difference(rc_hier).all_children_sorted() - ) - assert len(difference_children) == 6 - - -def test_difference3() -> None: - platform = Platform.CISCO_IOS - rc = ("ip access-list extended test", " 10 a", " 20 b") - step = ("ip access-list extended test", " 10 a", " 20 b", " 30 c") - rc_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(rc)) - step_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(step)) - - difference_children = tuple( - c.cisco_style_text() - for c in step_hier.difference(rc_hier).all_children_sorted() - ) - assert difference_children == ("ip access-list extended test", " 30 c") - - -def test_unified_diff() -> None: - platform = Platform.CISCO_IOS - - config_a = get_hconfig(platform) - config_b = get_hconfig(platform) - # deep differences - config_a.add_children_deep(("a", "aa", "aaa", "aaaa")) - config_b.add_children_deep(("a", "aa", "aab", "aaba")) - # these children will be the same and should not appear in the diff - config_a.add_children_deep(("b", "ba", "baa")) - config_b.add_children_deep(("b", "ba", "baa")) - # root level differences - config_a.add_children_deep(("c", "ca")) - config_b.add_child("d") - - diff = tuple(config_a.unified_diff(config_b)) - assert diff == ( - "a", - " aa", - " - aaa", - " - aaaa", - " + aab", - " + aaba", - "- c", - " - ca", - "+ d", - ) - - -def test_idempotent_commands() -> None: - platform = Platform.HP_PROCURVE - config_a = get_hconfig(platform) - config_b = get_hconfig(platform) - interface_name = "interface 1/1" - config_a.add_children_deep((interface_name, "untagged vlan 1")) - config_b.add_children_deep((interface_name, "untagged vlan 2")) - interface = config_a.config_to_get_to(config_b).get_child(equals=interface_name) - assert interface is not None - assert interface.get_child(equals="untagged vlan 2") - assert len(interface.children) == 1 - - -def test_idempotent_commands2() -> None: - platform = Platform.CISCO_IOS - config_a = get_hconfig(platform) - config_b = get_hconfig(platform) - interface_name = "interface 1/1" - config_a.add_children_deep((interface_name, "authentication host-mode multi-auth")) - config_b.add_children_deep( - (interface_name, "authentication host-mode multi-domain"), - ) - interface = config_a.config_to_get_to(config_b).get_child(equals=interface_name) - assert interface is not None - assert interface.get_child(equals="authentication host-mode multi-domain") - assert len(interface.children) == 1 - - -def test_future_config_no_command_in_source() -> None: - platform = Platform.HP_PROCURVE - running_config = get_hconfig(platform) - generated_config = get_hconfig(platform) - generated_config.add_child("no service dhcp") - - remediation_config = running_config.config_to_get_to(generated_config) - future_config = running_config.future(remediation_config) - assert len(future_config.children) == 1 - assert future_config.get_child(equals="no service dhcp") - assert not tuple(future_config.unified_diff(generated_config)) - rollback_config = future_config.config_to_get_to(running_config) - assert len(rollback_config.children) == 1 - assert rollback_config.get_child(equals="service dhcp") - calculated_running_config = future_config.future(rollback_config) - assert not calculated_running_config.children - assert not tuple(calculated_running_config.unified_diff(running_config)) - - -def test_sectional_overwrite() -> None: - platform = Platform.CISCO_XR - # There is a sectional_overwrite rules in the CISCO_XR driver for "template". - running_config = get_hconfig_fast_load(platform, "template test\n a\n b") - generated_config = get_hconfig_fast_load(platform, "template test\n a") - expected_remediation_config = get_hconfig_fast_load( - platform, "no template test\ntemplate test\n a" - ) - workflow_remediation = WorkflowRemediation(running_config, generated_config) - remediation_config = workflow_remediation.remediation_config - assert remediation_config == expected_remediation_config - - -def test_sectional_overwrite_no_negate() -> None: - platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load(platform, "as-path-set test\n a\n b") - generated_config = get_hconfig_fast_load(platform, "as-path-set test\n a") - expected_remediation_config = get_hconfig_fast_load( - platform, "as-path-set test\n a" - ) - workflow_remediation = WorkflowRemediation(running_config, generated_config) - remediation_config = workflow_remediation.remediation_config - assert remediation_config == expected_remediation_config - - -def test_sectional_overwrite_no_negate2() -> None: - platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( - platform, - "route-policy test\n duplicate\n not_duplicate1\n duplicate\n not_duplicate2", - ) - generated_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not_duplicate1" - ) - expected_remediation_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not_duplicate1" - ) - workflow_remediation = WorkflowRemediation(running_config, generated_config) - remediation_config = workflow_remediation.remediation_config - assert remediation_config == expected_remediation_config - - -def test_overwrite_with_negate() -> None: - platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not_duplicate\n duplicate" - ) - generated_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not_duplicate" - ) - expected_config = get_hconfig_fast_load( - platform, - "no route-policy test\nroute-policy test\n duplicate\n not_duplicate", - ) - delta_config = get_hconfig(platform) - running_config.children["route-policy test"].overwrite_with( - generated_config.children["route-policy test"], delta_config - ) - assert delta_config == expected_config - - -def test_overwrite_with_no_negate() -> None: - platform = Platform.CISCO_XR - running_config = get_hconfig_fast_load( - platform, - "route-policy test\n duplicate\n not-duplicate\n duplicate\n duplicate", - ) - generated_config = get_hconfig_fast_load( - platform, "route-policy test\n duplicate\n not-duplicate\n duplicate" - ) - expected_config = get_hconfig_fast_load( - platform, - "route-policy test\n duplicate\n not-duplicate\n duplicate", - ) - delta_config = get_hconfig(platform) - running_config.children["route-policy test"].overwrite_with( - generated_config.children["route-policy test"], delta_config, negate=False - ) - assert delta_config == expected_config - - -def test_config_to_get_to_parent_identity() -> None: - interface_vlan2 = "interface Vlan2" - platform = Platform.CISCO_IOS - running_config_hier = get_hconfig(platform) - running_config_hier.add_children_deep( - (interface_vlan2, "ip address 192.168.1.1/24") - ) - generated_config_hier = get_hconfig(platform) - generated_config_hier.add_child(interface_vlan2) - remediation_config_hier = running_config_hier.config_to_get_to( - generated_config_hier, - ) - remediation_config_interface = remediation_config_hier.get_child( - equals=interface_vlan2 - ) - assert remediation_config_interface - assert id(remediation_config_interface.parent) == id(remediation_config_hier) - assert id(remediation_config_interface.root) == id(remediation_config_hier) - - -def test_add_child_with_empty_text() -> None: - """Test that add_child raises ValueError when text is empty.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - - with pytest.raises(ValueError, match="text was empty"): - config.add_child("") - - -def test_add_child_duplicate_error() -> None: - """Test DuplicateChildError when adding duplicate child.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - - with pytest.raises(DuplicateChildError, match="Found a duplicate section"): - config.add_child( - "interface GigabitEthernet0/0", - check_if_present=True, - return_if_present=False, - ) - - -def test_add_child_return_if_present() -> None: - """Test return_if_present option in add_child.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - child2 = config.add_child("interface GigabitEthernet0/0", return_if_present=True) - - assert id(child1) == id(child2) - - -def test_child_repr() -> None: - """Test HConfigChild __repr__ method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child = config.add_child("interface GigabitEthernet0/0") - subchild = child.add_child("description test") - repr_str = repr(child) - - assert "HConfigChild(HConfig, interface GigabitEthernet0/0)" in repr_str - - repr_str2 = repr(subchild) - - assert "HConfigChild(HConfigChild, description test)" in repr_str2 - - -def test_child_ne() -> None: - """Test HConfigChild __ne__ method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - child2 = config.add_child("interface GigabitEthernet0/1") - - assert child1 != child2 - - -def test_cisco_style_text_with_comments() -> None: - """Test cisco_style_text with comments.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child = config.add_child("interface GigabitEthernet0/0") - child.comments.add("test comment") - child.comments.add("another comment") - line = child.cisco_style_text(style="with_comments") - - assert "!another comment, test comment" in line - - instance = Instance( - id=1, comments=frozenset(["instance comment"]), tags=frozenset(["tag1"]) - ) - child.instances.append(instance) - line_merged = child.cisco_style_text(style="merged", tag="tag1") - - assert "1 instance" in line_merged - assert "instance comment" in line_merged - - instance2 = Instance(id=2, comments=frozenset(), tags=frozenset(["tag1"])) - child.instances.append(instance2) - line_merged2 = child.cisco_style_text(style="merged", tag="tag1") - - assert "2 instances" in line_merged2 - - -def test_hconfig_children_setitem() -> None: - """Test HConfigChildren __setitem__.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - child2_text = "interface GigabitEthernet0/1" - config.add_child(child2_text) - child3_text = "interface GigabitEthernet0/2" - child3 = config.instantiate_child(child3_text) - config.children[1] = child3 - - assert config.children[1].text == child3_text - assert child3_text in config.children - - -def test_hconfig_children_contains() -> None: - """Test HConfigChildren __contains__.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - - assert "interface GigabitEthernet0/0" in config.children - assert "interface GigabitEthernet0/1" not in config.children - - -def test_hconfig_children_eq_fast_fail() -> None: - """Test HConfigChildren __eq__ fast fail.""" - platform = Platform.CISCO_IOS - config1 = get_hconfig(platform) - config2 = get_hconfig(platform) - - config1.add_child("interface GigabitEthernet0/0") - config2.add_child("interface GigabitEthernet0/0") - config2.add_child("interface GigabitEthernet0/1") - - assert config1.children != config2.children - - -def test_hconfig_children_eq_keys_mismatch() -> None: - """Test HConfigChildren __eq__ key mismatch.""" - platform = Platform.CISCO_IOS - config1 = get_hconfig(platform) - config2 = get_hconfig(platform) - - config1.add_child("interface GigabitEthernet0/0") - config2.add_child("interface GigabitEthernet0/1") - - assert config1.children != config2.children - - -def test_hconfig_children_hash() -> None: - """Test HConfigChildren __hash__.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - hash_val = hash(config.children) - - assert isinstance(hash_val, int) - - -def test_hconfig_children_getitem_slice() -> None: - """Test HConfigChildren __getitem__ with slice.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - config.add_child("interface GigabitEthernet0/1") - config.add_child("interface GigabitEthernet0/2") - slice_result = config.children[0:2] - - assert isinstance(slice_result, list) - assert len(slice_result) == 2 - assert slice_result[0].text == "interface GigabitEthernet0/0" - - -def test_future_with_negated_command_in_config() -> None: - """Test _future with negated command.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("interface GigabitEthernet0/0") - remediation_config = get_hconfig(platform) - remediation_config.add_child("no interface GigabitEthernet0/0") - future_config = running_config.future(remediation_config) - - assert future_config.get_child(equals="interface GigabitEthernet0/0") is None - - -def test_future_with_negation_prefix_match() -> None: - """Test _future when negated form exists.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("no logging console") - remediation_config = get_hconfig(platform) - remediation_config.add_child("logging console") - future_config = running_config.future(remediation_config) - - assert future_config.get_child(equals="logging console") is not None - assert future_config.get_child(equals="no logging console") is None - - -def test_difference_with_negation() -> None: - """Test _difference with negation prefix.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("interface GigabitEthernet0/0") - running_config.add_child("logging console") - generated_config = get_hconfig(platform) - generated_config.add_child("interface GigabitEthernet0/0") - difference = running_config.difference(generated_config) - - assert difference.get_child(equals="logging console") is not None - - -def test_child_lt_comparison() -> None: - """Test HConfigChild __lt__ for ordering.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - child2 = config.add_child("interface GigabitEthernet0/1") - child1.order_weight = 100 - child2.order_weight = 50 - - assert child2 < child1 - assert not child1 < child2 # pylint: disable=unneeded-not - - -def test_child_hash_consistency() -> None: - """Test HConfigChild __hash__.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child = config.add_child("interface GigabitEthernet0/0") - child.add_child("description test") - hash1 = hash(child) - hash2 = hash(child) - - assert hash1 == hash2 - - -def test_child_hash_eq_consistency_new_in_config() -> None: - """Test that equal HConfigChild objects have equal hashes regardless of new_in_config. - - Validates the bug in issue #185: __hash__ includes new_in_config but __eq__ does not, - violating the Python invariant that a == b implies hash(a) == hash(b). - """ - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.new_in_config = False - child2.new_in_config = True - - # These two children compare as equal (same text, no tags, no children) - assert child1 == child2 - # Python invariant: equal objects must have equal hashes - assert hash(child1) == hash(child2) - - -def test_child_hash_eq_consistency_order_weight() -> None: - """Test that equal HConfigChild objects have equal hashes regardless of order_weight. - - Validates the bug in issue #185: __hash__ includes order_weight but __eq__ does not, - violating the Python invariant that a == b implies hash(a) == hash(b). - """ - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.order_weight = 0 - child2.order_weight = 100 - - # These two children compare as equal (same text, no tags, no children) - assert child1 == child2 - # Python invariant: equal objects must have equal hashes - assert hash(child1) == hash(child2) - - -def test_child_hash_eq_consistency_tags() -> None: - """Test that __hash__ and __eq__ agree on whether tags affect equality. - - Validates the bug in issue #185: __eq__ checks tags but __hash__ does not include - tags, meaning two objects that compare unequal could have the same hash (not a - correctness violation, but inconsistent) while also raising the question of whether - tags should be part of the hash. - """ - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.tags = frozenset({"safe"}) - child2.tags = frozenset() - - # __eq__ considers tags, so these are unequal - assert child1 != child2 - # Since they are unequal, their hashes should differ to avoid excessive collisions - # (not strictly required by the invariant, but required for correctness in reverse: - # if hash(a) != hash(b) then a != b must hold — currently tags are in __eq__ but - # not __hash__, so unequal objects can share a hash, which means dict/set lookup - # will fall back to __eq__ unexpectedly) - assert hash(child1) != hash(child2) - - -def test_child_set_deduplication_with_new_in_config() -> None: - """Test that equal HConfigChild objects are deduplicated correctly in sets. - - Validates the practical impact of issue #185: when new_in_config differs, - two logically equal children occupy different set buckets, causing duplicates. - """ - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.new_in_config = False - child2.new_in_config = True - - assert child1 == child2 - # Equal objects must collapse to one entry in a set - assert len({child1, child2}) == 1 - - -def test_child_dict_key_lookup_with_order_weight() -> None: - """Test that HConfigChild objects with differing order_weight work as dict keys. - - Validates the practical impact of issue #185: when order_weight differs, a - logically equal child cannot be found as a dict key. - """ - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - config2 = get_hconfig(platform) - child2 = config2.add_child("interface GigabitEthernet0/0") - - child1.order_weight = 0 - child2.order_weight = 100 - - assert child1 == child2 - lookup: dict[HConfigChild, str] = {child1: "found"} - # child2 is equal to child1, so it must find the same dict entry - assert lookup[child2] == "found" - - -def test_with_tags_recursive() -> None: - """Test _with_tags recursion.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - interface.tags = frozenset(["production"]) - desc = interface.add_child("description test") - desc.tags = frozenset(["production"]) - tagged_config = config.with_tags(frozenset(["production"])) - - assert tagged_config.get_child(equals="interface GigabitEthernet0/0") is not None - - tagged_interface = tagged_config.get_child(equals="interface GigabitEthernet0/0") - - assert tagged_interface is not None - assert tagged_interface.get_child(equals="description test") is not None - - -def test_difference_with_default_prefix() -> None: - """Test _difference skips lines with 'default' prefix.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("interface GigabitEthernet0/0") - running_config.add_child("default interface GigabitEthernet0/1") - generated_config = get_hconfig(platform) - generated_config.add_child("interface GigabitEthernet0/0") - difference = running_config.difference(generated_config) - - assert difference.get_child(startswith="default") is None - - -def test_add_child_with_duplicates_allowed() -> None: - """Test add_child when duplicates are allowed.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) - route_policy = config.add_child("route-policy test") - child1 = route_policy.add_child("if destination in test then") - child2 = route_policy.add_child("if destination in test then") - - assert id(child1) != id(child2) - assert child1.text == child2.text - - -def test_get_children_with_duplicates() -> None: - """Test get_children when duplicates are allowed.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) - route_policy = config.add_child("route-policy test") - route_policy.add_child("if destination in test then") - route_policy.add_child("if destination in test then") - route_policy.add_child("if source in test then") - children = tuple(route_policy.get_children(startswith="if destination")) - - assert len(children) == 2 - - -def test_child_sectional_exit_no_exit_text() -> None: - """Test sectional_exit when rule returns None.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child = config.add_child("hostname test") - - assert child.sectional_exit is None - - -def test_child_is_match_endswith() -> None: - """Test is_match with endswith filter.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - - assert interface.is_match(endswith="Ethernet0/0") - assert not interface.is_match(endswith="Ethernet0/1") - - -def test_child_is_match_contains_single() -> None: - """Test is_match with single contains filter.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - - assert interface.is_match(contains="Gigabit") - assert not interface.is_match(contains="FastEthernet") - - -def test_child_is_match_contains_tuple() -> None: - """Test is_match with tuple contains filter.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - - assert interface.is_match(contains=("Gigabit", "FastEthernet")) - assert not interface.is_match(contains=("TenGigabit", "FastEthernet")) - - -def test_child_use_default_for_negation() -> None: - """Test use_default_for_negation.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - uses_default = description.use_default_for_negation(description) - - assert isinstance(uses_default, bool) - - -def test_child_tags_remove_branch() -> None: - """Test tags_remove on branch node.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - description.tags_add("test_tag") - interface.tags_remove("test_tag") - - assert "test_tag" not in description.tags - - -def test_child_is_idempotent_command_avoid() -> None: - """Test is_idempotent_command with avoid rule.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - ip_address = interface.add_child("ip address 192.168.1.1 255.255.255.0") - other_children: list[HConfigChild] = [] - result = ip_address.is_idempotent_command(other_children) - - assert isinstance(result, bool) - - -def test_child_overwrite_with_negate_else_branch() -> None: - """Test overwrite_with when negated child doesn't exist.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_interface = running_config.add_child("interface GigabitEthernet0/0") - running_interface.add_child("description old") - generated_config = get_hconfig(platform) - generated_interface = generated_config.add_child("interface GigabitEthernet0/0") - generated_interface.add_child("description new") - delta_config = get_hconfig(platform) - running_interface.overwrite_with(generated_interface, delta_config, negate=True) - delta_interface = delta_config.get_child(equals="interface GigabitEthernet0/0") - - assert delta_interface is not None - - -def test_child_tags_setter_on_branch() -> None: - """Test tags setter on branch node.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - interface.tags = frozenset(["production", "critical"]) - - assert "production" in description.tags - assert "critical" in description.tags - - -def test_child_add_children_deep() -> None: - """Test add_children_deep method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - result = interface.add_children_deep( - ["ip access-group test in", "description test"] - ) - - assert result.text == "description test" - assert result.depth() == 3 - - -def test_child_default_method() -> None: - """Test _default method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - description._default() # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] - - assert description.text == "default description test" - - -def test_abstract_methods_coverage() -> None: - """Test coverage of abstract method implementations.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - desc = interface.add_child("description test") - - assert interface.root is config - assert desc.root is config - - assert interface.driver is not None - assert config.driver is not None - - lineage = tuple(desc.lineage()) - assert len(lineage) == 2 - assert lineage[0] is interface - - assert config.depth() == 0 - assert interface.depth() == 1 - assert desc.depth() == 2 - - hash_value = hash(interface) - assert isinstance(hash_value, int) - - children_list = list(config) - assert len(children_list) == 1 - assert children_list[0] is interface - - -def test_get_child_deep_none() -> None: - """Test get_child_deep returns None when no match.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - result = config.get_child_deep((MatchRule(equals="interface GigabitEthernet0/1"),)) - - assert result is None - - -def test_future_with_idempotent_command() -> None: - """Test _future with idempotent command.""" - platform = Platform.HP_PROCURVE - running_config = get_hconfig(platform) - interface = running_config.add_child("interface 1/1") - interface.add_child("untagged vlan 1") - remediation_config = get_hconfig(platform) - remediation_interface = remediation_config.add_child("interface 1/1") - remediation_interface.add_child("untagged vlan 2") - future_config = running_config.future(remediation_config) - future_interface = future_config.get_child(equals="interface 1/1") - - assert future_interface is not None - assert future_interface.get_child(equals="untagged vlan 2") is not None - - -def test_future_with_negation_prefix() -> None: - """Test _future with negation prefix in self.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("no ip routing") - remediation_config = get_hconfig(platform) - remediation_config.add_child("ip routing") - future_config = running_config.future(remediation_config) - - assert future_config.get_child(equals="ip routing") is None - assert future_config.get_child(equals="no ip routing") is None - - -def test_future_self_child_not_in_negated_or_recursed() -> None: - """Test _future when self_child is not in negated_or_recursed.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_config.add_child("hostname router1") - running_config.add_child("interface GigabitEthernet0/0") - remediation_config = get_hconfig(platform) - remediation_config.add_child("hostname router2") - future_config = running_config.future(remediation_config) - - assert future_config.get_child(equals="hostname router2") is not None - assert future_config.get_child(equals="interface GigabitEthernet0/0") is not None - - -def test_difference_with_acl_none_target() -> None: - """Test _difference with ACL when target_acl_children is None.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - - acl = running_config.add_child("ip access-list extended test") - acl.add_child("10 permit ip any any") - target_config = get_hconfig(platform) - difference = running_config.difference(target_config) - - assert difference.get_child(equals="ip access-list extended test") is not None - - -def test_child_eq_comparison() -> None: - """Test HConfigChild __eq__ returns False for different text.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - child1 = config.add_child("interface GigabitEthernet0/0") - child2 = config.add_child("interface GigabitEthernet0/1") - - assert child1 != child2 - - config2 = get_hconfig(platform) - child3 = config2.add_child("interface GigabitEthernet0/0") - assert child1 == child3 - - -def test_child_sectional_exit_with_exit_text() -> None: - """Test sectional_exit when rule has exit_text.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - interface.add_child("description test") - exit_text = interface.sectional_exit - - assert exit_text == "exit" - - -def test_child_tags_remove_leaf_iterable() -> None: - """Test tags_remove on leaf with iterable.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - description.tags_add(frozenset(["tag1", "tag2", "tag3"])) - description.tags_remove(["tag1", "tag2"]) - - assert "tag1" not in description.tags - assert "tag2" not in description.tags - assert "tag3" in description.tags - - -def test_child_use_default_for_negation_true() -> None: - """Test use_default_for_negation returns True.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - description = interface.add_child("description test") - result = description.use_default_for_negation(description) - - assert isinstance(result, bool) - - -def test_child_is_idempotent_command_with_avoid_rule() -> None: - """Test is_idempotent_command with avoid rule match.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - ip_access_group = interface.add_child("ip access-group test in") - result = ip_access_group.is_idempotent_command([]) - - assert isinstance(result, bool) - - -def test_child_overwrite_with_existing_negated() -> None: - """Test overwrite_with when negated child exists in delta.""" - platform = Platform.CISCO_IOS - running_config = get_hconfig(platform) - running_interface = running_config.add_child("interface GigabitEthernet0/0") - running_interface.add_child("description old") - generated_config = get_hconfig(platform) - generated_interface = generated_config.add_child("interface GigabitEthernet0/0") - generated_interface.add_child("description new") - delta_config = get_hconfig(platform) - delta_config.add_child("interface GigabitEthernet0/0") - running_interface.overwrite_with(generated_interface, delta_config, negate=True) - delta_interface = delta_config.get_child(equals="interface GigabitEthernet0/0") - - assert delta_interface is not None - - -def test_children_eq_empty_fast_success() -> None: - """Test HConfigChildren __eq__ fast success for empty.""" - platform = Platform.CISCO_IOS - config1 = get_hconfig(platform) - config2 = get_hconfig(platform) - - assert config1.children == config2.children - - -def test_children_hash_with_data() -> None: - """Test HConfigChildren __hash__ with data.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - config.add_child("interface GigabitEthernet0/1") - hash1 = hash(config.children) - hash2 = hash(config.children) - - assert hash1 == hash2 - assert isinstance(hash1, int) - - -def test_children_getitem_with_slice() -> None: - """Test HConfigChildren __getitem__ with slice.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("interface GigabitEthernet0/0") - config.add_child("interface GigabitEthernet0/1") - config.add_child("interface GigabitEthernet0/2") - config.add_child("interface GigabitEthernet0/3") - slice1 = config.children[1:3] - assert len(slice1) == 2 - - slice2 = config.children[::2] - assert len(slice2) == 2 - - slice3 = config.children[:2] - assert len(slice3) == 2 - - -def test_hconfig_str() -> None: - """Test HConfig __str__ method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - config.add_child("hostname router1") - config.add_child("interface GigabitEthernet0/0") - str_output = str(config) - - assert "hostname router1" in str_output - assert "interface GigabitEthernet0/0" in str_output - assert isinstance(str_output, str) - - -def test_hconfig_eq_not_hconfig() -> None: - """Test HConfig __eq__ with non-HConfig object.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - result = config == "not an HConfig" - - assert not result - - -def test_hconfig_real_indent_level() -> None: - """Test HConfig real_indent_level property.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - - assert config.real_indent_level == -1 - - -def test_hconfig_parent_property() -> None: - """Test HConfig parent property returns self.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - - assert config.parent is config - - -def test_hconfig_is_leaf() -> None: - """Test HConfig is_leaf property.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - - assert config.is_leaf is False - - -def test_hconfig_tags_setter() -> None: - """Test HConfig tags setter.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - desc = interface.add_child("description test") - config.tags = frozenset(["production", "core"]) - - assert "production" in desc.tags - assert "core" in desc.tags - - -def test_hconfig_add_children_deep_typeerror() -> None: - """Test HConfig add_children_deep raises TypeError.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - - with pytest.raises(TypeError, match="base was an HConfig object"): - config.add_children_deep([]) - - -def test_hconfig_deep_copy() -> None: - """Test HConfig deep_copy method).""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - interface.add_child("description test") - config.add_child("hostname router1") - config_copy = config.deep_copy() - - assert config_copy is not config - assert len(tuple(config_copy.all_children())) == len(tuple(config.all_children())) - assert config_copy.get_child(equals="interface GigabitEthernet0/0") is not None - assert config_copy.get_child(equals="hostname router1") is not None - - original_interface = config.get_child(equals="interface GigabitEthernet0/0") - copied_interface = config_copy.get_child(equals="interface GigabitEthernet0/0") - assert original_interface is not None - assert copied_interface is not None - assert original_interface is not copied_interface - - -def test_sectional_exit_text_parent_level_cisco_xr() -> None: - """Test sectional_exit_text_parent_level returns True for Cisco XR configs with parent-level exit text.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) - - # Test route-policy which has exit_text_parent_level=True - route_policy = config.add_child("route-policy TEST") - assert route_policy.sectional_exit_text_parent_level is True - - # Test prefix-set which has exit_text_parent_level=True - prefix_set = config.add_child("prefix-set TEST") - assert prefix_set.sectional_exit_text_parent_level is True - - # Test policy-map which has exit_text_parent_level=True - policy_map = config.add_child("policy-map TEST") - assert policy_map.sectional_exit_text_parent_level is True - - # Test class-map which has exit_text_parent_level=True - class_map = config.add_child("class-map TEST") - assert class_map.sectional_exit_text_parent_level is True - - # Test community-set which has exit_text_parent_level=True - community_set = config.add_child("community-set TEST") - assert community_set.sectional_exit_text_parent_level is True - - # Test extcommunity-set which has exit_text_parent_level=True - extcommunity_set = config.add_child("extcommunity-set TEST") - assert extcommunity_set.sectional_exit_text_parent_level is True - - # Test template which has exit_text_parent_level=True - template = config.add_child("template TEST") - assert template.sectional_exit_text_parent_level is True - - -def test_sectional_exit_text_parent_level_cisco_xr_false() -> None: - """Test sectional_exit_text_parent_level returns False for Cisco XR configs without parent-level exit text.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) - - # Test interface which has exit_text_parent_level=False (default) - interface = config.add_child("interface GigabitEthernet0/0/0/0") - assert interface.sectional_exit_text_parent_level is False - - # Test router bgp which has exit_text_parent_level=False (default) - router_bgp = config.add_child("router bgp 65000") - assert router_bgp.sectional_exit_text_parent_level is False - - -def test_sectional_exit_text_parent_level_cisco_ios() -> None: - """Test sectional_exit_text_parent_level returns False for standard Cisco IOS configs.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - - # Cisco IOS interfaces don't have exit_text_parent_level=True - interface = config.add_child("interface GigabitEthernet0/0") - assert interface.sectional_exit_text_parent_level is False - - # Cisco IOS router configurations don't have exit_text_parent_level=True - router = config.add_child("router ospf 1") - assert router.sectional_exit_text_parent_level is False - - # Standard configuration sections - line = config.add_child("line vty 0 4") - assert line.sectional_exit_text_parent_level is False - - -def test_sectional_exit_text_parent_level_no_match() -> None: - """Test sectional_exit_text_parent_level returns False when no rules match.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - - # A child that doesn't match any sectional_exiting rules - hostname = config.add_child("hostname TEST") - assert hostname.sectional_exit_text_parent_level is False - - # A simple config line without children - ntp = config.add_child("ntp server 10.0.0.1") - assert ntp.sectional_exit_text_parent_level is False - - -def test_sectional_exit_text_parent_level_with_nested_children() -> None: - """Test sectional_exit_text_parent_level with nested child configurations.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) - - # Create a route-policy with nested children - route_policy = config.add_child("route-policy TEST") - if_statement = route_policy.add_child("if destination in (192.0.2.0/24) then") - - # Parent (route-policy) should have exit_text_parent_level=True - assert route_policy.sectional_exit_text_parent_level is True - - # Nested child should not match the sectional_exiting rule for route-policy - assert if_statement.sectional_exit_text_parent_level is False - - -def test_sectional_exit_text_parent_level_indentation_in_lines() -> None: - """Test that sectional_exit_text_parent_level affects indentation in lines output.""" - platform = Platform.CISCO_XR - config = get_hconfig(platform) - - # Create a route-policy with children - exit text should be at parent level (depth - 1) - route_policy = config.add_child("route-policy TEST") - route_policy.add_child("set local-preference 200") - route_policy.add_child("pass") - - # Get lines with sectional_exiting=True - lines = list(config.lines(sectional_exiting=True)) - - # The last line should be "end-policy" at depth 0 (parent level) - # route-policy is at depth 1, so exit text at depth 0 means no indentation - assert lines[-1] == "end-policy" - assert not lines[-1].startswith(" ") - - -def test_sectional_exit_text_parent_level_generic_platform() -> None: - """Test sectional_exit_text_parent_level with generic platform.""" - platform = Platform.GENERIC - config = get_hconfig(platform) - - # Generic platform has no specific sectional_exiting rules with parent_level=True - section = config.add_child("section test") - assert section.sectional_exit_text_parent_level is False - - -def test_children_eq_with_non_children_type() -> None: - """Test HConfigChildren.__eq__ with non-HConfigChildren object returns NotImplemented.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - - # Directly call __eq__ to verify it returns NotImplemented for non-HConfigChildren types - # We must use __eq__ directly here to test the NotImplemented return value - result = interface.children.__eq__("not a children object") # pylint: disable=unnecessary-dunder-call # ruff:ignore[unnecessary-dunder-call] - assert result is NotImplemented - - # This allows Python to try the reverse comparison, which results in False - assert interface.children != "not a children object" - - -def test_children_clear() -> None: - """Test HConfigChildren.clear() method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - interface.add_child("description test") - interface.add_child("ip address 192.0.2.1 255.255.255.0") - - # Verify children exist - assert len(interface.children) == 2 - assert "description test" in interface.children - - # Clear all children - interface.children.clear() - - # Verify children are gone - assert len(interface.children) == 0 - assert "description test" not in interface.children - - -def test_children_delete_by_child_object() -> None: - """Test HConfigChildren.delete() with HConfigChild object.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - desc = interface.add_child("description test") - ip_addr = interface.add_child("ip address 192.0.2.1 255.255.255.0") - - # Verify both children exist - assert len(interface.children) == 2 - - # Delete by child object - interface.children.delete(desc) - - # Verify only one child remains - assert len(interface.children) == 1 - assert interface.children[0] is ip_addr - assert "description test" not in interface.children - - -def test_children_delete_by_child_object_not_present() -> None: - """Test HConfigChildren.delete() with HConfigChild object that's not in the collection.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface = config.add_child("interface GigabitEthernet0/0") - interface.add_child("description test") - - # Create a child that's not part of this interface - other_interface = config.add_child("interface GigabitEthernet0/1") - other_child = other_interface.add_child("description other") - - # Verify interface has 1 child - assert len(interface.children) == 1 - - # Try to delete a child that's not in the collection - interface.children.delete(other_child) - - # Verify child count hasn't changed - assert len(interface.children) == 1 - - -def test_children_extend() -> None: - """Test HConfigChildren.extend() method.""" - platform = Platform.CISCO_IOS - config = get_hconfig(platform) - interface1 = config.add_child("interface GigabitEthernet0/0") - interface2 = config.add_child("interface GigabitEthernet0/1") - - # Add children to interface2 - desc = interface2.add_child("description test") - ip_addr = interface2.add_child("ip address 192.0.2.1 255.255.255.0") - - # Verify interface1 has no children - assert len(interface1.children) == 0 - - # Extend interface1's children with interface2's children - interface1.children.extend([desc, ip_addr]) - - # Verify interface1 now has 2 children - assert len(interface1.children) == 2 - assert "description test" in interface1.children - assert "ip address 192.0.2.1 255.255.255.0" in interface1.children - - -def test_cisco_style_text_literal_styles(platform_a: Platform) -> None: - """Verify cisco_style_text works with each valid TextStyle literal value (#189).""" - config = get_hconfig(platform_a) - child = config.add_child("interface Vlan2") - child.add_child("ip address 10.0.0.1 255.255.255.0") - - # Each valid style should produce a non-empty string without raising - for style in ("without_comments", "merged", "with_comments"): - result = child.cisco_style_text(style=style) - assert isinstance(result, str) - assert "interface Vlan2" in result - - -def test_future_value_carrying_negation_on_idempotent_line() -> None: - """A negation matching an existing line removes it and does not survive (#269).""" - running_config = get_hconfig( - Platform.ARISTA_EOS, - "router bgp 65000\n" - " neighbor 10.0.0.1 peer group PEERS\n" - " neighbor 10.0.0.1 description spine1\n", - ) - change = get_hconfig( - Platform.ARISTA_EOS, - "router bgp 65000\n" - " no neighbor 10.0.0.1 peer group PEERS\n" - " no neighbor 10.0.0.1 description spine1\n", - ) - future_config = running_config.future(change) - - assert future_config.dump_simple() == ("router bgp 65000",) - - -def test_future_value_differing_negation_replaces_via_idempotency() -> None: - """A stale-valued negation displaces the tracked line but stays visible. - - Idempotency rules declare interchangeable forms of one setting, so the - negation replaces the matched line; keeping it in the render preserves - the did-not-apply-cleanly signal (#269). - """ - running_config = get_hconfig( - Platform.ARISTA_EOS, - "router bgp 65000\n neighbor 10.0.0.1 description spine1\n", - ) - change = get_hconfig( - Platform.ARISTA_EOS, - "router bgp 65000\n no neighbor 10.0.0.1 description stale-value\n", - ) - future_config = running_config.future(change) - - assert future_config.dump_simple() == ( - "router bgp 65000", - " no neighbor 10.0.0.1 description stale-value", - ) - - -def test_future_bare_shorthand_negation_removes_valued_line() -> None: - """`no description` removes `description foo` as devices do (#269).""" - running_config = get_hconfig( - Platform.ARISTA_EOS, - "interface Ethernet1\n description foo\n switchport access vlan 10\n", - ) - change = get_hconfig(Platform.ARISTA_EOS, "interface Ethernet1\n no description\n") - future_config = running_config.future(change) - - assert future_config.dump_simple() == ( - "interface Ethernet1", - " switchport access vlan 10", - ) - - -def test_future_unmatched_negation_is_kept_as_signal() -> None: - """A negation matching nothing still surfaces in the render (#269).""" - running_config = get_hconfig( - Platform.ARISTA_EOS, "interface Ethernet1\n switchport access vlan 10\n" - ) - change = get_hconfig(Platform.ARISTA_EOS, "interface Ethernet1\n no description\n") - future_config = running_config.future(change) - - assert future_config.dump_simple() == ( - "interface Ethernet1", - " no description", - " switchport access vlan 10", - ) - - -def test_future_prune_emptied_parents() -> None: - """Removing the last child can prune the emptied ancestors (#269).""" - running_config = get_hconfig( - Platform.CISCO_XR, - "router static\n address-family ipv4 unicast\n 192.0.2.0/24 Null0\n", - ) - change = get_hconfig( - Platform.CISCO_XR, - "router static\n address-family ipv4 unicast\n no 192.0.2.0/24 Null0\n", - ) - - assert not running_config.future(change, prune_empty_branches=True).dump_simple() - # Default keeps the emptied parents (spurious-diff behavior is opt-out only). - assert running_config.future(change).dump_simple() == ( - "router static", - " address-family ipv4 unicast", - ) - - -def test_future_prune_keeps_originally_empty_parents() -> None: - """Pruning only removes parents that had children in the running config (#269).""" - running_config = get_hconfig( - Platform.CISCO_XR, "interface GigabitEthernet0/0/0/0\n" - ) - change = get_hconfig(Platform.CISCO_XR, "hostname r1\n") - future_config = running_config.future(change, prune_empty_branches=True) - - assert future_config.dump_simple() == ( - "hostname r1", - "interface GigabitEthernet0/0/0/0", - ) diff --git a/tests/test_juniper_syntax.py b/tests/test_juniper_syntax.py deleted file mode 100644 index bc2ccb11..00000000 --- a/tests/test_juniper_syntax.py +++ /dev/null @@ -1,49 +0,0 @@ -from hier_config import WorkflowRemediation, get_hconfig, get_hconfig_fast_load -from hier_config.models import Platform - - -def test_junos_basic_remediation() -> None: - platform = Platform.JUNIPER_JUNOS - running_config_str = "set vlans switch_mgmt_10.0.2.0/24 vlan-id 2" - generated_config_str = "set vlans switch_mgmt_10.0.3.0/24 vlan-id 3" - remediation_str = "delete vlans switch_mgmt_10.0.2.0/24 vlan-id 2\nset vlans switch_mgmt_10.0.3.0/24 vlan-id 3" - - workflow_remediation = WorkflowRemediation( - get_hconfig_fast_load(platform, running_config_str), - get_hconfig_fast_load(platform, generated_config_str), - ) - - assert workflow_remediation.remediation_config_filtered_text() == remediation_str - - -def test_junos_convert_to_set( - running_config_junos: str, - generated_config_junos: str, - remediation_config_flat_junos: str, -) -> None: - platform = Platform.JUNIPER_JUNOS - workflow_remediation = WorkflowRemediation( - get_hconfig(platform, running_config_junos), - get_hconfig(platform, generated_config_junos), - ) - - assert ( - workflow_remediation.remediation_config_filtered_text() - == remediation_config_flat_junos - ) - - -def test_flat_junos_remediation( - running_config_flat_junos: str, - generated_config_flat_junos: str, - remediation_config_flat_junos: str, -) -> None: - platform = Platform.JUNIPER_JUNOS - workflow_remediation = WorkflowRemediation( - get_hconfig_fast_load(platform, running_config_flat_junos), - get_hconfig_fast_load(platform, generated_config_flat_junos), - ) - - remediation_list = remediation_config_flat_junos.splitlines() - for line in str(workflow_remediation.remediation_config).splitlines(): - assert line in remediation_list diff --git a/tests/test_workflow.py b/tests/test_workflow.py deleted file mode 100644 index e74d7e61..00000000 --- a/tests/test_workflow.py +++ /dev/null @@ -1,72 +0,0 @@ -import pytest - -from hier_config import WorkflowRemediation, get_hconfig -from hier_config.models import Platform, TagRule - - -@pytest.fixture(name="wfr") -def workflow_remediation( - running_config: str, generated_config: str -) -> WorkflowRemediation: - return WorkflowRemediation( - running_config=get_hconfig(Platform.CISCO_IOS, running_config), - generated_config=get_hconfig(Platform.CISCO_IOS, generated_config), - ) - - -def test_config_lengths(wfr: WorkflowRemediation) -> None: - assert wfr.running_config.children - assert wfr.generated_config.children - assert wfr.remediation_config.children - assert wfr.rollback_config.children - - -def test_apply_tags( - wfr: WorkflowRemediation, tag_rules_ios: tuple[TagRule, ...] -) -> None: - wfr.apply_remediation_tag_rules(tag_rules_ios) - assert len(wfr.remediation_config.tags) > 0 - - -def test_remediation_config_filtered_text( - wfr: WorkflowRemediation, - tag_rules_ios: tuple[TagRule, ...], - remediation_config_with_safe_tags: str, - remediation_config_without_tags: str, -) -> None: - wfr.apply_remediation_tag_rules(tag_rules_ios) - - rem1 = wfr.remediation_config_filtered_text(set(), set()) - rem2 = wfr.remediation_config_filtered_text({"safe"}, set()) - - assert rem1 != rem2 - assert rem1 == remediation_config_without_tags - assert rem2 == remediation_config_with_safe_tags - - -def test_remediation_config_driver_mismatch() -> None: - # Test to ensure ValueError is raised for mismatched drivers - running_config = get_hconfig(Platform.CISCO_IOS, "dummy_config") - generated_config = get_hconfig(Platform.JUNIPER_JUNOS, "dummy_config") - - with pytest.raises( - ValueError, match=r"The running and generated configs must use the same driver." - ): - WorkflowRemediation(running_config, generated_config) - - -def test_rollback_config_exists(wfr: WorkflowRemediation) -> None: - # Check if rollback config is generated and accessible - rollback_config = wfr.rollback_config - assert rollback_config is not None - assert len(rollback_config.children) > 0 # Ensure rollback config has content - - -def test_rollback_config_reverts_changes(wfr: WorkflowRemediation) -> None: - # Test if rollback config correctly represents changes needed to revert generated to running - rollback_config = wfr.rollback_config - rollback_text = "\n".join( - line.cisco_style_text() for line in rollback_config.all_children_sorted() - ) - expected_text = "no vlan 4\nno interface Vlan4\nvlan 3\n name switch_mgmt_10.0.4.0/24\ninterface Vlan2\n no mtu 9000\n no ip access-group TEST in\n shutdown\ninterface Vlan3\n description switch_mgmt_10.0.4.0/24\n ip address 10.0.4.1 255.255.0.0" - assert rollback_text == expected_text diff --git a/tests/test_xr_comments.py b/tests/test_xr_comments.py deleted file mode 100644 index d16db673..00000000 --- a/tests/test_xr_comments.py +++ /dev/null @@ -1,144 +0,0 @@ -from hier_config import get_hconfig, get_hconfig_fast_load -from hier_config.models import Platform - - -def test_xr_comment_attached_to_next_sibling() -> None: - """IOS-XR inline comments are attached to the next sibling's comments set.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - ! ISIS network number should be encoded with 0-padded loopback IP - net 49.0001.1921.2022.0222.00 -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child( - startswith="net ", - ) - assert net_child is not None - assert ( - "ISIS network number should be encoded with 0-padded loopback IP" - in net_child.comments - ) - - -def test_xr_multiple_comments_before_line() -> None: - """Multiple consecutive comment lines are all attached to the next sibling.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - ! first comment - ! second comment - net 49.0001.1921.2022.0222.00 -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child(startswith="net ") - assert net_child is not None - assert "first comment" in net_child.comments - assert "second comment" in net_child.comments - - -def test_xr_comment_lines_not_parsed_as_children() -> None: - """Comment lines starting with ! should not appear as config children.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - ! this is a comment - net 49.0001.1921.2022.0222.00 -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - for child in router_isis.all_children(): - assert not child.text.startswith("!") - - -def test_xr_top_level_bang_delimiters_stripped() -> None: - """Top-level ! delimiters (with no comment text) are stripped.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -hostname router1 -! -interface GigabitEthernet0/0/0/0 - description test -! -""", - ) - children = [child.text for child in config.children] - assert "hostname router1" in children - assert "interface GigabitEthernet0/0/0/0" in children - assert "!" not in children - - -def test_xr_comment_preservation_with_fast_load() -> None: - """Comments are also preserved when using get_hconfig_fast_load.""" - config = get_hconfig_fast_load( - Platform.CISCO_XR, - ( - "router isis backbone", - " ! loopback comment", - " net 49.0001.0000.0000.0001.00", - ), - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child(startswith="net ") - assert net_child is not None - assert "loopback comment" in net_child.comments - - -def test_xr_hash_comments_still_stripped() -> None: - """Lines starting with # are still stripped (not preserved).""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -hostname router1 -# this should be stripped -interface GigabitEthernet0/0/0/0 -""", - ) - for child in config.all_children(): - assert not child.text.startswith("#") - - -def test_xr_comment_with_leading_bang_preserved() -> None: - """A comment containing ! in its body is preserved correctly.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - ! !important note about ISIS - net 49.0001.1921.2022.0222.00 -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child(startswith="net ") - assert net_child is not None - assert "!important note about ISIS" in net_child.comments - - -def test_xr_trailing_comment_with_no_following_sibling_is_dropped() -> None: - """A trailing ! comment at the end of a section with no following sibling is silently dropped.""" - config = get_hconfig( - Platform.CISCO_XR, - """\ -router isis backbone - net 49.0001.1921.2022.0222.00 - ! trailing comment with no following sibling -""", - ) - router_isis = config.get_child(equals="router isis backbone") - assert router_isis is not None - net_child = router_isis.get_child(startswith="net ") - assert net_child is not None - assert len(net_child.comments) == 0 - for child in router_isis.all_children(): - assert not child.text.startswith("!") diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/platforms/__init__.py b/tests/unit/platforms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/platforms/test_aruba_aoscx.py b/tests/unit/platforms/test_aruba_aoscx.py new file mode 100644 index 00000000..efb147ee --- /dev/null +++ b/tests/unit/platforms/test_aruba_aoscx.py @@ -0,0 +1,13 @@ +from hier_config.platforms.aruba_aoscx.driver import ( + HConfigDriverArubaAOSCX, + split_interface_vlan_trunk_allowed, +) +from hier_config.platforms.utils import split_vlan_id_lists + + +def test_default_post_load_callbacks_are_public() -> None: + """Built-in AOS-CX post-load callbacks are public, pinned by identity (#286).""" + callbacks = HConfigDriverArubaAOSCX().rules.post_load_callbacks + + assert split_vlan_id_lists in callbacks + assert split_interface_vlan_trunk_allowed in callbacks diff --git a/tests/unit/platforms/test_cisco_ios.py b/tests/unit/platforms/test_cisco_ios.py new file mode 100644 index 00000000..8cb60e98 --- /dev/null +++ b/tests/unit/platforms/test_cisco_ios.py @@ -0,0 +1,75 @@ +from hier_config import HConfig +from hier_config.models import Platform +from hier_config.platforms.cisco_ios.driver import ( + HConfigDriverCiscoIOS, + add_acl_sequence_numbers, + remove_ipv4_acl_remarks, + remove_ipv6_acl_sequence_numbers, +) +from hier_config.platforms.utils import split_vlan_id_lists + + +def test_remove_ipv6_acl_sequence_numbers() -> None: + """Test post-load callback that removes IPv6 ACL sequence numbers.""" + platform = Platform.CISCO_IOS + config_text = "ipv6 access-list TEST_IPV6_ACL\n sequence 10 permit tcp any any eq 443\n sequence 20 deny ipv6 any any" + config = HConfig.from_text(platform, config_text) + acl = config.get_child(equals="ipv6 access-list TEST_IPV6_ACL") + + assert acl is not None + assert acl.get_child(equals="permit tcp any any eq 443") is not None + assert acl.get_child(equals="deny ipv6 any any") is not None + assert acl.get_child(startswith="sequence") is None + + +def test_remove_ipv4_acl_remarks() -> None: + """Test post-load callback that removes IPv4 ACL remarks.""" + platform = Platform.CISCO_IOS + config_text = "ip access-list extended TEST_ACL\n remark Allow HTTPS traffic\n permit tcp any any eq 443\n remark Block all other traffic\n deny ip any any" + config = HConfig.from_text(platform, config_text) + acl = config.get_child(equals="ip access-list extended TEST_ACL") + + assert acl is not None + assert acl.get_child(equals="10 permit tcp any any eq 443") is not None + assert acl.get_child(equals="20 deny ip any any") is not None + assert acl.get_child(startswith="remark") is None + + +def test_add_acl_sequence_numbers() -> None: + """Test post-load callback that adds sequence numbers to IPv4 ACLs.""" + platform = Platform.CISCO_IOS + config_text = "ip access-list extended TEST_ACL\n permit tcp any any eq 443\n permit tcp any any eq 80\n deny ip any any" + config = HConfig.from_text(platform, config_text) + acl = config.get_child(equals="ip access-list extended TEST_ACL") + + assert acl is not None + assert acl.get_child(equals="10 permit tcp any any eq 443") is not None + assert acl.get_child(equals="20 permit tcp any any eq 80") is not None + assert acl.get_child(equals="30 deny ip any any") is not None + + +def test_default_post_load_callbacks_are_public() -> None: + """Built-in IOS post-load callbacks are public and pinned by identity (#286).""" + callbacks = HConfigDriverCiscoIOS().rules.post_load_callbacks + + assert remove_ipv6_acl_sequence_numbers in callbacks + assert remove_ipv4_acl_remarks in callbacks + assert add_acl_sequence_numbers in callbacks + assert split_vlan_id_lists in callbacks + + +def test_remove_ipv4_acl_remarks_callback_removable_by_identity() -> None: + """The docs recipe: removing the public callback keeps ACL remarks (#286).""" + driver = HConfigDriverCiscoIOS() + driver.rules.post_load_callbacks.remove(remove_ipv4_acl_remarks) + config_text = ( + "ip access-list extended TEST_ACL\n" + " remark Allow HTTPS traffic\n" + " permit tcp any any eq 443\n" + ) + config = HConfig.from_text(driver, config_text) + acl = config.get_child(equals="ip access-list extended TEST_ACL") + + assert acl is not None + assert acl.get_child(equals="remark Allow HTTPS traffic") is not None + assert acl.get_child(equals="10 permit tcp any any eq 443") is not None diff --git a/tests/unit/platforms/test_cisco_xr.py b/tests/unit/platforms/test_cisco_xr.py new file mode 100644 index 00000000..bbf87979 --- /dev/null +++ b/tests/unit/platforms/test_cisco_xr.py @@ -0,0 +1,479 @@ +from hier_config import HConfig +from hier_config.models import Platform +from hier_config.platforms.cisco_xr.driver import ( + HConfigDriverCiscoIOSXR, + fixup_xr_comments, +) + + +def test_multiple_groups_no_duplicate_child_error() -> None: + """Test that multiple group blocks don't raise DuplicateChildError (issue #209).""" + platform = Platform.CISCO_XR + config_text = """\ +hostname router1 +group core + interface 'Bundle-Ether.*' + mtu 9188 + ! +end-group +group edge + interface 'Bundle-Ether.*' + mtu 9092 + ! +end-group +""" + hconfig = HConfig.from_text(platform, config_text) + children = [child.text for child in hconfig.children] + assert "hostname router1" in children + assert "group core" in children + assert "group edge" in children + + +def test_sectional_exit_text_parent_level_route_policy() -> None: + """Test that route-policy exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "route-policy TEST", + " set local-preference 200", + " pass", + ), + ) + + route_policy = config.get_child(equals="route-policy TEST") + assert route_policy is not None + assert route_policy.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "route-policy TEST", + " set local-preference 200", + " pass", + "end-policy", + ) + + +def test_sectional_exit_text_parent_level_prefix_set() -> None: + """Test that prefix-set exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + " 198.51.100.0/24", + ), + ) + + prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") + assert prefix_set is not None + assert prefix_set.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + " 198.51.100.0/24", + "end-set", + ) + + +def test_sectional_exit_text_parent_level_policy_map() -> None: + """Test that policy-map exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "policy-map TEST_POLICY", + " class TEST_CLASS", + " set precedence 5", + ), + ) + + policy_map = config.get_child(equals="policy-map TEST_POLICY") + assert policy_map is not None + assert policy_map.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "policy-map TEST_POLICY", + " class TEST_CLASS", + " set precedence 5", + " exit", + "end-policy-map", + ) + + +def test_sectional_exit_text_parent_level_class_map() -> None: + """Test that class-map exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "class-map match-any TEST_CLASS", + " match access-group TEST_ACL", + ), + ) + + class_map = config.get_child(equals="class-map match-any TEST_CLASS") + assert class_map is not None + assert class_map.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "class-map match-any TEST_CLASS", + " match access-group TEST_ACL", + "end-class-map", + ) + + +def test_sectional_exit_text_parent_level_community_set() -> None: + """Test that community-set exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "community-set TEST_COMM", + " 65001:100", + " 65001:200", + ), + ) + + community_set = config.get_child(equals="community-set TEST_COMM") + assert community_set is not None + assert community_set.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "community-set TEST_COMM", + " 65001:100", + " 65001:200", + "end-set", + ) + + +def test_sectional_exit_text_parent_level_extcommunity_set() -> None: + """Test that extcommunity-set exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "extcommunity-set rt TEST_RT", + " 1:100", + " 2:200", + ), + ) + + extcommunity_set = config.get_child(equals="extcommunity-set rt TEST_RT") + assert extcommunity_set is not None + assert extcommunity_set.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "extcommunity-set rt TEST_RT", + " 1:100", + " 2:200", + "end-set", + ) + + +def test_sectional_exit_text_parent_level_template() -> None: + """Test that template exit text appears at parent level (no indentation).""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "template TEST_TEMPLATE", + " description test template", + ), + ) + + template = config.get_child(equals="template TEST_TEMPLATE") + assert template is not None + assert template.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "template TEST_TEMPLATE", + " description test template", + "end-template", + ) + + +def test_sectional_exit_text_current_level_interface() -> None: + """Test that interface exit text appears at current level (with indentation).""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "interface GigabitEthernet0/0/0/0", + " description test interface", + " ipv4 address 192.0.2.1 255.255.255.0", + ), + ) + + interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") + assert interface is not None + assert interface.sectional_exit_text_parent_level is False + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "interface GigabitEthernet0/0/0/0", + " description test interface", + " ipv4 address 192.0.2.1 255.255.255.0", + " root", + ) + + +def test_sectional_exit_text_current_level_router_bgp() -> None: + """Test that router bgp exit text appears at current level (with indentation).""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "router bgp 65000", + " bgp router-id 192.0.2.1", + " address-family ipv4 unicast", + ), + ) + + router_bgp = config.get_child(equals="router bgp 65000") + assert router_bgp is not None + assert router_bgp.sectional_exit_text_parent_level is False + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "router bgp 65000", + " bgp router-id 192.0.2.1", + " address-family ipv4 unicast", + " root", + ) + + +def test_sectional_exit_text_multiple_sections() -> None: + """Test multiple sections with different exit text level behaviors.""" + platform = Platform.CISCO_XR + config = HConfig.from_lines( + platform, + ( + "route-policy TEST1", + " pass", + "!", + "interface GigabitEthernet0/0/0/0", + " description test", + "!", + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + ), + ) + + route_policy = config.get_child(equals="route-policy TEST1") + assert route_policy is not None + assert route_policy.sectional_exit_text_parent_level is True + + interface = config.get_child(equals="interface GigabitEthernet0/0/0/0") + assert interface is not None + assert interface.sectional_exit_text_parent_level is False + + prefix_set = config.get_child(equals="prefix-set TEST_PREFIX") + assert prefix_set is not None + assert prefix_set.sectional_exit_text_parent_level is True + + output = config.to_lines(sectional_exiting=True) + assert output == ( + "route-policy TEST1", + " pass", + "end-policy", + "interface GigabitEthernet0/0/0/0", + " description test", + " root", + "prefix-set TEST_PREFIX", + " 192.0.2.0/24", + "end-set", + ) + + +def test_indented_bang_section_separators_no_duplicate_child_error() -> None: + """Test that indented ! section separators don't raise DuplicateChildError (issue #231).""" + platform = Platform.CISCO_XR + config_text = """\ +telemetry model-driven + destination-group DEST-GROUP-1 + address-family ipv4 10.0.0.1 port 57000 + encoding self-describing-gpb + protocol tcp + ! + ! + destination-group DEST-GROUP-2 + address-family ipv4 10.0.0.2 port 57000 + encoding self-describing-gpb + protocol tcp + ! + ! + sensor-group SENSOR-1 + sensor-path openconfig-platform:components/component/cpu + sensor-path openconfig-platform:components/component/memory + ! + sensor-group SENSOR-2 + sensor-path openconfig-interfaces:interfaces/interface/state/counters + ! +! +""" + hconfig = HConfig.from_text(platform, config_text) + telemetry = hconfig.get_child(equals="telemetry model-driven") + assert telemetry is not None + child_texts = [child.text for child in telemetry.children] + assert "destination-group DEST-GROUP-1" in child_texts + assert "destination-group DEST-GROUP-2" in child_texts + assert "sensor-group SENSOR-1" in child_texts + assert "sensor-group SENSOR-2" in child_texts + + +def test_xr_comment_attached_to_next_sibling() -> None: + """IOS-XR inline comments are attached to the next sibling's comments set.""" + config = HConfig.from_text( + Platform.CISCO_XR, + """\ +router isis backbone + ! ISIS network number should be encoded with 0-padded loopback IP + net 49.0001.1921.2022.0222.00 +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child( + startswith="net ", + ) + assert net_child is not None + assert ( + "ISIS network number should be encoded with 0-padded loopback IP" + in net_child.comments + ) + + +def test_xr_multiple_comments_before_line() -> None: + """Multiple consecutive comment lines are all attached to the next sibling.""" + config = HConfig.from_text( + Platform.CISCO_XR, + """\ +router isis backbone + ! first comment + ! second comment + net 49.0001.1921.2022.0222.00 +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child(startswith="net ") + assert net_child is not None + assert "first comment" in net_child.comments + assert "second comment" in net_child.comments + + +def test_xr_comment_lines_not_parsed_as_children() -> None: + """Comment lines starting with ! should not appear as config children.""" + config = HConfig.from_text( + Platform.CISCO_XR, + """\ +router isis backbone + ! this is a comment + net 49.0001.1921.2022.0222.00 +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + for child in router_isis.all_children(): + assert not child.text.startswith("!") + + +def test_xr_top_level_bang_delimiters_stripped() -> None: + """Top-level ! delimiters (with no comment text) are stripped.""" + config = HConfig.from_text( + Platform.CISCO_XR, + """\ +hostname router1 +! +interface GigabitEthernet0/0/0/0 + description test +! +""", + ) + children = [child.text for child in config.children] + assert "hostname router1" in children + assert "interface GigabitEthernet0/0/0/0" in children + assert "!" not in children + + +def test_xr_comment_preservation_with_fast_load() -> None: + """Comments are also preserved when using get_hconfig_fast_load.""" + config = HConfig.from_lines( + Platform.CISCO_XR, + ( + "router isis backbone", + " ! loopback comment", + " net 49.0001.0000.0000.0001.00", + ), + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child(startswith="net ") + assert net_child is not None + assert "loopback comment" in net_child.comments + + +def test_xr_hash_comments_still_stripped() -> None: + """Lines starting with # are still stripped (not preserved).""" + config = HConfig.from_text( + Platform.CISCO_XR, + """\ +hostname router1 +# this should be stripped +interface GigabitEthernet0/0/0/0 +""", + ) + for child in config.all_children(): + assert not child.text.startswith("#") + + +def test_xr_comment_with_leading_bang_preserved() -> None: + """A comment containing ! in its body is preserved correctly.""" + config = HConfig.from_text( + Platform.CISCO_XR, + """\ +router isis backbone + ! !important note about ISIS + net 49.0001.1921.2022.0222.00 +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child(startswith="net ") + assert net_child is not None + assert "!important note about ISIS" in net_child.comments + + +def test_xr_trailing_comment_with_no_following_sibling_is_dropped() -> None: + """A trailing ! comment at the end of a section with no following sibling is silently dropped.""" + config = HConfig.from_text( + Platform.CISCO_XR, + """\ +router isis backbone + net 49.0001.1921.2022.0222.00 + ! trailing comment with no following sibling +""", + ) + router_isis = config.get_child(equals="router isis backbone") + assert router_isis is not None + net_child = router_isis.get_child(startswith="net ") + assert net_child is not None + assert len(net_child.comments) == 0 + for child in router_isis.all_children(): + assert not child.text.startswith("!") + + +def test_default_post_load_callbacks_are_public() -> None: + """Built-in XR post-load callbacks are public and pinned by identity (#286).""" + callbacks = HConfigDriverCiscoIOSXR().rules.post_load_callbacks + + assert fixup_xr_comments in callbacks diff --git a/tests/test_driver.py b/tests/unit/platforms/test_driver_base.py similarity index 94% rename from tests/test_driver.py rename to tests/unit/platforms/test_driver_base.py index 30e2648a..135f38f3 100644 --- a/tests/test_driver.py +++ b/tests/unit/platforms/test_driver_base.py @@ -1,6 +1,5 @@ -from hier_config import get_hconfig_driver +from hier_config import HConfig, get_hconfig_driver from hier_config.child import HConfigChild -from hier_config.constructors import get_hconfig from hier_config.models import Platform from hier_config.platforms.arista_eos.driver import HConfigDriverAristaEOS from hier_config.platforms.aruba_aoscx.driver import HConfigDriverArubaAOSCX @@ -32,7 +31,7 @@ def test_driver_base_properties() -> None: assert not driver.declaration_prefix assert driver.negation_prefix == "no " - config = get_hconfig(Platform.GENERIC) + config = HConfig.from_text(Platform.GENERIC) child = HConfigChild(config, "interface GigabitEthernet0/0") result = driver.swap_negation(child) assert result.text == "no interface GigabitEthernet0/0" diff --git a/tests/unit/platforms/test_fortinet_fortios.py b/tests/unit/platforms/test_fortinet_fortios.py new file mode 100644 index 00000000..a1362a7e --- /dev/null +++ b/tests/unit/platforms/test_fortinet_fortios.py @@ -0,0 +1,72 @@ +from hier_config import HConfig +from hier_config.child import HConfigChild +from hier_config.models import Platform +from hier_config.platforms.fortinet_fortios.driver import HConfigDriverFortinetFortiOS + + +def test_swap_negation_direct() -> None: + """Test swap_negation method directly to cover set-to-unset conversion.""" + driver = HConfigDriverFortinetFortiOS() + config = HConfig.from_text(Platform.FORTINET_FORTIOS) + child = HConfigChild(config, "set description 'test value'") + result = driver.swap_negation(child) + assert result.text == "unset description" + + child2 = HConfigChild(config, "unset description") + result2 = driver.swap_negation(child2) + + assert result2.text == "set description" + + +def test_swap_negation_drops_parameters_intentionally() -> None: + """FortiOS negation resets an attribute to its default via `unset `. + + The value is never part of the unset command, so parameters after the + attribute name must be dropped (#225). + """ + driver = HConfigDriverFortinetFortiOS() + config = HConfig.from_text(Platform.FORTINET_FORTIOS) + child = HConfigChild(config, 'set description "Port 1"') + result = driver.swap_negation(child) + + assert result.text == "unset description" + + +def test_swap_negation_bare_set_is_unchanged() -> None: + """A bare `set` command with no attribute has nothing to negate (#225).""" + driver = HConfigDriverFortinetFortiOS() + config = HConfig.from_text(Platform.FORTINET_FORTIOS) + child = HConfigChild(config, "set") + result = driver.swap_negation(child) + + assert result.text == "set" + + +def test_idempotent_for_matches_same_attribute() -> None: + """Two `set` commands for the same attribute are idempotent (#225).""" + driver = HConfigDriverFortinetFortiOS() + config = HConfig.from_text(Platform.FORTINET_FORTIOS) + child = HConfigChild(config, "set primary 192.0.2.1") + other = HConfigChild(config, "set primary 192.0.2.3") + + assert driver.idempotent_for(child, [other]) is other + + +def test_idempotent_for_different_attribute_returns_none() -> None: + """`set` commands for different attributes are not idempotent (#225).""" + driver = HConfigDriverFortinetFortiOS() + config = HConfig.from_text(Platform.FORTINET_FORTIOS) + child = HConfigChild(config, "set primary 192.0.2.1") + other = HConfigChild(config, "set secondary 192.0.2.3") + + assert driver.idempotent_for(child, [other]) is None + + +def test_idempotent_for_single_word_commands_do_not_crash() -> None: + """Single-word commands must not raise IndexError in idempotent_for (#225).""" + driver = HConfigDriverFortinetFortiOS() + config = HConfig.from_text(Platform.FORTINET_FORTIOS) + child = HConfigChild(config, "set") + other = HConfigChild(config, "set") + + assert driver.idempotent_for(child, [other]) is None diff --git a/tests/test_driver_hp_procurve.py b/tests/unit/platforms/test_hp_procurve.py similarity index 50% rename from tests/test_driver_hp_procurve.py rename to tests/unit/platforms/test_hp_procurve.py index e3221946..dfe4b1cb 100644 --- a/tests/test_driver_hp_procurve.py +++ b/tests/unit/platforms/test_hp_procurve.py @@ -1,92 +1,18 @@ -from hier_config import get_hconfig_fast_load -from hier_config.constructors import get_hconfig +from hier_config import HConfig from hier_config.models import Platform - - -def test_negate_with() -> None: - platform = Platform.HP_PROCURVE - running_config = get_hconfig_fast_load( - platform, - ( - "aaa port-access authenticator 1/1 tx-period 3", - "aaa port-access authenticator 1/1 supplicant-timeout 3", - "aaa port-access authenticator 1/1 client-limit 4", - "aaa port-access mac-based 1/1 addr-limit 4", - "aaa port-access mac-based 1/1 logoff-period 3", - 'aaa port-access 1/1 critical-auth user-role "allowall"', - ), - ) - generated_config = get_hconfig(platform) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( - "aaa port-access authenticator 1/1 tx-period 30", - "aaa port-access authenticator 1/1 supplicant-timeout 30", - "no aaa port-access authenticator 1/1 client-limit", - "aaa port-access mac-based 1/1 addr-limit 1", - "aaa port-access mac-based 1/1 logoff-period 300", - "no aaa port-access 1/1 critical-auth user-role", - ) - - -def test_idempotent_for() -> None: - platform = Platform.HP_PROCURVE - running_config = get_hconfig_fast_load( - platform, - ( - "aaa port-access authenticator 1/1 tx-period 3", - "aaa port-access authenticator 1/1 supplicant-timeout 3", - "aaa port-access authenticator 1/1 client-limit 4", - "aaa port-access mac-based 1/1 addr-limit 4", - "aaa port-access mac-based 1/1 logoff-period 3", - 'aaa port-access 1/1 critical-auth user-role "allowall"', - ), - ) - generated_config = get_hconfig_fast_load( - platform, - ( - "aaa port-access authenticator 1/1 tx-period 4", - "aaa port-access authenticator 1/1 supplicant-timeout 4", - "aaa port-access authenticator 1/1 client-limit 5", - "aaa port-access mac-based 1/1 addr-limit 5", - "aaa port-access mac-based 1/1 logoff-period 4", - 'aaa port-access 1/1 critical-auth user-role "allownone"', - ), - ) - remediation_config = running_config.config_to_get_to(generated_config) - assert remediation_config.dump_simple() == ( - "aaa port-access authenticator 1/1 tx-period 4", - "aaa port-access authenticator 1/1 supplicant-timeout 4", - "aaa port-access authenticator 1/1 client-limit 5", - "aaa port-access mac-based 1/1 addr-limit 5", - "aaa port-access mac-based 1/1 logoff-period 4", - 'aaa port-access 1/1 critical-auth user-role "allownone"', - ) - - -def test_future() -> None: - platform = Platform.HP_PROCURVE - running_config = get_hconfig(platform) - remediation_config = get_hconfig_fast_load( - platform, - ( - "aaa port-access authenticator 3/34", - "aaa port-access authenticator 3/34 tx-period 10", - "aaa port-access authenticator 3/34 supplicant-timeout 10", - "aaa port-access authenticator 3/34 client-limit 2", - "aaa port-access mac-based 3/34", - "aaa port-access mac-based 3/34 addr-limit 2", - 'aaa port-access 3/34 critical-auth user-role "allowall"', - ), - ) - future_config = running_config.future(remediation_config) - assert not tuple(remediation_config.unified_diff(future_config)) +from hier_config.platforms.hp_procurve.driver import ( + HConfigDriverHPProcurve, + fixup_hp_procurve_aaa_port_access, + fixup_hp_procurve_device_profile, + fixup_hp_procurve_vlan, +) def test_fixup_aaa_port_access_ranges() -> None: """Test post-load callback that expands interface ranges in AAA port-access commands (covers lines 33-38).""" platform = Platform.HP_PROCURVE config_text = "aaa port-access authenticator 1/15-1/20,1/26-1/28\naaa port-access mac-based 2/14-2/16\naaa port-access authenticator 1/1" - config = get_hconfig(platform, config_text) + config = HConfig.from_text(platform, config_text) assert config.get_child(equals="aaa port-access authenticator 1/15") is not None assert config.get_child(equals="aaa port-access authenticator 1/16") is not None @@ -112,7 +38,7 @@ def test_fixup_vlan_transformation() -> None: """Test post-load callback that transforms VLAN config to interface config (covers lines 63-88).""" platform = Platform.HP_PROCURVE config_text = "vlan 80\n untagged 2/43-2/44,3/43-3/44\n tagged 1/23,2/23,Trk1\nvlan 90\n untagged 5/29\n no untagged 1/2-1/5" - config = get_hconfig(platform, config_text) + config = HConfig.from_text(platform, config_text) interface_2_43 = config.get_child(equals="interface 2/43") assert interface_2_43 is not None @@ -163,7 +89,7 @@ def test_fixup_device_profile_tagged_vlans() -> None: """Test post-load callback that separates device-profile tagged-vlans onto individual lines (covers lines 104-110).""" platform = Platform.HP_PROCURVE config_text = 'device-profile name "phone"\n tagged-vlan 10,20,30\ndevice-profile name "printer"\n tagged-vlan 40' - config = get_hconfig(platform, config_text) + config = HConfig.from_text(platform, config_text) device_profile_phone = config.get_child(equals='device-profile name "phone"') assert device_profile_phone is not None @@ -178,45 +104,10 @@ def test_fixup_device_profile_tagged_vlans() -> None: assert device_profile_printer.get_child(equals="tagged-vlan 40") is not None -def test_negate_with_child_config() -> None: - """Test negate_with returns None for non-root config without special rule (covers line 166).""" - platform = Platform.HP_PROCURVE - running_config = get_hconfig_fast_load( - platform, - ( - "interface 1/1", - " speed-duplex auto", - ), - ) - generated_config = get_hconfig_fast_load( - platform, - ("interface 1/1",), - ) - remediation_config = running_config.config_to_get_to(generated_config) - - assert remediation_config.dump_simple() == ( - "interface 1/1", - " no speed-duplex auto", - ) +def test_default_post_load_callbacks_are_public() -> None: + """Built-in ProCurve post-load callbacks are public, pinned by identity (#286).""" + callbacks = HConfigDriverHPProcurve().rules.post_load_callbacks - -def test_negate_with_from_base_driver() -> None: - """Test negate_with uses parent driver rule when applicable (covers line 163).""" - platform = Platform.HP_PROCURVE - running_config = get_hconfig_fast_load( - platform, - ( - "interface 1/1", - " disable", - ), - ) - generated_config = get_hconfig_fast_load( - platform, - ("interface 1/1",), - ) - remediation_config = running_config.config_to_get_to(generated_config) - - assert remediation_config.dump_simple() == ( - "interface 1/1", - " enable", - ) + assert fixup_hp_procurve_aaa_port_access in callbacks + assert fixup_hp_procurve_device_profile in callbacks + assert fixup_hp_procurve_vlan in callbacks diff --git a/tests/unit/platforms/test_juniper_junos.py b/tests/unit/platforms/test_juniper_junos.py new file mode 100644 index 00000000..c9fe7c7e --- /dev/null +++ b/tests/unit/platforms/test_juniper_junos.py @@ -0,0 +1,41 @@ +import pytest + +from hier_config import HConfig +from hier_config.child import HConfigChild +from hier_config.models import Platform +from hier_config.platforms.juniper_junos.driver import HConfigDriverJuniperJUNOS + + +def test_swap_negation_delete_to_set() -> None: + """Test swapping from 'delete' to 'set' prefix.""" + platform = Platform.JUNIPER_JUNOS + driver = HConfigDriverJuniperJUNOS() + root = HConfig.from_text(platform) + child = HConfigChild(root, "delete vlans test_vlan vlan-id 100") + result = driver.swap_negation(child) + assert result.text == "set vlans test_vlan vlan-id 100" + assert result.text.startswith("set ") + + +def test_swap_negation_set_to_delete() -> None: + """Test swapping from 'set' to 'delete' prefix.""" + platform = Platform.JUNIPER_JUNOS + driver = HConfigDriverJuniperJUNOS() + root = HConfig.from_text(platform) + child = HConfigChild(root, "set vlans test_vlan vlan-id 100") + result = driver.swap_negation(child) + assert result.text == "delete vlans test_vlan vlan-id 100" + assert result.text.startswith("delete ") + + +def test_swap_negation_invalid_prefix() -> None: + """Test ValueError when text has neither 'set' nor 'delete' prefix.""" + platform = Platform.JUNIPER_JUNOS + driver = HConfigDriverJuniperJUNOS() + root = HConfig.from_text(platform) + child = HConfigChild(root, "vlans test_vlan vlan-id 100") + with pytest.raises(ValueError, match="did not start with") as exc_info: + driver.swap_negation(child) + assert "did not start with" in str(exc_info.value) + assert "delete " in str(exc_info.value) + assert "set " in str(exc_info.value) diff --git a/tests/unit/platforms/test_nokia_srl.py b/tests/unit/platforms/test_nokia_srl.py new file mode 100644 index 00000000..1eb84d6b --- /dev/null +++ b/tests/unit/platforms/test_nokia_srl.py @@ -0,0 +1,94 @@ +from hier_config import HConfig +from hier_config.child import HConfigChild +from hier_config.models import Platform +from hier_config.platforms.nokia_srl.driver import HConfigDriverNokiaSRL + + +def test_swap_negation_delete_to_set() -> None: + """Test swapping from 'delete' to 'set' prefix.""" + platform = Platform.NOKIA_SRL + driver = HConfigDriverNokiaSRL() + root = HConfig.from_text(platform) + + child = HConfigChild( + root, "delete interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" + ) + result = driver.swap_negation(child) + + assert ( + result.text + == "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" + ) + assert result.text.startswith("set ") + + +def test_swap_negation_set_to_delete() -> None: + """Test swapping from 'set' to 'delete' prefix.""" + platform = Platform.NOKIA_SRL + driver = HConfigDriverNokiaSRL() + root = HConfig.from_text(platform) + + child = HConfigChild( + root, "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" + ) + result = driver.swap_negation(child) + + assert ( + result.text + == "delete interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" + ) + assert result.text.startswith("delete ") + + +def test_swap_negation_no_prefix() -> None: + """Test swap_negation when text has neither prefix.""" + driver = HConfigDriverNokiaSRL() + root = HConfig.from_text(Platform.NOKIA_SRL) + + child = HConfigChild( + root, "interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" + ) + original_text = child.text + + result = driver.swap_negation(child) + assert result.text == original_text + + +def test_declaration_prefix() -> None: + """Test declaration_prefix property.""" + driver = HConfigDriverNokiaSRL() + assert driver.declaration_prefix == "set " + + +def test_negation_prefix() -> None: + """Test negation_prefix property.""" + driver = HConfigDriverNokiaSRL() + assert driver.negation_prefix == "delete " + + +def test_config_preprocessor() -> None: + """Test config_preprocessor with hierarchical SRL config.""" + hierarchical_config = """interface { + ethernet-1/1 { + subinterface 0 { + ipv4 { + admin-state enable + address 192.168.1.1/24 + } + } + } +} +system { + name { + host-name srl-router + } +}""" + + result = HConfigDriverNokiaSRL.config_preprocessor(hierarchical_config) + + assert "set interface ethernet-1/1 subinterface 0 ipv4 admin-state enable" in result + assert ( + "set interface ethernet-1/1 subinterface 0 ipv4 address 192.168.1.1/24" + in result + ) + assert "set system name host-name srl-router" in result diff --git a/tests/unit/platforms/test_vyos.py b/tests/unit/platforms/test_vyos.py new file mode 100644 index 00000000..5319cf1e --- /dev/null +++ b/tests/unit/platforms/test_vyos.py @@ -0,0 +1,85 @@ +from hier_config import HConfig +from hier_config.child import HConfigChild +from hier_config.models import Platform +from hier_config.platforms.vyos.driver import HConfigDriverVYOS + + +def test_swap_negation_delete_to_set() -> None: + """Test swapping from 'delete' to 'set' prefix (covers lines 9-11).""" + platform = Platform.VYOS + driver = HConfigDriverVYOS() + root = HConfig.from_text(platform) + + # Create a child with 'delete' prefix + child = HConfigChild(root, "delete interfaces ethernet eth0 address 192.168.1.1/24") + + # Swap negation should convert to 'set' + result = driver.swap_negation(child) + + assert result.text == "set interfaces ethernet eth0 address 192.168.1.1/24" + assert result.text.startswith("set ") + + +def test_swap_negation_set_to_delete() -> None: + """Test swapping from 'set' to 'delete' prefix (covers lines 10, 12).""" + platform = Platform.VYOS + driver = HConfigDriverVYOS() + root = HConfig.from_text(platform) + + # Create a child with 'set' prefix + child = HConfigChild(root, "set interfaces ethernet eth0 address 192.168.1.1/24") + + # Swap negation should convert to 'delete' + result = driver.swap_negation(child) + + assert result.text == "delete interfaces ethernet eth0 address 192.168.1.1/24" + assert result.text.startswith("delete ") + + +def test_swap_negation_no_prefix() -> None: + """Test swap_negation behavior when text has neither prefix (covers VyOS-specific behavior).""" + platform = Platform.VYOS + driver = HConfigDriverVYOS() + root = HConfig.from_text(platform) + + # Create a child without proper prefix + child = HConfigChild(root, "interfaces ethernet eth0 address 192.168.1.1/24") + original_text = child.text + + # VyOS driver doesn't raise an error, it just returns the child unchanged + result = driver.swap_negation(child) + + # Text should remain unchanged since neither if/elif matched + assert result.text == original_text + + +def test_declaration_prefix() -> None: + """Test declaration_prefix property (covers line 18).""" + driver = HConfigDriverVYOS() + assert driver.declaration_prefix == "set " + + +def test_negation_prefix() -> None: + """Test negation_prefix property (covers line 22).""" + driver = HConfigDriverVYOS() + assert driver.negation_prefix == "delete " + + +def test_config_preprocessor() -> None: + """Test config_preprocessor with hierarchical VyOS config (covers line 26).""" + hierarchical_config = """interfaces { + ethernet eth0 { + address 192.168.1.1/24 + description "WAN Interface" + } +} +system { + host-name vyos-router +}""" + + result = HConfigDriverVYOS.config_preprocessor(hierarchical_config) + + # Should convert to set commands + assert "set interfaces ethernet eth0 address 192.168.1.1/24" in result + assert "set interfaces ethernet eth0 description" in result + assert "set system host-name vyos-router" in result diff --git a/tests/unit/platforms/views/__init__.py b/tests/unit/platforms/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/platforms/views/test_arista_eos.py b/tests/unit/platforms/views/test_arista_eos.py new file mode 100644 index 00000000..b991e836 --- /dev/null +++ b/tests/unit/platforms/views/test_arista_eos.py @@ -0,0 +1,372 @@ +"""Tests for Arista EOS view.py ConfigViewInterfaceAristaEOS and HConfigViewAristaEOS classes.""" + +from ipaddress import IPv4Address, IPv4Interface + +from hier_config import ( + HConfig, + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, + Platform, + get_hconfig_view, +) +from hier_config.platforms.arista_eos.view import ConfigViewInterfaceAristaEOS +from hier_config.platforms.models import InterfaceDot1qMode, Vlan + + +def _interface_view( + config: HConfig, name: str = "Ethernet1" +) -> ConfigViewInterfaceAristaEOS: + interface_view = get_hconfig_view(config).interface_view_by_name(name) + assert isinstance(interface_view, ConfigViewInterfaceAristaEOS) + return interface_view + + +def test_capabilities() -> None: + """EOS interface views support bundles and VLANs but not NAC or physical.""" + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + + interface_view = _interface_view(config) + assert isinstance(interface_view, InterfaceBundleViewMixin) + assert isinstance(interface_view, InterfaceVlanViewMixin) + assert not isinstance(interface_view, InterfaceNACViewMixin) + assert not isinstance(interface_view, InterfacePhysicalViewMixin) + + +def test_bundle_id() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "channel-group 5 mode active")) + + assert _interface_view(config).bundle_id == "5" + + +def test_bundle_id_none() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + + assert _interface_view(config).bundle_id is None + + +def test_bundle_name() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "channel-group 5 mode active")) + + assert _interface_view(config).bundle_name == "Port-Channel5" + + +def test_bundle_member_interfaces() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Port-Channel5") + config.add_children_deep(("interface Ethernet1", "channel-group 5 mode active")) + config.add_children_deep(("interface Ethernet2", "channel-group 5 mode active")) + config.add_children_deep(("interface Ethernet3", "channel-group 6 mode active")) + + interface_view = _interface_view(config, "Port-Channel5") + assert list(interface_view.bundle_member_interfaces) == ["Ethernet1", "Ethernet2"] + + +def test_bundle_member_interfaces_not_a_bundle() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + + assert not list(_interface_view(config).bundle_member_interfaces) + + +def test_is_bundle() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Port-Channel5") + config.add_child("interface Ethernet1") + + assert _interface_view(config, "Port-Channel5").is_bundle is True + assert _interface_view(config).is_bundle is False + + view = get_hconfig_view(config) + assert [iv.name for iv in view.bundle_interface_views] == ["Port-Channel5"] + + +def test_description() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "description Uplink to Spine")) + + assert _interface_view(config).description == "Uplink to Spine" + + +def test_description_empty() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + + assert not _interface_view(config).description + + +def test_enabled() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + config.add_children_deep(("interface Ethernet2", "shutdown")) + + assert _interface_view(config).enabled is True + assert _interface_view(config, "Ethernet2").enabled is False + + +def test_ipv4_interfaces_cidr() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "ip address 10.1.1.1/24")) + + assert list(_interface_view(config).ipv4_interfaces) == [ + IPv4Interface("10.1.1.1/24") + ] + + +def test_ipv4_interfaces_netmask() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep( + ("interface Ethernet1", "ip address 10.1.1.1 255.255.255.0") + ) + + assert _interface_view(config).ipv4_interface == IPv4Interface("10.1.1.1/24") + + +def test_ipv4_interfaces_invalid() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "ip address dhcp")) + + assert not list(_interface_view(config).ipv4_interfaces) + + +def test_is_loopback() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Loopback0") + config.add_child("interface Ethernet1") + + assert _interface_view(config, "Loopback0").is_loopback is True + assert _interface_view(config).is_loopback is False + + +def test_is_svi() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Vlan100") + config.add_child("interface Ethernet1") + + assert _interface_view(config, "Vlan100").is_svi is True + assert _interface_view(config).is_svi is False + + +def test_name_and_number() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet3/25") + + interface_view = _interface_view(config, "Ethernet3/25") + assert interface_view.name == "Ethernet3/25" + assert interface_view.number == "3/25" + assert interface_view.port_number == 25 + + +def test_subinterface() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1.100") + + interface_view = _interface_view(config, "Ethernet1.100") + assert interface_view.is_subinterface is True + assert interface_view.parent_name == "Ethernet1" + assert interface_view.subinterface_number == 100 + + +def test_native_vlan_subinterface() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep( + ("interface Ethernet1.100", "encapsulation dot1q vlan 100") + ) + + assert _interface_view(config, "Ethernet1.100").native_vlan == 100 + + +def test_native_vlan_routed_port() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "no switchport")) + + assert _interface_view(config).native_vlan is None + + +def test_native_vlan_trunk() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + interface = config.add_child("interface Ethernet1") + interface.add_child("switchport mode trunk") + interface.add_child("switchport trunk native vlan 999") + + assert _interface_view(config).native_vlan == 999 + + +def test_native_vlan_trunk_default() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "switchport mode trunk")) + + assert _interface_view(config).native_vlan is None + + +def test_native_vlan_access() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "switchport access vlan 50")) + + interface_view = _interface_view(config) + assert interface_view.native_vlan == 50 + assert interface_view.dot1q_mode == InterfaceDot1qMode.ACCESS + + +def test_native_vlan_default() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + + assert _interface_view(config).native_vlan == 1 + + +def test_tagged_all() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "switchport mode trunk")) + + interface_view = _interface_view(config) + assert interface_view.tagged_all is True + assert interface_view.dot1q_mode == InterfaceDot1qMode.TAGGED_ALL + + +def test_tagged_vlans() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + interface = config.add_child("interface Ethernet1") + interface.add_child("switchport mode trunk") + interface.add_child("switchport trunk allowed vlan 10,20,30-32") + + interface_view = _interface_view(config) + assert interface_view.tagged_vlans == (10, 20, 30, 31, 32) + assert interface_view.tagged_all is False + assert interface_view.dot1q_mode == InterfaceDot1qMode.TAGGED + + +def test_tagged_vlans_empty() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + + assert _interface_view(config).tagged_vlans == () + + +def test_vrf() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("interface Ethernet1", "vrf RED")) + config.add_children_deep(("interface Ethernet2", "vrf forwarding BLUE")) + config.add_child("interface Ethernet3") + + assert _interface_view(config).vrf == "RED" + assert _interface_view(config, "Ethernet2").vrf == "BLUE" + assert not _interface_view(config, "Ethernet3").vrf + + +def test_hostname() -> None: + """Test hostname returns hostname.""" + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("hostname ARISTA-LEAF-01") + + view = get_hconfig_view(config) + assert view.hostname == "arista-leaf-01" + + +def test_hostname_none() -> None: + """Test hostname returns None.""" + config = HConfig.from_text(Platform.ARISTA_EOS) + + view = get_hconfig_view(config) + assert view.hostname is None + + +def test_interface_names_mentioned() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + config.add_child("interface Ethernet2") + + view = get_hconfig_view(config) + assert view.interface_names_mentioned == frozenset({"Ethernet1", "Ethernet2"}) + + +def test_interface_views() -> None: + """Test interface_views yields interface views.""" + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + config.add_child("interface Ethernet2") + config.add_child("interface Management1") + + view = get_hconfig_view(config) + interface_views = list(view.interface_views) + + assert len(interface_views) == 3 + + +def test_interfaces() -> None: + """Test interfaces returns interface children.""" + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("interface Ethernet1") + config.add_child("interface Ethernet2") + + view = get_hconfig_view(config) + interfaces = list(view.interfaces) + + assert len(interfaces) == 2 + + +def test_ipv4_default_gw() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child("ip route 0.0.0.0/0 192.0.2.254") + + view = get_hconfig_view(config) + assert view.ipv4_default_gw == IPv4Address("192.0.2.254") + + +def test_ipv4_default_gw_none() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + + view = get_hconfig_view(config) + assert view.ipv4_default_gw is None + + +def test_location() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_child('snmp-server location "Data Center 1"') + + view = get_hconfig_view(config) + assert view.location == "Data Center 1" + + +def test_location_empty() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + + view = get_hconfig_view(config) + assert not view.location + + +def test_stack_members() -> None: + """EOS has no stacking, so stack_members is always empty.""" + config = HConfig.from_text(Platform.ARISTA_EOS) + + view = get_hconfig_view(config) + assert not list(view.stack_members) + + +def test_vlans() -> None: + config = HConfig.from_text(Platform.ARISTA_EOS) + config.add_children_deep(("vlan 10", "name PROD")) + config.add_child("vlan 20") + config.add_children_deep(("interface Ethernet1", "switchport access vlan 30")) + + view = get_hconfig_view(config) + assert list(view.vlans) == [ + Vlan(id=10, name="PROD"), + Vlan(id=20, name=None), + Vlan(id=30, name=None), + ] + assert view.vlan_ids == frozenset({10, 20, 30}) + + +def test_port_number_on_bundle_interface() -> None: + """port_number must not crash on slash-less names like Port-Channel10 (#278 review).""" + config = HConfig.from_text(Platform.ARISTA_EOS, "interface Port-Channel10\n") + view = get_hconfig_view(config) + interface_view = view.interface_view_by_name("Port-Channel10") + assert interface_view is not None + assert interface_view.port_number == 10 diff --git a/tests/config_view/test_view_aruba_aoscx.py b/tests/unit/platforms/views/test_aruba_aoscx.py similarity index 78% rename from tests/config_view/test_view_aruba_aoscx.py rename to tests/unit/platforms/views/test_aruba_aoscx.py index 084ee5bc..63ca2ad9 100644 --- a/tests/config_view/test_view_aruba_aoscx.py +++ b/tests/unit/platforms/views/test_aruba_aoscx.py @@ -4,13 +4,23 @@ import pytest -from hier_config import Platform, get_hconfig, get_hconfig_view +from hier_config import HConfig, Platform, get_hconfig_view +from hier_config.platforms.aruba_aoscx.view import ConfigViewInterfaceArubaAOSCX from hier_config.platforms.models import InterfaceDot1qMode, InterfaceDuplex, Vlan from hier_config.platforms.view_base import HConfigViewBase def _view_from_config(config_text: str = "") -> HConfigViewBase: - return get_hconfig_view(get_hconfig(Platform.ARUBA_AOSCX, config_text)) + return get_hconfig_view(HConfig.from_text(Platform.ARUBA_AOSCX, config_text)) + + +def _interface_view( + view: HConfigViewBase, + name: str, +) -> ConfigViewInterfaceArubaAOSCX: + interface_view = view.interface_view_by_name(name) + assert isinstance(interface_view, ConfigViewInterfaceArubaAOSCX) + return interface_view def test_hostname() -> None: @@ -31,8 +41,7 @@ def test_interface_properties() -> None: """ ) - interface_view = view.interface_view_by_name("1/1/1") - assert interface_view is not None + interface_view = _interface_view(view, "1/1/1") assert interface_view.description == "Uplink to Core" assert interface_view.enabled is True assert interface_view.native_vlan == 10 @@ -50,8 +59,7 @@ def test_access_interface_properties() -> None: """ ) - interface_view = view.interface_view_by_name("1/1/2") - assert interface_view is not None + interface_view = _interface_view(view, "1/1/2") assert interface_view.enabled is False assert interface_view.native_vlan == 25 assert interface_view.dot1q_mode == InterfaceDot1qMode.ACCESS @@ -67,8 +75,7 @@ def test_interface_enabled_defaults_to_down() -> None: """ ) - interface_view = view.interface_view_by_name("1/1/3") - assert interface_view is not None + interface_view = _interface_view(view, "1/1/3") assert interface_view.enabled is False @@ -84,8 +91,7 @@ def test_view_tolerates_malformed_trunk_vlan_spec() -> None: """ ) - interface_view = view.interface_view_by_name("1/1/1") - assert interface_view is not None + interface_view = _interface_view(view, "1/1/1") assert interface_view.tagged_vlans == (10,) assert view.vlan_ids == frozenset({10}) @@ -99,8 +105,7 @@ def test_ip_properties() -> None: """ ) - interface_view = view.interface_view_by_name("vlan 25") - assert interface_view is not None + interface_view = _interface_view(view, "vlan 25") assert interface_view.is_svi is True assert list(interface_view.ipv4_interfaces) == [IPv4Interface("10.25.0.1/24")] assert view.ipv4_default_gw == IPv4Address("10.25.0.254") @@ -137,10 +142,8 @@ def test_bundle_properties() -> None: """ ) - bundle_view = view.interface_view_by_name("lag 48") - member_view = view.interface_view_by_name("1/1/47") - assert bundle_view is not None - assert member_view is not None + bundle_view = _interface_view(view, "lag 48") + member_view = _interface_view(view, "1/1/47") assert bundle_view.is_bundle is True assert bundle_view.bundle_id == "48" assert tuple(bundle_view.bundle_member_interfaces) == ("1/1/47", "1/1/48") @@ -160,10 +163,8 @@ def test_bundle_properties_multi_chassis() -> None: """ ) - bundle_view = view.interface_view_by_name("lag 1 multi-chassis") - member_view = view.interface_view_by_name("1/1/1") - assert bundle_view is not None - assert member_view is not None + bundle_view = _interface_view(view, "lag 1 multi-chassis") + member_view = _interface_view(view, "1/1/1") assert bundle_view.is_bundle is True assert bundle_view.bundle_id == "1" assert bundle_view.bundle_name == "lag 1" @@ -175,8 +176,7 @@ def test_bundle_properties_multi_chassis() -> None: def test_duplex_speed_and_poe_defaults() -> None: view = _view_from_config("interface 1/1/1\n") - interface_view = view.interface_view_by_name("1/1/1") - assert interface_view is not None + interface_view = _interface_view(view, "1/1/1") assert interface_view.duplex == InterfaceDuplex.AUTO assert interface_view.speed is None assert interface_view.poe is True @@ -185,8 +185,7 @@ def test_duplex_speed_and_poe_defaults() -> None: def test_unsupported_nac_client_limits() -> None: view = _view_from_config("interface 1/1/1\n") - interface_view = view.interface_view_by_name("1/1/1") - assert interface_view is not None + interface_view = _interface_view(view, "1/1/1") with pytest.raises(NotImplementedError): _ = interface_view.nac_max_dot1x_clients with pytest.raises(NotImplementedError): diff --git a/tests/config_view/test_view_cisco_ios.py b/tests/unit/platforms/views/test_cisco_ios.py similarity index 70% rename from tests/config_view/test_view_cisco_ios.py rename to tests/unit/platforms/views/test_cisco_ios.py index 9563ee77..b63962af 100644 --- a/tests/config_view/test_view_cisco_ios.py +++ b/tests/unit/platforms/views/test_cisco_ios.py @@ -4,285 +4,327 @@ import pytest -from hier_config import Platform, get_hconfig, get_hconfig_view +from hier_config import HConfig, Platform, get_hconfig_view +from hier_config.platforms.cisco_ios.view import ConfigViewInterfaceCiscoIOS from hier_config.platforms.models import InterfaceDuplex, NACHostMode, StackMember def test_bundle_id() -> None: """Test bundle_id returns channel-group ID (covers lines 25, 29).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("channel-group 10 mode active") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.bundle_id == "10" def test_bundle_id_none() -> None: """Test bundle_id returns None (covers line 25).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.bundle_id is None -def test_bundle_member_interfaces_not_implemented() -> None: - """Test bundle_member_interfaces raises NotImplementedError (covers line 29).""" - config = get_hconfig(Platform.CISCO_IOS) +def test_bundle_member_interfaces() -> None: + """Test bundle_member_interfaces yields the bundle's member interfaces.""" + config = HConfig.from_text(Platform.CISCO_IOS) + config.add_child("interface Port-channel10") + config.add_children_deep( + ("interface GigabitEthernet0/0", "channel-group 10 mode active") + ) + config.add_children_deep( + ("interface GigabitEthernet0/1", "channel-group 10 mode active") + ) + config.add_children_deep( + ("interface GigabitEthernet0/2", "channel-group 20 mode active") + ) + + view = get_hconfig_view(config) + interface_view = view.interface_view_by_name("Port-channel10") + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) + + assert list(interface_view.bundle_member_interfaces) == [ + "GigabitEthernet0/0", + "GigabitEthernet0/1", + ] + + +def test_bundle_member_interfaces_not_a_bundle() -> None: + """Test bundle_member_interfaces yields nothing for a non-bundle interface.""" + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) - with pytest.raises(NotImplementedError): - _ = list(interface_view.bundle_member_interfaces) + assert not list(interface_view.bundle_member_interfaces) + + +def test_is_bundle() -> None: + """Test is_bundle matches Port-channel interfaces case-insensitively.""" + config = HConfig.from_text(Platform.CISCO_IOS) + config.add_child("interface Port-channel10") + config.add_child("interface GigabitEthernet0/0") + + view = get_hconfig_view(config) + port_channel = view.interface_view_by_name("Port-channel10") + assert isinstance(port_channel, ConfigViewInterfaceCiscoIOS) + assert port_channel.is_bundle is True + + ethernet = view.interface_view_by_name("GigabitEthernet0/0") + assert isinstance(ethernet, ConfigViewInterfaceCiscoIOS) + assert ethernet.is_bundle is False + + assert [iv.name for iv in view.bundle_interface_views] == ["Port-channel10"] def test_bundle_name() -> None: """Test bundle_name returns formatted name (covers lines 35, 39-41).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("channel-group 5 mode active") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.bundle_name == "Port-channel5" def test_bundle_name_none() -> None: """Test bundle_name returns None (covers line 35).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.bundle_name is None def test_description() -> None: """Test description returns description text (covers lines 45-47).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("description Uplink to Core") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.description == "Uplink to Core" def test_description_empty() -> None: """Test description returns empty string (covers line 47).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert not interface_view.description def test_duplex_auto() -> None: """Test duplex returns auto (covers line 51).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.duplex == InterfaceDuplex.AUTO def test_enabled_true() -> None: """Test enabled returns True (covers line 55).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.enabled is True def test_enabled_false() -> None: """Test enabled returns False when shutdown (covers line 55).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("shutdown") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.enabled is False def test_has_nac_port_control() -> None: """Test has_nac with port-control (covers line 70).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("authentication port-control auto") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.has_nac is True def test_has_nac_mab() -> None: """Test has_nac with mab (covers line 70).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("mab") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.has_nac is True def test_has_nac_false() -> None: """Test has_nac returns False (covers lines 70-74).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.has_nac is False def test_ipv4_interface_none() -> None: """Test ipv4_interface returns None (covers line 78).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.ipv4_interface is None def test_nac_control_direction_in_true() -> None: """Test nac_control_direction_in returns True (covers line 102).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("authentication control-direction in") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.nac_control_direction_in is True def test_nac_control_direction_in_false() -> None: """Test nac_control_direction_in returns False (covers line 102).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.nac_control_direction_in is False def test_nac_host_mode_multi_auth() -> None: """Test nac_host_mode returns MULTI_AUTH (covers lines 107-122).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("authentication host-mode multi-auth") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.nac_host_mode == NACHostMode.MULTI_AUTH def test_nac_host_mode_multi_domain() -> None: """Test nac_host_mode returns MULTI_DOMAIN (covers lines 107-122).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("authentication host-mode multi-domain") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.nac_host_mode == NACHostMode.MULTI_DOMAIN def test_nac_host_mode_multi_host() -> None: """Test nac_host_mode returns MULTI_HOST (covers lines 107-122).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("authentication host-mode multi-host") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.nac_host_mode == NACHostMode.MULTI_HOST def test_nac_host_mode_single_host() -> None: """Test nac_host_mode returns SINGLE_HOST (covers lines 107-122).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("authentication host-mode single-host") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.nac_host_mode == NACHostMode.SINGLE_HOST def test_nac_host_mode_none() -> None: """Test nac_host_mode returns None (covers lines 107-122).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.nac_host_mode is None def test_nac_mab_first_true() -> None: """Test nac_mab_first returns True (covers line 127).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("authentication order mab dot1x") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.nac_mab_first is True def test_nac_mab_first_false() -> None: """Test nac_mab_first returns False (covers line 127).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.nac_mab_first is False def test_nac_max_dot1x_clients_not_implemented() -> None: """Test nac_max_dot1x_clients raises NotImplementedError (covers line 132).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) with pytest.raises(NotImplementedError): _ = interface_view.nac_max_dot1x_clients @@ -290,12 +332,12 @@ def test_nac_max_dot1x_clients_not_implemented() -> None: def test_nac_max_mab_clients_not_implemented() -> None: """Test nac_max_mab_clients raises NotImplementedError (covers line 137).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) with pytest.raises(NotImplementedError): _ = interface_view.nac_max_mab_clients @@ -303,192 +345,183 @@ def test_nac_max_mab_clients_not_implemented() -> None: def test_native_vlan_subinterface() -> None: """Test native_vlan from subinterface encapsulation (covers lines 149, 162-167).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0.100") interface.add_child("encapsulation dot1Q 50") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0.100") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.native_vlan == 50 def test_native_vlan_no_switchport() -> None: """Test native_vlan returns None for routed port (covers lines 149, 162-167).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("no switchport") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.native_vlan is None def test_native_vlan_trunk() -> None: """Test native_vlan on trunk port (covers lines 171, 182-184).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("switchport mode trunk") interface.add_child("switchport trunk native vlan 999") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.native_vlan == 999 def test_native_vlan_trunk_default() -> None: """Test native_vlan on trunk without explicit native (covers lines 171, 182-184).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("switchport mode trunk") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.native_vlan is None def test_native_vlan_access() -> None: """Test native_vlan on access port (covers line 188).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("switchport access vlan 50") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.native_vlan == 50 def test_native_vlan_default() -> None: """Test native_vlan defaults to 1 (covers line 192).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.native_vlan == 1 def test_poe_true() -> None: """Test poe returns True (covers line 196).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.poe is True def test_poe_false() -> None: """Test poe returns False (covers lines 196-200).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("power inline never") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.poe is False def test_speed() -> None: """Test speed returns speed value (covers line 204).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("speed 1000") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.speed == (1000,) def test_tagged_all_true() -> None: """Test tagged_all returns True (covers line 223).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("switchport mode trunk") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.tagged_all is True def test_tagged_all_false() -> None: """Test tagged_all returns False (covers lines 223-225).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.tagged_all is False def test_tagged_vlans() -> None: """Test tagged_vlans returns VLAN list (covers line 240).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("switchport trunk allowed vlan 10,20,30-35") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.tagged_vlans == (10, 20, 30, 31, 32, 33, 34, 35) def test_tagged_vlans_empty() -> None: """Test tagged_vlans returns empty tuple (covers lines 240, 244-246).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.tagged_vlans == () def test_vrf() -> None: """Test vrf returns VRF name (covers line 251).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("ip vrf forwarding MGMT") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.vrf == "MGMT" def test_vrf_empty() -> None: """Test vrf returns empty string (covers line 251).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert not interface_view.vrf -def test_dot1q_mode_from_vlans_not_implemented() -> None: - """Test dot1q_mode_from_vlans raises NotImplementedError (covers line 264).""" - config = get_hconfig(Platform.CISCO_IOS) - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - view.dot1q_mode_from_vlans(untagged_vlan=10) - - def test_hostname() -> None: """Test hostname returns hostname (covers lines 270-272).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("hostname ROUTER-01") view = get_hconfig_view(config) @@ -497,7 +530,7 @@ def test_hostname() -> None: def test_hostname_none() -> None: """Test hostname returns None (covers line 272).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) view = get_hconfig_view(config) assert view.hostname is None @@ -505,7 +538,7 @@ def test_hostname_none() -> None: def test_interface_names_mentioned() -> None: """Test interface_names_mentioned returns interface names (covers line 283).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") config.add_child("interface GigabitEthernet0/1") @@ -518,7 +551,7 @@ def test_interface_names_mentioned() -> None: def test_ipv4_default_gw() -> None: """Test ipv4_default_gw returns gateway IP (covers lines 283-286).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("ip default-gateway 192.168.1.1") view = get_hconfig_view(config) @@ -527,7 +560,7 @@ def test_ipv4_default_gw() -> None: def test_ipv4_default_gw_none() -> None: """Test ipv4_default_gw returns None (covers line 286).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) view = get_hconfig_view(config) assert view.ipv4_default_gw is None @@ -535,7 +568,7 @@ def test_ipv4_default_gw_none() -> None: def test_location() -> None: """Test location returns location string (covers lines 301-302).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child('snmp-server location "Building A, Floor 2"') view = get_hconfig_view(config) @@ -544,7 +577,7 @@ def test_location() -> None: def test_location_empty() -> None: """Test location returns empty string (covers line 302).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) view = get_hconfig_view(config) assert not view.location @@ -552,7 +585,7 @@ def test_location_empty() -> None: def test_stack_members() -> None: """Test stack_members yields stack members (covers lines 312-316).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("switch 1 provision ws-c3850-24p") config.add_child("switch 2 provision ws-c3850-24p") @@ -570,7 +603,7 @@ def test_stack_members() -> None: def test_vlans_explicit() -> None: """Test vlans yields explicitly defined VLANs (covers lines 264-266).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) vlan10 = config.add_child("vlan 10") vlan10.add_child("name Data") vlan20 = config.add_child("vlan 20") @@ -586,7 +619,7 @@ def test_vlans_explicit() -> None: def test_vlans_from_interfaces() -> None: """Test vlans includes VLANs from interfaces (covers lines 264-266).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) interface = config.add_child("interface GigabitEthernet0/0") interface.add_child("switchport access vlan 100") @@ -594,3 +627,12 @@ def test_vlans_from_interfaces() -> None: vlans = list(view.vlans) assert any(v.id == 100 for v in vlans) + + +def test_port_number_on_bundle_interface() -> None: + """port_number must not crash on slash-less names like Port-channel10 (#278 review).""" + config = HConfig.from_text(Platform.CISCO_IOS, "interface Port-channel10\n") + view = get_hconfig_view(config) + interface_view = view.interface_view_by_name("Port-channel10") + assert interface_view is not None + assert interface_view.port_number == 10 diff --git a/tests/unit/platforms/views/test_cisco_nxos.py b/tests/unit/platforms/views/test_cisco_nxos.py new file mode 100644 index 00000000..761778d1 --- /dev/null +++ b/tests/unit/platforms/views/test_cisco_nxos.py @@ -0,0 +1,446 @@ +"""Tests for Cisco NX-OS view.py ConfigViewInterfaceCiscoNXOS and HConfigViewCiscoNXOS classes.""" + +from ipaddress import IPv4Address, IPv4Interface + +from hier_config import ( + HConfig, + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, + Platform, + get_hconfig_view, +) +from hier_config.platforms.cisco_nxos.view import ConfigViewInterfaceCiscoNXOS +from hier_config.platforms.models import InterfaceDot1qMode, Vlan + + +def _interface_view( + config: HConfig, name: str = "Ethernet1/1" +) -> ConfigViewInterfaceCiscoNXOS: + interface_view = get_hconfig_view(config).interface_view_by_name(name) + assert isinstance(interface_view, ConfigViewInterfaceCiscoNXOS) + return interface_view + + +def test_capabilities() -> None: + """NX-OS interface views support bundles and VLANs but not NAC or physical.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + interface_view = _interface_view(config) + assert isinstance(interface_view, InterfaceBundleViewMixin) + assert isinstance(interface_view, InterfaceVlanViewMixin) + assert not isinstance(interface_view, InterfaceNACViewMixin) + assert not isinstance(interface_view, InterfacePhysicalViewMixin) + + +def test_bundle_id() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("interface Ethernet1/1", "channel-group 10 mode active")) + + assert _interface_view(config).bundle_id == "10" + + +def test_bundle_id_none() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + assert _interface_view(config).bundle_id is None + + +def test_bundle_name() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("interface Ethernet1/1", "channel-group 10 mode active")) + + assert _interface_view(config).bundle_name == "port-channel10" + + +def test_bundle_member_interfaces() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface port-channel10") + config.add_children_deep(("interface Ethernet1/1", "channel-group 10 mode active")) + config.add_children_deep(("interface Ethernet1/2", "channel-group 10 mode active")) + config.add_children_deep(("interface Ethernet1/3", "channel-group 20 mode active")) + + interface_view = _interface_view(config, "port-channel10") + assert list(interface_view.bundle_member_interfaces) == [ + "Ethernet1/1", + "Ethernet1/2", + ] + + +def test_bundle_member_interfaces_not_a_bundle() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + assert not list(_interface_view(config).bundle_member_interfaces) + + +def test_description() -> None: + """Test description returns description text.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + interface = config.add_child("interface Ethernet1/1") + interface.add_child("description Uplink to Core") + + assert _interface_view(config).description == "Uplink to Core" + + +def test_description_empty() -> None: + """Test description returns empty string.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + assert not _interface_view(config).description + + +def test_enabled() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + config.add_children_deep(("interface Ethernet1/2", "shutdown")) + + assert _interface_view(config).enabled is True + assert _interface_view(config, "Ethernet1/2").enabled is False + + +def test_ipv4_interface_none() -> None: + """Test ipv4_interface returns None.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + assert _interface_view(config).ipv4_interface is None + + +def test_ipv4_interfaces() -> None: + """Test ipv4_interfaces returns IP addresses.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + interface = config.add_child("interface Ethernet1/1") + interface.add_child("ip address 10.1.1.1 255.255.255.0") + + assert list(_interface_view(config).ipv4_interfaces) == [ + IPv4Interface("10.1.1.1/24") + ] + + +def test_ipv4_interfaces_cidr() -> None: + """Test ipv4_interfaces supports NX-OS address/prefix syntax.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("interface Ethernet1/1", "ip address 10.1.1.1/24")) + + assert _interface_view(config).ipv4_interface == IPv4Interface("10.1.1.1/24") + + +def test_ipv4_interfaces_invalid() -> None: + """Test ipv4_interfaces skips invalid addresses.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + interface = config.add_child("interface Ethernet1/1") + interface.add_child("ip address dhcp") + + assert not list(_interface_view(config).ipv4_interfaces) + + +def test_is_bundle_true() -> None: + """Test is_bundle returns True.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface port-channel10") + + assert _interface_view(config, "port-channel10").is_bundle is True + + +def test_is_bundle_false() -> None: + """Test is_bundle returns False.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + assert _interface_view(config).is_bundle is False + + +def test_is_loopback() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface loopback0") + config.add_child("interface Ethernet1/1") + + assert _interface_view(config, "loopback0").is_loopback is True + assert _interface_view(config).is_loopback is False + + +def test_is_subinterface() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1.100") + config.add_child("interface Ethernet1/1") + + assert _interface_view(config, "Ethernet1/1.100").is_subinterface is True + assert _interface_view(config).is_subinterface is False + + +def test_is_svi() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface vlan100") + config.add_child("interface Ethernet1/1") + + assert _interface_view(config, "vlan100").is_svi is True + assert _interface_view(config).is_svi is False + + +def test_name() -> None: + """Test name returns interface name.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/10") + + assert _interface_view(config, "Ethernet1/10").name == "Ethernet1/10" + + +def test_native_vlan_subinterface() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("interface Ethernet1/1.100", "encapsulation dot1q 100")) + + assert _interface_view(config, "Ethernet1/1.100").native_vlan == 100 + + +def test_native_vlan_routed_port() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("interface Ethernet1/1", "no switchport")) + + assert _interface_view(config).native_vlan is None + + +def test_native_vlan_trunk() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + interface = config.add_child("interface Ethernet1/1") + interface.add_child("switchport mode trunk") + interface.add_child("switchport trunk native vlan 999") + + assert _interface_view(config).native_vlan == 999 + + +def test_native_vlan_trunk_default() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("interface Ethernet1/1", "switchport mode trunk")) + + assert _interface_view(config).native_vlan is None + + +def test_native_vlan_access() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("interface Ethernet1/1", "switchport access vlan 50")) + + interface_view = _interface_view(config) + assert interface_view.native_vlan == 50 + assert interface_view.dot1q_mode == InterfaceDot1qMode.ACCESS + + +def test_native_vlan_default() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + assert _interface_view(config).native_vlan == 1 + + +def test_number() -> None: + """Test number returns interface number.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet3/25") + + assert _interface_view(config, "Ethernet3/25").number == "3/25" + + +def test_parent_name() -> None: + """Test parent_name returns parent interface.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1.200") + + assert _interface_view(config, "Ethernet1/1.200").parent_name == "Ethernet1/1" + + +def test_parent_name_none() -> None: + """Test parent_name returns None.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + assert _interface_view(config).parent_name is None + + +def test_port_number() -> None: + """Test port_number returns port number.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet2/48") + + assert _interface_view(config, "Ethernet2/48").port_number == 48 + + +def test_port_number_with_subinterface() -> None: + """Test port_number with subinterface.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/5.300") + + assert _interface_view(config, "Ethernet1/5.300").port_number == 5 + + +def test_subinterface_number() -> None: + """Test subinterface_number returns number.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1.999") + + assert _interface_view(config, "Ethernet1/1.999").subinterface_number == 999 + + +def test_subinterface_number_none() -> None: + """Test subinterface_number returns None.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + assert _interface_view(config).subinterface_number is None + + +def test_tagged_all() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("interface Ethernet1/1", "switchport mode trunk")) + + interface_view = _interface_view(config) + assert interface_view.tagged_all is True + assert interface_view.dot1q_mode == InterfaceDot1qMode.TAGGED_ALL + + +def test_tagged_vlans() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + interface = config.add_child("interface Ethernet1/1") + interface.add_child("switchport mode trunk") + interface.add_child("switchport trunk allowed vlan 10,20,30-32") + + interface_view = _interface_view(config) + assert interface_view.tagged_vlans == (10, 20, 30, 31, 32) + assert interface_view.tagged_all is False + assert interface_view.dot1q_mode == InterfaceDot1qMode.TAGGED + + +def test_tagged_vlans_empty() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + + assert _interface_view(config).tagged_vlans == () + + +def test_vrf() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("interface Ethernet1/1", "vrf member RED")) + config.add_child("interface Ethernet1/2") + + assert _interface_view(config).vrf == "RED" + assert not _interface_view(config, "Ethernet1/2").vrf + + +def test_hostname() -> None: + """Test hostname returns hostname.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("hostname NEXUS-CORE-01") + + view = get_hconfig_view(config) + assert view.hostname == "nexus-core-01" + + +def test_hostname_none() -> None: + """Test hostname returns None.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + + view = get_hconfig_view(config) + assert view.hostname is None + + +def test_interface_names_mentioned() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + config.add_child("interface Ethernet1/2") + + view = get_hconfig_view(config) + assert view.interface_names_mentioned == frozenset({"Ethernet1/1", "Ethernet1/2"}) + + +def test_interface_views() -> None: + """Test interface_views yields interface views.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + config.add_child("interface Ethernet1/2") + config.add_child("interface loopback0") + + view = get_hconfig_view(config) + interface_views = list(view.interface_views) + + assert len(interface_views) == 3 + assert any(iv.name == "Ethernet1/1" for iv in interface_views) + assert any(iv.name == "Ethernet1/2" for iv in interface_views) + assert any(iv.name == "loopback0" for iv in interface_views) + + +def test_interfaces() -> None: + """Test interfaces returns interface children.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("interface Ethernet1/1") + config.add_child("interface Ethernet1/2") + config.add_child("interface port-channel1") + + view = get_hconfig_view(config) + interfaces = list(view.interfaces) + + assert len(interfaces) == 3 + + +def test_ipv4_default_gw() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child("ip route 0.0.0.0/0 192.0.2.254") + + view = get_hconfig_view(config) + assert view.ipv4_default_gw == IPv4Address("192.0.2.254") + + +def test_ipv4_default_gw_none() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + + view = get_hconfig_view(config) + assert view.ipv4_default_gw is None + + +def test_location() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_child('snmp-server location "Data Center 1"') + + view = get_hconfig_view(config) + assert view.location == "Data Center 1" + + +def test_location_empty() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + + view = get_hconfig_view(config) + assert not view.location + + +def test_stack_members() -> None: + """NX-OS has no stacking, so stack_members is always empty.""" + config = HConfig.from_text(Platform.CISCO_NXOS) + + view = get_hconfig_view(config) + assert not list(view.stack_members) + + +def test_vlans() -> None: + config = HConfig.from_text(Platform.CISCO_NXOS) + config.add_children_deep(("vlan 10", "name PROD")) + config.add_child("vlan 20") + config.add_children_deep(("interface Ethernet1/1", "switchport access vlan 30")) + + view = get_hconfig_view(config) + assert list(view.vlans) == [ + Vlan(id=10, name="PROD"), + Vlan(id=20, name=None), + Vlan(id=30, name=None), + ] + assert view.vlan_ids == frozenset({10, 20, 30}) + + +def test_port_number_on_bundle_interface() -> None: + """port_number must not crash on slash-less names like port-channel10 (#278 review).""" + config = HConfig.from_text(Platform.CISCO_NXOS, "interface port-channel10\n") + view = get_hconfig_view(config) + interface_view = view.interface_view_by_name("port-channel10") + assert interface_view is not None + assert interface_view.port_number == 10 diff --git a/tests/unit/platforms/views/test_cisco_xr.py b/tests/unit/platforms/views/test_cisco_xr.py new file mode 100644 index 00000000..8ceb012d --- /dev/null +++ b/tests/unit/platforms/views/test_cisco_xr.py @@ -0,0 +1,386 @@ +"""Tests for Cisco IOS-XR view.py ConfigViewInterfaceCiscoIOSXR and HConfigViewCiscoIOSXR classes.""" + +from ipaddress import IPv4Address, IPv4Interface + +from hier_config import ( + HConfig, + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, + Platform, + get_hconfig_view, +) +from hier_config.platforms.cisco_xr.view import ConfigViewInterfaceCiscoIOSXR +from hier_config.platforms.models import Vlan + + +def _interface_view( + config: HConfig, name: str = "GigabitEthernet0/0/0/1" +) -> ConfigViewInterfaceCiscoIOSXR: + interface_view = get_hconfig_view(config).interface_view_by_name(name) + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOSXR) + return interface_view + + +def test_capabilities() -> None: + """IOS XR interface views support bundles and VLANs but not NAC or physical.""" + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + + interface_view = _interface_view(config) + assert isinstance(interface_view, InterfaceBundleViewMixin) + assert isinstance(interface_view, InterfaceVlanViewMixin) + assert not isinstance(interface_view, InterfaceNACViewMixin) + assert not isinstance(interface_view, InterfacePhysicalViewMixin) + + +def test_bundle_id() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/1", "bundle id 42 mode active") + ) + + assert _interface_view(config).bundle_id == "42" + + +def test_bundle_id_none() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + + assert _interface_view(config).bundle_id is None + + +def test_bundle_name() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/1", "bundle id 42 mode active") + ) + + assert _interface_view(config).bundle_name == "Bundle-Ether42" + + +def test_bundle_name_none() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + + assert _interface_view(config).bundle_name is None + + +def test_bundle_member_interfaces() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface Bundle-Ether42") + config.add_children_deep( + ("interface GigabitEthernet0/0/0/1", "bundle id 42 mode active") + ) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/2", "bundle id 42 mode active") + ) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/3", "bundle id 43 mode active") + ) + + interface_view = _interface_view(config, "Bundle-Ether42") + assert list(interface_view.bundle_member_interfaces) == [ + "GigabitEthernet0/0/0/1", + "GigabitEthernet0/0/0/2", + ] + + +def test_bundle_member_interfaces_not_a_bundle() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + + assert not list(_interface_view(config).bundle_member_interfaces) + + +def test_is_bundle() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface Bundle-Ether42") + config.add_child("interface GigabitEthernet0/0/0/1") + + assert _interface_view(config, "Bundle-Ether42").is_bundle is True + assert _interface_view(config).is_bundle is False + + view = get_hconfig_view(config) + assert [iv.name for iv in view.bundle_interface_views] == ["Bundle-Ether42"] + + +def test_description() -> None: + """Test description returns description text.""" + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep(("interface GigabitEthernet0/0/0/1", "description Uplink")) + + assert _interface_view(config).description == "Uplink" + + +def test_description_empty() -> None: + """Test description returns empty string.""" + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + + assert not _interface_view(config).description + + +def test_enabled() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + config.add_children_deep(("interface GigabitEthernet0/0/0/2", "shutdown")) + + assert _interface_view(config).enabled is True + assert _interface_view(config, "GigabitEthernet0/0/0/2").enabled is False + + +def test_ipv4_interfaces_netmask() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/1", "ipv4 address 10.1.1.1 255.255.255.0") + ) + + assert list(_interface_view(config).ipv4_interfaces) == [ + IPv4Interface("10.1.1.1/24") + ] + + +def test_ipv4_interfaces_cidr() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/1", "ipv4 address 10.1.1.1/24") + ) + + assert _interface_view(config).ipv4_interface == IPv4Interface("10.1.1.1/24") + + +def test_ipv4_interfaces_cidr_with_space() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/1", "ipv4 address 10.1.1.1 /24") + ) + + assert _interface_view(config).ipv4_interface == IPv4Interface("10.1.1.1/24") + + +def test_ipv4_interfaces_invalid() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep(("interface GigabitEthernet0/0/0/1", "ipv4 address dhcp")) + + assert not list(_interface_view(config).ipv4_interfaces) + + +def test_is_loopback() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface Loopback0") + config.add_child("interface GigabitEthernet0/0/0/1") + + assert _interface_view(config, "Loopback0").is_loopback is True + assert _interface_view(config).is_loopback is False + + +def test_is_svi() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + + assert _interface_view(config).is_svi is False + + +def test_name_and_number() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/1/2/3") + + interface_view = _interface_view(config, "GigabitEthernet0/1/2/3") + assert interface_view.name == "GigabitEthernet0/1/2/3" + assert interface_view.number == "0/1/2/3" + assert interface_view.port_number == 3 + + +def test_subinterface() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1.100") + config.add_child("interface GigabitEthernet0/0/0/1") + + subinterface_view = _interface_view(config, "GigabitEthernet0/0/0/1.100") + assert subinterface_view.is_subinterface is True + assert subinterface_view.parent_name == "GigabitEthernet0/0/0/1" + assert subinterface_view.subinterface_number == 100 + + interface_view = _interface_view(config) + assert interface_view.is_subinterface is False + assert interface_view.parent_name is None + assert interface_view.subinterface_number is None + + +def test_native_vlan_subinterface() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/1.100", "encapsulation dot1q 100") + ) + + assert _interface_view(config, "GigabitEthernet0/0/0/1.100").native_vlan == 100 + + +def test_native_vlan_none() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + + assert _interface_view(config).native_vlan is None + + +def test_tagged_all_and_tagged_vlans() -> None: + """IOS XR interfaces never carry switchport-style tagged VLANs.""" + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + + interface_view = _interface_view(config) + assert interface_view.tagged_all is False + assert interface_view.tagged_vlans == () + assert interface_view.dot1q_mode is None + + +def test_vrf() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep(("interface GigabitEthernet0/0/0/1", "vrf RED")) + config.add_child("interface GigabitEthernet0/0/0/2") + + assert _interface_view(config).vrf == "RED" + assert not _interface_view(config, "GigabitEthernet0/0/0/2").vrf + + +def test_hostname() -> None: + """Test hostname returns hostname.""" + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("hostname XR-PE-01") + + view = get_hconfig_view(config) + assert view.hostname == "xr-pe-01" + + +def test_hostname_none() -> None: + """Test hostname returns None.""" + config = HConfig.from_text(Platform.CISCO_XR) + + view = get_hconfig_view(config) + assert view.hostname is None + + +def test_interface_names_mentioned() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + config.add_child("interface Bundle-Ether42") + + view = get_hconfig_view(config) + assert view.interface_names_mentioned == frozenset( + {"GigabitEthernet0/0/0/1", "Bundle-Ether42"} + ) + + +def test_interface_views() -> None: + """Test interface_views yields interface views.""" + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + config.add_child("interface GigabitEthernet0/0/0/2") + config.add_child("interface Loopback0") + + view = get_hconfig_view(config) + interface_views = list(view.interface_views) + + assert len(interface_views) == 3 + + +def test_interfaces() -> None: + """Test interfaces returns interface children.""" + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child("interface GigabitEthernet0/0/0/1") + config.add_child("interface GigabitEthernet0/0/0/2") + + view = get_hconfig_view(config) + interfaces = list(view.interfaces) + + assert len(interfaces) == 2 + + +def test_ipv4_default_gw() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep( + ( + "router static", + "address-family ipv4 unicast", + "0.0.0.0/0 192.0.2.254", + ) + ) + + view = get_hconfig_view(config) + assert view.ipv4_default_gw == IPv4Address("192.0.2.254") + + +def test_ipv4_default_gw_none() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + + view = get_hconfig_view(config) + assert view.ipv4_default_gw is None + + +def test_ipv4_default_gw_none_without_default_route() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep( + ( + "router static", + "address-family ipv4 unicast", + "10.0.0.0/8 192.0.2.1", + ) + ) + + view = get_hconfig_view(config) + assert view.ipv4_default_gw is None + + +def test_location() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + config.add_child('snmp-server location "Data Center 1"') + + view = get_hconfig_view(config) + assert view.location == "Data Center 1" + + +def test_location_empty() -> None: + config = HConfig.from_text(Platform.CISCO_XR) + + view = get_hconfig_view(config) + assert not view.location + + +def test_stack_members() -> None: + """IOS XR has no stacking, so stack_members is always empty.""" + config = HConfig.from_text(Platform.CISCO_XR) + + view = get_hconfig_view(config) + assert not list(view.stack_members) + + +def test_vlans() -> None: + """VLANs are derived from sub-interface encapsulations.""" + config = HConfig.from_text(Platform.CISCO_XR) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/1.100", "encapsulation dot1q 100") + ) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/1.200", "encapsulation dot1q 200") + ) + config.add_children_deep( + ("interface GigabitEthernet0/0/0/2.100", "encapsulation dot1q 100") + ) + + view = get_hconfig_view(config) + assert list(view.vlans) == [ + Vlan(id=100, name=None), + Vlan(id=200, name=None), + ] + assert view.vlan_ids == frozenset({100, 200}) + + +def test_port_number_on_bundle_interface() -> None: + """port_number must not crash on slash-less names like Bundle-Ether10 (#278 review).""" + config = HConfig.from_text(Platform.CISCO_XR, "interface Bundle-Ether10\n") + view = get_hconfig_view(config) + interface_view = view.interface_view_by_name("Bundle-Ether10") + assert interface_view is not None + assert interface_view.port_number == 10 diff --git a/tests/config_view/test_view_hp_procurve.py b/tests/unit/platforms/views/test_hp_procurve.py similarity index 72% rename from tests/config_view/test_view_hp_procurve.py rename to tests/unit/platforms/views/test_hp_procurve.py index 1a1b2adf..649dffec 100644 --- a/tests/config_view/test_view_hp_procurve.py +++ b/tests/unit/platforms/views/test_hp_procurve.py @@ -4,32 +4,43 @@ import pytest -from hier_config import Platform, get_hconfig, get_hconfig_view +from hier_config import HConfig, Platform, get_hconfig_view +from hier_config.platforms.hp_procurve.view import ConfigViewInterfaceHPProcurve from hier_config.platforms.models import InterfaceDuplex, StackMember -def test_bundle_id_not_implemented() -> None: - """Test bundle_id raises NotImplementedError (covers line 26).""" - config = get_hconfig(Platform.HP_PROCURVE) - config.add_child("interface Trk1") +def test_bundle_id() -> None: + """Test bundle_id returns the trunk number of a bundle member.""" + config = HConfig.from_text(Platform.HP_PROCURVE) + config.add_child("interface 1/45") + config.add_child("trunk 1/45,2/45 trk1 trunk") view = get_hconfig_view(config) - interface_view = view.interface_view_by_name("Trk1") - assert interface_view is not None + interface_view = view.interface_view_by_name("1/45") + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) + assert interface_view.bundle_id == "1" + + +def test_bundle_id_none() -> None: + """Test bundle_id returns None when the interface is not a bundle member.""" + config = HConfig.from_text(Platform.HP_PROCURVE) + config.add_child("interface 1/1") - with pytest.raises(NotImplementedError): - _ = interface_view.bundle_id + view = get_hconfig_view(config) + interface_view = view.interface_view_by_name("1/1") + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) + assert interface_view.bundle_id is None def test_bundle_member_interfaces() -> None: """Test bundle_member_interfaces returns member interfaces (covers lines 31-42).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface Trk1") config.add_child("trunk 1/45,2/45 trk1 trunk") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("Trk1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) members = list(interface_view.bundle_member_interfaces) assert "1/45" in members @@ -38,12 +49,12 @@ def test_bundle_member_interfaces() -> None: def test_bundle_member_interfaces_bundle_not_found_error() -> None: """Test bundle_member_interfaces raises TypeError when bundle config missing (covers lines 33-36).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface Trk1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("Trk1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) with pytest.raises( TypeError, match="Interface is a bundle but bundle config was not found" @@ -53,12 +64,12 @@ def test_bundle_member_interfaces_bundle_not_found_error() -> None: def test_bundle_member_interfaces_value_error() -> None: """Test bundle_member_interfaces raises ValueError for non-bundle (covers lines 38-40).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) with pytest.raises(ValueError, match="The bundle config line couldn't be found"): _ = list(interface_view.bundle_member_interfaces) @@ -66,136 +77,136 @@ def test_bundle_member_interfaces_value_error() -> None: def test_bundle_name() -> None: """Test bundle_name returns bundle name (covers lines 46-53).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_child("trunk 1/1-2 trk1 lacp") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.bundle_name == "Trk1" def test_bundle_name_none() -> None: """Test bundle_name returns None when not in bundle (covers line 53).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.bundle_name is None def test_description() -> None: """Test description returns interface name (covers lines 57-59).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_children_deep(("interface 1/1", 'name "uplink port"')) view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.description == "uplink port" def test_description_empty() -> None: """Test description returns empty string (covers line 59).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert not interface_view.description def test_duplex_auto() -> None: """Test duplex returns auto (covers line 65).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.duplex == InterfaceDuplex.AUTO def test_enabled_true() -> None: """Test enabled returns True (covers line 69).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.enabled is True def test_enabled_false() -> None: """Test enabled returns False when disabled (covers line 69).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_children_deep(("interface 1/1", "disable")) view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.enabled is False def test_has_nac_authenticator() -> None: """Test has_nac with authenticator (covers line 74).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_child("aaa port-access authenticator 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.has_nac is True def test_has_nac_mac_based() -> None: """Test has_nac with mac-based (covers line 74).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_child("aaa port-access mac-based 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.has_nac is True def test_has_nac_false() -> None: """Test has_nac returns False (covers line 74).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.has_nac is False def test_ipv4_interface_none() -> None: """Test ipv4_interface returns None (covers line 84).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.ipv4_interface is None def test_ipv4_interfaces() -> None: """Test ipv4_interfaces returns IP addresses (covers lines 88-93).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_children_deep(("vlan 10", "ip address 10.1.1.1 255.255.255.0")) view = get_hconfig_view(config) interface_view = view.interface_view_by_name("vlan 10") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) ips = list(interface_view.ipv4_interfaces) assert len(ips) == 1 @@ -204,12 +215,12 @@ def test_ipv4_interfaces() -> None: def test_ipv4_interfaces_invalid() -> None: """Test ipv4_interfaces skips invalid addresses (covers line 93).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_children_deep(("vlan 10", "ip address dhcp-bootp")) view = get_hconfig_view(config) interface_view = view.interface_view_by_name("vlan 10") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) ips = list(interface_view.ipv4_interfaces) assert len(ips) == 0 @@ -217,73 +228,73 @@ def test_ipv4_interfaces_invalid() -> None: def test_is_bundle_true() -> None: """Test is_bundle returns True (covers line 97).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface Trk1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("Trk1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.is_bundle is True def test_is_bundle_false() -> None: """Test is_bundle returns False (covers line 97).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.is_bundle is False def test_is_loopback_true() -> None: """Test is_loopback returns True (covers line 101).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface Loopback0") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("Loopback0") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.is_loopback is True def test_is_loopback_false() -> None: """Test is_loopback returns False (covers line 101).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.is_loopback is False def test_is_subinterface_true() -> None: """Test is_subinterface returns True (covers line 105).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1.100") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1.100") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.is_subinterface is True def test_is_subinterface_false() -> None: """Test is_subinterface returns False (covers line 105).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.is_subinterface is False def test_is_svi_true() -> None: """Test is_svi returns True (covers line 109).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) vlan = config.add_child("vlan 10") vlan.add_child("ip address 10.1.1.1 255.255.255.0") @@ -296,154 +307,154 @@ def test_is_svi_true() -> None: def test_is_svi_false() -> None: """Test is_svi returns False (covers line 109).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.is_svi is False def test_module_number() -> None: """Test module_number returns module (covers lines 113-116).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 2/10") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("2/10") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.module_number == 2 def test_module_number_none() -> None: """Test module_number returns None (covers lines 115-116).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface Trk1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("Trk1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.module_number is None def test_nac_control_direction_in_true() -> None: """Test nac_control_direction_in returns True (covers line 121).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_child("aaa port-access 1/1 controlled-direction in") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.nac_control_direction_in is True def test_nac_control_direction_in_false() -> None: """Test nac_control_direction_in returns False (covers line 121).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.nac_control_direction_in is False def test_nac_host_mode() -> None: """Test nac_host_mode returns None (covers line 130).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.nac_host_mode is None def test_nac_mab_first_true() -> None: """Test nac_mab_first returns True (covers line 135).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_child("aaa port-access 1/1 auth-order mac-based authenticator") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.nac_mab_first is True def test_nac_mab_first_false() -> None: """Test nac_mab_first returns False (covers line 135).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.nac_mab_first is False def test_nac_max_dot1x_clients() -> None: """Test nac_max_dot1x_clients returns count (covers lines 144-148).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_child("aaa port-access authenticator 1/1 client-limit 5") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.nac_max_dot1x_clients == 5 def test_nac_max_dot1x_clients_default() -> None: """Test nac_max_dot1x_clients returns default (covers line 148).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.nac_max_dot1x_clients == 1 def test_nac_max_mab_clients() -> None: """Test nac_max_mab_clients returns count (covers lines 153-157).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_child("aaa port-access mac-based 1/1 addr-limit 10") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.nac_max_mab_clients == 10 def test_nac_max_mab_clients_default() -> None: """Test nac_max_mab_clients returns default (covers line 157).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.nac_max_mab_clients == 1 def test_name_with_interface_prefix() -> None: """Test name returns interface name (covers lines 161-163).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.name == "1/1" def test_name_without_interface_prefix() -> None: """Test name returns text as-is (covers line 163).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) vlan = config.add_child("vlan 10") vlan.add_child("ip address 10.1.1.1 255.255.255.0") @@ -456,205 +467,196 @@ def test_name_without_interface_prefix() -> None: def test_native_vlan() -> None: """Test native_vlan returns VLAN ID (covers lines 167-169).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_children_deep(("interface 1/1", "untagged vlan 100")) view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.native_vlan == 100 def test_native_vlan_none() -> None: """Test native_vlan returns None (covers line 169).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.native_vlan is None def test_number() -> None: """Test number returns interface number (covers line 173).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/10") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/10") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.number == "1/10" def test_parent_name() -> None: """Test parent_name returns parent interface (covers lines 177-179).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1.100") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1.100") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.parent_name == "1/1" def test_parent_name_none() -> None: """Test parent_name returns None (covers line 179).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.parent_name is None def test_poe_true() -> None: """Test poe returns True (covers line 183).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.poe is True def test_poe_false() -> None: """Test poe returns False (covers line 183).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_children_deep(("interface 1/1", "no power-over-ethernet")) view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.poe is False def test_port_number() -> None: """Test port_number returns port number (covers line 187).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 2/15") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("2/15") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.port_number == 15 def test_port_number_with_subinterface() -> None: """Test port_number with subinterface (covers line 187).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/5.100") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/5.100") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.port_number == 5 def test_speed_none() -> None: """Test speed returns None (covers line 193).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.speed is None def test_subinterface_number() -> None: """Test subinterface_number returns number (covers line 197).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1.200") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1.200") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.subinterface_number == 200 def test_subinterface_number_none() -> None: """Test subinterface_number returns None (covers line 197).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.subinterface_number is None def test_tagged_all_false() -> None: """Test tagged_all always returns False (covers line 201).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.tagged_all is False def test_tagged_vlans() -> None: """Test tagged_vlans returns VLAN list (covers line 205).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) interface = config.add_child("interface 1/1") interface.add_child("tagged vlan 10") interface.add_child("tagged vlan 20") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.tagged_vlans == (10, 20) def test_tagged_vlans_empty() -> None: """Test tagged_vlans returns empty tuple (covers line 205).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.tagged_vlans == () def test_vrf_empty() -> None: """Test vrf always returns empty string (covers line 212).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("1/1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert not interface_view.vrf def test_bundle_prefix() -> None: """Test _bundle_prefix returns 'trk' (covers line 216).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface Trk1") view = get_hconfig_view(config) interface_view = view.interface_view_by_name("Trk1") - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceHPProcurve) assert interface_view.is_bundle -def test_dot1q_mode_from_vlans_not_implemented() -> None: - """Test dot1q_mode_from_vlans raises NotImplementedError (covers line 243).""" - config = get_hconfig(Platform.HP_PROCURVE) - view = get_hconfig_view(config) - - with pytest.raises(NotImplementedError): - view.dot1q_mode_from_vlans(untagged_vlan=10) - - def test_hostname() -> None: """Test hostname returns hostname (covers lines 247-249).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child('hostname "SWITCH01"') view = get_hconfig_view(config) @@ -663,7 +665,7 @@ def test_hostname() -> None: def test_hostname_none() -> None: """Test hostname returns None (covers line 249).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) view = get_hconfig_view(config) assert view.hostname is None @@ -671,7 +673,7 @@ def test_hostname_none() -> None: def test_interface_names_mentioned() -> None: """Test interface_names_mentioned includes all interfaces (covers lines 253-266).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_child("interface 2/5") config.add_child("aaa port-access authenticator 1/10") @@ -690,7 +692,7 @@ def test_interface_names_mentioned() -> None: def test_interface_views() -> None: """Test interface_views yields interface views (covers lines 270-274).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_children_deep(("vlan 10", "ip address 10.1.1.1 255.255.255.0")) @@ -704,7 +706,7 @@ def test_interface_views() -> None: def test_interfaces() -> None: """Test interfaces returns interface children (covers line 278).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("interface 1/1") config.add_child("interface 2/2") @@ -716,7 +718,7 @@ def test_interfaces() -> None: def test_ipv4_default_gw() -> None: """Test ipv4_default_gw returns gateway IP (covers lines 282-284).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("ip default-gateway 192.168.1.1") view = get_hconfig_view(config) @@ -725,7 +727,7 @@ def test_ipv4_default_gw() -> None: def test_ipv4_default_gw_none() -> None: """Test ipv4_default_gw returns None (covers line 284).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) view = get_hconfig_view(config) assert view.ipv4_default_gw is None @@ -733,7 +735,7 @@ def test_ipv4_default_gw_none() -> None: def test_location() -> None: """Test location returns location string (covers lines 288-290).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child('snmp-server location "Building A, Floor 2"') view = get_hconfig_view(config) @@ -742,7 +744,7 @@ def test_location() -> None: def test_location_empty() -> None: """Test location returns empty string (covers line 290).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) view = get_hconfig_view(config) assert not view.location @@ -750,7 +752,7 @@ def test_location_empty() -> None: def test_stack_members() -> None: """Test stack_members yields stack members (covers lines 301-309).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) stacking = config.add_child("stacking") stacking.add_child('member 1 type "JL123" mac-address abc123-def456') stacking.add_child('member 2 type "JL456" mac-address xyz789-uvw012') @@ -769,7 +771,7 @@ def test_stack_members() -> None: def test_stack_members_no_stacking() -> None: """Test stack_members returns empty when no stacking (covers line 301).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) view = get_hconfig_view(config) members = list(view.stack_members) @@ -779,7 +781,7 @@ def test_stack_members_no_stacking() -> None: def test_vlans_explicit() -> None: """Test vlans yields explicitly defined VLANs (covers lines 318-346).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_children_deep(("vlan 10", 'name "Data"')) config.add_children_deep(("vlan 20", 'name "Voice"')) @@ -793,7 +795,7 @@ def test_vlans_explicit() -> None: def test_vlans_range() -> None: """Test vlans expands VLAN ranges (covers lines 318-346).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) config.add_child("vlan 10-12") view = get_hconfig_view(config) @@ -807,7 +809,7 @@ def test_vlans_range() -> None: def test_vlans_from_interfaces() -> None: """Test vlans includes VLANs from interfaces (covers lines 318-346).""" - config = get_hconfig(Platform.HP_PROCURVE) + config = HConfig.from_text(Platform.HP_PROCURVE) interface = config.add_child("interface 1/1") interface.add_child("tagged vlan 100") interface.add_child("untagged vlan 50") diff --git a/tests/config_view/test_interface.py b/tests/unit/platforms/views/test_interface.py similarity index 84% rename from tests/config_view/test_interface.py rename to tests/unit/platforms/views/test_interface.py index 70e3b0d7..a66f7e6d 100644 --- a/tests/config_view/test_interface.py +++ b/tests/unit/platforms/views/test_interface.py @@ -1,5 +1,6 @@ -from hier_config import get_hconfig, get_hconfig_view +from hier_config import HConfig, get_hconfig_view from hier_config.models import Platform +from hier_config.platforms.cisco_ios.view import ConfigViewInterfaceCiscoIOS from hier_config.platforms.hp_procurve.functions import hp_procurve_expand_range @@ -34,12 +35,12 @@ def test_hp_procurve_expand_range() -> None: def test_bundle_name() -> None: - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_children_deep(("interface GigabitEthernet1/1/3", "channel-group 1")) config.add_child("interface Port-channel1") interface_view = get_hconfig_view(config).interface_view_by_name( "GigabitEthernet1/1/3" ) - assert interface_view is not None + assert isinstance(interface_view, ConfigViewInterfaceCiscoIOS) assert interface_view.bundle_name == "Port-channel1" diff --git a/tests/config_view/test_view.py b/tests/unit/platforms/views/test_view.py similarity index 51% rename from tests/config_view/test_view.py rename to tests/unit/platforms/views/test_view.py index 1651915b..dc00ce81 100644 --- a/tests/config_view/test_view.py +++ b/tests/unit/platforms/views/test_view.py @@ -1,13 +1,21 @@ """Tests for view_base.py ConfigViewInterfaceBase and HConfigViewBase classes.""" -from hier_config import Platform, get_hconfig, get_hconfig_view +from hier_config import HConfig, Platform, get_hconfig_view +from hier_config.platforms.cisco_ios.view import ConfigViewInterfaceCiscoIOS from hier_config.platforms.models import InterfaceDot1qMode -from hier_config.platforms.view_base import ConfigViewInterfaceBase, HConfigViewBase +from hier_config.platforms.view_base import ( + ConfigViewInterfaceBase, + HConfigViewBase, + InterfaceBundleViewMixin, + InterfaceNACViewMixin, + InterfacePhysicalViewMixin, + InterfaceVlanViewMixin, +) def test_interface_dot1q_mode_tagged() -> None: """Test dot1q_mode returns TAGGED (covers view_base.py line 45).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_children_deep(("interface GigabitEthernet0/0", "switchport mode trunk")) config.add_children_deep( ("interface GigabitEthernet0/0", "switchport trunk allowed vlan 10,20") @@ -15,37 +23,37 @@ def test_interface_dot1q_mode_tagged() -> None: view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, InterfaceVlanViewMixin) assert interface_view.dot1q_mode == InterfaceDot1qMode.TAGGED def test_interface_dot1q_mode_access() -> None: """Test dot1q_mode returns ACCESS (covers view_base.py line 47).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_children_deep( ("interface GigabitEthernet0/0", "switchport", "switchport mode access") ) view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, InterfaceVlanViewMixin) assert interface_view.dot1q_mode == InterfaceDot1qMode.ACCESS def test_interface_dot1q_mode_none() -> None: """Test dot1q_mode returns None (covers view_base.py line 49).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_children_deep(("interface GigabitEthernet0/0", "no switchport")) view = get_hconfig_view(config) interface_view = view.interface_view_by_name("GigabitEthernet0/0") - assert interface_view is not None + assert isinstance(interface_view, InterfaceVlanViewMixin) assert interface_view.dot1q_mode is None def test_interface_ipv4_interface_none() -> None: """Test ipv4_interface returns None when no IP (covers view_base.py line 69).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) @@ -56,7 +64,7 @@ def test_interface_ipv4_interface_none() -> None: def test_interface_is_subinterface_true() -> None: """Test is_subinterface returns True (covers view_base.py line 89).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0.100") view = get_hconfig_view(config) @@ -67,7 +75,7 @@ def test_interface_is_subinterface_true() -> None: def test_hconfig_view_interface_view_by_name_none() -> None: """Test interface_view_by_name returns None (covers view_base.py line 221).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") view = get_hconfig_view(config) @@ -78,7 +86,7 @@ def test_hconfig_view_interface_view_by_name_none() -> None: def test_hconfig_view_interfaces_names() -> None: """Test interfaces_names property (covers view_base.py line 236).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet0/0") config.add_child("interface GigabitEthernet0/1") config.add_child("interface Port-channel1") @@ -94,7 +102,7 @@ def test_hconfig_view_interfaces_names() -> None: def test_hconfig_view_module_numbers_none() -> None: """Test module_numbers when module_number is None (covers view_base.py line 250).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface Port-channel1") config.add_child("interface Loopback0") @@ -106,7 +114,7 @@ def test_hconfig_view_module_numbers_none() -> None: def test_hconfig_view_module_numbers_duplicate() -> None: """Test module_numbers skips duplicates (covers view_base.py lines 251-252).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet1/0/1") config.add_child("interface GigabitEthernet1/0/2") config.add_child("interface GigabitEthernet2/0/1") @@ -121,7 +129,7 @@ def test_hconfig_view_module_numbers_duplicate() -> None: def test_hconfig_view_module_numbers_yield() -> None: """Test module_numbers yields values (covers view_base.py lines 253-254).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("interface GigabitEthernet1/0/1") config.add_child("interface GigabitEthernet2/0/1") config.add_child("interface GigabitEthernet3/0/1") @@ -134,7 +142,7 @@ def test_hconfig_view_module_numbers_yield() -> None: def test_hconfig_view_vlan_ids() -> None: """Test vlan_ids property (covers view_base.py line 266).""" - config = get_hconfig(Platform.CISCO_IOS) + config = HConfig.from_text(Platform.CISCO_IOS) config.add_child("vlan 10") config.add_child("vlan 20") config.add_child("vlan 30") @@ -146,20 +154,39 @@ def test_hconfig_view_vlan_ids() -> None: def test_interface_view_abstract_properties_coverage() -> None: - """Test that abstract properties are properly defined (covers view_base.py lines 184, 205, 210, 226, 231, 235, 241, 246, 256).""" - assert hasattr(ConfigViewInterfaceBase, "bundle_id") - assert hasattr(ConfigViewInterfaceBase, "bundle_member_interfaces") - assert hasattr(ConfigViewInterfaceBase, "bundle_name") + """Test that abstract properties are defined on the base and the mixins.""" assert hasattr(ConfigViewInterfaceBase, "description") - assert hasattr(ConfigViewInterfaceBase, "duplex") assert hasattr(ConfigViewInterfaceBase, "enabled") - assert hasattr(ConfigViewInterfaceBase, "has_nac") assert hasattr(ConfigViewInterfaceBase, "ipv4_interfaces") - assert hasattr(ConfigViewInterfaceBase, "is_bundle") assert hasattr(ConfigViewInterfaceBase, "is_loopback") assert hasattr(ConfigViewInterfaceBase, "is_svi") - assert hasattr(ConfigViewInterfaceBase, "module_number") - assert hasattr(ConfigViewInterfaceBase, "_bundle_prefix") + assert hasattr(ConfigViewInterfaceBase, "name") + assert hasattr(ConfigViewInterfaceBase, "number") + assert hasattr(ConfigViewInterfaceBase, "port_number") + assert hasattr(ConfigViewInterfaceBase, "vrf") + + assert hasattr(InterfaceBundleViewMixin, "bundle_id") + assert hasattr(InterfaceBundleViewMixin, "bundle_member_interfaces") + assert hasattr(InterfaceBundleViewMixin, "bundle_name") + assert hasattr(InterfaceBundleViewMixin, "is_bundle") + assert hasattr(InterfaceBundleViewMixin, "_bundle_prefix") + + assert hasattr(InterfaceVlanViewMixin, "dot1q_mode") + assert hasattr(InterfaceVlanViewMixin, "native_vlan") + assert hasattr(InterfaceVlanViewMixin, "tagged_all") + assert hasattr(InterfaceVlanViewMixin, "tagged_vlans") + + assert hasattr(InterfaceNACViewMixin, "has_nac") + assert hasattr(InterfaceNACViewMixin, "nac_control_direction_in") + assert hasattr(InterfaceNACViewMixin, "nac_host_mode") + assert hasattr(InterfaceNACViewMixin, "nac_mab_first") + assert hasattr(InterfaceNACViewMixin, "nac_max_dot1x_clients") + assert hasattr(InterfaceNACViewMixin, "nac_max_mab_clients") + + assert hasattr(InterfacePhysicalViewMixin, "duplex") + assert hasattr(InterfacePhysicalViewMixin, "module_number") + assert hasattr(InterfacePhysicalViewMixin, "poe") + assert hasattr(InterfacePhysicalViewMixin, "speed") # Verify HConfigViewBase has the expected abstract methods/properties assert hasattr(HConfigViewBase, "dot1q_mode_from_vlans") @@ -171,3 +198,75 @@ def test_interface_view_abstract_properties_coverage() -> None: assert hasattr(HConfigViewBase, "location") assert hasattr(HConfigViewBase, "stack_members") assert hasattr(HConfigViewBase, "vlans") + + +def test_dot1q_mode_from_vlans_tagged_all() -> None: + """tagged_all wins over any other VLAN data (#228).""" + view = get_hconfig_view(HConfig.from_text(Platform.CISCO_IOS)) + assert ( + view.dot1q_mode_from_vlans( + untagged_vlan=10, tagged_vlans=(20,), tagged_all=True + ) + == InterfaceDot1qMode.TAGGED_ALL + ) + + +def test_dot1q_mode_from_vlans_tagged() -> None: + """Explicit tagged VLANs mean TAGGED mode (#228).""" + view = get_hconfig_view(HConfig.from_text(Platform.CISCO_IOS)) + assert ( + view.dot1q_mode_from_vlans(tagged_vlans=(20, 30)) == InterfaceDot1qMode.TAGGED + ) + assert ( + view.dot1q_mode_from_vlans(untagged_vlan=10, tagged_vlans=(20, 30)) + == InterfaceDot1qMode.TAGGED + ) + + +def test_dot1q_mode_from_vlans_access() -> None: + """An untagged VLAN alone means ACCESS mode (#228).""" + view = get_hconfig_view(HConfig.from_text(Platform.CISCO_IOS)) + assert view.dot1q_mode_from_vlans(untagged_vlan=10) == InterfaceDot1qMode.ACCESS + + +def test_dot1q_mode_from_vlans_none() -> None: + """No VLAN data means no 802.1Q mode (#228).""" + view = get_hconfig_view(HConfig.from_text(Platform.CISCO_IOS)) + assert view.dot1q_mode_from_vlans() is None + + +def test_dot1q_mode_from_vlans_available_on_all_views() -> None: + """Every platform view shares the base implementation (#228).""" + for platform in ( + Platform.ARISTA_EOS, + Platform.CISCO_IOS, + Platform.CISCO_NXOS, + Platform.CISCO_XR, + Platform.HP_PROCURVE, + ): + view = get_hconfig_view(HConfig.from_text(platform)) + assert view.dot1q_mode_from_vlans(untagged_vlan=1) == InterfaceDot1qMode.ACCESS + + +def test_bundle_mixin_without_membership_prefix_is_inert() -> None: + """An unset _bundle_membership_prefix must not match arbitrary children (#278). + + get_child(startswith="") matches any first child, so the defaults must + short-circuit rather than return garbage for a platform that inherits the + bundle mixin without declaring its membership command prefix. + """ + config = HConfig.from_text( + Platform.CISCO_IOS, + "interface Port-channel10\n description not-a-bundle-command\n", + ) + interface = config.get_child(startswith="interface ") + assert interface is not None + + class PrefixlessBundleView(ConfigViewInterfaceCiscoIOS): + """IOS view with the membership prefix unset.""" + + _bundle_membership_prefix = "" + + view = PrefixlessBundleView(interface) + assert view.bundle_id is None + assert not tuple(view.bundle_member_interfaces) diff --git a/tests/unit/test_child.py b/tests/unit/test_child.py new file mode 100644 index 00000000..8d10ff88 --- /dev/null +++ b/tests/unit/test_child.py @@ -0,0 +1,1278 @@ +"""Tests for HConfigChild functionality.""" +# pylint: disable=too-many-lines + +import types + +import pytest + +from hier_config import HConfig, HConfigChild +from hier_config.exceptions import DuplicateChildError +from hier_config.models import IdempotentCommandsRule, Instance, MatchRule, Platform +from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS + + +def test_add_ancestor_copy_of(platform_a: Platform) -> None: + source_config = HConfig.from_text(platform_a) + ipv4_address = source_config.add_children_deep( + ("interface Vlan2", "ip address 192.168.1.0/24") + ) + destination_config = HConfig.from_text(platform_a) + destination_config.add_ancestor_copy_of(ipv4_address) + + assert len(tuple(destination_config.all_children())) == 2 + assert isinstance(destination_config.all_children(), types.GeneratorType) + + +def test_depth(platform_a: Platform) -> None: + ip_address = HConfig.from_text(platform_a).add_children_deep( + ("interface Vlan2", "ip address 192.168.1.1 255.255.255.0"), + ) + assert ip_address.depth == 2 + + +def test_get_child(platform_a: Platform) -> None: + hier = HConfig.from_text(platform_a) + hier.add_child("interface Vlan2") + child = hier.get_child(equals="interface Vlan2") + assert child is not None + assert child.text == "interface Vlan2" + + +def test_get_child_deep(platform_a: Platform) -> None: + hier = HConfig.from_text(platform_a) + interface1 = hier.add_child("interface Vlan1") + interface1.add_children( + ("ip address 192.168.1.1 255.255.255.0", "description asdf1"), + ) + interface2 = hier.add_child("interface Vlan2") + interface2.add_children( + ("ip address 192.168.2.1 255.255.255.0", "description asdf2"), + ) + interface3 = hier.add_child("interface Vlan3") + interface3.add_children( + ("ip address 192.168.3.1 255.255.255.0", "description asdf3"), + ) + + # search all 'interface vlan' interfaces for 'ip address' + children = tuple( + hier.get_children_deep( + ( + MatchRule(startswith="interface Vlan"), + MatchRule(startswith="ip address "), + ), + ), + ) + assert len(children) == 3 + children = tuple( + hier.get_children_deep( + ( + MatchRule(startswith="interface Vlan1"), + MatchRule(startswith="ip address "), + ), + ), + ) + assert len(children) == 1 + children = tuple( + hier.get_children_deep( + ( + MatchRule(equals="interface Vlan2"), + MatchRule(equals="ip address 192.168.2.1 255.255.255.0"), + ), + ), + ) + assert len(children) == 1 + + +def test_child_deep2() -> None: + config = HConfig.from_text(Platform.CISCO_IOS) + + config.add_children_deep(("a", "b")) + config.add_children_deep(("a", "b1")) + config.add_children_deep(("a", "b2")) + + assert ( + len( + tuple( + config.get_children_deep( + (MatchRule(startswith="a"), MatchRule(startswith="b")), + ), + ), + ) + == 3 + ) + + assert ( + len( + tuple( + config.get_children_deep( + (MatchRule(equals="a"), MatchRule(startswith="b2")), + ), + ), + ) + == 1 + ) + + +def test_get_children(platform_a: Platform) -> None: + hier = HConfig.from_text(platform_a) + hier.add_child("interface Vlan2") + hier.add_child("interface Vlan3") + children = tuple(hier.get_children(startswith="interface")) + assert len(children) == 2 + for child in children: + assert child.text.startswith("interface Vlan") + + +def test_move(platform_a: Platform, platform_b: Platform) -> None: + hier1 = HConfig.from_text(platform_a) + interface1 = hier1.add_child("interface Vlan2") + interface1.add_child("192.168.0.1/30") + + assert len(tuple(hier1.all_children())) == 2 + + hier2 = HConfig.from_text(platform_b) + + assert not tuple(hier2.all_children()) + + interface1.move(hier2) + + assert not tuple(hier1.all_children()) + assert len(tuple(hier2.all_children())) == 2 + + +def test_del_child_by_text(platform_a: Platform) -> None: + hier = HConfig.from_text(platform_a) + hier.add_child("interface Vlan2") + hier.children.delete("interface Vlan2") + + assert not tuple(hier.all_children()) + + +def test_del_child(platform_a: Platform) -> None: + hier1 = HConfig.from_text(platform_a) + hier1.add_child("interface Vlan2") + + assert len(tuple(hier1.all_children())) == 1 + + child_to_delete = hier1.get_child(startswith="interface") + assert child_to_delete is not None + hier1.children.delete(child_to_delete) + + assert not tuple(hier1.all_children()) + + +def test_add_children(platform_a: Platform) -> None: + interface_items1 = ( + "description switch-mgmt 192.168.1.0/24", + "ip address 192.168.1.1/24", + ) + hier1 = HConfig.from_text(platform_a) + interface1 = hier1.add_child("interface Vlan2") + interface1.add_children(interface_items1) + + assert len(tuple(hier1.all_children())) == 3 + + interface_items2 = ("description switch-mgmt 192.168.1.0/24",) + hier2 = HConfig.from_text(platform_a) + interface2 = hier2.add_child("interface Vlan2") + interface2.add_children(interface_items2) + + assert len(tuple(hier2.all_children())) == 2 + + +def test_add_child(platform_a: Platform) -> None: + config = HConfig.from_text(platform_a) + interface = config.add_child("interface Vlan2") + assert interface.depth == 1 + assert interface.text == "interface Vlan2" + with pytest.raises(DuplicateChildError): + config.add_child("interface Vlan2") + assert config.children.get("interface Vlan2") is interface + + +def test_add_deep_copy_of(platform_a: Platform, platform_b: Platform) -> None: + interface1 = HConfig.from_text(platform_a).add_child("interface Vlan2") + interface1.add_children( + ("description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"), + ) + + hier2 = HConfig.from_text(platform_b) + hier2.add_deep_copy_of(interface1) + + assert len(tuple(hier2.all_children())) == 3 + assert isinstance(hier2.all_children(), types.GeneratorType) + + +def test_path(platform_a: Platform) -> None: + config_aaa = HConfig.from_text(platform_a).add_children_deep(("a", "aa", "aaa")) + assert tuple(config_aaa.path()) == ("a", "aa", "aaa") + + +def test_indented_text(platform_a: Platform) -> None: + ip_address = ( + HConfig.from_text(platform_a) + .add_child("interface Vlan2") + .add_child("ip address 192.168.1.1 255.255.255.0") + ) + assert ip_address.indented_text() == " ip address 192.168.1.1 255.255.255.0" + assert isinstance(ip_address.indented_text(), str) + assert not isinstance(ip_address.indented_text(), list) + + +def test_all_children_sorted_by_tags(platform_a: Platform) -> None: + config = HConfig.from_text(platform_a) + config_a = config.add_child("a") + config_aa = config_a.add_child("aa") + config_a.add_child("ab") + config_aaa = config_aa.add_child("aaa") + config_aab = config_aa.add_child("aab") + config_aaa.add_tags("aaa") + config_aab.add_tags("aab") + + case_1_matches = [ + c.text + for c in config.all_children_sorted_by_tags(frozenset(("aaa",)), frozenset()) + ] + assert case_1_matches == ["a", "aa", "aaa"] + case_2_matches = [ + c.text + for c in config.all_children_sorted_by_tags(frozenset(), frozenset(("aab",))) + ] + assert case_2_matches == ["a", "aa", "aaa", "ab"] + case_3_matches = [ + c.text + for c in config.all_children_sorted_by_tags( + frozenset(("aaa",)), + frozenset(("aab",)), + ) + ] + assert case_3_matches == ["a", "aa", "aaa"] + + +def test_all_children_sorted(platform_a: Platform) -> None: + hier = HConfig.from_text(platform_a) + interface = hier.add_child("interface Vlan2") + interface.add_child("standby 1 ip 10.15.11.1") + assert len(tuple(hier.all_children_sorted())) == 2 + + +def test_all_children(platform_a: Platform) -> None: + hier = HConfig.from_text(platform_a) + interface = hier.add_child("interface Vlan2") + interface.add_child("standby 1 ip 10.15.11.1") + assert len(tuple(hier.all_children())) == 2 + + +def test_delete(platform_a: Platform) -> None: + hier = HConfig.from_text(platform_a) + config_a = hier.add_child("a") + config_a.delete() + assert not hier.children + + +def test_set_order_weight(platform_a: Platform) -> None: + hier = HConfig.from_text(platform_a) + child = hier.add_child("no vlan filter") + hier.set_order_weight() + assert child.order_weight == 200 + + +def test_add_tags(platform_a: Platform) -> None: + interface = HConfig.from_text(platform_a).add_child("interface Vlan2") + ip_address = interface.add_child("ip address 192.168.1.1/24") + assert not interface.tags + assert not ip_address.tags + ip_address.add_tags("a") + assert "a" in interface.tags + assert "a" in ip_address.tags + assert "b" not in interface.tags + assert "b" not in ip_address.tags + interface.add_tags("c") + assert "c" in ip_address.tags + interface.remove_tags("c") + assert "c" not in ip_address.tags + + +def test_append_tags(platform_a: Platform) -> None: + config = HConfig.from_text(platform_a) + interface = config.add_child("interface Vlan2") + ip_address = interface.add_child("ip address 192.168.1.1/24") + ip_address.add_tags("test_tag") + assert "test_tag" in config.tags + assert "test_tag" in interface.tags + assert "test_tag" in ip_address.tags + + +def test_remove_tags(platform_a: Platform) -> None: + config = HConfig.from_text(platform_a) + interface = config.add_child("interface Vlan2") + ip_address = interface.add_child("ip address 192.168.1.1/24") + ip_address.add_tags("test_tag") + assert "test_tag" in config.tags + assert "test_tag" in interface.tags + assert "test_tag" in ip_address.tags + ip_address.remove_tags("test_tag") + assert "test_tag" not in config.tags + assert "test_tag" not in interface.tags + assert "test_tag" not in ip_address.tags + + +def test_negate(platform_a: Platform) -> None: + config = HConfig.from_text(platform_a) + interface = config.add_child("interface Vlan2") + interface.negate() + assert interface.text == "no interface Vlan2" + assert config.children.get("no interface Vlan2") is interface + + +def test_add_shallow_copy_of(platform_a: Platform) -> None: + base_config = HConfig.from_text(platform_a) + + interface_a = HConfig.from_text(platform_a).add_child("interface Vlan2") + interface_a.add_tags(frozenset(("ta", "tb"))) + interface_a.comments.add("ca") + interface_a.order_weight = 200 + + copied_interface = base_config.add_shallow_copy_of(interface_a, merged=True) + assert copied_interface.tags == frozenset(("ta", "tb")) + assert copied_interface.comments == frozenset(("ca",)) + assert copied_interface.order_weight == 200 + assert copied_interface.instances == [ + Instance( + id=id(interface_a.root), + comments=frozenset(interface_a.comments), + tags=interface_a.tags, + ), + ] + + +def test_line_inclusion_test(platform_a: Platform) -> None: + ip_address_ab = HConfig.from_text(platform_a).add_children_deep( + ("interface Vlan2", "ip address 192.168.2.1/24"), + ) + ip_address_ab.add_tags(frozenset(("a", "b"))) + + assert not ip_address_ab.line_inclusion_test(frozenset(("a",)), frozenset(("b",))) + assert not ip_address_ab.line_inclusion_test(frozenset(), frozenset(("a",))) + assert ip_address_ab.line_inclusion_test(frozenset(("a",)), frozenset()) + assert not ip_address_ab.line_inclusion_test(frozenset(), frozenset()) + + +def test_add_child_with_empty_text() -> None: + """Test that add_child raises ValueError when text is empty.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + + with pytest.raises(ValueError, match="text was empty"): + config.add_child("") + + +def test_add_child_duplicate_error() -> None: + """Test DuplicateChildError when adding duplicate child.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + config.add_child("interface GigabitEthernet0/0") + + with pytest.raises(DuplicateChildError, match="Found a duplicate section"): + config.add_child( + "interface GigabitEthernet0/0", + check_if_present=True, + return_if_present=False, + ) + + +def test_add_child_return_if_present() -> None: + """Test return_if_present option in add_child.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + child2 = config.add_child("interface GigabitEthernet0/0", return_if_present=True) + + assert id(child1) == id(child2) + + +def test_child_repr() -> None: + """Test HConfigChild __repr__ method.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child = config.add_child("interface GigabitEthernet0/0") + subchild = child.add_child("description test") + repr_str = repr(child) + + assert "HConfigChild(HConfig, interface GigabitEthernet0/0)" in repr_str + + repr_str2 = repr(subchild) + + assert "HConfigChild(HConfigChild, description test)" in repr_str2 + + +def test_child_ne() -> None: + """Test HConfigChild __ne__ method.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + child2 = config.add_child("interface GigabitEthernet0/1") + + assert child1 != child2 + + +def test_indented_text_with_comments() -> None: + """Test indented_text with comments.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child = config.add_child("interface GigabitEthernet0/0") + child.comments.add("test comment") + child.comments.add("another comment") + line = child.indented_text(style="with_comments") + + assert "!another comment, test comment" in line + + instance = Instance( + id=1, comments=frozenset(["instance comment"]), tags=frozenset(["tag1"]) + ) + child.instances.append(instance) + line_merged = child.indented_text(style="merged", tag="tag1") + + assert "1 instance" in line_merged + assert "instance comment" in line_merged + + instance2 = Instance(id=2, comments=frozenset(), tags=frozenset(["tag1"])) + child.instances.append(instance2) + line_merged2 = child.indented_text(style="merged", tag="tag1") + + assert "2 instances" in line_merged2 + + +def test_child_sectional_exit_no_exit_text() -> None: + """Test sectional_exit when rule returns None.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child = config.add_child("hostname test") + + assert child.sectional_exit is None + + +def test_child_is_match_endswith() -> None: + """Test is_match with endswith filter.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + + assert interface.is_match(endswith="Ethernet0/0") + assert not interface.is_match(endswith="Ethernet0/1") + + +def test_child_is_match_contains_single() -> None: + """Test is_match with single contains filter.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + + assert interface.is_match(contains="Gigabit") + assert not interface.is_match(contains="FastEthernet") + + +def test_child_is_match_contains_tuple() -> None: + """Test is_match with tuple contains filter.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + + assert interface.is_match(contains=("Gigabit", "FastEthernet")) + assert not interface.is_match(contains=("TenGigabit", "FastEthernet")) + + +def test_child_negate_default_strategy() -> None: + """A DEFAULT-strategy negation rule rewrites to the default form (#220).""" + platform = Platform.ARISTA_EOS + config = HConfig.from_text(platform) + interface = config.add_child("interface Ethernet1") + logging_event = interface.add_child("logging event link-status") + + assert logging_event.negate().text == "default logging event link-status" + + +def test_child_remove_tags_leaf_iterable() -> None: + """Test remove_tags on leaf with iterable.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + description.add_tags(frozenset(["tag1", "tag2", "tag3"])) + description.remove_tags(["tag1", "tag2"]) + + assert "tag1" not in description.tags + assert "tag2" not in description.tags + assert "tag3" in description.tags + + +def test_child_tags_setter_on_branch() -> None: + """Test tags setter on branch node.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + interface.tags = frozenset(["production", "critical"]) + + assert "production" in description.tags + assert "critical" in description.tags + + +def test_child_is_idempotent_command_avoid() -> None: + """Test is_idempotent_command with avoid rule.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + ip_address = interface.add_child("ip address 192.168.1.1 255.255.255.0") + other_children: list[HConfigChild] = [] + result = ip_address.is_idempotent_command(other_children) + + assert isinstance(result, bool) + + +def test_child_is_idempotent_command_with_avoid_rule() -> None: + """Test is_idempotent_command with avoid rule match.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + ip_access_group = interface.add_child("ip access-group test in") + result = ip_access_group.is_idempotent_command([]) + + assert isinstance(result, bool) + + +def test_child_overwrite_with_negate_else_branch() -> None: + """Test overwrite_with when negated child doesn't exist.""" + platform = Platform.CISCO_IOS + running_config = HConfig.from_text(platform) + running_interface = running_config.add_child("interface GigabitEthernet0/0") + running_interface.add_child("description old") + generated_config = HConfig.from_text(platform) + generated_interface = generated_config.add_child("interface GigabitEthernet0/0") + generated_interface.add_child("description new") + delta_config = HConfig.from_text(platform) + running_interface.overwrite_with(generated_interface, delta_config, negate=True) + delta_interface = delta_config.get_child(equals="interface GigabitEthernet0/0") + + assert delta_interface is not None + + +def test_child_overwrite_with_existing_negated() -> None: + """Test overwrite_with when negated child exists in delta.""" + platform = Platform.CISCO_IOS + running_config = HConfig.from_text(platform) + running_interface = running_config.add_child("interface GigabitEthernet0/0") + running_interface.add_child("description old") + generated_config = HConfig.from_text(platform) + generated_interface = generated_config.add_child("interface GigabitEthernet0/0") + generated_interface.add_child("description new") + delta_config = HConfig.from_text(platform) + delta_config.add_child("interface GigabitEthernet0/0") + running_interface.overwrite_with(generated_interface, delta_config, negate=True) + delta_interface = delta_config.get_child(equals="interface GigabitEthernet0/0") + + assert delta_interface is not None + + +def test_child_remove_tags_branch() -> None: + """Test remove_tags on branch node.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + description.add_tags("test_tag") + interface.remove_tags("test_tag") + + assert "test_tag" not in description.tags + + +def test_child_add_children_deep() -> None: + """Test add_children_deep method.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + result = interface.add_children_deep( + ["ip access-group test in", "description test"] + ) + + assert result.text == "description test" + assert result.depth == 3 + + +def test_child_default_method() -> None: + """Test _default method.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + description._default() # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + + assert description.text == "default description test" + + +def test_abstract_methods_coverage() -> None: + """Test coverage of abstract method implementations.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + desc = interface.add_child("description test") + + assert interface.root is config + assert desc.root is config + + assert interface.driver is not None + assert config.driver is not None + + lineage = tuple(desc.lineage()) + assert len(lineage) == 2 + assert lineage[0] is interface + + assert config.depth == 0 + assert interface.depth == 1 + assert desc.depth == 2 + + hash_value = hash(interface) + assert isinstance(hash_value, int) + + children_list = list(config) + assert len(children_list) == 1 + assert children_list[0] is interface + + +def test_get_child_deep_none() -> None: + """Test get_child_deep returns None when no match.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + config.add_child("interface GigabitEthernet0/0") + result = config.get_child_deep((MatchRule(equals="interface GigabitEthernet0/1"),)) + + assert result is None + + +def test_child_eq_comparison() -> None: + """Test HConfigChild __eq__ returns False for different text.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + child2 = config.add_child("interface GigabitEthernet0/1") + + assert child1 != child2 + + config2 = HConfig.from_text(platform) + child3 = config2.add_child("interface GigabitEthernet0/0") + assert child1 == child3 + + +def test_child_hash_consistency() -> None: + """Test HConfigChild __hash__.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child = config.add_child("interface GigabitEthernet0/0") + child.add_child("description test") + hash1 = hash(child) + hash2 = hash(child) + + assert hash1 == hash2 + + +def test_with_tags_recursive() -> None: + """Test _with_tags recursion.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.tags = frozenset(["production"]) + desc = interface.add_child("description test") + desc.tags = frozenset(["production"]) + tagged_config = config.with_tags(frozenset(["production"])) + + assert tagged_config.get_child(equals="interface GigabitEthernet0/0") is not None + + tagged_interface = tagged_config.get_child(equals="interface GigabitEthernet0/0") + + assert tagged_interface is not None + assert tagged_interface.get_child(equals="description test") is not None + + +def test_child_sectional_exit_with_exit_text() -> None: + """Test sectional_exit when rule has exit_text.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + exit_text = interface.sectional_exit + + assert exit_text == "exit" + + +def test_child_negate_swap_fallback() -> None: + """A command matching no negation rule falls back to swap_negation (#220).""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + description = interface.add_child("description test") + + assert description.negate().text == "no description test" + + +def test_child_lt_comparison() -> None: + """Test HConfigChild __lt__ for ordering.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + child2 = config.add_child("interface GigabitEthernet0/1") + child1.order_weight = 100 + child2.order_weight = 50 + + assert child2 < child1 + assert not child1 < child2 # pylint: disable=unneeded-not + + +def test_add_child_with_duplicates_allowed() -> None: + """Test add_child when duplicates are allowed.""" + platform = Platform.CISCO_XR + config = HConfig.from_text(platform) + route_policy = config.add_child("route-policy test") + child1 = route_policy.add_child("if destination in test then") + child2 = route_policy.add_child("if destination in test then") + + assert id(child1) != id(child2) + assert child1.text == child2.text + + +def test_get_children_with_duplicates() -> None: + """Test get_children when duplicates are allowed.""" + platform = Platform.CISCO_XR + config = HConfig.from_text(platform) + route_policy = config.add_child("route-policy test") + route_policy.add_child("if destination in test then") + route_policy.add_child("if destination in test then") + route_policy.add_child("if source in test then") + children = tuple(route_policy.get_children(startswith="if destination")) + + assert len(children) == 2 + + +def test_idempotency_key_with_equals_string() -> None: + """Test idempotency key generation with equals constraint as string.""" + driver = HConfigDriverCiscoIOS() + # Add a rule with equals as string + driver.rules.idempotent_commands.append( + IdempotentCommandsRule( + match_rules=(MatchRule(equals="logging console"),), + ) + ) + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Test the idempotency with equals string + key = driver._idempotency_key(child, (MatchRule(equals="logging console"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("equals|logging console",) + + +def test_idempotency_key_with_equals_frozenset() -> None: + """Test idempotency key generation with equals constraint as frozenset.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Test the idempotency with equals frozenset (should fall back to text) + key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + child, (MatchRule(equals=frozenset(["logging console", "other"])),) + ) + assert key == ("equals|logging console",) + + +def test_idempotency_key_no_match_rules() -> None: + """Test idempotency key falls back to text when no match rules apply.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """some command +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Empty MatchRule should fall back to text + key = driver._idempotency_key(child, (MatchRule(),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("text|some command",) + + +def test_idempotency_key_prefix_no_match() -> None: + """Test idempotency key when prefix doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Prefix that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(startswith="interface"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("text|logging console",) + + +def test_idempotency_key_suffix_no_match() -> None: + """Test idempotency key when suffix doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Suffix that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(endswith="emergency"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("text|logging console",) + + +def test_idempotency_key_contains_no_match() -> None: + """Test idempotency key when contains doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Contains that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(contains="interface"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("text|logging console",) + + +def test_idempotency_key_regex_no_match() -> None: + """Test idempotency key when regex doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Regex that doesn't match should fall back to text + key = driver._idempotency_key(child, (MatchRule(re_search="^interface"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("text|logging console",) + + +def test_idempotency_key_prefix_tuple_no_match() -> None: + """Test idempotency key with tuple of prefixes that don't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of prefixes that don't match should fall back to text + key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + child, (MatchRule(startswith=("interface", "router", "vlan")),) + ) + assert key == ("text|logging console",) + + +def test_idempotency_key_prefix_tuple_match() -> None: + """Test idempotency key with tuple of prefixes that match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of prefixes with one matching - should return longest match + key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + child, (MatchRule(startswith=("log", "logging", "logging console")),) + ) + assert key == ("startswith|logging console",) + + +def test_idempotency_key_suffix_tuple_no_match() -> None: + """Test idempotency key with tuple of suffixes that don't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of suffixes that don't match should fall back to text + key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + child, (MatchRule(endswith=("emergency", "alert", "critical")),) + ) + assert key == ("text|logging console",) + + +def test_idempotency_key_suffix_tuple_match() -> None: + """Test idempotency key with tuple of suffixes that match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of suffixes with one matching - should return longest match + key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + child, (MatchRule(endswith=("ole", "sole", "console")),) + ) + assert key == ("endswith|console",) + + +def test_idempotency_key_contains_tuple_no_match() -> None: + """Test idempotency key with tuple of contains that don't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of contains that don't match should fall back to text + key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + child, (MatchRule(contains=("interface", "router", "vlan")),) + ) + assert key == ("text|logging console",) + + +def test_idempotency_key_contains_tuple_match() -> None: + """Test idempotency key with tuple of contains that match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Tuple of contains with matches - should return longest match + key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + child, (MatchRule(contains=("log", "console", "logging console")),) + ) + assert key == ("contains|logging console",) + + +def test_idempotency_key_regex_with_groups() -> None: + """Test idempotency key with regex capture groups.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """router bgp 1 + neighbor 10.1.1.1 description peer1 +""" + config = HConfig.from_text(driver, config_raw) + bgp_child = next(iter(config.children)) + neighbor_child = next(iter(bgp_child.children)) + + # Regex with capture groups should use groups + key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + neighbor_child, + ( + MatchRule(startswith="router bgp"), + MatchRule(re_search=r"neighbor (\S+) description"), + ), + ) + assert key == ("startswith|router bgp", "re|10.1.1.1") + + +def test_idempotency_key_regex_with_empty_groups() -> None: + """Test idempotency key with regex that has empty capture groups.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Regex with empty/None groups should fall back to match result + key = driver._idempotency_key( # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + child, (MatchRule(re_search=r"logging ()?(console)"),) + ) + # Group 1 is empty, group 2 has "console", so should use groups + assert "re|" in key[0] + + +def test_idempotency_key_regex_greedy_pattern() -> None: + """Test idempotency key with greedy regex pattern (.* or .+).""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console emergency +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Regex with .* should be trimmed + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("re|logging console",) + + +def test_idempotency_key_regex_greedy_pattern_with_dollar() -> None: + """Test idempotency key with greedy regex pattern with $ anchor.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console emergency +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Regex with .*$ should be trimmed + key = driver._idempotency_key(child, (MatchRule(re_search=r"logging console.*$"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("re|logging console",) + + +def test_idempotency_key_regex_only_greedy() -> None: + """Test idempotency key with regex that is only greedy pattern.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Regex that is only .* should not trim to empty + key = driver._idempotency_key(child, (MatchRule(re_search=r".*"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + # Should use the full match result + assert key == ("re|logging console",) + + +def test_idempotency_key_lineage_mismatch() -> None: + """Test idempotency key when lineage length doesn't match rules length.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """interface GigabitEthernet1/1 + description test +""" + config = HConfig.from_text(driver, config_raw) + interface_child = next(iter(config.children)) + desc_child = next(iter(interface_child.children)) + + # Try to match with wrong number of rules (desc has 2 lineage levels, only 1 rule) + key = driver._idempotency_key(desc_child, (MatchRule(startswith="description"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + # Should return empty tuple when lineage length != match_rules length + assert not key + + +def test_idempotency_key_negated_command() -> None: + """Test idempotency key with negated command.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """no logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Negated command should strip 'no ' prefix for matching + key = driver._idempotency_key(child, (MatchRule(startswith="logging"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("startswith|logging",) + + +def test_idempotency_key_regex_fallback_to_original() -> None: + """Test idempotency key regex matching fallback to original text.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """no logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Regex that matches original but not normalized (tests lines 328-329) + key = driver._idempotency_key(child, (MatchRule(re_search=r"^no logging"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert "re|no logging" in key[0] + + +def test_idempotency_key_suffix_single_match() -> None: + """Test idempotency key with single suffix that matches (not tuple).""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Single suffix that matches (tests line 359) + key = driver._idempotency_key(child, (MatchRule(endswith="console"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("endswith|console",) + + +def test_idempotency_key_contains_single_match() -> None: + """Test idempotency key with single contains that matches (not tuple).""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console emergency +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Single contains that matches (tests line 372) + key = driver._idempotency_key(child, (MatchRule(contains="console"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + assert key == ("contains|console",) + + +def test_idempotency_key_regex_greedy_with_plus() -> None: + """Test idempotency key with greedy regex using .+ suffix.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """interface GigabitEthernet1 +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Regex with .+ should be trimmed similar to .* + # Tests the .+ branch in line 389 + key = driver._idempotency_key(child, (MatchRule(re_search=r"interface .+"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + # Should trim to just "interface " and use that + assert key == ("re|interface",) + + +def test_idempotency_key_regex_trimmed_to_no_match() -> None: + """Test idempotency key when trimmed regex doesn't match.""" + driver = HConfigDriverCiscoIOS() + + config_raw = """logging console +""" + config = HConfig.from_text(driver, config_raw) + child = next(iter(config.children)) + + # Regex "interface.*" matches nothing, but after trimming .* we get "interface" + # which also doesn't match "logging console", so we fall back to full match result + # This should hit the break at line 399 because trimmed_match is None + key = driver._idempotency_key(child, (MatchRule(re_search=r"interface.*"),)) # ruff:ignore[private-member-access] # pyright: ignore[reportPrivateUsage] + # Since "interface.*" doesn't match "logging console", should fall back to text + assert key == ("text|logging console",) + + +def test_child_hash_eq_consistency_new_in_config() -> None: + """Test that equal HConfigChild objects have equal hashes regardless of new_in_config. + + Validates the bug in issue #185: __hash__ includes new_in_config but __eq__ does not, + violating the Python invariant that a == b implies hash(a) == hash(b). + """ + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = HConfig.from_text(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") + + child1.new_in_config = False + child2.new_in_config = True + + # These two children compare as equal (same text, no tags, no children) + assert child1 == child2 + # Python invariant: equal objects must have equal hashes + assert hash(child1) == hash(child2) + + +def test_child_hash_eq_consistency_order_weight() -> None: + """Test that equal HConfigChild objects have equal hashes regardless of order_weight. + + Validates the bug in issue #185: __hash__ includes order_weight but __eq__ does not, + violating the Python invariant that a == b implies hash(a) == hash(b). + """ + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = HConfig.from_text(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") + + child1.order_weight = 0 + child2.order_weight = 100 + + # These two children compare as equal (same text, no tags, no children) + assert child1 == child2 + # Python invariant: equal objects must have equal hashes + assert hash(child1) == hash(child2) + + +def test_child_hash_eq_consistency_tags() -> None: + """Test that __hash__ and __eq__ agree on whether tags affect equality. + + Validates the bug in issue #185: __eq__ checks tags but __hash__ does not include + tags, meaning two objects that compare unequal could have the same hash (not a + correctness violation, but inconsistent) while also raising the question of whether + tags should be part of the hash. + """ + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = HConfig.from_text(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") + + child1.tags = frozenset({"safe"}) + child2.tags = frozenset() + + # __eq__ considers tags, so these are unequal + assert child1 != child2 + # Since they are unequal, their hashes should differ to avoid excessive collisions + # (not strictly required by the invariant, but required for correctness in reverse: + # if hash(a) != hash(b) then a != b must hold — currently tags are in __eq__ but + # not __hash__, so unequal objects can share a hash, which means dict/set lookup + # will fall back to __eq__ unexpectedly) + assert hash(child1) != hash(child2) + + +def test_child_set_deduplication_with_new_in_config() -> None: + """Test that equal HConfigChild objects are deduplicated correctly in sets. + + Validates the practical impact of issue #185: when new_in_config differs, + two logically equal children occupy different set buckets, causing duplicates. + """ + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = HConfig.from_text(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") + + child1.new_in_config = False + child2.new_in_config = True + + assert child1 == child2 + # Equal objects must collapse to one entry in a set + assert len({child1, child2}) == 1 + + +def test_child_dict_key_lookup_with_order_weight() -> None: + """Test that HConfigChild objects with differing order_weight work as dict keys. + + Validates the practical impact of issue #185: when order_weight differs, a + logically equal child cannot be found as a dict key. + """ + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child1 = config.add_child("interface GigabitEthernet0/0") + config2 = HConfig.from_text(platform) + child2 = config2.add_child("interface GigabitEthernet0/0") + + child1.order_weight = 0 + child2.order_weight = 100 + + assert child1 == child2 + lookup: dict[HConfigChild, str] = {child1: "found"} + # child2 is equal to child1, so it must find the same dict entry + assert lookup[child2] == "found" + + +def test_indented_text_style_literal_values() -> None: + """Test that indented_text accepts each valid TextStyle literal value.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + child = config.add_child("interface GigabitEthernet0/0") + child.comments.add("a comment") + + # without_comments: should NOT include the comment + result_without = child.indented_text(style="without_comments") + assert "interface GigabitEthernet0/0" in result_without + assert "!" not in result_without + + # with_comments: should include the comment + result_with = child.indented_text(style="with_comments") + assert "interface GigabitEthernet0/0" in result_with + assert "!a comment" in result_with + + # merged: should include instance count info + instance = Instance( + id=1, comments=frozenset(["inst comment"]), tags=frozenset(["tag1"]) + ) + child.instances.append(instance) + result_merged = child.indented_text(style="merged") + assert "1 instance" in result_merged diff --git a/tests/unit/test_children.py b/tests/unit/test_children.py new file mode 100644 index 00000000..f021f1d7 --- /dev/null +++ b/tests/unit/test_children.py @@ -0,0 +1,230 @@ +"""Unit tests for HConfigChildren.""" + +from hier_config import HConfig +from hier_config.models import Platform + + +def test_rebuild_children_dict(platform_a: Platform) -> None: + hier1 = HConfig.from_text(platform_a) + interface = hier1.add_child("interface Vlan2") + interface.add_children( + ("description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"), + ) + delta_a = hier1 + hier1.children.rebuild_mapping() + delta_b = hier1 + + assert tuple(delta_a.all_children()) == tuple(delta_b.all_children()) + + +def test_hconfig_children_setitem() -> None: + """Test HConfigChildren __setitem__.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + config.add_child("interface GigabitEthernet0/0") + child2_text = "interface GigabitEthernet0/1" + config.add_child(child2_text) + child3_text = "interface GigabitEthernet0/2" + child3 = config.instantiate_child(child3_text) + config.children[1] = child3 + + assert config.children[1].text == child3_text + assert child3_text in config.children + + +def test_hconfig_children_contains() -> None: + """Test HConfigChildren __contains__.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + config.add_child("interface GigabitEthernet0/0") + + assert "interface GigabitEthernet0/0" in config.children + assert "interface GigabitEthernet0/1" not in config.children + + +def test_hconfig_children_eq_fast_fail() -> None: + """Test HConfigChildren __eq__ fast fail.""" + platform = Platform.CISCO_IOS + config1 = HConfig.from_text(platform) + config2 = HConfig.from_text(platform) + + config1.add_child("interface GigabitEthernet0/0") + config2.add_child("interface GigabitEthernet0/0") + config2.add_child("interface GigabitEthernet0/1") + + assert config1.children != config2.children + + +def test_hconfig_children_eq_keys_mismatch() -> None: + """Test HConfigChildren __eq__ key mismatch.""" + platform = Platform.CISCO_IOS + config1 = HConfig.from_text(platform) + config2 = HConfig.from_text(platform) + + config1.add_child("interface GigabitEthernet0/0") + config2.add_child("interface GigabitEthernet0/1") + + assert config1.children != config2.children + + +def test_hconfig_children_hash() -> None: + """Test HConfigChildren __hash__.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + config.add_child("interface GigabitEthernet0/0") + hash_val = hash(config.children) + + assert isinstance(hash_val, int) + + +def test_hconfig_children_getitem_slice() -> None: + """Test HConfigChildren __getitem__ with slice.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + config.add_child("interface GigabitEthernet0/0") + config.add_child("interface GigabitEthernet0/1") + config.add_child("interface GigabitEthernet0/2") + slice_result = config.children[0:2] + + assert isinstance(slice_result, list) + assert len(slice_result) == 2 + assert slice_result[0].text == "interface GigabitEthernet0/0" + + +def test_children_eq_with_non_children_type() -> None: + """Test HConfigChildren.__eq__ with non-HConfigChildren object returns NotImplemented.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + + # Directly call __eq__ to verify it returns NotImplemented for non-HConfigChildren types + # We must use __eq__ directly here to test the NotImplemented return value + result = interface.children.__eq__("not a children object") # pylint: disable=unnecessary-dunder-call # ruff:ignore[unnecessary-dunder-call] + assert result is NotImplemented + + # This allows Python to try the reverse comparison, which results in False + assert interface.children != "not a children object" + + +def test_children_clear() -> None: + """Test HConfigChildren.clear() method.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + interface.add_child("ip address 192.0.2.1 255.255.255.0") + + # Verify children exist + assert len(interface.children) == 2 + assert "description test" in interface.children + + # Clear all children + interface.children.clear() + + # Verify children are gone + assert len(interface.children) == 0 + assert "description test" not in interface.children + + +def test_children_delete_by_child_object() -> None: + """Test HConfigChildren.delete() with HConfigChild object.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + desc = interface.add_child("description test") + ip_addr = interface.add_child("ip address 192.0.2.1 255.255.255.0") + + # Verify both children exist + assert len(interface.children) == 2 + + # Delete by child object + interface.children.delete(desc) + + # Verify only one child remains + assert len(interface.children) == 1 + assert interface.children[0] is ip_addr + assert "description test" not in interface.children + + +def test_children_delete_by_child_object_not_present() -> None: + """Test HConfigChildren.delete() with HConfigChild object that's not in the collection.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + + # Create a child that's not part of this interface + other_interface = config.add_child("interface GigabitEthernet0/1") + other_child = other_interface.add_child("description other") + + # Verify interface has 1 child + assert len(interface.children) == 1 + + # Try to delete a child that's not in the collection + interface.children.delete(other_child) + + # Verify child count hasn't changed + assert len(interface.children) == 1 + + +def test_children_extend() -> None: + """Test HConfigChildren.extend() method.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface1 = config.add_child("interface GigabitEthernet0/0") + interface2 = config.add_child("interface GigabitEthernet0/1") + + # Add children to interface2 + desc = interface2.add_child("description test") + ip_addr = interface2.add_child("ip address 192.0.2.1 255.255.255.0") + + # Verify interface1 has no children + assert len(interface1.children) == 0 + + # Extend interface1's children with interface2's children + interface1.children.extend([desc, ip_addr]) + + # Verify interface1 now has 2 children + assert len(interface1.children) == 2 + assert "description test" in interface1.children + assert "ip address 192.0.2.1 255.255.255.0" in interface1.children + + +def test_children_eq_empty_fast_success() -> None: + """Test HConfigChildren __eq__ fast success for empty.""" + platform = Platform.CISCO_IOS + config1 = HConfig.from_text(platform) + config2 = HConfig.from_text(platform) + + assert config1.children == config2.children + + +def test_children_hash_with_data() -> None: + """Test HConfigChildren __hash__ with data.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + config.add_child("interface GigabitEthernet0/0") + config.add_child("interface GigabitEthernet0/1") + hash1 = hash(config.children) + hash2 = hash(config.children) + + assert hash1 == hash2 + assert isinstance(hash1, int) + + +def test_children_getitem_with_slice() -> None: + """Test HConfigChildren __getitem__ with slice.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + config.add_child("interface GigabitEthernet0/0") + config.add_child("interface GigabitEthernet0/1") + config.add_child("interface GigabitEthernet0/2") + config.add_child("interface GigabitEthernet0/3") + slice1 = config.children[1:3] + assert len(slice1) == 2 + + slice2 = config.children[::2] + assert len(slice2) == 2 + + slice3 = config.children[:2] + assert len(slice3) == 2 diff --git a/tests/test_constructors.py b/tests/unit/test_constructors.py similarity index 70% rename from tests/test_constructors.py rename to tests/unit/test_constructors.py index 6b1b1d6c..df407428 100644 --- a/tests/test_constructors.py +++ b/tests/unit/test_constructors.py @@ -6,38 +6,87 @@ import pytest from hier_config import ( - get_hconfig, get_hconfig_driver, - get_hconfig_fast_load, - get_hconfig_from_dump, get_hconfig_view, ) from hier_config.constructors import ( _adjust_indent, # pyright: ignore[reportPrivateUsage] _config_from_string_lines_end_of_banner_test, # pyright: ignore[reportPrivateUsage] _load_from_string_lines, # pyright: ignore[reportPrivateUsage] - get_hconfig_fast_generic_load, ) +from hier_config.exceptions import DriverNotFoundError, InvalidConfigError from hier_config.models import Platform +from hier_config.platforms.arista_eos.view import HConfigViewAristaEOS +from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS +from hier_config.platforms.cisco_ios.view import HConfigViewCiscoIOS +from hier_config.platforms.cisco_nxos.view import HConfigViewCiscoNXOS +from hier_config.platforms.cisco_xr.view import HConfigViewCiscoIOSXR +from hier_config.platforms.hp_procurve.view import HConfigViewHPProcurve from hier_config.root import HConfig def test_get_hconfig_driver_unsupported_platform() -> None: - """Test ValueError when platform is not supported (lines 49-50).""" - with pytest.raises(ValueError, match="Unsupported platform: invalid_platform"): - get_hconfig_driver("invalid_platform") # type: ignore[arg-type] + """Test DriverNotFoundError when platform is not supported (lines 49-50).""" + with pytest.raises( + DriverNotFoundError, match="Unsupported platform: invalid_platform" + ): + get_hconfig_driver("invalid_platform") def test_get_hconfig_view_unsupported_platform() -> None: - """Test ValueError when platform is not supported (lines 72-73).""" + """Test DriverNotFoundError when the driver declares no view.""" driver = get_hconfig_driver(Platform.FORTINET_FORTIOS) config = HConfig(driver=driver) with pytest.raises( - ValueError, match="Unsupported platform: HConfigDriverFortinetFortiOS" + DriverNotFoundError, + match="No view registered for driver: HConfigDriverFortinetFortiOS", ): get_hconfig_view(config) +def test_get_hconfig_view_dispatches_on_driver_view_class() -> None: + """Each built-in driver with a view resolves it via view_class (#187).""" + for platform, view_cls in ( + (Platform.ARISTA_EOS, HConfigViewAristaEOS), + (Platform.CISCO_IOS, HConfigViewCiscoIOS), + (Platform.CISCO_NXOS, HConfigViewCiscoNXOS), + (Platform.CISCO_XR, HConfigViewCiscoIOSXR), + (Platform.HP_PROCURVE, HConfigViewHPProcurve), + ): + config = HConfig(driver=get_hconfig_driver(platform)) + view = get_hconfig_view(config) + assert isinstance(view, view_cls) + + +def test_get_hconfig_view_inherited_by_driver_subclass() -> None: + """A driver subclass inherits its parent's view_class (#187).""" + + class ExtendedIOSDriver(HConfigDriverCiscoIOS): + """Driver subclass without its own view_class.""" + + config = HConfig(driver=ExtendedIOSDriver()) + view = get_hconfig_view(config) + + assert isinstance(view, HConfigViewCiscoIOS) + + +def test_get_hconfig_view_custom_driver_view_class() -> None: + """A user-defined driver can supply its own view via view_class (#187, #229).""" + + class CustomView(HConfigViewCiscoIOS): + """User-defined view.""" + + class CustomDriver(HConfigDriverCiscoIOS): + """User-defined driver registering its own view.""" + + view_class = CustomView + + config = HConfig(driver=CustomDriver()) + view = get_hconfig_view(config) + + assert isinstance(view, CustomView) + + def test_get_hconfig_from_path() -> None: """Test loading HConfig from a Path object (line 85).""" config_content = "hostname test\ninterface GigabitEthernet0/0\n description test" @@ -49,7 +98,7 @@ def test_get_hconfig_from_path() -> None: path = Path(tmpfile.name) driver = get_hconfig_driver(Platform.CISCO_IOS) - result = get_hconfig(driver, path) + result = HConfig.from_text(driver, path) hostname_child = result.get_child(startswith="hostname") try: assert hostname_child is not None @@ -67,11 +116,11 @@ def test_get_hconfig_from_dump_with_depth_calculation() -> None: no shutdown """ driver = get_hconfig_driver(Platform.CISCO_IOS) - hconfig = get_hconfig(driver, config) + hconfig = HConfig.from_text(driver, config) dump = hconfig.dump() - restored = get_hconfig_from_dump(driver, dump) + restored = HConfig.from_dump(driver, dump) hostname_child = restored.get_child(startswith="hostname") assert hostname_child is not None @@ -83,9 +132,9 @@ def test_get_hconfig_from_dump_with_depth_calculation() -> None: assert interface is not None assert len(interface.children) > 0 - assert interface.depth() == 1 + assert interface.depth == 1 for subchild in interface.children: - assert subchild.depth() == 2 + assert subchild.depth == 2 def test_get_hconfig_fast_generic_load_with_string_conversion() -> None: @@ -95,7 +144,7 @@ def test_get_hconfig_fast_generic_load_with_string_conversion() -> None: "interface GigabitEthernet0/0", " description test", ] - result = get_hconfig_fast_generic_load(config_lines) + result = HConfig.from_lines(Platform.GENERIC, config_lines) hostname_child = result.get_child(startswith="hostname") assert hostname_child is not None assert len(result.children) > 0 @@ -109,7 +158,7 @@ def test_get_hconfig_fast_load_with_string_conversion() -> None: " description test", ] driver = get_hconfig_driver(Platform.CISCO_IOS) - result = get_hconfig_fast_load(driver, config_lines) + result = HConfig.from_lines(driver, config_lines) hostname_child = result.get_child(startswith="hostname") assert hostname_child is not None assert len(result.children) > 0 @@ -237,7 +286,9 @@ def test_load_from_string_lines_with_incomplete_banner() -> None: This is line 1 This is line 2 """ - with pytest.raises(ValueError, match="we are still in a banner for some reason"): + with pytest.raises( + InvalidConfigError, match="we are still in a banner for some reason" + ): _load_from_string_lines(config, config_text) @@ -286,10 +337,10 @@ def test_get_hconfig_from_dump_parent_depth_traversal() -> None: neighbor 10.0.0.2 activate """ driver = get_hconfig_driver(Platform.CISCO_IOS) - hconfig = get_hconfig(driver, config) + hconfig = HConfig.from_text(driver, config) dump = hconfig.dump() - restored = get_hconfig_from_dump(driver, dump) + restored = HConfig.from_dump(driver, dump) router_bgp = None for child in restored.children: @@ -298,7 +349,7 @@ def test_get_hconfig_from_dump_parent_depth_traversal() -> None: break assert router_bgp is not None - assert router_bgp.depth() == 1 + assert router_bgp.depth == 1 if router_bgp.children: address_family = None @@ -308,10 +359,10 @@ def test_get_hconfig_from_dump_parent_depth_traversal() -> None: break if address_family: - assert address_family.depth() == 2 + assert address_family.depth == 2 if address_family.children: for nested_child in address_family.children: - assert nested_child.depth() == 3 + assert nested_child.depth == 3 def test_banner_detection_with_various_delimiters() -> None: @@ -377,11 +428,11 @@ def test_get_hconfig_from_dump_with_complex_nesting() -> None: description another """ driver = get_hconfig_driver(Platform.CISCO_IOS) - hconfig = get_hconfig(driver, config) + hconfig = HConfig.from_text(driver, config) dump = hconfig.dump() - restored = get_hconfig_from_dump(driver, dump) + restored = HConfig.from_dump(driver, dump) assert len(restored.children) > 0 @@ -397,3 +448,31 @@ def test_get_hconfig_from_dump_with_complex_nesting() -> None: for af_child in router_bgp.children: if af_child.text.startswith("address-family"): assert len(af_child.children) >= 1 + + +def test_xml_config_raises_invalid_config_error() -> None: + """XML input is detected and rejected with a clear message (#232).""" + xml_text = 'r1' + with pytest.raises(InvalidConfigError, match="appears to be XML"): + HConfig.from_text(Platform.CISCO_IOS, xml_text) + + +def test_json_config_raises_invalid_config_error() -> None: + """JSON input is detected and rejected with a clear message (#232).""" + json_text = '{"system": {"config": {"hostname": "r1"}}}' + with pytest.raises(InvalidConfigError, match="appears to be JSON"): + HConfig.from_text(Platform.CISCO_IOS, json_text) + + +def test_curly_brace_junos_config_still_parses() -> None: + """Junos hierarchical (curly-brace) config is not misdetected as JSON (#232).""" + junos_text = "system {\n host-name r1;\n}\n" + config = HConfig.from_text(Platform.JUNIPER_JUNOS, junos_text) + assert config.get_child(equals="set system host-name r1") is not None + + +def test_json_via_from_lines_str_raises() -> None: + """The str form of from_lines gets the same format guard as from_text (#232).""" + json_text = '{"system": {"config": {"hostname": "r1"}}}' + with pytest.raises(InvalidConfigError, match="appears to be JSON"): + HConfig.from_lines(Platform.CISCO_IOS, json_text) diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 00000000..71e0ded2 --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,52 @@ +"""Tests for the custom exception hierarchy (#219).""" + +import pytest + +from hier_config import HConfig, Platform, WorkflowRemediation, get_hconfig_driver +from hier_config.exceptions import ( + DriverNotFoundError, + DuplicateChildError, + HierConfigError, + IncompatibleDriverError, + InvalidConfigError, +) + + +def test_hier_config_error_is_base_exception() -> None: + """All custom exceptions inherit from HierConfigError.""" + assert issubclass(DuplicateChildError, HierConfigError) + assert issubclass(DriverNotFoundError, HierConfigError) + assert issubclass(InvalidConfigError, HierConfigError) + assert issubclass(IncompatibleDriverError, HierConfigError) + + +def test_hier_config_error_is_catchable_as_exception() -> None: + """HierConfigError itself inherits from Exception.""" + assert issubclass(HierConfigError, Exception) + + +def test_duplicate_child_error_on_duplicate_section() -> None: + config = HConfig.from_text(Platform.CISCO_IOS, "interface Loopback0") + with pytest.raises(DuplicateChildError, match="Found a duplicate section"): + config.add_child("interface Loopback0") + + +def test_driver_not_found_error_invalid_platform() -> None: + """get_hconfig_driver raises DriverNotFoundError for unsupported platforms.""" + with pytest.raises(DriverNotFoundError, match="Unsupported platform"): + get_hconfig_driver("bogus_platform") + + +def test_incompatible_driver_error_mismatched_drivers() -> None: + """WorkflowRemediation raises IncompatibleDriverError for mismatched drivers.""" + running = HConfig.from_text(Platform.CISCO_IOS) + generated = HConfig.from_text(Platform.ARISTA_EOS) + with pytest.raises(IncompatibleDriverError, match="same driver"): + WorkflowRemediation(running, generated) + + +def test_invalid_config_error_banner_parsing() -> None: + """Malformed banner config raises InvalidConfigError.""" + config_text = "banner motd ^C\nthis banner never ends" + with pytest.raises(InvalidConfigError, match="banner"): + HConfig.from_text(Platform.CISCO_IOS, config_text) diff --git a/tests/unit/test_formats.py b/tests/unit/test_formats.py new file mode 100644 index 00000000..06dc9e9a --- /dev/null +++ b/tests/unit/test_formats.py @@ -0,0 +1,514 @@ +"""Tests for hier_config/formats.py — JSON/XML ingestion and rendering (#232).""" + +import json +import xml.etree.ElementTree as ET # ruff:ignore[suspicious-xml-etree-import] + +import pytest + +from hier_config import HConfig, Platform, WorkflowRemediation +from hier_config.exceptions import DuplicateChildError, InvalidConfigError +from hier_config.formats import hconfig_to_gnmi_json, hconfig_to_netconf_xml + +OPENCONFIG_STYLE = { + "system": { + "config": { + "hostname": "router1", + "login-banner": "unauthorized access is prohibited", + }, + "ntp": {"enabled": True, "port": 123}, + }, + "interfaces": { + "interface": [ + { + "name": "eth0", + "config": {"description": "uplink", "mtu": 9000}, + }, + { + "name": "eth1", + "config": {"description": "downlink", "mtu": 1500}, + }, + ], + }, + "dns-servers": ["192.0.2.1", "192.0.2.2"], +} + + +def test_from_json_builds_expected_tree() -> None: + config = HConfig.from_json(Platform.GENERIC, OPENCONFIG_STYLE) + + system = config.get_child(equals="system") + assert system is not None + system_config = system.get_child(equals="config") + assert system_config is not None + assert system_config.get_child(equals='hostname "router1"') is not None + + ntp = system.get_child(equals="ntp") + assert ntp is not None + assert ntp.get_child(equals="enabled true") is not None + assert ntp.get_child(equals="port 123") is not None + + interfaces = config.get_child(equals="interfaces") + assert interfaces is not None + eth0 = interfaces.get_child(equals='interface "eth0"') + assert eth0 is not None + assert eth0.get_child(equals='name "eth0"') is not None + + assert config.get_child(equals='dns-servers "192.0.2.1"') is not None + assert config.get_child(equals='dns-servers "192.0.2.2"') is not None + + +def test_from_json_accepts_json_text() -> None: + config = HConfig.from_json(Platform.GENERIC, json.dumps(OPENCONFIG_STYLE)) + assert config.get_child(equals="system") is not None + + +def test_json_round_trip() -> None: + config = HConfig.from_json(Platform.GENERIC, OPENCONFIG_STYLE) + assert json.loads(config.to_json()) == OPENCONFIG_STYLE + + +def test_json_round_trip_via_reparse() -> None: + config = HConfig.from_json(Platform.GENERIC, OPENCONFIG_STYLE) + reparsed = HConfig.from_json(Platform.GENERIC, config.to_json()) + assert reparsed == config + + +def test_from_json_invalid_text_raises() -> None: + with pytest.raises(InvalidConfigError, match="not valid JSON"): + HConfig.from_json(Platform.GENERIC, "{not json") + + +def test_from_json_non_object_root_raises() -> None: + with pytest.raises(InvalidConfigError, match="must be an object"): + HConfig.from_json(Platform.GENERIC, "[1, 2, 3]") + + +def test_from_json_whitespace_key_raises() -> None: + with pytest.raises(InvalidConfigError, match="Unsupported JSON key"): + HConfig.from_json(Platform.GENERIC, {"bad key": 1}) + + +def test_from_json_unidentified_list_entry_raises() -> None: + with pytest.raises(InvalidConfigError, match="identify"): + HConfig.from_json(Platform.GENERIC, {"vlans": [{"vid": 100}]}) + + +def test_from_json_custom_list_keys() -> None: + config = HConfig.from_json( + Platform.GENERIC, {"vlans": [{"vid": 100}]}, list_keys=("vid",) + ) + assert config.get_child(equals="vlans 100") is not None + + +def test_json_diff_between_structured_configs() -> None: + """Structured configs work with the tree diff engine.""" + running = HConfig.from_json( + Platform.GENERIC, {"system": {"hostname": "old", "domain": "example.com"}} + ) + generated = HConfig.from_json( + Platform.GENERIC, {"system": {"hostname": "new", "domain": "example.com"}} + ) + remediation = running.remediation(generated) + system = remediation.get_child(equals="system") + assert system is not None + assert system.get_child(equals='no hostname "old"') is not None + assert system.get_child(equals='hostname "new"') is not None + + +def test_json_future_renders_back_to_json() -> None: + """future() of a structured config renders back to valid JSON.""" + running = HConfig.from_json(Platform.GENERIC, {"system": {"hostname": "old"}}) + generated = HConfig.from_json(Platform.GENERIC, {"system": {"hostname": "new"}}) + future = running.future(running.remediation(generated)) + assert json.loads(future.to_json()) == {"system": {"hostname": "new"}} + + +XML_TEXT = ( + "" + '' + "router1" + "" + "" + "" + "eth09000" + "eth11500" + "" + "" +) + + +def test_from_xml_builds_expected_tree() -> None: + config = HConfig.from_xml(Platform.GENERIC, XML_TEXT) + + root = config.get_child(equals="config") + assert root is not None + system = root.get_child(equals="system") + assert system is not None + assert system.get_child(equals='@foo "bar"') is not None + assert system.get_child(equals='hostname "router1"') is not None + assert system.get_child(equals="location") is not None + + interfaces = root.get_child(equals="interfaces") + assert interfaces is not None + eth0 = interfaces.get_child(equals='interface "eth0"') + assert eth0 is not None + assert eth0.get_child(equals='mtu "9000"') is not None + + +def test_xml_round_trip() -> None: + config = HConfig.from_xml(Platform.GENERIC, XML_TEXT) + reparsed = HConfig.from_xml(Platform.GENERIC, config.to_xml()) + assert reparsed == config + + +def test_from_xml_invalid_raises() -> None: + with pytest.raises(InvalidConfigError, match="not valid XML"): + HConfig.from_xml(Platform.GENERIC, "") + + +def test_from_xml_repeated_elements_without_identity_raises() -> None: + xml_text = "12" + with pytest.raises(InvalidConfigError, match="identify"): + HConfig.from_xml(Platform.GENERIC, xml_text) + + +def test_to_xml_requires_single_root() -> None: + config = HConfig.from_json(Platform.GENERIC, {"a": {"x": 1}, "b": {"y": 2}}) + with pytest.raises(InvalidConfigError, match="single root"): + config.to_xml() + + +def test_xml_mixed_text_content() -> None: + xml_text = "textinner" + config = HConfig.from_xml(Platform.GENERIC, xml_text) + outer = config.get_child(equals="c") + assert outer is not None + element_a = outer.get_child(equals="a") + assert element_a is not None + assert element_a.get_child(equals='#text "text"') is not None + assert element_a.get_child(equals='b "inner"') is not None + reparsed = HConfig.from_xml(Platform.GENERIC, config.to_xml()) + assert reparsed == config + + +def test_detection_error_mentions_structured_constructors() -> None: + with pytest.raises(InvalidConfigError, match="from_json"): + HConfig.from_text(Platform.GENERIC, '{"a": 1}') + with pytest.raises(InvalidConfigError, match="from_xml"): + HConfig.from_text(Platform.GENERIC, "") + + +def test_empty_object_round_trips() -> None: + """An empty JSON object must not collapse to null (#279 review).""" + data: dict[str, object] = {"system": {}, "count": 1, "missing": None} + config = HConfig.from_json(Platform.GENERIC, data) + assert json.loads(config.to_json()) == data + + +def test_duplicate_list_items_raise_hier_config_error() -> None: + """Duplicate scalar items and duplicate identities surface as tree errors.""" + with pytest.raises(DuplicateChildError): + HConfig.from_json(Platform.GENERIC, {"dns": ["192.0.2.1", "192.0.2.1"]}) + with pytest.raises(DuplicateChildError): + HConfig.from_json( + Platform.GENERIC, {"interface": [{"name": "e0"}, {"name": "e0"}]} + ) + + +NC_OPERATION = "{urn:ietf:params:xml:ns:netconf:base:1.0}operation" + +NETCONF_RUNNING_XML = ( + "" + "oldhq" + "" + "eth09000" + "eth11500" + "" + "" +) +NETCONF_GENERATED_XML = ( + "" + "newhq" + "" + "eth09000" + "" + "" +) + + +def test_netconf_remediation_payload() -> None: + """Remediation between from_xml trees renders as a NETCONF edit-config payload.""" + running = HConfig.from_xml(Platform.GENERIC, NETCONF_RUNNING_XML) + generated = HConfig.from_xml(Platform.GENERIC, NETCONF_GENERATED_XML) + workflow = WorkflowRemediation(running, generated) + payload = workflow.remediation_netconf_xml() + + assert 'xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0"' in payload + root = ET.fromstring(payload) # ruff:ignore[suspicious-xml-element-tree-usage] + assert root.tag == "config" + + system = root.find("system") + assert system is not None + hostnames = system.findall("hostname") + deleted = [e for e in hostnames if e.get(NC_OPERATION) == "delete"] + added = [e for e in hostnames if e.get(NC_OPERATION) is None] + assert len(deleted) == 1 + assert added[0].text == "new" + + # The removed keyed list entry deletes by key leaf, not by value text. + interfaces = root.find("interfaces") + assert interfaces is not None + removed = interfaces.find("interface") + assert removed is not None + assert removed.get(NC_OPERATION) == "delete" + name = removed.find("name") + assert name is not None + assert name.text == "eth1" + assert removed.find("mtu") is None + + +def test_netconf_addition_renders_plain_subtree() -> None: + """Added sections carry no operation attribute (NETCONF merge default).""" + running = HConfig.from_xml(Platform.GENERIC, "") + generated = HConfig.from_xml( + Platform.GENERIC, + "true", + ) + workflow = WorkflowRemediation(running, generated) + root = ET.fromstring(workflow.remediation_netconf_xml()) # ruff:ignore[suspicious-xml-element-tree-usage] + ntp = root.find("ntp") + assert ntp is not None + assert ntp.get(NC_OPERATION) is None + enabled = ntp.find("enabled") + assert enabled is not None + assert enabled.text == "true" + + +def test_netconf_attribute_negation_raises() -> None: + """Attribute removals cannot be expressed as NETCONF operations.""" + running = HConfig.from_xml(Platform.GENERIC, '') + generated = HConfig.from_xml(Platform.GENERIC, "") + workflow = WorkflowRemediation(running, generated) + with pytest.raises(InvalidConfigError, match="Attribute"): + workflow.remediation_netconf_xml() + + +def test_netconf_leaf_delete_without_running_context() -> None: + """The standalone function falls back to value-bearing leaf deletes.""" + running = HConfig.from_xml( + Platform.GENERIC, "old" + ) + generated = HConfig.from_xml(Platform.GENERIC, "") + remediation = running.remediation(generated) + root = ET.fromstring(hconfig_to_netconf_xml(remediation)) # ruff:ignore[suspicious-xml-element-tree-usage] + system = root.find("system") + assert system is not None + hostname = system.find("hostname") + assert hostname is not None + assert hostname.get(NC_OPERATION) == "delete" + assert hostname.text == "old" + + +def test_xml_diff_is_surgical_across_entry_counts() -> None: + """A keyed entry keeps the same node text regardless of sibling count. + + Removing one of two entries must not delete-and-re-add the survivor + (#232): identity suffixes apply whenever an identifying child exists, + not only when the tag repeats. + """ + running = HConfig.from_xml(Platform.GENERIC, NETCONF_RUNNING_XML) + generated = HConfig.from_xml(Platform.GENERIC, NETCONF_GENERATED_XML) + remediation = running.remediation(generated) + interfaces_lines = [ + line.strip() for line in remediation.to_lines() if "interface" in line + ] + assert interfaces_lines == ["interfaces", 'no interface "eth1"'] + + +GNMI_RUNNING = { + "system": {"config": {"hostname": "old", "location": "hq"}}, + "interfaces": { + "interface": [ + {"name": "eth0", "config": {"mtu": 9000}}, + {"name": "eth1", "config": {"mtu": 1500}}, + ], + }, +} +GNMI_GENERATED = { + "system": {"config": {"hostname": "new", "location": "hq"}}, + "interfaces": { + "interface": [ + {"name": "eth0", "config": {"mtu": 9000}}, + ], + }, +} + + +def test_gnmi_remediation_payload() -> None: + """Remediation between from_json trees renders as update/delete sets.""" + running = HConfig.from_json(Platform.GENERIC, GNMI_RUNNING) + generated = HConfig.from_json(Platform.GENERIC, GNMI_GENERATED) + workflow = WorkflowRemediation(running, generated) + result = workflow.remediation_json() + + assert result["delete"] == [ + "system/config/hostname", + "interfaces/interface[name=eth1]", + ] + assert result["update"] == {"system": {"config": {"hostname": "new"}}} + + +def test_gnmi_scalar_leaf_delete_prunes_branch() -> None: + """A branch containing only deletions must not appear in the update tree.""" + running = HConfig.from_json(Platform.GENERIC, {"system": {"hostname": "old"}}) + generated = HConfig.from_json(Platform.GENERIC, {"system": {}}) + result = WorkflowRemediation(running, generated).remediation_json() + + assert result == {"update": {}, "delete": ["system/hostname"]} + + +def test_gnmi_keyed_entry_delete_custom_list_keys() -> None: + running = HConfig.from_json( + Platform.GENERIC, + {"vlans": {"vlan": [{"vid": 100}, {"vid": 200}]}}, + list_keys=("vid",), + ) + generated = HConfig.from_json( + Platform.GENERIC, {"vlans": {"vlan": [{"vid": 200}]}}, list_keys=("vid",) + ) + result = hconfig_to_gnmi_json( + running.remediation(generated), running=running, list_keys=("vid",) + ) + + assert result == {"update": {}, "delete": ["vlans/vlan[vid=100]"]} + + +def test_gnmi_nested_delete_under_keyed_ancestor() -> None: + """An ancestor keyed entry gets a selector resolved against the running config.""" + running = HConfig.from_json( + Platform.GENERIC, + { + "interfaces": { + "interface": [ + {"name": "eth0", "config": {"mtu": 9000, "description": "uplink"}}, + ], + }, + }, + ) + generated = HConfig.from_json( + Platform.GENERIC, + { + "interfaces": { + "interface": [ + {"name": "eth0", "config": {"description": "uplink"}}, + ], + }, + }, + ) + result = WorkflowRemediation(running, generated).remediation_json() + + assert result == { + "update": {}, + "delete": ["interfaces/interface[name=eth0]/config/mtu"], + } + + +def test_gnmi_update_reinjects_identity_leaf() -> None: + """A modified keyed entry's update carries its identity leaf and re-ingests.""" + running = HConfig.from_json( + Platform.GENERIC, + {"interfaces": {"interface": [{"name": "eth0", "config": {"mtu": 9000}}]}}, + ) + generated = HConfig.from_json( + Platform.GENERIC, + {"interfaces": {"interface": [{"name": "eth0", "config": {"mtu": 1500}}]}}, + ) + result = WorkflowRemediation(running, generated).remediation_json() + + assert result["update"] == { + "interfaces": {"interface": [{"name": "eth0", "config": {"mtu": 1500}}]}, + } + assert HConfig.from_json(Platform.GENERIC, result["update"]) is not None + + +def test_gnmi_pure_addition_has_empty_delete() -> None: + running = HConfig.from_json(Platform.GENERIC, {"system": {}}) + generated = HConfig.from_json( + Platform.GENERIC, {"system": {}, "ntp": {"enabled": True, "port": 123}} + ) + result = WorkflowRemediation(running, generated).remediation_json() + + assert result == {"update": {"ntp": {"enabled": True, "port": 123}}, "delete": []} + + +def test_gnmi_empty_remediation() -> None: + running = HConfig.from_json(Platform.GENERIC, GNMI_RUNNING) + generated = HConfig.from_json(Platform.GENERIC, GNMI_RUNNING) + result = WorkflowRemediation(running, generated).remediation_json() + + assert result == {"update": {}, "delete": []} + + +def test_gnmi_no_running_context_falls_back_to_scalar() -> None: + """Without a running config, keyed-entry deletes degrade to bare paths.""" + running = HConfig.from_json(Platform.GENERIC, GNMI_RUNNING) + generated = HConfig.from_json(Platform.GENERIC, GNMI_GENERATED) + result = hconfig_to_gnmi_json(running.remediation(generated)) + + assert result["delete"] == ["system/config/hostname", "interfaces/interface"] + assert result["update"] == {"system": {"config": {"hostname": "new"}}} + + +def test_gnmi_unresolved_identity_falls_back_to_default_key() -> None: + """An unresolvable entry key guesses the selector but skips injection. + + A modified keyed entry's remediation subtree lacks its identity leaf, so + without a running config the key name cannot be resolved: the delete-path + selector falls back to the first `list_keys` name, and no identity leaf is + injected into the update (a guessed key would become applied config). + """ + running = HConfig.from_json( + Platform.GENERIC, + {"interfaces": {"interface": [{"name": "eth0", "config": {"mtu": 9000}}]}}, + ) + generated = HConfig.from_json( + Platform.GENERIC, + {"interfaces": {"interface": [{"name": "eth0", "config": {"mtu": 1500}}]}}, + ) + result = hconfig_to_gnmi_json(running.remediation(generated)) + + assert result == { + "update": {"interfaces": {"interface": [{"config": {"mtu": 1500}}]}}, + "delete": ["interfaces/interface[name=eth0]/config/mtu"], + } + + +def test_gnmi_attribute_negation_raises() -> None: + """Attribute removals cannot be expressed as gNMI delete paths.""" + running = HConfig.from_xml(Platform.GENERIC, '') + generated = HConfig.from_xml(Platform.GENERIC, "") + remediation = running.remediation(generated) + with pytest.raises(InvalidConfigError, match="Attribute"): + hconfig_to_gnmi_json(remediation, running=running) + + +def test_gnmi_selector_value_escaping() -> None: + r"""Selector values escape `\` and `]` so paths stay parseable.""" + running = HConfig.from_json( + Platform.GENERIC, + { + "policies": { + "policy": [ + {"name": "a]b\\c", "action": "deny"}, + {"name": "keep", "action": "permit"}, + ], + }, + }, + ) + generated = HConfig.from_json( + Platform.GENERIC, + {"policies": {"policy": [{"name": "keep", "action": "permit"}]}}, + ) + result = WorkflowRemediation(running, generated).remediation_json() + + assert result["delete"] == ["policies/policy[name=a\\]b\\\\c]"] diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 00000000..21661e37 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,44 @@ +"""Tests for hier_config/models.py.""" + +import pytest +from pydantic import ValidationError + +from hier_config.models import MatchRule, NegationRule, NegationStrategy + + +def test_negation_rule_replace_requires_use() -> None: + """A REPLACE-strategy rule without `use` is a misconfiguration (#278 review).""" + with pytest.raises(ValidationError, match="REPLACE strategy requires `use`"): + NegationRule( + match_rules=(MatchRule(startswith="logging console"),), + strategy=NegationStrategy.REPLACE, + ) + + +def test_negation_rule_regex_sub_requires_search() -> None: + """A REGEX_SUB-strategy rule without `search` is a misconfiguration (#278 review).""" + with pytest.raises(ValidationError, match="REGEX_SUB strategy requires `search`"): + NegationRule( + match_rules=(MatchRule(startswith="snmp-server user"),), + strategy=NegationStrategy.REGEX_SUB, + ) + + +def test_negation_rule_regex_sub_allows_empty_replace() -> None: + """REGEX_SUB with an empty `replace` is valid (deletion-style substitution).""" + rule = NegationRule( + match_rules=(MatchRule(startswith="snmp-server user"),), + strategy=NegationStrategy.REGEX_SUB, + search=r"(no snmp-server user \S+).*", + ) + assert not rule.replace + + +def test_negation_rule_default_requires_no_extra_fields() -> None: + """A DEFAULT-strategy rule is valid with only match_rules.""" + rule = NegationRule( + match_rules=(MatchRule(startswith="interface"),), + strategy=NegationStrategy.DEFAULT, + ) + assert not rule.use + assert not rule.search diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py new file mode 100644 index 00000000..592ece98 --- /dev/null +++ b/tests/unit/test_registry.py @@ -0,0 +1,173 @@ +"""Tests for the driver registration system (#226, #229).""" + +import pytest + +from hier_config import ( + HConfig, + HConfigDriverBase, + HConfigDriverRules, + Platform, + get_hconfig_driver, + get_hconfig_view, + get_registered_platforms, + register_driver, + unregister_driver, +) +from hier_config.exceptions import DriverNotFoundError +from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS +from hier_config.platforms.cisco_ios.view import HConfigViewCiscoIOS + + +class _CustomDriver(HConfigDriverBase): + """A user-defined driver for a custom platform.""" + + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules() + + +# The member's value string ("3"): a str-Enum artifact, not a platform name. +_CISCO_IOS_VALUE = str(Platform.CISCO_IOS.value) + + +def _assert_custom_platform_works() -> None: + driver = get_hconfig_driver("MY_NOS") + assert isinstance(driver, _CustomDriver) + + config = HConfig.from_text("MY_NOS", "hostname test\n") + assert config.get_child(equals="hostname test") is not None + + +def test_register_custom_platform() -> None: + """A registered string platform works with driver lookup and constructors.""" + register_driver("MY_NOS", _CustomDriver) + try: + _assert_custom_platform_works() + finally: + unregister_driver("MY_NOS") + + +def _assert_case_insensitive_lookup() -> None: + assert isinstance(get_hconfig_driver("MY_NOS"), _CustomDriver) + assert isinstance(get_hconfig_driver("my_nos"), _CustomDriver) + + +def test_register_custom_platform_is_case_insensitive() -> None: + """Custom platform names are normalized to uppercase.""" + register_driver("my_nos", _CustomDriver) + try: + _assert_case_insensitive_lookup() + finally: + unregister_driver("MY_NOS") + + +def test_override_builtin_driver() -> None: + """Registering an existing Platform overrides the built-in driver.""" + + class CustomIOSDriver(HConfigDriverCiscoIOS): + """Override of the built-in IOS driver.""" + + register_driver(Platform.CISCO_IOS, CustomIOSDriver) + try: + assert isinstance(get_hconfig_driver(Platform.CISCO_IOS), CustomIOSDriver) + finally: + unregister_driver(Platform.CISCO_IOS) + + # Unregistering an overridden built-in restores the default driver. + driver = get_hconfig_driver(Platform.CISCO_IOS) + assert driver.__class__ is HConfigDriverCiscoIOS + + +def _assert_custom_view_resolves() -> None: + config = HConfig.from_text("CUSTOM_IOS", "hostname test\n") + view = get_hconfig_view(config) + assert isinstance(view, HConfigViewCiscoIOS) + + +def test_registered_driver_view_follows_driver() -> None: + """A custom driver's view_class works through get_hconfig_view (#229).""" + + class CustomIOSDriver(HConfigDriverCiscoIOS): + """Override with the inherited IOS view.""" + + register_driver("CUSTOM_IOS", CustomIOSDriver) + try: + _assert_custom_view_resolves() + finally: + unregister_driver("CUSTOM_IOS") + + +def test_get_registered_platforms_contains_builtins() -> None: + """All built-in platforms are registered at import time.""" + registered = get_registered_platforms() + for platform in Platform: + assert platform in registered + + +def test_unknown_platform_raises() -> None: + """Looking up an unregistered platform raises DriverNotFoundError.""" + with pytest.raises(DriverNotFoundError, match="Unsupported platform"): + get_hconfig_driver("NOT_REGISTERED") + + +def test_unregister_unknown_platform_raises() -> None: + """Unregistering a platform that is not registered raises.""" + with pytest.raises(DriverNotFoundError, match="Unsupported platform"): + unregister_driver("NOT_REGISTERED") + + +def test_unregister_builtin_without_override_raises() -> None: + """A built-in platform without an override cannot be unregistered.""" + with pytest.raises(DriverNotFoundError, match="not overridden"): + unregister_driver(Platform.CISCO_XR) + + +def test_register_platform_value_does_not_collide_with_builtin() -> None: + """A Platform member's value string is a distinct custom key (#284).""" + register_driver(_CISCO_IOS_VALUE, _CustomDriver) + try: + driver = get_hconfig_driver(Platform.CISCO_IOS) + finally: + unregister_driver(_CISCO_IOS_VALUE) + assert driver.__class__ is HConfigDriverCiscoIOS + + +def test_platform_value_lookup_raises() -> None: + """Platform member value strings are not platform names (#284).""" + with pytest.raises(DriverNotFoundError, match="Unsupported platform"): + get_hconfig_driver(_CISCO_IOS_VALUE) + + +def test_get_registered_platforms_includes_custom_names() -> None: + """Custom platforms are listed by their canonical uppercase name (#284).""" + register_driver("my_nos", _CustomDriver) + try: + assert [ + platform + for platform in get_registered_platforms() + if not isinstance(platform, Platform) + ] == ["MY_NOS"] + finally: + unregister_driver("my_nos") + + +def test_get_registered_platforms_returns_enum_members_for_builtins() -> None: + """Enum-known names are listed as Platform members, not strings (#284).""" + members = { + platform + for platform in get_registered_platforms() + if isinstance(platform, Platform) + } + assert members == set(Platform) + + +def test_platform_name_string_interchangeable_with_member() -> None: + """A Platform member and its name address the same registry entry (#284).""" + register_driver("cisco_ios", _CustomDriver) + try: + assert isinstance(get_hconfig_driver(Platform.CISCO_IOS), _CustomDriver) + finally: + unregister_driver(Platform.CISCO_IOS) + + driver = get_hconfig_driver(Platform.CISCO_IOS) + assert driver.__class__ is HConfigDriverCiscoIOS diff --git a/tests/test_reporting.py b/tests/unit/test_reporting.py similarity index 98% rename from tests/test_reporting.py rename to tests/unit/test_reporting.py index d5aa4981..e2758317 100644 --- a/tests/test_reporting.py +++ b/tests/unit/test_reporting.py @@ -15,7 +15,6 @@ RemediationReporter, TagRule, WorkflowRemediation, - get_hconfig, ) from hier_config.models import ChangeDetail, ReportSummary @@ -23,7 +22,7 @@ @pytest.fixture def sample_remediation_1() -> tuple[HConfig, str]: """Create a sample remediation configuration for device 1.""" - running = get_hconfig( + running = HConfig.from_text( Platform.CISCO_IOS, """interface Vlan2 ip address 10.0.0.1 255.255.255.0 @@ -32,7 +31,7 @@ def sample_remediation_1() -> tuple[HConfig, str]: ntp server 10.1.1.1""", ) - generated = get_hconfig( + generated = HConfig.from_text( Platform.CISCO_IOS, """interface Vlan2 ip address 10.0.0.2 255.255.255.0 @@ -50,7 +49,7 @@ def sample_remediation_1() -> tuple[HConfig, str]: @pytest.fixture def sample_remediation_2() -> tuple[HConfig, str]: """Create a sample remediation configuration for device 2.""" - running = get_hconfig( + running = HConfig.from_text( Platform.CISCO_IOS, """interface Vlan3 ip address 10.0.1.1 255.255.255.0 @@ -59,7 +58,7 @@ def sample_remediation_2() -> tuple[HConfig, str]: ntp server 10.1.1.1""", ) - generated = get_hconfig( + generated = HConfig.from_text( Platform.CISCO_IOS, """interface Vlan3 ip address 10.0.1.2 255.255.255.0 @@ -77,14 +76,14 @@ def sample_remediation_2() -> tuple[HConfig, str]: @pytest.fixture def sample_remediation_3() -> tuple[HConfig, str]: """Create a sample remediation configuration for device 3.""" - running = get_hconfig( + running = HConfig.from_text( Platform.CISCO_IOS, """interface Vlan4 ip address 10.0.2.1 255.255.255.0 ntp server 10.1.1.1""", ) - generated = get_hconfig( + generated = HConfig.from_text( Platform.CISCO_IOS, """interface Vlan4 ip address 10.0.2.2 255.255.255.0 @@ -153,7 +152,7 @@ def test_from_merged_config( rem2, _ = sample_remediation_2 # Create a merged config manually - merged = get_hconfig(Platform.CISCO_IOS) + merged = HConfig.from_text(Platform.CISCO_IOS) merged.merge([rem1, rem2]) reporter = RemediationReporter.from_merged_config(merged) @@ -686,7 +685,7 @@ def test_empty_reporter() -> None: def test_reporter_with_empty_remediation() -> None: """Test reporter with empty remediation config.""" - empty_config = get_hconfig(Platform.CISCO_IOS) + empty_config = HConfig.from_text(Platform.CISCO_IOS) reporter = RemediationReporter() reporter.add_remediation(empty_config) diff --git a/tests/unit/test_root.py b/tests/unit/test_root.py new file mode 100644 index 00000000..591109fb --- /dev/null +++ b/tests/unit/test_root.py @@ -0,0 +1,225 @@ +"""Tests for HConfig root node behavior.""" + +import tempfile +from pathlib import Path + +import pytest + +from hier_config import HConfig, get_hconfig_driver +from hier_config.exceptions import DuplicateChildError +from hier_config.models import ParentAllowsDuplicateChildRule, Platform + + +def test_bool(platform_a: Platform) -> None: + config = HConfig.from_text(platform_a) + assert config + + +def test_hash(platform_a: Platform) -> None: + config = HConfig.from_lines(platform_a, ("interface 1/1", " untagged vlan 5")) + assert hash(config) + + +def test_merge(platform_a: Platform, platform_b: Platform) -> None: + hier1 = HConfig.from_text(platform_a) + hier1.add_child("interface Vlan2") + hier2 = HConfig.from_text(platform_b) + hier2.add_child("interface Vlan3") + + assert len(tuple(hier1.all_children())) == 1 + assert len(tuple(hier2.all_children())) == 1 + + hier1.merge(hier2) + + assert len(tuple(hier1.all_children())) == 2 + + +def test_load_from_file(platform_a: Platform) -> None: + config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" + + with tempfile.NamedTemporaryFile( + mode="r+", + delete=False, + encoding="utf8", + ) as myfile: + myfile.file.write(config) + myfile.file.flush() + myfile.close() + hier = HConfig.from_text(get_hconfig_driver(platform_a), Path(myfile.name)) + Path(myfile.name).unlink() + + assert len(tuple(hier.all_children())) == 2 + + +def test_load_from_config_text(platform_a: Platform) -> None: + config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" + hier = HConfig.from_text(get_hconfig_driver(platform_a), config) + assert len(tuple(hier.all_children())) == 2 + + +def test_dump_and_load_from_dump_and_compare(platform_a: Platform) -> None: + hier_pre_dump = HConfig.from_text(platform_a) + b2 = hier_pre_dump.add_children_deep(("a1", "b2")) + + b2.order_weight = 400 + b2.add_tags("test") + b2.comments.add("test comment") + b2.new_in_config = True + + dump = hier_pre_dump.dump() + hier_post_dump = HConfig.from_dump(hier_pre_dump.driver, dump) + + assert hier_pre_dump == hier_post_dump + + +def test_unified_diff() -> None: + platform = Platform.CISCO_IOS + + config_a = HConfig.from_text(platform) + config_b = HConfig.from_text(platform) + # deep differences + config_a.add_children_deep(("a", "aa", "aaa", "aaaa")) + config_b.add_children_deep(("a", "aa", "aab", "aaba")) + # these children will be the same and should not appear in the diff + config_a.add_children_deep(("b", "ba", "baa")) + config_b.add_children_deep(("b", "ba", "baa")) + # root level differences + config_a.add_children_deep(("c", "ca")) + config_b.add_child("d") + + diff = tuple(config_a.unified_diff(config_b)) + assert diff == ( + "a", + " aa", + " - aaa", + " - aaaa", + " + aab", + " + aaba", + "- c", + " - ca", + "+ d", + ) + + +def test_hconfig_str() -> None: + """Test HConfig __str__ method.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + config.add_child("hostname router1") + config.add_child("interface GigabitEthernet0/0") + str_output = str(config) + + assert "hostname router1" in str_output + assert "interface GigabitEthernet0/0" in str_output + assert isinstance(str_output, str) + + +def test_hconfig_eq_not_hconfig() -> None: + """Test HConfig __eq__ with non-HConfig object.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + result = config == "not an HConfig" + + assert not result + + +def test_hconfig_real_indent_level() -> None: + """Test HConfig real_indent_level property.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + + assert config.real_indent_level == -1 + + +def test_hconfig_parent_property() -> None: + """Test HConfig parent property returns self.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + + assert config.parent is config + + +def test_hconfig_is_leaf() -> None: + """Test HConfig is_leaf property.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + + assert config.is_leaf is False + + +def test_hconfig_tags_setter() -> None: + """Test HConfig tags setter.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + desc = interface.add_child("description test") + config.tags = frozenset(["production", "core"]) + + assert "production" in desc.tags + assert "core" in desc.tags + + +def test_hconfig_add_children_deep_typeerror() -> None: + """Test HConfig add_children_deep raises TypeError.""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + + with pytest.raises(TypeError, match="base was an HConfig object"): + config.add_children_deep([]) + + +def test_hconfig_deep_copy() -> None: + """Test HConfig deep_copy method).""" + platform = Platform.CISCO_IOS + config = HConfig.from_text(platform) + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + config.add_child("hostname router1") + config_copy = config.deep_copy() + + assert config_copy is not config + assert len(tuple(config_copy.all_children())) == len(tuple(config.all_children())) + assert config_copy.get_child(equals="interface GigabitEthernet0/0") is not None + assert config_copy.get_child(equals="hostname router1") is not None + + original_interface = config.get_child(equals="interface GigabitEthernet0/0") + copied_interface = config_copy.get_child(equals="interface GigabitEthernet0/0") + assert original_interface is not None + assert copied_interface is not None + assert original_interface is not copied_interface + + +def test_len_counts_all_descendants() -> None: + """__len__ counts every descendant node, not just direct children (#188).""" + config = HConfig.from_text(Platform.CISCO_IOS) + assert len(config) == 0 + + interface = config.add_child("interface GigabitEthernet0/0") + interface.add_child("description test") + interface.add_child("ip address 192.0.2.1 255.255.255.0") + config.add_child("hostname router1") + + assert len(config) == 4 + assert len(interface) == 2 + + +def test_root_duplicate_children_allowed_by_rule() -> None: + """A ParentAllowsDuplicateChildRule with empty match_rules applies to the root (#215).""" + driver = get_hconfig_driver(Platform.GENERIC) + driver.rules.parent_allows_duplicate_child.append( + ParentAllowsDuplicateChildRule(match_rules=()) + ) + config = HConfig.from_text(driver) + child1 = config.add_child("ip prefix-list PL seq 10 permit 10.0.0.0/8") + child2 = config.add_child("ip prefix-list PL seq 10 permit 10.0.0.0/8") + + assert child1 is not child2 + assert len(config.children) == 2 + + +def test_root_duplicate_children_denied_by_default() -> None: + """Without a root rule, duplicate root children still raise (#215).""" + config = HConfig.from_text(Platform.CISCO_IOS) + config.add_child("hostname router1") + with pytest.raises(DuplicateChildError): + config.add_child("hostname router1") diff --git a/tests/test_utils.py b/tests/unit/test_utils.py similarity index 69% rename from tests/test_utils.py rename to tests/unit/test_utils.py index df8ed3ed..787692ab 100644 --- a/tests/test_utils.py +++ b/tests/unit/test_utils.py @@ -6,14 +6,12 @@ from pydantic import ValidationError from hier_config import Platform -from hier_config.models import MatchRule, TagRule +from hier_config.models import MatchRule, NegationStrategy, TagRule from hier_config.utils import ( _set_match_rule, # pyright: ignore[reportPrivateUsage] - hconfig_v2_os_v3_platform_mapper, - hconfig_v3_platform_v2_os_mapper, - load_hconfig_v2_options, - load_hconfig_v2_tags, + load_driver_rules, load_hier_config_tags, + load_tag_rules, read_text_from_file, ) @@ -91,36 +89,48 @@ def test_load_hier_config_tags_empty_file(tmp_path: Path) -> None: load_hier_config_tags(str(empty_file)) -def test_hconfig_v2_os_v3_platform_mapper() -> None: - # Valid mappings - assert hconfig_v2_os_v3_platform_mapper("ios") == Platform.CISCO_IOS - assert hconfig_v2_os_v3_platform_mapper("aruba_aoscx") == Platform.ARUBA_AOSCX - # Surrounding whitespace must not defeat the lookup (real network_driver - # mappings have been seen with a trailing space). - assert hconfig_v2_os_v3_platform_mapper("aruba_aoscx ") == Platform.ARUBA_AOSCX - assert hconfig_v2_os_v3_platform_mapper("nxos") == Platform.CISCO_NXOS - assert hconfig_v2_os_v3_platform_mapper("junos") == Platform.JUNIPER_JUNOS - assert hconfig_v2_os_v3_platform_mapper("nokia_srl") == Platform.NOKIA_SRL - assert hconfig_v2_os_v3_platform_mapper("invalid") == Platform.GENERIC - - -def test_hconfig_v3_platform_v2_os_mapper() -> None: - # Valid mappings - assert hconfig_v3_platform_v2_os_mapper(Platform.ARUBA_AOSCX) == "aruba_aoscx" - assert hconfig_v3_platform_v2_os_mapper(Platform.CISCO_IOS) == "ios" - assert hconfig_v3_platform_v2_os_mapper(Platform.CISCO_NXOS) == "nxos" - assert hconfig_v3_platform_v2_os_mapper(Platform.JUNIPER_JUNOS) == "junos" - assert hconfig_v3_platform_v2_os_mapper(Platform.NOKIA_SRL) == "nokia_srl" - assert hconfig_v3_platform_v2_os_mapper(Platform.GENERIC) == "generic" - - -def test_load_hconfig_v2_options( - platform_generic: Platform, v2_options: dict[str, Any] -) -> None: - # pylint: disable=redefined-outer-name, unused-argument - platform = platform_generic - - driver = load_hconfig_v2_options(v2_options, platform) +def test_load_driver_rules(platform_generic: Platform) -> None: + # pylint: disable=redefined-outer-name + options: dict[str, Any] = { + "negation": "no", + "sectional_overwrite": [{"lineage": [{"startswith": "template"}]}], + "sectional_overwrite_no_negate": [{"lineage": [{"startswith": "as-path-set"}]}], + "ordering": [{"lineage": [{"startswith": "ntp"}], "order": 700}], + "indent_adjust": [ + { + "start_expression": "^\\s*template", + "end_expression": "^\\s*end-template", + } + ], + "parent_allows_duplicate_child": [ + {"lineage": [{"startswith": "route-policy"}]} + ], + "sectional_exiting": [ + {"lineage": [{"startswith": "router bgp"}], "exit_text": "exit"} + ], + "full_text_sub": [{"search": "banner motd # replace me #", "replace": ""}], + "per_line_sub": [{"search": "^!.*Generated.*$", "replace": ""}], + "idempotent_commands_blacklist": [ + { + "lineage": [ + {"startswith": "interface"}, + {"re_search": "ip address.*secondary"}, + ] + } + ], + "idempotent_commands": [{"lineage": [{"startswith": "interface"}]}], + "negation_negate_with": [ + { + "lineage": [ + {"startswith": "interface Ethernet"}, + {"startswith": "spanning-tree port type"}, + ], + "use": "no spanning-tree port type", + } + ], + } + + driver = load_driver_rules(options, platform_generic) # Assert sectional overwrite assert len(driver.rules.sectional_overwrite) == 1 @@ -180,18 +190,17 @@ def test_load_hconfig_v2_options( assert len(driver.rules.idempotent_commands) == 1 assert driver.rules.idempotent_commands[0].match_rules[0].startswith == "interface" - # Assert negation_negate_with -> negate_with - assert len(driver.rules.negate_with) == 1 - assert driver.rules.negate_with[0].match_rules[0].startswith == "interface Ethernet" - assert ( - driver.rules.negate_with[0].match_rules[1].startswith - == "spanning-tree port type" - ) - assert driver.rules.negate_with[0].use == "no spanning-tree port type" + # Assert negation_negate_with -> unified negation rule (REPLACE) + assert len(driver.rules.negation) == 1 + negation_rule = driver.rules.negation[0] + assert negation_rule.strategy == NegationStrategy.REPLACE + assert negation_rule.match_rules[0].startswith == "interface Ethernet" + assert negation_rule.match_rules[1].startswith == "spanning-tree port type" + assert negation_rule.use == "no spanning-tree port type" -def test_load_hconfig_v2_tags_valid_input() -> None: - v2_tags = [ +def test_load_tag_rules_valid_input() -> None: + tags = [ { "lineage": [ {"startswith": ["ip name-server", "no ip name-server", "ntp", "no ntp"]} @@ -219,21 +228,21 @@ def test_load_hconfig_v2_tags_valid_input() -> None: ), ) - result = load_hconfig_v2_tags(v2_tags) + result = load_tag_rules(tags) assert result == expected_output -def test_load_hconfig_v2_tags_empty_input() -> None: - v2_tags: list[dict[str, Any]] = [] +def test_load_tag_rules_empty_input() -> None: + tags: list[dict[str, Any]] = [] expected_output = () - result = load_hconfig_v2_tags(v2_tags) + result = load_tag_rules(tags) assert result == expected_output -def test_load_hconfig_v2_tags_multiple_lineage_fields() -> None: - v2_tags = [ +def test_load_tag_rules_multiple_lineage_fields() -> None: + tags = [ { "lineage": [ {"startswith": ["ip name-server"]}, @@ -253,12 +262,12 @@ def test_load_hconfig_v2_tags_multiple_lineage_fields() -> None: ), ) - result = load_hconfig_v2_tags(v2_tags) + result = load_tag_rules(tags) assert result == expected_output -def test_load_hconfig_v2_tags_empty_lineage() -> None: - v2_tags: list[dict[str, str | list[str]]] = [ +def test_load_tag_rules_empty_lineage() -> None: + tags: list[dict[str, str | list[str]]] = [ { "lineage": [], "add_tags": "empty", @@ -267,13 +276,13 @@ def test_load_hconfig_v2_tags_empty_lineage() -> None: expected_output = (TagRule(match_rules=(), apply_tags=frozenset(["empty"])),) - result = load_hconfig_v2_tags(v2_tags) + result = load_tag_rules(tags) assert result == expected_output -def test_load_hconfig_v2_options_from_file_valid(tmp_path: Path) -> None: - """Test loading valid v2 options from a YAML file.""" - file_path = tmp_path / "v2_options.yml" +def test_load_driver_rules_from_file_valid(tmp_path: Path) -> None: + """Test loading valid driver rules from a YAML file.""" + file_path = tmp_path / "options.yml" file_content = """ordering: - lineage: - startswith: ntp @@ -288,7 +297,7 @@ def test_load_hconfig_v2_options_from_file_valid(tmp_path: Path) -> None: file_path.write_text(file_content) platform = Platform.GENERIC - driver = load_hconfig_v2_options(v2_options=str(file_path), platform=platform) + driver = load_driver_rules(options=str(file_path), platform=platform) assert len(driver.rules.ordering) == 1 assert driver.rules.ordering[0].match_rules[0].startswith == "ntp" @@ -302,9 +311,9 @@ def test_load_hconfig_v2_options_from_file_valid(tmp_path: Path) -> None: assert driver.rules.indent_adjust[0].end_expression == "end expression" -def test_load_hconfig_v2_options_from_file_invalid_yaml(tmp_path: Path) -> None: - """Test loading v2 options from a file with invalid YAML syntax.""" - file_path = tmp_path / "invalid_v2_options.yml" +def test_load_driver_rules_from_file_invalid_yaml(tmp_path: Path) -> None: + """Test loading driver rules from a file with invalid YAML syntax.""" + file_path = tmp_path / "invalid_options.yml" file_content = """ordering: - lineage: - startswith: ntp @@ -314,12 +323,12 @@ def test_load_hconfig_v2_options_from_file_invalid_yaml(tmp_path: Path) -> None: platform = Platform.GENERIC with pytest.raises(TypeError): - load_hconfig_v2_options(v2_options=str(file_path), platform=platform) + load_driver_rules(options=str(file_path), platform=platform) -def test_load_hconfig_v2_tags_from_file_valid(tmp_path: Path) -> None: - """Test loading valid v2 tags from a YAML file.""" - file_path = tmp_path / "v2_tags.yml" +def test_load_tag_rules_from_file_valid(tmp_path: Path) -> None: + """Test loading valid tag rules from a YAML file.""" + file_path = tmp_path / "tags.yml" file_content = """- lineage: - startswith: ip name-server add_tags: dns @@ -329,16 +338,16 @@ def test_load_hconfig_v2_tags_from_file_valid(tmp_path: Path) -> None: """ file_path.write_text(file_content) - result = load_hconfig_v2_tags(v2_tags=str(file_path)) + result = load_tag_rules(tags=str(file_path)) assert len(result) == 2 assert result[0].apply_tags == frozenset(["dns"]) assert result[1].apply_tags == frozenset(["bgp"]) -def test_load_hconfig_v2_tags_from_file_invalid_yaml(tmp_path: Path) -> None: - """Test loading v2 tags from a file with invalid YAML syntax.""" - file_path = tmp_path / "invalid_v2_tags.yml" +def test_load_tag_rules_from_file_invalid_yaml(tmp_path: Path) -> None: + """Test loading tag rules from a file with invalid YAML syntax.""" + file_path = tmp_path / "invalid_tags.yml" file_content = """- lineage: - startswith: ip name-server add_tags dns # Missing colon causes a syntax error @@ -346,16 +355,16 @@ def test_load_hconfig_v2_tags_from_file_invalid_yaml(tmp_path: Path) -> None: file_path.write_text(file_content) with pytest.raises(yaml.YAMLError): - load_hconfig_v2_tags(v2_tags=str(file_path)) + load_tag_rules(tags=str(file_path)) -def test_load_hconfig_v2_tags_from_file_empty_file(tmp_path: Path) -> None: - """Test loading v2 tags from an empty file.""" - file_path = tmp_path / "empty_v2_tags.yml" +def test_load_tag_rules_from_file_empty_file(tmp_path: Path) -> None: + """Test loading tag rules from an empty file.""" + file_path = tmp_path / "empty_tags.yml" file_path.write_text("") with pytest.raises(TypeError): - load_hconfig_v2_tags(v2_tags=str(file_path)) + load_tag_rules(tags=str(file_path)) def test_set_match_rule_endswith() -> None: @@ -393,24 +402,24 @@ def test_set_match_rule_none() -> None: assert result is None -def test_load_hconfig_v2_options_invalid_type() -> None: - """Test load_hconfig_v2_options with invalid type.""" +def test_load_driver_rules_invalid_type() -> None: + """Test load_driver_rules with invalid type.""" with pytest.raises( - TypeError, match="v2_options must be a dictionary or a valid file path" + TypeError, match="options must be a dictionary or a valid file path" ): - load_hconfig_v2_options(v2_options=123, platform=Platform.CISCO_IOS) # type: ignore[arg-type] + load_driver_rules(options=123, platform=Platform.CISCO_IOS) # type: ignore[arg-type] -def test_load_hconfig_v2_tags_from_file(tmp_path: Path) -> None: - """Test load_hconfig_v2_tags with file path.""" - file_path = tmp_path / "test_v2_tags.yml" +def test_load_tag_rules_from_file(tmp_path: Path) -> None: + """Test load_tag_rules with file path.""" + file_path = tmp_path / "test_tags.yml" tags_content = """ - lineage: - startswith: interface add_tags: interfaces """ file_path.write_text(tags_content) - result = load_hconfig_v2_tags(v2_tags=str(file_path)) + result = load_tag_rules(tags=str(file_path)) assert len(result) == 1 assert result[0].apply_tags == frozenset(["interfaces"]) diff --git a/tests/unit/test_workflows.py b/tests/unit/test_workflows.py new file mode 100644 index 00000000..8f008d92 --- /dev/null +++ b/tests/unit/test_workflows.py @@ -0,0 +1,125 @@ +import pytest + +from hier_config import HConfig, RemediationPlugin, WorkflowRemediation +from hier_config.exceptions import IncompatibleDriverError +from hier_config.models import Platform, TagRule + + +@pytest.fixture(name="wfr") +def workflow_remediation( + running_config: str, generated_config: str +) -> WorkflowRemediation: + return WorkflowRemediation( + running_config=HConfig.from_text(Platform.CISCO_IOS, running_config), + generated_config=HConfig.from_text(Platform.CISCO_IOS, generated_config), + ) + + +def test_config_lengths(wfr: WorkflowRemediation) -> None: + assert wfr.running_config.children + assert wfr.generated_config.children + assert wfr.remediation_config.children + assert wfr.rollback_config.children + + +def test_apply_tags( + wfr: WorkflowRemediation, tag_rules_ios: tuple[TagRule, ...] +) -> None: + wfr.apply_remediation_tag_rules(tag_rules_ios) + assert len(wfr.remediation_config.tags) > 0 + + +def test_remediation_config_filtered_text( + wfr: WorkflowRemediation, + tag_rules_ios: tuple[TagRule, ...], + remediation_config_with_safe_tags: str, + remediation_config_without_tags: str, +) -> None: + wfr.apply_remediation_tag_rules(tag_rules_ios) + + rem1 = wfr.remediation_config_filtered_text(set(), set()) + rem2 = wfr.remediation_config_filtered_text({"safe"}, set()) + + assert rem1 != rem2 + assert rem1 == remediation_config_without_tags + assert rem2 == remediation_config_with_safe_tags + + +def test_remediation_config_driver_mismatch() -> None: + # Test to ensure ValueError is raised for mismatched drivers + running_config = HConfig.from_text(Platform.CISCO_IOS, "dummy_config") + generated_config = HConfig.from_text(Platform.JUNIPER_JUNOS, "dummy_config") + + with pytest.raises( + IncompatibleDriverError, + match=r"The running and generated configs must use the same driver.", + ): + WorkflowRemediation(running_config, generated_config) + + +def test_rollback_config_exists(wfr: WorkflowRemediation) -> None: + # Check if rollback config is generated and accessible + rollback_config = wfr.rollback_config + assert rollback_config is not None + assert len(rollback_config.children) > 0 # Ensure rollback config has content + + +def test_rollback_config_reverts_changes(wfr: WorkflowRemediation) -> None: + # Test if rollback config correctly represents changes needed to revert generated to running + rollback_config = wfr.rollback_config + rollback_text = "\n".join( + line.indented_text() for line in rollback_config.all_children_sorted() + ) + expected_text = "no vlan 4\nno interface Vlan4\nvlan 3\n name switch_mgmt_10.0.4.0/24\ninterface Vlan2\n no mtu 9000\n no ip access-group TEST in\n shutdown\ninterface Vlan3\n description switch_mgmt_10.0.4.0/24\n ip address 10.0.4.1 255.255.0.0" + assert rollback_text == expected_text + + +def test_remediation_transform_callbacks_applied() -> None: + """Driver remediation_transform_callbacks run on the remediation config (#180).""" + running_config = HConfig.from_text(Platform.CISCO_IOS, "hostname old\n") + generated_config = HConfig.from_text(Platform.CISCO_IOS, "hostname new\n") + + def add_marker(remediation: HConfig) -> None: + remediation.add_child("end") + + running_config.driver.rules.remediation_transform_callbacks.append(add_marker) + workflow = WorkflowRemediation(running_config, generated_config) + + assert workflow.remediation_config.get_child(equals="end") is not None + + +def test_remediation_plugin_applied() -> None: + """User plugins transform the remediation config (#181).""" + + class MarkerPlugin(RemediationPlugin): + """Adds a marker line to every remediation.""" + + @property + def name(self) -> str: + return "marker" + + def transform(self, remediation: HConfig) -> None: # ruff:ignore[no-self-use] + remediation.add_child("end") + + running_config = HConfig.from_text(Platform.CISCO_IOS, "hostname old\n") + generated_config = HConfig.from_text(Platform.CISCO_IOS, "hostname new\n") + plugin = MarkerPlugin() + workflow = WorkflowRemediation(running_config, generated_config, plugins=(plugin,)) + + assert workflow.remediation_config.get_child(equals="end") is not None + assert not plugin.description + + +def test_plain_callable_plugin_applied() -> None: + """plugins= accepts bare callables, not just RemediationPlugin instances.""" + running_config = HConfig.from_text(Platform.CISCO_IOS, "hostname old\n") + generated_config = HConfig.from_text(Platform.CISCO_IOS, "hostname new\n") + + def add_marker(remediation: HConfig) -> None: + remediation.add_child("end") + + workflow = WorkflowRemediation( + running_config, generated_config, plugins=(add_marker,) + ) + + assert workflow.remediation_config.get_child(equals="end") is not None