confgraph 0.3.7__tar.gz → 0.3.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {confgraph-0.3.7 → confgraph-0.3.8}/PKG-INFO +1 -1
  2. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/interface.py +28 -5
  3. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/ospf.py +33 -3
  4. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/__init__.py +38 -1
  5. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/base.py +50 -9
  6. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/eos_parser.py +9 -2
  7. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/ios_parser.py +73 -10
  8. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/nxos_parser.py +6 -1
  9. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/tombstones.py +15 -0
  10. confgraph-0.3.8/confgraph/utils/ospf.py +43 -0
  11. confgraph-0.3.8/dist-0.3.7/confgraph-0.3.7-py3-none-any.whl +0 -0
  12. confgraph-0.3.8/dist-0.3.7/confgraph-0.3.7.tar.gz +0 -0
  13. confgraph-0.3.8/dist-0.3.8/.gitignore +1 -0
  14. {confgraph-0.3.7 → confgraph-0.3.8}/pyproject.toml +1 -1
  15. {confgraph-0.3.7 → confgraph-0.3.8}/uv.lock +1 -1
  16. {confgraph-0.3.7 → confgraph-0.3.8}/.github/workflows/publish.yml +0 -0
  17. {confgraph-0.3.7 → confgraph-0.3.8}/.github/workflows/redos-scan.yml +0 -0
  18. {confgraph-0.3.7 → confgraph-0.3.8}/.gitignore +0 -0
  19. {confgraph-0.3.7 → confgraph-0.3.8}/.python-version +0 -0
  20. {confgraph-0.3.7 → confgraph-0.3.8}/CHANGELOG.md +0 -0
  21. {confgraph-0.3.7 → confgraph-0.3.8}/CLAUDE.md +0 -0
  22. {confgraph-0.3.7 → confgraph-0.3.8}/INTERFACE.md +0 -0
  23. {confgraph-0.3.7 → confgraph-0.3.8}/LICENSE +0 -0
  24. {confgraph-0.3.7 → confgraph-0.3.8}/README.md +0 -0
  25. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/analysis/__init__.py +0 -0
  26. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/analysis/dependency_resolver.py +0 -0
  27. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/change_ir.py +0 -0
  28. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/cli.py +0 -0
  29. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/graph/__init__.py +0 -0
  30. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/graph/assets/cytoscape-dagre.min.js +0 -0
  31. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/graph/assets/cytoscape.min.js +0 -0
  32. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/graph/assets/dagre.min.js +0 -0
  33. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/graph/builder.py +0 -0
  34. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/graph/exporters/__init__.py +0 -0
  35. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/graph/exporters/base.py +0 -0
  36. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/graph/exporters/html.py +0 -0
  37. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/graph/exporters/json.py +0 -0
  38. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/loader.py +0 -0
  39. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/__init__.py +0 -0
  40. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/aaa.py +0 -0
  41. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/acl.py +0 -0
  42. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/banner.py +0 -0
  43. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/base.py +0 -0
  44. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/bfd.py +0 -0
  45. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/bgp.py +0 -0
  46. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/cdp.py +0 -0
  47. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/community_list.py +0 -0
  48. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/crypto.py +0 -0
  49. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/dhcp.py +0 -0
  50. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/dns.py +0 -0
  51. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/eem.py +0 -0
  52. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/eigrp.py +0 -0
  53. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/evpn.py +0 -0
  54. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/ipsla.py +0 -0
  55. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/isis.py +0 -0
  56. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/line.py +0 -0
  57. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/lldp.py +0 -0
  58. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/logging_config.py +0 -0
  59. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/mpls.py +0 -0
  60. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/multicast.py +0 -0
  61. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/nat.py +0 -0
  62. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/netflow.py +0 -0
  63. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/ntp.py +0 -0
  64. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/object_group.py +0 -0
  65. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/object_tracking.py +0 -0
  66. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/panos_zone.py +0 -0
  67. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/parsed_config.py +0 -0
  68. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/prefix_list.py +0 -0
  69. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/qos.py +0 -0
  70. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/rip.py +0 -0
  71. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/route_map.py +0 -0
  72. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/snmp.py +0 -0
  73. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/static_route.py +0 -0
  74. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/stp.py +0 -0
  75. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/topology.py +0 -0
  76. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/vlan.py +0 -0
  77. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/vpc.py +0 -0
  78. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/vrf.py +0 -0
  79. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/models/vxlan.py +0 -0
  80. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/iosxr_parser.py +0 -0
  81. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/junos_groups.py +0 -0
  82. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/junos_hierarchy.py +0 -0
  83. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/junos_parser.py +0 -0
  84. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/panos_parser.py +0 -0
  85. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/parsers/panos_xml.py +0 -0
  86. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/topology/__init__.py +0 -0
  87. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/topology/exporters.py +0 -0
  88. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/topology/graph.py +0 -0
  89. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/topology/ingest.py +0 -0
  90. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/utils/__init__.py +0 -0
  91. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/utils/escaping.py +0 -0
  92. {confgraph-0.3.7 → confgraph-0.3.8}/confgraph/utils/interface.py +0 -0
  93. {confgraph-0.3.7 → confgraph-0.3.8}/dist-0.3.7/.gitignore +0 -0
  94. {confgraph-0.3.7 → confgraph-0.3.8}/docs/ADDING_NEW_OS_SUPPORT.md +0 -0
  95. {confgraph-0.3.7 → confgraph-0.3.8}/docs/ARCHITECTURE.md +0 -0
  96. {confgraph-0.3.7 → confgraph-0.3.8}/docs/CLI_USAGE.md +0 -0
  97. {confgraph-0.3.7 → confgraph-0.3.8}/docs/EOS_PARSER_SUPPORT.md +0 -0
  98. {confgraph-0.3.7 → confgraph-0.3.8}/docs/IOSXR_PARSER_SUPPORT.md +0 -0
  99. {confgraph-0.3.7 → confgraph-0.3.8}/docs/IOS_PARSER_SUPPORT.md +0 -0
  100. {confgraph-0.3.7 → confgraph-0.3.8}/docs/JUNOS_PARSER_SUPPORT.md +0 -0
  101. {confgraph-0.3.7 → confgraph-0.3.8}/docs/NXOS_PARSER_SUPPORT.md +0 -0
  102. {confgraph-0.3.7 → confgraph-0.3.8}/docs/PANOS_PARSER_SUPPORT.md +0 -0
  103. {confgraph-0.3.7 → confgraph-0.3.8}/docs/PARSER_SUPPORT_MATRIX.md +0 -0
  104. {confgraph-0.3.7 → confgraph-0.3.8}/docs/bgp_cluster_view.png +0 -0
  105. {confgraph-0.3.7 → confgraph-0.3.8}/docs/default_view.png +0 -0
  106. {confgraph-0.3.7 → confgraph-0.3.8}/docs/specific_node_highlight.png +0 -0
  107. {confgraph-0.3.7 → confgraph-0.3.8}/docs/supported_os_types.md +0 -0
  108. {confgraph-0.3.7 → confgraph-0.3.8}/main.py +0 -0
  109. {confgraph-0.3.7 → confgraph-0.3.8}/samples/complex_router.ios +0 -0
  110. {confgraph-0.3.7 → confgraph-0.3.8}/samples/eos.txt +0 -0
  111. {confgraph-0.3.7 → confgraph-0.3.8}/samples/ios.txt +0 -0
  112. {confgraph-0.3.7 → confgraph-0.3.8}/samples/ios01.txt +0 -0
  113. {confgraph-0.3.7 → confgraph-0.3.8}/samples/ios_xe.txt +0 -0
  114. {confgraph-0.3.7 → confgraph-0.3.8}/samples/ios_xr.txt +0 -0
  115. {confgraph-0.3.7 → confgraph-0.3.8}/samples/iosxr_test.cfg +0 -0
  116. {confgraph-0.3.7 → confgraph-0.3.8}/samples/junos_test.cfg +0 -0
  117. {confgraph-0.3.7 → confgraph-0.3.8}/samples/nxos.txt +0 -0
  118. {confgraph-0.3.7 → confgraph-0.3.8}/samples/panos_sample.xml +0 -0
  119. {confgraph-0.3.7 → confgraph-0.3.8}/tools/redos_baseline.json +0 -0
  120. {confgraph-0.3.7 → confgraph-0.3.8}/tools/redos_scan.json +0 -0
  121. {confgraph-0.3.7 → confgraph-0.3.8}/tools/redos_scan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: confgraph
