huginn-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. huginn/__init__.py +41 -0
  2. huginn/_version.py +24 -0
  3. huginn/brokers/__init__.py +80 -0
  4. huginn/brokers/exceptions.py +46 -0
  5. huginn/brokers/http.py +436 -0
  6. huginn/brokers/netconf.py +574 -0
  7. huginn/brokers/null.py +17 -0
  8. huginn/brokers/protocol.py +265 -0
  9. huginn/brokers/ssh.py +438 -0
  10. huginn/cli.py +1658 -0
  11. huginn/context.py +34 -0
  12. huginn/enums.py +76 -0
  13. huginn/execute.py +344 -0
  14. huginn/hooks.py +112 -0
  15. huginn/inject.py +435 -0
  16. huginn/inventory_plugins.py +129 -0
  17. huginn/jobs.py +123 -0
  18. huginn/loaders.py +1057 -0
  19. huginn/logging_helpers.py +24 -0
  20. huginn/models.py +296 -0
  21. huginn/output.py +161 -0
  22. huginn/parameters.py +65 -0
  23. huginn/plan_filtering.py +266 -0
  24. huginn/plugin_registry.py +239 -0
  25. huginn/prune.py +554 -0
  26. huginn/py.typed +0 -0
  27. huginn/reconcile.py +561 -0
  28. huginn/reporting/__init__.py +1 -0
  29. huginn/reporting/html.py +485 -0
  30. huginn/reporting/protocol.py +50 -0
  31. huginn/reporting/static/styles.css +988 -0
  32. huginn/reporting/templates/dashboard.html.j2 +318 -0
  33. huginn/reporting/templates/test_case_detail.html.j2 +221 -0
  34. huginn/result_store.py +270 -0
  35. huginn/results.py +99 -0
  36. huginn/runner.py +1970 -0
  37. huginn/runtime_broker.py +687 -0
  38. huginn/testcase.py +207 -0
  39. huginn/utils/__init__.py +1 -0
  40. huginn/utils/commands.py +20 -0
  41. huginn/validation.py +417 -0
  42. huginn/volatile.py +548 -0
  43. huginn_framework-0.1.0.dist-info/METADATA +46 -0
  44. huginn_framework-0.1.0.dist-info/RECORD +47 -0
  45. huginn_framework-0.1.0.dist-info/WHEEL +4 -0
  46. huginn_framework-0.1.0.dist-info/entry_points.txt +13 -0
  47. huginn_framework-0.1.0.dist-info/licenses/LICENSE +201 -0
