diffnc 0.0.1__py3-none-any.whl
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.
- diffnc/__init__.py +37 -0
- diffnc/__main__.py +8 -0
- diffnc/cli.py +140 -0
- diffnc/detect.py +170 -0
- diffnc/diff.py +353 -0
- diffnc/errors.py +22 -0
- diffnc/ir.py +58 -0
- diffnc/py.typed +0 -0
- diffnc/reconcile.py +132 -0
- diffnc/vendors/__init__.py +58 -0
- diffnc/vendors/_cisco_like.py +203 -0
- diffnc/vendors/base.py +88 -0
- diffnc/vendors/eos.py +19 -0
- diffnc/vendors/ios.py +19 -0
- diffnc/vendors/iosxe.py +20 -0
- diffnc/vendors/iosxr.py +19 -0
- diffnc/vendors/junos.py +175 -0
- diffnc/vendors/junos_set.py +158 -0
- diffnc/vendors/nxos.py +22 -0
- diffnc-0.0.1.dist-info/METADATA +283 -0
- diffnc-0.0.1.dist-info/RECORD +24 -0
- diffnc-0.0.1.dist-info/WHEEL +4 -0
- diffnc-0.0.1.dist-info/entry_points.txt +2 -0
- diffnc-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Shared parser for Cisco-style "indent + ``!`` comments" vendors.
|
|
2
|
+
|
|
3
|
+
IOS, NX-OS, IOS-XE, IOS-XR, and Arista EOS all share the same structural grammar:
|
|
4
|
+
|
|
5
|
+
* Section heads (e.g. ``interface ...``, ``router ospf 1``) introduce nested children at
|
|
6
|
+
deeper indent. Indentation is significant; depth is determined by the stack of seen
|
|
7
|
+
indents, not by a fixed unit.
|
|
8
|
+
* Lines starting with ``!`` or ``#`` are comments and discarded.
|
|
9
|
+
* Some vendors use trailing keywords like ``end`` / ``exit`` / ``commit`` / ``root`` as
|
|
10
|
+
configuration terminators that should be ignored during parse.
|
|
11
|
+
* ``shutdown`` / ``no shutdown`` form a tri-state per parent — the last toggle in input
|
|
12
|
+
order wins and only one of the two appears in the parent's children.
|
|
13
|
+
* ``default <args>`` drops siblings under the current parent whose line equals or
|
|
14
|
+
token-prefix-matches ``<args>``.
|
|
15
|
+
|
|
16
|
+
Vendors differ only in:
|
|
17
|
+
|
|
18
|
+
* the rendered indent width (`indent_unit`)
|
|
19
|
+
* which standalone keywords act as terminators (`terminators`)
|
|
20
|
+
* the vendor name reported by :attr:`name`
|
|
21
|
+
|
|
22
|
+
so this module exposes :class:`CiscoLikeParser` parameterised on those three knobs.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from diffnc.errors import ParseError
|
|
32
|
+
from diffnc.ir import ConfigNode, ConfigTree
|
|
33
|
+
from diffnc.vendors.base import VendorParser, render_subtree
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from diffnc.reconcile import ReconcileEvent
|
|
37
|
+
|
|
38
|
+
_DEFAULT_PREFIX = "default "
|
|
39
|
+
_SHUT_STATES = ("shutdown", "no shutdown")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cisco_default_order_sensitive(path: tuple[str, ...]) -> bool:
|
|
43
|
+
"""Path-based order-sensitivity shared by IOS / IOS-XE / IOS-XR / NX-OS / EOS.
|
|
44
|
+
|
|
45
|
+
ACL entries (``ip``/``ipv6``/``mac access-list``) and ``policy-map`` class blocks
|
|
46
|
+
are evaluated top-to-bottom in the order they appear; everything else (interfaces,
|
|
47
|
+
VRFs, ``route-map FOO permit <seq>`` siblings at top level, …) is order-insensitive.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
if not path:
|
|
51
|
+
return False
|
|
52
|
+
head = path[-1]
|
|
53
|
+
if head.startswith(("ip access-list ", "ipv6 access-list ", "mac access-list ")):
|
|
54
|
+
return True
|
|
55
|
+
return head.startswith("policy-map ")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CiscoLikeParser:
|
|
60
|
+
name: str
|
|
61
|
+
indent_unit: int
|
|
62
|
+
terminators: frozenset[str] = field(default_factory=frozenset)
|
|
63
|
+
order_sensitive_predicate: Callable[[tuple[str, ...]], bool] = field(
|
|
64
|
+
default=cisco_default_order_sensitive
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def parse(self, text: str) -> ConfigTree:
|
|
68
|
+
tree = ConfigTree.empty(vendor=self.name)
|
|
69
|
+
|
|
70
|
+
stack: list[tuple[int, ConfigNode]] = [(-1, tree.root)]
|
|
71
|
+
|
|
72
|
+
for lineno, raw in enumerate(text.splitlines(), start=1):
|
|
73
|
+
stripped = raw.strip()
|
|
74
|
+
if not stripped or stripped.startswith("!") or stripped.startswith("#"):
|
|
75
|
+
continue
|
|
76
|
+
if stripped in self.terminators:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
indent = _leading_spaces(raw)
|
|
80
|
+
|
|
81
|
+
while stack and indent <= stack[-1][0]:
|
|
82
|
+
stack.pop()
|
|
83
|
+
if not stack:
|
|
84
|
+
raise ParseError(f"line {lineno}: unexpected indentation in {self.name} config")
|
|
85
|
+
|
|
86
|
+
parent_node = stack[-1][1]
|
|
87
|
+
|
|
88
|
+
if stripped == "default" or stripped.startswith(_DEFAULT_PREFIX):
|
|
89
|
+
path = stripped[len(_DEFAULT_PREFIX) :].strip() if stripped != "default" else ""
|
|
90
|
+
if not path:
|
|
91
|
+
raise ParseError(f"line {lineno}: 'default' requires a path")
|
|
92
|
+
_apply_default(parent_node, path)
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if stripped in _SHUT_STATES:
|
|
96
|
+
_apply_shut_toggle(parent_node, stripped)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
actual = parent_node.add_child(ConfigNode(line=stripped))
|
|
100
|
+
stack.append((indent, actual))
|
|
101
|
+
|
|
102
|
+
return tree
|
|
103
|
+
|
|
104
|
+
def format(self, tree: ConfigTree) -> list[str]:
|
|
105
|
+
lines: list[str] = []
|
|
106
|
+
for child in tree.root.children:
|
|
107
|
+
lines.extend(render_subtree(self, child, depth=0))
|
|
108
|
+
return lines
|
|
109
|
+
|
|
110
|
+
def render_open(self, node: ConfigNode, depth: int) -> str:
|
|
111
|
+
return self._pad(depth) + node.line
|
|
112
|
+
|
|
113
|
+
def render_close(self, node: ConfigNode, depth: int) -> str | None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def render_leaf(self, node: ConfigNode, depth: int) -> str:
|
|
117
|
+
return self._pad(depth) + node.line
|
|
118
|
+
|
|
119
|
+
def is_order_sensitive(self, path: tuple[str, ...]) -> bool:
|
|
120
|
+
return self.order_sensitive_predicate(path)
|
|
121
|
+
|
|
122
|
+
def render_reconcile(self, events: Iterable[ReconcileEvent]) -> Iterator[str]:
|
|
123
|
+
from diffnc.reconcile import ReconcileAdd, ReconcileDelete, ReconcileRecreate
|
|
124
|
+
|
|
125
|
+
last_path: tuple[str, ...] | None = None
|
|
126
|
+
for ev in events:
|
|
127
|
+
if isinstance(ev, ReconcileRecreate):
|
|
128
|
+
parents = ev.section_path[:-1]
|
|
129
|
+
section_header = ev.section_path[-1]
|
|
130
|
+
yield from parents
|
|
131
|
+
yield f"no {section_header}"
|
|
132
|
+
yield from parents
|
|
133
|
+
yield section_header
|
|
134
|
+
for child in ev.new_node.children:
|
|
135
|
+
yield from _walk_lines(child)
|
|
136
|
+
last_path = None
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if ev.parent_path != last_path:
|
|
140
|
+
yield from ev.parent_path
|
|
141
|
+
last_path = ev.parent_path
|
|
142
|
+
|
|
143
|
+
if isinstance(ev, ReconcileAdd):
|
|
144
|
+
yield from _walk_lines(ev.node)
|
|
145
|
+
elif isinstance(ev, ReconcileDelete):
|
|
146
|
+
yield _negate(ev.node.line)
|
|
147
|
+
|
|
148
|
+
def _pad(self, depth: int) -> str:
|
|
149
|
+
return " " * (self.indent_unit * depth)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _walk_lines(node: ConfigNode) -> Iterator[str]:
|
|
153
|
+
"""Yield ``node.line`` followed by each descendant's line, pre-order."""
|
|
154
|
+
|
|
155
|
+
yield node.line
|
|
156
|
+
for child in node.children:
|
|
157
|
+
yield from _walk_lines(child)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _negate(line: str) -> str:
|
|
161
|
+
"""Return the negation of *line* under Cisco's ``no`` semantics.
|
|
162
|
+
|
|
163
|
+
``"description foo"`` → ``"no description foo"``; ``"no shutdown"`` → ``"shutdown"``.
|
|
164
|
+
Avoids ``"no no <foo>"`` double negation.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
if line.startswith("no "):
|
|
168
|
+
return line[3:]
|
|
169
|
+
return f"no {line}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _apply_shut_toggle(parent: ConfigNode, new_state: str) -> None:
|
|
173
|
+
"""Toggle the shutdown/no-shutdown state under ``parent``.
|
|
174
|
+
|
|
175
|
+
Replaces any existing ``shutdown``/``no shutdown`` child line in place (preserving
|
|
176
|
+
first-occurrence position), or appends a new state node when none exists.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
for child in parent.children:
|
|
180
|
+
if child.line in _SHUT_STATES:
|
|
181
|
+
child.line = new_state
|
|
182
|
+
return
|
|
183
|
+
parent.children.append(ConfigNode(line=new_state))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _apply_default(parent: ConfigNode, default_path: str) -> None:
|
|
187
|
+
"""Drop ``parent``'s children whose line equals or token-prefix-matches ``default_path``."""
|
|
188
|
+
|
|
189
|
+
boundary = default_path + " "
|
|
190
|
+
surviving: list[ConfigNode] = []
|
|
191
|
+
for child in parent.children:
|
|
192
|
+
if child.line == default_path or child.line.startswith(boundary):
|
|
193
|
+
continue
|
|
194
|
+
surviving.append(child)
|
|
195
|
+
parent.children = surviving
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _leading_spaces(line: str) -> int:
|
|
199
|
+
return len(line) - len(line.lstrip(" \t"))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# Re-export for typing convenience.
|
|
203
|
+
__all__ = ["CiscoLikeParser", "VendorParser", "cisco_default_order_sensitive"]
|
diffnc/vendors/base.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Vendor parser contract.
|
|
2
|
+
|
|
3
|
+
A vendor plugin must expose:
|
|
4
|
+
|
|
5
|
+
* :meth:`parse` — turn raw text into a normalised :class:`~diffnc.ir.ConfigTree`.
|
|
6
|
+
* :meth:`format` — render a tree back to a list of display lines.
|
|
7
|
+
* :meth:`render_open`, :meth:`render_close`, :meth:`render_leaf` — emit a single line at
|
|
8
|
+
the given indent depth. Used by the diff engine to splice context lines around changes
|
|
9
|
+
without re-rendering full subtrees.
|
|
10
|
+
* :meth:`is_order_sensitive` (optional) — declare whether the children at a given
|
|
11
|
+
configuration path are order-sensitive. When ``False`` (the default), the diff engine
|
|
12
|
+
matches children as a multiset so that pure reordering does not produce a diff.
|
|
13
|
+
|
|
14
|
+
Adding a new vendor is a matter of writing a module that implements :class:`VendorParser`
|
|
15
|
+
and registering it via :mod:`diffnc.vendors`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable, Iterator
|
|
21
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from diffnc.ir import ConfigNode, ConfigTree
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from diffnc.reconcile import ReconcileEvent
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class VendorParser(Protocol):
|
|
31
|
+
name: str
|
|
32
|
+
"""Vendor identifier, e.g. ``"junos"`` or ``"nxos"``."""
|
|
33
|
+
|
|
34
|
+
def parse(self, text: str) -> ConfigTree: ...
|
|
35
|
+
def format(self, tree: ConfigTree) -> list[str]: ...
|
|
36
|
+
def render_open(self, node: ConfigNode, depth: int) -> str: ...
|
|
37
|
+
def render_close(self, node: ConfigNode, depth: int) -> str | None: ...
|
|
38
|
+
def render_leaf(self, node: ConfigNode, depth: int) -> str: ...
|
|
39
|
+
|
|
40
|
+
def is_order_sensitive(self, path: tuple[str, ...]) -> bool:
|
|
41
|
+
"""Return True if the children at ``path`` must be diffed positionally.
|
|
42
|
+
|
|
43
|
+
``path`` is the tuple of ``node.line`` values from root (exclusive) down to the
|
|
44
|
+
parent whose children are being matched — e.g. ``("firewall", "filter FOO")``.
|
|
45
|
+
Vendors that don't implement this fall back to order-insensitive matching.
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def render_reconcile(self, events: Iterable[ReconcileEvent]) -> Iterator[str]:
|
|
50
|
+
"""Translate :mod:`diffnc.reconcile` events into config-mode command lines.
|
|
51
|
+
|
|
52
|
+
Optional. Parsers that don't implement this can still be used for diffing; only
|
|
53
|
+
:func:`diffnc.reconcile` will fail (with :class:`NotImplementedError`) when
|
|
54
|
+
called against them.
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_order_sensitive_for(parser: VendorParser, path: tuple[str, ...]) -> bool:
|
|
60
|
+
"""Resolve :meth:`VendorParser.is_order_sensitive` with a safe default.
|
|
61
|
+
|
|
62
|
+
Older / third-party parsers may not implement the method; treat such parents as
|
|
63
|
+
order-insensitive so the diff engine can fall back to multiset matching.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
impl = getattr(parser, "is_order_sensitive", None)
|
|
67
|
+
if impl is None:
|
|
68
|
+
return False
|
|
69
|
+
return bool(impl(path))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_subtree(
|
|
73
|
+
parser: VendorParser,
|
|
74
|
+
node: ConfigNode,
|
|
75
|
+
depth: int,
|
|
76
|
+
) -> list[str]:
|
|
77
|
+
"""Render *node* and all its descendants as display lines."""
|
|
78
|
+
|
|
79
|
+
if node.is_leaf:
|
|
80
|
+
return [parser.render_leaf(node, depth)]
|
|
81
|
+
|
|
82
|
+
lines = [parser.render_open(node, depth)]
|
|
83
|
+
for child in node.children:
|
|
84
|
+
lines.extend(render_subtree(parser, child, depth + 1))
|
|
85
|
+
close = parser.render_close(node, depth)
|
|
86
|
+
if close is not None:
|
|
87
|
+
lines.append(close)
|
|
88
|
+
return lines
|
diffnc/vendors/eos.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Arista EOS vendor parser.
|
|
2
|
+
|
|
3
|
+
EOS configurations look very Cisco-ish — significant indentation, ``!`` comments,
|
|
4
|
+
``end`` / ``exit`` terminators — but use a 3-space indent unit and a distinct top-level
|
|
5
|
+
vocabulary (``vrf instance MGMT`` rather than NX-OS's ``vrf context``, ``daemon
|
|
6
|
+
TerminAttr``, ``management api http-commands``, ...). The grammar itself is shared with
|
|
7
|
+
the other Cisco-style vendors via :mod:`diffnc.vendors._cisco_like`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from diffnc.vendors._cisco_like import CiscoLikeParser
|
|
13
|
+
from diffnc.vendors.base import VendorParser
|
|
14
|
+
|
|
15
|
+
PARSER: VendorParser = CiscoLikeParser(
|
|
16
|
+
name="eos",
|
|
17
|
+
indent_unit=3,
|
|
18
|
+
terminators=frozenset({"end", "exit"}),
|
|
19
|
+
)
|
diffnc/vendors/ios.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Cisco IOS vendor parser.
|
|
2
|
+
|
|
3
|
+
IOS configurations use significant indentation under section heads such as
|
|
4
|
+
``interface GigabitEthernet0/0`` or ``router ospf 1`` with a 1-space indent unit, and
|
|
5
|
+
end with ``end`` / ``exit`` terminator lines that we drop. Everything else (shutdown
|
|
6
|
+
toggling, ``default`` semantics, ``!``/``#`` comment handling) is shared with the other
|
|
7
|
+
Cisco-style vendors and lives in :mod:`diffnc.vendors._cisco_like`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from diffnc.vendors._cisco_like import CiscoLikeParser
|
|
13
|
+
from diffnc.vendors.base import VendorParser
|
|
14
|
+
|
|
15
|
+
PARSER: VendorParser = CiscoLikeParser(
|
|
16
|
+
name="ios",
|
|
17
|
+
indent_unit=1,
|
|
18
|
+
terminators=frozenset({"end", "exit"}),
|
|
19
|
+
)
|
diffnc/vendors/iosxe.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Cisco IOS-XE vendor parser.
|
|
2
|
+
|
|
3
|
+
IOS-XE shares its CLI grammar with classic IOS — 1-space indentation, ``end`` / ``exit``
|
|
4
|
+
terminators, ``!`` comments — but ships with a richer top-level vocabulary
|
|
5
|
+
(``platform ...``, ``license boot level ...``, 3-tuple interface names like
|
|
6
|
+
``GigabitEthernet0/0/0``). All of these structural rules are handled by the shared
|
|
7
|
+
:class:`~diffnc.vendors._cisco_like.CiscoLikeParser`; only the auto-detection layer
|
|
8
|
+
needs to know about the IOS-XE-specific markers.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from diffnc.vendors._cisco_like import CiscoLikeParser
|
|
14
|
+
from diffnc.vendors.base import VendorParser
|
|
15
|
+
|
|
16
|
+
PARSER: VendorParser = CiscoLikeParser(
|
|
17
|
+
name="iosxe",
|
|
18
|
+
indent_unit=1,
|
|
19
|
+
terminators=frozenset({"end", "exit"}),
|
|
20
|
+
)
|
diffnc/vendors/iosxr.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Cisco IOS-XR vendor parser.
|
|
2
|
+
|
|
3
|
+
IOS-XR uses the same indent-based section grammar as IOS (1-space unit) but has its own
|
|
4
|
+
configuration session terminators — ``commit`` (and the less common ``root`` /
|
|
5
|
+
``abort``) — that we treat the same way IOS treats ``end`` / ``exit``: drop them on
|
|
6
|
+
parse. Interface naming conventions (``Bundle-Ether1``, 4-tuple ``GigabitEthernet0/0/0/0``)
|
|
7
|
+
matter only for auto-detection and are handled in :mod:`diffnc.detect`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from diffnc.vendors._cisco_like import CiscoLikeParser
|
|
13
|
+
from diffnc.vendors.base import VendorParser
|
|
14
|
+
|
|
15
|
+
PARSER: VendorParser = CiscoLikeParser(
|
|
16
|
+
name="iosxr",
|
|
17
|
+
indent_unit=1,
|
|
18
|
+
terminators=frozenset({"end", "exit", "commit", "root"}),
|
|
19
|
+
)
|
diffnc/vendors/junos.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Junos hierarchical-form vendor parser.
|
|
2
|
+
|
|
3
|
+
Handles the curly-brace form produced by ``show configuration``. Documents are modelled as
|
|
4
|
+
a nested tree matching their brace structure, with same-named sibling blocks merged. The
|
|
5
|
+
``inactive:`` prefix is preserved verbatim on the node line (mirroring how NX-OS keeps
|
|
6
|
+
``no`` as part of its command line).
|
|
7
|
+
|
|
8
|
+
Block comments (``/* ... */``) and line comments (``#``, ``//`` to end of line) are
|
|
9
|
+
stripped during parsing.
|
|
10
|
+
|
|
11
|
+
The set form (``display set`` output) is handled by a separate vendor, see
|
|
12
|
+
:mod:`diffnc.vendors.junos_set`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Iterable, Iterator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from diffnc.errors import ParseError
|
|
23
|
+
from diffnc.ir import ConfigNode, ConfigTree
|
|
24
|
+
from diffnc.vendors.base import VendorParser, render_subtree
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from diffnc.reconcile import ReconcileEvent
|
|
28
|
+
|
|
29
|
+
INDENT_UNIT = 4
|
|
30
|
+
|
|
31
|
+
_BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _JunosParser:
|
|
36
|
+
name: str = "junos"
|
|
37
|
+
|
|
38
|
+
def parse(self, text: str) -> ConfigTree:
|
|
39
|
+
cleaned = _BLOCK_COMMENT.sub("", text)
|
|
40
|
+
tree = ConfigTree.empty(vendor=self.name)
|
|
41
|
+
stack: list[ConfigNode] = [tree.root]
|
|
42
|
+
|
|
43
|
+
for lineno, raw in enumerate(cleaned.splitlines(), start=1):
|
|
44
|
+
stripped = _strip_line_comment(raw).strip()
|
|
45
|
+
if not stripped:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
if stripped == "}":
|
|
49
|
+
if len(stack) <= 1:
|
|
50
|
+
raise ParseError(f"line {lineno}: unmatched '}}'")
|
|
51
|
+
stack.pop()
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if stripped.endswith("{"):
|
|
55
|
+
name = stripped[:-1].strip()
|
|
56
|
+
if not name:
|
|
57
|
+
raise ParseError(f"line {lineno}: anonymous block")
|
|
58
|
+
actual = stack[-1].add_child(ConfigNode(line=name))
|
|
59
|
+
stack.append(actual)
|
|
60
|
+
elif stripped.endswith(";"):
|
|
61
|
+
name = stripped[:-1].strip()
|
|
62
|
+
if name:
|
|
63
|
+
stack[-1].add_child(ConfigNode(line=name))
|
|
64
|
+
else:
|
|
65
|
+
raise ParseError(
|
|
66
|
+
f"line {lineno}: unterminated statement (expected ';' or '{{'): {stripped!r}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if len(stack) != 1:
|
|
70
|
+
raise ParseError("unclosed block at end of Junos hierarchical config")
|
|
71
|
+
|
|
72
|
+
return tree
|
|
73
|
+
|
|
74
|
+
def format(self, tree: ConfigTree) -> list[str]:
|
|
75
|
+
lines: list[str] = []
|
|
76
|
+
for child in tree.root.children:
|
|
77
|
+
lines.extend(render_subtree(self, child, depth=0))
|
|
78
|
+
return lines
|
|
79
|
+
|
|
80
|
+
def render_open(self, node: ConfigNode, depth: int) -> str:
|
|
81
|
+
return f"{_pad(depth)}{node.line} {{"
|
|
82
|
+
|
|
83
|
+
def render_close(self, node: ConfigNode, depth: int) -> str | None:
|
|
84
|
+
return f"{_pad(depth)}}}"
|
|
85
|
+
|
|
86
|
+
def render_leaf(self, node: ConfigNode, depth: int) -> str:
|
|
87
|
+
return f"{_pad(depth)}{node.line};"
|
|
88
|
+
|
|
89
|
+
def is_order_sensitive(self, path: tuple[str, ...]) -> bool:
|
|
90
|
+
"""Term order matters inside firewall filters and policy-statements.
|
|
91
|
+
|
|
92
|
+
Junos evaluates ``term`` entries top-to-bottom, so a reorder changes behaviour
|
|
93
|
+
even when the term bodies are identical. Everything else in a Junos config is
|
|
94
|
+
a (named) container where ordering is purely cosmetic.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
# firewall { filter <name> { term ... } }
|
|
98
|
+
if len(path) == 2 and path[0] == "firewall" and path[1].startswith("filter "):
|
|
99
|
+
return True
|
|
100
|
+
# firewall { family <fam> { filter <name> { term ... } } }
|
|
101
|
+
if (
|
|
102
|
+
len(path) == 3
|
|
103
|
+
and path[0] == "firewall"
|
|
104
|
+
and path[1].startswith("family ")
|
|
105
|
+
and path[2].startswith("filter ")
|
|
106
|
+
):
|
|
107
|
+
return True
|
|
108
|
+
# policy-options { policy-statement <name> { term ... } }
|
|
109
|
+
return (
|
|
110
|
+
len(path) == 2
|
|
111
|
+
and path[0] == "policy-options"
|
|
112
|
+
and path[1].startswith("policy-statement ")
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def render_reconcile(self, events: Iterable[ReconcileEvent]) -> Iterator[str]:
|
|
116
|
+
from diffnc.reconcile import ReconcileAdd, ReconcileDelete, ReconcileRecreate
|
|
117
|
+
|
|
118
|
+
for ev in events:
|
|
119
|
+
if isinstance(ev, ReconcileRecreate):
|
|
120
|
+
section = " ".join(ev.section_path)
|
|
121
|
+
yield f"delete {section}"
|
|
122
|
+
prefix = f"set {section} "
|
|
123
|
+
for child in ev.new_node.children:
|
|
124
|
+
yield from _walk_set(prefix, child)
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
base = " ".join(ev.parent_path)
|
|
128
|
+
base_with_sep = f"{base} " if base else ""
|
|
129
|
+
|
|
130
|
+
if isinstance(ev, ReconcileAdd):
|
|
131
|
+
yield from _walk_set(f"set {base_with_sep}", ev.node)
|
|
132
|
+
elif isinstance(ev, ReconcileDelete):
|
|
133
|
+
yield f"delete {base_with_sep}{ev.node.line}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _walk_set(prefix: str, node: ConfigNode) -> Iterator[str]:
|
|
137
|
+
"""Yield ``prefix + <leaf-path>`` for every leaf reachable from *node*.
|
|
138
|
+
|
|
139
|
+
Sections become an intermediate ``prefix + node.line + " "`` for their descendants.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
if node.is_leaf:
|
|
143
|
+
yield prefix + node.line
|
|
144
|
+
return
|
|
145
|
+
next_prefix = f"{prefix}{node.line} "
|
|
146
|
+
for child in node.children:
|
|
147
|
+
yield from _walk_set(next_prefix, child)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _strip_line_comment(line: str) -> str:
|
|
151
|
+
"""Truncate at the first ``#`` or ``//`` that lies outside a double-quoted string."""
|
|
152
|
+
|
|
153
|
+
in_quote = False
|
|
154
|
+
i = 0
|
|
155
|
+
n = len(line)
|
|
156
|
+
while i < n:
|
|
157
|
+
ch = line[i]
|
|
158
|
+
if ch == '"':
|
|
159
|
+
in_quote = not in_quote
|
|
160
|
+
i += 1
|
|
161
|
+
continue
|
|
162
|
+
if not in_quote:
|
|
163
|
+
if ch == "#":
|
|
164
|
+
return line[:i]
|
|
165
|
+
if ch == "/" and i + 1 < n and line[i + 1] == "/":
|
|
166
|
+
return line[:i]
|
|
167
|
+
i += 1
|
|
168
|
+
return line
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _pad(depth: int) -> str:
|
|
172
|
+
return " " * (INDENT_UNIT * depth)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
PARSER: VendorParser = _JunosParser()
|