3
- Version: 0.3.7
3
+ Version: 0.3.8
4
4
  Summary: Local-first network config dependency mapper — parse, visualize, and lint vendor configs
5
5
  Project-URL: Homepage, https://github.com/verigraphs/confgraph
6
6
  Project-URL: Repository, https://github.com/verigraphs/confgraph
@@ -2,8 +2,9 @@
2
2
 
3
3
  from enum import Enum
4
4
  from ipaddress import IPv4Address, IPv4Interface, IPv6Interface
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, Field, field_validator
6
6
  from confgraph.models.base import BaseConfigObject
7
+ from confgraph.utils.ospf import canonical_area_id
7
8
 
8
9
 
9
10
  class InterfaceType(str, Enum):
@@ -355,7 +356,11 @@ class InterfaceConfig(BaseConfigObject):
355
356
  )
356
357
  ospf_area: str | None = Field(
357
358
  default=None,
358
- description="OSPF area ID",
359
+ description=(
360
+ "OSPF area ID, CANONICALIZED to the decimal spelling (CCR-0227) "
361
+ "so a '0.0.0.0' interface and a '0' interface are in the same "
362
+ "area here, as they are on hardware."
363
+ ),
359
364
  )
360
365
  ospf_cost: int | None = Field(
361
366
  default=None,
@@ -377,9 +382,22 @@ class InterfaceConfig(BaseConfigObject):
377
382
  default=None,
378
383
  description="OSPF network type ('point-to-point', 'broadcast', etc.)",
379
384
  )
