acex-devkit 1.2.0__tar.gz → 1.3.0__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.0 → acex_devkit-1.3.0}/PKG-INFO +1 -1
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/pyproject.toml +1 -1
- acex_devkit-1.3.0/src/acex_devkit/configdiffer/configdiffer.py +158 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/diff.py +4 -2
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/composed_configuration.py +44 -32
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/logging.py +1 -1
- acex_devkit-1.2.0/src/acex_devkit/configdiffer/configdiffer.py +0 -178
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/README.md +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/command.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/configdiffer/old_diff.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/drivers/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/drivers/base.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/drivers/base_driver.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/exceptions/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/__init__.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/acl_model.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/attribute_value.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/external_value.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/ned.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/node_response.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/models/spanning_tree.py +0 -0
- {acex_devkit-1.2.0 → acex_devkit-1.3.0}/src/acex_devkit/types/__init__.py +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
)
|
|
@@ -22,9 +22,11 @@ class ComponentDiffOp(str, Enum):
|
|
|
22
22
|
|
|
23
23
|
class AttributeChange(BaseModel):
|
|
24
24
|
"""Represents a change to a single attribute within a component"""
|
|
25
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
26
|
+
|
|
25
27
|
attribute_name: str
|
|
26
|
-
before: Optional[
|
|
27
|
-
after: Optional[
|
|
28
|
+
before: Optional[Any] = None
|
|
29
|
+
after: Optional[Any] = None
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
class ComponentChange(BaseModel):
|
|
@@ -52,13 +52,22 @@ class SystemConfig(BaseModel):
|
|
|
52
52
|
#class TripleA(BaseModel): ...
|
|
53
53
|
|
|
54
54
|
# Trying to avoid using "Logging" or "logging" as names for anything due to conflicts with standard lib.
|
|
55
|
-
class
|
|
55
|
+
class RemoteServers(BaseModel):
|
|
56
|
+
servers: Dict[str, RemoteServer] = {}
|
|
57
|
+
|
|
58
|
+
class VtyLines(BaseModel):
|
|
59
|
+
lines: Dict[str, VtyLine] = {}
|
|
60
|
+
|
|
61
|
+
class LogFiles(BaseModel):
|
|
62
|
+
files: Dict[str, FileLogging] = {}
|
|
63
|
+
|
|
64
|
+
class LoggingComponents(BaseModel):
|
|
56
65
|
config: LoggingConfig = LoggingConfig()
|
|
57
66
|
console: Optional[Console] = None
|
|
58
|
-
remote_servers: Optional[
|
|
67
|
+
remote_servers: Optional[RemoteServers] = RemoteServers()
|
|
59
68
|
events: Optional[LoggingEvents] = None
|
|
60
|
-
vty: Optional[
|
|
61
|
-
files: Optional[
|
|
69
|
+
vty: Optional[VtyLines] = VtyLines()
|
|
70
|
+
files: Optional[LogFiles] = LogFiles()
|
|
62
71
|
|
|
63
72
|
class NtpConfig(BaseModel):
|
|
64
73
|
enabled: AttributeValue[bool] = AttributeValue(value=False)
|
|
@@ -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
|
|
@@ -100,7 +109,7 @@ class AuthorizedKey(BaseModel):
|
|
|
100
109
|
|
|
101
110
|
class Ssh(BaseModel):
|
|
102
111
|
config: Optional[SshServer] = None
|
|
103
|
-
host_keys: Optional[Dict[str, AuthorizedKey]] =
|
|
112
|
+
host_keys: Optional[Dict[str, AuthorizedKey]] = {}
|
|
104
113
|
|
|
105
114
|
class Lldp(BaseModel): ...
|
|
106
115
|
|
|
@@ -225,11 +234,11 @@ class StaticRouteNextHop(BaseModel):
|
|
|
225
234
|
class StaticRoute(BaseModel):
|
|
226
235
|
route_name: Optional[AttributeValue[str]] = None
|
|
227
236
|
prefix: AttributeValue[str]
|
|
228
|
-
next_hops: Optional[Dict[str, StaticRouteNextHop]] =
|
|
237
|
+
next_hops: Optional[Dict[str, StaticRouteNextHop]] = {}
|
|
229
238
|
network_instance: Optional[AttributeValue[str]] = None
|
|
230
239
|
|
|
231
240
|
class Protocols(BaseModel):
|
|
232
|
-
static_routes: Optional[Dict[str, StaticRoute]] =
|
|
241
|
+
static_routes: Optional[Dict[str, StaticRoute]] = {}
|
|
233
242
|
# OSPF, BGP, etc. can be added here as needed
|
|
234
243
|
|
|
235
244
|
class RouteTarget(BaseModel):
|
|
@@ -245,9 +254,9 @@ class InterInstancePolicy(BaseModel):
|
|
|
245
254
|
class NetworkInstance(BaseModel):
|
|
246
255
|
name: AttributeValue[str]
|
|
247
256
|
description: Optional[AttributeValue[str]] = None
|
|
248
|
-
vlans: Optional[Dict[str, Vlan]] =
|
|
257
|
+
vlans: Optional[Dict[str, Vlan]] = {}
|
|
249
258
|
interfaces: Optional[Dict[str, Reference]] = {}
|
|
250
|
-
inter_instance_policies: Optional[Dict[str, InterInstancePolicy]] =
|
|
259
|
+
inter_instance_policies: Optional[Dict[str, InterInstancePolicy]] = {}
|
|
251
260
|
protocols: Optional[Protocols] = Protocols()
|
|
252
261
|
|
|
253
262
|
class LacpConfig(BaseModel):
|
|
@@ -257,7 +266,7 @@ class LacpConfig(BaseModel):
|
|
|
257
266
|
|
|
258
267
|
class Lacp(BaseModel):
|
|
259
268
|
config: Optional[LacpConfig] = LacpConfig()
|
|
260
|
-
interfaces: Optional[Dict[str, Interface]] =
|
|
269
|
+
interfaces: Optional[Dict[str, Interface]] = {}
|
|
261
270
|
|
|
262
271
|
# SNMP
|
|
263
272
|
class SnmpAccess(str, Enum):
|
|
@@ -455,12 +464,12 @@ class TrapEvent(BaseModel):
|
|
|
455
464
|
#class SnmpTrap(BaseModel): ...
|
|
456
465
|
|
|
457
466
|
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]] =
|
|
467
|
+
config: Optional[Dict[str, SnmpConfig]] = {}
|
|
468
|
+
communities: Optional[Dict[str, SnmpCommunity]] = {}
|
|
469
|
+
users: Optional[Dict[str, SnmpUser]] = {}
|
|
470
|
+
trap_servers: Optional[Dict[str, SnmpServer]] = {}
|
|
471
|
+
trap_events: Optional[Dict[str, TrapEvent]] = {}
|
|
472
|
+
views: Optional[Dict[str, SnmpView]] = {}
|
|
464
473
|
|
|
465
474
|
# AAA
|
|
466
475
|
class aaaBaseClass(BaseModel):
|
|
@@ -509,8 +518,8 @@ class aaaServerGroupAttributes(BaseModel):
|
|
|
509
518
|
"""
|
|
510
519
|
enable: Optional[AttributeValue[bool]] = None
|
|
511
520
|
type: Optional[AttributeValue[Literal['tacacs','radius']]] = None
|
|
512
|
-
tacacs: Optional[Dict[str, aaaTacacsAttributes]] =
|
|
513
|
-
radius: Optional[Dict[str, aaaRadiusAttributes]] =
|
|
521
|
+
tacacs: Optional[Dict[str, aaaTacacsAttributes]] = {}
|
|
522
|
+
radius: Optional[Dict[str, aaaRadiusAttributes]] = {}
|
|
514
523
|
|
|
515
524
|
# Authentication Models
|
|
516
525
|
class aaaAuthenticationMethods(aaaBaseClass):
|
|
@@ -540,19 +549,19 @@ class authenticationUser(aaaBaseClass):
|
|
|
540
549
|
role: Optional[AttributeValue[str]] = None
|
|
541
550
|
|
|
542
551
|
class aaaAuthenticationUsers(aaaBaseClass):
|
|
543
|
-
username: Optional[Dict[str, authenticationUser]] =
|
|
552
|
+
username: Optional[Dict[str, authenticationUser]] = {}
|
|
544
553
|
|
|
545
554
|
class adminUser(aaaBaseClass):
|
|
546
555
|
admin_password: Optional[AttributeValue[str]] = None
|
|
547
556
|
admin_password_hashed: Optional[AttributeValue[str]] = None
|
|
548
557
|
|
|
549
558
|
class aaaAuthenticationAdminUsers(BaseModel):
|
|
550
|
-
config: Optional[Dict[str, adminUser]] =
|
|
559
|
+
config: Optional[Dict[str, adminUser]] = {}
|
|
551
560
|
|
|
552
561
|
class aaaAuthentication(BaseModel):
|
|
553
|
-
config: Optional[Dict[str, aaaAuthenticationMethods]] =
|
|
554
|
-
admin_user: Optional[Dict[str, aaaAuthenticationAdminUsers]] =
|
|
555
|
-
users: Optional[Dict[str, aaaAuthenticationUsers]] =
|
|
562
|
+
config: Optional[Dict[str, aaaAuthenticationMethods]] = {}
|
|
563
|
+
admin_user: Optional[Dict[str, aaaAuthenticationAdminUsers]] = {}
|
|
564
|
+
users: Optional[Dict[str, aaaAuthenticationUsers]] = {}
|
|
556
565
|
|
|
557
566
|
# Authorization Models
|
|
558
567
|
class aaaAuthorizationMethods(aaaBaseClass):
|
|
@@ -582,8 +591,8 @@ class aaaAuthorizationEvents(aaaBaseClass):
|
|
|
582
591
|
event: Optional[AttributeValue[str]] = None
|
|
583
592
|
|
|
584
593
|
class aaaAuthorization(BaseModel):
|
|
585
|
-
config: Optional[Dict[str, aaaAuthorizationMethods]] =
|
|
586
|
-
events: Optional[Dict[str, aaaAuthorizationEvents]] =
|
|
594
|
+
config: Optional[Dict[str, aaaAuthorizationMethods]] = {}
|
|
595
|
+
events: Optional[Dict[str, aaaAuthorizationEvents]] = {}
|
|
587
596
|
|
|
588
597
|
# Accounting Models
|
|
589
598
|
class aaaAccountingMethods(BaseModel):
|
|
@@ -611,15 +620,15 @@ class aaaAccountingEvents(BaseModel):
|
|
|
611
620
|
event: Optional[AttributeValue[str]] = None
|
|
612
621
|
|
|
613
622
|
class aaaAccounting(BaseModel):
|
|
614
|
-
config: Optional[Dict[str, aaaAccountingMethods]] =
|
|
615
|
-
events: Optional[Dict[str, aaaAccountingEvents]] =
|
|
623
|
+
config: Optional[Dict[str, aaaAccountingMethods]] = {}
|
|
624
|
+
events: Optional[Dict[str, aaaAccountingEvents]] = {}
|
|
616
625
|
|
|
617
626
|
class aaaGlobalAttributes(BaseModel):
|
|
618
627
|
enabled: Optional[AttributeValue[bool]] = False # default False
|
|
619
628
|
|
|
620
629
|
class TripleA(BaseModel):
|
|
621
630
|
config: aaaGlobalAttributes = aaaGlobalAttributes()
|
|
622
|
-
server_groups: Optional[Dict[str, aaaServerGroupAttributes]] =
|
|
631
|
+
server_groups: Optional[Dict[str, aaaServerGroupAttributes]] = {}
|
|
623
632
|
authentication: aaaAuthentication = aaaAuthentication()
|
|
624
633
|
authorization: aaaAuthorization = aaaAuthorization()
|
|
625
634
|
accounting: aaaAccounting = aaaAccounting()
|
|
@@ -652,11 +661,14 @@ class ComposedConfiguration(BaseModel):
|
|
|
652
661
|
stp: Optional[SpanningTree] = SpanningTree()
|
|
653
662
|
|
|
654
663
|
|
|
655
|
-
"""
|
|
664
|
+
"""
|
|
656
665
|
GUIDELINES FOR COMPOSED CONFIGURATION:
|
|
657
666
|
|
|
658
667
|
1. All values must always be typed as AttributeValue.
|
|
659
668
|
2. Containers must always be defined as hierarchical pydantic types, no dicts as placeholders.
|
|
660
|
-
3. Component collections
|
|
661
|
-
|
|
669
|
+
3. Component collections must use a typed container class (e.g. RemoteServers, VtyLines) with an inner
|
|
670
|
+
Dict[str, BaseModel] field — the key identifies the component. Raw Dict fields are not allowed at
|
|
671
|
+
the container level.
|
|
672
|
+
4. Default values for Optional containers are always an empty instance of the container class, e.g.
|
|
673
|
+
Optional[RemoteServers] = RemoteServers(). Never default to None or a raw empty dict for collections.
|
|
662
674
|
"""
|
|
@@ -50,7 +50,7 @@ class RemoteServer(BaseModel):
|
|
|
50
50
|
name: Optional[AttributeValue[str]] = None
|
|
51
51
|
host: Optional[AttributeValue[str]] = None
|
|
52
52
|
port: Optional[AttributeValue[int]] = None
|
|
53
|
-
|
|
53
|
+
transport: Optional[AttributeValue[str]] = None
|
|
54
54
|
source_address: Optional[AttributeValue[str]] = None # Can be an IP address or an interface reference
|
|
55
55
|
|
|
56
56
|
class VtyLine(BaseModel):
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
from pydantic import BaseModel
|
|
2
|
-
from typing import Any, Dict, List, Iterator, Tuple
|
|
3
|
-
|
|
4
|
-
from acex_devkit.models.composed_configuration import ComposedConfiguration, Reference, ReferenceTo, ReferenceFrom, Metadata
|
|
5
|
-
from acex_devkit.models.attribute_value import AttributeValue
|
|
6
|
-
from acex_devkit.configdiffer.diff import Diff, ComponentChange, AttributeChange, ComponentDiffOp
|
|
7
|
-
|
|
8
|
-
import json
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ConfigDiffer:
|
|
12
|
-
|
|
13
|
-
IGNORED_KEYS = ["metadata", "logging", "ssh", "snmp", "network_instances", "interfaces", "ntp", "domain_name", "location", "contact", "motd_banner"]
|
|
14
|
-
|
|
15
|
-
def _clean_metadata(self, obj: dict) -> dict:
|
|
16
|
-
"""
|
|
17
|
-
Recursively ignored keys.
|
|
18
|
-
"""
|
|
19
|
-
if not isinstance(obj, dict):
|
|
20
|
-
return obj
|
|
21
|
-
cleaned = {}
|
|
22
|
-
for k, v in obj.items():
|
|
23
|
-
if k in self.__class__.IGNORED_KEYS:
|
|
24
|
-
continue
|
|
25
|
-
if isinstance(v, dict):
|
|
26
|
-
cleaned[k] = self._clean_metadata(v)
|
|
27
|
-
elif isinstance(v, list):
|
|
28
|
-
cleaned[k] = [self._clean_metadata(item) if isinstance(item, dict) else item for item in v]
|
|
29
|
-
else:
|
|
30
|
-
cleaned[k] = v
|
|
31
|
-
return cleaned
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _dump_to_dicts(self, config: ComposedConfiguration) -> dict:
|
|
35
|
-
"""
|
|
36
|
-
Dumps to dict, removes unnecessary keys except component_class.
|
|
37
|
-
"""
|
|
38
|
-
# Remove most metadata, but keep component_class for type identification
|
|
39
|
-
config_dict = config.model_dump(exclude_unset=True)
|
|
40
|
-
return self._clean_metadata(config_dict)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _flatten(self, d: dict, path: tuple = ()) -> Dict[tuple, dict]:
|
|
44
|
-
"""
|
|
45
|
-
Recursively walk the tree and collect every component (dict with
|
|
46
|
-
'component_class') into a flat mapping of path → component_dict.
|
|
47
|
-
|
|
48
|
-
Example result:
|
|
49
|
-
{
|
|
50
|
-
('interfaces', 'GigabitEthernet0/0/1'): {...},
|
|
51
|
-
('network_instances', 'default', 'vlans', '100'): {...},
|
|
52
|
-
}
|
|
53
|
-
"""
|
|
54
|
-
result = {}
|
|
55
|
-
for key, value in d.items():
|
|
56
|
-
current_path = path + (key,)
|
|
57
|
-
if not isinstance(value, dict):
|
|
58
|
-
continue
|
|
59
|
-
# Try to find deeper components first
|
|
60
|
-
deeper = self._flatten(value, current_path)
|
|
61
|
-
if deeper:
|
|
62
|
-
# Sub-components found — use those (don't emit current level)
|
|
63
|
-
result.update(deeper)
|
|
64
|
-
elif any(isinstance(v, dict) and 'value' in v for v in value.values()):
|
|
65
|
-
# No sub-components, but this dict has AttributeValue fields → it's a leaf
|
|
66
|
-
result[current_path] = value
|
|
67
|
-
return result
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _get_by_path(self, config: ComposedConfiguration, path: tuple) -> Any:
|
|
72
|
-
"""
|
|
73
|
-
Traverse the ComposedConfiguration object using a path tuple and return
|
|
74
|
-
the actual Pydantic model instance (or primitive) at that location.
|
|
75
|
-
|
|
76
|
-
Example:
|
|
77
|
-
path = ('interfaces', 'GigabitEthernet0/0/1')
|
|
78
|
-
→ returns the actual SoftwareLoopbackInterface / EthernetCsmacdInterface etc.
|
|
79
|
-
"""
|
|
80
|
-
obj = config
|
|
81
|
-
for key in path:
|
|
82
|
-
if isinstance(obj, dict):
|
|
83
|
-
obj = obj[key]
|
|
84
|
-
else:
|
|
85
|
-
obj = getattr(obj, key)
|
|
86
|
-
return obj
|
|
87
|
-
|
|
88
|
-
def _attribute_changes(self, before: dict, after: dict) -> List[AttributeChange]:
|
|
89
|
-
"""
|
|
90
|
-
Compare two component dicts and return a list of changed attributes.
|
|
91
|
-
"""
|
|
92
|
-
changes = []
|
|
93
|
-
for key in set(before.keys()) | set(after.keys()):
|
|
94
|
-
b = before.get(key)
|
|
95
|
-
a = after.get(key)
|
|
96
|
-
if b != a:
|
|
97
|
-
changes.append(AttributeChange(attribute_name=key, before=b, after=a))
|
|
98
|
-
return changes
|
|
99
|
-
|
|
100
|
-
def diff(self, *, desired_config: ComposedConfiguration, observed_config: ComposedConfiguration) -> Diff:
|
|
101
|
-
"""
|
|
102
|
-
Compare two ComposedConfiguration objects and return a component-based diff.
|
|
103
|
-
|
|
104
|
-
Walks both configs using Pydantic model structure (not heuristics) to find
|
|
105
|
-
all named components inside Dict[str, Model] containers. Components are
|
|
106
|
-
identified by their full path, e.g. ('interfaces', 'GigabitEthernet0/0/1').
|
|
107
|
-
|
|
108
|
-
Args:
|
|
109
|
-
desired_config: The target configuration we want to achieve.
|
|
110
|
-
observed_config: The current configuration as observed on the device.
|
|
111
|
-
|
|
112
|
-
Returns:
|
|
113
|
-
Diff: A structured diff showing added, removed, and changed components.
|
|
114
|
-
"""
|
|
115
|
-
flat_observed = self._flatten(self._dump_to_dicts(observed_config))
|
|
116
|
-
flat_desired = self._flatten(self._dump_to_dicts(desired_config))
|
|
117
|
-
|
|
118
|
-
observed_paths = set(flat_observed.keys())
|
|
119
|
-
desired_paths = set(flat_desired.keys())
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
added = []
|
|
123
|
-
removed = []
|
|
124
|
-
changed = []
|
|
125
|
-
|
|
126
|
-
for path in desired_paths - observed_paths:
|
|
127
|
-
obj = self._get_by_path(desired_config, path)
|
|
128
|
-
added.append(ComponentChange(
|
|
129
|
-
op=ComponentDiffOp.ADD,
|
|
130
|
-
path=list(path),
|
|
131
|
-
component_type=type(obj),
|
|
132
|
-
component_name=path[-1],
|
|
133
|
-
before=None,
|
|
134
|
-
after=obj,
|
|
135
|
-
before_dict=None,
|
|
136
|
-
after_dict=flat_desired[path],
|
|
137
|
-
))
|
|
138
|
-
|
|
139
|
-
for path in observed_paths - desired_paths:
|
|
140
|
-
obj = self._get_by_path(observed_config, path)
|
|
141
|
-
removed.append(ComponentChange(
|
|
142
|
-
op=ComponentDiffOp.REMOVE,
|
|
143
|
-
path=list(path),
|
|
144
|
-
component_type=type(obj),
|
|
145
|
-
component_name=path[-1],
|
|
146
|
-
before=obj,
|
|
147
|
-
after=None,
|
|
148
|
-
before_dict=flat_observed[path],
|
|
149
|
-
after_dict=None,
|
|
150
|
-
))
|
|
151
|
-
|
|
152
|
-
for path in desired_paths & observed_paths:
|
|
153
|
-
desired_comp = flat_desired[path]
|
|
154
|
-
observed_comp = flat_observed[path]
|
|
155
|
-
if desired_comp != observed_comp:
|
|
156
|
-
desired_obj = self._get_by_path(desired_config, path)
|
|
157
|
-
observed_obj = self._get_by_path(observed_config, path)
|
|
158
|
-
changed.append(ComponentChange(
|
|
159
|
-
op=ComponentDiffOp.CHANGE,
|
|
160
|
-
path=list(path),
|
|
161
|
-
component_type=type(desired_obj),
|
|
162
|
-
component_name=path[-1],
|
|
163
|
-
before=observed_obj,
|
|
164
|
-
after=desired_obj,
|
|
165
|
-
before_dict=observed_comp,
|
|
166
|
-
after_dict=desired_comp,
|
|
167
|
-
changed_attributes=self._attribute_changes(observed_comp, desired_comp),
|
|
168
|
-
))
|
|
169
|
-
|
|
170
|
-
return Diff(
|
|
171
|
-
added=added,
|
|
172
|
-
removed=removed,
|
|
173
|
-
changed=changed,
|
|
174
|
-
total_desired=len(desired_paths),
|
|
175
|
-
total_observed=len(observed_paths),
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|