acex-devkit 1.6.2__tar.gz → 1.7.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.
Files changed (29) hide show
  1. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/PKG-INFO +1 -1
  2. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/pyproject.toml +1 -1
  3. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/__init__.py +2 -0
  4. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/drivers/base.py +50 -27
  5. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/drivers/base_driver.py +12 -7
  6. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/__init__.py +1 -0
  7. acex_devkit-1.7.1/src/acex_devkit/models/management_connection.py +16 -0
  8. acex_devkit-1.7.1/src/acex_devkit/normalizer/__init__.py +22 -0
  9. acex_devkit-1.7.1/src/acex_devkit/normalizer/base.py +80 -0
  10. acex_devkit-1.7.1/src/acex_devkit/normalizer/engine.py +162 -0
  11. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/README.md +0 -0
  12. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/__init__.py +0 -0
  13. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/command.py +0 -0
  14. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/configdiffer.py +0 -0
  15. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/diff.py +0 -0
  16. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
  17. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/configdiffer/old_diff.py +0 -0
  18. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/drivers/__init__.py +0 -0
  19. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/exceptions/__init__.py +0 -0
  20. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/acl_model.py +0 -0
  21. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/attribute_value.py +0 -0
  22. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/composed_configuration.py +0 -0
  23. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/container_entry.py +0 -0
  24. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/external_value.py +0 -0
  25. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/logging.py +0 -0
  26. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/ned.py +0 -0
  27. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/node_response.py +0 -0
  28. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/models/spanning_tree.py +0 -0
  29. {acex_devkit-1.6.2 → acex_devkit-1.7.1}/src/acex_devkit/types/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acex-devkit
3
- Version: 1.6.2
3
+ Version: 1.7.1
4
4
  Summary: ACE-X DevKit - Development kit for building ACE-X drivers and plugins
5
5
  License: AGPL-3.0
6
6
  Keywords: automation,devkit,sdk,drivers,plugins
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "acex-devkit"
3
- version = "1.6.2"
3
+ version = "1.7.1"
4
4
  description = "ACE-X DevKit - Development kit for building ACE-X drivers and plugins"
5
5
  authors = ["Johan Lahti <johan.lahti@acebit.se>"]
6
6
  readme = "README.md"
@@ -8,10 +8,12 @@ from acex_devkit.drivers import (
8
8
  RendererBase,
9
9
  ParserBase,
10
10
  )
11
+ from acex_devkit.normalizer import BaseNormalizer
11
12
 
12
13
  __all__ = [
13
14
  "NetworkElementDriver",
14
15
  "TransportBase",
15
16
  "RendererBase",
16
17
  "ParserBase",
18
+ "BaseNormalizer",
17
19
  ]
@@ -3,6 +3,9 @@
3
3
  from abc import ABC, abstractmethod
4
4
  from typing import Any, Dict
5
5
 
6
+ from acex_devkit.models.node_response import NodeListItem
7
+ from acex_devkit.models.management_connection import ManagementConnection
8
+
6
9
 
7
10
  class ParserBase(ABC):
8
11
  """Base class for configuration parsers."""
@@ -38,52 +41,51 @@ class RendererBase(ABC):
38
41
 
39
42
 
40
43
  class TransportBase(ABC):
41
- """Base class for device transport/communication."""
42
-
43
- @abstractmethod
44
- def connect(self) -> None:
45
- """Establish connection to the device."""
46
- pass
44
+ """Base class for device transport/communication.
47
45
 
48
- @abstractmethod
49
- def send(self, payload: Any) -> None:
50
- """Send configuration to the device.
51
-
52
- Args:
53
- payload: Configuration payload to send
54
- """
55
- pass
46
+ Each method is self-contained — the driver decides internally
47
+ whether to open/close sessions per call, pool connections, or
48
+ make stateless requests.
49
+
50
+ Args:
51
+ node: The node instance (identity, hostname, vendor, os, ned_id)
52
+ connection: Management connection (target_ip, connection_type)
53
+ **kwargs: Future use (credentials, options, etc.)
54
+ """
56
55
 
57
56
  @abstractmethod
58
- def verify(self) -> bool:
59
- """Verify configuration was applied correctly.
60
-
61
- Returns:
62
- True if verification succeeded, False otherwise
63
- """
57
+ def get_config(self, node: NodeListItem, connection: ManagementConnection, **kwargs) -> str:
58
+ """Fetch the full running configuration from a device."""
64
59
  pass
65
60
 
66
61
  @abstractmethod