380
- ospf_passive: bool = Field(
381
- default=False,
382
- description="OSPF passive interface",
385
+ ospf_passive: bool | None = Field(
386
+ default=None,
387
+ description=(
388
+ "OSPF passive interface. TRI-STATE (CCR-0222, the CCR-0165 "
389
+ "``activate`` precedent): True = this interface is passive "
390
+ "(an interface-level ``ip ospf passive-interface``, or the "
391
+ "process-level passive config back-filled by the M3 walk); "
392
+ "False = EXPLICITLY non-passive (``no ip ospf passive-interface`` "
393
+ "— the per-interface override of a process-level "
394
+ "``passive-interface default``); None = NOT STATED on the "
395
+ "interface, so the consumer falls back to the process-level "
396
+ "passive config. A bool default of False could not express the "
397
+ "override at all: the M3 back-fill clobbered it, a field-level "
398
+ "merge could never overlay it (the CCR-0211 disease), and no "
399
+ "Change-IR SET op was emitted for it."
400
+ ),
383
401
  )
384
402
  ospf_authentication: str | None = Field(
385
403
  default=None,
@@ -646,6 +664,11 @@ class InterfaceConfig(BaseConfigObject):
646
664
  or self.dynamic_address
647
665
  )
648
666
 
667
+ # CCR-0227: area-ID storage point 4 of 4.
668
+ _canonical_area = field_validator("ospf_area", mode="before")(
669
+ canonical_area_id
670
+ )
671
+
649
672
  class Config:
650
673
  """Pydantic model configuration."""
651
674
  use_enum_values = True
@@ -2,8 +2,9 @@
2
2
 
3
3
  from enum import Enum
4
4
  from ipaddress import IPv4Address, IPv4Network
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, Field, field_validator
6
6
  from confgraph.models.base import BaseConfigObject
7
+ from confgraph.utils.ospf import canonical_area_id
7
8
 
8
9
 
9
10
  class OSPFAreaType(str, Enum):
@@ -159,12 +160,24 @@ class OSPFInterfaceConfig(BaseModel):
159
160
  default=None, description="BFD detection multiplier"
160
161
  )
161
162
 
163
+ # CCR-0227: area-ID storage point 1 of 4.
164
+ _canonical_area = field_validator("area_id", mode="before")(
165
+ canonical_area_id
166
+ )
167
+
162
168
 
163
169
  class OSPFArea(BaseModel):
164
170
  """OSPF area configuration."""
165
171
 
166
172
  area_id: str = Field(
167
- ..., description="Area ID (e.g., '0', '0.0.0.1', '10')"
173
+ ...,
174
+ description=(
175
+ "Area ID, CANONICALIZED to the decimal spelling (CCR-0227): the "
176
+ "NX-OS/JunOS '0.0.0.0' and the IOS/IOS-XR '0' are the same area on "
177
+ "hardware and are the same key here. Dotted quads convert to their "
178
+ "32-bit decimal ('0.0.0.51' -> '51'); anything else is stored as "
179
+ "written."
180
+ ),
168
181
  )
169
182
  area_type: OSPFAreaType = Field(
170
183
  default=OSPFAreaType.NORMAL, description="Area type"
@@ -220,6 +233,11 @@ class OSPFArea(BaseModel):
220
233
  description="Prefix-list name filtering Type-3 LSAs sent out of this area (outbound from area)",
221
234
  )
222
235
 
236
+ # CCR-0227: area-ID storage point 2 of 4.
237
+ _canonical_area = field_validator("area_id", mode="before")(
238
+ canonical_area_id
239
+ )
240
+
223
241
  class Config:
224
242
  """Pydantic model configuration."""
225
243
  use_enum_values = True
@@ -326,7 +344,11 @@ class OSPFConfig(BaseConfigObject):
326
344
  )
327
345
  network_statements: list[tuple[IPv4Network, str]] = Field(
328
346
  default_factory=list,
329
- description="Network statements (network, wildcard, area) - IOS style",
347
+ description=(
348
+ "Network statements (network, area) - IOS style. The area half is "
349
+ "canonicalized exactly like every other area-ID storage point "
350
+ "(CCR-0227)."
351
+ ),
330
352
  )
331
353
  shutdown: bool = Field(
332
354
  default=False, description="OSPF process shutdown"
@@ -341,6 +363,14 @@ class OSPFConfig(BaseConfigObject):
341
363
  default=False, description="BFD enabled on all OSPF interfaces"
342
364
  )
343
365
 
366
+ # CCR-0227: area-ID storage point 3 of 4 — the area half of each
367
+ # statement. ``mode="after"`` so the tuples are already built; the
368
+ # network half is untouched.
369
+ @field_validator("network_statements", mode="after")
370
+ @classmethod
371
+ def _canonical_statement_areas(cls, value):
372
+ return [(net, canonical_area_id(area)) for net, area in value]
373
+
344
374
  class Config:
345
375
  """Pydantic model configuration."""
346
376
  use_enum_values = True
@@ -108,7 +108,44 @@ from confgraph.parsers.panos_parser import PANOSParser
108
108
  # Persisted baseline snapshots are invalidated by the pbv move itself (it is a
