appmesh 1.3.6__py3-none-any.whl → 1.3.8__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 +6 -1772
- appmesh/http_client.py +1216 -0
- appmesh/tcp_client.py +216 -0
- appmesh/tcp_messages.py +41 -0
- appmesh/tcp_transport.py +160 -0
- {appmesh-1.3.6.dist-info → appmesh-1.3.8.dist-info}/METADATA +1 -1
- appmesh-1.3.8.dist-info/RECORD +13 -0
- {appmesh-1.3.6.dist-info → appmesh-1.3.8.dist-info}/WHEEL +1 -1
- appmesh-1.3.6.dist-info/RECORD +0 -6
- {appmesh-1.3.6.dist-info → appmesh-1.3.8.dist-info}/top_level.txt +0 -0
appmesh/appmesh_client.py
CHANGED
@@ -1,1774 +1,8 @@
|
|
1
|
-
|
2
|
-
"""App Mesh Python SDK"""
|
3
|
-
import abc
|
4
|
-
import base64
|
5
|
-
from contextlib import contextmanager
|
6
|
-
import copy
|
7
|
-
import json
|
8
|
-
import os
|
9
|
-
import socket
|
10
|
-
import ssl
|
11
|
-
import uuid
|
1
|
+
# appmesh_client.py
|
12
2
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
from urllib import parse
|
3
|
+
# Legacy Compatibility Layer
|
4
|
+
# These imports provide backward compatibility for older code that relies on
|
5
|
+
# AppMeshClient, App, and AppOutput classes. The updated implementation can be found
|
6
|
+
# in http_client.py, where these classes are now primarily maintained.
|
18
7
|
|
19
|
-
import
|
20
|
-
import requests
|
21
|
-
|
22
|
-
# pylint: disable=broad-exception-raised,line-too-long,broad-exception-caught,too-many-lines, import-outside-toplevel, protected-access
|
23
|
-
|
24
|
-
DURATION_ONE_WEEK_ISO = "P1W"
|
25
|
-
DURATION_TWO_DAYS_ISO = "P2D"
|
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)
|
324
|
-
|
325
|
-
|
326
|
-
class AppMeshClient(metaclass=abc.ABCMeta):
|
327
|
-
"""
|
328
|
-
Client SDK for interacting with the App Mesh service via REST API.
|
329
|
-
|
330
|
-
The `AppMeshClient` class provides a comprehensive interface for managing and monitoring distributed applications
|
331
|
-
within the App Mesh ecosystem. It enables communication with the App Mesh REST API for operations such as
|
332
|
-
application lifecycle management, monitoring, and configuration.
|
333
|
-
|
334
|
-
This client is designed for direct usage in applications that require access to App Mesh services over HTTP-based REST.
|
335
|
-
|
336
|
-
Usage:
|
337
|
-
- Install the App Mesh Python package:
|
338
|
-
python3 -m pip install --upgrade appmesh
|
339
|
-
- Import the client module:
|
340
|
-
from appmesh import appmesh_client
|
341
|
-
|
342
|
-
Example:
|
343
|
-
client = appmesh_client.AppMeshClient()
|
344
|
-
client.login("your-name", "your-password")
|
345
|
-
response = client.app_view(app_name='ping')
|
346
|
-
|
347
|
-
Attributes:
|
348
|
-
- TLS (Transport Layer Security): Supports secure connections between the client and App Mesh service,
|
349
|
-
ensuring encrypted communication.
|
350
|
-
- JWT (JSON Web Token) and RBAC (Role-Based Access Control): Provides secure API access with
|
351
|
-
token-based authentication and authorization to enforce fine-grained permissions.
|
352
|
-
|
353
|
-
Methods:
|
354
|
-
- login()
|
355
|
-
- logoff()
|
356
|
-
- authentication()
|
357
|
-
- renew()
|
358
|
-
- totp_disable()
|
359
|
-
- totp_secret()
|
360
|
-
- totp_setup()
|
361
|
-
|
362
|
-
- app_add()
|
363
|
-
- app_delete()
|
364
|
-
- app_disable()
|
365
|
-
- app_enable()
|
366
|
-
- app_health()
|
367
|
-
- app_output()
|
368
|
-
- app_view()
|
369
|
-
- app_view_all()
|
370
|
-
|
371
|
-
- run_async()
|
372
|
-
- run_async_wait()
|
373
|
-
- run_sync()
|
374
|
-
|
375
|
-
- config_set()
|
376
|
-
- config_view()
|
377
|
-
- log_level_set()
|
378
|
-
- host_resource()
|
379
|
-
- forwarding_host
|
380
|
-
- metrics()
|
381
|
-
|
382
|
-
- tag_add()
|
383
|
-
- tag_delete()
|
384
|
-
- tag_view()
|
385
|
-
|
386
|
-
- file_download()
|
387
|
-
- file_upload()
|
388
|
-
|
389
|
-
- user_add()
|
390
|
-
- user_delete()
|
391
|
-
- user_lock()
|
392
|
-
- user_passwd_update()
|
393
|
-
- user_self()
|
394
|
-
- user_unlock()
|
395
|
-
- users_view()
|
396
|
-
- permissions_for_user()
|
397
|
-
- permissions_view()
|
398
|
-
- role_delete()
|
399
|
-
- role_update()
|
400
|
-
- roles_view()
|
401
|
-
- groups_view()
|
402
|
-
"""
|
403
|
-
|
404
|
-
@unique
|
405
|
-
class Method(Enum):
|
406
|
-
"""REST methods"""
|
407
|
-
|
408
|
-
GET = "GET"
|
409
|
-
PUT = "PUT"
|
410
|
-
POST = "POST"
|
411
|
-
DELETE = "DELETE"
|
412
|
-
POST_STREAM = "POST_STREAM"
|
413
|
-
|
414
|
-
def __init__(
|
415
|
-
self,
|
416
|
-
rest_url: str = "https://127.0.0.1:6060",
|
417
|
-
rest_ssl_verify=DEFAULT_SSL_CA_CERT_PATH if os.path.exists(DEFAULT_SSL_CA_CERT_PATH) else False,
|
418
|
-
rest_ssl_client_cert=(DEFAULT_SSL_CLIENT_CERT_PATH, DEFAULT_SSL_CLIENT_KEY_PATH) if os.path.exists(DEFAULT_SSL_CLIENT_CERT_PATH) else None,
|
419
|
-
rest_timeout=(60, 300),
|
420
|
-
jwt_token=None,
|
421
|
-
):
|
422
|
-
"""Initialize an App Mesh HTTP client for interacting with the App Mesh server via secure HTTPS.
|
423
|
-
|
424
|
-
Args:
|
425
|
-
rest_url (str, optional): The server's base URI, including protocol, hostname, and port. Defaults to `"https://127.0.0.1:6060"`.
|
426
|
-
|
427
|
-
rest_ssl_verify (Union[bool, str], optional): Configures SSL certificate verification for HTTPS requests:
|
428
|
-
- `True`: Uses system CA certificates to verify the server's identity.
|
429
|
-
- `False`: Disables SSL verification (insecure, use cautiously for development).
|
430
|
-
- `str`: Path to a custom CA certificate or directory for verification. This option allows custom CA configuration,
|
431
|
-
which may be necessary in environments requiring specific CA chains that differ from the default system CAs.
|
432
|
-
|
433
|
-
rest_ssl_client_cert (Union[tuple, str], optional): Specifies a client certificate for mutual TLS authentication:
|
434
|
-
- If a `str`, provides the path to a PEM file with both client certificate and private key.
|
435
|
-
- If a `tuple`, contains two paths as (`cert`, `key`), where `cert` is the certificate file and `key` is the private key file.
|
436
|
-
|
437
|
-
rest_timeout (tuple, optional): HTTP connection timeouts for API requests, as `(connect_timeout, read_timeout)`.
|
438
|
-
The default is `(60, 300)`, where `60` seconds is the maximum time to establish a connection and `300` seconds for the maximum read duration.
|
439
|
-
|
440
|
-
jwt_token (str, optional): JWT token for API authentication, used in headers to authorize requests where required.
|
441
|
-
|
442
|
-
"""
|
443
|
-
|
444
|
-
self.server_url = rest_url
|
445
|
-
self._jwt_token = jwt_token
|
446
|
-
self.ssl_verify = rest_ssl_verify
|
447
|
-
self.ssl_client_cert = rest_ssl_client_cert
|
448
|
-
self.rest_timeout = rest_timeout
|
449
|
-
self._forwarding_host = None
|
450
|
-
|
451
|
-
@property
|
452
|
-
def jwt_token(self) -> str:
|
453
|
-
"""property for jwt_token
|
454
|
-
|
455
|
-
Returns:
|
456
|
-
str: _description_
|
457
|
-
"""
|
458
|
-
return self._jwt_token
|
459
|
-
|
460
|
-
@jwt_token.setter
|
461
|
-
def jwt_token(self, token: str) -> None:
|
462
|
-
"""setter for jwt_token
|
463
|
-
|
464
|
-
Args:
|
465
|
-
token (str): _description_
|
466
|
-
"""
|
467
|
-
self._jwt_token = token
|
468
|
-
|
469
|
-
@property
|
470
|
-
def forwarding_host(self) -> str:
|
471
|
-
"""property for forwarding_host
|
472
|
-
|
473
|
-
Returns:
|
474
|
-
str: forward request to target host (host:port)
|
475
|
-
"""
|
476
|
-
return self._forwarding_host
|
477
|
-
|
478
|
-
@forwarding_host.setter
|
479
|
-
def forwarding_host(self, host: str) -> None:
|
480
|
-
"""setter for forwarding_host
|
481
|
-
|
482
|
-
Args:
|
483
|
-
host (str): forward request to target host (host:port)
|
484
|
-
"""
|
485
|
-
self._forwarding_host = host
|
486
|
-
|
487
|
-
########################################
|
488
|
-
# Security
|
489
|
-
########################################
|
490
|
-
def login(self, user_name: str, user_pwd: str, totp_code="", timeout_seconds=DURATION_ONE_WEEK_ISO) -> str:
|
491
|
-
"""Login with user name and password
|
492
|
-
|
493
|
-
Args:
|
494
|
-
user_name (str): the name of the user.
|
495
|
-
user_pwd (str): the password of the user.
|
496
|
-
totp_code (str, optional): the TOTP code if enabled for the user.
|
497
|
-
timeout_seconds (int | str, optional): token expire timeout of seconds. support ISO 8601 durations (e.g., 'P1Y2M3DT4H5M6S' 'P1W').
|
498
|
-
|
499
|
-
Returns:
|
500
|
-
str: JWT token.
|
501
|
-
"""
|
502
|
-
self.jwt_token = None
|
503
|
-
resp = self._request_http(
|
504
|
-
AppMeshClient.Method.POST,
|
505
|
-
path="/appmesh/login",
|
506
|
-
header={
|
507
|
-
"Authorization": "Basic " + base64.b64encode((user_name + ":" + user_pwd).encode()).decode(),
|
508
|
-
"Expire-Seconds": self._parse_duration(timeout_seconds),
|
509
|
-
},
|
510
|
-
)
|
511
|
-
if resp.status_code == HTTPStatus.OK:
|
512
|
-
if "Access-Token" in resp.json():
|
513
|
-
self.jwt_token = resp.json()["Access-Token"]
|
514
|
-
elif resp.status_code == HTTPStatus.UNAUTHORIZED and "Totp-Challenge" in resp.json():
|
515
|
-
challenge = resp.json()["Totp-Challenge"]
|
516
|
-
resp = self._request_http(
|
517
|
-
AppMeshClient.Method.POST,
|
518
|
-
path="/appmesh/totp/validate",
|
519
|
-
header={
|
520
|
-
"Username": base64.b64encode(user_name.encode()).decode(),
|
521
|
-
"Totp-Challenge": base64.b64encode(challenge.encode()).decode(),
|
522
|
-
"Totp": totp_code,
|
523
|
-
"Expire-Seconds": self._parse_duration(timeout_seconds),
|
524
|
-
},
|
525
|
-
)
|
526
|
-
if resp.status_code == HTTPStatus.OK:
|
527
|
-
if "Access-Token" in resp.json():
|
528
|
-
self.jwt_token = resp.json()["Access-Token"]
|
529
|
-
else:
|
530
|
-
raise Exception(resp.text)
|
531
|
-
else:
|
532
|
-
raise Exception(resp.text)
|
533
|
-
return self.jwt_token
|
534
|
-
|
535
|
-
def logoff(self) -> bool:
|
536
|
-
"""Logoff current session from server
|
537
|
-
|
538
|
-
Returns:
|
539
|
-
bool: logoff success or failure.
|
540
|
-
"""
|
541
|
-
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/self/logoff")
|
542
|
-
if resp.status_code != HTTPStatus.OK:
|
543
|
-
raise Exception(resp.text)
|
544
|
-
self.jwt_token = None
|
545
|
-
return resp.status_code == HTTPStatus.OK
|
546
|
-
|
547
|
-
def authentication(self, token: str, permission=None) -> bool:
|
548
|
-
"""Login with token and verify permission when specified,
|
549
|
-
verified token will be stored in client object when success
|
550
|
-
|
551
|
-
Args:
|
552
|
-
token (str): JWT token returned from login().
|
553
|
-
permission (str, optional): the permission ID used to verify the token user
|
554
|
-
permission ID can be:
|
555
|
-
- pre-defined by App Mesh from security.yaml (e.g 'app-view', 'app-delete')
|
556
|
-
- defined by input from role_update() or security.yaml
|
557
|
-
|
558
|
-
Returns:
|
559
|
-
bool: authentication success or failure.
|
560
|
-
"""
|
561
|
-
old_token = self.jwt_token
|
562
|
-
self.jwt_token = token
|
563
|
-
headers = {}
|
564
|
-
if permission:
|
565
|
-
headers["Auth-Permission"] = permission
|
566
|
-
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/auth", header=headers)
|
567
|
-
if resp.status_code != HTTPStatus.OK:
|
568
|
-
self.jwt_token = old_token
|
569
|
-
raise Exception(resp.text)
|
570
|
-
return resp.status_code == HTTPStatus.OK
|
571
|
-
|
572
|
-
def renew(self, timeout_seconds=DURATION_ONE_WEEK_ISO) -> str:
|
573
|
-
"""Renew current token
|
574
|
-
|
575
|
-
Args:
|
576
|
-
timeout_seconds (int | str, optional): token expire timeout of seconds. support ISO 8601 durations (e.g., 'P1Y2M3DT4H5M6S' 'P1W').
|
577
|
-
|
578
|
-
Returns:
|
579
|
-
str: The new JWT token if renew success, otherwise return None.
|
580
|
-
"""
|
581
|
-
assert self.jwt_token
|
582
|
-
resp = self._request_http(
|
583
|
-
AppMeshClient.Method.POST,
|
584
|
-
path="/appmesh/token/renew",
|
585
|
-
header={
|
586
|
-
"Expire-Seconds": self._parse_duration(timeout_seconds),
|
587
|
-
},
|
588
|
-
)
|
589
|
-
if resp.status_code == HTTPStatus.OK:
|
590
|
-
if "Access-Token" in resp.json():
|
591
|
-
self.jwt_token = resp.json()["Access-Token"]
|
592
|
-
return self.jwt_token
|
593
|
-
raise Exception(resp.text)
|
594
|
-
|
595
|
-
def totp_secret(self) -> str:
|
596
|
-
"""Generate TOTP secret for current login user and return MFA URI with JSON body
|
597
|
-
|
598
|
-
Returns:
|
599
|
-
str: TOTP secret str
|
600
|
-
"""
|
601
|
-
resp = self._request_http(method=AppMeshClient.Method.POST, path="/appmesh/totp/secret")
|
602
|
-
if resp.status_code == HTTPStatus.OK:
|
603
|
-
totp_uri = base64.b64decode(resp.json()["Mfa-Uri"]).decode()
|
604
|
-
return self._parse_totp_uri(totp_uri).get("secret")
|
605
|
-
raise Exception(resp.text)
|
606
|
-
|
607
|
-
def totp_setup(self, totp_code: str) -> bool:
|
608
|
-
"""Setup 2FA for current login user
|
609
|
-
|
610
|
-
Args:
|
611
|
-
totp_code (str): TOTP code
|
612
|
-
|
613
|
-
Returns:
|
614
|
-
bool: success or failure.
|
615
|
-
"""
|
616
|
-
resp = self._request_http(
|
617
|
-
method=AppMeshClient.Method.POST,
|
618
|
-
path="/appmesh/totp/setup",
|
619
|
-
header={"Totp": totp_code},
|
620
|
-
)
|
621
|
-
if resp.status_code != HTTPStatus.OK:
|
622
|
-
raise Exception(resp.text)
|
623
|
-
return resp.status_code == HTTPStatus.OK
|
624
|
-
|
625
|
-
def totp_disable(self, user="self") -> bool:
|
626
|
-
"""Disable 2FA for current user
|
627
|
-
|
628
|
-
Args:
|
629
|
-
user (str, optional): user name for disable TOTP.
|
630
|
-
|
631
|
-
Returns:
|
632
|
-
bool: success or failure.
|
633
|
-
"""
|
634
|
-
resp = self._request_http(
|
635
|
-
method=AppMeshClient.Method.POST,
|
636
|
-
path=f"/appmesh/totp/{user}/disable",
|
637
|
-
)
|
638
|
-
if resp.status_code != HTTPStatus.OK:
|
639
|
-
raise Exception(resp.text)
|
640
|
-
return resp.status_code == HTTPStatus.OK
|
641
|
-
|
642
|
-
@staticmethod
|
643
|
-
def _parse_totp_uri(totp_uri: str) -> dict:
|
644
|
-
"""Extract TOTP parameters
|
645
|
-
|
646
|
-
Args:
|
647
|
-
totp_uri (str): TOTP uri
|
648
|
-
|
649
|
-
Returns:
|
650
|
-
dict: eextract parameters
|
651
|
-
"""
|
652
|
-
parsed_info = {}
|
653
|
-
parsed_uri = parse.urlparse(totp_uri)
|
654
|
-
|
655
|
-
# Extract label from the path
|
656
|
-
parsed_info["label"] = parsed_uri.path[1:] # Remove the leading slash
|
657
|
-
|
658
|
-
# Extract parameters from the query string
|
659
|
-
query_params = parse.parse_qs(parsed_uri.query)
|
660
|
-
for key, value in query_params.items():
|
661
|
-
parsed_info[key] = value[0]
|
662
|
-
return parsed_info
|
663
|
-
|
664
|
-
########################################
|
665
|
-
# Application view
|
666
|
-
########################################
|
667
|
-
def app_view(self, app_name: str) -> App:
|
668
|
-
"""Get one application information
|
669
|
-
|
670
|
-
Args:
|
671
|
-
app_name (str): the application name.
|
672
|
-
|
673
|
-
Returns:
|
674
|
-
App: the application object both contain static configuration and runtime information.
|
675
|
-
|
676
|
-
Exception:
|
677
|
-
failed request or no such application
|
678
|
-
"""
|
679
|
-
resp = self._request_http(AppMeshClient.Method.GET, path=f"/appmesh/app/{app_name}")
|
680
|
-
if resp.status_code != HTTPStatus.OK:
|
681
|
-
raise Exception(resp.text)
|
682
|
-
return App(resp.json())
|
683
|
-
|
684
|
-
def app_view_all(self):
|
685
|
-
"""Get all applications
|
686
|
-
|
687
|
-
Returns:
|
688
|
-
list: the application object both contain static configuration and runtime information, only return applications that the user has permissions.
|
689
|
-
|
690
|
-
Exception:
|
691
|
-
failed request or no such application
|
692
|
-
"""
|
693
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/applications")
|
694
|
-
if resp.status_code != HTTPStatus.OK:
|
695
|
-
raise Exception(resp.text)
|
696
|
-
apps = []
|
697
|
-
for app in resp.json():
|
698
|
-
apps.append(App(app))
|
699
|
-
return apps
|
700
|
-
|
701
|
-
def app_output(self, app_name: str, stdout_position: int = 0, stdout_index: int = 0, stdout_maxsize: int = 10240, process_uuid: str = "", timeout: int = 0) -> AppOutput:
|
702
|
-
"""Get application stdout/stderr
|
703
|
-
|
704
|
-
Args:
|
705
|
-
app_name (str): the application name
|
706
|
-
stdout_position (int, optional): start read position, 0 means start from beginning.
|
707
|
-
stdout_index (int, optional): index of history process stdout, 0 means get from current running process,
|
708
|
-
the stdout number depends on 'stdout_cache_size' of the application.
|
709
|
-
stdout_maxsize (int, optional): max buffer size to read.
|
710
|
-
process_uuid (str, optional): used to get the specified process.
|
711
|
-
timeout (int, optional): wait for the running process for some time(seconds) to get the output.
|
712
|
-
|
713
|
-
Returns:
|
714
|
-
AppOutput object.
|
715
|
-
"""
|
716
|
-
resp = self._request_http(
|
717
|
-
AppMeshClient.Method.GET,
|
718
|
-
path=f"/appmesh/app/{app_name}/output",
|
719
|
-
query={
|
720
|
-
"stdout_position": str(stdout_position),
|
721
|
-
"stdout_index": str(stdout_index),
|
722
|
-
"stdout_maxsize": str(stdout_maxsize),
|
723
|
-
"process_uuid": process_uuid,
|
724
|
-
"timeout": str(timeout),
|
725
|
-
},
|
726
|
-
)
|
727
|
-
out_position = int(resp.headers["Output-Position"]) if "Output-Position" in resp.headers else None
|
728
|
-
exit_code = int(resp.headers["Exit-Code"]) if "Exit-Code" in resp.headers else None
|
729
|
-
return AppOutput(status_code=resp.status_code, output=resp.text, out_position=out_position, exit_code=exit_code)
|
730
|
-
|
731
|
-
def app_health(self, app_name: str) -> bool:
|
732
|
-
"""Get application health status
|
733
|
-
|
734
|
-
Args:
|
735
|
-
app_name (str): the application name.
|
736
|
-
|
737
|
-
Returns:
|
738
|
-
bool: healthy or not
|
739
|
-
"""
|
740
|
-
resp = self._request_http(AppMeshClient.Method.GET, path=f"/appmesh/app/{app_name}/health")
|
741
|
-
if resp.status_code != HTTPStatus.OK:
|
742
|
-
raise Exception(resp.text)
|
743
|
-
return int(resp.text) == 0
|
744
|
-
|
745
|
-
########################################
|
746
|
-
# Application manage
|
747
|
-
########################################
|
748
|
-
def app_add(self, app: App) -> App:
|
749
|
-
"""Register an application
|
750
|
-
|
751
|
-
Args:
|
752
|
-
app (App): the application definition.
|
753
|
-
|
754
|
-
Returns:
|
755
|
-
App: resigtered application object.
|
756
|
-
|
757
|
-
Exception:
|
758
|
-
failed request
|
759
|
-
"""
|
760
|
-
resp = self._request_http(AppMeshClient.Method.PUT, path=f"/appmesh/app/{app.name}", body=app.json())
|
761
|
-
if resp.status_code != HTTPStatus.OK:
|
762
|
-
raise Exception(resp.text)
|
763
|
-
return App(resp.json())
|
764
|
-
|
765
|
-
def app_delete(self, app_name: str) -> bool:
|
766
|
-
"""Remove an application.
|
767
|
-
|
768
|
-
Args:
|
769
|
-
app_name (str): the application name.
|
770
|
-
|
771
|
-
Returns:
|
772
|
-
bool: True for delete success, Flase for not exist anymore.
|
773
|
-
"""
|
774
|
-
resp = self._request_http(AppMeshClient.Method.DELETE, path=f"/appmesh/app/{app_name}")
|
775
|
-
if resp.status_code == HTTPStatus.OK:
|
776
|
-
return True
|
777
|
-
elif resp.status_code == HTTPStatus.NOT_FOUND:
|
778
|
-
return False
|
779
|
-
else:
|
780
|
-
raise Exception(resp.text)
|
781
|
-
|
782
|
-
def app_enable(self, app_name: str) -> bool:
|
783
|
-
"""Enable an application
|
784
|
-
|
785
|
-
Args:
|
786
|
-
app_name (str): the application name.
|
787
|
-
|
788
|
-
Returns:
|
789
|
-
bool: success or failure.
|
790
|
-
"""
|
791
|
-
resp = self._request_http(AppMeshClient.Method.POST, path=f"/appmesh/app/{app_name}/enable")
|
792
|
-
if resp.status_code != HTTPStatus.OK:
|
793
|
-
raise Exception(resp.text)
|
794
|
-
return resp.status_code == HTTPStatus.OK
|
795
|
-
|
796
|
-
def app_disable(self, app_name: str) -> bool:
|
797
|
-
"""Stop and disable an application
|
798
|
-
|
799
|
-
Args:
|
800
|
-
app_name (str): the application name.
|
801
|
-
|
802
|
-
Returns:
|
803
|
-
bool: success or failure.
|
804
|
-
"""
|
805
|
-
resp = self._request_http(AppMeshClient.Method.POST, path=f"/appmesh/app/{app_name}/disable")
|
806
|
-
if resp.status_code != HTTPStatus.OK:
|
807
|
-
raise Exception(resp.text)
|
808
|
-
return resp.status_code == HTTPStatus.OK
|
809
|
-
|
810
|
-
########################################
|
811
|
-
# Cloud management
|
812
|
-
########################################
|
813
|
-
def cloud_app_view_all(self) -> dict:
|
814
|
-
"""Get all cloud applications
|
815
|
-
|
816
|
-
Returns:
|
817
|
-
dict: cloud applications in JSON format.
|
818
|
-
"""
|
819
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/cloud/applications")
|
820
|
-
if resp.status_code != HTTPStatus.OK:
|
821
|
-
raise Exception(resp.text)
|
822
|
-
return resp.json()
|
823
|
-
|
824
|
-
def cloud_app(self, app_name: str) -> dict:
|
825
|
-
"""Get an cloud application
|
826
|
-
|
827
|
-
Args:
|
828
|
-
app_name (str): the application name.
|
829
|
-
|
830
|
-
Returns:
|
831
|
-
dict: application in JSON format.
|
832
|
-
"""
|
833
|
-
resp = self._request_http(AppMeshClient.Method.GET, path=f"/appmesh/cloud/app/{app_name}")
|
834
|
-
if resp.status_code != HTTPStatus.OK:
|
835
|
-
raise Exception(resp.text)
|
836
|
-
return resp.json()
|
837
|
-
|
838
|
-
def cloud_app_output(self, app_name: str, host_name: str, stdout_position: int = 0, stdout_index: int = 0, stdout_maxsize: int = 10240, process_uuid: str = ""):
|
839
|
-
"""Get cloud application stdout/stderr from master agent
|
840
|
-
|
841
|
-
Args:
|
842
|
-
app_name (str): the application name
|
843
|
-
host_name (str): the target host name where the application is running
|
844
|
-
stdout_position (int, optional): start read position, 0 means start from beginning.
|
845
|
-
stdout_index (int, optional): index of history process stdout, 0 means get from current running process,
|
846
|
-
the stdout number depends on 'stdout_cache_size' of the application.
|
847
|
-
stdout_maxsize (int, optional): max buffer size to read.
|
848
|
-
process_uuid (str, optional): used to get the specified process.
|
849
|
-
|
850
|
-
Returns:
|
851
|
-
bool: success or failure.
|
852
|
-
str: output string.
|
853
|
-
int or None: current read position.
|
854
|
-
int or None: process exit code.
|
855
|
-
"""
|
856
|
-
resp = self._request_http(
|
857
|
-
AppMeshClient.Method.GET,
|
858
|
-
path=f"/appmesh/cloud/app/{app_name}/output/{host_name}",
|
859
|
-
query={
|
860
|
-
"stdout_position": str(stdout_position),
|
861
|
-
"stdout_index": str(stdout_index),
|
862
|
-
"stdout_maxsize": str(stdout_maxsize),
|
863
|
-
"process_uuid": process_uuid,
|
864
|
-
},
|
865
|
-
)
|
866
|
-
out_position = int(resp.headers["Output-Position"]) if "Output-Position" in resp.headers else None
|
867
|
-
exit_code = int(resp.headers["Exit-Code"]) if "Exit-Code" in resp.headers else None
|
868
|
-
return (resp.status_code == HTTPStatus.OK), resp.text, out_position, exit_code
|
869
|
-
|
870
|
-
def cloud_app_delete(self, app_name: str) -> bool:
|
871
|
-
"""Delete a cloud application
|
872
|
-
|
873
|
-
Args:
|
874
|
-
app_name (str): The application name for cloud
|
875
|
-
|
876
|
-
Returns:
|
877
|
-
bool: success or failure.
|
878
|
-
"""
|
879
|
-
resp = self._request_http(AppMeshClient.Method.DELETE, path=f"/appmesh/cloud/app/{app_name}")
|
880
|
-
if resp.status_code != HTTPStatus.OK:
|
881
|
-
raise Exception(resp.text)
|
882
|
-
return resp.status_code == HTTPStatus.OK
|
883
|
-
|
884
|
-
def cloud_app_add(self, app_json: dict) -> dict:
|
885
|
-
"""Add a cloud application
|
886
|
-
|
887
|
-
Args:
|
888
|
-
app_json (dict): the cloud application definition with replication, condition and resource requirement
|
889
|
-
|
890
|
-
Returns:
|
891
|
-
dict: cluster application json.
|
892
|
-
"""
|
893
|
-
resp = self._request_http(AppMeshClient.Method.PUT, path=f"/appmesh/cloud/app/{app_json['content']['name']}", body=app_json)
|
894
|
-
if resp.status_code != HTTPStatus.OK:
|
895
|
-
raise Exception(resp.text)
|
896
|
-
return resp.json()
|
897
|
-
|
898
|
-
def cloud_nodes(self) -> dict:
|
899
|
-
"""Get cluster node list
|
900
|
-
|
901
|
-
Returns:
|
902
|
-
dict: cluster node list json.
|
903
|
-
"""
|
904
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/cloud/nodes")
|
905
|
-
if resp.status_code != HTTPStatus.OK:
|
906
|
-
raise Exception(resp.text)
|
907
|
-
return resp.json()
|
908
|
-
|
909
|
-
########################################
|
910
|
-
# Configuration
|
911
|
-
########################################
|
912
|
-
def host_resource(self) -> dict:
|
913
|
-
"""Get App Mesh host resource report include CPU, memory and disk
|
914
|
-
|
915
|
-
Returns:
|
916
|
-
dict: the host resource json.
|
917
|
-
"""
|
918
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/resources")
|
919
|
-
if resp.status_code != HTTPStatus.OK:
|
920
|
-
raise Exception(resp.text)
|
921
|
-
return resp.json()
|
922
|
-
|
923
|
-
def config_view(self) -> dict:
|
924
|
-
"""Get App Mesh configuration JSON
|
925
|
-
|
926
|
-
Returns:
|
927
|
-
dict: the configuration json.
|
928
|
-
"""
|
929
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/config")
|
930
|
-
if resp.status_code != HTTPStatus.OK:
|
931
|
-
raise Exception(resp.text)
|
932
|
-
return resp.json()
|
933
|
-
|
934
|
-
def config_set(self, cfg_json) -> dict:
|
935
|
-
"""Update configuration, the format follow 'config.yaml', support partial update
|
936
|
-
|
937
|
-
Args:
|
938
|
-
cfg_json (dict): the new configuration json.
|
939
|
-
|
940
|
-
Returns:
|
941
|
-
dict: the updated configuration json.
|
942
|
-
"""
|
943
|
-
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/config", body=cfg_json)
|
944
|
-
if resp.status_code != HTTPStatus.OK:
|
945
|
-
raise Exception(resp.text)
|
946
|
-
return resp.json()
|
947
|
-
|
948
|
-
def log_level_set(self, level: str = "DEBUG") -> str:
|
949
|
-
"""Update App Mesh log level(DEBUG/INFO/NOTICE/WARN/ERROR), a wrapper of config_set()
|
950
|
-
|
951
|
-
Args:
|
952
|
-
level (str, optional): log level.
|
953
|
-
|
954
|
-
Returns:
|
955
|
-
str: the updated log level.
|
956
|
-
"""
|
957
|
-
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/config", body={"BaseConfig": {"LogLevel": level}})
|
958
|
-
if resp.status_code != HTTPStatus.OK:
|
959
|
-
raise Exception(resp.text)
|
960
|
-
return resp.json()["BaseConfig"]["LogLevel"]
|
961
|
-
|
962
|
-
########################################
|
963
|
-
# User Management
|
964
|
-
########################################
|
965
|
-
def user_passwd_update(self, new_password: str, user_name: str = "self") -> bool:
|
966
|
-
"""Change user password
|
967
|
-
|
968
|
-
Args:
|
969
|
-
user_name (str): the user name.
|
970
|
-
new_password (str):the new password string
|
971
|
-
|
972
|
-
Returns:
|
973
|
-
bool: success
|
974
|
-
"""
|
975
|
-
resp = self._request_http(
|
976
|
-
method=AppMeshClient.Method.POST,
|
977
|
-
path=f"/appmesh/user/{user_name}/passwd",
|
978
|
-
header={"New-Password": base64.b64encode(new_password.encode())},
|
979
|
-
)
|
980
|
-
if resp.status_code != HTTPStatus.OK:
|
981
|
-
raise Exception(resp.text)
|
982
|
-
return True
|
983
|
-
|
984
|
-
def user_add(self, user_name: str, user_json: dict) -> bool:
|
985
|
-
"""Add a new user, not available for LDAP user
|
986
|
-
|
987
|
-
Args:
|
988
|
-
user_name (str): the user name.
|
989
|
-
user_json (dict): user definition, follow same user format from security.yaml.
|
990
|
-
|
991
|
-
Returns:
|
992
|
-
bool: success or failure.
|
993
|
-
"""
|
994
|
-
resp = self._request_http(
|
995
|
-
method=AppMeshClient.Method.PUT,
|
996
|
-
path=f"/appmesh/user/{user_name}",
|
997
|
-
body=user_json,
|
998
|
-
)
|
999
|
-
return resp.status_code == HTTPStatus.OK
|
1000
|
-
|
1001
|
-
def user_delete(self, user_name: str) -> bool:
|
1002
|
-
"""Delete a user
|
1003
|
-
|
1004
|
-
Args:
|
1005
|
-
user_name (str): the user name.
|
1006
|
-
|
1007
|
-
Returns:
|
1008
|
-
bool: success or failure.
|
1009
|
-
"""
|
1010
|
-
resp = self._request_http(
|
1011
|
-
method=AppMeshClient.Method.DELETE,
|
1012
|
-
path=f"/appmesh/user/{user_name}",
|
1013
|
-
)
|
1014
|
-
return resp.status_code == HTTPStatus.OK
|
1015
|
-
|
1016
|
-
def user_lock(self, user_name: str) -> bool:
|
1017
|
-
"""Lock a user
|
1018
|
-
|
1019
|
-
Args:
|
1020
|
-
user_name (str): the user name.
|
1021
|
-
|
1022
|
-
Returns:
|
1023
|
-
bool: success or failure.
|
1024
|
-
"""
|
1025
|
-
resp = self._request_http(
|
1026
|
-
method=AppMeshClient.Method.POST,
|
1027
|
-
path=f"/appmesh/user/{user_name}/lock",
|
1028
|
-
)
|
1029
|
-
if resp.status_code != HTTPStatus.OK:
|
1030
|
-
raise Exception(resp.text)
|
1031
|
-
return resp.status_code == HTTPStatus.OK
|
1032
|
-
|
1033
|
-
def user_unlock(self, user_name: str) -> bool:
|
1034
|
-
"""Unlock a user
|
1035
|
-
|
1036
|
-
Args:
|
1037
|
-
user_name (str): the user name.
|
1038
|
-
|
1039
|
-
Returns:
|
1040
|
-
bool: success or failure.
|
1041
|
-
"""
|
1042
|
-
resp = self._request_http(
|
1043
|
-
method=AppMeshClient.Method.POST,
|
1044
|
-
path=f"/appmesh/user/{user_name}/unlock",
|
1045
|
-
)
|
1046
|
-
if resp.status_code != HTTPStatus.OK:
|
1047
|
-
raise Exception(resp.text)
|
1048
|
-
return resp.status_code == HTTPStatus.OK
|
1049
|
-
|
1050
|
-
def users_view(self) -> dict:
|
1051
|
-
"""Get all users
|
1052
|
-
|
1053
|
-
Returns:
|
1054
|
-
dict: all user definition
|
1055
|
-
"""
|
1056
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/users")
|
1057
|
-
if resp.status_code != HTTPStatus.OK:
|
1058
|
-
raise Exception(resp.text)
|
1059
|
-
return resp.json()
|
1060
|
-
|
1061
|
-
def user_self(self) -> dict:
|
1062
|
-
"""Get current user infomation
|
1063
|
-
|
1064
|
-
Returns:
|
1065
|
-
dict: user definition.
|
1066
|
-
"""
|
1067
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/self")
|
1068
|
-
if resp.status_code != HTTPStatus.OK:
|
1069
|
-
raise Exception(resp.text)
|
1070
|
-
return resp.json()
|
1071
|
-
|
1072
|
-
def groups_view(self) -> list:
|
1073
|
-
"""Get all user groups
|
1074
|
-
|
1075
|
-
Returns:
|
1076
|
-
dict: user group array.
|
1077
|
-
"""
|
1078
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/groups")
|
1079
|
-
if resp.status_code != HTTPStatus.OK:
|
1080
|
-
raise Exception(resp.text)
|
1081
|
-
return resp.json()
|
1082
|
-
|
1083
|
-
def permissions_view(self) -> list:
|
1084
|
-
"""Get all available permissions
|
1085
|
-
|
1086
|
-
Returns:
|
1087
|
-
dict: permission array
|
1088
|
-
"""
|
1089
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/permissions")
|
1090
|
-
if resp.status_code != HTTPStatus.OK:
|
1091
|
-
raise Exception(resp.text)
|
1092
|
-
return resp.json()
|
1093
|
-
|
1094
|
-
def permissions_for_user(self) -> list:
|
1095
|
-
"""Get current user permissions
|
1096
|
-
|
1097
|
-
Returns:
|
1098
|
-
dict: user permission array.
|
1099
|
-
"""
|
1100
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/permissions")
|
1101
|
-
if resp.status_code != HTTPStatus.OK:
|
1102
|
-
raise Exception(resp.text)
|
1103
|
-
return resp.json()
|
1104
|
-
|
1105
|
-
def roles_view(self) -> list:
|
1106
|
-
"""Get all roles with permission definition
|
1107
|
-
|
1108
|
-
Returns:
|
1109
|
-
dict: all role definition.
|
1110
|
-
"""
|
1111
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/roles")
|
1112
|
-
if resp.status_code != HTTPStatus.OK:
|
1113
|
-
raise Exception(resp.text)
|
1114
|
-
return resp.json()
|
1115
|
-
|
1116
|
-
def role_update(self, role_name: str, role_permission_json: dict) -> bool:
|
1117
|
-
"""Update (or add) a role with defined permissions, the permission ID can be App Mesh pre-defined or other permission ID.
|
1118
|
-
|
1119
|
-
Args:
|
1120
|
-
role_name (str): the role name.
|
1121
|
-
role_permission_json (dict): role permission definition array, e.g: ["app-control", "app-delete", "cloud-app-reg", "cloud-app-delete"]
|
1122
|
-
|
1123
|
-
Returns:
|
1124
|
-
bool: success or failure.
|
1125
|
-
"""
|
1126
|
-
resp = self._request_http(method=AppMeshClient.Method.POST, path=f"/appmesh/role/{role_name}", body=role_permission_json)
|
1127
|
-
if resp.status_code != HTTPStatus.OK:
|
1128
|
-
raise Exception(resp.text)
|
1129
|
-
return resp.status_code == HTTPStatus.OK
|
1130
|
-
|
1131
|
-
def role_delete(self, role_name: str) -> bool:
|
1132
|
-
"""Delete a user role
|
1133
|
-
|
1134
|
-
Args:
|
1135
|
-
role_name (str): the role name.
|
1136
|
-
|
1137
|
-
Returns:
|
1138
|
-
bool: success or failure.
|
1139
|
-
"""
|
1140
|
-
resp = self._request_http(
|
1141
|
-
method=AppMeshClient.Method.DELETE,
|
1142
|
-
path=f"/appmesh/role/{role_name}",
|
1143
|
-
)
|
1144
|
-
if resp.status_code != HTTPStatus.OK:
|
1145
|
-
raise Exception(resp.text)
|
1146
|
-
return resp.status_code == HTTPStatus.OK
|
1147
|
-
|
1148
|
-
########################################
|
1149
|
-
# Tag management
|
1150
|
-
########################################
|
1151
|
-
def tag_add(self, tag_name: str, tag_value: str) -> bool:
|
1152
|
-
"""Add a new label
|
1153
|
-
|
1154
|
-
Args:
|
1155
|
-
tag_name (str): the label name.
|
1156
|
-
tag_value (str): the label value.
|
1157
|
-
|
1158
|
-
Returns:
|
1159
|
-
bool: success or failure.
|
1160
|
-
"""
|
1161
|
-
resp = self._request_http(
|
1162
|
-
AppMeshClient.Method.PUT,
|
1163
|
-
query={"value": tag_value},
|
1164
|
-
path=f"/appmesh/label/{tag_name}",
|
1165
|
-
)
|
1166
|
-
if resp.status_code != HTTPStatus.OK:
|
1167
|
-
raise Exception(resp.text)
|
1168
|
-
return resp.status_code == HTTPStatus.OK
|
1169
|
-
|
1170
|
-
def tag_delete(self, tag_name: str) -> bool:
|
1171
|
-
"""Delete a label
|
1172
|
-
|
1173
|
-
Args:
|
1174
|
-
tag_name (str): the label name.
|
1175
|
-
|
1176
|
-
Returns:
|
1177
|
-
bool: success or failure.
|
1178
|
-
"""
|
1179
|
-
resp = self._request_http(AppMeshClient.Method.DELETE, path=f"/appmesh/label/{tag_name}")
|
1180
|
-
if resp.status_code != HTTPStatus.OK:
|
1181
|
-
raise Exception(resp.text)
|
1182
|
-
return resp.status_code == HTTPStatus.OK
|
1183
|
-
|
1184
|
-
def tag_view(self) -> dict:
|
1185
|
-
"""Get the server labels
|
1186
|
-
|
1187
|
-
Returns:
|
1188
|
-
dict: label data.
|
1189
|
-
"""
|
1190
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/labels")
|
1191
|
-
if resp.status_code != HTTPStatus.OK:
|
1192
|
-
raise Exception(resp.text)
|
1193
|
-
return resp.json()
|
1194
|
-
|
1195
|
-
########################################
|
1196
|
-
# Promethus metrics
|
1197
|
-
########################################
|
1198
|
-
def metrics(self):
|
1199
|
-
"""Prometheus metrics (this does not call Prometheus API /metrics, just copy the same metrics data)
|
1200
|
-
|
1201
|
-
Returns:
|
1202
|
-
str: prometheus metrics texts
|
1203
|
-
"""
|
1204
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/metrics")
|
1205
|
-
if resp.status_code != HTTPStatus.OK:
|
1206
|
-
raise Exception(resp.text)
|
1207
|
-
return resp.text
|
1208
|
-
|
1209
|
-
########################################
|
1210
|
-
# File management
|
1211
|
-
########################################
|
1212
|
-
def file_download(self, remote_file: str, local_file: str, apply_file_attributes: bool = True) -> None:
|
1213
|
-
"""Copy a remote file to local. Optionally, the local file will have the same permission as the remote file.
|
1214
|
-
|
1215
|
-
Args:
|
1216
|
-
remote_file (str): the remote file path.
|
1217
|
-
local_file (str): the local file path to be downloaded.
|
1218
|
-
apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
|
1219
|
-
"""
|
1220
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={"File-Path": remote_file})
|
1221
|
-
resp.raise_for_status()
|
1222
|
-
|
1223
|
-
# Write the file content locally
|
1224
|
-
with open(local_file, "wb") as fp:
|
1225
|
-
for chunk in resp.iter_content(chunk_size=8 * 1024): # 8 KB
|
1226
|
-
if chunk:
|
1227
|
-
fp.write(chunk)
|
1228
|
-
|
1229
|
-
# Apply file attributes (permissions, owner, group) if requested
|
1230
|
-
if apply_file_attributes:
|
1231
|
-
if "File-Mode" in resp.headers:
|
1232
|
-
os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
|
1233
|
-
if "File-User" in resp.headers and "File-Group" in resp.headers:
|
1234
|
-
file_uid = int(resp.headers["File-User"])
|
1235
|
-
file_gid = int(resp.headers["File-Group"])
|
1236
|
-
try:
|
1237
|
-
os.chown(path=local_file, uid=file_uid, gid=file_gid)
|
1238
|
-
except PermissionError:
|
1239
|
-
print(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
|
1240
|
-
|
1241
|
-
def file_upload(self, local_file: str, remote_file: str, apply_file_attributes: bool = True) -> None:
|
1242
|
-
"""Upload a local file to the remote server. Optionally, the remote file will have the same permission as the local file.
|
1243
|
-
|
1244
|
-
Dependency:
|
1245
|
-
sudo apt install python3-pip
|
1246
|
-
pip3 install requests_toolbelt
|
1247
|
-
|
1248
|
-
Args:
|
1249
|
-
local_file (str): the local file path.
|
1250
|
-
remote_file (str): the target remote file to be uploaded.
|
1251
|
-
apply_file_attributes (bool): whether to upload file attributes (permissions, owner, group) along with the file.
|
1252
|
-
"""
|
1253
|
-
if not os.path.exists(local_file):
|
1254
|
-
raise FileNotFoundError(f"Local file not found: {local_file}")
|
1255
|
-
|
1256
|
-
from requests_toolbelt import MultipartEncoder
|
1257
|
-
|
1258
|
-
with open(file=local_file, mode="rb") as fp:
|
1259
|
-
encoder = MultipartEncoder(fields={"filename": os.path.basename(remote_file), "file": ("filename", fp, "application/octet-stream")})
|
1260
|
-
header = {"File-Path": remote_file, "Content-Type": encoder.content_type}
|
1261
|
-
|
1262
|
-
# Include file attributes (permissions, owner, group) if requested
|
1263
|
-
if apply_file_attributes:
|
1264
|
-
file_stat = os.stat(local_file)
|
1265
|
-
header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
1266
|
-
header["File-User"] = str(file_stat.st_uid)
|
1267
|
-
header["File-Group"] = str(file_stat.st_gid)
|
1268
|
-
|
1269
|
-
# Upload file with or without attributes
|
1270
|
-
# https://stackoverflow.com/questions/22567306/python-requests-file-upload
|
1271
|
-
resp = self._request_http(
|
1272
|
-
AppMeshClient.Method.POST_STREAM,
|
1273
|
-
path="/appmesh/file/upload",
|
1274
|
-
header=header,
|
1275
|
-
body=encoder,
|
1276
|
-
)
|
1277
|
-
resp.raise_for_status()
|
1278
|
-
|
1279
|
-
########################################
|
1280
|
-
# Application run
|
1281
|
-
########################################
|
1282
|
-
def _parse_duration(self, timeout) -> str:
|
1283
|
-
if isinstance(timeout, int):
|
1284
|
-
return str(timeout)
|
1285
|
-
elif isinstance(timeout, str):
|
1286
|
-
return str(int(aniso8601.parse_duration(timeout).total_seconds()))
|
1287
|
-
else:
|
1288
|
-
raise TypeError(f"Invalid timeout type: {str(timeout)}")
|
1289
|
-
|
1290
|
-
def run_async(
|
1291
|
-
self,
|
1292
|
-
app: Union[App, str],
|
1293
|
-
max_time_seconds: Union[int, str] = DURATION_TWO_DAYS_ISO,
|
1294
|
-
life_cycle_seconds: Union[int, str] = DURATION_TWO_DAYS_HALF_ISO,
|
1295
|
-
) -> AppRun:
|
1296
|
-
"""Run an application asynchronously on a remote system without blocking the API.
|
1297
|
-
|
1298
|
-
Args:
|
1299
|
-
app (Union[App, str]): An `App` instance or a shell command string.
|
1300
|
-
- If `app` is a string, it is treated as a shell command for the remote run,
|
1301
|
-
and an `App` instance is created as:
|
1302
|
-
`App({"command": "<command_string>", "shell": True})`.
|
1303
|
-
- If `app` is an `App` object, providing only the `name` attribute (without
|
1304
|
-
a command) will run an existing application; otherwise, it is treated as a new application.
|
1305
|
-
max_time_seconds (Union[int, str], optional): Maximum runtime for the remote process.
|
1306
|
-
Accepts ISO 8601 duration format (e.g., 'P1Y2M3DT4H5M6S', 'P5W'). Defaults to `P2D`.
|
1307
|
-
life_cycle_seconds (Union[int, str], optional): Maximum lifecycle time for the remote process.
|
1308
|
-
Accepts ISO 8601 duration format. Defaults to `P2DT12H`.
|
1309
|
-
|
1310
|
-
Returns:
|
1311
|
-
AppRun: An application run object that can be used to monitor and retrieve the result of the run.
|
1312
|
-
"""
|
1313
|
-
if isinstance(app, str):
|
1314
|
-
app = App({"command": app, "shell": True})
|
1315
|
-
|
1316
|
-
path = "/appmesh/app/run"
|
1317
|
-
resp = self._request_http(
|
1318
|
-
AppMeshClient.Method.POST,
|
1319
|
-
body=app.json(),
|
1320
|
-
path=path,
|
1321
|
-
query={
|
1322
|
-
"timeout": self._parse_duration(max_time_seconds),
|
1323
|
-
"lifecycle": self._parse_duration(life_cycle_seconds),
|
1324
|
-
},
|
1325
|
-
)
|
1326
|
-
if resp.status_code != HTTPStatus.OK:
|
1327
|
-
raise Exception(resp.text)
|
1328
|
-
|
1329
|
-
# Return an AppRun object with the application name and process UUID
|
1330
|
-
return AppRun(self, resp.json()["name"], resp.json()["process_uuid"])
|
1331
|
-
|
1332
|
-
def run_async_wait(self, run: AppRun, stdout_print: bool = True, timeout: int = 0) -> int:
|
1333
|
-
"""Wait for an async run to be finished
|
1334
|
-
|
1335
|
-
Args:
|
1336
|
-
run (AppRun): asyncrized run result from run_async().
|
1337
|
-
stdout_print (bool, optional): print remote stdout to local or not.
|
1338
|
-
timeout (int, optional): wait max timeout seconds and return if not finished, 0 means wait until finished
|
1339
|
-
|
1340
|
-
Returns:
|
1341
|
-
int: return exit code if process finished, return None for timeout or exception.
|
1342
|
-
"""
|
1343
|
-
if run:
|
1344
|
-
last_output_position = 0
|
1345
|
-
start = datetime.now()
|
1346
|
-
interval = 1 if self.__class__.__name__ == "AppMeshClient" else 1000
|
1347
|
-
while len(run.proc_uid) > 0:
|
1348
|
-
app_out = self.app_output(app_name=run.app_name, stdout_position=last_output_position, stdout_index=0, process_uuid=run.proc_uid, timeout=interval)
|
1349
|
-
if app_out.output and stdout_print:
|
1350
|
-
print(app_out.output, end="")
|
1351
|
-
if app_out.out_position is not None:
|
1352
|
-
last_output_position = app_out.out_position
|
1353
|
-
if app_out.exit_code is not None:
|
1354
|
-
# success
|
1355
|
-
self.app_delete(run.app_name)
|
1356
|
-
return app_out.exit_code
|
1357
|
-
if app_out.status_code != HTTPStatus.OK:
|
1358
|
-
# failed
|
1359
|
-
break
|
1360
|
-
if timeout > 0 and (datetime.now() - start).seconds > timeout:
|
1361
|
-
# timeout
|
1362
|
-
break
|
1363
|
-
return None
|
1364
|
-
|
1365
|
-
def run_sync(
|
1366
|
-
self,
|
1367
|
-
app: Union[App, str],
|
1368
|
-
stdout_print: bool = True,
|
1369
|
-
max_time_seconds: Union[int, str] = DURATION_TWO_DAYS_ISO,
|
1370
|
-
life_cycle_seconds: Union[int, str] = DURATION_TWO_DAYS_HALF_ISO,
|
1371
|
-
) -> Tuple[Union[int, None], str]:
|
1372
|
-
"""Synchronously run an application remotely, blocking until completion, and return the result.
|
1373
|
-
|
1374
|
-
If 'app' is a string, it is treated as a shell command and converted to an App instance.
|
1375
|
-
If 'app' is App object, the name attribute is used to run an existing application if specified.
|
1376
|
-
|
1377
|
-
Args:
|
1378
|
-
app (Union[App, str]): An App instance or a shell command string.
|
1379
|
-
If a string, an App instance is created as:
|
1380
|
-
`appmesh_client.App({"command": "<command_string>", "shell": True})`
|
1381
|
-
stdout_print (bool, optional): If True, prints the remote stdout locally. Defaults to True.
|
1382
|
-
max_time_seconds (Union[int, str], optional): Maximum runtime for the remote process.
|
1383
|
-
Supports ISO 8601 duration format (e.g., 'P1Y2M3DT4H5M6S', 'P5W'). Defaults to DEFAULT_RUN_APP_TIMEOUT_SECONDS.
|
1384
|
-
life_cycle_seconds (Union[int, str], optional): Maximum lifecycle time for the remote process.
|
1385
|
-
Supports ISO 8601 duration format. Defaults to DEFAULT_RUN_APP_LIFECYCLE_SECONDS.
|
1386
|
-
|
1387
|
-
Returns:
|
1388
|
-
Tuple[Union[int, None], str]: Exit code of the process (None if unavailable) and the stdout text.
|
1389
|
-
"""
|
1390
|
-
if isinstance(app, str):
|
1391
|
-
app = App({"command": app, "shell": True})
|
1392
|
-
|
1393
|
-
path = "/appmesh/app/syncrun"
|
1394
|
-
resp = self._request_http(
|
1395
|
-
AppMeshClient.Method.POST,
|
1396
|
-
body=app.json(),
|
1397
|
-
path=path,
|
1398
|
-
query={
|
1399
|
-
"timeout": self._parse_duration(max_time_seconds),
|
1400
|
-
"lifecycle": self._parse_duration(life_cycle_seconds),
|
1401
|
-
},
|
1402
|
-
)
|
1403
|
-
exit_code = None
|
1404
|
-
if resp.status_code == HTTPStatus.OK:
|
1405
|
-
if stdout_print:
|
1406
|
-
print(resp.text, end="")
|
1407
|
-
if "Exit-Code" in resp.headers:
|
1408
|
-
exit_code = int(resp.headers.get("Exit-Code"))
|
1409
|
-
elif stdout_print:
|
1410
|
-
print(resp.text)
|
1411
|
-
|
1412
|
-
return exit_code, resp.text
|
1413
|
-
|
1414
|
-
def _request_http(self, method: Method, path: str, query: dict = None, header: dict = None, body=None) -> requests.Response:
|
1415
|
-
"""REST API
|
1416
|
-
|
1417
|
-
Args:
|
1418
|
-
method (Method): AppMeshClient.Method.
|
1419
|
-
path (str): URI patch str.
|
1420
|
-
query (dict, optional): HTTP query parameters.
|
1421
|
-
header (dict, optional): HTTP headers.
|
1422
|
-
body (_type_, optional): object to send in the body of the :class:`Request`.
|
1423
|
-
|
1424
|
-
Returns:
|
1425
|
-
requests.Response: HTTP response
|
1426
|
-
"""
|
1427
|
-
rest_url = parse.urljoin(self.server_url, path)
|
1428
|
-
|
1429
|
-
header = {} if header is None else header
|
1430
|
-
if self.jwt_token:
|
1431
|
-
header["Authorization"] = "Bearer " + self.jwt_token
|
1432
|
-
if self.forwarding_host and len(self.forwarding_host) > 0:
|
1433
|
-
if ":" in self.forwarding_host:
|
1434
|
-
header[HTTP_HEADER_KEY_X_TARGET_HOST] = self.forwarding_host
|
1435
|
-
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
|
1438
|
-
|
1439
|
-
if method is AppMeshClient.Method.GET:
|
1440
|
-
return requests.get(url=rest_url, params=query, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
|
1441
|
-
elif method is AppMeshClient.Method.POST:
|
1442
|
-
return requests.post(
|
1443
|
-
url=rest_url, params=query, headers=header, data=json.dumps(body) if type(body) in (dict, list) else body, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout
|
1444
|
-
)
|
1445
|
-
elif method is AppMeshClient.Method.POST_STREAM:
|
1446
|
-
return requests.post(url=rest_url, params=query, headers=header, data=body, cert=self.ssl_client_cert, verify=self.ssl_verify, stream=True, timeout=self.rest_timeout)
|
1447
|
-
elif method is AppMeshClient.Method.DELETE:
|
1448
|
-
return requests.delete(url=rest_url, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
|
1449
|
-
elif method is AppMeshClient.Method.PUT:
|
1450
|
-
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
|
-
else:
|
1452
|
-
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)
|
8
|
+
from .http_client import AppMeshClient, App, AppOutput
|