spoe-forge 0.0.1__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.
spoe_forge/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """SPOE Forge - A Python framework for building HAProxy SPOA agents."""
2
+
3
+ from spoe_forge.spoe_forge import SpoeForge
4
+ from spoe_forge.spop.spop_types import SetVarAction, UnsetVarAction, Action
5
+ from spoe_forge.spop.constants import ActionScope
6
+ from spoe_forge.agent.context import AgentContext
7
+
8
+ __all__ = [
9
+ "SpoeForge",
10
+ "AgentContext",
11
+ "Action",
12
+ "ActionScope",
13
+ "SetVarAction",
14
+ "UnsetVarAction",
15
+ ]
File without changes
@@ -0,0 +1,53 @@
1
+ import logging
2
+
3
+ from spoe_forge.spop.spop_types import SpoaDataType
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class AgentContext:
9
+ """
10
+ Context object passed to message handlers containing message data.
11
+
12
+ Provides access to message name and arguments from HAProxy NOTIFY frames.
13
+ """
14
+
15
+ def __init__(self, message: str, args: dict[str, SpoaDataType]):
16
+ """
17
+ Create a new agent context.
18
+
19
+ :param str message: Message name from NOTIFY frame
20
+ :param dict[str, SpoaDataType] args: Message arguments
21
+ """
22
+ self._message = message
23
+ self._args = args
24
+
25
+ def get_arg(self, arg: str, default: SpoaDataType = None) -> SpoaDataType:
26
+ """
27
+ Retrieve a message argument by name.
28
+
29
+ :param str arg: Argument name to retrieve
30
+ :param default: Default value to return if argument is not present.
31
+ :return: Argument value, or None if not found
32
+ """
33
+ try:
34
+ return self._args[arg]
35
+ except KeyError:
36
+ return default
37
+
38
+ def get_args(self) -> dict[str, SpoaDataType]:
39
+ """
40
+ Retrieve all message arguments.
41
+
42
+ :return: Dictionary mapping argument names to values
43
+ """
44
+ return self._args
45
+
46
+ def has_arg(self, arg: str) -> bool:
47
+ """
48
+ Check if an argument exists in the message.
49
+
50
+ :param str arg: Argument name to check
51
+ :return: True if argument exists, False otherwise
52
+ """
53
+ return arg in self._args
@@ -0,0 +1,7 @@
1
+ from spoe_forge.exception import SpoeForgeError
2
+
3
+
4
+ class SpoeAgentError(SpoeForgeError):
5
+ """Exception raised when agent message processing fails."""
6
+
7
+ pass
@@ -0,0 +1,67 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Callable
4
+
5
+ from spoe_forge.agent.context import AgentContext
6
+ from spoe_forge.agent.exceptions import SpoeAgentError
7
+ from spoe_forge.spop.spop_types import SpoaDataType, Action
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ MessageHandlerFunc = Callable[[AgentContext], list[Action]]
12
+
13
+
14
+ class AgentRegistry:
15
+ """
16
+ Internal registry mapping message names to handler functions.
17
+
18
+ Note: Users don't interact with this directly - it's used by the Agent class.
19
+ """
20
+
21
+ def __init__(self):
22
+ """Initialize a new agent registry."""
23
+ self._handlers: dict[str, MessageHandlerFunc] = {}
24
+ self._validation_cache: dict[str, bool] = {}
25
+
26
+ def register(self, message_name: str, handler: MessageHandlerFunc) -> None:
27
+ """
28
+ Register a handler for a message type.
29
+
30
+ :param str message_name: Message name to handle
31
+ :param MessageHandlerFunc handler: User-defined handler function
32
+ """
33
+ if message_name in self._handlers:
34
+ logger.warning(f"Overwriting existing handler for message '{message_name}'")
35
+
36
+ self._handlers[message_name] = handler
37
+ logger.debug(f"Registered handler for message '{message_name}'")
38
+
39
+ async def handle_message(
40
+ self, message: str, args: dict[str, SpoaDataType]
41
+ ) -> list[Action]:
42
+ """
43
+ Route a message to its registered handler.
44
+
45
+ Wraps synchronous handlers with asyncio.to_thread for async compatibility.
46
+
47
+ :param str message: Message name to handle
48
+ :param dict[str, SpoaDataType] args: Message arguments
49
+ :return: List of actions from handler (empty if no handler registered)
50
+ :raises SpoeAgentError: If handler raises an exception
51
+ """
52
+ handler = self._handlers.get(message)
53
+
54
+ if handler is None:
55
+ logger.warning(f"No handler registered for message '{message}'")
56
+ return []
57
+
58
+ ctx = AgentContext(message, args)
59
+ try:
60
+ return await asyncio.to_thread(handler, ctx)
61
+ except Exception as e:
62
+ logger.error(
63
+ f"Error in handler for message '{message}': {e}", exc_info=True
64
+ )
65
+ # Convert to our own error - will be caught in our own handlers to
66
+ # cause a graceful kill of the connection
67
+ raise SpoeAgentError(e)
@@ -0,0 +1,4 @@
1
+ class SpoeForgeError(Exception):
2
+ """Base exception class for all SPOE Forge errors."""
3
+
4
+ pass
spoe_forge/log.py ADDED
@@ -0,0 +1,60 @@
1
+ import logging
2
+ import sys
3
+
4
+
5
+ _default_handler = logging.StreamHandler(sys.stderr)
6
+ _default_handler.setFormatter(
7
+ logging.Formatter("%(levelname)s | %(asctime)s | %(name)s | %(message)s")
8
+ )
9
+
10
+
11
+ def _has_level_handler(logger: logging.Logger) -> bool:
12
+ """
13
+ Check if there is a handler in the logging chain that will handle the
14
+ given logger's effective level.
15
+
16
+ This traverses the logger hierarchy to check if any parent logger
17
+ has a handler configured. Based on Flask's implementation.
18
+
19
+ :param logger: Logger to check
20
+ :return: True if a handler exists in the chain, False otherwise
21
+ """
22
+ level = logger.getEffectiveLevel()
23
+ current = logger
24
+
25
+ while current:
26
+ if any(handler.level <= level for handler in current.handlers):
27
+ return True
28
+
29
+ if not current.propagate:
30
+ break
31
+
32
+ current = current.parent
33
+
34
+ return False
35
+
36
+
37
+ def create_logger(debug: bool = False) -> logging.Logger:
38
+ """
39
+ Get the SPOE Forge logger and configure it if needed.
40
+
41
+ This follows Flask's pattern:
42
+ - Uses 'spoe_forge' as the logger name
43
+ - Only adds default handler if no handler exists in the hierarchy
44
+ - Respects existing logging configuration
45
+
46
+ Internal function - called by spoe_forge.py at import time.
47
+
48
+ :return: Configured logger instance
49
+ """
50
+ logger = logging.getLogger("spoe_forge")
51
+
52
+ # Only add handler if none exists in the hierarchy
53
+ if not _has_level_handler(logger):
54
+ logger.addHandler(_default_handler)
55
+
56
+ # Set default level if not already set
57
+ if not logger.level:
58
+ logger.setLevel(logging.DEBUG if debug else logging.INFO)
59
+
60
+ return logger
File without changes
@@ -0,0 +1,135 @@
1
+ import logging
2
+ from math import floor
3
+
4
+ from spoe_forge.server.constants import DEFAULT_MAX_FRAME_SIZE
5
+ from spoe_forge.server.constants import SPOE_CAPABILITIES
6
+ from spoe_forge.server.constants import SPOE_VERSION
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ServerConfiguration:
12
+ """
13
+ Manages SPOP protocol negotiation for a single connection.
14
+
15
+ Fresh instance created per connection to negotiate version, capabilities,
16
+ and max frame size with HAProxy.
17
+ """
18
+
19
+ _server_compatible: bool
20
+
21
+ _max_frame_size: int
22
+ _capabilities: list[str]
23
+ _version: str
24
+
25
+ def __init__(self, max_frame_size: int = DEFAULT_MAX_FRAME_SIZE) -> None:
26
+ """
27
+ Initialize server configuration with defaults.
28
+
29
+ :param int max_frame_size: Maximum frame size to negotiate with HAProxy
30
+ """
31
+ self._version = SPOE_VERSION
32
+ self._capabilities = []
33
+
34
+ self._max_frame_size = (
35
+ max_frame_size # Set to default until negotiation complete
36
+ )
37
+ self._server_compatible = True
38
+
39
+ async def _check_version_compatibility(self, ha_versions: list[str]):
40
+ """
41
+ Check if server version is compatible with HAProxy versions.
42
+
43
+ :param list[str] ha_versions: Versions supported by HAProxy
44
+ """
45
+ float_ver = float(self._version)
46
+ for version in ha_versions:
47
+ float_ha_ver = float(version)
48
+ if floor(float_ha_ver) != floor(float_ha_ver):
49
+ continue
50
+
51
+ if float_ver <= float_ha_ver:
52
+ self._server_compatible = True
53
+ return
54
+
55
+ self._server_compatible = False
56
+ logger.error(
57
+ f"No compatible versions found for server compatibility. "
58
+ f"Agent supports {self._version}. "
59
+ f"HAProxy supports {', '.join(ha_versions)}."
60
+ )
61
+
62
+ async def _find_max_frame_size(self, ha_max_frame_size: int):
63
+ """
64
+ Negotiate maximum frame size with HAProxy.
65
+
66
+ Takes the minimum of server and HAProxy frame sizes.
67
+
68
+ :param int ha_max_frame_size: Maximum frame size supported by HAProxy
69
+ """
70
+ self._max_frame_size = min(ha_max_frame_size, self._max_frame_size)
71
+
72
+ if self.max_frame_size <= 0:
73
+ logger.error(
74
+ f"Frame size negotiation failed: negotiated size {self._max_frame_size} is not positive"
75
+ )
76
+ self._server_compatible = False
77
+ return
78
+
79
+ async def _find_common_capabilities(self, ha_capabilities: list[str]):
80
+ """
81
+ Find common capabilities between server and HAProxy.
82
+
83
+ :param list[str] ha_capabilities: Capabilities supported by HAProxy
84
+ """
85
+ self._capabilities = list(set(ha_capabilities) & set(SPOE_CAPABILITIES))
86
+
87
+ if "pipelining" not in self._capabilities:
88
+ logger.warning(
89
+ "Pipelining capability is not supported by HAProxy. "
90
+ "Req/Resp cycles may be slower than they could be."
91
+ )
92
+
93
+ # We don't set compatability here as pipelining is the only option available, and we support it w/o
94
+ # changes to the system - instead just log a warning that HAProxy won't take advantage of it and
95
+ # move on
96
+
97
+ async def negotiate_server_compatibility(
98
+ self,
99
+ supported_versions: list[str],
100
+ ha_max_frame_size: int,
101
+ ha_capabilities: list[str],
102
+ ) -> None:
103
+ """
104
+ Negotiate protocol compatibility with HAProxy.
105
+
106
+ Checks version, frame size, and capabilities to determine if connection
107
+ can proceed.
108
+
109
+ :param list[str] supported_versions: Versions supported by HAProxy
110
+ :param int ha_max_frame_size: Maximum frame size supported by HAProxy
111
+ :param list[str] ha_capabilities: Capabilities supported by HAProxy
112
+ """
113
+ await self._check_version_compatibility(supported_versions)
114
+ await self._find_max_frame_size(ha_max_frame_size)
115
+ await self._find_common_capabilities(ha_capabilities)
116
+
117
+ @property
118
+ def is_compatible(self) -> bool:
119
+ """Check if negotiation succeeded and connection is compatible."""
120
+ return self._server_compatible
121
+
122
+ @property
123
+ def version(self) -> str:
124
+ """Get negotiated SPOP protocol version."""
125
+ return self._version
126
+
127
+ @property
128
+ def max_frame_size(self) -> int:
129
+ """Get negotiated maximum frame size."""
130
+ return self._max_frame_size
131
+
132
+ @property
133
+ def capabilities(self) -> list[str]:
134
+ """Get negotiated capabilities shared by server and HAProxy."""
135
+ return self._capabilities
@@ -0,0 +1,33 @@
1
+ from enum import IntEnum
2
+
3
+ SPOE_VERSION = "2.0"
4
+ """Hardcoded to the version of SPOP we built around"""
5
+
6
+ SPOE_CAPABILITIES = ["pipelining"]
7
+ """Pipelining is the only capability available in 2.0"""
8
+
9
+ DEFAULT_MAX_FRAME_SIZE = 1024 * 4
10
+ """4kb max frame size - set to a comfortably low value"""
11
+
12
+
13
+ class DisconnectCode(IntEnum):
14
+ # Predefined by HAProxy Protocol
15
+ NORMAL = 0
16
+ IO_ERROR = 1
17
+ TIMEOUT = 2
18
+ FRAME_TOO_BIG = 3
19
+ INVALID_FRAME_RECEIVED = 4
20
+ VERSION_NOT_FOUND = 5
21
+ MAX_FRAME_SIZE_NOT_FOUND = 6
22
+ CAPABILITY_NOT_FOUND = 7
23
+ UNSUPPORTED_VERSION = 8
24
+ MAX_FRAME_SIZE_OUT_OF_RANGE = 9
25
+ FRAGMENTATION_NOT_SUPPORTED = 10
26
+ INVALID_INTERLACED_FRAME = 11
27
+ FRAME_ID_NOT_FOUND = 12
28
+ RESOURCE_ALLOCATION_ERROR = 13
29
+ UNKNOWN_ERROR = 99
30
+
31
+ # Custom Error Codes
32
+ SERVER_INCOMPATIBLE = 101
33
+ PROTOCOL_ERROR = 102
@@ -0,0 +1,245 @@
1
+ import logging
2
+ from asyncio import StreamReader
3
+ from asyncio import StreamWriter
4
+ from typing import Callable
5
+ from typing import Awaitable
6
+
7
+ from spoe_forge.exception import SpoeForgeError
8
+ from spoe_forge.server.configuration import ServerConfiguration
9
+ from spoe_forge.server.constants import DisconnectCode
10
+ from spoe_forge.spop.constants import FrameType
11
+ from spoe_forge.spop.exception import SpopEOFError
12
+ from spoe_forge.spop.frame import Disconnect
13
+ from spoe_forge.spop.frame import Frame
14
+ from spoe_forge.spop.frame import HaproxyHello
15
+ from spoe_forge.spop.frame import Notify
16
+ from spoe_forge.spop.spop_types import Action, Messages
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ForgeHandler:
22
+ """
23
+ Handles SPOP protocol lifecycle for a single connection.
24
+
25
+ Manages handshake, NOTIFY/ACK cycles, and disconnection for one HAProxy connection.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ notify_handler: Callable[[Messages], Awaitable[list[Action]]],
31
+ config: ServerConfiguration,
32
+ reader: StreamReader,
33
+ writer: StreamWriter,
34
+ ):
35
+ """
36
+ Initialize connection handler.
37
+
38
+ :param notify_handler: Callback from Forge to process Messages into Actions
39
+ :param ServerConfiguration config: Configuration for this connection
40
+ :param StreamReader reader: AsyncIO stream reader for connection
41
+ :param StreamWriter writer: AsyncIO stream writer for connection
42
+ """
43
+ self.notify_handler = notify_handler
44
+ self.config = config
45
+ self.reader = reader
46
+ self.writer = writer
47
+
48
+ async def close_connection(self):
49
+ """Close the connection stream and wait for it to close."""
50
+ if not self.writer.is_closing():
51
+ self.writer.close()
52
+
53
+ await self.writer.wait_closed()
54
+ logger.debug("Stream disconnected")
55
+
56
+ async def send_frame(self, frame: Frame) -> bool:
57
+ """
58
+ Encode and send a frame to HAProxy.
59
+
60
+ :param Frame frame: Frame to encode and send
61
+ :return: True if frame was sent successfully, False otherwise
62
+ """
63
+ if self.writer.is_closing():
64
+ logger.warning(
65
+ f"Could not send frame {frame.frame_type.name} - stream closed"
66
+ )
67
+ return False
68
+
69
+ try:
70
+ self.writer.write(await frame.encode(self.config.max_frame_size))
71
+ except SpoeForgeError as e:
72
+ logger.warning(
73
+ f"Failed to write frame to stream - {frame.frame_type.name} - {e}"
74
+ )
75
+ return False
76
+
77
+ await self.writer.drain()
78
+ return True
79
+
80
+ async def send_disconnect(self, status_code: DisconnectCode, message: str) -> bool:
81
+ """
82
+ Send AGENT_DISCONNECT frame to HAProxy.
83
+
84
+ :param DisconnectCode status_code: Disconnect reason code
85
+ :param str message: Human-readable disconnect message
86
+ :return: True if disconnect frame was sent successfully, False otherwise
87
+ """
88
+ err_frame = await Frame.construct(
89
+ FrameType.AGENT_DISCONNECT,
90
+ stream_id=0,
91
+ frame_id=0,
92
+ status_code=status_code,
93
+ message=message,
94
+ )
95
+
96
+ return await self.send_frame(err_frame)
97
+
98
+ async def send_disconnect_on_error(
99
+ self, status_code: DisconnectCode, message: str
100
+ ) -> bool:
101
+ """
102
+ Send AGENT_DISCONNECT frame and log as error.
103
+
104
+ :param DisconnectCode status_code: Disconnect reason code
105
+ :param str message: Human-readable disconnect message
106
+ :return: True if disconnect frame was sent successfully, False otherwise
107
+ """
108
+ logger.error(
109
+ f"SPOA server encountered an error, disconnecting: {status_code.name}: {message}"
110
+ )
111
+ return await self.send_disconnect(status_code, message)
112
+
113
+ async def handle_handshake(self) -> bool:
114
+ """
115
+ Handle SPOP handshake phase with HAProxy.
116
+
117
+ Receives HAPROXY_HELLO, negotiates compatibility, and sends AGENT_HELLO.
118
+ Closes connection immediately if healthcheck flag is set.
119
+
120
+ :return: True if handshake succeeded and processing should continue, False otherwise
121
+ """
122
+ frame = await Frame.decode(self.reader)
123
+ if not isinstance(frame, HaproxyHello):
124
+ await self.send_disconnect_on_error(
125
+ status_code=DisconnectCode.INVALID_FRAME_RECEIVED,
126
+ message=f"Expected HAPROXY-HELLO, received {frame.frame_type.name}",
127
+ )
128
+ return False
129
+
130
+ await self.config.negotiate_server_compatibility(
131
+ frame.supported_versions, frame.max_frame_size, frame.capabilities
132
+ )
133
+
134
+ if not self.config.is_compatible:
135
+ await self.send_disconnect_on_error(
136
+ status_code=DisconnectCode.SERVER_INCOMPATIBLE,
137
+ message="Handshake failed to find compatibility.",
138
+ )
139
+ return False
140
+
141
+ agent_hello = await Frame.construct(
142
+ FrameType.AGENT_HELLO,
143
+ stream_id=0,
144
+ frame_id=0,
145
+ version=self.config.version,
146
+ max_frame_size=self.config.max_frame_size,
147
+ capabilities=self.config.capabilities,
148
+ )
149
+
150
+ res = await self.send_frame(agent_hello)
151
+ if frame.healthcheck:
152
+ # Health check means we close the connection immediately after sending AGENT-HELLO
153
+ logger.debug("Healthcheck connection - closing after AGENT-HELLO")
154
+ return False
155
+
156
+ if res:
157
+ logger.debug(
158
+ f"Connection established - SPOP {self.config.version}, "
159
+ f"frame_size={self.config.max_frame_size}, "
160
+ f"capabilities={','.join(self.config.capabilities)}"
161
+ )
162
+
163
+ return res
164
+
165
+ async def handle_notify_cycle(self) -> bool:
166
+ """
167
+ Handle a single NOTIFY/ACK cycle or HAPROXY_DISCONNECT.
168
+
169
+ Receives NOTIFY frame, calls agent to process messages, and sends ACK with actions.
170
+ Also handles graceful HAPROXY_DISCONNECT frames and EOF conditions.
171
+
172
+ :return: True if cycle completed successfully, False if connection should close
173
+ """
174
+ try:
175
+ frame = await Frame.decode(self.reader)
176
+ except SpopEOFError:
177
+ logger.debug("Stream disconnected with EOF")
178
+ return False
179
+
180
+ # Handle graceful disconnect from HAProxy
181
+ if isinstance(frame, Disconnect):
182
+ if frame.status_code == DisconnectCode.NORMAL:
183
+ logger.debug("Connection closed gracefully by HAProxy")
184
+ else:
185
+ logger.warning(
186
+ f"Received HAPROXY_DISCONNECT: {frame.message} (status: {frame.status_code})"
187
+ )
188
+
189
+ # Respond with AGENT_DISCONNECT
190
+ await self.send_disconnect(
191
+ status_code=DisconnectCode.NORMAL,
192
+ message="Disconnecting normally",
193
+ )
194
+ return False
195
+
196
+ if not isinstance(frame, Notify):
197
+ await self.send_disconnect_on_error(
198
+ status_code=DisconnectCode.INVALID_FRAME_RECEIVED,
199
+ message=f"Expected NOTIFY or HAPROXY_DISCONNECT, received {frame.frame_type.name}",
200
+ )
201
+ return False
202
+
203
+ actions = await self.notify_handler(frame.messages)
204
+
205
+ ack = await Frame.construct(
206
+ FrameType.ACK,
207
+ stream_id=frame.metadata.stream_id,
208
+ frame_id=frame.metadata.frame_id,
209
+ actions=actions,
210
+ )
211
+
212
+ return await self.send_frame(ack)
213
+
214
+ async def core_handler(self):
215
+ """
216
+ Main connection lifecycle handler.
217
+
218
+ Executes handshake followed by NOTIFY/ACK processing loop until connection closes.
219
+ Handles protocol errors and connection resets gracefully.
220
+ """
221
+ try:
222
+ if not await self.handle_handshake():
223
+ await self.close_connection()
224
+ return
225
+
226
+ while True:
227
+ if not await self.handle_notify_cycle():
228
+ await self.close_connection()
229
+ break
230
+
231
+ except SpoeForgeError as e:
232
+ if not await self.send_disconnect_on_error(
233
+ status_code=DisconnectCode.PROTOCOL_ERROR,
234
+ message=str(e),
235
+ ):
236
+ logger.error(
237
+ f"Failed to send disconnect on error while handling: {str(e)}",
238
+ exc_info=True,
239
+ )
240
+
241
+ await self.close_connection()
242
+
243
+ except ConnectionResetError:
244
+ # Expected case from HAProxy - we treat it as a graceful disconnect
245
+ logger.debug("Connection reset by HAProxy")