109
109
  # digest-key component), which subsumes the Tier-A digest shift these fields
110
110
  # cause.
111
- PARSER_BEHAVIOR_VERSION: int = 10
111
+ # 11 (CCR-0222): ``InterfaceConfig.ospf_passive`` becomes tri-state
112
+ # (``bool | None``, default None) and the shared interface walk extracts BOTH
113
+ # polarities of the per-interface ``[no] ip ospf passive-interface`` override
114
+ # (NX-OS/EOS inherit it). Observable parse output moves three ways: every
115
+ # interface that says nothing now reads None rather than False; an interface
116
+ # exempted from a process-level ``passive-interface default`` reads an explicit
117
+ # False and gains a native SET op for it (False != the new default); and the
118
+ # ``no`` line it was emitting as an UnrecognizedBlock disappears, claimed by
119
+ # that op's line provenance (CCR-0203 ledger). No ParsedConfig JSON-schema
120
+ # change beyond the field's own type, so pbv is what invalidates the platform
121
+ # parse cache and entrp's digest keys.
122
+ # 12 (CCR-0227): OSPF area IDs are CANONICALIZED at parse to the decimal
123
+ # spelling, by one shared ``canonical_area_id`` applied via model validators on
124
+ # all four storage points (OSPFArea.area_id, OSPFInterfaceConfig.area_id,
125
+ # InterfaceConfig.ospf_area, the area half of OSPFConfig.network_statements).
126
+ # `0.0.0.0` and `0` are the same area on hardware; stored raw they were two
127
+ # keys, so every mixed-vendor estate (NX-OS/JunOS dotted, IOS/IOS-XR decimal)
128
+ # modelled one real area as two — no adjacency across the spellings and wrong
129
+ # ABR/inter-area computation. Observable parse output moves for every config
130
+ # that spells an area as a dotted quad, with NO ParsedConfig JSON-schema
131
+ # change — the sharp case this constant exists for. Decimal is canonical
132
+ # precisely so the existing tombstone/golden keys (`ospf:1:area:0:...`) do not
133
+ # move.
134
+ # 13 (CCR-0227 completion): the two OPS-ONLY area-scoped removals —
135
+ # ``no network A W area <aid>`` (and the EOS ``no network A/M area <aid>``
136
+ # twin) and ``no area <aid> range A M`` — build their LIST_REMOVE key
137
+ # directly in the parser rather than through the tombstone registry, and were
138
+ # left spelling the area as the proposal wrote it while 12 made storage
139
+ # canonical. A dotted withdrawal therefore addressed an area that no longer
140
+ # existed under that spelling and removed NOTHING, silently — including the
141
+ # dotted-baseline/dotted-proposal pair that worked before 12. The removal key
142
+ # and the refresh-suppression map on both sides of each walk are now
143
+ # canonical. Observable parse output moves (those op paths) with no schema
144
+ # change. Bumped rather than riding 12 — unreleased though 12 is, the rule in
145
+ # this module's header is per-commit and not optional for a qualifying
146
+ # change, and deciding otherwise would mean re-deriving "was the predecessor
147
+ # released?" at every review.
148
+ PARSER_BEHAVIOR_VERSION: int = 13
112
149
 
113
150
  __all__ = [
114
151
  "BaseParser",
@@ -956,16 +956,23 @@ class BaseParser(ABC):
956
956
  # (explicitly named in passive_interfaces or non_passive_interfaces,
957
957
  # or whose ospf_process_id matches). Never infer passive for L2 ports
958
958
  # or interfaces not in any OSPF process — correctness over coverage.
959
+ #
960
+ # CCR-0222: every stamp here is guarded on ``ospf_passive is None``.
961
+ # The field is tri-state, and a non-None value means the INTERFACE
962
+ # block itself spoke (`[no] ip ospf passive-interface`) — which on
963
+ # hardware overrides anything the process says. Without the guard
964
+ # this walk clobbers the explicit False and the override vanishes
965
+ # again, one layer down.
959
966
  if pc.ospf_instances and pc.interfaces:
960
967
  intf_by_name = {i.name: i for i in pc.interfaces}
961
968
  for ospf in pc.ospf_instances:
962
969
  for name in ospf.passive_interfaces:
963
970
  intf = intf_by_name.get(name)
964
- if intf:
971
+ if intf and intf.ospf_passive is None:
965
972
  intf.ospf_passive = True
966
973
  for name in ospf.non_passive_interfaces:
967
974
  intf = intf_by_name.get(name)
968
- if intf:
975
+ if intf and intf.ospf_passive is None:
969
976
  intf.ospf_passive = False
970
977
  # For default-passive, only mark interfaces that belong to
971
978
  # this process (ospf_process_id matches or listed in an area)
@@ -975,6 +982,8 @@ class BaseParser(ABC):
975
982
  area_intfs.update(area.interfaces)
976
983
  non_passive_set = set(ospf.non_passive_interfaces)
977
984
  for intf in pc.interfaces:
985
+ if intf.ospf_passive is not None:
986
+ continue # the interface block already decided
978
987
  if intf.name in non_passive_set:
979
988
  continue # already handled above
980
989
  if intf.name in area_intfs or intf.ospf_process_id == ospf.process_id:
@@ -1036,6 +1045,27 @@ class BaseParser(ABC):
1036
1045
  ("bfd_multiplier", "bfd_multiplier"),
1037
1046
  )
