acex-devkit 1.2.1__tar.gz → 1.3.1__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.
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/PKG-INFO +1 -1
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/pyproject.toml +1 -1
- acex_devkit-1.3.1/src/acex_devkit/configdiffer/configdiffer.py +211 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/acl_model.py +8 -4
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/attribute_value.py +8 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/composed_configuration.py +111 -80
- acex_devkit-1.3.1/src/acex_devkit/models/container_entry.py +42 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/logging.py +12 -5
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/spanning_tree.py +12 -6
- acex_devkit-1.2.1/src/acex_devkit/configdiffer/configdiffer.py +0 -158
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/README.md +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/__init__.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/__init__.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/command.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/diff.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/old_diff.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/drivers/__init__.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/drivers/base.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/drivers/base_driver.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/exceptions/__init__.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/__init__.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/external_value.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/ned.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/models/node_response.py +0 -0
- {acex_devkit-1.2.1 → acex_devkit-1.3.1}/src/acex_devkit/types/__init__.py +0 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from typing import Any, Dict, List, get_args
|
|
4
|
+
|
|
5
|
+
from acex_devkit.models.composed_configuration import ComposedConfiguration
|
|
6
|
+
from acex_devkit.models.attribute_value import AttributeValue
|
|
7
|
+
from acex_devkit.models.container_entry import ContainerEntry
|
|
8
|
+
from acex_devkit.configdiffer.diff import Diff, ComponentChange, AttributeChange, ComponentDiffOp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigDiffer:
|
|
12
|
+
|
|
13
|
+
def _is_leaf(self, model: BaseModel) -> bool:
|
|
14
|
+
"""A model is a leaf if it has at least one direct AttributeValue field."""
|
|
15
|
+
for field_info in model.model_fields.values():
|
|
16
|
+
ann = field_info.annotation
|
|
17
|
+
if isinstance(ann, type) and issubclass(ann, AttributeValue):
|
|
18
|
+
return True
|
|
19
|
+
for arg in get_args(ann):
|
|
20
|
+
if isinstance(arg, type) and issubclass(arg, AttributeValue):
|
|
21
|
+
return True
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
def _flatten(self, model: BaseModel, path: tuple = ()) -> Dict[tuple, BaseModel]:
|
|
25
|
+
"""
|
|
26
|
+
Recursively walk a model tree, collecting leaf nodes.
|
|
27
|
+
A leaf is any BaseModel that has direct AttributeValue fields.
|
|
28
|
+
A model can be both a leaf (stored) and have children (recursed into).
|
|
29
|
+
Returns a flat mapping of path → leaf instance.
|
|
30
|
+
"""
|
|
31
|
+
result = {}
|
|
32
|
+
|
|
33
|
+
# Store this model if it has AttributeValue fields (e.g. SystemConfig, NtpServer, NetworkInstance)
|
|
34
|
+
if self._is_leaf(model):
|
|
35
|
+
result[path] = model
|
|
36
|
+
|
|
37
|
+
# Walk each field declared on the model (e.g. System has config, aaa, logging, ntp, ssh, snmp)
|
|
38
|
+
for field_name in model.model_fields:
|
|
39
|
+
value = getattr(model, field_name, None)
|
|
40
|
+
if value is None:
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# Skip data fields — AttributeValue holds actual config values (e.g. hostname, enabled)
|
|
44
|
+
if isinstance(value, AttributeValue):
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
# Single nested model — recurse deeper (e.g. System.config → SystemConfig, System.ssh → Ssh)
|
|
48
|
+
if isinstance(value, BaseModel):
|
|
49
|
+
result.update(self._flatten(value, path + (field_name,)))
|
|
50
|
+
|
|
51
|
+
# Keyed collection — recurse into each entry (e.g. interfaces: Dict[str, Interface])
|
|
52
|
+
elif isinstance(value, dict):
|
|
53
|
+
for key, item in value.items():
|
|
54
|
+
# Each dict entry is a named component (e.g. "Gi0/0/1" → EthernetCsmacdInterface)
|
|
55
|
+
if isinstance(item, BaseModel):
|
|
56
|
+
result.update(self._flatten(item, path + (field_name, key)))
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _attribute_changes(self, before: BaseModel, after: BaseModel) -> List[AttributeChange]:
|
|
62
|
+
"""Compare two component instances and return a list of changed attributes."""
|
|
63
|
+
changes = []
|
|
64
|
+
all_fields = set(before.model_fields.keys()) | set(after.model_fields.keys())
|
|
65
|
+
for field_name in all_fields:
|
|
66
|
+
b = getattr(before, field_name, None)
|
|
67
|
+
a = getattr(after, field_name, None)
|
|
68
|
+
if b != a:
|
|
69
|
+
changes.append(AttributeChange(attribute_name=field_name, before=b, after=a))
|
|
70
|
+
return changes
|
|
71
|
+
|
|
72
|
+
def _identity_key(self, model: BaseModel) -> tuple:
|
|
73
|
+
"""Extract identity values from a ContainerEntry for matching.
|
|
74
|
+
Returns a tuple of raw values (unwrapped from AttributeValue) from identity_fields."""
|
|
75
|
+
fields = getattr(model, "identity_fields", ())
|
|
76
|
+
values = []
|
|
77
|
+
for f in fields:
|
|
78
|
+
v = getattr(model, f, None)
|
|
79
|
+
# Unwrap AttributeValue to its raw value for hashing
|
|
80
|
+
if isinstance(v, AttributeValue):
|
|
81
|
+
v = v.value
|
|
82
|
+
values.append(v)
|
|
83
|
+
return tuple(values)
|
|
84
|
+
|
|
85
|
+
def _make_change(self, op: ComponentDiffOp, path: tuple, before=None, after=None) -> ComponentChange:
|
|
86
|
+
"""Build a ComponentChange from an operation, path, and before/after models."""
|
|
87
|
+
obj = after or before
|
|
88
|
+
return ComponentChange(
|
|
89
|
+
op=op,
|
|
90
|
+
path=list(path),
|
|
91
|
+
component_type=type(obj),
|
|
92
|
+
component_name=path[-1] if path else "",
|
|
93
|
+
before=before,
|
|
94
|
+
after=after,
|
|
95
|
+
before_dict=before.model_dump() if before else None,
|
|
96
|
+
after_dict=after.model_dump() if after else None,
|
|
97
|
+
changed_attributes=self._attribute_changes(before, after) if (before and after) else [],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _diff_singletons(self, desired: Dict[tuple, BaseModel], observed: Dict[tuple, BaseModel]) -> List[ComponentChange]:
|
|
101
|
+
"""Diff leaves that are not ContainerEntry — matched by exact path."""
|
|
102
|
+
changes = []
|
|
103
|
+
all_paths = set(desired) | set(observed)
|
|
104
|
+
for path in all_paths:
|
|
105
|
+
d = desired.get(path)
|
|
106
|
+
o = observed.get(path)
|
|
107
|
+
if d and o:
|
|
108
|
+
if d != o:
|
|
109
|
+
changes.append(self._make_change(ComponentDiffOp.CHANGE, path, before=o, after=d))
|
|
110
|
+
elif d:
|
|
111
|
+
changes.append(self._make_change(ComponentDiffOp.ADD, path, after=d))
|
|
112
|
+
else:
|
|
113
|
+
changes.append(self._make_change(ComponentDiffOp.REMOVE, path, before=o))
|
|
114
|
+
return changes
|
|
115
|
+
|
|
116
|
+
def _diff_entries(self, desired: Dict[tuple, BaseModel], observed: Dict[tuple, BaseModel]) -> List[ComponentChange]:
|
|
117
|
+
"""Diff ContainerEntry leaves — matched by identity_fields within each container scope."""
|
|
118
|
+
changes = []
|
|
119
|
+
|
|
120
|
+
# Group by container path (parent = path[:-1]) so identity matching is scoped
|
|
121
|
+
desired_by_container: Dict[tuple, Dict[str, BaseModel]] = defaultdict(dict)
|
|
122
|
+
observed_by_container: Dict[tuple, Dict[str, BaseModel]] = defaultdict(dict)
|
|
123
|
+
|
|
124
|
+
for path, model in desired.items():
|
|
125
|
+
desired_by_container[path[:-1]][path[-1]] = model
|
|
126
|
+
for path, model in observed.items():
|
|
127
|
+
observed_by_container[path[:-1]][path[-1]] = model
|
|
128
|
+
|
|
129
|
+
all_containers = set(desired_by_container) | set(observed_by_container)
|
|
130
|
+
|
|
131
|
+
for container_path in all_containers:
|
|
132
|
+
d_components = desired_by_container.get(container_path, {})
|
|
133
|
+
o_components = observed_by_container.get(container_path, {})
|
|
134
|
+
|
|
135
|
+
# Build identity → (key, model) lookup for observed
|
|
136
|
+
o_by_identity: Dict[tuple, tuple[str, BaseModel]] = {}
|
|
137
|
+
o_by_key: Dict[str, BaseModel] = dict(o_components)
|
|
138
|
+
for key, model in o_components.items():
|
|
139
|
+
identity = self._identity_key(model)
|
|
140
|
+
if identity: # non-empty identity_fields — match by field values
|
|
141
|
+
o_by_identity[identity] = (key, model)
|
|
142
|
+
|
|
143
|
+
matched_o_keys = set()
|
|
144
|
+
|
|
145
|
+
for d_key, d_model in d_components.items():
|
|
146
|
+
d_identity = self._identity_key(d_model)
|
|
147
|
+
o_key, o_model = None, None
|
|
148
|
+
|
|
149
|
+
if d_identity:
|
|
150
|
+
# Match by identity field values (e.g. same address, same vlan_id)
|
|
151
|
+
match = o_by_identity.get(d_identity)
|
|
152
|
+
if match:
|
|
153
|
+
o_key, o_model = match
|
|
154
|
+
else:
|
|
155
|
+
# identity_fields = () — fall back to dict key matching
|
|
156
|
+
if d_key in o_by_key:
|
|
157
|
+
o_key, o_model = d_key, o_by_key[d_key]
|
|
158
|
+
|
|
159
|
+
if o_model:
|
|
160
|
+
matched_o_keys.add(o_key)
|
|
161
|
+
if d_model != o_model:
|
|
162
|
+
changes.append(self._make_change(
|
|
163
|
+
ComponentDiffOp.CHANGE, container_path + (d_key,),
|
|
164
|
+
before=o_model, after=d_model,
|
|
165
|
+
))
|
|
166
|
+
else:
|
|
167
|
+
changes.append(self._make_change(
|
|
168
|
+
ComponentDiffOp.ADD, container_path + (d_key,),
|
|
169
|
+
after=d_model,
|
|
170
|
+
))
|
|
171
|
+
|
|
172
|
+
# Remaining observed entries that weren't matched → removed
|
|
173
|
+
for o_key, o_model in o_components.items():
|
|
174
|
+
if o_key not in matched_o_keys:
|
|
175
|
+
changes.append(self._make_change(
|
|
176
|
+
ComponentDiffOp.REMOVE, container_path + (o_key,),
|
|
177
|
+
before=o_model,
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
return changes
|
|
181
|
+
|
|
182
|
+
def diff(self, *, desired_config: ComposedConfiguration, observed_config: ComposedConfiguration) -> Diff:
|
|
183
|
+
"""
|
|
184
|
+
Compare two ComposedConfiguration objects and return a component-based diff.
|
|
185
|
+
|
|
186
|
+
Singletons (fixed-path leaves like SystemConfig) are matched by path.
|
|
187
|
+
ContainerEntry leaves (dict entries like interfaces) are matched by identity_fields,
|
|
188
|
+
handling cases where keys differ (e.g. "Gi0/0/1" vs "GigabitEthernet0/0/1").
|
|
189
|
+
"""
|
|
190
|
+
flat_desired = self._flatten(desired_config)
|
|
191
|
+
flat_observed = self._flatten(observed_config)
|
|
192
|
+
|
|
193
|
+
# Split into singletons (path-matched) and entries (identity-matched)
|
|
194
|
+
singleton_d = {p: m for p, m in flat_desired.items() if not isinstance(m, ContainerEntry)}
|
|
195
|
+
singleton_o = {p: m for p, m in flat_observed.items() if not isinstance(m, ContainerEntry)}
|
|
196
|
+
entry_d = {p: m for p, m in flat_desired.items() if isinstance(m, ContainerEntry)}
|
|
197
|
+
entry_o = {p: m for p, m in flat_observed.items() if isinstance(m, ContainerEntry)}
|
|
198
|
+
|
|
199
|
+
all_changes = self._diff_singletons(singleton_d, singleton_o) + self._diff_entries(entry_d, entry_o)
|
|
200
|
+
|
|
201
|
+
added = [c for c in all_changes if c.op == ComponentDiffOp.ADD]
|
|
202
|
+
removed = [c for c in all_changes if c.op == ComponentDiffOp.REMOVE]
|
|
203
|
+
changed = [c for c in all_changes if c.op == ComponentDiffOp.CHANGE]
|
|
204
|
+
|
|
205
|
+
return Diff(
|
|
206
|
+
added=added,
|
|
207
|
+
removed=removed,
|
|
208
|
+
changed=changed,
|
|
209
|
+
total_desired=len(flat_desired),
|
|
210
|
+
total_observed=len(flat_observed),
|
|
211
|
+
)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from pydantic import BaseModel
|
|
2
2
|
from ipaddress import IPv4Network, IPv6Network
|
|
3
3
|
from acex_devkit.models.attribute_value import AttributeValue
|
|
4
|
+
from acex_devkit.models.container_entry import ContainerEntry
|
|
4
5
|
from enum import Enum
|
|
5
|
-
from typing import Optional, Dict, Literal
|
|
6
|
+
from typing import ClassVar, Optional, Dict, Literal
|
|
6
7
|
|
|
7
8
|
class AclProtoclOptions(str, Enum):
|
|
8
9
|
TCP = 'tcp'
|
|
@@ -12,7 +13,8 @@ class AclProtoclOptions(str, Enum):
|
|
|
12
13
|
IP = 'ip'
|
|
13
14
|
ANY = 'any'
|
|
14
15
|
|
|
15
|
-
class IpAclOptions(BaseModel):
|
|
16
|
+
class IpAclOptions(ContainerEntry, BaseModel):
|
|
17
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("sequence_id",)
|
|
16
18
|
description: Optional[AttributeValue[str]] = None
|
|
17
19
|
source_port: Optional[AttributeValue[str]] = None # e.g., "100-200"
|
|
18
20
|
destination_port: Optional[AttributeValue[str]] = None # e.g., "300-400"
|
|
@@ -64,12 +66,14 @@ class Ipv6AclEntryAttributes(IpAclOptions):
|
|
|
64
66
|
destination_address: Optional[AttributeValue[IPv6Network | Literal['any']]] = None # Destination IPv6 address prefix.
|
|
65
67
|
ipv6_acl: Optional[AttributeValue[str]] = None # Reference to the ACL Set this entry belongs to.
|
|
66
68
|
|
|
67
|
-
class Ipv4AclAttributes(BaseModel):
|
|
69
|
+
class Ipv4AclAttributes(ContainerEntry, BaseModel):
|
|
70
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("name",)
|
|
68
71
|
name: AttributeValue[str]
|
|
69
72
|
type: AttributeValue[str] = "ipv4_acl"
|
|
70
73
|
acl_entries: Optional[Dict[str, Ipv4AclEntryAttributes]] = None
|
|
71
74
|
|
|
72
|
-
class Ipv6AclAttributes(BaseModel):
|
|
75
|
+
class Ipv6AclAttributes(ContainerEntry, BaseModel):
|
|
76
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("name",)
|
|
73
77
|
name: AttributeValue[str]
|
|
74
78
|
type: AttributeValue[str] = "ipv6_acl"
|
|
75
79
|
acl_entries: Optional[Dict[str, Ipv6AclEntryAttributes]] = None
|
|
@@ -113,6 +113,14 @@ class AttributeValue(BaseModel, Generic[T]):
|
|
|
113
113
|
return str(value)
|
|
114
114
|
return value
|
|
115
115
|
|
|
116
|
+
def __eq__(self, other):
|
|
117
|
+
if not isinstance(other, AttributeValue):
|
|
118
|
+
return NotImplemented
|
|
119
|
+
return self.value == other.value
|
|
120
|
+
|
|
121
|
+
def __hash__(self):
|
|
122
|
+
return hash(self.value)
|
|
123
|
+
|
|
116
124
|
# Convenience helpers
|
|
117
125
|
def is_external(self) -> bool:
|
|
118
126
|
return isinstance(self.value, ExternalValue)
|
|
@@ -5,10 +5,11 @@ from enum import Enum
|
|
|
5
5
|
|
|
6
6
|
from acex_devkit.models.external_value import ExternalValue
|
|
7
7
|
from acex_devkit.models.attribute_value import AttributeValue
|
|
8
|
+
from acex_devkit.models.container_entry import ContainerEntry
|
|
8
9
|
from acex_devkit.models.logging import (
|
|
9
10
|
LoggingConfig,
|
|
10
11
|
Console,
|
|
11
|
-
|
|
12
|
+
RemoteServers,
|
|
12
13
|
VtyLine,
|
|
13
14
|
FileLogging,
|
|
14
15
|
LoggingEvents
|
|
@@ -25,7 +26,8 @@ class Metadata(BaseModel):
|
|
|
25
26
|
type: Optional[str] = "str"
|
|
26
27
|
value_source: MetadataValueType = MetadataValueType.CONCRETE
|
|
27
28
|
|
|
28
|
-
class Reference(BaseModel):
|
|
29
|
+
class Reference(ContainerEntry, BaseModel):
|
|
30
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("pointer",)
|
|
29
31
|
pointer: str
|
|
30
32
|
metadata: Metadata = Metadata(type="str", value_source="reference")
|
|
31
33
|
|
|
@@ -50,20 +52,27 @@ class SystemConfig(BaseModel):
|
|
|
50
52
|
motd_banner: Optional[AttributeValue[str]] = None
|
|
51
53
|
|
|
52
54
|
#class TripleA(BaseModel): ...
|
|
53
|
-
|
|
54
55
|
# Trying to avoid using "Logging" or "logging" as names for anything due to conflicts with standard lib.
|
|
55
|
-
|
|
56
|
+
|
|
57
|
+
class VtyLines(BaseModel):
|
|
58
|
+
lines: Dict[str, VtyLine] = {}
|
|
59
|
+
|
|
60
|
+
class LogFiles(BaseModel):
|
|
61
|
+
files: Dict[str, FileLogging] = {}
|
|
62
|
+
|
|
63
|
+
class LoggingComponents(BaseModel):
|
|
56
64
|
config: LoggingConfig = LoggingConfig()
|
|
57
65
|
console: Optional[Console] = None
|
|
58
|
-
remote_servers: Optional[
|
|
66
|
+
remote_servers: Optional[RemoteServers] = RemoteServers()
|
|
59
67
|
events: Optional[LoggingEvents] = None
|
|
60
|
-
vty: Optional[
|
|
61
|
-
files: Optional[
|
|
68
|
+
vty: Optional[VtyLines] = VtyLines()
|
|
69
|
+
files: Optional[LogFiles] = LogFiles()
|
|
62
70
|
|
|
63
71
|
class NtpConfig(BaseModel):
|
|
64
72
|
enabled: AttributeValue[bool] = AttributeValue(value=False)
|
|
65
73
|
|
|
66
|
-
class NtpServer(BaseModel):
|
|
74
|
+
class NtpServer(ContainerEntry, BaseModel):
|
|
75
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("address",)
|
|
67
76
|
address: AttributeValue[str]
|
|
68
77
|
port: Optional[AttributeValue[int]] = None
|
|
69
78
|
version: Optional[AttributeValue[int]] = None
|
|
@@ -73,7 +82,7 @@ class NtpServer(BaseModel):
|
|
|
73
82
|
|
|
74
83
|
class Ntp(BaseModel):
|
|
75
84
|
config: Optional[NtpConfig] = None
|
|
76
|
-
servers: Optional[Dict[str, NtpServer]] =
|
|
85
|
+
servers: Optional[Dict[str, NtpServer]] = {}
|
|
77
86
|
|
|
78
87
|
class SshServer(BaseModel):
|
|
79
88
|
enable: Optional[AttributeValue[bool]] = None
|
|
@@ -94,25 +103,27 @@ class AuthorizedKeyAlgorithms(str, Enum):
|
|
|
94
103
|
SSH_RSA = "ssh-rsa"
|
|
95
104
|
SSH_DSS = "ssh-dss"
|
|
96
105
|
|
|
97
|
-
class AuthorizedKey(BaseModel):
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
class AuthorizedKey(ContainerEntry, BaseModel):
|
|
107
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("public_key",)
|
|
108
|
+
algorithm: Optional[AttributeValue[AuthorizedKeyAlgorithms]] = None
|
|
109
|
+
public_key: Optional[AttributeValue[str]] = None
|
|
100
110
|
|
|
101
|
-
class Ssh(BaseModel):
|
|
111
|
+
class Ssh(BaseModel):
|
|
102
112
|
config: Optional[SshServer] = None
|
|
103
|
-
host_keys: Optional[Dict[str, AuthorizedKey]] =
|
|
113
|
+
host_keys: Optional[Dict[str, AuthorizedKey]] = {}
|
|
104
114
|
|
|
105
115
|
class Lldp(BaseModel): ...
|
|
106
116
|
|
|
107
|
-
class Vlan(BaseModel):
|
|
117
|
+
class Vlan(ContainerEntry, BaseModel):
|
|
118
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("vlan_id",)
|
|
108
119
|
name: AttributeValue[str]
|
|
109
120
|
vlan_id: Optional[AttributeValue[int]] = None
|
|
110
121
|
vlan_name: Optional[AttributeValue[str]] = None
|
|
111
122
|
network_instance: Optional[AttributeValue[str]] = None
|
|
112
|
-
metadata: Optional[Metadata] = Metadata()
|
|
113
123
|
|
|
114
|
-
class Interface(BaseModel):
|
|
124
|
+
class Interface(ContainerEntry, BaseModel):
|
|
115
125
|
"Base class for all interfaces"
|
|
126
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("index", "type")
|
|
116
127
|
index: AttributeValue[int]
|
|
117
128
|
name: AttributeValue[str]
|
|
118
129
|
|
|
@@ -120,7 +131,6 @@ class Interface(BaseModel):
|
|
|
120
131
|
enabled: Optional[AttributeValue[bool]] = None
|
|
121
132
|
ipv4: Optional[AttributeValue[str]] = None
|
|
122
133
|
|
|
123
|
-
metadata: Optional[Metadata] = Metadata()
|
|
124
134
|
type: Literal[
|
|
125
135
|
"ethernetCsmacd",
|
|
126
136
|
"ieee8023adLag",
|
|
@@ -190,21 +200,21 @@ class Ieee8023adLagInterface(Interface):
|
|
|
190
200
|
class L3IpvlanInterface(Interface):
|
|
191
201
|
"SVI Interface"
|
|
192
202
|
type: Literal["l3ipvlan"] = "l3ipvlan"
|
|
193
|
-
vlan_id: Optional[int] = None
|
|
203
|
+
vlan_id: Optional[AttributeValue[int]] = None
|
|
194
204
|
|
|
195
205
|
class SoftwareLoopbackInterface(Interface):
|
|
196
206
|
"Loopback Interface"
|
|
197
207
|
type: Literal["softwareLoopback"] = "softwareLoopback"
|
|
198
208
|
|
|
199
209
|
# Loopback har varken vlan, duplex eller speed
|
|
200
|
-
vlan_id: Optional[int] = None
|
|
210
|
+
vlan_id: Optional[AttributeValue[int]] = None
|
|
201
211
|
ipv4: Optional[AttributeValue[str]] = None
|
|
202
212
|
|
|
203
213
|
class SubInterface(Interface):
|
|
204
214
|
"Subinterface"
|
|
205
215
|
type: Literal["subinterface"] = "subinterface"
|
|
206
216
|
|
|
207
|
-
vlan_id: Optional[int] = None
|
|
217
|
+
vlan_id: Optional[AttributeValue[int]] = None
|
|
208
218
|
ipv4: Optional[AttributeValue[str]] = None
|
|
209
219
|
|
|
210
220
|
class ManagementInterface(Interface):
|
|
@@ -212,9 +222,10 @@ class ManagementInterface(Interface):
|
|
|
212
222
|
type: Literal["managementInterface"] = "managementInterface"
|
|
213
223
|
|
|
214
224
|
# Mgmt har inte vlan
|
|
215
|
-
vlan_id: Optional[int] = None
|
|
225
|
+
vlan_id: Optional[AttributeValue[int]] = None
|
|
216
226
|
|
|
217
|
-
class StaticRouteNextHop(BaseModel):
|
|
227
|
+
class StaticRouteNextHop(ContainerEntry, BaseModel):
|
|
228
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("next_hop",)
|
|
218
229
|
index: Optional[AttributeValue[int]] = None
|
|
219
230
|
next_hop: AttributeValue[str] # can be an IP address or an interface. Reference will be handled in config component
|
|
220
231
|
metric: Optional[AttributeValue[int]] = None
|
|
@@ -222,14 +233,15 @@ class StaticRouteNextHop(BaseModel):
|
|
|
222
233
|
network_instance: Optional[AttributeValue[str]] = None
|
|
223
234
|
|
|
224
235
|
|
|
225
|
-
class StaticRoute(BaseModel):
|
|
236
|
+
class StaticRoute(ContainerEntry, BaseModel):
|
|
237
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("prefix",)
|
|
226
238
|
route_name: Optional[AttributeValue[str]] = None
|
|
227
239
|
prefix: AttributeValue[str]
|
|
228
|
-
next_hops: Optional[Dict[str, StaticRouteNextHop]] =
|
|
240
|
+
next_hops: Optional[Dict[str, StaticRouteNextHop]] = {}
|
|
229
241
|
network_instance: Optional[AttributeValue[str]] = None
|
|
230
242
|
|
|
231
243
|
class Protocols(BaseModel):
|
|
232
|
-
static_routes: Optional[Dict[str, StaticRoute]] =
|
|
244
|
+
static_routes: Optional[Dict[str, StaticRoute]] = {}
|
|
233
245
|
# OSPF, BGP, etc. can be added here as needed
|
|
234
246
|
|
|
235
247
|
class RouteTarget(BaseModel):
|
|
@@ -239,15 +251,17 @@ class ImportExportPolicy(BaseModel):
|
|
|
239
251
|
export_route_target: Optional[List[RouteTarget]] = None
|
|
240
252
|
import_route_target: Optional[List[RouteTarget]] = None
|
|
241
253
|
|
|
242
|
-
class InterInstancePolicy(BaseModel):
|
|
254
|
+
class InterInstancePolicy(ContainerEntry, BaseModel):
|
|
255
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
243
256
|
import_export_policy: ImportExportPolicy
|
|
244
257
|
|
|
245
|
-
class NetworkInstance(BaseModel):
|
|
258
|
+
class NetworkInstance(ContainerEntry, BaseModel):
|
|
259
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("name",)
|
|
246
260
|
name: AttributeValue[str]
|
|
247
261
|
description: Optional[AttributeValue[str]] = None
|
|
248
|
-
vlans: Optional[Dict[str, Vlan]] =
|
|
262
|
+
vlans: Optional[Dict[str, Vlan]] = {}
|
|
249
263
|
interfaces: Optional[Dict[str, Reference]] = {}
|
|
250
|
-
inter_instance_policies: Optional[Dict[str, InterInstancePolicy]] =
|
|
264
|
+
inter_instance_policies: Optional[Dict[str, InterInstancePolicy]] = {}
|
|
251
265
|
protocols: Optional[Protocols] = Protocols()
|
|
252
266
|
|
|
253
267
|
class LacpConfig(BaseModel):
|
|
@@ -257,7 +271,7 @@ class LacpConfig(BaseModel):
|
|
|
257
271
|
|
|
258
272
|
class Lacp(BaseModel):
|
|
259
273
|
config: Optional[LacpConfig] = LacpConfig()
|
|
260
|
-
interfaces: Optional[Dict[str, Interface]] =
|
|
274
|
+
interfaces: Optional[Dict[str, Interface]] = {}
|
|
261
275
|
|
|
262
276
|
# SNMP
|
|
263
277
|
class SnmpAccess(str, Enum):
|
|
@@ -288,14 +302,16 @@ class SnmpPrivProtocol(str, Enum):
|
|
|
288
302
|
AES256 = "AES256"
|
|
289
303
|
|
|
290
304
|
|
|
291
|
-
class SnmpConfig(BaseModel):
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
305
|
+
class SnmpConfig(ContainerEntry, BaseModel):
|
|
306
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
307
|
+
enabled: AttributeValue[bool] = AttributeValue(value=False)
|
|
308
|
+
engine_id: Optional[AttributeValue[str]] = None
|
|
309
|
+
location: Optional[AttributeValue[str]] = None
|
|
310
|
+
contact: Optional[AttributeValue[str]] = None
|
|
296
311
|
|
|
297
312
|
|
|
298
|
-
class SnmpCommunity(BaseModel):
|
|
313
|
+
class SnmpCommunity(ContainerEntry, BaseModel):
|
|
314
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("name",)
|
|
299
315
|
name: AttributeValue[str]
|
|
300
316
|
community: Optional[AttributeValue[str]] = None # Community string
|
|
301
317
|
access: Optional[AttributeValue[SnmpAccess]] = AttributeValue(value=SnmpAccess.READ_ONLY)
|
|
@@ -306,22 +322,25 @@ class SnmpCommunity(BaseModel):
|
|
|
306
322
|
clients: Optional[AttributeValue[List[str]]] = None # Juniper specific
|
|
307
323
|
|
|
308
324
|
|
|
309
|
-
class SnmpUser(BaseModel):
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
325
|
+
class SnmpUser(ContainerEntry, BaseModel):
|
|
326
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("username",)
|
|
327
|
+
username: AttributeValue[str]
|
|
328
|
+
security_level: Optional[AttributeValue[SnmpSecurityLevel]] = AttributeValue(value=SnmpSecurityLevel.NO_AUTH_NO_PRIV)
|
|
329
|
+
auth_protocol: Optional[AttributeValue[SnmpAuthProtocol]] = None
|
|
330
|
+
auth_password: Optional[AttributeValue[str]] = None
|
|
331
|
+
priv_protocol: Optional[AttributeValue[SnmpPrivProtocol]] = None
|
|
332
|
+
priv_password: Optional[AttributeValue[str]] = None
|
|
316
333
|
|
|
317
334
|
|
|
318
|
-
class SnmpView(BaseModel):
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
335
|
+
class SnmpView(ContainerEntry, BaseModel):
|
|
336
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("name",)
|
|
337
|
+
name: AttributeValue[str]
|
|
338
|
+
oid: AttributeValue[str]
|
|
339
|
+
included: Optional[AttributeValue[bool]] = AttributeValue(value=True)
|
|
322
340
|
|
|
323
341
|
|
|
324
|
-
class SnmpServer(BaseModel):
|
|
342
|
+
class SnmpServer(ContainerEntry, BaseModel):
|
|
343
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("address",)
|
|
325
344
|
name: Optional[AttributeValue[str]] = None
|
|
326
345
|
address: AttributeValue[str]
|
|
327
346
|
port: Optional[AttributeValue[int]] = AttributeValue(value=162)
|
|
@@ -448,25 +467,28 @@ class TrapEventOptions(str, Enum):
|
|
|
448
467
|
BULKSTAT_COLLECTION = "bulkstat_collection"
|
|
449
468
|
BULKSTAT_TRANSFER = "bulkstat_transfer"
|
|
450
469
|
|
|
451
|
-
class TrapEvent(BaseModel):
|
|
470
|
+
class TrapEvent(ContainerEntry, BaseModel):
|
|
471
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("event_name",)
|
|
452
472
|
name: Optional[AttributeValue[str]] = None
|
|
453
473
|
event_name: Optional[AttributeValue[TrapEventOptions]] = None
|
|
454
474
|
|
|
455
475
|
#class SnmpTrap(BaseModel): ...
|
|
456
476
|
|
|
457
477
|
class Snmp(BaseModel):
|
|
458
|
-
config: Optional[Dict[str, SnmpConfig]] =
|
|
459
|
-
communities: Optional[Dict[str, SnmpCommunity]] =
|
|
460
|
-
users: Optional[Dict[str, SnmpUser]] =
|
|
461
|
-
trap_servers: Optional[Dict[str, SnmpServer]] =
|
|
462
|
-
trap_events: Optional[Dict[str, TrapEvent]] =
|
|
463
|
-
views: Optional[Dict[str, SnmpView]] =
|
|
478
|
+
config: Optional[Dict[str, SnmpConfig]] = {}
|
|
479
|
+
communities: Optional[Dict[str, SnmpCommunity]] = {}
|
|
480
|
+
users: Optional[Dict[str, SnmpUser]] = {}
|
|
481
|
+
trap_servers: Optional[Dict[str, SnmpServer]] = {}
|
|
482
|
+
trap_events: Optional[Dict[str, TrapEvent]] = {}
|
|
483
|
+
views: Optional[Dict[str, SnmpView]] = {}
|
|
464
484
|
|
|
465
485
|
# AAA
|
|
466
|
-
class aaaBaseClass(BaseModel):
|
|
486
|
+
class aaaBaseClass(ContainerEntry, BaseModel):
|
|
487
|
+
identity_fields: ClassVar[tuple[str, ...]] = () # key is identity (e.g. "default", "console")
|
|
467
488
|
name: Optional[AttributeValue[str]] = None
|
|
468
489
|
|
|
469
|
-
class aaaTacacsAttributes(BaseModel):
|
|
490
|
+
class aaaTacacsAttributes(ContainerEntry, BaseModel):
|
|
491
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("address",)
|
|
470
492
|
port: Optional[AttributeValue[int]] = None
|
|
471
493
|
secret_key: Optional[AttributeValue[str]] = None
|
|
472
494
|
secret_key_hashed: Optional[AttributeValue[str]] = None
|
|
@@ -475,7 +497,8 @@ class aaaTacacsAttributes(BaseModel):
|
|
|
475
497
|
source_interface: Optional[Reference] = None
|
|
476
498
|
server_group: Optional[AttributeValue[str]] = None
|
|
477
499
|
|
|
478
|
-
class aaaRadiusAttributes(BaseModel):
|
|
500
|
+
class aaaRadiusAttributes(ContainerEntry, BaseModel):
|
|
501
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("address",)
|
|
479
502
|
auth_port: Optional[AttributeValue[int]] = None
|
|
480
503
|
acct_port: Optional[AttributeValue[int]] = None
|
|
481
504
|
secret_key: Optional[AttributeValue[str]] = None
|
|
@@ -486,7 +509,8 @@ class aaaRadiusAttributes(BaseModel):
|
|
|
486
509
|
retransmit_attempts: Optional[AttributeValue[int]] = None
|
|
487
510
|
server_group: Optional[AttributeValue[str]] = None
|
|
488
511
|
|
|
489
|
-
class aaaServerGroupAttributes(BaseModel):
|
|
512
|
+
class aaaServerGroupAttributes(ContainerEntry, BaseModel):
|
|
513
|
+
identity_fields: ClassVar[tuple[str, ...]] = () # key is the group name
|
|
490
514
|
"""
|
|
491
515
|
Define a AAA server group that can contain multiple TACACS+ and/or RADIUS servers.
|
|
492
516
|
|
|
@@ -509,8 +533,8 @@ class aaaServerGroupAttributes(BaseModel):
|
|
|
509
533
|
"""
|
|
510
534
|
enable: Optional[AttributeValue[bool]] = None
|
|
511
535
|
type: Optional[AttributeValue[Literal['tacacs','radius']]] = None
|
|
512
|
-
tacacs: Optional[Dict[str, aaaTacacsAttributes]] =
|
|
513
|
-
radius: Optional[Dict[str, aaaRadiusAttributes]] =
|
|
536
|
+
tacacs: Optional[Dict[str, aaaTacacsAttributes]] = {}
|
|
537
|
+
radius: Optional[Dict[str, aaaRadiusAttributes]] = {}
|
|
514
538
|
|
|
515
539
|
# Authentication Models
|
|
516
540
|
class aaaAuthenticationMethods(aaaBaseClass):
|
|
@@ -533,6 +557,7 @@ class aaaAuthenticationMethods(aaaBaseClass):
|
|
|
533
557
|
# aaa authentication enable default group TACACS-GROUP-NEW enable
|
|
534
558
|
|
|
535
559
|
class authenticationUser(aaaBaseClass):
|
|
560
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("username",)
|
|
536
561
|
username: Optional[AttributeValue[str]] = None
|
|
537
562
|
password: Optional[AttributeValue[str]] = None
|
|
538
563
|
password_hahsed: Optional[AttributeValue[str]] = None
|
|
@@ -540,19 +565,20 @@ class authenticationUser(aaaBaseClass):
|
|
|
540
565
|
role: Optional[AttributeValue[str]] = None
|
|
541
566
|
|
|
542
567
|
class aaaAuthenticationUsers(aaaBaseClass):
|
|
543
|
-
username: Optional[Dict[str, authenticationUser]] =
|
|
568
|
+
username: Optional[Dict[str, authenticationUser]] = {}
|
|
544
569
|
|
|
545
570
|
class adminUser(aaaBaseClass):
|
|
546
571
|
admin_password: Optional[AttributeValue[str]] = None
|
|
547
572
|
admin_password_hashed: Optional[AttributeValue[str]] = None
|
|
548
573
|
|
|
549
|
-
class aaaAuthenticationAdminUsers(BaseModel):
|
|
550
|
-
|
|
574
|
+
class aaaAuthenticationAdminUsers(ContainerEntry, BaseModel):
|
|
575
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
576
|
+
config: Optional[Dict[str, adminUser]] = {}
|
|
551
577
|
|
|
552
578
|
class aaaAuthentication(BaseModel):
|
|
553
|
-
config: Optional[Dict[str, aaaAuthenticationMethods]] =
|
|
554
|
-
admin_user: Optional[Dict[str, aaaAuthenticationAdminUsers]] =
|
|
555
|
-
users: Optional[Dict[str, aaaAuthenticationUsers]] =
|
|
579
|
+
config: Optional[Dict[str, aaaAuthenticationMethods]] = {}
|
|
580
|
+
admin_user: Optional[Dict[str, aaaAuthenticationAdminUsers]] = {}
|
|
581
|
+
users: Optional[Dict[str, aaaAuthenticationUsers]] = {}
|
|
556
582
|
|
|
557
583
|
# Authorization Models
|
|
558
584
|
class aaaAuthorizationMethods(aaaBaseClass):
|
|
@@ -582,13 +608,14 @@ class aaaAuthorizationEvents(aaaBaseClass):
|
|
|
582
608
|
event: Optional[AttributeValue[str]] = None
|
|
583
609
|
|
|
584
610
|
class aaaAuthorization(BaseModel):
|
|
585
|
-
config: Optional[Dict[str, aaaAuthorizationMethods]] =
|
|
586
|
-
events: Optional[Dict[str, aaaAuthorizationEvents]] =
|
|
611
|
+
config: Optional[Dict[str, aaaAuthorizationMethods]] = {}
|
|
612
|
+
events: Optional[Dict[str, aaaAuthorizationEvents]] = {}
|
|
587
613
|
|
|
588
614
|
# Accounting Models
|
|
589
|
-
class aaaAccountingMethods(BaseModel):
|
|
615
|
+
class aaaAccountingMethods(ContainerEntry, BaseModel):
|
|
616
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
590
617
|
"""
|
|
591
|
-
Define the accounting methods used by AAA. If you define a server group using the "aaaServerGroup" model,
|
|
618
|
+
Define the accounting methods used by AAA. If you define a server group using the "aaaServerGroup" model,
|
|
592
619
|
you can reference it here by its name, but only as a string.
|
|
593
620
|
|
|
594
621
|
Example in config map:
|
|
@@ -600,10 +627,11 @@ class aaaAccountingMethods(BaseModel):
|
|
|
600
627
|
#method: Optional[List[str]] = None # Ex. ['TACACS_GROUP','LOCAL']
|
|
601
628
|
method: Optional[AttributeValue[str]] = None
|
|
602
629
|
|
|
603
|
-
class aaaAccountingEvents(BaseModel):
|
|
630
|
+
class aaaAccountingEvents(ContainerEntry, BaseModel):
|
|
631
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
604
632
|
"""
|
|
605
633
|
Define accounting events.
|
|
606
|
-
|
|
634
|
+
|
|
607
635
|
Cisco example:
|
|
608
636
|
aaa accounting send stop-record authentication failure
|
|
609
637
|
"""
|
|
@@ -611,15 +639,15 @@ class aaaAccountingEvents(BaseModel):
|
|
|
611
639
|
event: Optional[AttributeValue[str]] = None
|
|
612
640
|
|
|
613
641
|
class aaaAccounting(BaseModel):
|
|
614
|
-
config: Optional[Dict[str, aaaAccountingMethods]] =
|
|
615
|
-
events: Optional[Dict[str, aaaAccountingEvents]] =
|
|
642
|
+
config: Optional[Dict[str, aaaAccountingMethods]] = {}
|
|
643
|
+
events: Optional[Dict[str, aaaAccountingEvents]] = {}
|
|
616
644
|
|
|
617
645
|
class aaaGlobalAttributes(BaseModel):
|
|
618
646
|
enabled: Optional[AttributeValue[bool]] = False # default False
|
|
619
647
|
|
|
620
648
|
class TripleA(BaseModel):
|
|
621
649
|
config: aaaGlobalAttributes = aaaGlobalAttributes()
|
|
622
|
-
server_groups: Optional[Dict[str, aaaServerGroupAttributes]] =
|
|
650
|
+
server_groups: Optional[Dict[str, aaaServerGroupAttributes]] = {}
|
|
623
651
|
authentication: aaaAuthentication = aaaAuthentication()
|
|
624
652
|
authorization: aaaAuthorization = aaaAuthorization()
|
|
625
653
|
accounting: aaaAccounting = aaaAccounting()
|
|
@@ -652,11 +680,14 @@ class ComposedConfiguration(BaseModel):
|
|
|
652
680
|
stp: Optional[SpanningTree] = SpanningTree()
|
|
653
681
|
|
|
654
682
|
|
|
655
|
-
"""
|
|
683
|
+
"""
|
|
656
684
|
GUIDELINES FOR COMPOSED CONFIGURATION:
|
|
657
685
|
|
|
658
686
|
1. All values must always be typed as AttributeValue.
|
|
659
687
|
2. Containers must always be defined as hierarchical pydantic types, no dicts as placeholders.
|
|
660
|
-
3. Component collections
|
|
661
|
-
|
|
688
|
+
3. Component collections must use a typed container class (e.g. RemoteServers, VtyLines) with an inner
|
|
689
|
+
Dict[str, BaseModel] field — the key identifies the component. Raw Dict fields are not allowed at
|
|
690
|
+
the container level.
|
|
691
|
+
4. Default values for Optional containers are always an empty instance of the container class, e.g.
|
|
692
|
+
Optional[RemoteServers] = RemoteServers(). Never default to None or a raw empty dict for collections.
|
|
662
693
|
"""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import ClassVar
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ContainerEntry:
|
|
5
|
+
"""
|
|
6
|
+
Mixin for Pydantic models that are used as values in Dict[str, X] container fields.
|
|
7
|
+
|
|
8
|
+
Every subclass MUST declare `identity_fields` as a ClassVar[tuple[str, ...]]:
|
|
9
|
+
- Non-empty tuple: fields whose values uniquely identify this object.
|
|
10
|
+
The differ will match objects across desired/observed by these field values,
|
|
11
|
+
ignoring the dict key entirely.
|
|
12
|
+
- Empty tuple `()`: the dict key itself is the identity (key-based matching).
|
|
13
|
+
Use this for singleton-like entries (e.g., "global", "default") where no
|
|
14
|
+
field inside the object acts as a natural identifier.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
class NtpServer(ContainerEntry, BaseModel):
|
|
18
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("address",)
|
|
19
|
+
address: AttributeValue[str]
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
class SpanningTreeGlobalConfig(ContainerEntry, BaseModel):
|
|
23
|
+
identity_fields: ClassVar[tuple[str, ...]] = () # key is identity
|
|
24
|
+
mode: Optional[AttributeValue[str]] = None
|
|
25
|
+
...
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
identity_fields: ClassVar[tuple[str, ...]]
|
|
29
|
+
|
|
30
|
+
def __init_subclass__(cls, **kwargs):
|
|
31
|
+
super().__init_subclass__(**kwargs)
|
|
32
|
+
has_identity_fields = any(
|
|
33
|
+
"identity_fields" in c.__dict__
|
|
34
|
+
for c in cls.__mro__
|
|
35
|
+
if c is not ContainerEntry
|
|
36
|
+
)
|
|
37
|
+
if not has_identity_fields:
|
|
38
|
+
raise TypeError(
|
|
39
|
+
f"{cls.__name__} must declare 'identity_fields' as a ClassVar. "
|
|
40
|
+
f"Use identity_fields = ('field_name',) to match by field value, "
|
|
41
|
+
f"or identity_fields = () to match by dict key."
|
|
42
|
+
)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from pydantic import BaseModel
|
|
2
2
|
from acex_devkit.models.attribute_value import AttributeValue
|
|
3
|
+
from acex_devkit.models.container_entry import ContainerEntry
|
|
3
4
|
from enum import Enum
|
|
4
|
-
from typing import Optional, Dict
|
|
5
|
+
from typing import ClassVar, Optional, Dict
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class LoggingServerBase(BaseModel): ...
|
|
@@ -46,20 +47,26 @@ class Console(BaseModel):
|
|
|
46
47
|
line_number: Optional[AttributeValue[int]] = None
|
|
47
48
|
logging_synchronous: Optional[AttributeValue[bool]] = None
|
|
48
49
|
|
|
49
|
-
class RemoteServer(BaseModel):
|
|
50
|
+
class RemoteServer(ContainerEntry, BaseModel):
|
|
51
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("host",)
|
|
50
52
|
name: Optional[AttributeValue[str]] = None
|
|
51
53
|
host: Optional[AttributeValue[str]] = None
|
|
52
54
|
port: Optional[AttributeValue[int]] = None
|
|
53
|
-
|
|
55
|
+
transport: Optional[AttributeValue[str]] = None
|
|
54
56
|
source_address: Optional[AttributeValue[str]] = None # Can be an IP address or an interface reference
|
|
55
57
|
|
|
56
|
-
class
|
|
58
|
+
class RemoteServers(BaseModel):
|
|
59
|
+
servers: Dict[str, RemoteServer] = {}
|
|
60
|
+
|
|
61
|
+
class VtyLine(ContainerEntry, BaseModel):
|
|
62
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("line_number",)
|
|
57
63
|
name: Optional[AttributeValue[str]] = None
|
|
58
64
|
line_number: Optional[AttributeValue[int]] = None
|
|
59
65
|
logging_synchronous: Optional[AttributeValue[bool]] = None
|
|
60
66
|
transport_input: Optional[AttributeValue[str]] = None # default is SSH. Mostly used by Cisco.
|
|
61
67
|
|
|
62
|
-
class FileLogging(BaseModel):
|
|
68
|
+
class FileLogging(ContainerEntry, BaseModel):
|
|
69
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("filename",)
|
|
63
70
|
name: Optional[AttributeValue[str]] = None # object name
|
|
64
71
|
filename: Optional[AttributeValue[str]] = None # name of the file
|
|
65
72
|
rotate: Optional[AttributeValue[int]] = None # How many versions to keep. Juniper specific.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from pydantic import BaseModel
|
|
2
2
|
from acex_devkit.models.attribute_value import AttributeValue
|
|
3
|
+
from acex_devkit.models.container_entry import ContainerEntry
|
|
3
4
|
from enum import Enum
|
|
4
|
-
from typing import Optional, Dict
|
|
5
|
+
from typing import ClassVar, Optional, Dict
|
|
5
6
|
|
|
6
|
-
class SpanningTreeGlobalAttributes(BaseModel):
|
|
7
|
+
class SpanningTreeGlobalAttributes(ContainerEntry, BaseModel):
|
|
8
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
7
9
|
mode: Optional[AttributeValue[str]] = None # Needs to be defined by user. Default for Cisco is RAPID-PVST and for Juniper it's just RSTP
|
|
8
10
|
bpdu_filter: Optional[AttributeValue[bool]] = None # Disabled by default
|
|
9
11
|
bpdu_guard: Optional[AttributeValue[bool]] = None # Disabled by default
|
|
@@ -20,19 +22,22 @@ class SpanningTreeModeConfig(BaseModel):
|
|
|
20
22
|
hold_count: Optional[AttributeValue[int]] = None # Range 1..10
|
|
21
23
|
|
|
22
24
|
## RSTP
|
|
23
|
-
class RstpAttributes(SpanningTreeModeConfig):
|
|
25
|
+
class RstpAttributes(ContainerEntry, SpanningTreeModeConfig):
|
|
26
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
24
27
|
|
|
25
28
|
class RSTPConfig(BaseModel):
|
|
26
29
|
#config: RstpAttributes = RstpAttributes()
|
|
27
30
|
config: Optional[Dict[str, RstpAttributes]] = None
|
|
28
31
|
|
|
29
32
|
### MSTP
|
|
30
|
-
class MstpInstanceAttributes(SpanningTreeModeConfig):
|
|
33
|
+
class MstpInstanceAttributes(ContainerEntry, SpanningTreeModeConfig):
|
|
34
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("instance_id",)
|
|
31
35
|
instance_id: AttributeValue[int] # range: 1..4094
|
|
32
36
|
name: Optional[AttributeValue[str]] = None
|
|
33
37
|
vlan: Optional[AttributeValue[list[int]]] = None # List of VLANs mapped to the MST instance
|
|
34
38
|
|
|
35
|
-
class MstpAttributes(SpanningTreeModeConfig):
|
|
39
|
+
class MstpAttributes(ContainerEntry, SpanningTreeModeConfig):
|
|
40
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
36
41
|
revision: Optional[AttributeValue[int]] = None
|
|
37
42
|
max_hop: Optional[AttributeValue[int]] = None # Range 1..255
|
|
38
43
|
|
|
@@ -42,7 +47,8 @@ class MSTPConfig(BaseModel):
|
|
|
42
47
|
mst_instances: Optional[Dict[str, MstpInstanceAttributes]] = None
|
|
43
48
|
|
|
44
49
|
### Rapid PVST
|
|
45
|
-
class RapidPVSTAttributes(SpanningTreeModeConfig):
|
|
50
|
+
class RapidPVSTAttributes(ContainerEntry, SpanningTreeModeConfig):
|
|
51
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("vlan",)
|
|
46
52
|
"""
|
|
47
53
|
Docstring for RapidPVSTAttributes
|
|
48
54
|
vlan can be a string or list. Depending on how NED is built it will check wether it's a single VLAN or multiple VLANs and then format the data to the correct format
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
from pydantic import BaseModel
|
|
2
|
-
from typing import Any, Dict, List, Optional, get_origin, get_args
|
|
3
|
-
|
|
4
|
-
from acex_devkit.models.composed_configuration import ComposedConfiguration
|
|
5
|
-
from acex_devkit.models.attribute_value import AttributeValue
|
|
6
|
-
from acex_devkit.configdiffer.diff import Diff, ComponentChange, AttributeChange, ComponentDiffOp
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class ConfigDiffer:
|
|
10
|
-
|
|
11
|
-
def _is_dict_of_models(self, annotation) -> bool:
|
|
12
|
-
"""Check if a type annotation is Dict[str, SomeBaseModel]."""
|
|
13
|
-
origin = get_origin(annotation)
|
|
14
|
-
if origin is dict:
|
|
15
|
-
args = get_args(annotation)
|
|
16
|
-
if len(args) == 2 and isinstance(args[1], type) and issubclass(args[1], BaseModel):
|
|
17
|
-
return True
|
|
18
|
-
return False
|
|
19
|
-
|
|
20
|
-
def _is_base_model(self, annotation) -> bool:
|
|
21
|
-
"""Check if a type annotation is a BaseModel subclass (not Dict, not AttributeValue)."""
|
|
22
|
-
return (
|
|
23
|
-
isinstance(annotation, type)
|
|
24
|
-
and issubclass(annotation, BaseModel)
|
|
25
|
-
and not issubclass(annotation, AttributeValue)
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
def _unwrap_optional(self, annotation):
|
|
29
|
-
"""Unwrap Optional[X] to X. Returns the annotation unchanged if not Optional."""
|
|
30
|
-
origin = get_origin(annotation)
|
|
31
|
-
if origin is type(None):
|
|
32
|
-
return annotation
|
|
33
|
-
args = get_args(annotation)
|
|
34
|
-
if args:
|
|
35
|
-
# Optional[X] is Union[X, None]
|
|
36
|
-
non_none = [a for a in args if a is not type(None)]
|
|
37
|
-
if len(non_none) == 1:
|
|
38
|
-
return non_none[0]
|
|
39
|
-
return annotation
|
|
40
|
-
|
|
41
|
-
def _flatten(self, model: BaseModel, path: tuple = ()) -> Dict[tuple, BaseModel]:
|
|
42
|
-
"""
|
|
43
|
-
Recursively walk a Pydantic model using type annotations to find
|
|
44
|
-
all components. Returns a flat mapping of path → model instance.
|
|
45
|
-
|
|
46
|
-
Rules:
|
|
47
|
-
- Dict[str, BaseModel] → component collection, each entry is a leaf
|
|
48
|
-
- BaseModel (not in Dict) → container, recurse deeper
|
|
49
|
-
- AttributeValue / primitives / None → skip (attributes, not components)
|
|
50
|
-
"""
|
|
51
|
-
result = {}
|
|
52
|
-
|
|
53
|
-
for field_name, field_info in model.model_fields.items():
|
|
54
|
-
annotation = self._unwrap_optional(field_info.annotation)
|
|
55
|
-
value = getattr(model, field_name)
|
|
56
|
-
|
|
57
|
-
if value is None:
|
|
58
|
-
continue
|
|
59
|
-
|
|
60
|
-
if self._is_dict_of_models(annotation):
|
|
61
|
-
# Component collection: each entry is a diffable component
|
|
62
|
-
for key, component in value.items():
|
|
63
|
-
component_path = path + (field_name, key)
|
|
64
|
-
result[component_path] = component
|
|
65
|
-
|
|
66
|
-
elif self._is_base_model(annotation):
|
|
67
|
-
# Recurse deeper — if nothing found, this model itself is a leaf component
|
|
68
|
-
child_path = path + (field_name,)
|
|
69
|
-
deeper = self._flatten(value, child_path)
|
|
70
|
-
if deeper:
|
|
71
|
-
result.update(deeper)
|
|
72
|
-
else:
|
|
73
|
-
result[child_path] = value
|
|
74
|
-
|
|
75
|
-
return result
|
|
76
|
-
|
|
77
|
-
def _attribute_changes(self, before: BaseModel, after: BaseModel) -> List[AttributeChange]:
|
|
78
|
-
"""
|
|
79
|
-
Compare two component model instances and return a list of changed attributes.
|
|
80
|
-
"""
|
|
81
|
-
changes = []
|
|
82
|
-
all_fields = set(before.model_fields.keys()) | set(after.model_fields.keys())
|
|
83
|
-
|
|
84
|
-
for field_name in all_fields:
|
|
85
|
-
b = getattr(before, field_name, None)
|
|
86
|
-
a = getattr(after, field_name, None)
|
|
87
|
-
if b != a:
|
|
88
|
-
changes.append(AttributeChange(attribute_name=field_name, before=b, after=a))
|
|
89
|
-
|
|
90
|
-
return changes
|
|
91
|
-
|
|
92
|
-
def diff(self, *, desired_config: ComposedConfiguration, observed_config: ComposedConfiguration) -> Diff:
|
|
93
|
-
"""
|
|
94
|
-
Compare two ComposedConfiguration objects and return a component-based diff.
|
|
95
|
-
|
|
96
|
-
Uses Pydantic model introspection to find all components (entries in
|
|
97
|
-
Dict[str, BaseModel] fields). Components are identified by their full
|
|
98
|
-
path, e.g. ('interfaces', 'GigabitEthernet0/0/1').
|
|
99
|
-
"""
|
|
100
|
-
flat_desired = self._flatten(desired_config)
|
|
101
|
-
flat_observed = self._flatten(observed_config)
|
|
102
|
-
|
|
103
|
-
desired_paths = set(flat_desired.keys())
|
|
104
|
-
observed_paths = set(flat_observed.keys())
|
|
105
|
-
|
|
106
|
-
added = []
|
|
107
|
-
removed = []
|
|
108
|
-
changed = []
|
|
109
|
-
|
|
110
|
-
for path in desired_paths - observed_paths:
|
|
111
|
-
obj = flat_desired[path]
|
|
112
|
-
added.append(ComponentChange(
|
|
113
|
-
op=ComponentDiffOp.ADD,
|
|
114
|
-
path=list(path),
|
|
115
|
-
component_type=type(obj),
|
|
116
|
-
component_name=path[-1],
|
|
117
|
-
before=None,
|
|
118
|
-
after=obj,
|
|
119
|
-
before_dict=None,
|
|
120
|
-
after_dict=obj.model_dump(),
|
|
121
|
-
))
|
|
122
|
-
|
|
123
|
-
for path in observed_paths - desired_paths:
|
|
124
|
-
obj = flat_observed[path]
|
|
125
|
-
removed.append(ComponentChange(
|
|
126
|
-
op=ComponentDiffOp.REMOVE,
|
|
127
|
-
path=list(path),
|
|
128
|
-
component_type=type(obj),
|
|
129
|
-
component_name=path[-1],
|
|
130
|
-
before=obj,
|
|
131
|
-
after=None,
|
|
132
|
-
before_dict=obj.model_dump(),
|
|
133
|
-
after_dict=None,
|
|
134
|
-
))
|
|
135
|
-
|
|
136
|
-
for path in desired_paths & observed_paths:
|
|
137
|
-
desired_obj = flat_desired[path]
|
|
138
|
-
observed_obj = flat_observed[path]
|
|
139
|
-
if desired_obj != observed_obj:
|
|
140
|
-
changed.append(ComponentChange(
|
|
141
|
-
op=ComponentDiffOp.CHANGE,
|
|
142
|
-
path=list(path),
|
|
143
|
-
component_type=type(desired_obj),
|
|
144
|
-
component_name=path[-1],
|
|
145
|
-
before=observed_obj,
|
|
146
|
-
after=desired_obj,
|
|
147
|
-
before_dict=observed_obj.model_dump(),
|
|
148
|
-
after_dict=desired_obj.model_dump(),
|
|
149
|
-
changed_attributes=self._attribute_changes(observed_obj, desired_obj),
|
|
150
|
-
))
|
|
151
|
-
|
|
152
|
-
return Diff(
|
|
153
|
-
added=added,
|
|
154
|
-
removed=removed,
|
|
155
|
-
changed=changed,
|
|
156
|
-
total_desired=len(desired_paths),
|
|
157
|
-
total_observed=len(observed_paths),
|
|
158
|
-
)
|
|
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
|