├── .gitignore ├── LICENSE ├── README.md ├── README.zh_cn.md ├── py_eureka_client ├── __aws_info_loader.py ├── __dns_txt_resolver.py ├── __init__.py ├── eureka_basic.py ├── eureka_client.py ├── http_client.py ├── logger.py └── netint_utils.py ├── pyproject.toml ├── requirements.txt ├── setup.py ├── tests ├── __init__.py └── py_eureka_client │ ├── __init__.py │ ├── eureka_server_parse_test.py │ └── test_http_basic_auth.py └── upload.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | /my_test 107 | my_test_* 108 | 109 | # vscode 110 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Keijack Wu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-eureka-client 2 | 3 | [![PyPI version](https://badge.fury.io/py/py-eureka-client.png)](https://badge.fury.io/py/py-eureka-client) 4 | 5 | ## Discription 6 | 7 | This is an eureka client written in python, you can easily intergrate your python components with spring cloud. 8 | 9 | ## Support Python Version 10 | 11 | Python 3.7+ 12 | 13 | *From `0.9.0`, python 2 is no longer supported, if you are using python 2, please use version `0.8.12`.* 14 | 15 | ## Why choose 16 | 17 | * Register your python components to eureka server. 18 | * Support failover. 19 | * Support DNS discovery. 20 | * Send heartbeat to eureka server. 21 | * Pull registry from eureka server. 22 | * Easy to use interface to use other REST service. 23 | * HA when calling other REST service. 24 | * Both trandictional and async def interfaces are provided. 25 | * The http client lib is replacable. 26 | 27 | ## How to use 28 | 29 | ### Install 30 | 31 | ```Shell 32 | pip install py_eureka_client 33 | ``` 34 | 35 | ### Getting Start 36 | 37 | This is the easiest way to use this component. 38 | 39 | ```python 40 | import py_eureka_client.eureka_client as eureka_client 41 | 42 | your_rest_server_port = 9090 43 | # The flowing code will register your server to eureka server and also start to send heartbeat every 30 seconds 44 | eureka_client.init(eureka_server="http://your-eureka-server-peer1,http://your-eureka-server-peer2", 45 | app_name="your_app_name", 46 | instance_port=your_rest_server_port) 47 | ``` 48 | 49 | Then, in your business code, use 50 | 51 | ```python 52 | import py_eureka_client.eureka_client as eureka_client 53 | 54 | res = eureka_client.do_service("OTHER-SERVICE-NAME", "/service/context/path") 55 | print("result of other service" + res) 56 | 57 | ``` 58 | 59 | You can also use the `EurekaClient` class. 60 | 61 | ```python 62 | from py_eureka_client.eureka_client import EurekaClient 63 | client = EurekaClient(eureka_server="http://my_eureka_server_peer_1/eureka/v2,http://my_eureka_server_peer_2/eureka/v2", app_name="python_module_1", instance_port=9090) 64 | await client.start() 65 | res = await client.do_service("OTHER-SERVICE-NAME", "/service/context/path") 66 | print("result of other service" + res) 67 | # when server is shutted down: 68 | await client.stop() 69 | ``` 70 | 71 | In fact, the `init` function is a facade of the EurekaClient, it holds a client object behind, you can get that by catching its return value or use `eureka_client.get_client()` to get it. The `init` function will automatically start the client, while using raw `EurekaClient`, you must call the `start()` and `stop()` method explicitly. 72 | 73 | 74 | From `0.11.0`, all the methods in `EurekaClient` are defined `async`, and there are also async facade for `init`, `do_servise`, `stop` functions names `init_async`, `do_service_async`, `sto_async`. 75 | 76 | ### Registering to Eureka Server 77 | 78 | The most common method to will be like: 79 | 80 | ```Python 81 | import py_eureka_client.eureka_client as eureka_client 82 | 83 | your_rest_server_port = 9090 84 | eureka_client.init(eureka_server="http://your-eureka-server-peer1,http://your-eureka-server-peer2", 85 | app_name="python_module_1", 86 | instance_port=your_rest_server_port) 87 | ``` 88 | 89 | But if you have deploy your eureka server in several zones, you should specify the `eureka_availability_zones` parameter. 90 | 91 | ```python 92 | import py_eureka_client.eureka_client as eureka_client 93 | eureka_client.init(eureka_availability_zones={ 94 | "us-east-1c": "http://ec2-552-627-568-165.compute-1.amazonaws.com:7001/eureka/v2/,http://ec2-368-101-182-134.compute-1.amazonaws.com:7001/eureka/v2/", 95 | "us-east-1d": "http://ec2-552-627-568-170.compute-1.amazonaws.com:7001/eureka/v2/", 96 | "us-east-1e": "http://ec2-500-179-285-592.compute-1.amazonaws.com:7001/eureka/v2/"}, 97 | zone="us-east-1c", 98 | app_name="python_module_1", 99 | instance_port=9090, 100 | data_center_name="Amazon") 101 | ``` 102 | 103 | If you are looking for flexibility, you should configure Eureka service URLs using DNS. 104 | 105 | For instance, following is a DNS TXT record created in the DNS server that lists the set of available DNS names for a zone. 106 | 107 | ``` 108 | txt.us-east-1.mydomaintest.netflix.net="us-east-1c.mydomaintest.netflix.net" "us-east-1d.mydomaintest.netflix.net" "us-east-1e.mydomaintest.netflix.net" 109 | ``` 110 | 111 | Then, you can define TXT records recursively for each zone similar to the following (if more than one hostname per zone, space delimit) 112 | 113 | ``` 114 | txt.us-east-1c.mydomaintest.netflix.net="ec2-552-627-568-165.compute-1.amazonaws.com" "ec2-368-101-182-134.compute-1.amazonaws.com" 115 | txt.us-east-1d.mydomaintest.netflix.net="ec2-552-627-568-170.compute-1.amazonaws.com" 116 | txt.us-east-1e.mydomaintest.netflix.net="ec2-500-179-285-592.compute-1.amazonaws.com" 117 | ``` 118 | 119 | And then you can create the client like: 120 | 121 | ```python 122 | import py_eureka_client.eureka_client as eureka_client 123 | eureka_client.init(eureka_domain="mydomaintest.netflix.net", 124 | region="us-east-1", 125 | zone="us-east-1c", 126 | app_name="python_module_1", 127 | instance_port=9090, 128 | data_center_name="Amazon") 129 | ``` 130 | 131 | You can specify the protocol, basic authentication and context path of your eureka server separatly rather than setting it at the URL. 132 | 133 | ```python 134 | import py_eureka_client.eureka_client as eureka_client 135 | eureka_client.init(eureka_domain="mydomaintest.netflix.net", 136 | region="us-east-1", 137 | zone="us-east-1c", 138 | eureka_protocol="https", 139 | eureka_basic_auth_user="keijack", 140 | eureka_basic_auth_password="kjauthpass", 141 | eureka_context="/eureka/v2", 142 | app_name="python_module_1", 143 | instance_port=9090, 144 | ``` 145 | 146 | or 147 | 148 | ```python 149 | import py_eureka_client.eureka_client as eureka_client 150 | eureka_client.init(eureka_server="your-eureka-server-peer1,your-eureka-server-peer2", 151 | eureka_protocol="https", 152 | eureka_basic_auth_user="keijack", 153 | eureka_basic_auth_password="kjauthpass", 154 | eureka_context="/eureka/v2", 155 | app_name="python_module_1", 156 | instance_port=9090) 157 | ``` 158 | 159 | *About the instance `IP` and `hostname`*: 160 | 161 | If you are using a `Amazon` data center, `py-eureka-client` will try to use `local-ipv4` and `local-hostname` get from Amazon metadata service. In other cases, `py-eureka-client` will use the first non-loopback ip address and hostname from your net interface. 162 | 163 | You can also specify both these tow field or just one of them explicitly: 164 | 165 | ```python 166 | eureka_client.init(eureka_server="your-eureka-server-peer1,your-eureka-server-peer2", 167 | eureka_protocol="https", 168 | eureka_basic_auth_user="keijack", 169 | eureka_basic_auth_password="kjauthpass", 170 | eureka_context="/eureka/v2", 171 | app_name="python_module_1", 172 | instance_ip="192.168.10.168", 173 | instance_host="my-py-component.mydomian.com", 174 | instance_port=9090) 175 | ``` 176 | 177 | In some case you might have more than one interfaces attached, for example, you are running your application in a docker-container. In this case you can specify a network via `instance_ip_network` to be used to get the container's ip and host. You can use: 178 | 179 | ```python 180 | eureka_client.init(eureka_server="your-eureka-server-peer1,your-eureka-server-peer2", 181 | eureka_protocol="https", 182 | eureka_basic_auth_user="keijack", 183 | eureka_basic_auth_password="kjauthpass", 184 | eureka_context="/eureka/v2", 185 | app_name="python_module_1", 186 | instance_ip_network="192.168.10.0/24", 187 | instance_port=9090) 188 | ``` 189 | 190 | If you want to get the ip only and sepecify the host by yourself, try: 191 | 192 | ```python 193 | import py_eureka_client.netint_utils as netint_utils 194 | 195 | # you can get the ip only 196 | ip = netint_utils.get_first_non_loopback_ip("192.168.10.0/24") 197 | host = "my-py-component.mydomian.com" 198 | 199 | eureka_client.init(eureka_server="your-eureka-server-peer1,your-eureka-server-peer2", 200 | eureka_protocol="https", 201 | eureka_basic_auth_user="keijack", 202 | eureka_basic_auth_password="kjauthpass", 203 | eureka_context="/eureka/v2", 204 | app_name="python_module_1", 205 | instance_ip=ip, 206 | instance_host=host, 207 | instance_port=9090) 208 | ``` 209 | 210 | ### Error Callback 211 | 212 | You can specify a callback function when initializing the eureka client, when errors occur in `register`, `discover` or `status update` phase, the callback function will be called to inform you. The callback function will be called only when all the eureka server url are all tried and fails. 213 | 214 | The callback function should accept 2 arguments. which are the error type and the exception itself. please check: 215 | 216 | ```python 217 | def on_err(err_type: str, err: Exception): 218 | if err_type in (eureka_client.ERROR_REGISTER, eureka_client.ERROR_DISCOVER): 219 | eureka_client.stop() 220 | else: 221 | print(f"{err_type}::{err}") 222 | 223 | your_rest_server_port = 9090 224 | eureka_client.init(eureka_server="http://your-eureka-server-peer1,http://your-eureka-server-peer2", 225 | app_name="python_module_1", 226 | instance_port=your_rest_server_port, 227 | on_error=on_err) 228 | 229 | ``` 230 | 231 | ### Call Remote Service 232 | 233 | After `init` the eureka client, this is the most simplist way to do service: 234 | 235 | ```python 236 | import py_eureka_client.eureka_client as eureka_client 237 | 238 | try: 239 | res = eureka_client.do_service("OTHER-SERVICE-NAME", "/service/context/path") 240 | print("result of other service" + res) 241 | except urllib.request.HTTPError as e: 242 | # If all nodes are down, a `HTTPError` will raise. 243 | print(e) 244 | ``` 245 | 246 | `do_service` function also recieve a `return_type` keyword parameter, which when `json` was passed, the result will be a `dict` type object whereas `response_object` is pass, the original HTTPResponse object will be return. Please read the relative document for more information. 247 | 248 | You can also use its `async` version: 249 | 250 | ```python 251 | import py_eureka_client.eureka_client as eureka_client 252 | 253 | res = await eureka_client.do_service_async("OTHER-SERVICE-NAME", "/service/context/path") 254 | ``` 255 | 256 | *do_service method will automatically try other nodes when one node return a HTTP error, until one success or all nodes being tried.* 257 | 258 | If you want to handle all the services' calling, you can use `walk_nodes` function: 259 | 260 | ```python 261 | import py_eureka_client.eureka_client as eureka_client 262 | 263 | # you can define this function with `async def` 264 | def walk_using_your_own_urllib(url): 265 | print(url) 266 | """ 267 | # Connect to url and read result, then return it. 268 | # The result you return here will be returned to the `eureka_client.walk_nodes` function 269 | # If you want find this node is down, you can raise a `urllib.request.HTTPError`(urllib2.HTTPError in python2) 270 | # Then the `eureka_client.walk_nodes` will try to find another node to do the service. 271 | """ 272 | 273 | # result is the result that you return in walk_using_your_own_urllib function 274 | try: 275 | res = eureka_client.walk_nodes("OTHER-SERVICE-NAME", "/service/context/path", walker=walk_using_your_own_urllib) 276 | print(res) 277 | except urllib.request.HTTPError as e: 278 | # If all nodes are down, a `HTTPError` will raise. 279 | print(e) 280 | ``` 281 | 282 | A `async` version is also provied: 283 | 284 | ```python 285 | import py_eureka_client.eureka_client as eureka_client 286 | 287 | def walk_using_your_own_urllib(url): 288 | print(url) 289 | """ 290 | # Connect to url and read result, then return it. 291 | # The result you return here will be returned to the `eureka_client.walk_nodes` function 292 | # If provided node is down, you can raise a `urllib.request.HTTPError`(urllib2.HTTPError in python2) 293 | # Then the `eureka_client.walk_nodes` will try to find another node to do the service. 294 | """ 295 | 296 | res = await eureka_client.walk_nodes_async("OTHER-SERVICE-NAME", "/service/context/path", 297 | walker=walk_using_your_own_urllib) 298 | ``` 299 | 300 | ### High Available Strategies 301 | 302 | There are several HA strategies when using discovery client. They are: 303 | 304 | * HA_STRATEGY_RANDOM, default strategy, find an node randamly. 305 | * HA_STRATEGY_STICK, use one node until it goes down. 306 | * HA_STRATEGY_OTHER, always use a different node from the last time. 307 | 308 | In your `init` function, you can specify one of the above strategies: 309 | 310 | ```python 311 | import py_eureka_client.eureka_client as eureka_client 312 | # General init method 313 | eureka_client.init(eureka_server="http://your-eureka-server-peer1,http://your-eureka-server-peer2", 314 | app_name="your_app_name", 315 | instance_port=your_rest_server_port, 316 | ha_strategy=eureka_client.HA_STRATEGY_STICK) 317 | ``` 318 | 319 | If the build-in stratergies do not satify you, you can load all the registry by following code: 320 | 321 | ```python 322 | import py_eureka_client.eureka_client as eureka_client 323 | 324 | client = eureka_client.get_client() 325 | app = client.applications.get_application("OTHER-SERVICE-NAME") 326 | up_instances = app.up_instances 327 | up_instances_same_zone = app.up_instances_in_zone(client.zone) 328 | up_instances_other_zone = app.up_instances_not_in_zone(client.zone) 329 | inst = up_instances[0] 330 | 331 | # ... construct your url and do the service call 332 | 333 | ``` 334 | 335 | ### Use Other Http Client 336 | 337 | You can use other http client to connect to eureka server and other service rather than the build-in urlopen method. It should be useful if you use https connections via self-signed cetificates. 338 | 339 | From `0.11.0`, the methods of the `http_client.HttpClient` are defined `async`, you can now use some async http libs like `aiohttp` 340 | 341 | To do this, you should: 342 | 343 | 1. (Optional) At most scenario, you should also write a class that inherited from `py_eureka_client.http_client.HttpResponse`, for the reason of the `py_eureka_client.http_client.HttpResponse` class wraps the `http.client.HTTPResponse` which may not return by the third http libs. 344 | 2. Write a class inherited the `HttpClient` class in `py_eureka_client.http_client`. 345 | 3. Rewrite the `urlopen` method in your class. this method must return an subclass of `py_eureka_client.http_client.HttpResponse`, which is a wrapper class that hold to properties called `raw_response` and `body_text`. 346 | 4. Set you own HttpClient object into `py_eureka_client.http_client` by `py_eureka_client.set_http_client` 347 | 348 | ```python 349 | import py_eureka_client.http_client as http_client 350 | 351 | # 1. A class inherited `py_eureka_client.http_client.HttpResonse` 352 | 353 | class MyHttpResponse(http_client.HttpResponse): 354 | 355 | def __init__(self, raw_response): 356 | """ 357 | " This raw response will return when you pass `response_object` in the `do_service` function. 358 | """ 359 | self.raw_response = raw_response 360 | 361 | @property 362 | def body_text(self): 363 | txt = "" 364 | """ 365 | " Read the body text from `self.raw_response` 366 | """ 367 | return txt 368 | 369 | # 2. A class inherited `py_eureka_client.http_client.HttpClient`. 370 | class MyHttpClient(http_client.HttpClient): 371 | 372 | # 3. Rewrite the `urlopen` method in your class. 373 | # If you want to raise an exception, please make sure that the exception is an `http_client.HTTPError` or `http_client.URLError`. 374 | async def urlopen(self, request: Union[str, http_client.HttpRequest] = None, 375 | data: bytes = None, timeout: float = None) -> http_client.HttpResponse: 376 | res = await your_own_http_client_lib.do_the_visit(request, data, timeout) 377 | return MyHttpResponse(res) 378 | # You can parse your response object here, and set the body_text to http_client.HttpResponse, then you may ignore the http_client.HttpResponse inheritance. 379 | # body_txt = parse_res_body(res) 380 | # http_res = http_client.HttpResponse() 381 | # http_res.raw_response = res 382 | # http_res.body_text = body_text 383 | # return http_res 384 | 385 | 386 | # 4. Set your class to `py_eureka_client.http_client`. 387 | http_client.set_http_client(MyHttpClient()) 388 | ``` 389 | 390 | ### Logger 391 | 392 | The default logger is try to write logs to the screen, you can specify the logger handler to write it to a file. 393 | 394 | ```python 395 | import py_eureka_client.logger as logger 396 | import logging 397 | 398 | _formatter = logging.Formatter(fmt='[%(asctime)s]-[%(name)s]-%(levelname)-4s: %(message)s') 399 | _handler = logging.TimedRotatingFileHandler("/var/log/py-eureka-client.log", when="midnight", backupCount=7) 400 | _handler.setFormatter(_formatter) 401 | _handler.setLevel("INFO") 402 | 403 | logger.set_handler(_handler) 404 | ``` 405 | 406 | If you want to add a handler rather than replace the inner one, you can use: 407 | 408 | ```python 409 | logger.add_handler(_handler) 410 | ``` 411 | 412 | If you want to change the logger level: 413 | 414 | ```python 415 | logger.set_level("DEBUG") 416 | ``` 417 | 418 | This logger will first save all the log record to a global queue, and then output them in a background thread, so it is very suitable for getting several logger with a same handler, especialy the `TimedRotatingFileHandler` which may slice the log files not quite well in a mutiple thread environment. 419 | 420 | ### Costom Logger 421 | 422 | If you want to use your own logger, you can do this: 423 | 424 | ```python 425 | import py_eureka_client.logger as logger 426 | 427 | logger.set_custom_logger(your_logger) 428 | ``` 429 | 430 | ## Amazon Data Center Support 431 | 432 | This component should support deploying in Amazone EC2, it should automatically load metadata from Amazon metadata service. All the metadata keys come from `com.netflix.appinfo.AmazonInfo` in Netflix's java client. BUT for the reason that I have no amazon environment to test, it may not work. If errors occurs, please submit an issue and provide some detail logs, I will try to fix it as far as I can. If it works, a reply in this [issue](https://github.com/keijack/python-eureka-client/issues/33) is wellcomed. 433 | 434 | ## More Infomation 435 | 436 | You can find more information in the project comments. 437 | 438 | -------------------------------------------------------------------------------- /README.zh_cn.md: -------------------------------------------------------------------------------- 1 | # python-eureka-client 2 | 3 | [![PyPI version](https://badge.fury.io/py/py-eureka-client.png)](https://badge.fury.io/py/py-eureka-client) 4 | 5 | ## 描述 6 | 7 | 这是一个使用 Python 语言编写的 eureka 客户端,你可以非常简单的使得它与你的其他 Spring Cloud 组件集成在一起。 8 | 9 | ## 支持版本 10 | 11 | Python 3.7+ 12 | 13 | *从`0.9.0`开始,不再支持 python 2,如果你需要使用 python 2,请使用 `0.8.12` 版本。* 14 | 15 | ## 特点 16 | 17 | * 同时支持注册以及发现服务。 18 | * 支持故障切换。 19 | * 支持DNS发现。 20 | * 非常简单的配置过程,堪比 Springboot 的配置文件。 21 | * 自动化的心跳以及组件状态机制,不需要开发者维护心跳。 22 | * 封装了调用其他服务的接口,用法类似 Spring boot 的 RestTemplate。 23 | * 调用其他服务时支持多种 HA(高可用)的策略。 24 | * 支持普通接口调用以及异步 `async def` 接口调用。 25 | * 底层使用的 http client 非常容易便可替换。 26 | 27 | ## 如何使用 28 | 29 | ### 安装 30 | 31 | ```Shell 32 | pip install py_eureka_client 33 | ``` 34 | 35 | ### 推荐使用 36 | 37 | 最简单的使用方法如下: 38 | 39 | ```python 40 | import py_eureka_client.eureka_client as eureka_client 41 | 42 | your_rest_server_host = "192.168.10.106" 43 | your_rest_server_port = 9090 44 | # The flowing code will register your server to eureka server and also start to send heartbeat every 30 seconds 45 | eureka_client.init(eureka_server="http://your-eureka-server-peer1,http://your-eureka-server-peer2", 46 | app_name="your_app_name", 47 | # 当前组件的主机名,可选参数,如果不填写会自动计算一个,如果服务和 eureka 服务器部署在同一台机器,请必须填写,否则会计算出 127.0.0.1 48 | instance_host=your_rest_server_host, 49 | instance_port=your_rest_server_port, 50 | # 调用其他服务时的高可用策略,可选,默认为随机 51 | ha_strategy=eureka_client.HA_STRATEGY_RANDOM) 52 | ``` 53 | 54 | 在你的业务代码中,通过以下的方法调用其他组件的服务 55 | 56 | ```python 57 | import py_eureka_client.eureka_client as eureka_client 58 | 59 | res = eureka_client.do_service("OTHER-SERVICE-NAME", "/service/context/path", 60 | # 返回类型,默认为 `string`,可以传入 `json`,如果传入值是 `json`,那么该方法会返回一个 `dict` 对象 61 | return_type="string") 62 | print("result of other service" + res) 63 | # 服务停止时调用,会调用 eureka 的 cancel 方法 64 | eureka_client.stop() 65 | ``` 66 | 67 | 你也可以直接使用 `EurekaClient` 类。 68 | 69 | ```python 70 | from py_eureka_client.eureka_client import EurekaClient 71 | client = EurekaClient(eureka_server="http://my_eureka_server_peer_1/eureka/v2,http://my_eureka_server_peer_2/eureka/v2", app_name="python_module_1", instance_port=9090) 72 | await client.start() 73 | res = await client.do_service("OTHER-SERVICE-NAME", "/service/context/path") 74 | print("result of other service" + res) 75 | # when server is shutted down: 76 | await client.stop() 77 | ``` 78 | 79 | 事实上,`init` 和相关的方法只是 `EurekaClient` 的一个门面(facade),其底层最终还是包含这一个 `EurekaClient` 的实例对象。你可以接收 `init` 方法的返回值,或者使用 `eureka_client.get_client()` 取得这个对象。`init` 会自动开始注册、心跳流程。而如果你 直接使用 `EurekaClient` 对象,你需要显式调用`start()` 和 `stop()` 方法来开始和停止注册过程。 80 | 81 | 从`0.11.0`开始,`EurekaClient` 提供的方法均为`async def`,而门面方面也提供对应的`async def` 版本,分别命名为 `init_async`、`do_service_async`、`walk_nodes_async`、`stop_async`。 82 | 83 | *在接下来的文档中,我会仅使用门面(facade)函数作为例子,事实上,你可以从 `EurekaClient` 类中找到这些函数对应的方法。* 84 | 85 | ### 注册服务 86 | 87 | 最常用的注册方法是: 88 | 89 | ```Python 90 | import py_eureka_client.eureka_client as eureka_client 91 | 92 | eureka_server_list = "http://your-eureka-server-peer1,http://your-eureka-server-peer2" 93 | your_rest_server_host = "http://192.168.10.11" 94 | your_rest_server_port = 9090 95 | # The flowing code will register your server to eureka server and also start to send heartbeat every 30 seconds 96 | eureka_client.init(eureka_server=eureka_server_list, 97 | app_name="your_app_name", 98 | instance_host=your_rest_server_host, 99 | instance_port=your_rest_server_port) 100 | ``` 101 | 102 | 你还可以不传入`instance_host`参数,如果不传入那个参数,组件会根据当前的网络取得一个 ip 作为参数。 103 | 104 | ```Python 105 | import py_eureka_client.eureka_client as eureka_client 106 | 107 | your_rest_server_port = 9090 108 | # The flowing code will register your server to eureka server and also start to send heartbeat every 30 seconds 109 | eureka_client.init(eureka_server="http://your-eureka-server-peer1,http://your-eureka-server-peer2", 110 | app_name="your_app_name", 111 | instance_port=your_rest_server_port) 112 | ``` 113 | 114 | 如果你有多个 `zone`,你可以通过参数 `eureka_availability_zones` 来进行配置。 115 | 116 | ```python 117 | import py_eureka_client.eureka_client as eureka_client 118 | eureka_client.init(eureka_availability_zones={ 119 | "us-east-1c": "http://ec2-552-627-568-165.compute-1.amazonaws.com:7001/eureka/v2/,http://ec2-368-101-182-134.compute-1.amazonaws.com:7001/eureka/v2/", 120 | "us-east-1d": "http://ec2-552-627-568-170.compute-1.amazonaws.com:7001/eureka/v2/", 121 | "us-east-1e": "http://ec2-500-179-285-592.compute-1.amazonaws.com:7001/eureka/v2/"}, 122 | zone="us-east-1c", 123 | app_name="python_module_1", 124 | instance_port=9090, 125 | data_center_name="Amazon") 126 | ``` 127 | 128 | 但如果你希望更具灵活性,你可以使用 DNS 来配置 Eureka 服务器的 URL。 129 | 130 | 假设,你有以下的 DNS txt 记录: 131 | 132 | ``` 133 | txt.us-east-1.mydomaintest.netflix.net="us-east-1c.mydomaintest.netflix.net" "us-east-1d.mydomaintest.netflix.net" "us-east-1e.mydomaintest.netflix.net" 134 | ``` 135 | 136 | 然后,你可以使用 DNS txt 记录 为每个上述的 `zone` 定义实际的 Eureka 服务的 URL: 137 | 138 | ``` 139 | txt.us-east-1c.mydomaintest.netflix.net="ec2-552-627-568-165.compute-1.amazonaws.com" "ec2-368-101-182-134.compute-1.amazonaws.com" 140 | txt.us-east-1d.mydomaintest.netflix.net="ec2-552-627-568-170.compute-1.amazonaws.com" 141 | txt.us-east-1e.mydomaintest.netflix.net="ec2-500-179-285-592.compute-1.amazonaws.com" 142 | ``` 143 | 144 | 之后,你可以通过这样的方式来初始化 eureka client: 145 | 146 | ```python 147 | import py_eureka_client.eureka_client as eureka_client 148 | eureka_client.init(eureka_domain="mydomaintest.netflix.net", 149 | region="us-east-1", 150 | zone="us-east-1c", 151 | app_name="python_module_1", 152 | instance_port=9090, 153 | data_center_name="Amazon") 154 | ``` 155 | 156 | 你可以独立配置 eureka 服务器的协议、简单认证、上下文路径,而不把这些放在 URL中。 157 | 158 | ```python 159 | import py_eureka_client.eureka_client as eureka_client 160 | eureka_client.init(eureka_domain="mydomaintest.netflix.net", 161 | region="us-east-1", 162 | zone="us-east-1c", 163 | eureka_protocol="https", 164 | eureka_basic_auth_user="keijack", 165 | eureka_basic_auth_password="kjauthpass", 166 | eureka_context="/eureka/v2", 167 | app_name="python_module_1", 168 | instance_port=9090, 169 | ``` 170 | 171 | 或者 172 | 173 | ```python 174 | import py_eureka_client.eureka_client as eureka_client 175 | eureka_client.init(eureka_server="your-eureka-server-peer1,your-eureka-server-peer2", 176 | eureka_protocol="https", 177 | eureka_basic_auth_user="keijack", 178 | eureka_basic_auth_password="kjauthpass", 179 | eureka_context="/eureka/v2", 180 | app_name="python_module_1", 181 | instance_port=9090) 182 | ``` 183 | 184 | *关于默认的 `instance_ip` 和 `instance_host`*: 185 | 186 | 如 Spring 的实现一样,`py-eureka-client` 在亚马逊的数据中心,会使用数据中心元数据服务取得的 `local-ipv4` 和 `local-hostname` 做为默认值,否则则会取第一个取得的具有 IPv4 的地址的网卡地址作为默认的地址。 187 | 188 | 你的机器环境中可能存在多个网卡(特别是使用 docker 容器的时候),那么你可以使用 `instance_ip_network` 参数指定网段来取得 IP 地址: 189 | 190 | ```python 191 | eureka_client.init(eureka_server="your-eureka-server-peer1,your-eureka-server-peer2", 192 | eureka_protocol="https", 193 | eureka_basic_auth_user="keijack", 194 | eureka_basic_auth_password="kjauthpass", 195 | eureka_context="/eureka/v2", 196 | app_name="python_module_1", 197 | instance_ip_network="192.168.10.0/24", 198 | instance_port=9090) 199 | ``` 200 | 201 | 如果你仅想动态取得 IP,但需要手动指定 host,那么你可以使用以下方法来实习: 202 | 203 | ```python 204 | import py_eureka_client.netint_utils as netint_utils 205 | 206 | ip = netint_utils.get_first_non_loopback_ip("192.168.10.0/24") 207 | host = "my-py-component.mydomian.com" 208 | 209 | eureka_client.init(eureka_server="your-eureka-server-peer1,your-eureka-server-peer2", 210 | eureka_protocol="https", 211 | eureka_basic_auth_user="keijack", 212 | eureka_basic_auth_password="kjauthpass", 213 | eureka_context="/eureka/v2", 214 | app_name="python_module_1", 215 | instance_ip=ip, 216 | instance_host=host, 217 | instance_port=9090) 218 | ``` 219 | 220 | ### 错误回调 221 | 222 | 你可以在初始化时指定一个错误回调函数,当`注册`、`发现`、`状态更新`时,如果发生错误,这个回调函数会被触发。请注意,如果你传入多个 eureka 服务器的 url,那么该回调会在所有服务器均尝试失败之后才会被触发。 223 | 224 | 定义的回调函数必须接收两个变量:一个是错误类型,一个是异常本身,请参考: 225 | 226 | ```python 227 | def on_err(err_type: str, err: Exception): 228 | if err_type in (eureka_client.ERROR_REGISTER, eureka_client.ERROR_DISCOVER): 229 | eureka_client.stop() 230 | else: 231 | print(f"{err_type}::{err}") 232 | 233 | your_rest_server_port = 9090 234 | eureka_client.init(eureka_server="http://your-eureka-server-peer1,http://your-eureka-server-peer2", 235 | app_name="python_module_1", 236 | instance_port=your_rest_server_port, 237 | on_error=on_err) 238 | ``` 239 | 240 | ### 调用远程服务 241 | 242 | 当初始化完 eureka client 之后,你就可以通过拉取 eureka server 的信息来调用远程服务了。 243 | 244 | 最简单的调用方式是: 245 | 246 | ```python 247 | import py_eureka_client.eureka_client as eureka_client 248 | 249 | try: 250 | res = eureka_client.do_service("OTHER-SERVICE-NAME", "/service/context/path", return_type="string") 251 | print("result of other service" + res) 252 | except urllib.request.HTTPError as e: 253 | # If all nodes are down, a `HTTPError` will raise. 254 | print(e) 255 | ``` 256 | 257 | 上述参数中,return_type 可以选择传入`json`,如果传入`json`,则该接口返回一个 `dict` 对象,如果传入`response_object`,那么该方法会返回原始的 HTTPResponse 对象。该参数也可不传入,默认返回的为 `str` 的响应体的内容。 258 | 259 | 这个方法还提供异步的版本: 260 | 261 | ```python 262 | import py_eureka_client.eureka_client as eureka_client 263 | 264 | res = await eureka_client.do_service_async("OTHER-SERVICE-NAME", "/service/context/path") 265 | ``` 266 | 267 | 如果你不希望使用内置的 HTTP 客户端,希望使用其他的客户端的话,你可以使用 `walk_nodes` 函数来实现: 268 | 269 | ```python 270 | import py_eureka_client.eureka_client as eureka_client 271 | 272 | def walk_using_your_own_urllib(url): 273 | print(url) 274 | """ 275 | # 根据传入的 url 参数,通过你选择的其他库来调用其他组件提供的 Restful 接口。 276 | # 你返回的数据会直接被 `eureka_client.walk_nodes` 函数返回 277 | # 如果你发现给定的 url 的节点无法访问,请 raise 一个 `urllib.request.HTTPError`(urllib2.HTTPError in python2), 278 | # 之后 `eureka_client.walk_nodes` 会继续寻找其他状态为 UP 的节点来调用。 279 | """ 280 | 281 | try: 282 | # `res` 是你在 walk_using_your_own_urllib 中返回的数据。 283 | res = eureka_client.walk_nodes("OTHER-SERVICE-NAME", "/service/context/path", walker=walk_using_your_own_urllib) 284 | print(res) 285 | except urllib.request.HTTPError as e: 286 | # 如果所有的节点没有正确返回结果,以上错误将被抛出 287 | print(e) 288 | ``` 289 | 290 | 这个方法同样有一个异步的版本: 291 | 292 | ```python 293 | import py_eureka_client.eureka_client as eureka_client 294 | 295 | def walk_using_your_own_urllib(url): 296 | print(url) 297 | 298 | 299 | res = await eureka_client.walk_nodes("OTHER-SERVICE-NAME", "/service/context/path", 300 | walker=walk_using_your_own_urllib, 301 | on_success=success_callabck, 302 | on_error=error_callback) 303 | ``` 304 | 305 | ### 高可用 306 | 307 | `do_service` 和 `walk_nodes` 方法支持 HA(高可用),该方法会尝试所有从 ereka 服务器取得的节点,直至其中一个节点返回数据,或者所有的节点都尝试失败。 308 | 309 | 该方法有几种 HA 的策略,这些策略分别是: 310 | 311 | * HA_STRATEGY_RANDOM, 默认策略,随机取得一个节点。 312 | * HA_STRATEGY_STICK, 随机取得一个节点之后一直使用该节点,直至这个节点被删除或者状态设为 DOWN。 313 | * HA_STRATEGY_OTHER, 总是使用和上次不同的节点。 314 | 315 | 如果你需要修改这些策略,你可以初始化发现服务时指定相应的策略: 316 | 317 | ```python 318 | import py_eureka_client.eureka_client as eureka_client 319 | 320 | eureka_server_list = "http://your-eureka-server-peer1,http://your-eureka-server-peer2" 321 | 322 | eureka_client.init(eureka_server=eureka_server_list, 323 | app_name="your_app_name", 324 | instance_port=9090, 325 | ha_strategy=eureka_client.HA_STRATEGY_OTHER) 326 | ``` 327 | 328 | 如果上述内置的 HA 策略都不能满足你的需求,你可以将按以下的办法取得整个服务注册库来构建你自己的访问方法: 329 | 330 | ```python 331 | import py_eureka_client.eureka_client as eureka_client 332 | 333 | client = eureka_client.get_client() 334 | app = client.applications.get_application("OTHER-SERVICE-NAME") 335 | up_instances = app.up_instances 336 | up_instances_same_zone = app.up_instances_in_zone(client.zone) 337 | up_instances_other_zone = app.up_instances_not_in_zone(client.zone) 338 | inst = up_instances[0] 339 | 340 | # ... 组装访问链接和进行远程调用 341 | 342 | ``` 343 | 344 | ### 使用三方 HTTP 客户端 345 | 346 | 默认情况下,组件使用了内置的 urllib.request 来进行 HTTP 请求。你可以使用别的 HTTP 库来进行访问。这在自签名的 HTTPS 证书的场景下尤为有效。 347 | 348 | 从 `0.11.0` 开始,底层的 Httplient 类的所有方法都定义为 `async def`,这使得你更容易地接入一些异步 IO 的第三方库,例如 `aiohttp`。 349 | 350 | 你需要以下步骤来使用自己的 HTTP 客户端: 351 | 352 | 1. (可选)大部分情况下,你需要编写一个继承`py_eureka_client.http_client.HttpResponse`的类,该类必须提供两个属性`raw_response`和`body_text`。其中,`raw_response`仅在`do_service`传入`response_object`时返回。 353 | 2. 编写一个类继承 `py_eureka_client.http_client.HttpClient` 类。 354 | 3. 重写该类的 `urlopen` 方法,该方法需要返回一个`py_eureka_client.http_client.HttpResponse`的子类对象。 355 | 4. 将你定义的类的对象设置到`py_eureka_client.http_client` 中。 356 | 357 | ```python 358 | import py_eureka_client.http_client as http_client 359 | 360 | # 1. 编写一个继承`py_eureka_client.http_client.HttpResponse`的类,该类必须提供两个属性`raw_response`和`body_text`。 361 | class MyHttpResponse(http_client.HttpResponse): 362 | 363 | def __init__(self, raw_response): 364 | """ 365 | " This raw response will return when you pass `response_object` in the `do_service` function. 366 | """ 367 | self.raw_response = raw_response 368 | 369 | @property 370 | def body_text(self): 371 | txt = "" 372 | """ 373 | " Read the body text from `self.raw_response` 374 | """ 375 | return txt 376 | 377 | # 2. 编写一个类继承 `py_eureka_client.http_client.HttpClient` 类 378 | class MyHttpClient(http_client.HttpClient): 379 | 380 | # 3. 重写该类的 `urlopen` 方法,该方法需要返回一个`py_eureka_client.http_client.HttpResponse`的子类对象 381 | # 如果你需要返回异常,请注意返回 `http_client.HTTPError` 或者 `http_client.URLError`。 382 | async def urlopen(self, request: Union[str, http_client.HttpRequest] = None, 383 | data: bytes = None, timeout: float = None) -> http_client.HttpResponse: 384 | res = await your_own_http_client_lib.do_the_visit(request, data, timeout) 385 | # 返回你定义的 HttpRespone 对象。 386 | return MyHttpResponse(res) 387 | # 你也可以在此解析了 body_text,摄入 HttpResponse 中,那么你就不需要继承 http_client.HttpResponse 了。 388 | # body_txt = parse_res_body(res) 389 | # http_res = http_client.HttpResponse() 390 | # http_res.raw_response = res 391 | # http_res.body_text = body_text 392 | # return http_res 393 | 394 | 395 | # 4. 将你的类对象设置到 http_client 中。 396 | http_client.set_http_client(MyHttpClient()) 397 | ``` 398 | 399 | ### 日志 400 | 401 | 默认情况下,日志会输出到控制台,你创建自己的 Logging Handler 来将日志输出到别处,例如一个滚动文件中: 402 | 403 | ```python 404 | import py_eureka_client.logger as logger 405 | import logging 406 | 407 | _formatter = logging.Formatter(fmt='[%(asctime)s]-[%(name)s]-%(levelname)-4s: %(message)s') 408 | _handler = logging.TimedRotatingFileHandler("/var/log/py-eureka-client.log", when="midnight", backupCount=7) 409 | _handler.setFormatter(_formatter) 410 | _handler.setLevel("INFO") 411 | 412 | logger.set_handler(_handler) 413 | ``` 414 | 415 | 如果你想增加一个日志控制器而不是想替代内置的,那么你可以使用以下方法: 416 | 417 | ```python 418 | logger.add_handler(_handler) 419 | ``` 420 | 421 | 你也可以使用以下方法来设置日志输出级别: 422 | 423 | ```python 424 | logger.set_level("DEBUG") 425 | ``` 426 | 427 | 这个日志使用了一个背景线程来输出日志,因此其非常适合使用在多线程的场景,特别你是你有多个 logger 共用一个 `TimedRotatingFileHandler` 的时候。在多线程的场景下,这个日志控制器经常不能正常地按时切割文件。 428 | 429 | ### 自定义 Logger 430 | 431 | 你可以通过以下方法来设置自己的 logger: 432 | 433 | ```python 434 | import py_eureka_client.logger as logger 435 | 436 | logger.set_custom_logger(your_logger) 437 | ``` 438 | 439 | ## 亚马逊数据中心支持 440 | 441 | 理论上,这个组件可以正常运行在亚马逊的数据中心。当运行在亚马逊数据中心,会从亚马逊的 metadata 服务中取得相关的元数据并且自动填充到 DataCenterInfo 中,填充的字段信息来源自 Netflix 的 Java 客户端中的 `com.netflix.appinfo.AmazonInfo` 类。**不过**,由于我本人没有亚马逊的相关环境作为测试,所以,在实际的运行当中,可能会发生错误。如果真的发生了错误的话,请提出 ISSUE 并且提供详细的日志,我会尽力支持。如果运行没有问题,如果可以,也欢迎在这个[问题](https://github.com/keijack/python-eureka-client/issues/33)进行回复。 442 | 443 | 444 | ## 更多信息 445 | 446 | **其他更多的信息请查看项目注释。** 447 | -------------------------------------------------------------------------------- /py_eureka_client/__aws_info_loader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | import socket 25 | import json 26 | import time 27 | from py_eureka_client.http_client import http_client 28 | from py_eureka_client.logger import get_logger 29 | 30 | _logger = get_logger("aws_info_loader") 31 | 32 | _CONNECTIVITY_TEST_TIMES = 5 33 | _AWS_METADATA_SERVICE_IP = "169.254.169.254" 34 | _AWS_METADATA_SERVICE_URL = f"http://{_AWS_METADATA_SERVICE_IP}/latest/" 35 | 36 | 37 | class AmazonInfo: 38 | 39 | def __init__(self): 40 | self.__can_access = self.__check_connectivity() 41 | 42 | def __check_connectivity(self): 43 | for i in range(_CONNECTIVITY_TEST_TIMES): 44 | try: 45 | s = socket.socket() 46 | s.connect((_AWS_METADATA_SERVICE_IP, 80)) 47 | s.close() 48 | return True 49 | except socket.error: 50 | idx = i + 1 51 | _logger.debug( 52 | f"Try amazon metadata services connectivity fail, retry ({idx}/{_CONNECTIVITY_TEST_TIMES})") 53 | time.sleep(1) 54 | _logger.warn( 55 | f"Cannot connect to amazon metadata services in address [{_AWS_METADATA_SERVICE_IP}]") 56 | return False 57 | 58 | async def get_ec2_metadata(self, meta_path, default_value="", ignore_error=False): 59 | if not self.__can_access: 60 | _logger.warn( 61 | f"Cannot connect to amazon metadata services in address [{_AWS_METADATA_SERVICE_IP}], return default value. ") 62 | return default_value 63 | try: 64 | res = await http_client.urlopen(f"{_AWS_METADATA_SERVICE_URL}meta-data/{meta_path}") 65 | return res.body_text 66 | except Exception: 67 | log_excep = _logger.debug if ignore_error else _logger.exception 68 | log_excep(f"error when loading metadata from aws {meta_path}") 69 | return default_value 70 | 71 | async def get_instance_identity_document(self, default_value={}): 72 | if not self.__can_access: 73 | _logger.warn( 74 | f"Cannot connect to amazon metadata services in address [{_AWS_METADATA_SERVICE_IP}], return default value. ") 75 | return default_value 76 | try: 77 | doc = await http_client.urlopen(f"{_AWS_METADATA_SERVICE_URL}dynamic/instance-identity/document") 78 | return json.loads(doc.body_text) 79 | except Exception: 80 | _logger.exception( 81 | "error when loading dynamic instance identity document from aws") 82 | return default_value 83 | -------------------------------------------------------------------------------- /py_eureka_client/__dns_txt_resolver.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | import dns.resolver 26 | 27 | 28 | def get_txt_dns_record(domain): 29 | records = dns.resolver.resolve(domain, 'TXT') 30 | if len(records): 31 | return str(records[0]).replace('"', '').split(' ') 32 | -------------------------------------------------------------------------------- /py_eureka_client/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | version = "0.13.1" 26 | 27 | """ 28 | Status of instances 29 | """ 30 | INSTANCE_STATUS_UP: str = "UP" 31 | INSTANCE_STATUS_DOWN: str = "DOWN" 32 | INSTANCE_STATUS_STARTING: str = "STARTING" 33 | INSTANCE_STATUS_OUT_OF_SERVICE: str = "OUT_OF_SERVICE" 34 | INSTANCE_STATUS_UNKNOWN: str = "UNKNOWN" 35 | 36 | """ 37 | Action type of instances 38 | """ 39 | ACTION_TYPE_ADDED: str = "ADDED" 40 | ACTION_TYPE_MODIFIED: str = "MODIFIED" 41 | ACTION_TYPE_DELETED: str = "DELETED" 42 | 43 | """ 44 | This is for the DiscoveryClient, when this strategy is set, get_service_url will random choose one of the UP instance and return its url 45 | This is the default strategy 46 | """ 47 | HA_STRATEGY_RANDOM: int = 1 48 | """ 49 | This is for the DiscoveryClient, when this strategy is set, get_service_url will always return one instance until it is down 50 | """ 51 | HA_STRATEGY_STICK: int = 2 52 | """ 53 | This is for the DiscoveryClient, when this strategy is set, get_service_url will always return a new instance if any other instances are up 54 | """ 55 | HA_STRATEGY_OTHER: int = 3 56 | 57 | """ 58 | The error types that will send back to on_error callback function 59 | """ 60 | ERROR_REGISTER: str = "EUREKA_ERROR_REGISTER" 61 | ERROR_DISCOVER: str = "EUREKA_ERROR_DISCOVER" 62 | ERROR_STATUS_UPDATE: str = "EUREKA_ERROR_STATUS_UPDATE" 63 | 64 | """ 65 | Default eureka server url. 66 | """ 67 | _DEFAULT_EUREKA_SERVER_URL = "http://127.0.0.1:8761/eureka/" 68 | """ 69 | Default instance field values 70 | """ 71 | _DEFAULT_INSTNACE_PORT = 9090 72 | _DEFAULT_INSTNACE_SECURE_PORT = 9443 73 | _RENEWAL_INTERVAL_IN_SECS = 30 74 | _DURATION_IN_SECS = 90 75 | _DEFAULT_DATA_CENTER_INFO = "MyOwn" 76 | _DEFAULT_DATA_CENTER_INFO_CLASS = "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo" 77 | _AMAZON_DATA_CENTER_INFO_CLASS = "com.netflix.appinfo.AmazonInfo" 78 | """ 79 | Default configurations 80 | """ 81 | _DEFAULT_ENCODING = "utf-8" 82 | _DEFAUTL_ZONE = "default" 83 | """ 84 | The timeout seconds that all http request to the eureka server 85 | """ 86 | _DEFAULT_TIME_OUT = 5 87 | -------------------------------------------------------------------------------- /py_eureka_client/eureka_basic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | 26 | import json 27 | 28 | 29 | from typing import Dict, List 30 | import xml.etree.ElementTree as ElementTree 31 | from threading import RLock 32 | from urllib.parse import quote 33 | 34 | import py_eureka_client.http_client as http_client 35 | 36 | from py_eureka_client.logger import get_logger 37 | 38 | 39 | from py_eureka_client import INSTANCE_STATUS_UP, INSTANCE_STATUS_OUT_OF_SERVICE 40 | from py_eureka_client import ACTION_TYPE_ADDED 41 | from py_eureka_client import _DEFAULT_INSTNACE_PORT, _DEFAULT_INSTNACE_SECURE_PORT, _RENEWAL_INTERVAL_IN_SECS, _RENEWAL_INTERVAL_IN_SECS, _DURATION_IN_SECS, _DEFAULT_DATA_CENTER_INFO, _DEFAULT_DATA_CENTER_INFO_CLASS 42 | from py_eureka_client import _DEFAULT_ENCODING, _DEFAUTL_ZONE, _DEFAULT_TIME_OUT 43 | 44 | _logger = get_logger("eureka_basic") 45 | 46 | 47 | ### =========================> Base Mehods <======================================== ### 48 | ### Beans ### 49 | 50 | 51 | class LeaseInfo: 52 | 53 | def __init__(self, 54 | renewalIntervalInSecs: int = _RENEWAL_INTERVAL_IN_SECS, 55 | durationInSecs: int = _DURATION_IN_SECS, 56 | registrationTimestamp: int = 0, 57 | lastRenewalTimestamp: int = 0, 58 | renewalTimestamp: int = 0, 59 | evictionTimestamp: int = 0, 60 | serviceUpTimestamp: int = 0): 61 | self.renewalIntervalInSecs: int = renewalIntervalInSecs 62 | self.durationInSecs: int = durationInSecs 63 | self.registrationTimestamp: int = registrationTimestamp 64 | self.lastRenewalTimestamp: int = lastRenewalTimestamp 65 | self.renewalTimestamp: int = renewalTimestamp 66 | self.evictionTimestamp: int = evictionTimestamp 67 | self.serviceUpTimestamp: int = serviceUpTimestamp 68 | 69 | 70 | class DataCenterInfo: 71 | 72 | def __init__(self, 73 | name=_DEFAULT_DATA_CENTER_INFO, # Netflix, Amazon, MyOwn 74 | className=_DEFAULT_DATA_CENTER_INFO_CLASS, 75 | metadata={}): 76 | self.name: str = name 77 | self.className: str = className 78 | self.metadata: Dict = metadata if metadata else {} 79 | 80 | 81 | class PortWrapper: 82 | def __init__(self, port=0, enabled=False): 83 | self.port: int = port 84 | self.enabled: bool = enabled 85 | 86 | 87 | class Instance: 88 | 89 | def __init__(self, 90 | instanceId="", 91 | sid="", # @deprecated 92 | app="", 93 | appGroupName="", 94 | ipAddr="", 95 | port=PortWrapper(port=_DEFAULT_INSTNACE_PORT, enabled=True), 96 | securePort=PortWrapper( 97 | port=_DEFAULT_INSTNACE_SECURE_PORT, enabled=False), 98 | homePageUrl="", 99 | statusPageUrl="", 100 | healthCheckUrl="", 101 | secureHealthCheckUrl="", 102 | vipAddress="", 103 | secureVipAddress="", 104 | countryId=1, 105 | dataCenterInfo=DataCenterInfo(), 106 | hostName="", 107 | status="", # UP, DOWN, STARTING, OUT_OF_SERVICE, UNKNOWN 108 | overriddenstatus="", # UP, DOWN, STARTING, OUT_OF_SERVICE, UNKNOWN 109 | leaseInfo=LeaseInfo(), 110 | isCoordinatingDiscoveryServer=False, 111 | metadata=None, 112 | lastUpdatedTimestamp=0, 113 | lastDirtyTimestamp=0, 114 | actionType=ACTION_TYPE_ADDED, # ADDED, MODIFIED, DELETED 115 | asgName=""): 116 | self.__instanceId: str = instanceId 117 | self.sid: str = sid 118 | self.app: str = app 119 | self.appGroupName: str = appGroupName 120 | self.ipAddr: str = ipAddr 121 | self.port: PortWrapper = port 122 | self.securePort: PortWrapper = securePort 123 | self.homePageUrl: str = homePageUrl 124 | self.statusPageUrl: str = statusPageUrl 125 | self.healthCheckUrl: str = healthCheckUrl 126 | self.secureHealthCheckUrl: str = secureHealthCheckUrl 127 | self.vipAddress: str = vipAddress 128 | self.secureVipAddress: str = secureVipAddress 129 | self.countryId: str = countryId 130 | self.dataCenterInfo: DataCenterInfo = dataCenterInfo 131 | self.hostName: str = hostName 132 | self.status: str = status 133 | self.overriddenstatus: str = overriddenstatus 134 | self.leaseInfo: LeaseInfo = leaseInfo 135 | self.isCoordinatingDiscoveryServer: bool = isCoordinatingDiscoveryServer 136 | self.metadata: Dict = metadata if metadata is not None else {} 137 | self.lastUpdatedTimestamp: int = lastUpdatedTimestamp 138 | self.lastDirtyTimestamp: int = lastDirtyTimestamp 139 | self.actionType: int = actionType 140 | self.asgName: int = asgName 141 | 142 | @property 143 | def instanceId(self): 144 | return self.__instanceId if self.__instanceId else f"{self.hostName}:{self.ipAddr}:{self.app}:{self.port.port if self.port else 0}" 145 | 146 | @instanceId.setter 147 | def instanceId(self, id): 148 | self.__instanceId = id 149 | 150 | @property 151 | def zone(self) -> str: 152 | if self.dataCenterInfo and self.dataCenterInfo.name == "Amazon" \ 153 | and self.dataCenterInfo.metadata and "availability-zone" in self.dataCenterInfo.metadata: 154 | return self.dataCenterInfo.metadata["availability-zone"] 155 | if self.metadata and "zone" in self.metadata and self.metadata["zone"]: 156 | return self.metadata["zone"] 157 | else: 158 | return _DEFAUTL_ZONE 159 | 160 | 161 | class Application: 162 | 163 | def __init__(self, 164 | name="", 165 | instances=None): 166 | self.name: str = name 167 | if isinstance(instances, list): 168 | for ins in instances: 169 | self.add_instance(ins) 170 | self.__instances_dict = {} 171 | self.__inst_lock = RLock() 172 | 173 | @property 174 | def instances(self) -> List[Instance]: 175 | with self.__inst_lock: 176 | return list(self.__instances_dict.values()) 177 | 178 | @property 179 | def up_instances(self) -> List[Instance]: 180 | with self.__inst_lock: 181 | return [item for item in self.__instances_dict.values() if item.status == INSTANCE_STATUS_UP] 182 | 183 | def get_instance(self, instance_id: str) -> Instance: 184 | with self.__inst_lock: 185 | if instance_id in self.__instances_dict: 186 | return self.__instances_dict[instance_id] 187 | else: 188 | return None 189 | 190 | def add_instance(self, instance: Instance) -> None: 191 | with self.__inst_lock: 192 | self.__instances_dict[instance.instanceId] = instance 193 | 194 | def update_instance(self, instance: Instance) -> None: 195 | with self.__inst_lock: 196 | _logger.debug(f"update instance {instance.instanceId}") 197 | self.__instances_dict[instance.instanceId] = instance 198 | 199 | def remove_instance(self, instance: Instance) -> None: 200 | with self.__inst_lock: 201 | if instance.instanceId in self.__instances_dict: 202 | del self.__instances_dict[instance.instanceId] 203 | 204 | def up_instances_in_zone(self, zone: str) -> List[Instance]: 205 | with self.__inst_lock: 206 | _zone = zone if zone else _DEFAUTL_ZONE 207 | return [item for item in self.__instances_dict.values() if item.status == INSTANCE_STATUS_UP and item.zone == _zone] 208 | 209 | def up_instances_not_in_zone(self, zone: str) -> List[Instance]: 210 | with self.__inst_lock: 211 | _zone = zone if zone else _DEFAUTL_ZONE 212 | return [item for item in self.__instances_dict.values() if item.status == INSTANCE_STATUS_UP and item.zone != _zone] 213 | 214 | 215 | class Applications: 216 | 217 | def __init__(self, 218 | apps__hashcode="", 219 | versions__delta="", 220 | applications=None): 221 | self.apps__hashcode: str = apps__hashcode 222 | self.versions__delta: str = versions__delta 223 | self.__applications = applications if applications is not None else [] 224 | self.__application_name_dic = {} 225 | self.__app_lock = RLock() 226 | 227 | @property 228 | def appsHashcode(self) -> str: 229 | return self.apps__hashcode 230 | 231 | @property 232 | def applications(self) -> List[Application]: 233 | return self.__applications 234 | 235 | @property 236 | def versionsDelta(self) -> str: 237 | return self.versions__delta 238 | 239 | def add_application(self, application: Application) -> None: 240 | with self.__app_lock: 241 | self.__applications.append(application) 242 | self.__application_name_dic[application.name] = application 243 | 244 | def get_application(self, app_name: str = "") -> Application: 245 | with self.__app_lock: 246 | aname = app_name.upper() 247 | if aname in self.__application_name_dic: 248 | return self.__application_name_dic[aname] 249 | else: 250 | return Application(name=aname) 251 | 252 | 253 | ########################## Basic functions ################################# 254 | ####### Registry functions ######### 255 | async def register(eureka_server: str, instance: Instance) -> None: 256 | instance_dic = { 257 | 'instanceId': instance.instanceId, 258 | 'hostName': instance.hostName, 259 | 'app': instance.app, 260 | 'ipAddr': instance.ipAddr, 261 | 'status': instance.status, 262 | 'overriddenstatus': instance.overriddenstatus, 263 | 'port': { 264 | '$': instance.port.port, 265 | '@enabled': str(instance.port.enabled).lower() 266 | }, 267 | 'securePort': { 268 | '$': instance.securePort.port, 269 | '@enabled': str(instance.securePort.enabled).lower() 270 | }, 271 | 'countryId': instance.countryId, 272 | 'dataCenterInfo': { 273 | '@class': instance.dataCenterInfo.className, 274 | 'name': instance.dataCenterInfo.name 275 | }, 276 | 'leaseInfo': { 277 | 'renewalIntervalInSecs': instance.leaseInfo.renewalIntervalInSecs, 278 | 'durationInSecs': instance.leaseInfo.durationInSecs, 279 | 'registrationTimestamp': instance.leaseInfo.registrationTimestamp, 280 | 'lastRenewalTimestamp': instance.leaseInfo.lastRenewalTimestamp, 281 | 'evictionTimestamp': instance.leaseInfo.evictionTimestamp, 282 | 'serviceUpTimestamp': instance.leaseInfo.serviceUpTimestamp 283 | }, 284 | 'metadata': instance.metadata, 285 | 'homePageUrl': instance.homePageUrl, 286 | 'statusPageUrl': instance.statusPageUrl, 287 | 'healthCheckUrl': instance.healthCheckUrl, 288 | 'secureHealthCheckUrl': instance.secureHealthCheckUrl, 289 | 'vipAddress': instance.vipAddress, 290 | 'secureVipAddress': instance.secureVipAddress, 291 | 'lastUpdatedTimestamp': str(instance.lastUpdatedTimestamp), 292 | 'lastDirtyTimestamp': str(instance.lastDirtyTimestamp), 293 | 'isCoordinatingDiscoveryServer': str(instance.isCoordinatingDiscoveryServer).lower() 294 | } 295 | if instance.dataCenterInfo.metadata: 296 | instance_dic["dataCenterInfo"]["metadata"] = instance.dataCenterInfo.metadata 297 | await _register(eureka_server, instance_dic) 298 | 299 | 300 | async def _register(eureka_server: str, instance_dic: Dict) -> None: 301 | req = http_client.HttpRequest(f"{_format_url(eureka_server)}apps/{quote(instance_dic['app'])}", 302 | method="POST", 303 | headers={"Content-Type": "application/json"}) 304 | await http_client.http_client.urlopen(req, json.dumps({"instance": instance_dic}).encode( 305 | _DEFAULT_ENCODING), timeout=_DEFAULT_TIME_OUT) 306 | 307 | 308 | async def cancel(eureka_server: str, app_name: str, instance_id: str) -> None: 309 | req = http_client.HttpRequest(f"{_format_url(eureka_server)}apps/{quote(app_name)}/{quote(instance_id)}", 310 | method="DELETE") 311 | await http_client.http_client.urlopen(req, timeout=_DEFAULT_TIME_OUT) 312 | 313 | 314 | async def send_heartbeat(eureka_server: str, 315 | app_name: str, 316 | instance_id: str, 317 | last_dirty_timestamp: int, 318 | status: str = INSTANCE_STATUS_UP, 319 | overriddenstatus: str = "") -> None: 320 | url = f"{_format_url(eureka_server)}apps/{quote(app_name)}/{quote(instance_id)}?status={status}&lastDirtyTimestamp={last_dirty_timestamp}" 321 | if overriddenstatus != "": 322 | url += f"&overriddenstatus={overriddenstatus}" 323 | 324 | req = http_client.HttpRequest(url, method="PUT") 325 | await http_client.http_client.urlopen(req, timeout=_DEFAULT_TIME_OUT) 326 | 327 | 328 | async def status_update(eureka_server: str, 329 | app_name: str, 330 | instance_id: str, 331 | last_dirty_timestamp, 332 | status: str = INSTANCE_STATUS_OUT_OF_SERVICE, 333 | overriddenstatus: str = ""): 334 | url = f"{_format_url(eureka_server)}apps/{quote(app_name)}/{quote(instance_id)}/status?value={status}&lastDirtyTimestamp={last_dirty_timestamp}" 335 | if overriddenstatus != "": 336 | url += f"&overriddenstatus={overriddenstatus}" 337 | 338 | req = http_client.HttpRequest(url, method="PUT") 339 | await http_client.http_client.urlopen(req, timeout=_DEFAULT_TIME_OUT) 340 | 341 | 342 | async def delete_status_override(eureka_server: str, app_name: str, instance_id: str, last_dirty_timestamp: str): 343 | url = f"{_format_url(eureka_server)}apps/{quote(app_name)}/{quote(instance_id)}/status?lastDirtyTimestamp={last_dirty_timestamp}" 344 | 345 | req = http_client.HttpRequest(url, method="DELETE") 346 | await http_client.http_client.urlopen(req, timeout=_DEFAULT_TIME_OUT) 347 | 348 | 349 | ####### Discovory functions ######## 350 | 351 | 352 | async def get_applications(eureka_server: str, regions: List[str] = []) -> Applications: 353 | res = await _get_applications_(f"{_format_url(eureka_server)}apps/", regions) 354 | return res 355 | 356 | 357 | def _format_url(url): 358 | if url.endswith('/'): 359 | return url 360 | else: 361 | return url + "/" 362 | 363 | 364 | async def _get_applications_(url, regions=[]): 365 | _url = url 366 | if len(regions) > 0: 367 | _url = _url + ("&" if "?" in _url else "?") + \ 368 | "regions=" + (",".join(regions)) 369 | 370 | res = await http_client.http_client.urlopen( 371 | _url, timeout=_DEFAULT_TIME_OUT) 372 | return _build_applications(ElementTree.fromstring(res.body_text.encode(_DEFAULT_ENCODING))) 373 | 374 | 375 | def _build_applications(xml_node): 376 | if xml_node.tag != "applications": 377 | return None 378 | applications = Applications() 379 | for child_node in list(xml_node): 380 | if child_node.tag == "versions__delta" and child_node.text is not None: 381 | applications.versions__delta = child_node.text 382 | elif child_node.tag == "apps__hashcode" and child_node.text is not None: 383 | applications.apps__hashcode = child_node.text 384 | elif child_node.tag == "application": 385 | applications.add_application(_build_application(child_node)) 386 | 387 | return applications 388 | 389 | 390 | def _build_application(xml_node): 391 | if xml_node.tag != "application": 392 | return None 393 | application = Application() 394 | for child_node in xml_node: 395 | if child_node.tag == "name": 396 | application.name = child_node.text 397 | elif child_node.tag == "instance": 398 | application.add_instance(_build_instance(child_node)) 399 | return application 400 | 401 | 402 | def _build_instance(xml_node): 403 | if xml_node.tag != "instance": 404 | return None 405 | instance = Instance() 406 | for child_node in xml_node: 407 | if child_node.tag == "instanceId": 408 | instance.instanceId = child_node.text 409 | elif child_node.tag == "sid": 410 | instance.sid = child_node.text 411 | elif child_node.tag == "app": 412 | instance.app = child_node.text 413 | elif child_node.tag == "appGroupName": 414 | instance.appGroupName = child_node.text 415 | elif child_node.tag == "ipAddr": 416 | instance.ipAddr = child_node.text 417 | elif child_node.tag == "port": 418 | instance.port = _build_port(child_node) 419 | elif child_node.tag == "securePort": 420 | instance.securePort = _build_port(child_node) 421 | elif child_node.tag == "homePageUrl": 422 | instance.homePageUrl = child_node.text 423 | elif child_node.tag == "statusPageUrl": 424 | instance.statusPageUrl = child_node.text 425 | elif child_node.tag == "healthCheckUrl": 426 | instance.healthCheckUrl = child_node.text 427 | elif child_node.tag == "secureHealthCheckUrl": 428 | instance.secureHealthCheckUrl = child_node.text 429 | elif child_node.tag == "vipAddress": 430 | instance.vipAddress = child_node.text 431 | elif child_node.tag == "secureVipAddress": 432 | instance.secureVipAddress = child_node.text 433 | elif child_node.tag == "countryId": 434 | instance.countryId = int(child_node.text) 435 | elif child_node.tag == "dataCenterInfo": 436 | instance.dataCenterInfo = _build_data_center_info(child_node) 437 | elif child_node.tag == "hostName": 438 | instance.hostName = child_node.text 439 | elif child_node.tag == "status": 440 | instance.status = child_node.text 441 | elif child_node.tag == "overriddenstatus": 442 | instance.overriddenstatus = child_node.text 443 | elif child_node.tag == "leaseInfo": 444 | instance.leaseInfo = _build_lease_info(child_node) 445 | elif child_node.tag == "isCoordinatingDiscoveryServer": 446 | instance.isCoordinatingDiscoveryServer = ( 447 | child_node.text == "true") 448 | elif child_node.tag == "metadata": 449 | instance.metadata = _build_metadata(child_node) 450 | elif child_node.tag == "lastUpdatedTimestamp": 451 | instance.lastUpdatedTimestamp = int(child_node.text) 452 | elif child_node.tag == "lastDirtyTimestamp": 453 | instance.lastDirtyTimestamp = int(child_node.text) 454 | elif child_node.tag == "actionType": 455 | instance.actionType = child_node.text 456 | elif child_node.tag == "asgName": 457 | instance.asgName = child_node.text 458 | 459 | return instance 460 | 461 | 462 | def _build_data_center_info(xml_node): 463 | class_name = xml_node.attrib["class"] 464 | name = "" 465 | metadata = {} 466 | for child_node in xml_node: 467 | if child_node.tag == "name": 468 | name = child_node.text 469 | elif child_node.tag == "metadata": 470 | metadata = _build_metadata(child_node) 471 | 472 | return DataCenterInfo(name=name, className=class_name, metadata=metadata) 473 | 474 | 475 | def _build_metadata(xml_node): 476 | metadata = {} 477 | for child_node in list(xml_node): 478 | metadata[child_node.tag] = child_node.text 479 | return metadata 480 | 481 | 482 | def _build_lease_info(xml_node): 483 | leaseInfo = LeaseInfo() 484 | for child_node in list(xml_node): 485 | if child_node.tag == "renewalIntervalInSecs": 486 | leaseInfo.renewalIntervalInSecs = int(child_node.text) 487 | elif child_node.tag == "durationInSecs": 488 | leaseInfo.durationInSecs = int(child_node.text) 489 | elif child_node.tag == "registrationTimestamp": 490 | leaseInfo.registrationTimestamp = int(child_node.text) 491 | elif child_node.tag == "lastRenewalTimestamp": 492 | leaseInfo.lastRenewalTimestamp = int(child_node.text) 493 | elif child_node.tag == "renewalTimestamp": 494 | leaseInfo.renewalTimestamp = int(child_node.text) 495 | elif child_node.tag == "evictionTimestamp": 496 | leaseInfo.evictionTimestamp = int(child_node.text) 497 | elif child_node.tag == "serviceUpTimestamp": 498 | leaseInfo.serviceUpTimestamp = int(child_node.text) 499 | 500 | return leaseInfo 501 | 502 | 503 | def _build_port(xml_node): 504 | port = PortWrapper() 505 | port.port = int(xml_node.text) 506 | port.enabled = (xml_node.attrib["enabled"] == "true") 507 | return port 508 | 509 | 510 | async def get_delta(eureka_server: str, regions: List[str] = []) -> Applications: 511 | res = await _get_applications_(f"{_format_url(eureka_server)}apps/delta", regions) 512 | return res 513 | 514 | 515 | async def get_vip(eureka_server: str, vip: str, regions: List[str] = []) -> Applications: 516 | res = await _get_applications_(f"{_format_url(eureka_server)}vips/{vip}", regions) 517 | return res 518 | 519 | 520 | async def get_secure_vip(eureka_server: str, svip: str, regions: List[str] = []) -> Applications: 521 | res = await _get_applications_(f"{_format_url(eureka_server)}svips/{svip}", regions) 522 | return res 523 | 524 | 525 | async def get_application(eureka_server: str, app_name: str) -> Application: 526 | url = f"{_format_url(eureka_server)}apps/{quote(app_name)}" 527 | res = await http_client.http_client.urlopen(url, timeout=_DEFAULT_TIME_OUT) 528 | return _build_application(ElementTree.fromstring(res.body_text)) 529 | 530 | 531 | async def get_app_instance(eureka_server: str, app_name: str, instance_id: str) -> Instance: 532 | res = await _get_instance_(f"{_format_url(eureka_server)}apps/{quote(app_name)}/{quote(instance_id)}") 533 | return res 534 | 535 | 536 | async def get_instance(eureka_server: str, instance_id: str) -> Instance: 537 | res = _get_instance_( 538 | f"{_format_url(eureka_server)}instances/{quote(instance_id)}") 539 | return res 540 | 541 | 542 | async def _get_instance_(url): 543 | res = await http_client.http_client.urlopen( 544 | url, timeout=_DEFAULT_TIME_OUT) 545 | return _build_instance(ElementTree.fromstring(res.body_text)) 546 | -------------------------------------------------------------------------------- /py_eureka_client/eureka_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | 26 | import asyncio 27 | 28 | import json 29 | import re 30 | import socket 31 | import threading 32 | import time 33 | 34 | import random 35 | 36 | from copy import copy 37 | from typing import Callable, Dict, List, Union 38 | from threading import RLock, Timer 39 | from urllib.parse import quote 40 | 41 | import py_eureka_client.http_client as http_client 42 | import py_eureka_client.netint_utils as netint 43 | from py_eureka_client.logger import get_logger 44 | from py_eureka_client.__dns_txt_resolver import get_txt_dns_record 45 | from py_eureka_client.__aws_info_loader import AmazonInfo 46 | 47 | from py_eureka_client import INSTANCE_STATUS_UP, INSTANCE_STATUS_DOWN, INSTANCE_STATUS_STARTING, INSTANCE_STATUS_OUT_OF_SERVICE, INSTANCE_STATUS_UNKNOWN 48 | from py_eureka_client import ACTION_TYPE_ADDED, ACTION_TYPE_MODIFIED, ACTION_TYPE_DELETED 49 | from py_eureka_client import HA_STRATEGY_RANDOM, HA_STRATEGY_STICK, HA_STRATEGY_OTHER 50 | from py_eureka_client import ERROR_REGISTER, ERROR_DISCOVER, ERROR_STATUS_UPDATE 51 | from py_eureka_client import _DEFAULT_EUREKA_SERVER_URL, _DEFAULT_INSTNACE_PORT, _DEFAULT_INSTNACE_SECURE_PORT, _RENEWAL_INTERVAL_IN_SECS, _RENEWAL_INTERVAL_IN_SECS, _DURATION_IN_SECS, _DEFAULT_DATA_CENTER_INFO, _DEFAULT_DATA_CENTER_INFO_CLASS, _AMAZON_DATA_CENTER_INFO_CLASS 52 | from py_eureka_client import _DEFAUTL_ZONE, _DEFAULT_TIME_OUT 53 | 54 | from py_eureka_client.eureka_basic import LeaseInfo, DataCenterInfo, PortWrapper, Instance, Application, Applications 55 | from py_eureka_client.eureka_basic import register, _register, cancel, send_heartbeat, status_update, delete_status_override 56 | from py_eureka_client.eureka_basic import get_applications, get_delta, get_vip, get_secure_vip, get_application, get_app_instance, get_instance 57 | 58 | _logger = get_logger("eureka_client") 59 | 60 | 61 | def _current_time_millis(): 62 | return int(time.time() * 1000) 63 | 64 | 65 | """====================== Client =======================================""" 66 | 67 | 68 | class EurekaServerConf(object): 69 | 70 | def __init__(self, 71 | eureka_server=_DEFAULT_EUREKA_SERVER_URL, 72 | eureka_domain="", 73 | eureka_protocol="http", 74 | eureka_basic_auth_user="", 75 | eureka_basic_auth_password="", 76 | eureka_context="eureka/v2", 77 | eureka_availability_zones={}, 78 | region="", 79 | zone=""): 80 | self.__servers = {} 81 | self.region: str = region 82 | self.__zone = zone 83 | self.__eureka_availability_zones: dict = eureka_availability_zones 84 | _zone = zone if zone else _DEFAUTL_ZONE 85 | if eureka_domain: 86 | zone_urls = get_txt_dns_record(f"txt.{region}.{eureka_domain}") 87 | for zone_url in zone_urls: 88 | zone_name = zone_url.split(".")[0] 89 | eureka_urls = get_txt_dns_record(f"txt.{zone_url}") 90 | self.__servers[zone_name] = [self._format_url(eureka_url.strip(), eureka_protocol, eureka_basic_auth_user, 91 | eureka_basic_auth_password, eureka_context) for eureka_url in eureka_urls] 92 | elif eureka_availability_zones: 93 | for zone_name, v in eureka_availability_zones.items(): 94 | if isinstance(v, list): 95 | eureka_urls = v 96 | else: 97 | eureka_urls = str(v).split(",") 98 | self.__servers[zone_name] = [self._format_url(eureka_url.strip(), eureka_protocol, eureka_basic_auth_user, 99 | eureka_basic_auth_password, eureka_context) for eureka_url in eureka_urls] 100 | else: 101 | self.__servers[_zone] = [self._format_url(eureka_url.strip(), eureka_protocol, eureka_basic_auth_user, 102 | eureka_basic_auth_password, eureka_context) for eureka_url in eureka_server.split(",")] 103 | self.__servers_not_in_zone = copy(self.__servers) 104 | if _zone in self.__servers_not_in_zone: 105 | del self.__servers_not_in_zone[_zone] 106 | 107 | @property 108 | def zone(self) -> str: 109 | if self.__zone: 110 | return self.__zone 111 | elif self.__eureka_availability_zones: 112 | return list(self.__eureka_availability_zones.keys())[0] 113 | else: 114 | return _DEFAUTL_ZONE 115 | 116 | def _format_url(self, server_url="", 117 | eureka_protocol="http", 118 | eureka_basic_auth_user="", 119 | eureka_basic_auth_password="", 120 | eureka_context="eureka/v2"): 121 | url = server_url 122 | if url.endswith('/'): 123 | url = url[0: -1] 124 | if url.find("://") > 0: 125 | prtl, url = tuple(url.split("://")) 126 | else: 127 | prtl = eureka_protocol 128 | 129 | if url.find("@") > 0: 130 | basic_auth, url = tuple(url.split("@")) 131 | if basic_auth.find(":") > 0: 132 | user, password = tuple(basic_auth.split(":")) 133 | else: 134 | user = basic_auth 135 | password = "" 136 | else: 137 | user = quote(eureka_basic_auth_user) 138 | password = quote(eureka_basic_auth_password) 139 | 140 | basic_auth = "" 141 | if user: 142 | if password: 143 | basic_auth = f"{user}:{password}" 144 | else: 145 | basic_auth = user 146 | basic_auth += "@" 147 | 148 | if url.find("/") > 0: 149 | ctx = "" 150 | else: 151 | ctx = eureka_context if eureka_context.startswith( 152 | '/') else "/" + eureka_context 153 | 154 | return f"{prtl}://{basic_auth}{url}{ctx}" 155 | 156 | @property 157 | def servers(self) -> Dict: 158 | return self.__servers 159 | 160 | @property 161 | def servers_in_zone(self) -> List[str]: 162 | if self.zone in self.servers: 163 | return self.servers[self.zone] 164 | else: 165 | return [] 166 | 167 | @property 168 | def servers_not_in_zone(self) -> List[str]: 169 | return self.__servers_not_in_zone 170 | 171 | 172 | class EurekaServerConnectionException(http_client.URLError): 173 | pass 174 | 175 | 176 | class DiscoverException(http_client.URLError): 177 | pass 178 | 179 | 180 | class NodeError: 181 | 182 | def __init__(self, node, error: Exception): 183 | self.node = node 184 | self.error = error 185 | 186 | 187 | class WalkNodeException(http_client.URLError): 188 | 189 | def __init__(self, reason, node_errors: List[NodeError] = []): 190 | super(WalkNodeException, self).__init__("", reason) 191 | self.node_errors = node_errors or [] 192 | 193 | 194 | class EurekaClient: 195 | """ 196 | Example: 197 | 198 | >>> client = EurekaClient( 199 | eureka_server="http://my_eureka_server_peer_1/eureka/v2,http://my_eureka_server_peer_2/eureka/v2", 200 | app_name="python_module_1", 201 | instance_port=9090) 202 | >>> client.start() 203 | >>> result = client.do_service("APP_NAME", "/context/path", return_type="json") 204 | 205 | EIPs support: 206 | 207 | You can configure EIP using `eureka_availability_zones` and specify the `zone` of your instance. But please aware, that the client won't fill up the metadata atomatically, 208 | You should put it to the `metadata` when creating the object. 209 | 210 | >>> client = EurekaClient(eureka_availability_zones={ 211 | "us-east-1c": "http://ec2-552-627-568-165.compute-1.amazonaws.com:7001/eureka/v2/,http://ec2-368-101-182-134.compute-1.amazonaws.com:7001/eureka/v2/", 212 | "us-east-1d": "http://ec2-552-627-568-170.compute-1.amazonaws.com:7001/eureka/v2/", 213 | "us-east-1e": "http://ec2-500-179-285-592.compute-1.amazonaws.com:7001/eureka/v2/"}, 214 | zone="us-east-1c", 215 | app_name="python_module_1", 216 | instance_port=9090, 217 | data_center_name="Amazon") 218 | 219 | EurekaClient supports DNS discovery feature. 220 | 221 | For instance, following is a DNS TXT record created in the DNS server that lists the set of available DNS names for a zone. 222 | 223 | >>> txt.us-east-1.mydomaintest.netflix.net="us-east-1c.mydomaintest.netflix.net" "us-east-1d.mydomaintest.netflix.net" "us-east-1e.mydomaintest.netflix.net" 224 | 225 | Then, you can define TXT records recursively for each zone similar to the following (if more than one hostname per zone, space delimit) 226 | 227 | >>> txt.us-east-1c.mydomaintest.netflix.net="ec2-552-627-568-165.compute-1.amazonaws.com" "ec2-368-101-182-134.compute-1.amazonaws.com" 228 | >>> txt.us-east-1d.mydomaintest.netflix.net="ec2-552-627-568-170.compute-1.amazonaws.com" 229 | >>> txt.us-east-1e.mydomaintest.netflix.net="ec2-500-179-285-592.compute-1.amazonaws.com" 230 | 231 | And then you can create the client like: 232 | 233 | >>> client = EurekaClient(eureka_domain="mydomaintest.netflix.net", 234 | region="us-east-1", 235 | zone="us-east-1c", 236 | app_name="python_module_1", 237 | instance_port=9090, 238 | data_center_name="Amazon") 239 | 240 | Eureka client also supports setting up the protocol, basic authentication and context path of your eureka server. 241 | 242 | >>> client = EurekaClient(eureka_domain="mydomaintest.netflix.net", 243 | region="us-east-1", 244 | zone="us-east-1c", 245 | eureka_protocol="https", 246 | eureka_basic_auth_user="keijack", 247 | eureka_basic_auth_password="kjauthpass", 248 | eureka_context="/eureka/v2", 249 | app_name="python_module_1", 250 | instance_port=9090, 251 | data_center_name="Amazon") 252 | 253 | or 254 | 255 | >>> client = EurekaClient(eureka_server="my_eureka_server_peer_1,my_eureka_server_peer_2", 256 | eureka_protocol="https", 257 | eureka_basic_auth_user="keijack", 258 | eureka_basic_auth_password="kjauthpass", 259 | eureka_context="/eureka/v2", 260 | app_name="python_module_1", 261 | instance_port=9090) 262 | 263 | You can use `do_service`, `wall_nodes` to call the remote services. 264 | 265 | >>> res = eureka_client.do_service("OTHER-SERVICE-NAME", "/service/context/path") 266 | 267 | >>> def walk_using_your_own_urllib(url): 268 | ... 269 | 270 | res = await client.walk_nodes("OTHER-SERVICE-NAME", "/service/context/path", walker=walk_using_your_own_urllib) 271 | 272 | 273 | Attributes: 274 | 275 | * eureka_server: The eureka server url, if you want have deploy a cluster to do the failover, use `,` to separate the urls. 276 | 277 | * eureka_domain: The domain name when using the DNS discovery. 278 | 279 | * region: The region when using DNS discovery. 280 | 281 | * zone: Which zone your instances belong to, default is `default`. 282 | 283 | * eureka_availability_zones: The zones' url configurations. 284 | 285 | * eureka_protocol: The protocol of the eureka server, if the url include this part, this protocol will not add to the url. 286 | 287 | * eureka_basic_auth_user: User name of the basic authentication of the eureka server, if the url include this part, this protocol will not add to the url. 288 | 289 | * eureka_basic_auth_password: Password of the basic authentication of the eureka server, if the url include this part, this protocol will not add to the url. 290 | 291 | * eureka_context: The context path of the eureka server, if the url include this part, this protocol will not add to the url, default is `/eureka` 292 | which meets the spring-boot eureka context but not the Netflix eureka server url. 293 | 294 | * prefer_same_zone: When set to True, will first find the eureka server in the same zone to register, and find the instances in the same zone to do 295 | the service. Or it will randomly choose the eureka server to register and instances to do the services, default is `True`. 296 | 297 | * should_register: When set to False, will not register this instance to the eureka server, default is `True`. 298 | 299 | * should_discover: When set to False, will not pull registry from the eureka server, default is `True`. 300 | 301 | The following parameters all the properties of this instances, all this fields will be sent to the eureka server. 302 | 303 | * app_name: The application name of this instance. 304 | 305 | * instance_id: The id of this instance, if not specified, will generate one by app_name and instance_host/instance_ip and instance_port. 306 | 307 | * instance_host: The host of this instance. 308 | 309 | * instance_ip: The ip of this instance. If instance_host and instance_ip are not specified, will try to find the ip via connection to the eureka server. 310 | 311 | * instance_ip_network: The ip network of this instance. If instance_host and instance_ip are not specified, will try to find the ip from the avaiable network adapters that matches the specified network. For example 192.168.1.0/24. 312 | 313 | * instance_port: The port of this instance. 314 | 315 | * instance_unsecure_port_enabled: Set whether enable the instance's unsecure port, default is `True`. 316 | 317 | * instance_secure_port: The secure port of this instance. 318 | 319 | * instance_secure_port_enabled: Set whether enable the instance's secure port, default is `False`. 320 | 321 | * data_center_name: Accept `Netflix`, `Amazon`, `MyOwn`, default is `MyOwn` 322 | 323 | * renewal_interval_in_secs: Will send heartbeat and pull registry in this time interval, defalut is 30 seconds 324 | 325 | * duration_in_secs: Sets the client specified setting for eviction (e.g. how long to wait without renewal event). 326 | 327 | * home_page_url: The home page url of this instance. 328 | 329 | * status_page_url: The status page url of this instance. 330 | 331 | * health_check_url: The health check url of this instance. 332 | 333 | * secure_health_check_url: The secure health check url of this instance. 334 | 335 | * vip_adr: The virtual ip address of this instance. 336 | 337 | * secure_vip_addr: The secure virtual ip address of this instance. 338 | 339 | * is_coordinating_discovery_server: Sets a flag if this instance is the same as the discovery server that is 340 | return the instances. This flag is used by the discovery clients to 341 | identity the discovery server which is coordinating/returning the 342 | information. 343 | 344 | * metadata: The metadata map of this instances. 345 | 346 | * remote_regions: Will also find the services that belongs to these regions. 347 | 348 | * ha_strategy: Specify the strategy how to choose a instance when there are more than one instanse of an App. 349 | 350 | * strict_service_error_policy: When set to True, all errors(Including connection error and HttpError, like http 351 | status code is not 200) will consider as errors; Otherwise, only (ConnectionError, TimeoutError, socket.timeout) 352 | will be considered as errors, and other excptions and errors will be raised to upstream. Default is True. 353 | 354 | """ 355 | 356 | def __init__(self, 357 | eureka_server: str = _DEFAULT_EUREKA_SERVER_URL, 358 | eureka_domain: str = "", 359 | region: str = "", 360 | zone: str = "", 361 | eureka_availability_zones: Dict[str, str] = {}, 362 | eureka_protocol: str = "http", 363 | eureka_basic_auth_user: str = "", 364 | eureka_basic_auth_password: str = "", 365 | eureka_context: str = "/eureka", 366 | prefer_same_zone: bool = True, 367 | should_register: bool = True, 368 | should_discover: bool = True, 369 | on_error: Callable = None, 370 | app_name: str = "", 371 | instance_id: str = "", 372 | instance_host: str = "", 373 | instance_ip: str = "", 374 | instance_ip_network: str = "", 375 | instance_port: int = _DEFAULT_INSTNACE_PORT, 376 | instance_unsecure_port_enabled: bool = True, 377 | instance_secure_port: int = _DEFAULT_INSTNACE_SECURE_PORT, 378 | instance_secure_port_enabled: bool = False, 379 | data_center_name: str = _DEFAULT_DATA_CENTER_INFO, # Netflix, Amazon, MyOwn 380 | renewal_interval_in_secs: int = _RENEWAL_INTERVAL_IN_SECS, 381 | duration_in_secs: int = _DURATION_IN_SECS, 382 | home_page_url: str = "", 383 | status_page_url: str = "", 384 | health_check_url: str = "", 385 | secure_health_check_url: str = "", 386 | vip_adr: str = "", 387 | secure_vip_addr: str = "", 388 | is_coordinating_discovery_server: bool = False, 389 | metadata: Dict = {}, 390 | remote_regions: List[str] = [], 391 | ha_strategy: int = HA_STRATEGY_RANDOM, 392 | strict_service_error_policy: bool = True): 393 | assert app_name is not None and app_name != "" if should_register else True, "application name must be specified." 394 | assert instance_port > 0 if should_register else True, "port is unvalid" 395 | assert isinstance(metadata, dict), "metadata must be dict" 396 | assert ha_strategy in (HA_STRATEGY_RANDOM, HA_STRATEGY_STICK, 397 | HA_STRATEGY_OTHER) if should_discover else True, f"do not support strategy {ha_strategy}" 398 | 399 | self.__net_lock = RLock() 400 | self.__eureka_server_conf = EurekaServerConf( 401 | eureka_server=eureka_server, 402 | eureka_domain=eureka_domain, 403 | eureka_protocol=eureka_protocol, 404 | eureka_basic_auth_user=eureka_basic_auth_user, 405 | eureka_basic_auth_password=eureka_basic_auth_password, 406 | eureka_context=eureka_context, 407 | eureka_availability_zones=eureka_availability_zones, 408 | region=region, 409 | zone=zone 410 | ) 411 | self.__cache_eureka_url = {} 412 | self.__should_register = should_register 413 | self.__should_discover = should_discover 414 | self.__prefer_same_zone = prefer_same_zone 415 | self.__alive = False 416 | self.__heartbeat_interval = renewal_interval_in_secs 417 | self.__heartbeat_timer = Timer(self.__heartbeat_interval, self.__heartbeat_thread) 418 | self.__heartbeat_timer.name = "HeartbeatThread" 419 | self.__heartbeat_timer.daemon = True 420 | 421 | self.__instance_id = instance_id 422 | self.__instance_ip = instance_ip 423 | self.__instance_ip_network = instance_ip_network 424 | self.__instance_host = instance_host 425 | self.__instance_port = instance_port 426 | self.__app_name = app_name 427 | self.__instance_unsecure_port_enabled = instance_unsecure_port_enabled 428 | self.__instance_secure_port = instance_secure_port 429 | self.__instance_secure_port_enabled = instance_secure_port_enabled 430 | self.__data_center_name = data_center_name 431 | self.__duration_in_secs = duration_in_secs 432 | self.__metadata = metadata 433 | self.__home_page_url = home_page_url 434 | self.__status_page_url = status_page_url 435 | self.__health_check_url = health_check_url 436 | self.__secure_health_check_url = secure_health_check_url 437 | self.__vip_adr = vip_adr 438 | self.__secure_vip_addr = secure_vip_addr 439 | self.__is_coordinating_discovery_server = is_coordinating_discovery_server 440 | 441 | self.__aws_metadata = {} 442 | self.__on_error_callback = on_error 443 | 444 | # For Registery 445 | self.__instance = {} 446 | 447 | # For discovery 448 | self.__remote_regions = remote_regions if remote_regions is not None else [] 449 | self.__applications = None 450 | self.__delta = None 451 | self.__ha_strategy = ha_strategy 452 | self.__strict_service_error_policy = strict_service_error_policy 453 | self.__ha_cache = {} 454 | 455 | self.__application_mth_lock = RLock() 456 | 457 | async def __parepare_instance_info(self): 458 | if self.__data_center_name == "Amazon": 459 | self.__aws_metadata = await self.__load_ec2_metadata_dict() 460 | if self.__instance_host == "" and self.__instance_ip == "": 461 | self.__instance_ip, self.__instance_host = self.__get_ip_host( 462 | self.__instance_ip_network) 463 | elif self.__instance_host != "" and self.__instance_ip == "": 464 | self.__instance_ip = netint.get_ip_by_host( 465 | self.__instance_host) 466 | if not EurekaClient.__is_ip(self.__instance_ip): 467 | async def try_to_get_client_ip(url): 468 | self.__instance_ip = EurekaClient.__get_instance_ip(url) 469 | await self.__connect_to_eureka_server(try_to_get_client_ip) 470 | elif self.__instance_host == "" and self.__instance_ip != "": 471 | self.__instance_host = netint.get_host_by_ip(self.__instance_ip) 472 | 473 | mdata = { 474 | 'management.port': str(self.__instance_port) 475 | } 476 | if self.__eureka_server_conf.zone: 477 | mdata["zone"] = self.__eureka_server_conf.zone 478 | mdata.update(self.__metadata) 479 | ins_id = self.__instance_id or f"{self.__instance_ip}:{self.__app_name.lower()}:{self.__instance_port}" 480 | _logger.debug(f"register instance using id [#{ins_id}]") 481 | self.__instance = { 482 | 'instanceId': ins_id, 483 | 'hostName': self.__instance_host, 484 | 'app': self.__app_name.upper(), 485 | 'ipAddr': self.__instance_ip, 486 | 'port': { 487 | '$': self.__instance_port, 488 | '@enabled': str(self.__instance_unsecure_port_enabled).lower() 489 | }, 490 | 'securePort': { 491 | '$': self.__instance_secure_port, 492 | '@enabled': str(self.__instance_secure_port_enabled).lower() 493 | }, 494 | 'countryId': 1, 495 | 'dataCenterInfo': { 496 | '@class': _AMAZON_DATA_CENTER_INFO_CLASS if self.__data_center_name == "Amazon" else _DEFAULT_DATA_CENTER_INFO_CLASS, 497 | 'name': self.__data_center_name 498 | }, 499 | 'leaseInfo': { 500 | 'renewalIntervalInSecs': self.__heartbeat_interval, 501 | 'durationInSecs': self.__duration_in_secs, 502 | 'registrationTimestamp': 0, 503 | 'lastRenewalTimestamp': 0, 504 | 'evictionTimestamp': 0, 505 | 'serviceUpTimestamp': 0 506 | }, 507 | 'metadata': mdata, 508 | 'homePageUrl': EurekaClient.__format_url(self.__home_page_url, self.__instance_host, self.__instance_port), 509 | 'statusPageUrl': EurekaClient.__format_url(self.__status_page_url, self.__instance_host, self.__instance_port, "info"), 510 | 'healthCheckUrl': EurekaClient.__format_url(self.__health_check_url, self.__instance_host, self.__instance_port, "health"), 511 | 'secureHealthCheckUrl': self.__secure_health_check_url, 512 | 'vipAddress': self.__vip_adr or self.__app_name.lower(), 513 | 'secureVipAddress': self.__secure_vip_addr or self.__app_name.lower(), 514 | 'isCoordinatingDiscoveryServer': str(self.__is_coordinating_discovery_server).lower() 515 | } 516 | if self.__data_center_name == "Amazon": 517 | self.__instance["dataCenterInfo"]["metadata"] = self.__aws_metadata 518 | 519 | def __get_ip_host(self, network): 520 | ip, host = netint.get_ip_and_host(network) 521 | if self.__aws_metadata and "local-ipv4" in self.__aws_metadata and self.__aws_metadata["local-ipv4"]: 522 | ip = self.__aws_metadata["local-ipv4"] 523 | if self.__aws_metadata and "local-hostname" in self.__aws_metadata and self.__aws_metadata["local-hostname"]: 524 | host = self.__aws_metadata["local-hostname"] 525 | return ip, host 526 | 527 | async def __load_ec2_metadata_dict(self): 528 | # instance metadata 529 | amazon_info = AmazonInfo() 530 | mac = await amazon_info.get_ec2_metadata('mac') 531 | if mac: 532 | vpc_id = await amazon_info.get_ec2_metadata( 533 | f'network/interfaces/macs/{mac}/vpc-id') 534 | else: 535 | vpc_id = "" 536 | metadata = { 537 | 'instance-id': amazon_info.get_ec2_metadata('instance-id'), 538 | 'ami-id': amazon_info.get_ec2_metadata('ami-id'), 539 | 'instance-type': amazon_info.get_ec2_metadata('instance-type'), 540 | 'local-ipv4': amazon_info.get_ec2_metadata('local-ipv4'), 541 | 'local-hostname': amazon_info.get_ec2_metadata('local-hostname'), 542 | 'availability-zone': amazon_info.get_ec2_metadata('placement/availability-zone', ignore_error=True), 543 | 'public-hostname': amazon_info.get_ec2_metadata('public-hostname', ignore_error=True), 544 | 'public-ipv4': amazon_info.get_ec2_metadata('public-ipv4', ignore_error=True), 545 | 'mac': mac, 546 | 'vpcId': vpc_id 547 | } 548 | # accountId 549 | doc = await amazon_info.get_instance_identity_document() 550 | if doc and "accountId" in doc: 551 | metadata["accountId"] = doc["accountId"] 552 | return metadata 553 | 554 | @property 555 | def should_register(self) -> bool: 556 | return self.__should_register 557 | 558 | @property 559 | def should_discover(self) -> bool: 560 | return self.__should_discover 561 | 562 | @property 563 | def zone(self) -> str: 564 | return self.__eureka_server_conf.zone 565 | 566 | @property 567 | def applications(self) -> Applications: 568 | if not self.should_discover: 569 | raise DiscoverException( 570 | "should_discover set to False, no registry is pulled, cannot find any applications.") 571 | with self.__application_mth_lock: 572 | if self.__applications is None: 573 | self.__pull_full_registry() 574 | return self.__applications 575 | 576 | async def __try_eureka_server_in_cache(self, fun): 577 | ok = False 578 | invalid_keys = [] 579 | for z, url in self.__cache_eureka_url.items(): 580 | try: 581 | _logger.debug( 582 | f"Try to do {fun.__name__} in zone[{z}] using cached url {url}. ") 583 | await fun(url) 584 | except (http_client.HTTPError, http_client.URLError): 585 | _logger.warn( 586 | f"Eureka server [{url}] is down, use next url to try.", exc_info=True) 587 | invalid_keys.append(z) 588 | else: 589 | ok = True 590 | if invalid_keys: 591 | _logger.debug( 592 | f"Invalid keys::{invalid_keys} will be removed from cache.") 593 | for z in invalid_keys: 594 | del self.__cache_eureka_url[z] 595 | if not ok: 596 | raise EurekaServerConnectionException( 597 | "All eureka servers in cache are down!") 598 | 599 | async def __try_eureka_server_in_zone(self, fun): 600 | await self.__try_eureka_servers_in_list( 601 | fun, self.__eureka_server_conf.servers_in_zone, self.zone) 602 | 603 | async def __try_eureka_server_not_in_zone(self, fun): 604 | for zone, urls in self.__eureka_server_conf.servers_not_in_zone.items(): 605 | try: 606 | await self.__try_eureka_servers_in_list(fun, urls, zone) 607 | except EurekaServerConnectionException: 608 | _logger.warn( 609 | f"try eureka servers in zone[{zone}] error!", exc_info=True) 610 | else: 611 | return 612 | raise EurekaServerConnectionException( 613 | "All eureka servers in all zone are down!") 614 | 615 | async def __try_eureka_server_regardless_zones(self, fun): 616 | for zone, urls in self.__eureka_server_conf.servers.items(): 617 | try: 618 | await self.__try_eureka_servers_in_list(fun, urls, zone) 619 | except EurekaServerConnectionException: 620 | _logger.warn( 621 | f"try eureka servers in zone[{zone}] error!", exc_info=True) 622 | else: 623 | return 624 | raise EurekaServerConnectionException( 625 | "All eureka servers in all zone are down!") 626 | 627 | async def __try_all_eureka_servers(self, fun): 628 | if self.__prefer_same_zone: 629 | try: 630 | await self.__try_eureka_server_in_zone(fun) 631 | except EurekaServerConnectionException: 632 | await self.__try_eureka_server_not_in_zone(fun) 633 | else: 634 | await self.__try_eureka_server_regardless_zones(fun) 635 | 636 | async def __try_eureka_servers_in_list(self, fun, eureka_servers=[], zone=_DEFAUTL_ZONE): 637 | with self.__net_lock: 638 | ok = False 639 | _zone = zone if zone else _DEFAUTL_ZONE 640 | for url in eureka_servers: 641 | url = url.strip() 642 | try: 643 | _logger.debug( 644 | f"try to do {fun.__name__} in zone[{_zone}] using url {url}. ") 645 | await fun(url) 646 | except (http_client.HTTPError, http_client.URLError): 647 | _logger.warn( 648 | f"Eureka server [{url}] is down, use next url to try.", exc_info=True) 649 | else: 650 | ok = True 651 | self.__cache_eureka_url[_zone] = url 652 | break 653 | 654 | if not ok: 655 | if _zone in self.__cache_eureka_url: 656 | del self.__cache_eureka_url[_zone] 657 | raise EurekaServerConnectionException( 658 | f"All eureka servers in zone[{_zone}] are down!") 659 | 660 | async def __connect_to_eureka_server(self, fun): 661 | if self.__cache_eureka_url: 662 | try: 663 | await self.__try_eureka_server_in_cache(fun) 664 | except EurekaServerConnectionException: 665 | await self.__try_all_eureka_servers(fun) 666 | else: 667 | await self.__try_all_eureka_servers(fun) 668 | 669 | @staticmethod 670 | def __format_url(url: str, host: str, port: str, defalut_ctx=""): 671 | if url != "": 672 | if url.startswith('http'): 673 | _url = url 674 | elif url.startswith('/'): 675 | _url = f'http://{host}:{port}{url}' 676 | else: 677 | _url = f'http://{host}:{port}/{url}' 678 | else: 679 | _url = f'http://{host}:{port}/{defalut_ctx}' 680 | return _url 681 | 682 | @staticmethod 683 | def __is_ip(ip_str): 684 | return re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', ip_str) 685 | 686 | @staticmethod 687 | def __get_instance_ip(eureka_server): 688 | url_obj = http_client.parse_url(eureka_server) 689 | target_ip = url_obj.host 690 | target_port = url_obj.port 691 | if target_port is None: 692 | if url_obj.schema == "http": 693 | target_port = 80 694 | else: 695 | target_port = 443 696 | 697 | if url_obj.ipv6 is not None: 698 | target_ip = url_obj.ipv6 699 | socket_family = socket.AF_INET6 700 | else: 701 | socket_family = socket.AF_INET 702 | 703 | s = socket.socket(socket_family, socket.SOCK_DGRAM) 704 | s.connect((target_ip, target_port)) 705 | ip = s.getsockname()[0] 706 | s.close() 707 | return ip 708 | 709 | async def _on_error(self, error_type: str, exception: Exception): 710 | if self.__on_error_callback: 711 | if asyncio.iscoroutine(self.__on_error_callback): 712 | await self.__on_error_callback(error_type, exception) 713 | elif callable(self.__on_error_callback): 714 | self.__on_error_callback(error_type, exception) 715 | 716 | async def register(self, status: str = INSTANCE_STATUS_UP, overriddenstatus: str = INSTANCE_STATUS_UNKNOWN) -> None: 717 | self.__instance["status"] = status 718 | self.__instance["overriddenstatus"] = overriddenstatus 719 | self.__instance["lastUpdatedTimestamp"] = str(_current_time_millis()) 720 | self.__instance["lastDirtyTimestamp"] = str(_current_time_millis()) 721 | try: 722 | async def do_register(url): 723 | await _register(url, self.__instance) 724 | await self.__connect_to_eureka_server(do_register) 725 | except Exception as e: 726 | self.__alive = False 727 | _logger.warn( 728 | "Register error! Will try in next heartbeat. ", exc_info=True) 729 | await self._on_error(ERROR_REGISTER, e) 730 | else: 731 | _logger.debug("register successfully!") 732 | self.__alive = True 733 | 734 | async def cancel(self) -> None: 735 | try: 736 | async def do_cancel(url): 737 | await cancel(url, self.__instance["app"], 738 | self.__instance["instanceId"]) 739 | await self.__connect_to_eureka_server(do_cancel) 740 | except Exception as e: 741 | _logger.warn("Cancel error!", exc_info=True) 742 | await self._on_error(ERROR_STATUS_UPDATE, e) 743 | else: 744 | self.__alive = False 745 | 746 | async def send_heartbeat(self, overridden_status: str = "") -> None: 747 | if not self.__alive: 748 | await self.register() 749 | return 750 | try: 751 | _logger.debug("sending heartbeat to eureka server. ") 752 | 753 | async def do_send_heartbeat(url): 754 | await send_heartbeat(url, self.__instance["app"], 755 | self.__instance["instanceId"], self.__instance["lastDirtyTimestamp"], 756 | status=self.__instance["status"], overriddenstatus=overridden_status) 757 | await self.__connect_to_eureka_server(do_send_heartbeat) 758 | except Exception as e: 759 | _logger.warn( 760 | "Cannot send heartbeat to server, try to register. ", exc_info=True) 761 | await self._on_error(ERROR_STATUS_UPDATE, e) 762 | await self.register() 763 | 764 | async def status_update(self, new_status: str) -> None: 765 | self.__instance["status"] = new_status 766 | try: 767 | async def do_status_update(url): 768 | await status_update(url, self.__instance["app"], self.__instance["instanceId"], 769 | self.__instance["lastDirtyTimestamp"], new_status) 770 | await self.__connect_to_eureka_server(do_status_update) 771 | except Exception as e: 772 | _logger.warn("update status error!", exc_info=True) 773 | await self._on_error(ERROR_STATUS_UPDATE, e) 774 | 775 | async def delete_status_override(self) -> None: 776 | try: 777 | async def do_delete_status_override(url): 778 | await delete_status_override( 779 | url, self.__instance["app"], self.__instance["instanceId"], self.__instance["lastDirtyTimestamp"]) 780 | await self.__connect_to_eureka_server(do_delete_status_override) 781 | except Exception as e: 782 | _logger.warn("delete status overrid error!", exc_info=True) 783 | await self._on_error(ERROR_STATUS_UPDATE, e) 784 | 785 | async def __start_register(self): 786 | _logger.debug("start to registry client...") 787 | await self.register() 788 | 789 | async def __stop_registery(self): 790 | if self.__alive: 791 | await self.register(status=INSTANCE_STATUS_DOWN) 792 | await self.cancel() 793 | 794 | def __heartbeat_thread(self): 795 | _logger.debug("Start heartbeat!") 796 | loop = asyncio.new_event_loop() 797 | while True: 798 | loop.run_until_complete(self.__heartbeat()) 799 | time.sleep(self.__heartbeat_interval) 800 | 801 | async def __heartbeat(self): 802 | if self.__should_register: 803 | _logger.debug("sending heartbeat to eureka server ") 804 | await self.send_heartbeat() 805 | if self.__should_discover: 806 | _logger.debug("loading services from eureka server") 807 | await self.__fetch_delta() 808 | 809 | async def __pull_full_registry(self): 810 | async def do_pull(url): # the actual function body 811 | self.__applications = await get_applications(url, self.__remote_regions) 812 | self.__delta = self.__applications 813 | try: 814 | await self.__connect_to_eureka_server(do_pull) 815 | except Exception as e: 816 | _logger.warn( 817 | "pull full registry from eureka server error!", exc_info=True) 818 | await self._on_error(ERROR_DISCOVER, e) 819 | 820 | async def __fetch_delta(self): 821 | async def do_fetch(url): 822 | if self.__applications is None or len(self.__applications.applications) == 0: 823 | await self.__pull_full_registry() 824 | return 825 | delta = await get_delta(url, self.__remote_regions) 826 | _logger.debug( 827 | f"delta got: v.{delta.versionsDelta}::{delta.appsHashcode}") 828 | if self.__delta is not None \ 829 | and delta.versionsDelta == self.__delta.versionsDelta \ 830 | and delta.appsHashcode == self.__delta.appsHashcode: 831 | return 832 | self.__merge_delta(delta) 833 | self.__delta = delta 834 | if not self.__is_hash_match(): 835 | await self.__pull_full_registry() 836 | try: 837 | await self.__connect_to_eureka_server(do_fetch) 838 | except Exception as e: 839 | _logger.warn( 840 | "fetch delta from eureka server error!", exc_info=True) 841 | await self._on_error(ERROR_DISCOVER, e) 842 | 843 | def __is_hash_match(self): 844 | app_hash = self.__get_applications_hash() 845 | _logger.debug( 846 | f"check hash, local[{app_hash}], remote[{self.__delta.appsHashcode}]") 847 | return app_hash == self.__delta.appsHashcode 848 | 849 | def __merge_delta(self, delta): 850 | _logger.debug( 851 | f"merge delta...length of application got from delta::{len(delta.applications)}") 852 | for application in delta.applications: 853 | for instance in application.instances: 854 | _logger.debug( 855 | f"instance [{instance.instanceId}] has {instance.actionType}") 856 | if instance.actionType in (ACTION_TYPE_ADDED, ACTION_TYPE_MODIFIED): 857 | existingApp = self.applications.get_application( 858 | application.name) 859 | if existingApp is None: 860 | self.applications.add_application(application) 861 | else: 862 | existingApp.update_instance(instance) 863 | elif instance.actionType == ACTION_TYPE_DELETED: 864 | existingApp = self.applications.get_application( 865 | application.name) 866 | if existingApp is None: 867 | self.applications.add_application(application) 868 | existingApp.remove_instance(instance) 869 | 870 | def __get_applications_hash(self): 871 | app_hash = "" 872 | app_status_count = {} 873 | for application in self.__applications.applications: 874 | for instance in application.instances: 875 | if instance.status not in app_status_count: 876 | app_status_count[instance.status.upper()] = 0 877 | app_status_count[instance.status.upper( 878 | )] = app_status_count[instance.status.upper()] + 1 879 | 880 | sorted_app_status_count = sorted( 881 | app_status_count.items(), key=lambda item: item[0]) 882 | for item in sorted_app_status_count: 883 | app_hash = f"{app_hash}{item[0]}_{item[1]}_" 884 | return app_hash 885 | 886 | async def walk_nodes(self, 887 | app_name: str = "", 888 | service: str = "", 889 | prefer_ip: bool = False, 890 | prefer_https: bool = False, 891 | walker: Callable = None) -> Union[str, Dict, http_client.HttpResponse]: 892 | assert app_name is not None and app_name != "", "application_name should not be null" 893 | 894 | error_nodes = [] 895 | app_name = app_name.upper() 896 | node = self.__get_available_service(app_name) 897 | node_errors: List[NodeError] = [] 898 | 899 | while node is not None: 900 | try: 901 | url = self.__generate_service_url( 902 | node, prefer_ip, prefer_https) 903 | if service.startswith("/"): 904 | url = url + service[1:] 905 | else: 906 | url = url + service 907 | _logger.debug("do service with url::" + url) 908 | obj = walker(url) 909 | if asyncio.iscoroutine(obj): 910 | return await obj 911 | else: 912 | return obj 913 | except (ConnectionError, TimeoutError, socket.timeout) as e: 914 | node_errors.append(NodeError(node.instanceId, e)) 915 | _logger.warning( 916 | f"do service {service} in node [{node.instanceId}] error, use next node. Error: {e}") 917 | error_nodes.append(node.instanceId) 918 | node = self.__get_available_service(app_name, error_nodes) 919 | except (http_client.HTTPError, http_client.URLError) as e: 920 | node_errors.append(NodeError(node.instanceId, e)) 921 | if self.__strict_service_error_policy: 922 | _logger.warning( 923 | f"do service {service} in node [{node.instanceId}] error, use next node. Error: {e}") 924 | error_nodes.append(node.instanceId) 925 | node = self.__get_available_service(app_name, error_nodes) 926 | else: 927 | raise e 928 | 929 | raise WalkNodeException("Try all up instances in registry, but all fail", node_errors) 930 | 931 | async def do_service(self, app_name: str = "", service: str = "", return_type: str = "string", 932 | prefer_ip: bool = False, prefer_https: bool = False, 933 | method: str = "GET", headers: Dict[str, str] = None, 934 | data: Union[bytes, str, Dict] = None, timeout: float = _DEFAULT_TIME_OUT 935 | ) -> Union[str, Dict, http_client.HttpResponse]: 936 | if data and isinstance(data, dict): 937 | _data = json.dumps(data).encode() 938 | elif data and isinstance(data, str): 939 | _data = data.encode() 940 | else: 941 | _data = data 942 | 943 | async def walk_using_urllib(url): 944 | req = http_client.HttpRequest(url, method=method, headers=headers) 945 | 946 | res: http_client.HttpResponse = await http_client.http_client.urlopen( 947 | req, data=_data, timeout=timeout) 948 | if return_type.lower() in ("json", "dict", "dictionary"): 949 | return json.loads(res.body_text) 950 | elif return_type.lower() == "response_object": 951 | return res.raw_response 952 | else: 953 | return res.body_text 954 | return await self.walk_nodes(app_name, service, prefer_ip, prefer_https, walk_using_urllib) 955 | 956 | def __get_service_not_in_ignore_list(self, instances, ignores): 957 | ign = ignores if ignores else [] 958 | return [item for item in instances if item.instanceId not in ign] 959 | 960 | def __get_available_service(self, application_name, ignore_instance_ids=None): 961 | apps = self.applications 962 | if not apps: 963 | raise DiscoverException( 964 | "Cannot load registry from eureka server, please check your configurations. ") 965 | app = apps.get_application(application_name) 966 | if app is None: 967 | return None 968 | up_instances = [] 969 | if self.__prefer_same_zone: 970 | ups_same_zone = app.up_instances_in_zone(self.zone) 971 | up_instances = self.__get_service_not_in_ignore_list( 972 | ups_same_zone, ignore_instance_ids) 973 | if not up_instances: 974 | ups_not_same_zone = app.up_instances_not_in_zone(self.zone) 975 | _logger.debug( 976 | f"app[{application_name}]'s up instances not in same zone are all down, using the one that's not in the same zone: {[ins.instanceId for ins in ups_not_same_zone]}") 977 | up_instances = self.__get_service_not_in_ignore_list( 978 | ups_not_same_zone, ignore_instance_ids) 979 | else: 980 | up_instances = self.__get_service_not_in_ignore_list( 981 | app.up_instances, ignore_instance_ids) 982 | 983 | if len(up_instances) == 0: 984 | # no up instances 985 | return None 986 | elif len(up_instances) == 1: 987 | # only one available instance, then doesn't matter which strategy is. 988 | instance = up_instances[0] 989 | self.__ha_cache[application_name] = instance.instanceId 990 | return instance 991 | 992 | def random_one(instances): 993 | if len(instances) == 1: 994 | idx = 0 995 | else: 996 | idx = random.randint(0, len(instances) - 1) 997 | selected_instance = instances[idx] 998 | self.__ha_cache[application_name] = selected_instance.instanceId 999 | return selected_instance 1000 | 1001 | if self.__ha_strategy == HA_STRATEGY_RANDOM: 1002 | return random_one(up_instances) 1003 | elif self.__ha_strategy == HA_STRATEGY_STICK: 1004 | if application_name in self.__ha_cache: 1005 | cache_id = self.__ha_cache[application_name] 1006 | cahce_instance = app.get_instance(cache_id) 1007 | if cahce_instance is not None and cahce_instance.status == INSTANCE_STATUS_UP: 1008 | return cahce_instance 1009 | else: 1010 | return random_one(up_instances) 1011 | else: 1012 | return random_one(up_instances) 1013 | elif self.__ha_strategy == HA_STRATEGY_OTHER: 1014 | if application_name in self.__ha_cache: 1015 | cache_id = self.__ha_cache[application_name] 1016 | other_instances = [] 1017 | for up_instance in up_instances: 1018 | if up_instance.instanceId != cache_id: 1019 | other_instances.append(up_instance) 1020 | return random_one(other_instances) 1021 | else: 1022 | return random_one(up_instances) 1023 | else: 1024 | return None 1025 | 1026 | def __generate_service_url(self, instance: Instance, prefer_ip, prefer_https): 1027 | if instance is None: 1028 | return None 1029 | schema = "http" 1030 | port = 0 1031 | if instance.port.port and not instance.securePort.enabled: 1032 | schema = "http" 1033 | port = instance.port.port 1034 | elif not instance.port.port and instance.securePort.enabled: 1035 | schema = "https" 1036 | port = instance.securePort.port 1037 | elif instance.port.port and instance.securePort.enabled: 1038 | if prefer_https: 1039 | schema = "https" 1040 | port = instance.securePort.port 1041 | else: 1042 | schema = "http" 1043 | port = instance.port.port 1044 | else: 1045 | assert False, "generate_service_url error: No port is available" 1046 | 1047 | host = instance.ipAddr if prefer_ip else instance.hostName 1048 | 1049 | if (schema == "http" and port == 80) or (schema == 'https' and port == 443): 1050 | return f"{schema}://{host}/" 1051 | else: 1052 | return f"{schema}://{host}:{port}/" 1053 | 1054 | async def __start_discover(self): 1055 | await self.__pull_full_registry() 1056 | 1057 | async def start(self) -> None: 1058 | if self.should_register: 1059 | await self.__parepare_instance_info() 1060 | await self.__start_register() 1061 | if self.should_discover: 1062 | await self.__start_discover() 1063 | self.__heartbeat_timer.start() 1064 | 1065 | async def stop(self) -> None: 1066 | if self.__heartbeat_timer.is_alive(): 1067 | self.__heartbeat_timer.cancel() 1068 | if self.__should_register: 1069 | await self.__stop_registery() 1070 | 1071 | 1072 | __cache_key = "default" 1073 | __cache_clients: Dict[str, EurekaClient] = {} 1074 | __cache_clients_lock = RLock() 1075 | 1076 | 1077 | async def init_async(eureka_server: str = _DEFAULT_EUREKA_SERVER_URL, 1078 | eureka_domain: str = "", 1079 | region: str = "", 1080 | zone: str = "", 1081 | eureka_availability_zones: Dict[str, str] = {}, 1082 | eureka_protocol: str = "http", 1083 | eureka_basic_auth_user: str = "", 1084 | eureka_basic_auth_password: str = "", 1085 | eureka_context: str = "/eureka", 1086 | prefer_same_zone: bool = True, 1087 | should_register: bool = True, 1088 | should_discover: bool = True, 1089 | on_error: Callable = None, 1090 | app_name: str = "", 1091 | instance_id: str = "", 1092 | instance_host: str = "", 1093 | instance_ip: str = "", 1094 | instance_ip_network: str = "", 1095 | instance_port: int = _DEFAULT_INSTNACE_PORT, 1096 | instance_unsecure_port_enabled: bool = True, 1097 | instance_secure_port: int = _DEFAULT_INSTNACE_SECURE_PORT, 1098 | instance_secure_port_enabled: bool = False, 1099 | data_center_name: str = _DEFAULT_DATA_CENTER_INFO, # Netflix, Amazon, MyOwn 1100 | renewal_interval_in_secs: int = _RENEWAL_INTERVAL_IN_SECS, 1101 | duration_in_secs: int = _DURATION_IN_SECS, 1102 | home_page_url: str = "", 1103 | status_page_url: str = "", 1104 | health_check_url: str = "", 1105 | secure_health_check_url: str = "", 1106 | vip_adr: str = "", 1107 | secure_vip_addr: str = "", 1108 | is_coordinating_discovery_server: bool = False, 1109 | metadata: Dict = {}, 1110 | remote_regions: List[str] = [], 1111 | ha_strategy: int = HA_STRATEGY_RANDOM, 1112 | strict_service_error_policy: bool = True) -> EurekaClient: 1113 | """ 1114 | Initialize an EurekaClient object and put it to cache, you can use a set of functions to do the service. 1115 | 1116 | Unlike using EurekaClient class that you need to start and stop the client object by yourself, this method 1117 | will start the client automatically after the object created. 1118 | 1119 | read EurekaClient for more information for the parameters details. 1120 | """ 1121 | with __cache_clients_lock: 1122 | if __cache_key in __cache_clients: 1123 | _logger.warn( 1124 | "A client is already running, try to stop it and start the new one!") 1125 | __cache_clients[__cache_key].stop() 1126 | del __cache_clients[__cache_key] 1127 | client = EurekaClient(eureka_server=eureka_server, 1128 | eureka_domain=eureka_domain, 1129 | region=region, 1130 | zone=zone, 1131 | eureka_availability_zones=eureka_availability_zones, 1132 | eureka_protocol=eureka_protocol, 1133 | eureka_basic_auth_user=eureka_basic_auth_user, 1134 | eureka_basic_auth_password=eureka_basic_auth_password, 1135 | eureka_context=eureka_context, 1136 | prefer_same_zone=prefer_same_zone, 1137 | should_register=should_register, 1138 | should_discover=should_discover, 1139 | on_error=on_error, 1140 | app_name=app_name, 1141 | instance_id=instance_id, 1142 | instance_host=instance_host, 1143 | instance_ip=instance_ip, 1144 | instance_ip_network=instance_ip_network, 1145 | instance_port=instance_port, 1146 | instance_unsecure_port_enabled=instance_unsecure_port_enabled, 1147 | instance_secure_port=instance_secure_port, 1148 | instance_secure_port_enabled=instance_secure_port_enabled, 1149 | data_center_name=data_center_name, 1150 | renewal_interval_in_secs=renewal_interval_in_secs, 1151 | duration_in_secs=duration_in_secs, 1152 | home_page_url=home_page_url, 1153 | status_page_url=status_page_url, 1154 | health_check_url=health_check_url, 1155 | secure_health_check_url=secure_health_check_url, 1156 | vip_adr=vip_adr, 1157 | secure_vip_addr=secure_vip_addr, 1158 | is_coordinating_discovery_server=is_coordinating_discovery_server, 1159 | metadata=metadata, 1160 | remote_regions=remote_regions, 1161 | ha_strategy=ha_strategy, 1162 | strict_service_error_policy=strict_service_error_policy) 1163 | __cache_clients[__cache_key] = client 1164 | await client.start() 1165 | return client 1166 | 1167 | 1168 | def get_client() -> EurekaClient: 1169 | with __cache_clients_lock: 1170 | if __cache_key in __cache_clients: 1171 | return __cache_clients[__cache_key] 1172 | else: 1173 | return None 1174 | 1175 | 1176 | async def walk_nodes_async(app_name: str = "", 1177 | service: str = "", 1178 | prefer_ip: bool = False, 1179 | prefer_https: bool = False, 1180 | walker: Callable = None) -> Union[str, Dict, http_client.HttpResponse]: 1181 | cli = get_client() 1182 | if cli is None: 1183 | raise Exception("Discovery Client has not initialized. ") 1184 | res = await cli.walk_nodes(app_name=app_name, service=service, 1185 | prefer_ip=prefer_ip, prefer_https=prefer_https, walker=walker) 1186 | return res 1187 | 1188 | 1189 | async def do_service_async(app_name: str = "", service: str = "", return_type: str = "string", 1190 | prefer_ip: bool = False, prefer_https: bool = False, 1191 | method: str = "GET", headers: Dict[str, str] = None, 1192 | data: Union[bytes, str, Dict] = None, timeout: float = _DEFAULT_TIME_OUT 1193 | ) -> Union[str, Dict, http_client.HttpResponse]: 1194 | cli = get_client() 1195 | if cli is None: 1196 | raise Exception("Discovery Client has not initialized. ") 1197 | res = await cli.do_service(app_name=app_name, service=service, return_type=return_type, 1198 | prefer_ip=prefer_ip, prefer_https=prefer_https, 1199 | method=method, headers=headers, 1200 | data=data, timeout=timeout) 1201 | 1202 | return res 1203 | 1204 | 1205 | async def stop_async() -> None: 1206 | client = get_client() 1207 | if client is not None: 1208 | await client.stop() 1209 | 1210 | _thread_local = threading.local() 1211 | 1212 | 1213 | def set_event_loop(event_loop: asyncio.AbstractEventLoop): 1214 | if not isinstance(event_loop, asyncio.AbstractEventLoop): 1215 | raise Exception("You must set an even loop object into this.") 1216 | _thread_local.event_loop = event_loop 1217 | 1218 | 1219 | def get_event_loop() -> asyncio.AbstractEventLoop: 1220 | if not hasattr(_thread_local, "event_loop"): 1221 | try: 1222 | _thread_local.event_loop = asyncio.new_event_loop() 1223 | except: 1224 | _thread_local.event_loop = asyncio.get_event_loop() 1225 | return _thread_local.event_loop 1226 | 1227 | 1228 | def init(eureka_server: str = _DEFAULT_EUREKA_SERVER_URL, 1229 | eureka_domain: str = "", 1230 | region: str = "", 1231 | zone: str = "", 1232 | eureka_availability_zones: Dict[str, str] = {}, 1233 | eureka_protocol: str = "http", 1234 | eureka_basic_auth_user: str = "", 1235 | eureka_basic_auth_password: str = "", 1236 | eureka_context: str = "/eureka", 1237 | prefer_same_zone: bool = True, 1238 | should_register: bool = True, 1239 | should_discover: bool = True, 1240 | on_error: Callable = None, 1241 | app_name: str = "", 1242 | instance_id: str = "", 1243 | instance_host: str = "", 1244 | instance_ip: str = "", 1245 | instance_ip_network: str = "", 1246 | instance_port: int = _DEFAULT_INSTNACE_PORT, 1247 | instance_unsecure_port_enabled: bool = True, 1248 | instance_secure_port: int = _DEFAULT_INSTNACE_SECURE_PORT, 1249 | instance_secure_port_enabled: bool = False, 1250 | data_center_name: str = _DEFAULT_DATA_CENTER_INFO, # Netflix, Amazon, MyOwn 1251 | renewal_interval_in_secs: int = _RENEWAL_INTERVAL_IN_SECS, 1252 | duration_in_secs: int = _DURATION_IN_SECS, 1253 | home_page_url: str = "", 1254 | status_page_url: str = "", 1255 | health_check_url: str = "", 1256 | secure_health_check_url: str = "", 1257 | vip_adr: str = "", 1258 | secure_vip_addr: str = "", 1259 | is_coordinating_discovery_server: bool = False, 1260 | metadata: Dict = {}, 1261 | remote_regions: List[str] = [], 1262 | ha_strategy: int = HA_STRATEGY_RANDOM, 1263 | strict_service_error_policy: bool = True) -> EurekaClient: 1264 | """ 1265 | Initialize an EurekaClient object and put it to cache, you can use a set of functions to do the service. 1266 | 1267 | Unlike using EurekaClient class that you need to start and stop the client object by yourself, this method 1268 | will start the client automatically after the object created. 1269 | 1270 | read EurekaClient for more information for the parameters details. 1271 | """ 1272 | return get_event_loop().run_until_complete(init_async(eureka_server=eureka_server, 1273 | eureka_domain=eureka_domain, 1274 | region=region, 1275 | zone=zone, 1276 | eureka_availability_zones=eureka_availability_zones, 1277 | eureka_protocol=eureka_protocol, 1278 | eureka_basic_auth_user=eureka_basic_auth_user, 1279 | eureka_basic_auth_password=eureka_basic_auth_password, 1280 | eureka_context=eureka_context, 1281 | prefer_same_zone=prefer_same_zone, 1282 | should_register=should_register, 1283 | should_discover=should_discover, 1284 | on_error=on_error, 1285 | app_name=app_name, 1286 | instance_id=instance_id, 1287 | instance_host=instance_host, 1288 | instance_ip=instance_ip, 1289 | instance_ip_network=instance_ip_network, 1290 | instance_port=instance_port, 1291 | instance_unsecure_port_enabled=instance_unsecure_port_enabled, 1292 | instance_secure_port=instance_secure_port, 1293 | instance_secure_port_enabled=instance_secure_port_enabled, 1294 | data_center_name=data_center_name, 1295 | renewal_interval_in_secs=renewal_interval_in_secs, 1296 | duration_in_secs=duration_in_secs, 1297 | home_page_url=home_page_url, 1298 | status_page_url=status_page_url, 1299 | health_check_url=health_check_url, 1300 | secure_health_check_url=secure_health_check_url, 1301 | vip_adr=vip_adr, 1302 | secure_vip_addr=secure_vip_addr, 1303 | is_coordinating_discovery_server=is_coordinating_discovery_server, 1304 | metadata=metadata, 1305 | remote_regions=remote_regions, 1306 | ha_strategy=ha_strategy, 1307 | strict_service_error_policy=strict_service_error_policy)) 1308 | 1309 | 1310 | def walk_nodes(app_name: str = "", 1311 | service: str = "", 1312 | prefer_ip: bool = False, 1313 | prefer_https: bool = False, 1314 | walker: Callable = None) -> Union[str, Dict, http_client.HttpResponse]: 1315 | return get_event_loop().run_until_complete(walk_nodes_async(app_name=app_name, service=service, 1316 | prefer_ip=prefer_ip, prefer_https=prefer_https, walker=walker)) 1317 | 1318 | 1319 | def do_service(app_name: str = "", service: str = "", return_type: str = "string", 1320 | prefer_ip: bool = False, prefer_https: bool = False, 1321 | method: str = "GET", headers: Dict[str, str] = None, 1322 | data: Union[bytes, str, Dict] = None, timeout: float = _DEFAULT_TIME_OUT 1323 | ) -> Union[str, Dict, http_client.HttpResponse]: 1324 | 1325 | return get_event_loop().run_until_complete(do_service_async(app_name=app_name, service=service, return_type=return_type, 1326 | prefer_ip=prefer_ip, prefer_https=prefer_https, 1327 | method=method, headers=headers, 1328 | data=data, timeout=timeout)) 1329 | 1330 | 1331 | def stop() -> None: 1332 | get_event_loop().run_until_complete(stop_async()) 1333 | -------------------------------------------------------------------------------- /py_eureka_client/http_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | import re 26 | import base64 27 | import httpx 28 | from urllib.error import HTTPError, URLError 29 | from io import BytesIO 30 | 31 | from typing import Union 32 | from urllib.parse import unquote 33 | 34 | 35 | _URL_REGEX = re.compile( 36 | r'^((?:http)s?)://' # http:// or https:// 37 | # basic authentication -> username:password@ 38 | r'(([A-Z0-9-_~!.%]+):([A-Z0-9-_~!.%]+)@)?' 39 | # domain... 40 | r'((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' 41 | r'(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)|' # domain name without `.` 42 | r"(?:\[((?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4})\])|" # ipv6 43 | r'localhost|' # localhost... 44 | r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip 45 | r'(?::(\d+))?' # optional port 46 | r'(?:/?|[/?]\S+)$', re.IGNORECASE) 47 | 48 | 49 | class URLObj: 50 | 51 | def __init__(self, 52 | url: str = None, 53 | basic_auth: str = None, 54 | schema: str = None, 55 | host: str = None, 56 | ipv6: str = None, 57 | port: int = None) -> None: 58 | self.url: str = url 59 | self.basic_auth: str = basic_auth 60 | self.schema: str = schema 61 | self.host: str = host 62 | self.ipv6: str = ipv6 63 | self.port: int = port 64 | 65 | 66 | def parse_url(url) -> URLObj: 67 | m = _URL_REGEX.match(url) 68 | if m: 69 | addr = url 70 | if m.group(2) is not None: 71 | addr = addr.replace(m.group(2), "") 72 | ori_auth = f"{unquote(m.group(3))}:{unquote(m.group(4))}".encode() 73 | basic_auth_str = base64.standard_b64encode(ori_auth).decode() 74 | else: 75 | basic_auth_str = None 76 | return URLObj( 77 | url=addr, 78 | basic_auth=basic_auth_str, 79 | schema=m.group(1), 80 | host=m.group(5), 81 | ipv6=m.group(6), 82 | port=int(m.group(7)) if m.group(7) is not None else None 83 | ) 84 | else: 85 | raise URLError(f"url[{url}] is not a valid url.") 86 | 87 | 88 | class HttpRequest: 89 | 90 | def __init__(self, url, headers={}, method=None): 91 | url_match = _URL_REGEX.match(url) 92 | if url_match is None: 93 | raise URLError("Unvalid URL") 94 | url_obj = parse_url(url) 95 | url_addr = url_obj.url 96 | url_auth = url_obj.basic_auth 97 | 98 | self.url = url_addr 99 | self.headers = headers or {} 100 | self.method = method or "GET" 101 | self.content = None 102 | 103 | if url_auth is not None: 104 | self.headers['Authorization'] = f'Basic {url_auth}' 105 | 106 | def add_header(self, key: str, value: str): 107 | self.headers[key] = value 108 | 109 | def _to_httpx_request(self) -> httpx.Request: 110 | return httpx.Request(self.method, self.url, headers=self.headers, content=self.content) 111 | 112 | 113 | class HttpResponse: 114 | 115 | def __init__(self, raw_response=None) -> None: 116 | self.raw_response: httpx.Response = raw_response 117 | self.__body_text = self.raw_response.text if self.raw_response else '' 118 | 119 | @property 120 | def body_text(self) -> str: 121 | return self.__body_text 122 | 123 | @body_text.setter 124 | def body_text(self, value) -> None: 125 | self.__body_text = value 126 | 127 | 128 | class HttpClient: 129 | 130 | async def urlopen(self, request: Union[str, HttpRequest] = None, 131 | data: bytes = None, timeout: float = None) -> HttpResponse: 132 | if isinstance(request, HttpRequest): 133 | req = request 134 | elif isinstance(request, str): 135 | req = HttpRequest(request) 136 | else: 137 | raise URLError("Invalid URL") 138 | 139 | if data is not None: 140 | req.content = data 141 | 142 | req.add_header("Connection", "close") 143 | req.add_header("Accept-Encoding", "gzip, deflate") 144 | 145 | try: 146 | async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: 147 | res = await client.send(req._to_httpx_request()) 148 | res.raise_for_status() 149 | return HttpResponse(res) 150 | except httpx.HTTPStatusError as e: 151 | raise HTTPError(e.request.url, e.response.status_code, str(e), e.response.headers, BytesIO(e.response.content)) from e 152 | except httpx.RequestError as e: 153 | raise URLError(str(e)) from e 154 | 155 | 156 | http_client = HttpClient() 157 | 158 | 159 | def set_http_client(client: HttpClient) -> None: 160 | assert isinstance(client, HttpClient) 161 | global http_client 162 | http_client = client 163 | -------------------------------------------------------------------------------- /py_eureka_client/logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | from abc import abstractmethod 26 | import sys 27 | import time 28 | import logging 29 | import asyncio 30 | from threading import Thread 31 | from typing import Dict, List, Tuple 32 | 33 | _custom_logger: logging.Logger = None 34 | 35 | 36 | def set_custom_logger(logger: logging.Logger) -> None: 37 | assert isinstance(logger, logging.Logger) 38 | global _custom_logger 39 | _custom_logger = logger 40 | 41 | 42 | class LazyCalledLogger(logging.Logger): 43 | 44 | def do_call_handlers(self, record): 45 | super().callHandlers(record) 46 | 47 | @abstractmethod 48 | def callHandlers(self, record): 49 | pass 50 | 51 | 52 | class LazyCalledLoggerThread: 53 | 54 | daemon_threads = True 55 | 56 | def __init__(self) -> None: 57 | self.coroutine_loop = None 58 | self.coroutine_thread = None 59 | 60 | def coroutine_main(self): 61 | self.coroutine_loop = loop = asyncio.new_event_loop() 62 | try: 63 | loop.run_forever() 64 | finally: 65 | loop.run_until_complete(loop.shutdown_asyncgens()) 66 | loop.close() 67 | 68 | def start(self): 69 | if self.coroutine_thread is not None: 70 | while not self.coroutine_loop: 71 | # wait for the loop ready 72 | time.sleep(0.1) 73 | return 74 | self.coroutine_thread = Thread(target=self.coroutine_main, name="logger-thread", daemon=self.daemon_threads) 75 | self.coroutine_thread.start() 76 | 77 | while not self.coroutine_loop: 78 | # wait for the loop ready 79 | time.sleep(0.1) 80 | 81 | def stop(self): 82 | if self.coroutine_loop: 83 | self.coroutine_loop.call_soon_threadsafe(self.coroutine_loop.stop) 84 | self.coroutine_thread.join() 85 | self.coroutine_loop = None 86 | self.coroutine_thread = None 87 | 88 | async def _call(self, logger: LazyCalledLogger, record): 89 | logger.do_call_handlers(record) 90 | 91 | def call_logger_handler(self, logger: LazyCalledLogger, record): 92 | self.start() 93 | asyncio.run_coroutine_threadsafe(self._call(logger, record), self.coroutine_loop) 94 | 95 | 96 | class CachingLogger(LazyCalledLogger): 97 | 98 | logger_thread: LazyCalledLoggerThread = LazyCalledLoggerThread() 99 | 100 | def callHandlers(self, record): 101 | if _custom_logger is not None: 102 | _custom_logger.callHandlers(record) 103 | else: 104 | CachingLogger.logger_thread.call_logger_handler(self, record) 105 | 106 | 107 | class LoggerFactory: 108 | 109 | DEFAULT_LOG_FORMAT: str = '[%(asctime)s]-[%(threadName)s]-[%(name)s:%(lineno)d] %(levelname)-4s: %(message)s' 110 | 111 | DEFAULT_DATE_FORMAT: str = '%Y-%m-%d %H:%M:%S' 112 | 113 | _LOG_LVELS: Tuple[str] = ("DEBUG", "INFO", "WARN", "ERROR") 114 | 115 | def __init__(self, log_level: str = "INFO", log_format: str = DEFAULT_LOG_FORMAT, date_format: str = DEFAULT_DATE_FORMAT) -> None: 116 | self.__cache_loggers: Dict[str, CachingLogger] = {} 117 | self._log_level = log_level.upper() if log_level and log_level.upper() in self._LOG_LVELS else "INFO" 118 | self.log_format = log_format 119 | self.date_format = date_format 120 | 121 | self._handlers: List[logging.Handler] = [] 122 | 123 | @property 124 | def handlers(self) -> List[logging.Handler]: 125 | if self._handlers: 126 | return self._handlers 127 | _handler = logging.StreamHandler(sys.stdout) 128 | _formatter_ = logging.Formatter(fmt=self.log_format, datefmt=self.date_format) 129 | _handler.setFormatter(_formatter_) 130 | _handler.setLevel(self._log_level) 131 | self._handlers.append(_handler) 132 | return self._handlers 133 | 134 | @property 135 | def log_level(self): 136 | return self._log_level 137 | 138 | @log_level.setter 139 | def log_level(self, log_level: str): 140 | self._log_level = log_level.upper() if log_level and log_level.upper() in self._LOG_LVELS else "INFO" 141 | _logger_ = self.get_logger("Logger") 142 | _logger_.info(f"global logger set to {self._log_level}") 143 | for h in self._handlers: 144 | h.setLevel(self._log_level) 145 | for l in self.__cache_loggers.values(): 146 | l.setLevel(self._log_level) 147 | 148 | def add_handler(self, handler: logging.Handler) -> None: 149 | if self.__cache_loggers: 150 | self._handlers.append(handler) 151 | else: 152 | self.handlers.append(handler) 153 | for l in self.__cache_loggers.values(): 154 | l.addHandler(handler) 155 | 156 | def remove_handler(self, handler: logging.Handler) -> None: 157 | if handler in self._handlers: 158 | self._handlers.remove(handler) 159 | for l in self.__cache_loggers.values(): 160 | l.removeHandler(handler) 161 | 162 | def set_handler(self, handler: logging.Handler) -> None: 163 | self._handlers.clear() 164 | self._handlers.append(handler) 165 | for l in self.__cache_loggers.values(): 166 | for hdlr in l.handlers: 167 | l.removeHandler(hdlr) 168 | l.addHandler(handler) 169 | 170 | def get_logger(self, tag: str = "pythone-simple-http-server") -> logging.Logger: 171 | if tag not in self.__cache_loggers: 172 | self.__cache_loggers[tag] = CachingLogger(tag, self._log_level) 173 | for hdlr in self.handlers: 174 | self.__cache_loggers[tag].addHandler(hdlr) 175 | return self.__cache_loggers[tag] 176 | 177 | 178 | _default_logger_factory: LoggerFactory = LoggerFactory() 179 | 180 | _logger_factories: Dict[str, LoggerFactory] = {} 181 | 182 | 183 | def get_logger_factory(tag: str = "") -> LoggerFactory: 184 | if not tag: 185 | return _default_logger_factory 186 | if tag not in _logger_factories: 187 | _logger_factories[tag] = LoggerFactory() 188 | return _logger_factories[tag] 189 | 190 | 191 | def set_level(level) -> None: 192 | _default_logger_factory.log_level = level 193 | 194 | 195 | def add_handler(handler: logging.Handler) -> None: 196 | _default_logger_factory.add_handler(handler) 197 | 198 | 199 | def remove_handler(handler: logging.Handler) -> None: 200 | _default_logger_factory.remove_handler(handler) 201 | 202 | 203 | def set_handler(handler: logging.Handler) -> None: 204 | _default_logger_factory.set_handler(handler) 205 | 206 | 207 | def get_logger(tag: str = "python-eureka-client") -> logging.Logger: 208 | return _default_logger_factory.get_logger(tag) 209 | -------------------------------------------------------------------------------- /py_eureka_client/netint_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | import socket 25 | from typing import Tuple 26 | from ifaddr import get_adapters 27 | import ipaddress 28 | from py_eureka_client.logger import get_logger 29 | 30 | _logger = get_logger("netint_utils") 31 | 32 | 33 | def get_host_by_ip(ip): 34 | try: 35 | return socket.gethostbyaddr(ip)[0] 36 | except: 37 | _logger.warn("Error when getting host by ip", exc_info=True) 38 | return ip 39 | 40 | 41 | def get_ip_by_host(host): 42 | try: 43 | return socket.gethostbyname(host) 44 | except: 45 | _logger.warn("Error when getting ip by host", exc_info=True) 46 | return host 47 | 48 | 49 | def get_first_non_loopback_ip(network: str = "") -> str: 50 | adapters = get_adapters() 51 | for adapter in adapters: 52 | for iface in adapter.ips: 53 | if iface.is_IPv4: 54 | _ip = iface.ip 55 | if network: 56 | if ipaddress.ip_address(_ip) in ipaddress.ip_network(network): 57 | return _ip 58 | elif _ip != "127.0.0.1": 59 | return _ip 60 | return "" 61 | 62 | 63 | def get_ip_and_host(network: str = "") -> Tuple[str, str]: 64 | ip = get_first_non_loopback_ip(network=network) 65 | if not ip: 66 | host = socket.gethostname() 67 | ip = socket.gethostbyname(host) 68 | else: 69 | host = get_host_by_ip(ip) 70 | 71 | return ip, host 72 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=65", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "py_eureka_client" 10 | description = "A eureka client written in python. Support registering your python component to Eureka Server, as well as calling remote services by pulling the the Eureka registry. " 11 | readme = "README.md" 12 | authors = [ 13 | { name = "keijack", email = "keijack.wu@gmail.com" } 14 | ] 15 | requires-python = ">=3.7" 16 | keywords = ["eureka", "netflix", "microservice"] 17 | license = "MIT" 18 | classifiers = [ 19 | "Programming Language :: Python :: 3", 20 | "Operating System :: OS Independent", 21 | ] 22 | dynamic = ["version"] 23 | dependencies = [ 24 | "ifaddr>=0.2.0", 25 | "dnspython>=2.4.2" 26 | ] 27 | 28 | [tool.setuptools] 29 | packages = ["py_eureka_client"] 30 | 31 | [tool.setuptools.dynamic] 32 | version = {attr = "py_eureka_client.version"} 33 | 34 | [project.urls] 35 | homepage = "https://github.com/keijack/python-eureka-client" 36 | repository = "https://github.com/keijack/python-eureka-client" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ifaddr>=0.2.0 2 | dnspython>=2.4.2 3 | httpx>=0.23.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keijack/python-eureka-client/7020039ac3f5a93cfca8046b73a5447954b9b86b/tests/__init__.py -------------------------------------------------------------------------------- /tests/py_eureka_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keijack/python-eureka-client/7020039ac3f5a93cfca8046b73a5447954b9b86b/tests/py_eureka_client/__init__.py -------------------------------------------------------------------------------- /tests/py_eureka_client/eureka_server_parse_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | import unittest 26 | 27 | import py_eureka_client.logger as logger 28 | from py_eureka_client.eureka_client import EurekaServerConf 29 | 30 | logger.set_level("DEBUG") 31 | 32 | class TestEurekaServer(unittest.TestCase): 33 | 34 | def test_init_eureka_server(self): 35 | es = EurekaServerConf(eureka_server="https://a@10.0.2.16:8761/eureka,https://10.0.2.16:8762", 36 | eureka_basic_auth_user="keijack", eureka_basic_auth_password="!@#qwe", zone="zone1") 37 | print(es.servers) 38 | 39 | def test_init_eureka_dns(self): 40 | es = EurekaServerConf(eureka_domain="keijack.com", eureka_basic_auth_user="keijack", 41 | eureka_basic_auth_password="!@#qwe", region="dev", zone="zone1") 42 | print(es.servers_not_in_zone) 43 | 44 | def test_init_availability_zones(self): 45 | es = EurekaServerConf(eureka_availability_zones={"zone1": ["https://myec2.com", "myec1.com"], "zone2": "myzone2.com, myzone22.com"}) 46 | print(es.servers) 47 | -------------------------------------------------------------------------------- /tests/py_eureka_client/test_http_basic_auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Copyright (c) 2018 Keijack Wu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | import unittest 26 | import asyncio 27 | 28 | import py_eureka_client.logger as logger 29 | from py_eureka_client.http_client import HttpClient, HttpRequest, HttpResponse 30 | 31 | logger.set_level("DEBUG") 32 | 33 | 34 | class TestEurekaServer(unittest.TestCase): 35 | 36 | def test_load_page(self): 37 | req = HttpRequest("http://keijack:qwe%40rty%21@10.0.2.16:8080/a.txt") 38 | client = HttpClient() 39 | res: HttpResponse = asyncio.run(client.urlopen(req)) 40 | print(res.body_text) 41 | assert res.body_text == 'hello!' 42 | -------------------------------------------------------------------------------- /upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm dist/* 4 | 5 | python3 -m pip install --upgrade build wheel twine pkginfo 6 | 7 | python3 -m build --sdist --wheel . 8 | 9 | python3 -m twine check dist/* 10 | 11 | python3 -m twine upload dist/* --------------------------------------------------------------------------------