1038
1047
 
1048
+ @staticmethod
1049
+ def _is_model_default(obj, field_name: str, value) -> bool:
1050
+ """True when *value* is still *field_name*'s model-declared default.
1051
+
1052
+ The single "has this field been set?" test for the back-fill walks
1053
+ (CCR-0222). Identity first so the singletons (``None``, ``False``)
1054
+ match exactly; equality only within the same type, so an explicitly
1055
+ parsed ``0`` is never read as a default ``False``.
1056
+ """
1057
+ field_info = type(obj).model_fields.get(field_name)
1058
+ if field_info is None:
1059
+ return value is None
1060
+ default = (
1061
+ field_info.default_factory()
1062
+ if field_info.default_factory is not None
1063
+ else field_info.default
1064
+ )
1065
+ if value is default:
1066
+ return True
1067
+ return type(value) is type(default) and value == default
1068
+
1039
1069
  def _backfill_ospf_interface_settings(self, pc: ParsedConfig) -> None:
1040
1070
  """Carry ``area > interface`` OSPF settings out to ``InterfaceConfig``.
1041
1071
 
@@ -1077,14 +1107,25 @@ class BaseParser(ABC):
1077
1107
  continue
1078
1108
  # Never clobber a value the interface block itself set.
1079
1109
  #
1080
- # Compared with `is`, not `in (None, False)`: in Python
1081
- # `0 == False`, so a membership test would read an
1082
- # explicitly parsed ZERO as "unset" and overwrite it.
1083
- # `ip ospf priority 0` is a real, meaningful value — it
1084
- # says "never become DR" — and silently promoting it to a
1085
- # back-filled 33 would invert the operator's intent.
1110
+ # "Unset" means "still holds the field's MODEL-DECLARED
1111
+ # default", read off the model rather than hard-coded as
1112
+ # None-or-False (CCR-0222): `ospf_passive` is tri-state
1113
+ # with default None, so a False there is an EXPLICIT
1114
+ # `no ip ospf passive-interface` and must survive, while
1115
+ # a False in a default-False field (`ospf_mtu_ignore`)
1116
+ # is still unset. Behaviour-identical for every other
1117
+ # row — no default-None field in the table can hold
1118
+ # False, and no default-False field can hold None.
1119
+ #
1120
+ # Compared with `is` before `==`, not `in (None, False)`:
1121
+ # in Python `0 == False`, so a plain equality test would
1122
+ # read an explicitly parsed ZERO as "unset" and overwrite
1123
+ # it. `ip ospf priority 0` is a real, meaningful value —
1124
+ # it says "never become DR" — and silently promoting it
1125
+ # to a back-filled 33 would invert the operator's intent.
1126
+ # The type check keeps `0` distinct from `False`.
1086
1127
  current = getattr(intf, dst_field, None)
1087
- if current is not None and current is not False:
1128
+ if not self._is_model_default(intf, dst_field, current):
1088
1129
  continue
1089
1130
  setattr(intf, dst_field, value)
1090
1131
 
@@ -14,6 +14,7 @@ from confgraph.parsers.base import (
14
14
  from confgraph.models.base import OSType, UnrecognizedBlock
15
15
  from confgraph.models.line import LineType
16
16
  from confgraph.models.ospf import OSPFConfig
17
+ from confgraph.utils.ospf import canonical_area_id
17
18
  from confgraph.models.bgp import BGPConfig, BGPNeighborAF
18
19
  from confgraph.models.prefix_list import PrefixListConfig, PrefixListEntry
19
20
  from confgraph.models.static_route import StaticRoute
@@ -306,7 +307,10 @@ class EOSParser(IOSParser):
306
307
  ospf_area_children[0].text,
307
308
  )
308
309
  if am:
309
- intf_cfg.ospf_area = am.group(1)
310
+ # CCR-0227: assignment to an already-built model skips
311
+ # pydantic validation — canonicalize as the field
312
+ # validator would.
313
+ intf_cfg.ospf_area = canonical_area_id(am.group(1))
310
314
 
311
315
  # VARP: "ip virtual-router address <ip>". A device emits ONE LINE
312
316
  # PER ADDRESS, so accumulate rather than overwrite
@@ -397,7 +401,10 @@ class EOSParser(IOSParser):
397
401
  m = re.match(r"^\s+network\s+(\S+)\s+area\s+(\S+)\s*$", nc.text)
398
402
  if not m:
399
403
  continue
400
- prefix_str, area_id = m.group(1), m.group(2)
404
+ # CCR-0227: this appends into a LIVE model's list, which runs
405
+ # no field validator — canonicalize here as the validator on
406
+ # OSPFConfig.network_statements would.
407
+ prefix_str, area_id = m.group(1), canonical_area_id(m.group(2))
401
408
  try:
402
409
  net = IPv4Network(prefix_str, strict=False)
403
410
  except ValueError:
@@ -10,6 +10,7 @@ from confgraph.utils.interface import normalize_interface_name
10
10
  # confgraph imports, so this cannot cycle (the other change_ir names in this file
11
11
  # stay lazily imported at their call sites, unchanged).
12
12
  from confgraph.change_ir import _native_deletion_path
13
+ from confgraph.utils.ospf import canonical_area_id
13
14
  from confgraph.models.base import OSType
14
15
  from confgraph.models.vrf import VRFConfig
15
16
  from confgraph.models.interface import (
@@ -1266,7 +1267,7 @@ class IOSParser(BaseParser):
1266
1267
  ospf_hello_interval = None
1267
1268
  ospf_dead_interval = None
1268
1269
  ospf_network_type = None
1269
- ospf_passive = False
1270
+ ospf_passive = None
1270
1271
  ospf_authentication = None
1271
1272
  ospf_authentication_key = None
1272
1273
  ospf_message_digest_keys = {}
@@ -1380,6 +1381,28 @@ class IOSParser(BaseParser):
1380
1381
  if intf_obj.find_child_objects(r"^\s+ip\s+ospf\s+mtu-ignore"):
1381
1382
  ospf_mtu_ignore = True
1382
1383
 
1384
+ # ip ospf passive-interface / no ip ospf passive-interface
1385
+ #
1386
+ # CCR-0222: BOTH polarities are value-bearing state, so the
1387
+ # negation parses to an explicit False — it is the per-interface
1388
+ # override of a process-level `passive-interface default`, the
1389
+ # NX-OS hardening pattern, and on hardware the interface-level
1390
+ # word is final. Hence the tri-state field: None means the
1391
+ # interface said nothing and the process-level config decides.
1392
+ # `$`-anchored both ways (disjoint from any operand-bearing
1393
+ # spelling) with last-line-wins, exactly like every sibling
1394
+ # `ip ospf *` scalar above. This is the SHARED walk — NX-OS and
1395
+ # EOS inherit it through super().parse_interfaces(); the
1396
+ # process-level `passive-interface` spelling is parsed by the
1397
+ # OSPF-process walks and is untouched by this.
1398
+ ospf_passive_children = intf_obj.find_child_objects(
1399
+ r"^\s+(?:no\s+)?ip\s+ospf\s+passive-interface\s*$"
1400
+ )
1401
+ if ospf_passive_children:
1402
+ ospf_passive = not re.match(
1403
+ r"^\s*no\s+", ospf_passive_children[-1].text
1404
+ )
1405
+
1383
1406
  # Tunnel attributes
1384
1407
  tunnel_source = None
1385
1408
  tunnel_destination = None
@@ -1893,6 +1916,11 @@ class IOSParser(BaseParser):
1893
1916
  "ospf_dead_interval": r"^\s+ip\s+ospf\s+dead-interval\s+",
1894
1917
  "ospf_network_type": r"^\s+ip\s+ospf\s+network\s+",
1895
1918
  "ospf_mtu_ignore": r"^\s+ip\s+ospf\s+mtu-ignore\s*$",
1919
+ # CCR-0222: both polarities — the SET op for an explicit False must
1920
+ # carry the `no ip ospf passive-interface` line, which is also what
1921
+ # claims that negation for the CCR-0203 ledger (consumed, not
1922
+ # disclosed).
1923
+ "ospf_passive": r"^\s+(?:no\s+)?ip\s+ospf\s+passive-interface\s*$",
1896
1924
  "cdp_enabled": r"^\s+(?:no\s+)?cdp\s+enable\s*$",
1897
1925
  "lldp_transmit": r"^\s+(?:no\s+)?lldp\s+transmit\s*$",
1898
1926
  "lldp_receive": r"^\s+(?:no\s+)?lldp\s+receive\s*$",
@@ -4952,6 +4980,18 @@ class IOSParser(BaseParser):
4952
4980
  """
