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/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 Optional, Tuple, Union
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
- # 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)
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
- """property for jwt_token
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: _description_
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
- """setter for jwt_token
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): _description_
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
- """property for forwarding_host
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: forward request to target host (host:port)
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
- """setter for forwarding_host
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): forward request to target host (host:port)
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)