67
- def rollback(self) -> None:
68
- """Rollback configuration if verification fails."""
62
+ def send_config(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> str:
63
+ """Apply configuration commands to a device."""
69
64
  pass
70
65
 
66
+ def execute(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> list[str]:
67
+ """Run arbitrary commands and return output per command. Opt-in per driver."""
68
+ raise NotImplementedError(f"{self.__class__.__name__} does not implement execute()")
69
+
71
70
 
72
71
  class NetworkElementDriver:
73
72
  """Base class for network element drivers.
74
-
73
+
75
74
  Combines renderer, transport, and parser to provide complete
76
75
  configuration management for network devices.
77
-
76
+
78
77
  Attributes:
79
78
  renderer_class: Renderer class to use (must be set in subclass)
80
79
  transport_class: Transport class to use (must be set in subclass)
81
80
  parser_class: Parser class to use (must be set in subclass)
81
+ normalizer_class: Optional normalizer class for stripping
82
+ non-intent config and masking secrets
82
83
  """
83
-
84
+
84
85
  renderer_class = None
85
86
  transport_class = None
86
87
  parser_class = None
88
+ normalizer_class = None
87
89
 
88
90
  def __init__(self):
89
91
  """Initialize driver with renderer, transport, and parser instances."""
@@ -94,6 +96,7 @@ class NetworkElementDriver:
94
96
  self.renderer = self.renderer_class()
95
97
  self.transport = self.transport_class()
96
98
  self.parser = self.parser_class()
99
+ self.normalizer = self.normalizer_class() if self.normalizer_class else None
97
100
 
98
101
  @abstractmethod
99
102
  def render(self, logical_node: "LogicalNode", asset: Any = None) -> Any:
@@ -111,11 +114,31 @@ class NetworkElementDriver:
111
114
  @abstractmethod
112
115
  def parse(self, configuration: str) -> Any:
113
116
  """Parse device configuration.
114
-
117
+
115
118
  Args:
116
119
  configuration: Raw device configuration
117
-
120
+
118
121
  Returns:
119
122
  Parsed configuration model
120
123
  """
121
124
  return self.parser.parse(configuration)
125
+
126
+ def normalize(self, raw: str) -> str:
127
+ """Strip non-intent data (timestamps, auto-generated certs, etc.).
128
+
129
+ Returns the cleaned config string. If no normalizer is configured
130
+ the input is returned unchanged.
131
+ """
132
+ if self.normalizer is None:
133
+ return raw
134
+ return self.normalizer.normalize(raw).config
135
+
136
+ def mask(self, raw: str) -> str:
137
+ """Replace secrets with <REDACTED>.
138
+
139
+ Returns the masked config string. If no normalizer is configured
140
+ the input is returned unchanged.
141
+ """
142
+ if self.normalizer is None:
143
+ return raw
144
+ return self.normalizer.mask(raw).config
@@ -1,6 +1,9 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from typing import Any, Dict
3
3
 
4
+ from acex_devkit.models.node_response import NodeListItem
5
+ from acex_devkit.models.management_connection import ManagementConnection
6
+
4
7
  class ParserBase(ABC):
5
8
  @abstractmethod
6
9
  def parse(self, model: Dict[str, Any]) -> Any:
@@ -14,16 +17,18 @@ class RendererBase(ABC):
14
17
 
15
18
  class TransportBase(ABC):
16
19
  @abstractmethod
17
- def connect(self) -> None: ...
18
-
19
- @abstractmethod
20
- def send(self, payload: Any) -> None: ...
20
+ def get_config(self, node: NodeListItem, connection: ManagementConnection, **kwargs) -> str:
21
+ """Fetch the full running configuration from a device."""
22
+ pass
21
23
 
22
24
  @abstractmethod
23
- def verify(self) -> bool: ...
25
+ def send_config(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> str:
26
+ """Apply configuration commands to a device."""
27
+ pass
24
28
 
25
- @abstractmethod
26
- def rollback(self) -> None: ...
29
+ def execute(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> list[str]:
30
+ """Run arbitrary commands. Opt-in per driver."""
31
+ raise NotImplementedError(f"{self.__class__.__name__} does not implement execute()")
27
32
 
28
33
  class NetworkElementDriver:
29
34
  """Kombinerar renderer + transport – exponeras som en plugin."""
@@ -6,6 +6,7 @@
6
6
  from .external_value import ExternalValue
7
7
  from .attribute_value import AttributeValue
8
8
  from .node_response import NodeResponse, NodeListItem, LogicalNodeResponse
9
+ from .management_connection import ManagementConnection, ConnectionType
9
10
 
10
11
  __all__ = [
11
12
  ExternalValue,
@@ -0,0 +1,16 @@
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+ from enum import Enum
4
+
5
+
6
+ class ConnectionType(str, Enum):
7
+ ssh = "ssh"
8
+ telnet = "telnet"
9
+
10
+
11
+ class ManagementConnection(BaseModel):
12
+ id: Optional[int] = None
13
+ node_id: Optional[int] = None
14
+ primary: bool = True
15
+ connection_type: ConnectionType = ConnectionType.ssh
16
+ target_ip: Optional[str] = None
@@ -0,0 +1,22 @@
1
+ """Configuration normalizer — vendor-agnostic engine for stripping
2
+ runtime noise and masking secrets from device configurations."""
3
+
4
+ from acex_devkit.normalizer.engine import (
5
+ LineRule,
6
+ BlockRule,
7
+ RewriteRule,
8
+ OpStats,
9
+ OpResult,
10
+ NormalizerEngine,
11
+ )
12
+ from acex_devkit.normalizer.base import BaseNormalizer
13
+
14
+ __all__ = [
15
+ "LineRule",
16
+ "BlockRule",
17
+ "RewriteRule",
18
+ "OpStats",
19
+ "OpResult",
20
+ "NormalizerEngine",
21
+ "BaseNormalizer",
22
+ ]
@@ -0,0 +1,80 @@
1
+ """Base normalizer for vendor-specific subclasses.
2
+
3
+ Subclasses only need to declare rules:
4
+
5
+ class MyVendorNormalizer(BaseNormalizer):
6
+ line_rules = [...]
7
+ block_rules = [...]
8
+ rewrite_rules = [...]
9
+
10
+ Optional overrides if the vendor syntax differs:
11
+ is_block_continuation(line) -> bool
12
+ is_block_terminator(line) -> bool
13
+ post_process(lines, stats) -> list[str]
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ from acex_devkit.normalizer.engine import (
21
+ LineRule,
22
+ BlockRule,
23
+ RewriteRule,
24
+ OpStats,
25
+ OpResult,
26
+ NormalizerEngine,
27
+ )
28
+
29
+
30
+ class BaseNormalizer:
31
+ line_rules: list[LineRule] = []
32
+ block_rules: list[BlockRule] = []
33
+ rewrite_rules: list[RewriteRule] = []
34
+
35
+ engine: NormalizerEngine = NormalizerEngine()
36
+
37
+ # ── hooks (override per vendor) ───────────────────────────────
38
+
39
+ def is_block_continuation(self, line: str) -> bool:
40
+ if not line:
41
+ return False
42
+ if line.startswith((" ", "\t")):
43
+ return True
44
+ if re.fullmatch(r"[0-9A-Fa-f ]+", line.rstrip()):
45
+ return True
46
+ return False
47
+
48
+ def is_block_terminator(self, line: str) -> bool:
49
+ return line.strip() in ("quit", "exit")
50
+
51
+ def post_process(self, lines: list[str], stats: OpStats) -> list[str]:
52
+ return self.engine.collapse_and_trim(lines, stats)
53
+
54
+ # ── operations ────────────────────────────────────────────────
55
+
56
+ def normalize(self, raw: str) -> OpResult:
57
+ lines, stats = self.engine.normalize_pass(
58
+ raw,
59
+ line_rules=self.line_rules,
60
+ block_rules=self.block_rules,
61
+ is_block_continuation=self.is_block_continuation,
62
+ is_block_terminator=self.is_block_terminator,
63
+ )
64
+ lines = self.post_process(lines, stats)
65
+ stats.lines_out = len(lines)
66
+ return OpResult(
67
+ config="\n".join(lines) + ("\n" if lines else ""),
68
+ operation="normalize",
69
+ stats=stats,
70
+ )
71
+
72
+ def mask(self, raw: str) -> OpResult:
73
+ lines, stats = self.engine.mask_pass(
74
+ raw, rewrite_rules=self.rewrite_rules
75
+ )
76
+ return OpResult(
77
+ config="\n".join(lines) + ("\n" if lines else ""),
78
+ operation="mask",
79
+ stats=stats,
80
+ )
@@ -0,0 +1,162 @@
1
+ """Stateless rule engine and data types for config normalization.
2
+
3
+ Two independent passes:
4
+ normalize_pass — drops lines/blocks that are not intent-config
5
+ mask_pass — rewrites lines to redact secrets
6
+
7
+ The passes are isolated: normalize never touches content it keeps,
8
+ mask never drops lines. This makes the operations order-independent.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+
16
+
17
+ # ── Rule types ────────────────────────────────────────────────────────
18
+
19
+ @dataclass(frozen=True)
20
+ class LineRule:
21
+ """Matches a full line → the line is dropped."""
22
+ name: str
23
+ pattern: re.Pattern
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class BlockRule:
28
+ """Matches a block header → header + indented body are dropped."""
29
+ name: str
30
+ pattern: re.Pattern
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RewriteRule:
35
+ """Rewrites a line (e.g. redact secrets)."""
36
+ name: str
37
+ pattern: re.Pattern
38
+ replacement: str
39
+
40
+
41
+ # ── Result types ──────────────────────────────────────────────────────
42
+
43
+ @dataclass
44
+ class OpStats:
45
+ lines_in: int = 0
46
+ lines_out: int = 0
47
+ lines_dropped: int = 0
48
+ blocks_dropped: int = 0
49
+ lines_redacted: int = 0
50
+ by_rule: dict[str, int] = field(default_factory=dict)
51
+
52
+ def _bump(self, rule: str) -> None:
53
+ self.by_rule[rule] = self.by_rule.get(rule, 0) + 1
54
+
55
+
56
+ @dataclass
57
+ class OpResult:
58
+ config: str
59
+ operation: str # "normalize" or "mask"
60
+ stats: OpStats
61
+
62
+
63
+ # ── Engine ────────────────────────────────────────────────────────────
64
+
65
+ class NormalizerEngine:
66
+ """Stateless rule engine used by BaseNormalizer subclasses."""
67
+
68
+ def normalize_pass(
69
+ self,
70
+ raw: str,
71
+ *,
72
+ line_rules: list[LineRule],
73
+ block_rules: list[BlockRule],
74
+ is_block_continuation,
75
+ is_block_terminator,
76
+ ) -> tuple[list[str], OpStats]:
77
+ stats = OpStats()
78
+ lines = raw.splitlines()
79
+ stats.lines_in = len(lines)
80
+ out: list[str] = []
81
+
82
+ i = 0
83
+ while i < len(lines):
84
+ line = lines[i]
85
+
86
+ block_hit = next(
87
+ (r for r in block_rules if r.pattern.match(line)), None
88
+ )
89
+ if block_hit:
90
+ stats.blocks_dropped += 1
91
+ stats._bump(f"block:{block_hit.name}")
92
+ stats.lines_dropped += 1
93
+ i += 1
94
+ while i < len(lines) and (
95
+ is_block_continuation(lines[i])
96
+ or is_block_terminator(lines[i])
97
+ ):
98
+ stats.lines_dropped += 1
99
+ i += 1
100
+ continue
101
+
102
+ line_hit = next(
103
+ (r for r in line_rules if r.pattern.match(line)), None
104
+ )
105
+ if line_hit:
106
+ stats.lines_dropped += 1
107
+ stats._bump(f"line:{line_hit.name}")
108
+ i += 1
109
+ continue
110
+
111
+ out.append(line)
112
+ i += 1
113
+
114
+ return out, stats
115
+
116
+ def mask_pass(
117
+ self,
118
+ raw: str,
119
+ *,
120
+ rewrite_rules: list[RewriteRule],
121
+ ) -> tuple[list[str], OpStats]:
122
+ stats = OpStats()
123
+ lines = raw.splitlines()
124
+ stats.lines_in = len(lines)
125
+ out: list[str] = []
126
+
127
+ for line in lines:
128
+ rewritten = line
129
+ for rule in rewrite_rules:
130
+ new = rule.pattern.sub(rule.replacement, rewritten)
131
+ if new != rewritten:
132
+ stats.lines_redacted += 1
133
+ stats._bump(f"redact:{rule.name}")
134
+ rewritten = new
135
+ break
136
+ out.append(rewritten)
137
+
138
+ stats.lines_out = len(out)
139
+ return out, stats
140
+
141
+ @staticmethod
142
+ def collapse_and_trim(
143
+ lines: list[str],
144
+ stats: OpStats,
145
+ comment_marker: str = "!",
146
+ ) -> list[str]:
147
+ collapsed: list[str] = []
148
+ prev_marker = False
149
+ for line in lines:
150
+ is_marker = line.strip() == comment_marker
151
+ if is_marker and prev_marker:
152
+ stats.lines_dropped += 1
153
+ stats._bump("post:collapse_comment")
154
+ continue
155
+ collapsed.append(line)
156
+ prev_marker = is_marker
157
+
158
+ while collapsed and collapsed[0].strip() in ("", comment_marker):
159
+ collapsed.pop(0)
160
+ while collapsed and collapsed[-1].strip() in ("", comment_marker):
161
+ collapsed.pop()
162
+ return collapsed
File without changes