acex-devkit 1.3.0__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.3.0 → acex_devkit-1.3.1}/PKG-INFO +1 -1
- {acex_devkit-1.3.0 → 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.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/acl_model.py +8 -4
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/attribute_value.py +8 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/composed_configuration.py +69 -50
- acex_devkit-1.3.1/src/acex_devkit/models/container_entry.py +42 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/logging.py +11 -4
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/spanning_tree.py +12 -6
- acex_devkit-1.3.0/src/acex_devkit/configdiffer/configdiffer.py +0 -158
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/README.md +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/__init__.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/__init__.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/command.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/diff.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/configdiffer/old_diff.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/drivers/__init__.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/drivers/base.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/drivers/base_driver.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/exceptions/__init__.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/__init__.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/external_value.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/ned.py +0 -0
- {acex_devkit-1.3.0 → acex_devkit-1.3.1}/src/acex_devkit/models/node_response.py +0 -0
- {acex_devkit-1.3.0 → 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,10 +52,7 @@ 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
|
-
class RemoteServers(BaseModel):
|
|
56
|
-
servers: Dict[str, RemoteServer] = {}
|
|
57
56
|
|
|
58
57
|
class VtyLines(BaseModel):
|
|
59
58
|
lines: Dict[str, VtyLine] = {}
|
|
@@ -72,7 +71,8 @@ class LoggingComponents(BaseModel):
|
|
|
72
71
|
class NtpConfig(BaseModel):
|
|
73
72
|
enabled: AttributeValue[bool] = AttributeValue(value=False)
|
|
74
73
|
|
|
75
|
-
class NtpServer(BaseModel):
|
|
74
|
+
class NtpServer(ContainerEntry, BaseModel):
|
|
75
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("address",)
|
|
76
76
|
address: AttributeValue[str]
|
|
77
77
|
port: Optional[AttributeValue[int]] = None
|
|
78
78
|
version: Optional[AttributeValue[int]] = None
|
|
@@ -103,25 +103,27 @@ class AuthorizedKeyAlgorithms(str, Enum):
|
|
|
103
103
|
SSH_RSA = "ssh-rsa"
|
|
104
104
|
SSH_DSS = "ssh-dss"
|
|
105
105
|
|
|
106
|
-
class AuthorizedKey(BaseModel):
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
109
110
|
|
|
110
|
-
class Ssh(BaseModel):
|
|
111
|
+
class Ssh(BaseModel):
|
|
111
112
|
config: Optional[SshServer] = None
|
|
112
113
|
host_keys: Optional[Dict[str, AuthorizedKey]] = {}
|
|
113
114
|
|
|
114
115
|
class Lldp(BaseModel): ...
|
|
115
116
|
|
|
116
|
-
class Vlan(BaseModel):
|
|
117
|
+
class Vlan(ContainerEntry, BaseModel):
|
|
118
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("vlan_id",)
|
|
117
119
|
name: AttributeValue[str]
|
|
118
120
|
vlan_id: Optional[AttributeValue[int]] = None
|
|
119
121
|
vlan_name: Optional[AttributeValue[str]] = None
|
|
120
122
|
network_instance: Optional[AttributeValue[str]] = None
|
|
121
|
-
metadata: Optional[Metadata] = Metadata()
|
|
122
123
|
|
|
123
|
-
class Interface(BaseModel):
|
|
124
|
+
class Interface(ContainerEntry, BaseModel):
|
|
124
125
|
"Base class for all interfaces"
|
|
126
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("index", "type")
|
|
125
127
|
index: AttributeValue[int]
|
|
126
128
|
name: AttributeValue[str]
|
|
127
129
|
|
|
@@ -129,7 +131,6 @@ class Interface(BaseModel):
|
|
|
129
131
|
enabled: Optional[AttributeValue[bool]] = None
|
|
130
132
|
ipv4: Optional[AttributeValue[str]] = None
|
|
131
133
|
|
|
132
|
-
metadata: Optional[Metadata] = Metadata()
|
|
133
134
|
type: Literal[
|
|
134
135
|
"ethernetCsmacd",
|
|
135
136
|
"ieee8023adLag",
|
|
@@ -199,21 +200,21 @@ class Ieee8023adLagInterface(Interface):
|
|
|
199
200
|
class L3IpvlanInterface(Interface):
|
|
200
201
|
"SVI Interface"
|
|
201
202
|
type: Literal["l3ipvlan"] = "l3ipvlan"
|
|
202
|
-
vlan_id: Optional[int] = None
|
|
203
|
+
vlan_id: Optional[AttributeValue[int]] = None
|
|
203
204
|
|
|
204
205
|
class SoftwareLoopbackInterface(Interface):
|
|
205
206
|
"Loopback Interface"
|
|
206
207
|
type: Literal["softwareLoopback"] = "softwareLoopback"
|
|
207
208
|
|
|
208
209
|
# Loopback har varken vlan, duplex eller speed
|
|
209
|
-
vlan_id: Optional[int] = None
|
|
210
|
+
vlan_id: Optional[AttributeValue[int]] = None
|
|
210
211
|
ipv4: Optional[AttributeValue[str]] = None
|
|
211
212
|
|
|
212
213
|
class SubInterface(Interface):
|
|
213
214
|
"Subinterface"
|
|
214
215
|
type: Literal["subinterface"] = "subinterface"
|
|
215
216
|
|
|
216
|
-
vlan_id: Optional[int] = None
|
|
217
|
+
vlan_id: Optional[AttributeValue[int]] = None
|
|
217
218
|
ipv4: Optional[AttributeValue[str]] = None
|
|
218
219
|
|
|
219
220
|
class ManagementInterface(Interface):
|
|
@@ -221,9 +222,10 @@ class ManagementInterface(Interface):
|
|
|
221
222
|
type: Literal["managementInterface"] = "managementInterface"
|
|
222
223
|
|
|
223
224
|
# Mgmt har inte vlan
|
|
224
|
-
vlan_id: Optional[int] = None
|
|
225
|
+
vlan_id: Optional[AttributeValue[int]] = None
|
|
225
226
|
|
|
226
|
-
class StaticRouteNextHop(BaseModel):
|
|
227
|
+
class StaticRouteNextHop(ContainerEntry, BaseModel):
|
|
228
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("next_hop",)
|
|
227
229
|
index: Optional[AttributeValue[int]] = None
|
|
228
230
|
next_hop: AttributeValue[str] # can be an IP address or an interface. Reference will be handled in config component
|
|
229
231
|
metric: Optional[AttributeValue[int]] = None
|
|
@@ -231,7 +233,8 @@ class StaticRouteNextHop(BaseModel):
|
|
|
231
233
|
network_instance: Optional[AttributeValue[str]] = None
|
|
232
234
|
|
|
233
235
|
|
|
234
|
-
class StaticRoute(BaseModel):
|
|
236
|
+
class StaticRoute(ContainerEntry, BaseModel):
|
|
237
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("prefix",)
|
|
235
238
|
route_name: Optional[AttributeValue[str]] = None
|
|
236
239
|
prefix: AttributeValue[str]
|
|
237
240
|
next_hops: Optional[Dict[str, StaticRouteNextHop]] = {}
|
|
@@ -248,10 +251,12 @@ class ImportExportPolicy(BaseModel):
|
|
|
248
251
|
export_route_target: Optional[List[RouteTarget]] = None
|
|
249
252
|
import_route_target: Optional[List[RouteTarget]] = None
|
|
250
253
|
|
|
251
|
-
class InterInstancePolicy(BaseModel):
|
|
254
|
+
class InterInstancePolicy(ContainerEntry, BaseModel):
|
|
255
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
252
256
|
import_export_policy: ImportExportPolicy
|
|
253
257
|
|
|
254
|
-
class NetworkInstance(BaseModel):
|
|
258
|
+
class NetworkInstance(ContainerEntry, BaseModel):
|
|
259
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("name",)
|
|
255
260
|
name: AttributeValue[str]
|
|
256
261
|
description: Optional[AttributeValue[str]] = None
|
|
257
262
|
vlans: Optional[Dict[str, Vlan]] = {}
|
|
@@ -297,14 +302,16 @@ class SnmpPrivProtocol(str, Enum):
|
|
|
297
302
|
AES256 = "AES256"
|
|
298
303
|
|
|
299
304
|
|
|
300
|
-
class SnmpConfig(BaseModel):
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
305
311
|
|
|
306
312
|
|
|
307
|
-
class SnmpCommunity(BaseModel):
|
|
313
|
+
class SnmpCommunity(ContainerEntry, BaseModel):
|
|
314
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("name",)
|
|
308
315
|
name: AttributeValue[str]
|
|
309
316
|
community: Optional[AttributeValue[str]] = None # Community string
|
|
310
317
|
access: Optional[AttributeValue[SnmpAccess]] = AttributeValue(value=SnmpAccess.READ_ONLY)
|
|
@@ -315,22 +322,25 @@ class SnmpCommunity(BaseModel):
|
|
|
315
322
|
clients: Optional[AttributeValue[List[str]]] = None # Juniper specific
|
|
316
323
|
|
|
317
324
|
|
|
318
|
-
class SnmpUser(BaseModel):
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
325
333
|
|
|
326
334
|
|
|
327
|
-
class SnmpView(BaseModel):
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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)
|
|
331
340
|
|
|
332
341
|
|
|
333
|
-
class SnmpServer(BaseModel):
|
|
342
|
+
class SnmpServer(ContainerEntry, BaseModel):
|
|
343
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("address",)
|
|
334
344
|
name: Optional[AttributeValue[str]] = None
|
|
335
345
|
address: AttributeValue[str]
|
|
336
346
|
port: Optional[AttributeValue[int]] = AttributeValue(value=162)
|
|
@@ -457,7 +467,8 @@ class TrapEventOptions(str, Enum):
|
|
|
457
467
|
BULKSTAT_COLLECTION = "bulkstat_collection"
|
|
458
468
|
BULKSTAT_TRANSFER = "bulkstat_transfer"
|
|
459
469
|
|
|
460
|
-
class TrapEvent(BaseModel):
|
|
470
|
+
class TrapEvent(ContainerEntry, BaseModel):
|
|
471
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("event_name",)
|
|
461
472
|
name: Optional[AttributeValue[str]] = None
|
|
462
473
|
event_name: Optional[AttributeValue[TrapEventOptions]] = None
|
|
463
474
|
|
|
@@ -472,10 +483,12 @@ class Snmp(BaseModel):
|
|
|
472
483
|
views: Optional[Dict[str, SnmpView]] = {}
|
|
473
484
|
|
|
474
485
|
# AAA
|
|
475
|
-
class aaaBaseClass(BaseModel):
|
|
486
|
+
class aaaBaseClass(ContainerEntry, BaseModel):
|
|
487
|
+
identity_fields: ClassVar[tuple[str, ...]] = () # key is identity (e.g. "default", "console")
|
|
476
488
|
name: Optional[AttributeValue[str]] = None
|
|
477
489
|
|
|
478
|
-
class aaaTacacsAttributes(BaseModel):
|
|
490
|
+
class aaaTacacsAttributes(ContainerEntry, BaseModel):
|
|
491
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("address",)
|
|
479
492
|
port: Optional[AttributeValue[int]] = None
|
|
480
493
|
secret_key: Optional[AttributeValue[str]] = None
|
|
481
494
|
secret_key_hashed: Optional[AttributeValue[str]] = None
|
|
@@ -484,7 +497,8 @@ class aaaTacacsAttributes(BaseModel):
|
|
|
484
497
|
source_interface: Optional[Reference] = None
|
|
485
498
|
server_group: Optional[AttributeValue[str]] = None
|
|
486
499
|
|
|
487
|
-
class aaaRadiusAttributes(BaseModel):
|
|
500
|
+
class aaaRadiusAttributes(ContainerEntry, BaseModel):
|
|
501
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("address",)
|
|
488
502
|
auth_port: Optional[AttributeValue[int]] = None
|
|
489
503
|
acct_port: Optional[AttributeValue[int]] = None
|
|
490
504
|
secret_key: Optional[AttributeValue[str]] = None
|
|
@@ -495,7 +509,8 @@ class aaaRadiusAttributes(BaseModel):
|
|
|
495
509
|
retransmit_attempts: Optional[AttributeValue[int]] = None
|
|
496
510
|
server_group: Optional[AttributeValue[str]] = None
|
|
497
511
|
|
|
498
|
-
class aaaServerGroupAttributes(BaseModel):
|
|
512
|
+
class aaaServerGroupAttributes(ContainerEntry, BaseModel):
|
|
513
|
+
identity_fields: ClassVar[tuple[str, ...]] = () # key is the group name
|
|
499
514
|
"""
|
|
500
515
|
Define a AAA server group that can contain multiple TACACS+ and/or RADIUS servers.
|
|
501
516
|
|
|
@@ -542,6 +557,7 @@ class aaaAuthenticationMethods(aaaBaseClass):
|
|
|
542
557
|
# aaa authentication enable default group TACACS-GROUP-NEW enable
|
|
543
558
|
|
|
544
559
|
class authenticationUser(aaaBaseClass):
|
|
560
|
+
identity_fields: ClassVar[tuple[str, ...]] = ("username",)
|
|
545
561
|
username: Optional[AttributeValue[str]] = None
|
|
546
562
|
password: Optional[AttributeValue[str]] = None
|
|
547
563
|
password_hahsed: Optional[AttributeValue[str]] = None
|
|
@@ -555,7 +571,8 @@ class adminUser(aaaBaseClass):
|
|
|
555
571
|
admin_password: Optional[AttributeValue[str]] = None
|
|
556
572
|
admin_password_hashed: Optional[AttributeValue[str]] = None
|
|
557
573
|
|
|
558
|
-
class aaaAuthenticationAdminUsers(BaseModel):
|
|
574
|
+
class aaaAuthenticationAdminUsers(ContainerEntry, BaseModel):
|
|
575
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
559
576
|
config: Optional[Dict[str, adminUser]] = {}
|
|
560
577
|
|
|
561
578
|
class aaaAuthentication(BaseModel):
|
|
@@ -595,9 +612,10 @@ class aaaAuthorization(BaseModel):
|
|
|
595
612
|
events: Optional[Dict[str, aaaAuthorizationEvents]] = {}
|
|
596
613
|
|
|
597
614
|
# Accounting Models
|
|
598
|
-
class aaaAccountingMethods(BaseModel):
|
|
615
|
+
class aaaAccountingMethods(ContainerEntry, BaseModel):
|
|
616
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
599
617
|
"""
|
|
600
|
-
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,
|
|
601
619
|
you can reference it here by its name, but only as a string.
|
|
602
620
|
|
|
603
621
|
Example in config map:
|
|
@@ -609,10 +627,11 @@ class aaaAccountingMethods(BaseModel):
|
|
|
609
627
|
#method: Optional[List[str]] = None # Ex. ['TACACS_GROUP','LOCAL']
|
|
610
628
|
method: Optional[AttributeValue[str]] = None
|
|
611
629
|
|
|
612
|
-
class aaaAccountingEvents(BaseModel):
|
|
630
|
+
class aaaAccountingEvents(ContainerEntry, BaseModel):
|
|
631
|
+
identity_fields: ClassVar[tuple[str, ...]] = ()
|
|
613
632
|
"""
|
|
614
633
|
Define accounting events.
|
|
615
|
-
|
|
634
|
+
|
|
616
635
|
Cisco example:
|
|
617
636
|
aaa accounting send stop-record authentication failure
|
|
618
637
|
"""
|
|
@@ -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
|