4953
4981
  from confgraph.change_ir import ChangeOp, Verb
4954
4982
 
4983
+ # CCR-0227: areas are STORED canonical, so both sides of this walk must
4984
+ # speak the canonical spelling — the removal key, which the merger
4985
+ # compares against stored ``network_statements``, and the caller's
4986
+ # positive map, whose keys come from its own walk (IOS builds them raw
4987
+ # from the regex, EOS canonical). Normalizing both HERE, where
4988
+ # everything the two spellings must never disagree on already lives,
4989
+ # keeps suppression and removal from splitting on the spelling.
4990
+ positive_net_last_line = {
4991
+ (net, canonical_area_id(area)): line
4992
+ for (net, area), line in positive_net_last_line.items()
4993
+ }
4994
+
4955
4995
  for nnc in ospf_obj.find_child_objects(r"^\s+no\s+network\s+"):
4956
4996
  nnm = re.match(child_pattern, nnc.text)
4957
4997
  if not nnm:
@@ -4960,7 +5000,7 @@ class IOSParser(BaseParser):
4960
5000
  if extracted is None:
4961
5001
  continue
4962
5002
  rem_net, rem_area = extracted
4963
- rem_key = (str(rem_net), rem_area)
5003
+ rem_key = (str(rem_net), canonical_area_id(rem_area))
4964
5004
  if positive_net_last_line.get(rem_key, -1) > nnc.linenum:
4965
5005
  # CCR-0203: suppressed-by-refresh is CONSUMED (understood and
4966
5006
  # deliberately dropped), not blind — claim it or the fallback
@@ -5186,16 +5226,28 @@ class IOSParser(BaseParser):
5186
5226
  # (area_id, prefix) reappears as a positive range line LATER in
5187
5227
  # the block (refresh → re-add wins → device truth and legacy
5188
5228
  # parity).
5229
+ #
5230
+ # ONE key builder for both walks (CCR-0227): the area half is
5231
+ # canonicalized, because the merger looks the area up by its
5232
+ # STORED id, and the suppression map and the removal must agree
5233
+ # on the spelling or a refresh stops suppressing its own removal.
5234
+ # Returns None on an unparseable mask — dropped on BOTH walks, as
5235
+ # before.
5236
+ def _range_key(m) -> "tuple[str, str] | None":
5237
+ try:
5238
+ prefix = IPv4Network(f"{m.group(2)}/{m.group(3)}")
5239
+ except ValueError:
5240
+ return None
5241
+ return canonical_area_id(m.group(1)), str(prefix)
5242
+
5189
5243
  positive_range_last_line: dict[tuple[str, str], int] = {}
5190
5244
  for rc in ospf_obj.find_child_objects(r"^\s+area\s+\S+\s+range\s+"):
5191
5245
  rm = re.match(r"^\s+area\s+(\S+)\s+range\s+(\S+)\s+(\S+)", rc.text)
5192
5246
  if not rm:
5193
5247
  continue
5194
- try:
5195
- rng_pfx = IPv4Network(f"{rm.group(2)}/{rm.group(3)}")
5196
- except ValueError:
5248
+ rkey = _range_key(rm)
5249
+ if rkey is None:
5197
5250
  continue
5198
- rkey = (rm.group(1), str(rng_pfx))
5199
5251
  positive_range_last_line[rkey] = max(
5200
5252
  positive_range_last_line.get(rkey, -1), rc.linenum
5201
5253
  )
@@ -5205,11 +5257,9 @@ class IOSParser(BaseParser):
5205
5257
  )
5206
5258
  if not nrm:
5207
5259
  continue
5208
- try:
5209
- rng_pfx = IPv4Network(f"{nrm.group(2)}/{nrm.group(3)}")
5210
- except ValueError:
5260
+ rkey = _range_key(nrm)
5261
+ if rkey is None:
5211
5262
  continue
5212
- rkey = (nrm.group(1), str(rng_pfx))
5213
5263
  if positive_range_last_line.get(rkey, -1) > nrc.linenum:
5214
5264
  self._claim_negation(nrc) # CCR-0203: suppressed == consumed
5215
5265
  continue # re-added later in the block — removal suppressed
@@ -5383,6 +5433,19 @@ class IOSParser(BaseParser):
5383
5433
  def _queue_ospf_negation(verb, path_tail, child):
5384
5434
  # CCR-0110 Phase E4/E5: queue the native op only; op-primary
5385
5435
  # parsers no longer persist the deprecated no_commands string.
