appmesh 1.3.6__py3-none-any.whl → 1.3.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appmesh/__init__.py +18 -1
- appmesh/app.py +226 -0
- appmesh/app_output.py +26 -0
- appmesh/app_run.py +48 -0
- appmesh/appmesh_client.py +95 -646
- appmesh/appmesh_client_tcp.py +362 -0
- {appmesh-1.3.6.dist-info → appmesh-1.3.7.dist-info}/METADATA +1 -1
- appmesh-1.3.7.dist-info/RECORD +10 -0
- appmesh-1.3.6.dist-info/RECORD +0 -6
- {appmesh-1.3.6.dist-info → appmesh-1.3.7.dist-info}/WHEEL +0 -0
- {appmesh-1.3.6.dist-info → appmesh-1.3.7.dist-info}/top_level.txt +0 -0
appmesh/appmesh_client.py
CHANGED
@@ -1,326 +1,26 @@
|
|
1
|
-
#!/usr/bin/python3
|
2
1
|
"""App Mesh Python SDK"""
|
2
|
+
|
3
|
+
# pylint: disable=broad-exception-raised,line-too-long,broad-exception-caught,too-many-lines, import-outside-toplevel, protected-access
|
4
|
+
|
5
|
+
# Standard library imports
|
3
6
|
import abc
|
4
7
|
import base64
|
5
|
-
from contextlib import contextmanager
|
6
|
-
import copy
|
7
8
|
import json
|
8
9
|
import os
|
9
|
-
import socket
|
10
|
-
import ssl
|
11
|
-
import uuid
|
12
|
-
|
13
|
-
from enum import Enum, unique
|
14
10
|
from datetime import datetime
|
11
|
+
from enum import Enum, unique
|
15
12
|
from http import HTTPStatus
|
16
|
-
from typing import
|
13
|
+
from typing import Tuple, Union
|
17
14
|
from urllib import parse
|
18
15
|
|
16
|
+
# Third-party imports
|
19
17
|
import aniso8601
|
20
18
|
import requests
|
21
19
|
|
22
|
-
#
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
DURATION_TWO_DAYS_HALF_ISO = "P2DT12H"
|
27
|
-
|
28
|
-
DEFAULT_SSL_CA_CERT_PATH = "/opt/appmesh/ssl/ca.pem"
|
29
|
-
DEFAULT_SSL_CLIENT_CERT_PATH = "/opt/appmesh/ssl/client.pem"
|
30
|
-
DEFAULT_SSL_CLIENT_KEY_PATH = "/opt/appmesh/ssl/client-key.pem"
|
31
|
-
|
32
|
-
# TLS-optimized chunk size (slightly less than maximum TLS record size)
|
33
|
-
# leaves some room for TLS overhead (like headers) within the 16 KB limit.
|
34
|
-
TCP_BLOCK_SIZE = 16 * 1024 - 256 # target to 16KB
|
35
|
-
TCP_HEADER_LENGTH = 4
|
36
|
-
JSON_KEY_MESSAGE = "message"
|
37
|
-
ENCODING_UTF8 = "utf-8"
|
38
|
-
|
39
|
-
HTTP_USER_AGENT = "appmesh/python"
|
40
|
-
HTTP_USER_AGENT_TCP = "appmesh/python/tcp"
|
41
|
-
HTTP_HEADER_KEY_USER_AGENT = "User-Agent"
|
42
|
-
HTTP_HEADER_KEY_X_SEND_FILE_SOCKET = "X-Send-File-Socket"
|
43
|
-
HTTP_HEADER_KEY_X_RECV_FILE_SOCKET = "X-Recv-File-Socket"
|
44
|
-
HTTP_HEADER_KEY_X_TARGET_HOST = "X-Target-Host"
|
45
|
-
|
46
|
-
|
47
|
-
class App(object):
|
48
|
-
"""
|
49
|
-
Represents an application in App Mesh, including configuration, resource limitations, behaviors, and permissions.
|
50
|
-
"""
|
51
|
-
|
52
|
-
@staticmethod
|
53
|
-
def _get_str_item(data: dict, key: str) -> Optional[str]:
|
54
|
-
"""Retrieve a string value from a dictionary by key, if it exists and is a valid string."""
|
55
|
-
return data[key] if (data and key in data and data[key] and isinstance(data[key], str)) else None
|
56
|
-
|
57
|
-
@staticmethod
|
58
|
-
def _get_int_item(data: dict, key: str) -> Optional[int]:
|
59
|
-
"""Retrieve an integer value from a dictionary by key, if it exists and is a valid integer."""
|
60
|
-
return int(data[key]) if (data and key in data and data[key] and isinstance(data[key], int)) else None
|
61
|
-
|
62
|
-
@staticmethod
|
63
|
-
def _get_bool_item(data: dict, key: str) -> Optional[bool]:
|
64
|
-
"""Retrieve a boolean value from a dictionary by key, if it exists and is boolean-like."""
|
65
|
-
return bool(data[key]) if (data and key in data and data[key]) else None
|
66
|
-
|
67
|
-
@staticmethod
|
68
|
-
def _get_native_item(data: dict, key: str) -> Optional[object]:
|
69
|
-
"""Retrieve a deep copy of a value from a dictionary by key, if it exists."""
|
70
|
-
return copy.deepcopy(data[key]) if (data and key in data and data[key]) else None
|
71
|
-
|
72
|
-
@unique
|
73
|
-
class Permission(Enum):
|
74
|
-
"""Defines application permission levels."""
|
75
|
-
|
76
|
-
DENY = "1"
|
77
|
-
READ = "2"
|
78
|
-
WRITE = "3"
|
79
|
-
|
80
|
-
class Behavior(object):
|
81
|
-
"""
|
82
|
-
Manages application error handling behavior, including exit and control behaviors.
|
83
|
-
"""
|
84
|
-
|
85
|
-
@unique
|
86
|
-
class Action(Enum):
|
87
|
-
"""Defines actions for application exit behaviors."""
|
88
|
-
|
89
|
-
RESTART = "restart"
|
90
|
-
STANDBY = "standby"
|
91
|
-
KEEPALIVE = "keepalive"
|
92
|
-
REMOVE = "remove"
|
93
|
-
|
94
|
-
def __init__(self, data=None) -> None:
|
95
|
-
if isinstance(data, (str, bytes, bytearray)):
|
96
|
-
data = json.loads(data)
|
97
|
-
|
98
|
-
self.exit = App._get_str_item(data, "exit")
|
99
|
-
"""Default exit behavior, options: 'restart', 'standby', 'keepalive', 'remove'."""
|
100
|
-
|
101
|
-
self.control = App._get_native_item(data, "control") or {}
|
102
|
-
"""Exit code specific behavior (e.g, --control 0:restart --control 1:standby), higher priority than default exit behavior"""
|
103
|
-
|
104
|
-
def set_exit_behavior(self, action: Action) -> None:
|
105
|
-
"""Set default behavior for application exit."""
|
106
|
-
self.exit = action.value
|
107
|
-
|
108
|
-
def set_control_behavior(self, control_code: int, action: Action) -> None:
|
109
|
-
"""Define behavior for specific exit codes."""
|
110
|
-
self.control[str(control_code)] = action.value
|
111
|
-
|
112
|
-
class DailyLimitation(object):
|
113
|
-
"""
|
114
|
-
Defines application availability within a daily time range.
|
115
|
-
"""
|
116
|
-
|
117
|
-
def __init__(self, data=None) -> None:
|
118
|
-
if isinstance(data, (str, bytes, bytearray)):
|
119
|
-
data = json.loads(data)
|
120
|
-
|
121
|
-
self.daily_start = App._get_int_item(data, "daily_start")
|
122
|
-
"""Start time for application availability (e.g., 09:00:00+08)."""
|
123
|
-
|
124
|
-
self.daily_end = App._get_int_item(data, "daily_end")
|
125
|
-
"""End time for application availability (e.g., 20:00:00+08)."""
|
126
|
-
|
127
|
-
def set_daily_range(self, start: datetime, end: datetime) -> None:
|
128
|
-
"""Set the valid daily start and end times."""
|
129
|
-
self.daily_start = int(start.timestamp())
|
130
|
-
self.daily_end = int(end.timestamp())
|
131
|
-
|
132
|
-
class ResourceLimitation(object):
|
133
|
-
"""
|
134
|
-
Defines application resource limits, such as CPU and memory usage.
|
135
|
-
"""
|
136
|
-
|
137
|
-
def __init__(self, data=None) -> None:
|
138
|
-
if isinstance(data, (str, bytes, bytearray)):
|
139
|
-
data = json.loads(data)
|
140
|
-
|
141
|
-
self.cpu_shares = App._get_int_item(data, "cpu_shares")
|
142
|
-
"""CPU shares, relative weight of CPU usage."""
|
143
|
-
|
144
|
-
self.memory_mb = App._get_int_item(data, "memory_mb")
|
145
|
-
"""Physical memory limit in MB."""
|
146
|
-
|
147
|
-
self.memory_virt_mb = App._get_int_item(data, "memory_virt_mb")
|
148
|
-
"""Virtual memory limit in MB."""
|
149
|
-
|
150
|
-
def __init__(self, data=None) -> None:
|
151
|
-
"""Initialize an App instance with optional configuration data."""
|
152
|
-
if isinstance(data, (str, bytes, bytearray)):
|
153
|
-
data = json.loads(data)
|
154
|
-
|
155
|
-
self.name = App._get_str_item(data, "name")
|
156
|
-
"""application name (unique)"""
|
157
|
-
self.command = App._get_str_item(data, "command")
|
158
|
-
"""full command line with arguments"""
|
159
|
-
self.shell = App._get_bool_item(data, "shell")
|
160
|
-
"""use shell mode, cmd can be more shell commands with string format"""
|
161
|
-
self.session_login = App._get_bool_item(data, "session_login")
|
162
|
-
"""app run in session login mode"""
|
163
|
-
self.description = App._get_str_item(data, "description")
|
164
|
-
"""application description string"""
|
165
|
-
self.metadata = App._get_native_item(data, "metadata")
|
166
|
-
"""metadata string/JSON (input for application, pass to process stdin)"""
|
167
|
-
self.working_dir = App._get_str_item(data, "working_dir")
|
168
|
-
"""working directory"""
|
169
|
-
self.status = App._get_int_item(data, "status")
|
170
|
-
"""initial application status (true is enable, false is disabled)"""
|
171
|
-
self.docker_image = App._get_str_item(data, "docker_image")
|
172
|
-
"""docker image which used to run command line (for docker container application)"""
|
173
|
-
self.stdout_cache_num = App._get_int_item(data, "stdout_cache_num")
|
174
|
-
"""stdout file cache number"""
|
175
|
-
self.start_time = App._get_int_item(data, "start_time")
|
176
|
-
"""start date time for app (ISO8601 time format, e.g., '2020-10-11T09:22:05')"""
|
177
|
-
self.end_time = App._get_int_item(data, "end_time")
|
178
|
-
"""end date time for app (ISO8601 time format, e.g., '2020-10-11T10:22:05')"""
|
179
|
-
self.interval = App._get_int_item(data, "interval")
|
180
|
-
"""start interval seconds for short running app, support ISO 8601 durations and cron expression (e.g., 'P1Y2M3DT4H5M6S' 'P5W' '* */5 * * * *')"""
|
181
|
-
self.cron = App._get_bool_item(data, "cron")
|
182
|
-
"""indicate interval parameter use cron expression or not"""
|
183
|
-
self.daily_limitation = App.DailyLimitation(App._get_native_item(data, "daily_limitation"))
|
184
|
-
self.retention = App._get_str_item(data, "retention")
|
185
|
-
"""extra timeout seconds for stopping current process, support ISO 8601 durations (e.g., 'P1Y2M3DT4H5M6S' 'P5W')."""
|
186
|
-
self.health_check_cmd = App._get_str_item(data, "health_check_cmd")
|
187
|
-
"""health check script command (e.g., sh -x 'curl host:port/health', return 0 is health)"""
|
188
|
-
self.permission = App._get_int_item(data, "permission")
|
189
|
-
"""application user permission, value is 2 bit integer: [group & other], each bit can be deny:1, read:2, write: 3."""
|
190
|
-
self.behavior = App.Behavior(App._get_native_item(data, "behavior"))
|
191
|
-
|
192
|
-
self.env = data.get("env", {}) if data else {}
|
193
|
-
"""environment variables (e.g., -e env1=value1 -e env2=value2, APP_DOCKER_OPTS is used to input docker run parameters)"""
|
194
|
-
self.sec_env = data.get("sec_env", {}) if data else {}
|
195
|
-
"""security environment variables, encrypt in server side with application owner's cipher"""
|
196
|
-
self.pid = App._get_int_item(data, "pid")
|
197
|
-
"""process id used to attach to the running process"""
|
198
|
-
self.resource_limit = App.ResourceLimitation(App._get_native_item(data, "resource_limit"))
|
199
|
-
|
200
|
-
# Read-only attributes
|
201
|
-
self.owner = App._get_str_item(data, "owner")
|
202
|
-
"""owner name"""
|
203
|
-
self.pstree = App._get_str_item(data, "pstree")
|
204
|
-
"""process tree"""
|
205
|
-
self.container_id = App._get_str_item(data, "container_id")
|
206
|
-
"""container id"""
|
207
|
-
self.memory = App._get_int_item(data, "memory")
|
208
|
-
"""memory usage"""
|
209
|
-
self.cpu = App._get_int_item(data, "cpu")
|
210
|
-
"""cpu usage"""
|
211
|
-
self.fd = App._get_int_item(data, "fd")
|
212
|
-
"""file descriptor usage"""
|
213
|
-
self.last_start_time = App._get_int_item(data, "last_start_time")
|
214
|
-
"""last start time"""
|
215
|
-
self.last_exit_time = App._get_int_item(data, "last_exit_time")
|
216
|
-
"""last exit time"""
|
217
|
-
self.health = App._get_int_item(data, "health")
|
218
|
-
"""health status"""
|
219
|
-
self.version = App._get_int_item(data, "version")
|
220
|
-
"""version number"""
|
221
|
-
self.return_code = App._get_int_item(data, "return_code")
|
222
|
-
"""last exit code"""
|
223
|
-
|
224
|
-
def set_valid_time(self, start: datetime, end: datetime) -> None:
|
225
|
-
"""Define the valid time window for the application."""
|
226
|
-
self.start_time = int(start.timestamp()) if start else None
|
227
|
-
self.end_time = int(end.timestamp()) if end else None
|
228
|
-
|
229
|
-
def set_env(self, key: str, value: str, secure: bool = False) -> None:
|
230
|
-
"""Set an environment variable, marking it secure if specified."""
|
231
|
-
(self.sec_env if secure else self.env)[key] = value
|
232
|
-
|
233
|
-
def set_permission(self, group_user: Permission, others_user: Permission) -> None:
|
234
|
-
"""Define application permissions based on user roles."""
|
235
|
-
self.permission = int(group_user.value + others_user.value)
|
236
|
-
|
237
|
-
def __str__(self) -> str:
|
238
|
-
"""Return a JSON string representation of the application."""
|
239
|
-
return json.dumps(self.json())
|
240
|
-
|
241
|
-
def json(self) -> dict:
|
242
|
-
"""Convert the application data into a JSON-compatible dictionary, removing empty items."""
|
243
|
-
output = copy.deepcopy(self.__dict__)
|
244
|
-
output["behavior"] = self.behavior.__dict__
|
245
|
-
output["daily_limitation"] = self.daily_limitation.__dict__
|
246
|
-
output["resource_limit"] = self.resource_limit.__dict__
|
247
|
-
|
248
|
-
def clean_empty(data: dict) -> None:
|
249
|
-
keys_to_delete = []
|
250
|
-
for key, value in data.items():
|
251
|
-
if isinstance(value, dict) and key != "metadata":
|
252
|
-
clean_empty(value) # Recursive call (without check user metadata)
|
253
|
-
if data[key] in [None, "", {}]:
|
254
|
-
keys_to_delete.append(key) # Mark keys for deletion
|
255
|
-
|
256
|
-
for key in keys_to_delete: # Delete keys after the loop to avoid modifying dict during iteration
|
257
|
-
del data[key]
|
258
|
-
|
259
|
-
clean_empty(output)
|
260
|
-
return output
|
261
|
-
|
262
|
-
|
263
|
-
class AppOutput(object):
|
264
|
-
"""
|
265
|
-
Represents the output information returned by the `app_output()` API, including the application's
|
266
|
-
stdout content, current read position, status code, and exit code.
|
267
|
-
"""
|
268
|
-
|
269
|
-
def __init__(self, status_code: HTTPStatus, output: str, out_position: Optional[int], exit_code: Optional[int]) -> None:
|
270
|
-
self.status_code = status_code
|
271
|
-
"""HTTP status code from the `app_output()` API request, indicating the result status."""
|
272
|
-
|
273
|
-
self.output = output
|
274
|
-
"""Captured stdout content of the application as returned by the `app_output()` API."""
|
275
|
-
|
276
|
-
self.out_position = out_position
|
277
|
-
"""Current read position in the application's stdout stream, or `None` if not applicable."""
|
278
|
-
|
279
|
-
self.exit_code = exit_code
|
280
|
-
"""Exit code of the application, or `None` if the process is still running or hasn't exited."""
|
281
|
-
|
282
|
-
|
283
|
-
class AppRun(object):
|
284
|
-
"""
|
285
|
-
Represents an application run object initiated by `run_async()` for monitoring and retrieving
|
286
|
-
the result of a remote application run.
|
287
|
-
"""
|
288
|
-
|
289
|
-
def __init__(self, client, app_name: str, process_id: str):
|
290
|
-
self.app_name = app_name
|
291
|
-
"""Name of the application associated with this run."""
|
292
|
-
|
293
|
-
self.proc_uid = process_id
|
294
|
-
"""Unique process ID from `run_async()`."""
|
295
|
-
|
296
|
-
self._client = client
|
297
|
-
"""Instance of `AppMeshClient` used to manage this application run."""
|
298
|
-
|
299
|
-
self._forwarding_host = client.forwarding_host
|
300
|
-
"""Target server for the application run, used for forwarding."""
|
301
|
-
|
302
|
-
@contextmanager
|
303
|
-
def forwarding_host(self):
|
304
|
-
"""Context manager to override the `forwarding_host` for the duration of the run."""
|
305
|
-
original_value = self._client.forwarding_host
|
306
|
-
self._client.forwarding_host = self._forwarding_host
|
307
|
-
try:
|
308
|
-
yield
|
309
|
-
finally:
|
310
|
-
self._client.forwarding_host = original_value
|
311
|
-
|
312
|
-
def wait(self, stdout_print: bool = True, timeout: int = 0) -> int:
|
313
|
-
"""Wait for the asynchronous run to complete.
|
314
|
-
|
315
|
-
Args:
|
316
|
-
stdout_print (bool, optional): If `True`, prints remote stdout to local. Defaults to `True`.
|
317
|
-
timeout (int, optional): Maximum time to wait in seconds. If `0`, waits until completion. Defaults to `0`.
|
318
|
-
|
319
|
-
Returns:
|
320
|
-
int: Exit code if the process finishes successfully. Returns `None` on timeout or exception.
|
321
|
-
"""
|
322
|
-
with self.forwarding_host():
|
323
|
-
return self._client.run_async_wait(self, stdout_print, timeout)
|
20
|
+
# Local application-specific imports
|
21
|
+
from .app import App
|
22
|
+
from .app_run import AppRun
|
23
|
+
from .app_output import AppOutput
|
324
24
|
|
325
25
|
|
326
26
|
class AppMeshClient(metaclass=abc.ABCMeta):
|
@@ -351,6 +51,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
351
51
|
token-based authentication and authorization to enforce fine-grained permissions.
|
352
52
|
|
353
53
|
Methods:
|
54
|
+
# Authentication Management
|
354
55
|
- login()
|
355
56
|
- logoff()
|
356
57
|
- authentication()
|
@@ -359,6 +60,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
359
60
|
- totp_secret()
|
360
61
|
- totp_setup()
|
361
62
|
|
63
|
+
# Application Management
|
362
64
|
- app_add()
|
363
65
|
- app_delete()
|
364
66
|
- app_disable()
|
@@ -368,24 +70,27 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
368
70
|
- app_view()
|
369
71
|
- app_view_all()
|
370
72
|
|
73
|
+
# Run Application Operations
|
371
74
|
- run_async()
|
372
75
|
- run_async_wait()
|
373
76
|
- run_sync()
|
374
77
|
|
78
|
+
# System Management
|
79
|
+
- forwarding_host
|
375
80
|
- config_set()
|
376
81
|
- config_view()
|
377
82
|
- log_level_set()
|
378
83
|
- host_resource()
|
379
|
-
- forwarding_host
|
380
84
|
- metrics()
|
381
|
-
|
382
85
|
- tag_add()
|
383
86
|
- tag_delete()
|
384
87
|
- tag_view()
|
385
88
|
|
89
|
+
# File Management
|
386
90
|
- file_download()
|
387
91
|
- file_upload()
|
388
92
|
|
93
|
+
# User and Role Management
|
389
94
|
- user_add()
|
390
95
|
- user_delete()
|
391
96
|
- user_lock()
|
@@ -401,6 +106,19 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
401
106
|
- groups_view()
|
402
107
|
"""
|
403
108
|
|
109
|
+
DURATION_ONE_WEEK_ISO = "P1W"
|
110
|
+
DURATION_TWO_DAYS_ISO = "P2D"
|
111
|
+
DURATION_TWO_DAYS_HALF_ISO = "P2DT12H"
|
112
|
+
|
113
|
+
DEFAULT_SSL_CA_CERT_PATH = "/opt/appmesh/ssl/ca.pem"
|
114
|
+
DEFAULT_SSL_CLIENT_CERT_PATH = "/opt/appmesh/ssl/client.pem"
|
115
|
+
DEFAULT_SSL_CLIENT_KEY_PATH = "/opt/appmesh/ssl/client-key.pem"
|
116
|
+
|
117
|
+
JSON_KEY_MESSAGE = "message"
|
118
|
+
HTTP_USER_AGENT = "appmesh/python"
|
119
|
+
HTTP_HEADER_KEY_USER_AGENT = "User-Agent"
|
120
|
+
HTTP_HEADER_KEY_X_TARGET_HOST = "X-Target-Host"
|
121
|
+
|
404
122
|
@unique
|
405
123
|
class Method(Enum):
|
406
124
|
"""REST methods"""
|
@@ -450,38 +168,91 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
450
168
|
|
451
169
|
@property
|
452
170
|
def jwt_token(self) -> str:
|
453
|
-
"""
|
171
|
+
"""Get the current JWT (JSON Web Token) used for authentication.
|
172
|
+
|
173
|
+
This property manages the authentication token used for securing API requests.
|
174
|
+
The token is used to authenticate and authorize requests to the service.
|
454
175
|
|
455
176
|
Returns:
|
456
|
-
str:
|
177
|
+
str: The current JWT token string.
|
178
|
+
Returns empty string if no token is set.
|
179
|
+
|
180
|
+
Notes:
|
181
|
+
- The token typically includes claims for identity and permissions
|
182
|
+
- Token format: "header.payload.signature"
|
183
|
+
- Tokens are time-sensitive and may expire
|
457
184
|
"""
|
458
185
|
return self._jwt_token
|
459
186
|
|
460
187
|
@jwt_token.setter
|
461
188
|
def jwt_token(self, token: str) -> None:
|
462
|
-
"""
|
189
|
+
"""Set the JWT token for authentication.
|
190
|
+
|
191
|
+
Configure the JWT token used for authenticating requests. The token should be
|
192
|
+
a valid JWT issued by a trusted authority.
|
463
193
|
|
464
194
|
Args:
|
465
|
-
token (str):
|
195
|
+
token (str): JWT token string in standard JWT format
|
196
|
+
(e.g., "eyJhbGci...payload...signature")
|
197
|
+
Pass empty string to clear the token.
|
198
|
+
|
199
|
+
Example:
|
200
|
+
>>> client.jwt_token = "eyJhbGci..." # Set new token
|
201
|
+
>>> client.jwt_token = "" # Clear token
|
202
|
+
|
203
|
+
Notes:
|
204
|
+
Security best practices:
|
205
|
+
- Store tokens securely
|
206
|
+
- Never log or expose complete tokens
|
207
|
+
- Refresh tokens before expiration
|
208
|
+
- Validate token format before setting
|
466
209
|
"""
|
467
210
|
self._jwt_token = token
|
468
211
|
|
469
212
|
@property
|
470
213
|
def forwarding_host(self) -> str:
|
471
|
-
"""
|
214
|
+
"""Get the target host address for request forwarding in a cluster setup.
|
215
|
+
|
216
|
+
This property manages the destination host where requests will be forwarded to
|
217
|
+
within a cluster configuration. The host can be specified in two formats:
|
218
|
+
1. hostname/IP only: will use the current service's port
|
219
|
+
2. hostname/IP with port: will use the specified port
|
472
220
|
|
473
221
|
Returns:
|
474
|
-
str:
|
222
|
+
str: The target host address in either format:
|
223
|
+
- "hostname" or "IP" (using current service port)
|
224
|
+
- "hostname:port" or "IP:port" (using specified port)
|
225
|
+
Returns empty string if no forwarding host is set.
|
226
|
+
|
227
|
+
Notes:
|
228
|
+
For proper JWT token sharing across the cluster:
|
229
|
+
- All nodes must share the same JWT salt configuration
|
230
|
+
- All nodes must use identical JWT issuer settings
|
231
|
+
- When port is omitted, current service port will be used
|
475
232
|
"""
|
476
233
|
return self._forwarding_host
|
477
234
|
|
478
235
|
@forwarding_host.setter
|
479
236
|
def forwarding_host(self, host: str) -> None:
|
480
|
-
"""
|
237
|
+
"""Set the target host address for request forwarding.
|
238
|
+
|
239
|
+
Configure the destination host where requests should be forwarded to. This is
|
240
|
+
used in cluster setups for request routing and load distribution.
|
481
241
|
|
482
242
|
Args:
|
483
|
-
host (str):
|
243
|
+
host (str): Target host address in one of two formats:
|
244
|
+
1. "hostname" or "IP" - will use current service port
|
245
|
+
(e.g., "backend-node" or "192.168.1.100")
|
246
|
+
2. "hostname:port" or "IP:port" - will use specified port
|
247
|
+
(e.g., "backend-node:6060" or "192.168.1.100:6060")
|
248
|
+
Pass empty string to disable forwarding.
|
249
|
+
|
250
|
+
Examples:
|
251
|
+
>>> client.forwarding_host = "backend-node:6060" # Use specific port
|
252
|
+
>>> client.forwarding_host = "backend-node" # Use current service port
|
253
|
+
>>> client.forwarding_host = None # Disable forwarding
|
484
254
|
"""
|
255
|
+
|
485
256
|
self._forwarding_host = host
|
486
257
|
|
487
258
|
########################################
|
@@ -1431,10 +1202,10 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1431
1202
|
header["Authorization"] = "Bearer " + self.jwt_token
|
1432
1203
|
if self.forwarding_host and len(self.forwarding_host) > 0:
|
1433
1204
|
if ":" in self.forwarding_host:
|
1434
|
-
header[HTTP_HEADER_KEY_X_TARGET_HOST] = self.forwarding_host
|
1205
|
+
header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forwarding_host
|
1435
1206
|
else:
|
1436
|
-
header[HTTP_HEADER_KEY_X_TARGET_HOST] = self.forwarding_host + ":" + str(parse.urlsplit(self.server_url).port)
|
1437
|
-
header[HTTP_HEADER_KEY_USER_AGENT] = HTTP_USER_AGENT
|
1207
|
+
header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forwarding_host + ":" + str(parse.urlsplit(self.server_url).port)
|
1208
|
+
header[self.HTTP_HEADER_KEY_USER_AGENT] = self.HTTP_USER_AGENT
|
1438
1209
|
|
1439
1210
|
if method is AppMeshClient.Method.GET:
|
1440
1211
|
return requests.get(url=rest_url, params=query, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
|
@@ -1450,325 +1221,3 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1450
1221
|
return requests.put(url=rest_url, params=query, headers=header, json=body, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
|
1451
1222
|
else:
|
1452
1223
|
raise Exception("Invalid http method", method)
|
1453
|
-
|
1454
|
-
|
1455
|
-
class AppMeshClientTCP(AppMeshClient):
|
1456
|
-
"""
|
1457
|
-
Client SDK for interacting with the App Mesh service over TCP, with enhanced support for large file transfers.
|
1458
|
-
|
1459
|
-
The `AppMeshClientTCP` class extends the functionality of `AppMeshClient` by offering a TCP-based communication layer
|
1460
|
-
for the App Mesh REST API. It overrides the file download and upload methods to support large file transfers with
|
1461
|
-
improved performance, leveraging TCP for lower latency and higher throughput compared to HTTP.
|
1462
|
-
|
1463
|
-
This client is suitable for applications requiring efficient data transfers and high-throughput operations within the
|
1464
|
-
App Mesh ecosystem, while maintaining compatibility with all other attributes and methods from `AppMeshClient`.
|
1465
|
-
|
1466
|
-
Dependency:
|
1467
|
-
- Install the required package for message serialization:
|
1468
|
-
pip3 install msgpack
|
1469
|
-
|
1470
|
-
Usage:
|
1471
|
-
- Import the client module:
|
1472
|
-
from appmesh import appmesh_client
|
1473
|
-
|
1474
|
-
Example:
|
1475
|
-
client = appmesh_client.AppMeshClientTCP()
|
1476
|
-
client.login("your-name", "your-password")
|
1477
|
-
client.file_download("/tmp/os-release", "os-release")
|
1478
|
-
|
1479
|
-
Attributes:
|
1480
|
-
- Inherits all attributes from `AppMeshClient`, including TLS secure connections and JWT-based authentication.
|
1481
|
-
- Optimized for TCP-based communication to provide better performance for large file transfers.
|
1482
|
-
|
1483
|
-
Methods:
|
1484
|
-
- file_download()
|
1485
|
-
- file_upload()
|
1486
|
-
- Inherits all other methods from `AppMeshClient`, providing a consistent interface for managing applications within App Mesh.
|
1487
|
-
"""
|
1488
|
-
|
1489
|
-
def __init__(
|
1490
|
-
self,
|
1491
|
-
rest_ssl_verify=DEFAULT_SSL_CA_CERT_PATH if os.path.exists(DEFAULT_SSL_CA_CERT_PATH) else False,
|
1492
|
-
rest_ssl_client_cert=None,
|
1493
|
-
jwt_token=None,
|
1494
|
-
tcp_address=("localhost", 6059),
|
1495
|
-
):
|
1496
|
-
"""Construct an App Mesh client TCP object to communicate securely with an App Mesh server over TLS.
|
1497
|
-
|
1498
|
-
Args:
|
1499
|
-
rest_ssl_verify (Union[bool, str], optional): Specifies SSL certificate verification behavior. Can be:
|
1500
|
-
- `True`: Uses the system’s default CA certificates to verify the server’s identity.
|
1501
|
-
- `False`: Disables SSL certificate verification (insecure, intended for development).
|
1502
|
-
- `str`: Specifies a custom CA bundle or directory for server certificate verification. If a string is provided,
|
1503
|
-
it should either be a file path to a custom CA certificate (CA bundle) or a directory path containing multiple
|
1504
|
-
certificates (CA directory).
|
1505
|
-
|
1506
|
-
**Note**: Unlike HTTP requests, TCP connections cannot automatically retrieve intermediate or public CA certificates.
|
1507
|
-
When `rest_ssl_verify` is a path, it explicitly identifies a CA issuer to ensure certificate validation.
|
1508
|
-
|
1509
|
-
rest_ssl_client_cert (Union[str, Tuple[str, str]], optional): Path to the SSL client certificate and key. If a `str`,
|
1510
|
-
it should be the path to a PEM file containing both the client certificate and private key. If a `tuple`, it should
|
1511
|
-
be a pair of paths: (`cert`, `key`), where `cert` is the client certificate file and `key` is the private key file.
|
1512
|
-
|
1513
|
-
jwt_token (str, optional): JWT token for authentication. Used in methods requiring login and user authorization.
|
1514
|
-
|
1515
|
-
tcp_address (Tuple[str, int], optional): Address and port for establishing a TCP connection to the server.
|
1516
|
-
Defaults to `("localhost", 6059)`.
|
1517
|
-
"""
|
1518
|
-
self.tcp_address = tcp_address
|
1519
|
-
self.__socket_client = None
|
1520
|
-
super().__init__(rest_ssl_verify=rest_ssl_verify, rest_ssl_client_cert=rest_ssl_client_cert, jwt_token=jwt_token)
|
1521
|
-
|
1522
|
-
def __del__(self) -> None:
|
1523
|
-
"""De-construction"""
|
1524
|
-
self.__close_socket()
|
1525
|
-
|
1526
|
-
def __connect_socket(self) -> None:
|
1527
|
-
"""Establish tcp connection"""
|
1528
|
-
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
1529
|
-
# Set minimum TLS version
|
1530
|
-
if hasattr(context, "minimum_version"):
|
1531
|
-
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
1532
|
-
else:
|
1533
|
-
context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
|
1534
|
-
# Configure SSL verification
|
1535
|
-
if not self.ssl_verify:
|
1536
|
-
context.verify_mode = ssl.CERT_NONE
|
1537
|
-
else:
|
1538
|
-
context.verify_mode = ssl.CERT_REQUIRED # Require certificate verification
|
1539
|
-
context.load_default_certs() # Load system's default CA certificates
|
1540
|
-
if isinstance(self.ssl_verify, str):
|
1541
|
-
if os.path.isfile(self.ssl_verify):
|
1542
|
-
# Load custom CA certificate file
|
1543
|
-
context.load_verify_locations(cafile=self.ssl_verify)
|
1544
|
-
elif os.path.isdir(self.ssl_verify):
|
1545
|
-
# Load CA certificates from directory
|
1546
|
-
context.load_verify_locations(capath=self.ssl_verify)
|
1547
|
-
else:
|
1548
|
-
raise ValueError(f"ssl_verify path '{self.ssl_verify}' is neither a file nor a directory")
|
1549
|
-
|
1550
|
-
if self.ssl_client_cert is not None:
|
1551
|
-
# Load client-side certificate and private key
|
1552
|
-
context.load_cert_chain(certfile=self.ssl_client_cert[0], keyfile=self.ssl_client_cert[1])
|
1553
|
-
|
1554
|
-
# Create a TCP socket
|
1555
|
-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
1556
|
-
sock.setblocking(True)
|
1557
|
-
# Wrap the socket with SSL/TLS
|
1558
|
-
self.__socket_client = context.wrap_socket(sock, server_hostname=self.tcp_address[0])
|
1559
|
-
# Connect to the server
|
1560
|
-
self.__socket_client.connect(self.tcp_address)
|
1561
|
-
# Disable Nagle's algorithm
|
1562
|
-
self.__socket_client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
1563
|
-
|
1564
|
-
def __close_socket(self) -> None:
|
1565
|
-
"""Close socket connection"""
|
1566
|
-
if self.__socket_client:
|
1567
|
-
try:
|
1568
|
-
self.__socket_client.close()
|
1569
|
-
self.__socket_client = None
|
1570
|
-
except Exception as ex:
|
1571
|
-
print(ex)
|
1572
|
-
|
1573
|
-
def __recvall(self, length: int) -> bytes:
|
1574
|
-
"""socket recv data with fixed length
|
1575
|
-
https://stackoverflow.com/questions/64466530/using-a-custom-socket-recvall-function-works-only-if-thread-is-put-to-sleep
|
1576
|
-
Args:
|
1577
|
-
length (bytes): data length to be received
|
1578
|
-
|
1579
|
-
Raises:
|
1580
|
-
EOFError: socket closed unexpectedly
|
1581
|
-
|
1582
|
-
Returns:
|
1583
|
-
bytes: socket data
|
1584
|
-
"""
|
1585
|
-
fragments = []
|
1586
|
-
while length:
|
1587
|
-
chunk = self.__socket_client.recv(length)
|
1588
|
-
if not chunk:
|
1589
|
-
raise EOFError("socket closed")
|
1590
|
-
length -= len(chunk)
|
1591
|
-
fragments.append(chunk)
|
1592
|
-
return b"".join(fragments)
|
1593
|
-
|
1594
|
-
def _request_http(self, method: AppMeshClient.Method, path: str, query: dict = None, header: dict = None, body=None) -> requests.Response:
|
1595
|
-
"""TCP API
|
1596
|
-
|
1597
|
-
Args:
|
1598
|
-
method (Method): AppMeshClient.Method.
|
1599
|
-
path (str): URI patch str.
|
1600
|
-
query (dict, optional): HTTP query parameters.
|
1601
|
-
header (dict, optional): HTTP headers.
|
1602
|
-
body (_type_, optional): object to send in the body of the :class:`Request`.
|
1603
|
-
|
1604
|
-
Returns:
|
1605
|
-
requests.Response: HTTP response
|
1606
|
-
"""
|
1607
|
-
import msgpack
|
1608
|
-
|
1609
|
-
class RequestMsg:
|
1610
|
-
"""HTTP request message"""
|
1611
|
-
|
1612
|
-
uuid: str = ""
|
1613
|
-
request_uri: str = ""
|
1614
|
-
http_method: str = ""
|
1615
|
-
client_addr: str = ""
|
1616
|
-
body: bytes = b""
|
1617
|
-
headers: dict = {}
|
1618
|
-
querys: dict = {}
|
1619
|
-
|
1620
|
-
def serialize(self) -> bytes:
|
1621
|
-
"""Serialize request message to bytes"""
|
1622
|
-
# http://www.cnitblog.com/luckydmz/archive/2019/11/20/91959.html
|
1623
|
-
self_dict = vars(self)
|
1624
|
-
self_dict["headers"] = self.headers
|
1625
|
-
self_dict["querys"] = self.querys
|
1626
|
-
return msgpack.dumps(self_dict)
|
1627
|
-
|
1628
|
-
class ResponseMsg:
|
1629
|
-
"""HTTP response message"""
|
1630
|
-
|
1631
|
-
uuid: str = ""
|
1632
|
-
request_uri: str = ""
|
1633
|
-
http_status: int = 0
|
1634
|
-
body_msg_type: str = ""
|
1635
|
-
body: str = ""
|
1636
|
-
headers: dict = {}
|
1637
|
-
|
1638
|
-
def desirialize(self, buf: bytes):
|
1639
|
-
"""Deserialize response message"""
|
1640
|
-
dic = msgpack.unpackb(buf)
|
1641
|
-
for k, v in dic.items():
|
1642
|
-
setattr(self, k, v)
|
1643
|
-
return self
|
1644
|
-
|
1645
|
-
if self.__socket_client is None:
|
1646
|
-
self.__connect_socket()
|
1647
|
-
|
1648
|
-
appmesh_request = RequestMsg()
|
1649
|
-
if super().jwt_token:
|
1650
|
-
appmesh_request.headers["Authorization"] = "Bearer " + super().jwt_token
|
1651
|
-
if super().forwarding_host and len(super().forwarding_host) > 0:
|
1652
|
-
raise Exception("Not support forward request in TCP mode")
|
1653
|
-
appmesh_request.headers[HTTP_HEADER_KEY_USER_AGENT] = HTTP_USER_AGENT_TCP
|
1654
|
-
appmesh_request.uuid = str(uuid.uuid1())
|
1655
|
-
appmesh_request.http_method = method.value
|
1656
|
-
appmesh_request.request_uri = path
|
1657
|
-
appmesh_request.client_addr = socket.gethostname()
|
1658
|
-
if body:
|
1659
|
-
if isinstance(body, dict) or isinstance(body, list):
|
1660
|
-
appmesh_request.body = bytes(json.dumps(body, indent=2), ENCODING_UTF8)
|
1661
|
-
elif isinstance(body, str):
|
1662
|
-
appmesh_request.body = bytes(body, ENCODING_UTF8)
|
1663
|
-
elif isinstance(body, bytes):
|
1664
|
-
appmesh_request.body = body
|
1665
|
-
else:
|
1666
|
-
raise Exception(f"UnSupported body type: {type(body)}")
|
1667
|
-
if header:
|
1668
|
-
for k, v in header.items():
|
1669
|
-
appmesh_request.headers[k] = v
|
1670
|
-
if query:
|
1671
|
-
for k, v in query.items():
|
1672
|
-
appmesh_request.querys[k] = v
|
1673
|
-
data = appmesh_request.serialize()
|
1674
|
-
self.__socket_client.sendall(len(data).to_bytes(TCP_HEADER_LENGTH, byteorder="big", signed=False))
|
1675
|
-
self.__socket_client.sendall(data)
|
1676
|
-
|
1677
|
-
# https://developers.google.com/protocol-buffers/docs/pythontutorial
|
1678
|
-
# https://stackoverflow.com/questions/33913308/socket-module-how-to-send-integer
|
1679
|
-
resp_data = bytes()
|
1680
|
-
resp_data = self.__recvall(int.from_bytes(self.__recvall(TCP_HEADER_LENGTH), byteorder="big", signed=False))
|
1681
|
-
if resp_data is None or len(resp_data) == 0:
|
1682
|
-
self.__close_socket()
|
1683
|
-
raise Exception("socket connection broken")
|
1684
|
-
appmesh_resp = ResponseMsg().desirialize(resp_data)
|
1685
|
-
response = requests.Response()
|
1686
|
-
response.status_code = appmesh_resp.http_status
|
1687
|
-
response.encoding = ENCODING_UTF8
|
1688
|
-
response._content = appmesh_resp.body.encode(ENCODING_UTF8)
|
1689
|
-
response.headers = appmesh_resp.headers
|
1690
|
-
if appmesh_resp.body_msg_type:
|
1691
|
-
response.headers["Content-Type"] = appmesh_resp.body_msg_type
|
1692
|
-
return response
|
1693
|
-
|
1694
|
-
########################################
|
1695
|
-
# File management
|
1696
|
-
########################################
|
1697
|
-
def file_download(self, remote_file: str, local_file: str, apply_file_attributes: bool = True) -> None:
|
1698
|
-
"""Copy a remote file to local, the local file will have the same permission as the remote file
|
1699
|
-
|
1700
|
-
Args:
|
1701
|
-
remote_file (str): the remote file path.
|
1702
|
-
local_file (str): the local file path to be downloaded.
|
1703
|
-
apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
|
1704
|
-
"""
|
1705
|
-
header = {"File-Path": remote_file}
|
1706
|
-
header[HTTP_HEADER_KEY_X_RECV_FILE_SOCKET] = "true"
|
1707
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header=header)
|
1708
|
-
|
1709
|
-
resp.raise_for_status()
|
1710
|
-
if HTTP_HEADER_KEY_X_RECV_FILE_SOCKET not in resp.headers:
|
1711
|
-
raise ValueError(f"Server did not respond with socket transfer option: {HTTP_HEADER_KEY_X_RECV_FILE_SOCKET}")
|
1712
|
-
|
1713
|
-
with open(local_file, "wb") as fp:
|
1714
|
-
chunk_data = bytes()
|
1715
|
-
chunk_size = int.from_bytes(self.__recvall(TCP_HEADER_LENGTH), byteorder="big", signed=False)
|
1716
|
-
while chunk_size > 0:
|
1717
|
-
chunk_data = self.__recvall(chunk_size)
|
1718
|
-
if chunk_data is None or len(chunk_data) == 0:
|
1719
|
-
self.__close_socket()
|
1720
|
-
raise Exception("socket connection broken")
|
1721
|
-
fp.write(chunk_data)
|
1722
|
-
chunk_size = int.from_bytes(self.__recvall(TCP_HEADER_LENGTH), byteorder="big", signed=False)
|
1723
|
-
|
1724
|
-
if apply_file_attributes:
|
1725
|
-
if "File-Mode" in resp.headers:
|
1726
|
-
os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
|
1727
|
-
if "File-User" in resp.headers and "File-Group" in resp.headers:
|
1728
|
-
file_uid = int(resp.headers["File-User"])
|
1729
|
-
file_gid = int(resp.headers["File-Group"])
|
1730
|
-
try:
|
1731
|
-
os.chown(path=local_file, uid=file_uid, gid=file_gid)
|
1732
|
-
except PermissionError:
|
1733
|
-
print(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
|
1734
|
-
|
1735
|
-
def file_upload(self, local_file: str, remote_file: str, apply_file_attributes: bool = True) -> None:
|
1736
|
-
"""Upload a local file to the remote server, the remote file will have the same permission as the local file
|
1737
|
-
|
1738
|
-
Dependency:
|
1739
|
-
sudo apt install python3-pip
|
1740
|
-
pip3 install requests_toolbelt
|
1741
|
-
|
1742
|
-
Args:
|
1743
|
-
local_file (str): the local file path.
|
1744
|
-
remote_file (str): the target remote file to be uploaded.
|
1745
|
-
apply_file_attributes (bool): whether to upload file attributes (permissions, owner, group) along with the file.
|
1746
|
-
"""
|
1747
|
-
if not os.path.exists(local_file):
|
1748
|
-
raise FileNotFoundError(f"Local file not found: {local_file}")
|
1749
|
-
|
1750
|
-
with open(file=local_file, mode="rb") as fp:
|
1751
|
-
header = {"File-Path": remote_file, "Content-Type": "text/plain"}
|
1752
|
-
header[HTTP_HEADER_KEY_X_SEND_FILE_SOCKET] = "true"
|
1753
|
-
|
1754
|
-
if apply_file_attributes:
|
1755
|
-
file_stat = os.stat(local_file)
|
1756
|
-
header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
1757
|
-
header["File-User"] = str(file_stat.st_uid)
|
1758
|
-
header["File-Group"] = str(file_stat.st_gid)
|
1759
|
-
|
1760
|
-
# https://stackoverflow.com/questions/22567306/python-requests-file-upload
|
1761
|
-
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/file/upload", header=header)
|
1762
|
-
|
1763
|
-
resp.raise_for_status()
|
1764
|
-
if HTTP_HEADER_KEY_X_SEND_FILE_SOCKET not in resp.headers:
|
1765
|
-
raise ValueError(f"Server did not respond with socket transfer option: {HTTP_HEADER_KEY_X_SEND_FILE_SOCKET}")
|
1766
|
-
|
1767
|
-
chunk_size = TCP_BLOCK_SIZE
|
1768
|
-
while True:
|
1769
|
-
chunk_data = fp.read(chunk_size)
|
1770
|
-
if not chunk_data:
|
1771
|
-
self.__socket_client.sendall((0).to_bytes(TCP_HEADER_LENGTH, byteorder="big", signed=False))
|
1772
|
-
break
|
1773
|
-
self.__socket_client.sendall(len(chunk_data).to_bytes(TCP_HEADER_LENGTH, byteorder="big", signed=False))
|
1774
|
-
self.__socket_client.sendall(chunk_data)
|