huginn/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """A test automation framework for infrastructure."""
2
+
3
+ from huginn._version import __version__, __version_tuple__
4
+ from huginn.cli import main
5
+ from huginn.context import Context
6
+ from huginn.enums import ExecutionMode, ResultStatus
7
+ from huginn.execute import ExecuteCommandResult, ExecuteCommandSpec, execute_commands
8
+ from huginn.output import Output
9
+ from huginn.testcase import CommandSupportResult, LearningTestCase, TestCase
10
+ from huginn.utils.commands import is_command_unsupported
11
+ from huginn.volatile import (
12
+ Observation,
13
+ OperatorVolatileDeviceParameters,
14
+ OperatorVolatileLearningTestCase,
15
+ OperatorVolatileParameters,
16
+ VolatileLearningTestCase,
17
+ parse_duration_seconds,
18
+ )
19
+
20
+ __all__ = [
21
+ "__version__",
22
+ "__version_tuple__",
23
+ "main",
24
+ "is_command_unsupported",
25
+ "ExecutionMode",
26
+ "ExecuteCommandResult",
27
+ "ExecuteCommandSpec",
28
+ "execute_commands",
29
+ "ResultStatus",
30
+ "Output",
31
+ "Context",
32
+ "TestCase",
33
+ "LearningTestCase",
34
+ "CommandSupportResult",
35
+ "Observation",
36
+ "VolatileLearningTestCase",
37
+ "OperatorVolatileDeviceParameters",
38
+ "OperatorVolatileLearningTestCase",
39
+ "OperatorVolatileParameters",
40
+ "parse_duration_seconds",
41
+ ]
huginn/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,80 @@
1
+ """Connection broker implementations for Huginn.
2
+
3
+ This module provides the protocol definitions and implementations for
4
+ connection brokers that manage device connections.
5
+
6
+ Classes:
7
+ ConnectionBrokerProtocolV1: Protocol interface for connection brokers.
8
+ SSHBroker: SSH connection broker using Scrapli.
9
+ HTTPBroker: HTTP connection broker using aiohttp.
10
+ NETCONFBroker: NETCONF connection broker using scrapli_netconf.
11
+
12
+ Data Classes:
13
+ ConnectionHandle: Handle representing a managed connection.
14
+ ConnectionConfig: Configuration for establishing a connection.
15
+ CommandResult: Result of executing a command.
16
+
17
+ Enums:
18
+ ConnectionState: State of a connection.
19
+
20
+ Exceptions:
21
+ BrokerError: Base exception for broker errors.
22
+ ConnectionError: Connection establishment failed.
23
+ AuthenticationError: Authentication failed.
24
+ TimeoutError: Operation timed out.
25
+ NotConnectedError: Operation attempted on disconnected device.
26
+ InvalidHandleError: Invalid connection handle.
27
+ CommandError: Command execution failed.
28
+ ConfigurationError: Configuration operation failed.
29
+ OperationError: Generic operation error.
30
+ CapabilityError: Unsupported capability.
31
+ """
32
+
33
+ from huginn.brokers.exceptions import (
34
+ AuthenticationError,
35
+ BrokerError,
36
+ CapabilityError,
37
+ CommandError,
38
+ ConfigurationError,
39
+ ConnectionError,
40
+ InvalidHandleError,
41
+ NotConnectedError,
42
+ OperationError,
43
+ TimeoutError,
44
+ )
45
+ from huginn.brokers.http import HTTPBroker
46
+ from huginn.brokers.netconf import NETCONFBroker
47
+ from huginn.brokers.protocol import (
48
+ CommandResult,
49
+ ConnectionBrokerProtocolV1,
50
+ ConnectionConfig,
51
+ ConnectionHandle,
52
+ ConnectionState,
53
+ )
54
+ from huginn.brokers.ssh import SSHBroker
55
+
56
+ __all__ = [
57
+ # Protocol
58
+ "ConnectionBrokerProtocolV1",
59
+ # Data classes
60
+ "CommandResult",
61
+ "ConnectionConfig",
62
+ "ConnectionHandle",
63
+ # Enums
64
+ "ConnectionState",
65
+ # Implementations
66
+ "HTTPBroker",
67
+ "NETCONFBroker",
68
+ "SSHBroker",
69
+ # Exceptions
70
+ "AuthenticationError",
71
+ "BrokerError",
72
+ "CapabilityError",
73
+ "CommandError",
74
+ "ConfigurationError",
75
+ "ConnectionError",
76
+ "InvalidHandleError",
77
+ "NotConnectedError",
78
+ "OperationError",
79
+ "TimeoutError",
80
+ ]
@@ -0,0 +1,46 @@
1
+ """Exception hierarchy for connection brokers.
2
+
3
+ All broker-related exceptions inherit from BrokerError, allowing
4
+ callers to catch all broker exceptions with a single except clause
5
+ or specific exceptions as needed.
6
+ """
7
+
8
+
9
+ class BrokerError(Exception):
10
+ """Base exception for all broker errors."""
11
+
12
+
13
+ class ConnectionError(BrokerError):
14
+ """Failed to establish connection to device."""
15
+
16
+
17
+ class AuthenticationError(BrokerError):
18
+ """Authentication failed when connecting to device."""
19
+
20
+
21
+ class TimeoutError(BrokerError):
22
+ """Operation timed out."""
23
+
24
+
25
+ class NotConnectedError(BrokerError):
26
+ """Attempted operation on a device that is not connected."""
27
+
28
+
29
+ class InvalidHandleError(BrokerError):
30
+ """The connection handle is invalid or expired."""
31
+
32
+
33
+ class CommandError(BrokerError):
34
+ """Command execution failed."""
35
+
36
+
37
+ class ConfigurationError(BrokerError):
38
+ """Configuration operation failed."""
39
+
40
+
41
+ class OperationError(BrokerError):
42
+ """Generic operation error."""
43
+
44
+
45
+ class CapabilityError(BrokerError):
46
+ """Broker does not support the requested capability."""
huginn/brokers/http.py ADDED
@@ -0,0 +1,436 @@
1
+ """HTTP connection broker using aiohttp.
2
+
3
+ This module provides an HTTP broker implementation that uses aiohttp
4
+ for RESTful API connectivity. It supports GET and edit (POST/PUT/PATCH/DELETE)
5
+ operations.
6
+ """
7
+
8
+ import json
9
+ import time
10
+ import uuid
11
+ from typing import Any
12
+
13
+ import aiohttp
14
+ from aiohttp import ClientResponseError, ClientTimeout, ServerTimeoutError
15
+
16
+ from huginn.brokers.exceptions import (
17
+ AuthenticationError,
18
+ CapabilityError,
19
+ ConfigurationError,
20
+ ConnectionError,
21
+ InvalidHandleError,
22
+ NotConnectedError,
23
+ OperationError,
24
+ TimeoutError,
25
+ )
26
+ from huginn.brokers.protocol import (
27
+ CommandResult,
28
+ ConnectionConfig,
29
+ ConnectionHandle,
30
+ ConnectionState,
31
+ )
32
+
33
+ PROTOCOL_VERSION = 1
34
+
35
+
36
+ class HTTPBroker:
37
+ """HTTP connection broker using aiohttp.
38
+
39
+ This broker manages HTTP sessions for RESTful API interactions using
40
+ aiohttp's ClientSession. It supports GET and edit operations.
41
+
42
+ Unlike SSH connections which maintain a persistent channel, HTTP
43
+ connections are stateless. This broker manages a session with
44
+ connection pooling and shared configuration (headers, auth, timeouts).
45
+
46
+ Attributes:
47
+ PROTOCOL_VERSION: The protocol version this broker implements.
48
+ """
49
+
50
+ PROTOCOL_VERSION: int = PROTOCOL_VERSION
51
+
52
+ def __init__(self, broker_id: str | None = None) -> None:
53
+ """Initialize the HTTP broker.
54
+
55
+ Args:
56
+ broker_id: Optional unique identifier for this broker instance.
57
+ If not provided, a UUID will be generated.
58
+ """
59
+ self._broker_id = broker_id or str(uuid.uuid4())
60
+ self._sessions: dict[str, aiohttp.ClientSession] = {}
61
+ self._configs: dict[str, ConnectionConfig] = {}
62
+
63
+ @property
64
+ def name(self) -> str:
65
+ """Return the broker name."""
66
+ return "http"
67
+
68
+ @property
69
+ def connection_type(self) -> str:
70
+ """Return the connection type this broker handles."""
71
+ return "http"
72
+
73
+ def capabilities(self) -> set[str]:
74
+ """Return the set of capabilities this broker supports.
75
+
76
+ Returns:
77
+ Set containing 'get' and 'edit' for REST operations.
78
+ """
79
+ return {"get", "edit"}
80
+
81
+ def cache_key(self, operation: str, **kwargs: Any) -> str | None: # noqa: ANN401
82
+ """Generate a cache key for the given operation.
83
+
84
+ GET operations are cacheable by path. Edit operations (POST/PUT/PATCH/DELETE)
85
+ are not cacheable since they modify state.
86
+
87
+ Args:
88
+ operation: The operation type.
89
+ **kwargs: Operation-specific arguments.
90
+
91
+ Returns:
92
+ The path string for GET operations, None for edit operations.
93
+ """
94
+ if operation == "get":
95
+ return kwargs.get("path")
96
+ return None
97
+
98
+ async def connect(self, config: ConnectionConfig) -> ConnectionHandle:
99
+ """Create an HTTP session for API interactions.
100
+
101
+ Args:
102
+ config: Connection configuration including host, credentials, options.
103
+
104
+ Returns:
105
+ A handle representing the session.
106
+
107
+ Raises:
108
+ ConnectionError: If session cannot be created.
109
+ """
110
+ # Build base URL from host and port
111
+ scheme = config.options.get("scheme", "https")
112
+ base_url = config.options.get("base_url")
113
+ if base_url is None:
114
+ port_suffix = f":{config.port}" if config.port not in (80, 443) else ""
115
+ base_url = f"{scheme}://{config.host}{port_suffix}"
116
+
117
+ # Build default headers
118
+ headers: dict[str, str] = {
119
+ "Content-Type": "application/json",
120
+ "Accept": "application/json",
121
+ }
122
+ headers.update(config.options.get("headers", {}))
123
+
124
+ # Add authentication
125
+ auth = None
126
+ if "username" in config.credentials and "password" in config.credentials:
127
+ auth = aiohttp.BasicAuth(
128
+ config.credentials["username"],
129
+ config.credentials["password"],
130
+ )
131
+ elif "token" in config.credentials:
132
+ token = config.credentials["token"]
133
+ token_type = config.credentials.get("token_type", "Bearer")
134
+ headers["Authorization"] = f"{token_type} {token}"
135
+
136
+ # Configure timeouts
137
+ timeout_total = config.options.get("timeout", 30)
138
+ timeout_connect = config.options.get("timeout_connect", 10)
139
+ timeout = ClientTimeout(total=timeout_total, connect=timeout_connect)
140
+
141
+ # SSL verification
142
+ ssl_verify = config.options.get("verify_ssl", True)
143
+ connector = aiohttp.TCPConnector(ssl=ssl_verify)
144
+
145
+ try:
146
+ session = aiohttp.ClientSession(
147
+ base_url=base_url,
148
+ headers=headers,
149
+ auth=auth,
150
+ timeout=timeout,
151
+ connector=connector,
152
+ )
153
+ except Exception as e:
154
+ raise ConnectionError(f"Failed to create HTTP session: {e}") from e
155
+
156
+ self._sessions[config.device_name] = session
157
+ self._configs[config.device_name] = config
158
+
159
+ return ConnectionHandle(
160
+ broker_id=self._broker_id,
161
+ device_name=config.device_name,
162
+ connection_type=self.connection_type,
163
+ state=ConnectionState.CONNECTED,
164
+ metadata={"base_url": base_url},
165
+ )
166
+
167
+ async def disconnect(self, handle: ConnectionHandle) -> None:
168
+ """Close an HTTP session.
169
+
170
+ Args:
171
+ handle: The connection handle to disconnect.
172
+
173
+ Raises:
174
+ InvalidHandleError: If the handle is invalid.
175
+ """
176
+ if handle.device_name not in self._sessions:
177
+ raise InvalidHandleError(f"No session found for {handle.device_name}")
178
+
179
+ session = self._sessions.pop(handle.device_name)
180
+ self._configs.pop(handle.device_name, None)
181
+ await session.close()
182
+
183
+ async def is_alive(self, handle: ConnectionHandle) -> bool:
184
+ """Check if a session is still usable.
185
+
186
+ For HTTP, this checks if the session exists and is not closed.
187
+
188
+ Args:
189
+ handle: The connection handle to check.
190
+
191
+ Returns:
192
+ True if the session is usable, False otherwise.
193
+ """
194
+ if handle.device_name not in self._sessions:
195
+ return False
196
+
197
+ session = self._sessions[handle.device_name]
198
+ return not session.closed
199
+
200
+ async def reconnect(self, handle: ConnectionHandle) -> ConnectionHandle:
201
+ """Recreate an HTTP session.
202
+
203
+ Args:
204
+ handle: The connection handle to reconnect.
205
+
206
+ Returns:
207
+ A new handle for the recreated session.
208
+
209
+ Raises:
210
+ InvalidHandleError: If no config exists for this handle.
211
+ ConnectionError: If reconnection fails.
212
+ """
213
+ if handle.device_name not in self._configs:
214
+ raise InvalidHandleError(f"No configuration found for {handle.device_name}")
215
+
216
+ if handle.device_name in self._sessions:
217
+ session = self._sessions.pop(handle.device_name)
218
+ try:
219
+ await session.close()
220
+ except Exception:
221
+ pass # nosec B110 - intentional: best-effort cleanup during reconnect
222
+
223
+ config = self._configs[handle.device_name]
224
+ return await self.connect(config)
225
+
226
+ async def execute(
227
+ self,
228
+ handle: ConnectionHandle,
229
+ command: str,
230
+ **kwargs: Any, # noqa: ANN401
231
+ ) -> CommandResult:
232
+ """Execute a command.
233
+
234
+ HTTP broker does not support execute operations.
235
+ Use get() or edit() for REST API interactions.
236
+
237
+ Args:
238
+ handle: The connection handle.
239
+ command: The command to execute.
240
+ **kwargs: Additional options.
241
+
242
+ Raises:
243
+ CapabilityError: Always, as HTTP does not support execute.
244
+ """
245
+ raise CapabilityError(
246
+ "HTTP broker does not support execute. Use get() or edit() instead."
247
+ )
248
+
249
+ async def configure(
250
+ self,
251
+ handle: ConnectionHandle,
252
+ commands: list[str],
253
+ **kwargs: Any, # noqa: ANN401
254
+ ) -> CommandResult:
255
+ """Send configuration commands.
256
+
257
+ HTTP broker does not support configure operations.
258
+ Use edit() for REST API modifications.
259
+
260
+ Args:
261
+ handle: The connection handle.
262
+ commands: List of configuration commands.
263
+ **kwargs: Additional options.
264
+
265
+ Raises:
266
+ CapabilityError: Always, as HTTP does not support configure.
267
+ """
268
+ raise CapabilityError(
269
+ "HTTP broker does not support configure. Use edit() instead."
270
+ )
271
+
272
+ def _parse_request_body(self, config: str | Any) -> Any: # noqa: ANN401
273
+ """Parse request body, attempting JSON decode if string.
274
+
275
+ Args:
276
+ config: The request body (JSON string or already parsed).
277
+
278
+ Returns:
279
+ Parsed JSON object if config was a valid JSON string,
280
+ otherwise returns config as-is.
281
+ """
282
+ if isinstance(config, str):
283
+ try:
284
+ return json.loads(config)
285
+ except json.JSONDecodeError:
286
+ return config
287
+ return config
288
+
289
+ async def get(
290
+ self,
291
+ handle: ConnectionHandle,
292
+ path: str,
293
+ **kwargs: Any, # noqa: ANN401
294
+ ) -> CommandResult:
295
+ """Perform an HTTP GET request.
296
+
297
+ Args:
298
+ handle: The connection handle.
299
+ path: The API endpoint path (e.g., "/api/v1/interfaces").
300
+ **kwargs: Additional options:
301
+ - params: Query parameters dict
302
+ - headers: Additional headers dict
303
+
304
+ Returns:
305
+ CommandResult with response body and optional parsed JSON.
306
+
307
+ Raises:
308
+ NotConnectedError: If not connected.
309
+ AuthenticationError: If authentication fails (401/403).
310
+ TimeoutError: If the request times out.
311
+ OperationError: If the request fails.
312
+ """
313
+ if handle.device_name not in self._sessions:
314
+ raise NotConnectedError(f"No session for {handle.device_name}")
315
+
316
+ session = self._sessions[handle.device_name]
317
+ params = kwargs.get("params")
318
+ headers = kwargs.get("headers")
319
+
320
+ start_time = time.monotonic()
321
+ try:
322
+ async with session.get(path, params=params, headers=headers) as response:
323
+ body = await response.text()
324
+ elapsed_ms = (time.monotonic() - start_time) * 1000
325
+
326
+ # Try to parse JSON
327
+ structured = None
328
+ try:
329
+ structured = await response.json()
330
+ except (json.JSONDecodeError, aiohttp.ContentTypeError):
331
+ pass
332
+
333
+ # Check for auth errors
334
+ if response.status in (401, 403):
335
+ raise AuthenticationError(
336
+ f"Authentication failed for {path}: {response.status}"
337
+ )
338
+
339
+ # Raise for other error status codes
340
+ response.raise_for_status()
341
+
342
+ return CommandResult(
343
+ output=body,
344
+ structured=structured,
345
+ elapsed_ms=elapsed_ms,
346
+ cached=False,
347
+ )
348
+ except AuthenticationError:
349
+ raise
350
+ except ServerTimeoutError as e:
351
+ raise TimeoutError(f"GET {path} timed out: {e}") from e
352
+ except ClientResponseError as e:
353
+ raise OperationError(f"GET {path} failed: {e.status} {e.message}") from e
354
+ except Exception as e:
355
+ raise OperationError(f"GET {path} failed: {e}") from e
356
+
357
+ async def edit(
358
+ self,
359
+ handle: ConnectionHandle,
360
+ config: str,
361
+ **kwargs: Any, # noqa: ANN401
362
+ ) -> CommandResult:
363
+ """Perform an HTTP edit operation (POST/PUT/PATCH/DELETE).
364
+
365
+ Args:
366
+ handle: The connection handle.
367
+ config: The request body (JSON string or will be serialized).
368
+ **kwargs: Additional options:
369
+ - path: The API endpoint path (required)
370
+ - method: HTTP method (default: "POST")
371
+ - headers: Additional headers dict
372
+ - params: Query parameters dict
373
+
374
+ Returns:
375
+ CommandResult with response body and optional parsed JSON.
376
+
377
+ Raises:
378
+ NotConnectedError: If not connected.
379
+ ConfigurationError: If path is not provided.
380
+ AuthenticationError: If authentication fails (401/403).
381
+ TimeoutError: If the request times out.
382
+ OperationError: If the request fails.
383
+ """
384
+ if handle.device_name not in self._sessions:
385
+ raise NotConnectedError(f"No session for {handle.device_name}")
386
+
387
+ path = kwargs.get("path")
388
+ if not path:
389
+ raise ConfigurationError("path is required for edit operations")
390
+
391
+ session = self._sessions[handle.device_name]
392
+ method = kwargs.get("method", "POST").upper()
393
+ headers = kwargs.get("headers")
394
+ params = kwargs.get("params")
395
+ data = self._parse_request_body(config)
396
+
397
+ start_time = time.monotonic()
398
+ try:
399
+ async with session.request(
400
+ method, path, json=data, headers=headers, params=params
401
+ ) as response:
402
+ body = await response.text()
403
+ elapsed_ms = (time.monotonic() - start_time) * 1000
404
+
405
+ # Try to parse JSON
406
+ structured = None
407
+ try:
408
+ structured = await response.json()
409
+ except (json.JSONDecodeError, aiohttp.ContentTypeError):
410
+ pass
411
+
412
+ # Check for auth errors
413
+ if response.status in (401, 403):
414
+ raise AuthenticationError(
415
+ f"Authentication failed for {method} {path}: {response.status}"
416
+ )
417
+
418
+ # Raise for other error status codes
419
+ response.raise_for_status()
420
+
421
+ return CommandResult(
422
+ output=body,
423
+ structured=structured,
424
+ elapsed_ms=elapsed_ms,
425
+ cached=False,
426
+ )
427
+ except AuthenticationError:
428
+ raise
429
+ except ServerTimeoutError as e:
430
+ raise TimeoutError(f"{method} {path} timed out: {e}") from e
431
+ except ClientResponseError as e:
432
+ raise OperationError(
433
+ f"{method} {path} failed: {e.status} {e.message}"
434
+ ) from e
435
+ except Exception as e:
436
+ raise OperationError(f"{method} {path} failed: {e}") from e