5436
+ #
5437
+ # CCR-0227: the area segment of an area-scoped path comes from
5438
+ # the proposal TEXT, while the positive member ops key off the
5439
+ # stored (canonical) ``area.area_id`` — so canonicalize it
5440
+ # here, at the one point every OSPF negation op is built. A
5441
+ # `no area 0.0.0.1 stub` must address the same area a
5442
+ # `no area 1 stub` does, because the device does.
5443
+ if len(path_tail) > 1 and path_tail[0] == "area":
5444
+ path_tail = (
5445
+ "area",
5446
+ canonical_area_id(path_tail[1]),
5447
+ *path_tail[2:],
5448
+ )
5386
5449
  self._pending_native_ospf_ops.append(
5387
5450
  ChangeOp(
5388
5451
  verb=verb,
@@ -13,6 +13,7 @@ from confgraph.models.bgp import (
13
13
  BGPBestpathOptions,
14
14
  )
15
15
  from confgraph.models.ospf import OSPFConfig
16
+ from confgraph.utils.ospf import canonical_area_id
16
17
  from confgraph.models.qos import ControlPlaneConfig
17
18
  from confgraph.models.interface import InterfaceFlowMonitor, StormControlLevel, VRRPGroup
18
19
  from confgraph.models.netflow import (
@@ -488,7 +489,11 @@ class NXOSParser(IOSParser):
488
489
  )
489
490
  if m:
490
491
  intf_cfg.ospf_process_id = int(m.group(1))
491
- intf_cfg.ospf_area = m.group(2)
492
+ # CCR-0227: assignment to an already-built model skips
493
+ # pydantic validation, so this post-patch calls the
494
+ # canonicalizer the field validator would have — NX-OS is
495
+ # the dotted-quad vendor that makes it matter.
496
+ intf_cfg.ospf_area = canonical_area_id(m.group(2))
492
497
 
493
498
  # NX-OS storm-control: "storm-control {broadcast|multicast|unicast}
494
499
  # level <threshold>". The threshold is a bandwidth percentage by
@@ -86,6 +86,19 @@ def _derive_secondary_cidr(ctx: dict) -> "dict[str, Any] | None":
86
86
  return None
87
87
 
88
88
 
89
+ def _derive_area_id(ctx: dict) -> "dict[str, Any] | None":
90
+ """Canonical OSPF area-ID key for an area-scoped tombstone (CCR-0227).
91
+
92
+ Areas are stored canonical (``0.0.0.1`` -> ``1``), and the POSITIVE
93
+ member ops read the stored value, so a deletion key built from the raw
94
+ proposal text would address an area that no longer exists under that
95
+ spelling. One normalizer, the same one the models use.
96
+ """
97
+ from confgraph.utils.ospf import canonical_area_id
98
+
99
+ return {"area_id": canonical_area_id(ctx["area_id"])}
100
+
101
+
89
102
  def _derive_line_type(ctx: dict) -> "dict[str, Any] | None":
90
103
  """Canonical ``LineType`` value for a line-block tombstone key.
91
104
 
@@ -162,6 +175,7 @@ NESTED_DELETION_RULES: list[NestedDeletionRule] = [
162
175
  child_pattern=r"^no\s+area\s+(\S+)\s+stub",
163
176
  child_groups=["area_id"],
164
177
  template="ospf:{pid}:area:{area_id}:stub_reset",
178
+ derive=_derive_area_id,
165
179
  ),
166
180
  # OSPF area type reset — 'no area N nssa' / 'no area N nssa no-summary'
167
181
  # Proposal: ``no area 2 nssa`` inside ``router ospf 1``
@@ -173,6 +187,7 @@ NESTED_DELETION_RULES: list[NestedDeletionRule] = [
173
187
  child_pattern=r"^no\s+area\s+(\S+)\s+nssa",
174
188
  child_groups=["area_id"],
175
189
  template="ospf:{pid}:area:{area_id}:nssa_reset",
190
+ derive=_derive_area_id,
176
191
  ),
177
192
  # VRF route-target removals (CCR confgraph_vrf_rt_removal_tombstones.md).
178
193
  # The WI-4 merge made route_target_* lists ADDITIVE (device-faithful), so
@@ -0,0 +1,43 @@
1
+ """OSPF value normalization shared by the models and every consumer.
2
+
3
+ CCR-0227: an OSPF area ID is a 32-bit number that vendors print two ways.
4
+ NX-OS and JunOS spell the backbone ``0.0.0.0``; IOS and IOS-XR spell it ``0``.
5
+ On hardware they are the same area and the adjacency forms. Stored as raw
6
+ strings they are two different keys, so the model partitions one real area in
7
+ two across a mixed-vendor estate: the adjacency gate refuses the neighbor,
8
+ and every area-KEYED structure (ABR detection, inter-area filtering) computes
9
+ against half the area.
10
+
11
+ Canonical form is the DECIMAL spelling — chosen over dotted-quad because it
12
+ leaves existing tombstone and golden keys (``ospf:1:area:0:stub_reset``)
13
+ byte-identical.
14
+ """
15
+
16
+ import re
17
+
18
+ # Exactly four dotted octets. [0-9] not \d — \d is Unicode-aware, and a
19
+ # Devanagari digit is not an area ID.
20
+ _DOTTED_QUAD = re.compile(r"^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$")
21
+
22
+
23
+ def canonical_area_id(value):
24
+ """Return *value*'s canonical OSPF area ID — the single normalizer.
25
+
26
+ ``"0.0.0.0"`` -> ``"0"``; any dotted quad -> the decimal spelling of its
27
+ 32-bit value (``"0.0.0.51"`` -> ``"51"``). Everything else — decimal
28
+ strings, None, non-strings — passes through UNCHANGED: this normalizes a
29
+ spelling, it never rejects or invents one. Deliberately not a parser of
30
+ out-of-range quads either (``"0.0.0.999"`` is not an area ID in any
31
+ spelling, so it passes through for the caller to see as written).
32
+ """
33
+ if not isinstance(value, str):
34
+ return value
35
+ match = _DOTTED_QUAD.match(value.strip())
36
+ if match is None:
37
+ return value
38
+ octets = [int(group) for group in match.groups()]
39
+ if any(octet > 255 for octet in octets):
40
+ return value
41
+ return str(
42
+ (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
43
+ )
@@ -0,0 +1 @@
1
+ *
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "confgraph"
7
- version = "0.3.7"
7
+ version = "0.3.8"
8
8
  description = "Local-first network config dependency mapper — parse, visualize, and lint vendor configs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -67,7 +67,7 @@ wheels = [
67
67
 
68
68
  [[package]]
69
69
  name = "confgraph"
70
- version = "0.3.6"
70
+ version = "0.3.8"
71
71
  source = { editable = "." }
72
72
  dependencies = [
73
73
  { name = "ciscoconfparse2" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes