aspyx-service 0.9.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.
Potentially problematic release.
This version of aspyx-service might be problematic. Click here for more details.
- aspyx_service/__init__.py +71 -0
- aspyx_service/channels.py +210 -0
- aspyx_service/healthcheck.py +178 -0
- aspyx_service/registries.py +227 -0
- aspyx_service/restchannel.py +199 -0
- aspyx_service/serialization.py +125 -0
- aspyx_service/server.py +213 -0
- aspyx_service/service.py +749 -0
- aspyx_service-0.9.0.dist-info/METADATA +36 -0
- aspyx_service-0.9.0.dist-info/RECORD +12 -0
- aspyx_service-0.9.0.dist-info/WHEEL +4 -0
- aspyx_service-0.9.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the core Aspyx service management framework allowing for service discovery and transparent remoting including multiple possible transport protocols.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from aspyx.di import module
|
|
6
|
+
|
|
7
|
+
from .service import ServiceException, Server, Channel, ComponentDescriptor, inject_service, ChannelAddress, ServiceAddress, ServiceManager, Component, Service, AbstractComponent, ComponentStatus, ComponentRegistry, implementation, health, component, service
|
|
8
|
+
from .channels import HTTPXChannel, DispatchJSONChannel
|
|
9
|
+
from .registries import ConsulComponentRegistry
|
|
10
|
+
from .server import FastAPIServer
|
|
11
|
+
from .healthcheck import health_checks, health_check, HealthCheckManager, HealthStatus
|
|
12
|
+
from .restchannel import RestChannel, post, get, put, delete, QueryParam, Body, rest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@module()
|
|
16
|
+
class ServiceModule:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
# service
|
|
22
|
+
|
|
23
|
+
"ServiceManager",
|
|
24
|
+
"ServiceModule",
|
|
25
|
+
"ServiceException",
|
|
26
|
+
"Server",
|
|
27
|
+
"Component",
|
|
28
|
+
"Service",
|
|
29
|
+
"Channel",
|
|
30
|
+
"AbstractComponent",
|
|
31
|
+
"ComponentStatus",
|
|
32
|
+
"ComponentDescriptor",
|
|
33
|
+
"ComponentRegistry",
|
|
34
|
+
"ChannelAddress",
|
|
35
|
+
"ServiceAddress",
|
|
36
|
+
"health",
|
|
37
|
+
"component",
|
|
38
|
+
"service",
|
|
39
|
+
"implementation",
|
|
40
|
+
"inject_service",
|
|
41
|
+
|
|
42
|
+
# healthcheck
|
|
43
|
+
|
|
44
|
+
# serialization
|
|
45
|
+
|
|
46
|
+
# "deserialize",
|
|
47
|
+
|
|
48
|
+
# channel
|
|
49
|
+
|
|
50
|
+
"HTTPXChannel",
|
|
51
|
+
"DispatchJSONChannel",
|
|
52
|
+
|
|
53
|
+
# rest
|
|
54
|
+
|
|
55
|
+
"RestChannel",
|
|
56
|
+
"post",
|
|
57
|
+
"get",
|
|
58
|
+
"put",
|
|
59
|
+
"delete",
|
|
60
|
+
"rest",
|
|
61
|
+
"QueryParam",
|
|
62
|
+
"Body",
|
|
63
|
+
|
|
64
|
+
# registries
|
|
65
|
+
|
|
66
|
+
"ConsulComponentRegistry",
|
|
67
|
+
|
|
68
|
+
# server
|
|
69
|
+
|
|
70
|
+
"FastAPIServer"
|
|
71
|
+
]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Service management and dependency injection framework.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Type, Optional, Any, Callable
|
|
7
|
+
|
|
8
|
+
import msgpack
|
|
9
|
+
from httpx import Client, AsyncClient
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from aspyx.reflection import DynamicProxy, TypeDescriptor
|
|
13
|
+
from .service import ServiceManager
|
|
14
|
+
|
|
15
|
+
from .service import ComponentDescriptor, ServiceAddress, ServiceException, channel, Channel, RemoteServiceException
|
|
16
|
+
from .serialization import get_deserializer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HTTPXChannel(Channel):
|
|
20
|
+
__slots__ = [
|
|
21
|
+
"client",
|
|
22
|
+
"async_client",
|
|
23
|
+
"service_names",
|
|
24
|
+
"deserializers"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# constructor
|
|
28
|
+
|
|
29
|
+
def __init__(self, name):
|
|
30
|
+
super().__init__(name)
|
|
31
|
+
|
|
32
|
+
self.client: Optional[Client] = None
|
|
33
|
+
self.async_client: Optional[AsyncClient] = None
|
|
34
|
+
self.service_names: dict[Type, str] = {}
|
|
35
|
+
self.deserializers: dict[Callable, Callable] = {}
|
|
36
|
+
|
|
37
|
+
# protected
|
|
38
|
+
|
|
39
|
+
def get_deserializer(self, type: Type, method: Callable) -> Type:
|
|
40
|
+
deserializer = self.deserializers.get(method, None)
|
|
41
|
+
if deserializer is None:
|
|
42
|
+
return_type = TypeDescriptor.for_type(type).get_method(method.__name__).return_type
|
|
43
|
+
|
|
44
|
+
deserializer = get_deserializer(return_type)
|
|
45
|
+
|
|
46
|
+
self.deserializers[method] = deserializer
|
|
47
|
+
|
|
48
|
+
return deserializer
|
|
49
|
+
|
|
50
|
+
# override
|
|
51
|
+
|
|
52
|
+
def setup(self, component_descriptor: ComponentDescriptor, address: ServiceAddress):
|
|
53
|
+
super().setup(component_descriptor, address)
|
|
54
|
+
|
|
55
|
+
# remember service names
|
|
56
|
+
|
|
57
|
+
for service in component_descriptor.services:
|
|
58
|
+
self.service_names[service.type] = service.name
|
|
59
|
+
|
|
60
|
+
# make client
|
|
61
|
+
|
|
62
|
+
self.client = self.make_client()
|
|
63
|
+
self.async_client = self.make_async_client()
|
|
64
|
+
|
|
65
|
+
# public
|
|
66
|
+
|
|
67
|
+
def get_client(self) -> Client:
|
|
68
|
+
if self.client is None:
|
|
69
|
+
self.client = self.make_client()
|
|
70
|
+
|
|
71
|
+
return self.client
|
|
72
|
+
|
|
73
|
+
def get_async_client(self) -> Client:
|
|
74
|
+
if self.async_client is None:
|
|
75
|
+
self.async_client = self.make_async_client()
|
|
76
|
+
|
|
77
|
+
return self.async_client
|
|
78
|
+
|
|
79
|
+
def make_client(self):
|
|
80
|
+
return Client() # base_url=url
|
|
81
|
+
|
|
82
|
+
def make_async_client(self):
|
|
83
|
+
return AsyncClient() # base_url=url
|
|
84
|
+
|
|
85
|
+
class Request(BaseModel):
|
|
86
|
+
method: str # component:service:method
|
|
87
|
+
args: tuple[Any, ...]
|
|
88
|
+
|
|
89
|
+
class Response(BaseModel):
|
|
90
|
+
result: Optional[Any]
|
|
91
|
+
exception: Optional[Any]
|
|
92
|
+
|
|
93
|
+
@channel("dispatch-json")
|
|
94
|
+
class DispatchJSONChannel(HTTPXChannel):
|
|
95
|
+
# constructor
|
|
96
|
+
|
|
97
|
+
def __init__(self):
|
|
98
|
+
super().__init__("dispatch-json")
|
|
99
|
+
|
|
100
|
+
# internal
|
|
101
|
+
|
|
102
|
+
# implement Channel
|
|
103
|
+
|
|
104
|
+
def set_address(self, address: Optional[ServiceAddress]):
|
|
105
|
+
ServiceManager.logger.info("channel %s got an address %s", self.name, address)
|
|
106
|
+
|
|
107
|
+
super().set_address(address)
|
|
108
|
+
|
|
109
|
+
def setup(self, component_descriptor: ComponentDescriptor, address: ServiceAddress):
|
|
110
|
+
super().setup(component_descriptor, address)
|
|
111
|
+
|
|
112
|
+
def invoke(self, invocation: DynamicProxy.Invocation):
|
|
113
|
+
service_name = self.service_names[invocation.type] # map type to registered service name
|
|
114
|
+
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}", args=invocation.args)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
if self.client is not None:
|
|
118
|
+
result = Response(**self.get_client().post(f"{self.get_url()}/invoke", json=request.dict(), timeout=30000.0).json())
|
|
119
|
+
if result.exception is not None:
|
|
120
|
+
raise RemoteServiceException(f"server side exception {result.exception}")
|
|
121
|
+
|
|
122
|
+
return self.get_deserializer(invocation.type, invocation.method)(result.result)
|
|
123
|
+
else:
|
|
124
|
+
raise ServiceException(f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
|
|
125
|
+
except Exception as e:
|
|
126
|
+
raise ServiceException(f"communication exception {e}") from e
|
|
127
|
+
|
|
128
|
+
async def invoke_async(self, invocation: DynamicProxy.Invocation):
|
|
129
|
+
service_name = self.service_names[invocation.type] # map type to registered service name
|
|
130
|
+
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
|
|
131
|
+
args=invocation.args)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
if self.async_client is not None:
|
|
135
|
+
data = await self.async_client.post(f"{self.get_url()}/invoke", json=request.dict(), timeout=30000.0)
|
|
136
|
+
result = Response(**data.json())
|
|
137
|
+
if result.exception is not None:
|
|
138
|
+
raise RemoteServiceException(f"server side exception {result.exception}")
|
|
139
|
+
|
|
140
|
+
return self.get_deserializer(invocation.type, invocation.method)(result.result)
|
|
141
|
+
else:
|
|
142
|
+
raise ServiceException(
|
|
143
|
+
f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
raise ServiceException(f"communication exception {e}") from e
|
|
146
|
+
|
|
147
|
+
@channel("dispatch-msgpack")
|
|
148
|
+
class DispatchMSPackChannel(HTTPXChannel):
|
|
149
|
+
# constructor
|
|
150
|
+
|
|
151
|
+
def __init__(self):
|
|
152
|
+
super().__init__("dispatch-msgpack")
|
|
153
|
+
|
|
154
|
+
# override
|
|
155
|
+
|
|
156
|
+
def set_address(self, address: Optional[ServiceAddress]):
|
|
157
|
+
ServiceManager.logger.info("channel %s got an address %s", self.name, address)
|
|
158
|
+
|
|
159
|
+
super().set_address(address)
|
|
160
|
+
|
|
161
|
+
def invoke(self, invocation: DynamicProxy.Invocation):
|
|
162
|
+
service_name = self.service_names[invocation.type] # map type to registered service name
|
|
163
|
+
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
|
|
164
|
+
args=invocation.args)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
packed = msgpack.packb(request.dict(), use_bin_type=True)
|
|
168
|
+
|
|
169
|
+
response = self.get_client().post(
|
|
170
|
+
f"{self.get_url()}/invoke",
|
|
171
|
+
content=packed,
|
|
172
|
+
headers={"Content-Type": "application/msgpack"},
|
|
173
|
+
timeout=30.0
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
result = msgpack.unpackb(response.content, raw=False)
|
|
177
|
+
|
|
178
|
+
if result.get("exception", None):
|
|
179
|
+
raise RemoteServiceException(f"server-side: {result['exception']}")
|
|
180
|
+
|
|
181
|
+
return self.get_deserializer(invocation.type, invocation.method)(result["result"])
|
|
182
|
+
|
|
183
|
+
except Exception as e:
|
|
184
|
+
raise ServiceException(f"msgpack exception: {e}") from e
|
|
185
|
+
|
|
186
|
+
async def invoke_async(self, invocation: DynamicProxy.Invocation):
|
|
187
|
+
service_name = self.service_names[invocation.type] # map type to registered service name
|
|
188
|
+
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
|
|
189
|
+
args=invocation.args)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
packed = msgpack.packb(request.dict(), use_bin_type=True)
|
|
193
|
+
|
|
194
|
+
response = await self.get_async_client().post(
|
|
195
|
+
f"{self.get_url()}/invoke",
|
|
196
|
+
content=packed,
|
|
197
|
+
headers={"Content-Type": "application/msgpack"},
|
|
198
|
+
timeout=30.0
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
result = msgpack.unpackb(response.content, raw=False)
|
|
202
|
+
|
|
203
|
+
if result.get("exception", None):
|
|
204
|
+
raise RemoteServiceException(f"server-side: {result['exception']}")
|
|
205
|
+
|
|
206
|
+
return self.get_deserializer(invocation.type, invocation.method)(result["result"])
|
|
207
|
+
|
|
208
|
+
except Exception as e:
|
|
209
|
+
raise ServiceException(f"msgpack exception: {e}") from e
|
|
210
|
+
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
health checks
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, Callable, Type, Optional
|
|
11
|
+
|
|
12
|
+
from aspyx.di import Providers, ClassInstanceProvider, injectable, Environment, inject_environment, on_init
|
|
13
|
+
from aspyx.reflection import Decorators, TypeDescriptor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def health_checks():
|
|
17
|
+
"""
|
|
18
|
+
Instances of classes that are annotated with @injectable can be created by an Environment.
|
|
19
|
+
"""
|
|
20
|
+
def decorator(cls):
|
|
21
|
+
Decorators.add(cls, health_checks)
|
|
22
|
+
|
|
23
|
+
#if not Providers.is_registered(cls):
|
|
24
|
+
# Providers.register(ClassInstanceProvider(cls, True, "singleton"))
|
|
25
|
+
|
|
26
|
+
HealthCheckManager.types.append(cls)
|
|
27
|
+
|
|
28
|
+
return cls
|
|
29
|
+
|
|
30
|
+
return decorator
|
|
31
|
+
|
|
32
|
+
def health_check(name="", cache = 0, fail_if_slower_than = 0):
|
|
33
|
+
"""
|
|
34
|
+
Methods annotated with `@on_init` will be called when the instance is created."""
|
|
35
|
+
def decorator(func):
|
|
36
|
+
Decorators.add(func, health_check, name, cache, fail_if_slower_than)
|
|
37
|
+
return func
|
|
38
|
+
|
|
39
|
+
return decorator
|
|
40
|
+
|
|
41
|
+
class HealthStatus(Enum):
|
|
42
|
+
OK = 1
|
|
43
|
+
WARNING = 2
|
|
44
|
+
ERROR = 3
|
|
45
|
+
|
|
46
|
+
def __str__(self):
|
|
47
|
+
return self.name
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@injectable()
|
|
51
|
+
class HealthCheckManager:
|
|
52
|
+
logger = logging.getLogger("aspyx.service.health")
|
|
53
|
+
|
|
54
|
+
# local classes
|
|
55
|
+
|
|
56
|
+
class Check:
|
|
57
|
+
def __init__(self, name: str, cache: int, fail_if_slower_than: int, instance: Any, callable: Callable):
|
|
58
|
+
self.name = name
|
|
59
|
+
self.cache = cache
|
|
60
|
+
self.callable = callable
|
|
61
|
+
self.instance = instance
|
|
62
|
+
self.fail_if_slower_than = fail_if_slower_than
|
|
63
|
+
self.last_check = 0
|
|
64
|
+
|
|
65
|
+
self.last_value : Optional[HealthCheckManager.Result] = None
|
|
66
|
+
|
|
67
|
+
async def run(self, result: HealthCheckManager.Result):
|
|
68
|
+
now = time.time()
|
|
69
|
+
|
|
70
|
+
if self.cache > 0 and self.last_check is not None and now - self.last_check < self.cache:
|
|
71
|
+
result.copy_from(self.last_value)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
self.last_check = now
|
|
75
|
+
self.last_value = result
|
|
76
|
+
|
|
77
|
+
if asyncio.iscoroutinefunction(self.callable):
|
|
78
|
+
await self.callable(self.instance, result)
|
|
79
|
+
else:
|
|
80
|
+
await asyncio.to_thread(self.callable, self.instance, result)
|
|
81
|
+
|
|
82
|
+
spent = time.time() - now
|
|
83
|
+
|
|
84
|
+
if result.status == HealthStatus.OK and 0 < self.fail_if_slower_than < spent:
|
|
85
|
+
result.status = HealthStatus.ERROR
|
|
86
|
+
result.details = f"spent {spent:.3f}s"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Result:
|
|
90
|
+
def __init__(self, name: str):
|
|
91
|
+
self.status = HealthStatus.OK
|
|
92
|
+
self.name = name
|
|
93
|
+
self.details = ""
|
|
94
|
+
|
|
95
|
+
def copy_from(self, value: HealthCheckManager.Result):
|
|
96
|
+
self.status = value.status
|
|
97
|
+
self.details = value.details
|
|
98
|
+
|
|
99
|
+
def set_status(self, status: HealthStatus, details =""):
|
|
100
|
+
self.status = status
|
|
101
|
+
self.details = details
|
|
102
|
+
|
|
103
|
+
def to_dict(self):
|
|
104
|
+
result = {
|
|
105
|
+
"name": self.name,
|
|
106
|
+
"status": str(self.status),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if len(self.details) > 0:
|
|
110
|
+
result["details"] = self.details
|
|
111
|
+
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
class Health:
|
|
115
|
+
def __init__(self):
|
|
116
|
+
self.status = HealthStatus.OK
|
|
117
|
+
self.results : list[HealthCheckManager.Result] = []
|
|
118
|
+
|
|
119
|
+
def to_dict(self):
|
|
120
|
+
return {
|
|
121
|
+
"status": str(self.status),
|
|
122
|
+
"checks": [result.to_dict() for result in self.results]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# class data
|
|
126
|
+
|
|
127
|
+
types : list[Type] = []
|
|
128
|
+
|
|
129
|
+
# constructor
|
|
130
|
+
|
|
131
|
+
def __init__(self):
|
|
132
|
+
self.environment : Optional[Environment] = None
|
|
133
|
+
self.checks: list[HealthCheckManager.Check] = []
|
|
134
|
+
|
|
135
|
+
# check
|
|
136
|
+
|
|
137
|
+
async def check(self) -> HealthCheckManager.Health:
|
|
138
|
+
self.logger.info("Checking health...")
|
|
139
|
+
|
|
140
|
+
health = HealthCheckManager.Health()
|
|
141
|
+
|
|
142
|
+
tasks = []
|
|
143
|
+
for check in self.checks:
|
|
144
|
+
result = HealthCheckManager.Result(check.name)
|
|
145
|
+
health.results.append(result)
|
|
146
|
+
tasks.append(check.run(result))
|
|
147
|
+
|
|
148
|
+
await asyncio.gather(*tasks)
|
|
149
|
+
|
|
150
|
+
for result in health.results:
|
|
151
|
+
if result.status.value > health.status.value:
|
|
152
|
+
health.status = result.status
|
|
153
|
+
|
|
154
|
+
return health
|
|
155
|
+
|
|
156
|
+
# public
|
|
157
|
+
|
|
158
|
+
@inject_environment()
|
|
159
|
+
def set_environment(self, environment: Environment):
|
|
160
|
+
self.environment = environment
|
|
161
|
+
|
|
162
|
+
@on_init()
|
|
163
|
+
def setup(self):
|
|
164
|
+
for type in self.types:
|
|
165
|
+
descriptor = TypeDescriptor(type).for_type(type)
|
|
166
|
+
instance = self.environment.get(type)
|
|
167
|
+
|
|
168
|
+
for method in descriptor.get_methods():
|
|
169
|
+
if method.has_decorator(health_check):
|
|
170
|
+
decorator = method.get_decorator(health_check)
|
|
171
|
+
|
|
172
|
+
name = decorator.args[0]
|
|
173
|
+
cache = decorator.args[1]
|
|
174
|
+
fail_if_slower_than = decorator.args[2]
|
|
175
|
+
if len(name) == 0:
|
|
176
|
+
name = method.get_name()
|
|
177
|
+
|
|
178
|
+
self.checks.append(HealthCheckManager.Check(name, cache, fail_if_slower_than, instance, method.method))
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
registries for components in aspyx service
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
from abc import abstractmethod
|
|
7
|
+
import time
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import consul
|
|
12
|
+
|
|
13
|
+
from aspyx.util import StringBuilder
|
|
14
|
+
from aspyx.di import on_init
|
|
15
|
+
from .healthcheck import HealthCheckManager, HealthStatus
|
|
16
|
+
|
|
17
|
+
from .server import Server
|
|
18
|
+
from .service import ComponentRegistry, Channel, ServiceAddress, ServiceManager, ComponentDescriptor, Component, ChannelAddress
|
|
19
|
+
|
|
20
|
+
class ConsulComponentRegistry(ComponentRegistry):
|
|
21
|
+
# constructor
|
|
22
|
+
|
|
23
|
+
def __init__(self, port: int, consul_url: str):
|
|
24
|
+
self.port = port
|
|
25
|
+
self.ip = Server.get_local_ip()
|
|
26
|
+
self.running = False
|
|
27
|
+
self.consul = None
|
|
28
|
+
self.watchdog = None
|
|
29
|
+
self.interval = 5
|
|
30
|
+
self.last_index = {}
|
|
31
|
+
self.component_addresses : dict[str, dict[str, ServiceAddress]] = {} # comp -> channel -> address
|
|
32
|
+
self.watch_channels : list[Channel] = []
|
|
33
|
+
|
|
34
|
+
parsed = urlparse(consul_url)
|
|
35
|
+
|
|
36
|
+
self.consul_host = parsed.hostname
|
|
37
|
+
self.consul_port = parsed.port
|
|
38
|
+
|
|
39
|
+
def make_consul(self, host: str, port: int) -> consul.Consul:
|
|
40
|
+
return consul.Consul(host=host, port=port)
|
|
41
|
+
|
|
42
|
+
# lifecycle hooks
|
|
43
|
+
|
|
44
|
+
@on_init()
|
|
45
|
+
def setup(self):
|
|
46
|
+
# create consul client
|
|
47
|
+
|
|
48
|
+
self.consul = self.make_consul(host=self.consul_host, port=self.consul_port)
|
|
49
|
+
self.running = True
|
|
50
|
+
|
|
51
|
+
# start thread
|
|
52
|
+
|
|
53
|
+
self.watchdog = threading.Thread(target=self.watch_consul, daemon=True)
|
|
54
|
+
self.watchdog.start()
|
|
55
|
+
|
|
56
|
+
def inform_channels(self, old_address: ServiceAddress, new_address: Optional[ServiceAddress]):
|
|
57
|
+
for channel in self.watch_channels:
|
|
58
|
+
if channel.address is old_address:
|
|
59
|
+
channel.set_address(new_address)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def watch_consul(self):
|
|
63
|
+
while self.running:
|
|
64
|
+
# check services
|
|
65
|
+
|
|
66
|
+
for component, old_addresses in self.component_addresses.items():
|
|
67
|
+
old_addresses = dict(old_addresses) # we will modify it...
|
|
68
|
+
new_addresses = self.fetch_addresses(component, wait="1s")
|
|
69
|
+
|
|
70
|
+
# compare
|
|
71
|
+
|
|
72
|
+
changed = False
|
|
73
|
+
for channel_name, address in new_addresses.items():
|
|
74
|
+
service_address = old_addresses.get(channel_name, None)
|
|
75
|
+
if service_address is None:
|
|
76
|
+
ServiceManager.logger.info("new %s address for %s", channel_name, component)
|
|
77
|
+
changed = True
|
|
78
|
+
else:
|
|
79
|
+
if service_address != address:
|
|
80
|
+
changed = True
|
|
81
|
+
|
|
82
|
+
ServiceManager.logger.info("%s address for %s changed", channel_name, component)
|
|
83
|
+
|
|
84
|
+
# inform channels
|
|
85
|
+
|
|
86
|
+
self.inform_channels(service_address, address)
|
|
87
|
+
|
|
88
|
+
# delete
|
|
89
|
+
|
|
90
|
+
del old_addresses[channel_name]
|
|
91
|
+
|
|
92
|
+
# watchout for deleted addresses
|
|
93
|
+
|
|
94
|
+
for channel_name, address in old_addresses.items():
|
|
95
|
+
ServiceManager.logger.info("deleted %s address for %s", channel_name, component)
|
|
96
|
+
|
|
97
|
+
changed = True
|
|
98
|
+
|
|
99
|
+
# inform channel
|
|
100
|
+
|
|
101
|
+
self.inform_channels(address, None)
|
|
102
|
+
|
|
103
|
+
# replace ( does that work while iterating )
|
|
104
|
+
|
|
105
|
+
if changed:
|
|
106
|
+
self.component_addresses[component] = new_addresses
|
|
107
|
+
|
|
108
|
+
# time to sleep
|
|
109
|
+
|
|
110
|
+
time.sleep(5)
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def watch(self, channel: Channel):
|
|
114
|
+
self.watch_channels.append(channel)
|
|
115
|
+
|
|
116
|
+
#self.component_addresses[channel.component_descriptor.name] = {}
|
|
117
|
+
|
|
118
|
+
# public
|
|
119
|
+
|
|
120
|
+
def register_service(self, name, service_id, health: str, tags=None, meta=None):
|
|
121
|
+
ip = "host.docker.internal" # TODO
|
|
122
|
+
|
|
123
|
+
self.consul.agent.service.register(
|
|
124
|
+
name=name,
|
|
125
|
+
service_id=service_id,
|
|
126
|
+
address=self.ip,
|
|
127
|
+
port=self.port,
|
|
128
|
+
tags=tags or [],
|
|
129
|
+
meta=meta or {},
|
|
130
|
+
check=consul.Check().http(
|
|
131
|
+
url=f"http://{ip}:{self.port}{health}",
|
|
132
|
+
interval="10s",
|
|
133
|
+
timeout="3s",
|
|
134
|
+
deregister="5m")
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def deregister(self, descriptor: ComponentDescriptor[Component]):
|
|
138
|
+
name = descriptor.name
|
|
139
|
+
|
|
140
|
+
service_id = f"{self.ip}:{self.port}:{name}"
|
|
141
|
+
|
|
142
|
+
self.consul.agent.service.deregister(service_id)
|
|
143
|
+
|
|
144
|
+
def stop(self):
|
|
145
|
+
self.running = False
|
|
146
|
+
if self.watchdog is not None:
|
|
147
|
+
self.watchdog.join()
|
|
148
|
+
self.watchdog = None
|
|
149
|
+
|
|
150
|
+
# private
|
|
151
|
+
|
|
152
|
+
def fetch_addresses(self, component: str, wait=None) -> dict[str, ServiceAddress]:
|
|
153
|
+
addresses : dict[str, ServiceAddress] = {} # channel name -> ServiceAddress
|
|
154
|
+
|
|
155
|
+
index, nodes = self.consul.health.service(component, passing=True, index=self.last_index.get(component, None), wait=wait)
|
|
156
|
+
self.last_index[component] = index
|
|
157
|
+
|
|
158
|
+
for node in nodes:
|
|
159
|
+
service = node["Service"]
|
|
160
|
+
|
|
161
|
+
meta = service.get('Meta')
|
|
162
|
+
|
|
163
|
+
channels = meta.get("channels").split(",")
|
|
164
|
+
|
|
165
|
+
for channel in channels:
|
|
166
|
+
match = re.search(r"([\w-]+)\((.*)\)", channel)
|
|
167
|
+
|
|
168
|
+
channel_name = match.group(1)
|
|
169
|
+
url = match.group(2)
|
|
170
|
+
|
|
171
|
+
address = addresses.get(channel, None)
|
|
172
|
+
if address is None:
|
|
173
|
+
address = ServiceAddress(component=component, channel=channel_name)
|
|
174
|
+
addresses[channel] = address
|
|
175
|
+
|
|
176
|
+
address.urls.append(url)
|
|
177
|
+
|
|
178
|
+
# done
|
|
179
|
+
|
|
180
|
+
return addresses
|
|
181
|
+
|
|
182
|
+
# implement ComponentRegistry
|
|
183
|
+
|
|
184
|
+
def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]):
|
|
185
|
+
name = descriptor.name
|
|
186
|
+
|
|
187
|
+
id = f"{self.ip}:{self.port}:{name}"
|
|
188
|
+
|
|
189
|
+
builder = StringBuilder()
|
|
190
|
+
first = True
|
|
191
|
+
for address in addresses:
|
|
192
|
+
if not first:
|
|
193
|
+
builder.append(",")
|
|
194
|
+
|
|
195
|
+
builder.append(address.channel).append("(").append(address.uri).append(")")
|
|
196
|
+
|
|
197
|
+
first = False
|
|
198
|
+
|
|
199
|
+
addresses = str(builder)
|
|
200
|
+
|
|
201
|
+
self.register_service(name, id, descriptor.health, tags =["component"], meta={"channels": addresses})
|
|
202
|
+
|
|
203
|
+
def get_addresses(self, descriptor: ComponentDescriptor) -> list[ServiceAddress]:
|
|
204
|
+
component_addresses = self.component_addresses.get(descriptor.name, None)
|
|
205
|
+
if component_addresses is None:
|
|
206
|
+
component_addresses = self.fetch_addresses(descriptor.name)
|
|
207
|
+
|
|
208
|
+
# only cache if non-empty
|
|
209
|
+
|
|
210
|
+
if len(component_addresses) > 0:
|
|
211
|
+
self.component_addresses[descriptor.name] = component_addresses
|
|
212
|
+
|
|
213
|
+
return list(component_addresses.values())
|
|
214
|
+
|
|
215
|
+
#200–299 passing Service is healthy (OK, Created, No Content…)
|
|
216
|
+
#429 warning Rate limited or degraded
|
|
217
|
+
#300–399 warning Redirects interpreted as potential issues
|
|
218
|
+
#400–499 critical Client errors (Bad Request, Unauthorized…)
|
|
219
|
+
#500–599 critical Server errors (Internal Error, Timeout…)
|
|
220
|
+
#Other / No response critical Timeout, connection refused, etc.
|
|
221
|
+
def map_health(self, health: HealthCheckManager.Health) -> int:
|
|
222
|
+
if health.status is HealthStatus.OK:
|
|
223
|
+
return 200
|
|
224
|
+
elif health.status is HealthStatus.WARNING:
|
|
225
|
+
return 429
|
|
226
|
+
else:
|
|
227
|
+
return 500
|