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.
- huginn/__init__.py +41 -0
- huginn/_version.py +24 -0
- huginn/brokers/__init__.py +80 -0
- huginn/brokers/exceptions.py +46 -0
- huginn/brokers/http.py +436 -0
- huginn/brokers/netconf.py +574 -0
- huginn/brokers/null.py +17 -0
- huginn/brokers/protocol.py +265 -0
- huginn/brokers/ssh.py +438 -0
- huginn/cli.py +1658 -0
- huginn/context.py +34 -0
- huginn/enums.py +76 -0
- huginn/execute.py +344 -0
- huginn/hooks.py +112 -0
- huginn/inject.py +435 -0
- huginn/inventory_plugins.py +129 -0
- huginn/jobs.py +123 -0
- huginn/loaders.py +1057 -0
- huginn/logging_helpers.py +24 -0
- huginn/models.py +296 -0
- huginn/output.py +161 -0
- huginn/parameters.py +65 -0
- huginn/plan_filtering.py +266 -0
- huginn/plugin_registry.py +239 -0
- huginn/prune.py +554 -0
- huginn/py.typed +0 -0
- huginn/reconcile.py +561 -0
- huginn/reporting/__init__.py +1 -0
- huginn/reporting/html.py +485 -0
- huginn/reporting/protocol.py +50 -0
- huginn/reporting/static/styles.css +988 -0
- huginn/reporting/templates/dashboard.html.j2 +318 -0
- huginn/reporting/templates/test_case_detail.html.j2 +221 -0
- huginn/result_store.py +270 -0
- huginn/results.py +99 -0
- huginn/runner.py +1970 -0
- huginn/runtime_broker.py +687 -0
- huginn/testcase.py +207 -0
- huginn/utils/__init__.py +1 -0
- huginn/utils/commands.py +20 -0
- huginn/validation.py +417 -0
- huginn/volatile.py +548 -0
- huginn_framework-0.1.0.dist-info/METADATA +46 -0
- huginn_framework-0.1.0.dist-info/RECORD +47 -0
- huginn_framework-0.1.0.dist-info/WHEEL +4 -0
- huginn_framework-0.1.0.dist-info/entry_points.txt +13 -0
- 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
|