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 +15 -0
- spoe_forge/agent/__init__.py +0 -0
- spoe_forge/agent/context.py +53 -0
- spoe_forge/agent/exceptions.py +7 -0
- spoe_forge/agent/registry.py +67 -0
- spoe_forge/exception.py +4 -0
- spoe_forge/log.py +60 -0
- spoe_forge/server/__init__.py +0 -0
- spoe_forge/server/configuration.py +135 -0
- spoe_forge/server/constants.py +33 -0
- spoe_forge/server/handler.py +245 -0
- spoe_forge/spoe_forge.py +146 -0
- spoe_forge/spop/__init__.py +0 -0
- spoe_forge/spop/constants.py +112 -0
- spoe_forge/spop/decoders/__init__.py +0 -0
- spoe_forge/spop/decoders/data_types.py +282 -0
- spoe_forge/spop/decoders/payloads.py +204 -0
- spoe_forge/spop/encoders/__init__.py +0 -0
- spoe_forge/spop/encoders/data_types.py +307 -0
- spoe_forge/spop/encoders/payloads.py +196 -0
- spoe_forge/spop/exception.py +19 -0
- spoe_forge/spop/frame.py +508 -0
- spoe_forge/spop/spop_types.py +87 -0
- spoe_forge-0.0.1.dist-info/METADATA +168 -0
- spoe_forge-0.0.1.dist-info/RECORD +26 -0
- spoe_forge-0.0.1.dist-info/WHEEL +4 -0
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,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)
|
spoe_forge/exception.py
ADDED
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")
|