appmesh 1.6.14__py3-none-any.whl → 1.6.16__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/client_http.py +456 -682
- appmesh/client_http_oauth.py +5 -7
- appmesh/client_tcp.py +25 -25
- appmesh/server_http.py +3 -3
- appmesh/server_tcp.py +1 -1
- appmesh/tcp_messages.py +1 -0
- appmesh/tcp_transport.py +1 -0
- {appmesh-1.6.14.dist-info → appmesh-1.6.16.dist-info}/METADATA +1 -1
- appmesh-1.6.16.dist-info/RECORD +16 -0
- appmesh-1.6.14.dist-info/RECORD +0 -16
- {appmesh-1.6.14.dist-info → appmesh-1.6.16.dist-info}/WHEEL +0 -0
- {appmesh-1.6.14.dist-info → appmesh-1.6.16.dist-info}/top_level.txt +0 -0
appmesh/client_http.py
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
# client_http.py
|
2
2
|
# pylint: disable=broad-exception-raised,line-too-long,broad-exception-caught,too-many-lines,import-outside-toplevel
|
3
3
|
|
4
|
+
"""App Mesh HTTP Client SDK for REST API interactions."""
|
5
|
+
|
4
6
|
# Standard library imports
|
5
7
|
import abc
|
6
8
|
import base64
|
@@ -12,10 +14,11 @@ import os
|
|
12
14
|
import sys
|
13
15
|
import threading
|
14
16
|
import time
|
15
|
-
from
|
17
|
+
from contextlib import suppress
|
16
18
|
from datetime import datetime
|
17
19
|
from enum import Enum, unique
|
18
20
|
from http import HTTPStatus
|
21
|
+
from pathlib import Path
|
19
22
|
from typing import Optional, Tuple, Union
|
20
23
|
from urllib import parse
|
21
24
|
|
@@ -111,33 +114,36 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
111
114
|
>>> response = client.app_view(app_name='ping')
|
112
115
|
"""
|
113
116
|
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
117
|
+
# Duration constants
|
118
|
+
_DURATION_ONE_WEEK_ISO = "P1W"
|
119
|
+
_DURATION_TWO_DAYS_ISO = "P2D"
|
120
|
+
_DURATION_TWO_DAYS_HALF_ISO = "P2DT12H"
|
121
|
+
_TOKEN_REFRESH_INTERVAL = 300 # 5 min to refresh token
|
122
|
+
_TOKEN_REFRESH_OFFSET = 30 # 30s before token expire to refresh token
|
119
123
|
|
120
124
|
# Platform-aware default SSL paths
|
121
|
-
_DEFAULT_SSL_DIR = Path("
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
125
|
+
_DEFAULT_SSL_DIR = Path("c:/local/appmesh/ssl" if os.name == "nt" else "/opt/appmesh/ssl")
|
126
|
+
_DEFAULT_SSL_CA_CERT_PATH = str(_DEFAULT_SSL_DIR / "ca.pem")
|
127
|
+
_DEFAULT_SSL_CLIENT_CERT_PATH = str(_DEFAULT_SSL_DIR / "client.pem")
|
128
|
+
_DEFAULT_SSL_CLIENT_KEY_PATH = str(_DEFAULT_SSL_DIR / "client-key.pem")
|
129
|
+
|
130
|
+
# JWT constants
|
131
|
+
_DEFAULT_JWT_AUDIENCE = "appmesh-service"
|
132
|
+
|
133
|
+
# HTTP headers and constants
|
134
|
+
_JSON_KEY_MESSAGE = "message"
|
135
|
+
_HTTP_USER_AGENT = "appmesh/python"
|
136
|
+
_HTTP_HEADER_KEY_AUTH = "Authorization"
|
137
|
+
_HTTP_HEADER_KEY_USER_AGENT = "User-Agent"
|
138
|
+
_HTTP_HEADER_KEY_X_TARGET_HOST = "X-Target-Host"
|
139
|
+
_HTTP_HEADER_KEY_X_FILE_PATH = "X-File-Path"
|
140
|
+
_HTTP_HEADER_JWT_SET_COOKIE = "X-Set-Cookie"
|
141
|
+
_HTTP_HEADER_NAME_CSRF_TOKEN = "X-CSRF-Token"
|
142
|
+
_COOKIE_TOKEN = "appmesh_auth_token"
|
143
|
+
_COOKIE_CSRF_TOKEN = "appmesh_csrf_token"
|
138
144
|
|
139
145
|
@unique
|
140
|
-
class
|
146
|
+
class _Method(Enum):
|
141
147
|
"""REST methods"""
|
142
148
|
|
143
149
|
GET = "GET"
|
@@ -146,13 +152,11 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
146
152
|
DELETE = "DELETE"
|
147
153
|
POST_STREAM = "POST_STREAM"
|
148
154
|
|
149
|
-
class
|
155
|
+
class _EncodingResponse(requests.Response):
|
150
156
|
"""Response subclass that handles encoding conversion on Windows."""
|
151
157
|
|
152
158
|
def __init__(self, response: requests.Response):
|
153
159
|
super().__init__()
|
154
|
-
|
155
|
-
# copy essential fields from response
|
156
160
|
self.__dict__.update(response.__dict__)
|
157
161
|
|
158
162
|
self._converted_text = None
|
@@ -161,28 +165,24 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
161
165
|
# Check if we need to convert encoding on Windows
|
162
166
|
if sys.platform == "win32":
|
163
167
|
content_type = response.headers.get("Content-Type", "").lower()
|
164
|
-
|
168
|
+
is_ok = response.status_code == HTTPStatus.OK
|
169
|
+
is_utf8_text = "text/plain" in content_type and "utf-8" in content_type
|
170
|
+
|
171
|
+
if is_ok and is_utf8_text:
|
165
172
|
try:
|
166
173
|
local_encoding = locale.getpreferredencoding()
|
167
|
-
|
168
|
-
if local_encoding.lower() not in ["utf-8", "utf8"]:
|
174
|
+
if local_encoding.lower() not in {"utf-8", "utf8"}:
|
169
175
|
# Ensure response is decoded as UTF-8 first
|
170
176
|
self.encoding = "utf-8"
|
171
177
|
utf8_text = self.text # This gives us proper Unicode string
|
172
178
|
|
173
|
-
|
174
|
-
|
175
|
-
try:
|
179
|
+
with suppress(UnicodeEncodeError, LookupError):
|
180
|
+
# Convert Unicode to local encoding, then back to Unicode
|
176
181
|
local_bytes = utf8_text.encode(local_encoding, errors="replace")
|
177
182
|
self._converted_text = local_bytes.decode(local_encoding)
|
178
183
|
self._should_convert = True
|
179
|
-
except (UnicodeEncodeError, LookupError):
|
180
|
-
# If local encoding can't handle the characters, fall back to UTF-8
|
181
|
-
self._converted_text = utf8_text
|
182
|
-
self._should_convert = True
|
183
184
|
|
184
185
|
except (UnicodeError, LookupError):
|
185
|
-
# If any conversion fails, keep original UTF-8
|
186
186
|
self.encoding = "utf-8"
|
187
187
|
|
188
188
|
@property
|
@@ -190,14 +190,13 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
190
190
|
"""Return converted text if needed, otherwise original text."""
|
191
191
|
if self._should_convert and self._converted_text is not None:
|
192
192
|
return self._converted_text
|
193
|
-
# return the original text from _response without modification
|
194
193
|
return super().text
|
195
194
|
|
196
195
|
def __init__(
|
197
196
|
self,
|
198
197
|
rest_url: str = "https://127.0.0.1:6060",
|
199
|
-
rest_ssl_verify: Union[bool, str] =
|
200
|
-
rest_ssl_client_cert: Optional[Union[str, Tuple[str, str]]] = (
|
198
|
+
rest_ssl_verify: Union[bool, str] = _DEFAULT_SSL_CA_CERT_PATH,
|
199
|
+
rest_ssl_client_cert: Optional[Union[str, Tuple[str, str]]] = (_DEFAULT_SSL_CLIENT_CERT_PATH, _DEFAULT_SSL_CLIENT_KEY_PATH),
|
201
200
|
rest_timeout: Tuple[float, float] = (60, 300),
|
202
201
|
jwt_token: Optional[str] = None,
|
203
202
|
rest_cookie_file: Optional[str] = None,
|
@@ -206,18 +205,18 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
206
205
|
"""Initialize an App Mesh HTTP client for interacting with the App Mesh server via secure HTTPS.
|
207
206
|
|
208
207
|
Args:
|
209
|
-
rest_url:
|
210
|
-
rest_ssl_verify: SSL verification mode
|
208
|
+
rest_url: The server's base URI. Defaults to "https://127.0.0.1:6060".
|
209
|
+
rest_ssl_verify: SSL server verification mode:
|
211
210
|
- True: Use system CAs.
|
212
211
|
- False: Disable verification (insecure).
|
213
212
|
- str: Path to custom CA or directory. To include system CAs, combine them into one file (e.g., cat custom_ca.pem /etc/ssl/certs/ca-certificates.crt > combined_ca.pem).
|
214
|
-
rest_ssl_client_cert: SSL client certificate:
|
215
|
-
- str:
|
213
|
+
rest_ssl_client_cert: SSL client certificate file(s):
|
214
|
+
- str: Single PEM file with cert+key
|
216
215
|
- tuple: (cert_path, key_path)
|
217
|
-
rest_timeout: Timeouts `(
|
218
|
-
rest_cookie_file:
|
216
|
+
rest_timeout: Timeouts `(connect_timeout, read_timeout)` in seconds. Default `(60, 300)`.
|
217
|
+
rest_cookie_file: Path to a file for storing session cookies (alternative to jwt_token).
|
219
218
|
jwt_token: JWT token for API authentication, overrides cookie file if both provided.
|
220
|
-
auto_refresh_token:
|
219
|
+
auto_refresh_token: Enable automatic token refresh before expiration (supports App Mesh and Keycloak tokens).
|
221
220
|
"""
|
222
221
|
self._ensure_logging_configured()
|
223
222
|
self.auth_server_url = rest_url
|
@@ -230,86 +229,84 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
230
229
|
# Token auto-refresh
|
231
230
|
self._token_refresh_timer = None
|
232
231
|
self._auto_refresh_token = auto_refresh_token
|
233
|
-
self.jwt_token = jwt_token # Set property last after all dependencies are initialized to setup refresh timer
|
234
232
|
|
235
233
|
# Session and cookie management
|
236
234
|
self._lock = threading.Lock()
|
237
235
|
self.session = requests.Session()
|
238
|
-
self.cookie_file =
|
236
|
+
self.cookie_file = rest_cookie_file
|
237
|
+
loaded = self._load_cookies(rest_cookie_file)
|
238
|
+
cookie_token = self._get_cookie_value(self.session.cookies, self._COOKIE_TOKEN) if loaded else None
|
239
|
+
|
240
|
+
# Set property last after all dependencies are initialized to setup refresh timer
|
241
|
+
self.jwt_token = jwt_token or cookie_token
|
239
242
|
|
240
243
|
@staticmethod
|
241
|
-
def _ensure_logging_configured():
|
242
|
-
"""Ensure logging is configured
|
244
|
+
def _ensure_logging_configured() -> None:
|
245
|
+
"""Ensure logging is configured with a default console handler if needed."""
|
243
246
|
if not logging.root.handlers:
|
244
247
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
245
248
|
|
249
|
+
# @abc.abstractmethod
|
246
250
|
def _get_access_token(self) -> str:
|
247
|
-
|
251
|
+
"""Get the current access token."""
|
252
|
+
return self.jwt_token or ""
|
248
253
|
|
249
|
-
def _load_cookies(self, cookie_file: Optional[str]) ->
|
250
|
-
"""Load cookies from
|
254
|
+
def _load_cookies(self, cookie_file: Optional[str]) -> bool:
|
255
|
+
""" "Load cookies from a Mozilla-format file into the session"""
|
251
256
|
if not cookie_file:
|
252
|
-
return
|
257
|
+
return False
|
253
258
|
|
259
|
+
cookie_path = Path(cookie_file)
|
254
260
|
self.session.cookies = cookiejar.MozillaCookieJar(cookie_file)
|
255
|
-
|
261
|
+
|
262
|
+
if cookie_path.exists():
|
256
263
|
self.session.cookies.load(ignore_discard=True, ignore_expires=True)
|
257
|
-
self.jwt_token = self._get_cookie_value(self.session.cookies, self.COOKIE_TOKEN)
|
258
264
|
else:
|
259
|
-
|
265
|
+
cookie_path.parent.mkdir(parents=True, exist_ok=True)
|
260
266
|
self.session.cookies.save(ignore_discard=True, ignore_expires=True)
|
261
267
|
if os.name == "posix":
|
262
|
-
|
263
|
-
|
268
|
+
cookie_path.chmod(0o600) # User read/write only
|
269
|
+
|
270
|
+
return True
|
264
271
|
|
265
272
|
@staticmethod
|
266
|
-
def _get_cookie_value(cookies, name, check_expiry=True) -> Optional[str]:
|
273
|
+
def _get_cookie_value(cookies, name: str, check_expiry: bool = True) -> Optional[str]:
|
267
274
|
"""Get cookie value by name, checking expiry if requested."""
|
268
275
|
# If it's a RequestsCookieJar, use .get() but check expiry manually if requested
|
269
276
|
if hasattr(cookies, "get") and not isinstance(cookies, list):
|
270
277
|
cookie = cookies.get(name)
|
271
278
|
if cookie is None:
|
272
279
|
return None
|
273
|
-
if check_expiry and
|
280
|
+
if check_expiry and hasattr(cookie, "expires") and cookie.expires:
|
274
281
|
if cookie.expires < time.time():
|
275
282
|
return None # expired
|
276
283
|
return cookie.value if hasattr(cookie, "value") else cookie
|
277
284
|
|
278
285
|
# Otherwise, assume it's a MozillaCookieJar — iterate manually
|
279
|
-
for
|
280
|
-
if
|
281
|
-
if check_expiry and
|
282
|
-
if
|
286
|
+
for cookie in cookies:
|
287
|
+
if cookie.name == name:
|
288
|
+
if check_expiry and hasattr(cookie, "expires") and cookie.expires:
|
289
|
+
if cookie.expires < time.time():
|
283
290
|
return None # expired
|
284
|
-
return
|
291
|
+
return cookie.value
|
285
292
|
|
286
293
|
return None
|
287
294
|
|
288
|
-
def _check_and_refresh_token(self):
|
289
|
-
"""Check and refresh token if needed, then schedule next check.
|
290
|
-
|
291
|
-
This method is triggered by the refresh timer and will:
|
292
|
-
1. Check if token needs refresh based on expiration time
|
293
|
-
2. Refresh the token if needed
|
294
|
-
3. Schedule the next refresh check
|
295
|
-
"""
|
295
|
+
def _check_and_refresh_token(self) -> None:
|
296
|
+
"""Check and refresh token if needed, then schedule next check."""
|
296
297
|
if not self.jwt_token:
|
297
298
|
return
|
298
299
|
|
299
|
-
# Check if token needs refresh
|
300
300
|
needs_refresh = True
|
301
301
|
time_to_expiry = float("inf")
|
302
302
|
|
303
303
|
# Check token expiration directly from JWT
|
304
|
-
|
304
|
+
with suppress(Exception):
|
305
305
|
decoded_token = jwt.decode(self._get_access_token(), options={"verify_signature": False})
|
306
306
|
expiry = decoded_token.get("exp", 0)
|
307
307
|
current_time = time.time()
|
308
308
|
time_to_expiry = expiry - current_time
|
309
|
-
|
310
|
-
needs_refresh = time_to_expiry < self.TOKEN_REFRESH_OFFSET
|
311
|
-
except Exception as e:
|
312
|
-
logging.debug("Failed to parse JWT token for expiration check: %s", str(e))
|
309
|
+
needs_refresh = time_to_expiry < self._TOKEN_REFRESH_OFFSET
|
313
310
|
|
314
311
|
# Refresh token if needed
|
315
312
|
if needs_refresh:
|
@@ -317,25 +314,14 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
317
314
|
self.renew_token()
|
318
315
|
logging.info("Token successfully refreshed")
|
319
316
|
except Exception as e:
|
320
|
-
logging.error("Token refresh failed: %s",
|
317
|
+
logging.error("Token refresh failed: %s", e)
|
321
318
|
|
322
319
|
# Schedule next check if auto-refresh is still enabled
|
323
320
|
if self._auto_refresh_token and self.jwt_token:
|
324
321
|
self._schedule_token_refresh(time_to_expiry)
|
325
322
|
|
326
|
-
def _schedule_token_refresh(self, time_to_expiry=None):
|
327
|
-
"""Schedule next token refresh check.
|
328
|
-
|
329
|
-
Args:
|
330
|
-
time_to_expiry (float, optional): Time in seconds until token expiration.
|
331
|
-
When provided, helps calculate optimal refresh timing.
|
332
|
-
|
333
|
-
Calculates appropriate check interval:
|
334
|
-
- If token expires soon (within 5 minutes), refresh immediately
|
335
|
-
- Otherwise schedule refresh for the earlier of:
|
336
|
-
1. 5 minutes before expiration
|
337
|
-
2. 60 seconds from now
|
338
|
-
"""
|
323
|
+
def _schedule_token_refresh(self, time_to_expiry: Optional[float] = None) -> None:
|
324
|
+
"""Schedule next token refresh check."""
|
339
325
|
# Cancel existing timer if any
|
340
326
|
if self._token_refresh_timer:
|
341
327
|
self._token_refresh_timer.cancel()
|
@@ -343,15 +329,15 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
343
329
|
|
344
330
|
try:
|
345
331
|
# Default to checking after 60 seconds
|
346
|
-
check_interval = self.
|
332
|
+
check_interval = self._TOKEN_REFRESH_INTERVAL
|
347
333
|
|
348
334
|
# Calculate more precise check time if expiry is known
|
349
335
|
if time_to_expiry is not None:
|
350
|
-
if time_to_expiry <= self.
|
336
|
+
if time_to_expiry <= self._TOKEN_REFRESH_OFFSET: # Expires within 5 minutes
|
351
337
|
check_interval = 1 # Almost immediate refresh
|
352
338
|
else:
|
353
339
|
# Check at earlier of 5 minutes before expiry or regular interval
|
354
|
-
check_interval = max(1, min(time_to_expiry - self.
|
340
|
+
check_interval = max(1, min(time_to_expiry - self._TOKEN_REFRESH_OFFSET, self._TOKEN_REFRESH_INTERVAL))
|
355
341
|
|
356
342
|
# Create timer to execute refresh check
|
357
343
|
self._token_refresh_timer = threading.Timer(check_interval, self._check_and_refresh_token)
|
@@ -359,23 +345,23 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
359
345
|
self._token_refresh_timer.start()
|
360
346
|
logging.debug("Auto-refresh: Next token check scheduled in %.1f seconds", check_interval)
|
361
347
|
except Exception as e:
|
362
|
-
logging.error("Auto-refresh: Failed to schedule token refresh: %s",
|
348
|
+
logging.error("Auto-refresh: Failed to schedule token refresh: %s", e)
|
363
349
|
|
350
|
+
# @abc.abstractmethod
|
364
351
|
def close(self):
|
365
352
|
"""Close the session and release resources."""
|
366
353
|
# Cancel token refresh timer
|
367
|
-
if
|
354
|
+
if self._token_refresh_timer:
|
368
355
|
self._token_refresh_timer.cancel()
|
369
356
|
self._token_refresh_timer = None
|
370
357
|
|
371
358
|
# Close the session
|
372
|
-
if
|
359
|
+
if self.session:
|
373
360
|
self.session.close()
|
374
361
|
self.session = None
|
375
362
|
|
376
363
|
# Clean token
|
377
|
-
|
378
|
-
self._jwt_token = None
|
364
|
+
self._jwt_token = None
|
379
365
|
|
380
366
|
def __enter__(self):
|
381
367
|
"""Support for context manager protocol."""
|
@@ -385,114 +371,61 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
385
371
|
"""Support for context manager protocol, ensuring resources are released."""
|
386
372
|
self.close()
|
387
373
|
|
388
|
-
def __del__(self):
|
389
|
-
"""Ensure resources are properly released when the object is garbage collected."""
|
390
|
-
try:
|
391
|
-
self.close()
|
392
|
-
except Exception:
|
393
|
-
pass # Never raise in __del__
|
394
|
-
|
395
374
|
@property
|
396
375
|
def jwt_token(self) -> str:
|
397
|
-
"""Get the current JWT (JSON Web Token) used for authentication.
|
398
|
-
|
399
|
-
This property manages the authentication token used for securing API requests.
|
400
|
-
The token is used to authenticate and authorize requests to the service.
|
401
|
-
|
402
|
-
Returns:
|
403
|
-
str: The current JWT token string.
|
404
|
-
Returns empty string if no token is set.
|
405
|
-
|
406
|
-
Notes:
|
407
|
-
- The token typically includes claims for identity and permissions
|
408
|
-
- Token format: "header.payload.signature"
|
409
|
-
- Tokens are time-sensitive and may expire
|
410
|
-
"""
|
411
|
-
return self._jwt_token
|
376
|
+
"""Get the current JWT (JSON Web Token) used for authentication."""
|
377
|
+
return self._jwt_token or ""
|
412
378
|
|
413
379
|
@jwt_token.setter
|
414
|
-
def jwt_token(self, token: str) -> None:
|
380
|
+
def jwt_token(self, token: Optional[str]) -> None:
|
415
381
|
"""Set the JWT token for authentication.
|
416
382
|
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
Args:
|
421
|
-
token (str): JWT token string in standard JWT format
|
422
|
-
(e.g., "eyJhbGci...payload...signature")
|
423
|
-
Pass empty string to clear the token.
|
424
|
-
|
425
|
-
Example:
|
426
|
-
>>> client.jwt_token = "eyJhbGci..." # Set new token
|
427
|
-
>>> client.jwt_token = "" # Clear token
|
428
|
-
|
429
|
-
Notes:
|
430
|
-
Security best practices:
|
431
|
-
- Store tokens securely
|
432
|
-
- Never log or expose complete tokens
|
433
|
-
- Refresh tokens before expiration
|
434
|
-
- Validate token format before setting
|
383
|
+
Note:
|
384
|
+
This setter has no effect when cookie-based authentication is enabled (i.e., when a cookie file is being used).
|
435
385
|
"""
|
436
386
|
if self._jwt_token == token:
|
437
387
|
return # No change
|
438
388
|
self._jwt_token = token
|
439
389
|
|
440
|
-
#
|
390
|
+
# Handle refresh
|
441
391
|
if self._jwt_token and self._auto_refresh_token:
|
442
392
|
self._schedule_token_refresh()
|
443
393
|
elif self._token_refresh_timer:
|
444
394
|
self._token_refresh_timer.cancel()
|
445
395
|
self._token_refresh_timer = None
|
446
396
|
|
447
|
-
#
|
397
|
+
# Handle session persistence
|
448
398
|
with self._lock:
|
449
|
-
if
|
399
|
+
if self.cookie_file:
|
450
400
|
self.session.cookies.save(ignore_discard=True, ignore_expires=True)
|
451
401
|
|
452
402
|
@property
|
453
403
|
def forward_to(self) -> str:
|
454
|
-
"""
|
404
|
+
"""Target host for request forwarding in a cluster.
|
455
405
|
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
2. hostname/IP with port: will use the specified port
|
406
|
+
Supports:
|
407
|
+
- "hostname" or "IP" → uses current service port
|
408
|
+
- "hostname:port" or "IP:port" → uses specified port
|
460
409
|
|
461
410
|
Returns:
|
462
|
-
str:
|
463
|
-
- "hostname" or "IP" (using current service port)
|
464
|
-
- "hostname:port" or "IP:port" (using specified port)
|
465
|
-
Returns empty string if no forwarding host is set.
|
411
|
+
str: Target host (e.g., "node" or "node:6060"), or empty string if unset.
|
466
412
|
|
467
413
|
Notes:
|
468
|
-
For
|
469
|
-
- All nodes must
|
470
|
-
-
|
471
|
-
- When port is omitted, current service port will be used
|
414
|
+
For JWT sharing across the cluster:
|
415
|
+
- All nodes must use the same `JWTSalt` and `Issuer` for JWT settings
|
416
|
+
- If port is omitted, current service port is used
|
472
417
|
"""
|
473
|
-
return self._forward_to
|
418
|
+
return self._forward_to or ""
|
474
419
|
|
475
420
|
@forward_to.setter
|
476
421
|
def forward_to(self, host: str) -> None:
|
477
|
-
"""Set
|
478
|
-
|
479
|
-
Configure the destination host where requests should be forwarded to. This is
|
480
|
-
used in cluster setups for request routing and load distribution.
|
481
|
-
|
482
|
-
Args:
|
483
|
-
host (str): Target host address in one of two formats:
|
484
|
-
1. "hostname" or "IP" - will use current service port
|
485
|
-
(e.g., "backend-node" or "192.168.1.100")
|
486
|
-
2. "hostname:port" or "IP:port" - will use specified port
|
487
|
-
(e.g., "backend-node:6060" or "192.168.1.100:6060")
|
488
|
-
Pass empty string to disable forwarding.
|
422
|
+
"""Set target host for forwarding.
|
489
423
|
|
490
424
|
Examples:
|
491
425
|
>>> client.forward_to = "backend-node:6060" # Use specific port
|
492
426
|
>>> client.forward_to = "backend-node" # Use current service port
|
493
427
|
>>> client.forward_to = None # Disable forwarding
|
494
428
|
"""
|
495
|
-
|
496
429
|
self._forward_to = host
|
497
430
|
|
498
431
|
########################################
|
@@ -503,92 +436,100 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
503
436
|
user_name: str,
|
504
437
|
user_pwd: str,
|
505
438
|
totp_code: Optional[str] = "",
|
506
|
-
timeout_seconds: Union[str, int] =
|
439
|
+
timeout_seconds: Union[str, int] = _DURATION_ONE_WEEK_ISO,
|
507
440
|
audience: Optional[str] = None,
|
508
441
|
) -> str:
|
509
|
-
"""Login with user name and password
|
442
|
+
"""Login with user name and password.
|
510
443
|
|
511
444
|
Args:
|
512
|
-
user_name
|
513
|
-
user_pwd
|
514
|
-
totp_code
|
515
|
-
timeout_seconds
|
516
|
-
audience
|
445
|
+
user_name: The name of the user.
|
446
|
+
user_pwd: The password of the user.
|
447
|
+
totp_code: The TOTP code if enabled for the user.
|
448
|
+
timeout_seconds: Token expire timeout. Supports ISO 8601 durations (e.g., 'P1Y2M3DT4H5M6S' 'P1W').
|
449
|
+
audience: The audience of the JWT token, should be available by JWT service configuration (default is 'appmesh-service').
|
517
450
|
|
518
451
|
Returns:
|
519
|
-
|
452
|
+
JWT token.
|
520
453
|
"""
|
521
454
|
# Standard App Mesh authentication
|
522
455
|
self.jwt_token = None
|
456
|
+
|
457
|
+
credentials = f"{user_name}:{user_pwd}".encode()
|
458
|
+
headers = {
|
459
|
+
self._HTTP_HEADER_KEY_AUTH: f"Basic {base64.b64encode(credentials).decode()}",
|
460
|
+
"X-Expire-Seconds": str(self._parse_duration(timeout_seconds)),
|
461
|
+
}
|
462
|
+
if audience:
|
463
|
+
headers["X-Audience"] = audience
|
464
|
+
if self.cookie_file:
|
465
|
+
headers[self._HTTP_HEADER_JWT_SET_COOKIE] = "true"
|
466
|
+
# if totp_code:
|
467
|
+
# headers["X-Totp-Code"] = totp_code
|
468
|
+
|
523
469
|
resp = self._request_http(
|
524
|
-
AppMeshClient.
|
470
|
+
AppMeshClient._Method.POST,
|
525
471
|
path="/appmesh/login",
|
526
|
-
header=
|
527
|
-
self.HTTP_HEADER_KEY_AUTH: "Basic " + base64.b64encode(f"{user_name}:{user_pwd}".encode()).decode(),
|
528
|
-
"X-Expire-Seconds": str(self._parse_duration(timeout_seconds)),
|
529
|
-
**({"X-Audience": audience} if audience else {}),
|
530
|
-
**({self.HTTP_HEADER_JWT_set_cookie: "true"} if self.cookie_file else {}),
|
531
|
-
# **({"X-Totp-Code": totp_code} if totp_code else {}),
|
532
|
-
},
|
472
|
+
header=headers,
|
533
473
|
)
|
474
|
+
|
534
475
|
if resp.status_code == HTTPStatus.OK:
|
535
476
|
if "access_token" in resp.json():
|
536
477
|
self.jwt_token = resp.json()["access_token"]
|
537
|
-
elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED
|
538
|
-
|
539
|
-
|
478
|
+
elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED:
|
479
|
+
if not totp_code:
|
480
|
+
raise Exception("TOTP code required")
|
481
|
+
if "totp_challenge" in resp.json():
|
482
|
+
challenge = resp.json()["totp_challenge"]
|
483
|
+
self.validate_totp(user_name, challenge, totp_code, timeout_seconds)
|
540
484
|
else:
|
541
485
|
raise Exception(resp.text)
|
542
486
|
|
543
487
|
return self.jwt_token
|
544
488
|
|
545
|
-
def validate_totp(self, username: str, challenge: str, code: str, timeout: Union[int, str] =
|
489
|
+
def validate_totp(self, username: str, challenge: str, code: str, timeout: Union[int, str] = _DURATION_ONE_WEEK_ISO) -> str:
|
546
490
|
"""Validate TOTP challenge and obtain a new JWT token.
|
547
491
|
|
548
492
|
Args:
|
549
|
-
username
|
550
|
-
challenge
|
551
|
-
code
|
552
|
-
timeout
|
553
|
-
Accepts
|
554
|
-
|
493
|
+
username: Username to validate.
|
494
|
+
challenge: Challenge string from server.
|
495
|
+
code: TOTP code to validate.
|
496
|
+
timeout: Token expiration duration, defaults to `_DURATION_ONE_WEEK_ISO` (1 week).
|
497
|
+
Accepts either:
|
498
|
+
- **ISO 8601 duration string** (e.g., `'P1Y2M3DT4H5M6S'`, `'P1W'`)
|
499
|
+
- **Numeric value (seconds)** for simpler cases.
|
555
500
|
|
556
501
|
Returns:
|
557
|
-
|
558
|
-
|
559
|
-
Raises:
|
560
|
-
Exception: If validation fails or server returns error
|
502
|
+
New JWT token if validation succeeds.
|
561
503
|
"""
|
504
|
+
body = {
|
505
|
+
"user_name": username,
|
506
|
+
"totp_code": code,
|
507
|
+
"totp_challenge": challenge,
|
508
|
+
"expire_seconds": self._parse_duration(timeout),
|
509
|
+
}
|
510
|
+
|
511
|
+
headers = {self._HTTP_HEADER_JWT_SET_COOKIE: "true"} if self.cookie_file else {}
|
512
|
+
|
562
513
|
resp = self._request_http(
|
563
|
-
AppMeshClient.
|
514
|
+
AppMeshClient._Method.POST,
|
564
515
|
path="/appmesh/totp/validate",
|
565
|
-
body=
|
566
|
-
|
567
|
-
"totp_code": code,
|
568
|
-
"totp_challenge": challenge,
|
569
|
-
"expire_seconds": self._parse_duration(timeout),
|
570
|
-
},
|
571
|
-
header={self.HTTP_HEADER_JWT_set_cookie: "true"} if self.cookie_file else {},
|
516
|
+
body=body,
|
517
|
+
header=headers,
|
572
518
|
)
|
519
|
+
|
573
520
|
if resp.status_code == HTTPStatus.OK and "access_token" in resp.json():
|
574
521
|
self.jwt_token = resp.json()["access_token"]
|
575
522
|
return self.jwt_token
|
576
523
|
raise Exception(resp.text)
|
577
524
|
|
578
525
|
def logoff(self) -> bool:
|
579
|
-
"""Log out of the current session from the server.
|
580
|
-
|
581
|
-
|
582
|
-
bool: logoff success or failure.
|
583
|
-
"""
|
584
|
-
result = False
|
585
|
-
# Standard App Mesh logout
|
586
|
-
if self.jwt_token and isinstance(self.jwt_token, str):
|
587
|
-
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/self/logoff")
|
588
|
-
self.jwt_token = None
|
589
|
-
result = resp.status_code == HTTPStatus.OK
|
526
|
+
"""Log out of the current session from the server."""
|
527
|
+
if not self.jwt_token or not isinstance(self.jwt_token, str):
|
528
|
+
return False
|
590
529
|
|
591
|
-
|
530
|
+
resp = self._request_http(AppMeshClient._Method.POST, path="/appmesh/self/logoff")
|
531
|
+
self.jwt_token = None
|
532
|
+
return resp.status_code == HTTPStatus.OK
|
592
533
|
|
593
534
|
def authentication(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
|
594
535
|
"""Deprecated: Use authenticate() instead."""
|
@@ -598,197 +539,164 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
598
539
|
"""Authenticate with a token and verify permission if specified.
|
599
540
|
|
600
541
|
Args:
|
601
|
-
token
|
602
|
-
permission
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
audience
|
542
|
+
token: JWT token returned from login().
|
543
|
+
permission: Permission ID to verify the token user.
|
544
|
+
Can be one of:
|
545
|
+
- pre-defined by App Mesh from security.yaml (e.g 'app-view', 'app-delete')
|
546
|
+
- defined by input from role_update() or security.yaml
|
547
|
+
audience: The audience of the JWT token.
|
607
548
|
|
608
549
|
Returns:
|
609
|
-
|
550
|
+
True if authentication succeeds.
|
610
551
|
"""
|
611
552
|
old_token = self.jwt_token
|
612
553
|
self.jwt_token = token
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
554
|
+
|
555
|
+
headers = {}
|
556
|
+
if audience:
|
557
|
+
headers["X-Audience"] = audience
|
558
|
+
if permission:
|
559
|
+
headers["X-Permission"] = permission
|
560
|
+
|
561
|
+
resp = self._request_http(AppMeshClient._Method.POST, path="/appmesh/auth", header=headers)
|
562
|
+
|
618
563
|
if resp.status_code != HTTPStatus.OK:
|
619
564
|
self.jwt_token = old_token
|
620
565
|
raise Exception(resp.text)
|
621
|
-
return resp.status_code == HTTPStatus.OK
|
622
566
|
|
623
|
-
|
567
|
+
return True
|
568
|
+
|
569
|
+
def renew_token(self, timeout: Union[int, str] = _DURATION_ONE_WEEK_ISO) -> str:
|
624
570
|
"""Renew the current token.
|
625
571
|
|
626
572
|
Args:
|
627
|
-
|
573
|
+
timeout: Token expire timeout.
|
628
574
|
|
629
575
|
Returns:
|
630
|
-
|
631
|
-
|
632
|
-
Raises:
|
633
|
-
Exception: If token renewal fails or no token exists to renew
|
576
|
+
The new JWT token.
|
634
577
|
"""
|
635
|
-
# Ensure token exists
|
636
578
|
if not self.jwt_token:
|
637
579
|
raise Exception("No token to renew")
|
638
580
|
|
639
|
-
|
640
|
-
|
641
|
-
if isinstance(self.jwt_token, str):
|
642
|
-
resp = self._request_http(
|
643
|
-
AppMeshClient.Method.POST,
|
644
|
-
path="/appmesh/token/renew",
|
645
|
-
header={"X-Expire-Seconds": str(self._parse_duration(timeout))},
|
646
|
-
)
|
647
|
-
if resp.status_code == HTTPStatus.OK:
|
648
|
-
if "access_token" in resp.json():
|
649
|
-
new_token = resp.json()["access_token"]
|
650
|
-
self.jwt_token = new_token
|
651
|
-
else:
|
652
|
-
raise Exception("Token renewal response missing access_token")
|
653
|
-
else:
|
654
|
-
raise Exception(resp.text)
|
655
|
-
else:
|
656
|
-
raise Exception("Unsupported token format")
|
581
|
+
if not isinstance(self.jwt_token, str):
|
582
|
+
raise Exception("Unsupported token format")
|
657
583
|
|
658
|
-
|
584
|
+
resp = self._request_http(
|
585
|
+
AppMeshClient._Method.POST,
|
586
|
+
path="/appmesh/token/renew",
|
587
|
+
header={"X-Expire-Seconds": str(self._parse_duration(timeout))},
|
588
|
+
)
|
659
589
|
|
660
|
-
|
661
|
-
|
662
|
-
|
590
|
+
if resp.status_code == HTTPStatus.OK:
|
591
|
+
response_data = resp.json()
|
592
|
+
if "access_token" not in response_data:
|
593
|
+
raise Exception("Token renewal response missing access_token")
|
594
|
+
self.jwt_token = response_data["access_token"]
|
595
|
+
else:
|
596
|
+
raise Exception(resp.text)
|
597
|
+
|
598
|
+
return self.jwt_token
|
663
599
|
|
664
600
|
def get_totp_secret(self) -> str:
|
665
|
-
"""
|
666
|
-
|
601
|
+
"""Generate TOTP secret for the current user."""
|
602
|
+
resp = self._request_http(method=AppMeshClient._Method.POST, path="/appmesh/totp/secret")
|
667
603
|
|
668
|
-
|
669
|
-
|
670
|
-
"""
|
671
|
-
resp = self._request_http(method=AppMeshClient.Method.POST, path="/appmesh/totp/secret")
|
672
|
-
if resp.status_code == HTTPStatus.OK:
|
673
|
-
totp_uri = base64.b64decode(resp.json()["mfa_uri"]).decode()
|
674
|
-
return self._parse_totp_uri(totp_uri).get("secret")
|
604
|
+
if resp.status_code != HTTPStatus.OK:
|
605
|
+
raise Exception(resp.text)
|
675
606
|
|
676
|
-
|
607
|
+
totp_uri = base64.b64decode(resp.json()["mfa_uri"]).decode()
|
608
|
+
parsed_uri = self._parse_totp_uri(totp_uri)
|
609
|
+
secret = parsed_uri.get("secret")
|
610
|
+
if secret is None:
|
611
|
+
raise Exception("TOTP URI does not contain a 'secret' field")
|
612
|
+
return secret
|
677
613
|
|
678
614
|
def setup_totp(self, totp_code: str) -> str:
|
679
615
|
"""Set up 2FA for the current user.
|
680
616
|
|
681
617
|
Args:
|
682
|
-
totp_code
|
618
|
+
totp_code: TOTP code.
|
683
619
|
|
684
620
|
Returns:
|
685
|
-
|
621
|
+
The new JWT token if setup succeeds.
|
686
622
|
"""
|
687
623
|
resp = self._request_http(
|
688
|
-
method=AppMeshClient.
|
624
|
+
method=AppMeshClient._Method.POST,
|
689
625
|
path="/appmesh/totp/setup",
|
690
626
|
header={"X-Totp-Code": totp_code},
|
691
627
|
)
|
628
|
+
|
692
629
|
if resp.status_code == HTTPStatus.OK:
|
693
630
|
if "access_token" in resp.json():
|
694
631
|
self.jwt_token = resp.json()["access_token"]
|
695
632
|
return self.jwt_token
|
696
|
-
else:
|
697
|
-
raise Exception(resp.text)
|
698
|
-
|
699
|
-
def disable_totp(self, user: str = "self") -> bool:
|
700
|
-
"""Disable 2FA for the specified user.
|
701
633
|
|
702
|
-
|
703
|
-
user (str, optional): user name for disable TOTP.
|
634
|
+
raise Exception(resp.text)
|
704
635
|
|
705
|
-
|
706
|
-
|
707
|
-
"""
|
636
|
+
def disable_totp(self, user: str = "self") -> None:
|
637
|
+
"""Disable 2FA for the specified user."""
|
708
638
|
resp = self._request_http(
|
709
|
-
method=AppMeshClient.
|
639
|
+
method=AppMeshClient._Method.POST,
|
710
640
|
path=f"/appmesh/totp/{user}/disable",
|
711
641
|
)
|
642
|
+
|
712
643
|
if resp.status_code != HTTPStatus.OK:
|
713
644
|
raise Exception(resp.text)
|
714
|
-
return resp.status_code == HTTPStatus.OK
|
715
645
|
|
716
646
|
@staticmethod
|
717
647
|
def _parse_totp_uri(totp_uri: str) -> dict:
|
718
|
-
"""Extract TOTP parameters
|
719
|
-
|
720
|
-
Args:
|
721
|
-
totp_uri (str): TOTP uri
|
722
|
-
|
723
|
-
Returns:
|
724
|
-
dict: eextract parameters
|
725
|
-
"""
|
648
|
+
"""Extract TOTP parameters from URI."""
|
726
649
|
parsed_info = {}
|
727
650
|
parsed_uri = parse.urlparse(totp_uri)
|
728
651
|
|
729
652
|
# Extract label from the path
|
730
|
-
parsed_info["label"] = parsed_uri.path[1:] # Remove
|
653
|
+
parsed_info["label"] = parsed_uri.path[1:] # Remove leading slash
|
731
654
|
|
732
655
|
# Extract parameters from the query string
|
733
656
|
query_params = parse.parse_qs(parsed_uri.query)
|
734
657
|
for key, value in query_params.items():
|
735
658
|
parsed_info[key] = value[0]
|
659
|
+
|
736
660
|
return parsed_info
|
737
661
|
|
738
662
|
########################################
|
739
663
|
# Application view
|
740
664
|
########################################
|
741
665
|
def view_app(self, app_name: str) -> App:
|
742
|
-
"""Get information about a specific application.
|
743
|
-
|
744
|
-
Args:
|
745
|
-
app_name (str): the application name.
|
746
|
-
|
747
|
-
Returns:
|
748
|
-
App: the application object both contain static configuration and runtime information.
|
666
|
+
"""Get information about a specific application."""
|
667
|
+
resp = self._request_http(AppMeshClient._Method.GET, path=f"/appmesh/app/{app_name}")
|
749
668
|
|
750
|
-
Exception:
|
751
|
-
failed request or no such application
|
752
|
-
"""
|
753
|
-
resp = self._request_http(AppMeshClient.Method.GET, path=f"/appmesh/app/{app_name}")
|
754
669
|
if resp.status_code != HTTPStatus.OK:
|
755
670
|
raise Exception(resp.text)
|
756
|
-
return App(resp.json())
|
757
671
|
|
758
|
-
|
759
|
-
"""Get information about all applications.
|
672
|
+
return App(resp.json())
|
760
673
|
|
761
|
-
|
762
|
-
|
674
|
+
def view_all_apps(self) -> list:
|
675
|
+
"""Get information about all applications."""
|
676
|
+
resp = self._request_http(AppMeshClient._Method.GET, path="/appmesh/applications")
|
763
677
|
|
764
|
-
Exception:
|
765
|
-
failed request or no such application
|
766
|
-
"""
|
767
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/applications")
|
768
678
|
if resp.status_code != HTTPStatus.OK:
|
769
679
|
raise Exception(resp.text)
|
770
|
-
|
771
|
-
for app in resp.json()
|
772
|
-
apps.append(App(app))
|
773
|
-
return apps
|
680
|
+
|
681
|
+
return [App(app) for app in resp.json()]
|
774
682
|
|
775
683
|
def get_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:
|
776
684
|
"""Get the stdout/stderr of an application.
|
777
685
|
|
778
686
|
Args:
|
779
|
-
app_name
|
780
|
-
stdout_position
|
781
|
-
stdout_index
|
687
|
+
app_name: the application name
|
688
|
+
stdout_position: start read position, 0 means start from beginning.
|
689
|
+
stdout_index: index of history process stdout, 0 means get from current running process,
|
782
690
|
the stdout number depends on 'stdout_cache_size' of the application.
|
783
|
-
stdout_maxsize
|
784
|
-
process_uuid
|
785
|
-
timeout
|
691
|
+
stdout_maxsize: max buffer size to read.
|
692
|
+
process_uuid: used to get the specified process.
|
693
|
+
timeout: wait for the running process for some time(seconds) to get the output.
|
786
694
|
|
787
695
|
Returns:
|
788
696
|
AppOutput object.
|
789
697
|
"""
|
790
698
|
resp = self._request_http(
|
791
|
-
AppMeshClient.
|
699
|
+
AppMeshClient._Method.GET,
|
792
700
|
path=f"/appmesh/app/{app_name}/output",
|
793
701
|
query={
|
794
702
|
"stdout_position": str(stdout_position),
|
@@ -798,449 +706,311 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
798
706
|
"timeout": str(timeout),
|
799
707
|
},
|
800
708
|
)
|
709
|
+
|
801
710
|
out_position = int(resp.headers["X-Output-Position"]) if "X-Output-Position" in resp.headers else None
|
802
711
|
exit_code = int(resp.headers["X-Exit-Code"]) if "X-Exit-Code" in resp.headers else None
|
712
|
+
|
803
713
|
return AppOutput(status_code=resp.status_code, output=resp.text, out_position=out_position, exit_code=exit_code)
|
804
714
|
|
805
715
|
def check_app_health(self, app_name: str) -> bool:
|
806
|
-
"""Check the health status of an application.
|
716
|
+
"""Check the health status of an application."""
|
717
|
+
resp = self._request_http(AppMeshClient._Method.GET, path=f"/appmesh/app/{app_name}/health")
|
807
718
|
|
808
|
-
Args:
|
809
|
-
app_name (str): the application name.
|
810
|
-
|
811
|
-
Returns:
|
812
|
-
bool: healthy or not
|
813
|
-
"""
|
814
|
-
resp = self._request_http(AppMeshClient.Method.GET, path=f"/appmesh/app/{app_name}/health")
|
815
719
|
if resp.status_code != HTTPStatus.OK:
|
816
720
|
raise Exception(resp.text)
|
721
|
+
|
817
722
|
return int(resp.text) == 0
|
818
723
|
|
819
724
|
########################################
|
820
725
|
# Application manage
|
821
726
|
########################################
|
822
727
|
def add_app(self, app: App) -> App:
|
823
|
-
|
824
|
-
|
825
|
-
|
826
|
-
app (App): the application definition.
|
827
|
-
|
828
|
-
Returns:
|
829
|
-
App: resigtered application object.
|
728
|
+
# type: (App) -> App
|
729
|
+
"""Register a new application."""
|
730
|
+
resp = self._request_http(AppMeshClient._Method.PUT, path=f"/appmesh/app/{app.name}", body=app.json())
|
830
731
|
|
831
|
-
Exception:
|
832
|
-
failed request
|
833
|
-
"""
|
834
|
-
resp = self._request_http(AppMeshClient.Method.PUT, path=f"/appmesh/app/{app.name}", body=app.json())
|
835
732
|
if resp.status_code != HTTPStatus.OK:
|
836
733
|
raise Exception(resp.text)
|
734
|
+
|
837
735
|
return App(resp.json())
|
838
736
|
|
839
737
|
def delete_app(self, app_name: str) -> bool:
|
840
|
-
"""Remove an application.
|
841
|
-
|
842
|
-
Args:
|
843
|
-
app_name (str): the application name.
|
738
|
+
"""Remove an application."""
|
739
|
+
resp = self._request_http(AppMeshClient._Method.DELETE, path=f"/appmesh/app/{app_name}")
|
844
740
|
|
845
|
-
Returns:
|
846
|
-
bool: True for delete success, Flase for not exist anymore.
|
847
|
-
"""
|
848
|
-
resp = self._request_http(AppMeshClient.Method.DELETE, path=f"/appmesh/app/{app_name}")
|
849
741
|
if resp.status_code == HTTPStatus.OK:
|
850
742
|
return True
|
851
|
-
|
743
|
+
if resp.status_code == HTTPStatus.NOT_FOUND:
|
852
744
|
return False
|
853
|
-
else:
|
854
|
-
raise Exception(resp.text)
|
855
745
|
|
856
|
-
|
857
|
-
"""Enable an application.
|
746
|
+
raise Exception(resp.text)
|
858
747
|
|
859
|
-
|
860
|
-
|
748
|
+
def enable_app(self, app_name: str) -> None:
|
749
|
+
"""Enable an application."""
|
750
|
+
resp = self._request_http(AppMeshClient._Method.POST, path=f"/appmesh/app/{app_name}/enable")
|
861
751
|
|
862
|
-
Returns:
|
863
|
-
bool: success or failure.
|
864
|
-
"""
|
865
|
-
resp = self._request_http(AppMeshClient.Method.POST, path=f"/appmesh/app/{app_name}/enable")
|
866
752
|
if resp.status_code != HTTPStatus.OK:
|
867
753
|
raise Exception(resp.text)
|
868
|
-
return resp.status_code == HTTPStatus.OK
|
869
|
-
|
870
|
-
def disable_app(self, app_name: str) -> bool:
|
871
|
-
"""Disable an application.
|
872
754
|
|
873
|
-
|
874
|
-
|
755
|
+
def disable_app(self, app_name: str) -> None:
|
756
|
+
"""Disable an application."""
|
757
|
+
resp = self._request_http(AppMeshClient._Method.POST, path=f"/appmesh/app/{app_name}/disable")
|
875
758
|
|
876
|
-
Returns:
|
877
|
-
bool: success or failure.
|
878
|
-
"""
|
879
|
-
resp = self._request_http(AppMeshClient.Method.POST, path=f"/appmesh/app/{app_name}/disable")
|
880
759
|
if resp.status_code != HTTPStatus.OK:
|
881
760
|
raise Exception(resp.text)
|
882
|
-
return resp.status_code == HTTPStatus.OK
|
883
761
|
|
884
762
|
########################################
|
885
763
|
# Configuration
|
886
764
|
########################################
|
887
765
|
def view_host_resources(self) -> dict:
|
888
|
-
"""Get a report of host resources including CPU, memory, and disk.
|
766
|
+
"""Get a report of host resources including CPU, memory, and disk."""
|
767
|
+
resp = self._request_http(AppMeshClient._Method.GET, path="/appmesh/resources")
|
889
768
|
|
890
|
-
Returns:
|
891
|
-
dict: the host resource json.
|
892
|
-
"""
|
893
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/resources")
|
894
769
|
if resp.status_code != HTTPStatus.OK:
|
895
770
|
raise Exception(resp.text)
|
771
|
+
|
896
772
|
return resp.json()
|
897
773
|
|
898
774
|
def view_config(self) -> dict:
|
899
|
-
"""Get the App Mesh configuration in JSON format.
|
775
|
+
"""Get the App Mesh configuration in JSON format."""
|
776
|
+
resp = self._request_http(AppMeshClient._Method.GET, path="/appmesh/config")
|
900
777
|
|
901
|
-
Returns:
|
902
|
-
dict: the configuration json.
|
903
|
-
"""
|
904
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/config")
|
905
778
|
if resp.status_code != HTTPStatus.OK:
|
906
779
|
raise Exception(resp.text)
|
780
|
+
|
907
781
|
return resp.json()
|
908
782
|
|
909
783
|
def set_config(self, config_json: dict) -> dict:
|
910
|
-
"""Update the configuration.
|
784
|
+
"""Update the configuration."""
|
785
|
+
resp = self._request_http(AppMeshClient._Method.POST, path="/appmesh/config", body=config_json)
|
911
786
|
|
912
|
-
Args:
|
913
|
-
cfg_json (dict): the new configuration json.
|
914
|
-
|
915
|
-
Returns:
|
916
|
-
dict: the updated configuration json.
|
917
|
-
"""
|
918
|
-
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/config", body=config_json)
|
919
787
|
if resp.status_code != HTTPStatus.OK:
|
920
788
|
raise Exception(resp.text)
|
789
|
+
|
921
790
|
return resp.json()
|
922
791
|
|
923
792
|
def set_log_level(self, level: str = "DEBUG") -> str:
|
924
|
-
"""Update the log level.
|
793
|
+
"""Update the log level."""
|
794
|
+
resp = self._request_http(AppMeshClient._Method.POST, path="/appmesh/config", body={"BaseConfig": {"LogLevel": level}})
|
925
795
|
|
926
|
-
Args:
|
927
|
-
level (str, optional): log level.
|
928
|
-
|
929
|
-
Returns:
|
930
|
-
str: the updated log level.
|
931
|
-
"""
|
932
|
-
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/config", body={"BaseConfig": {"LogLevel": level}})
|
933
796
|
if resp.status_code != HTTPStatus.OK:
|
934
797
|
raise Exception(resp.text)
|
798
|
+
|
935
799
|
return resp.json()["BaseConfig"]["LogLevel"]
|
936
800
|
|
937
801
|
########################################
|
938
802
|
# User Management
|
939
803
|
########################################
|
940
|
-
def update_user_password(self, old_password: str, new_password: str, user_name: str = "self") ->
|
941
|
-
"""Change the password of a user.
|
942
|
-
|
943
|
-
|
944
|
-
|
945
|
-
|
946
|
-
new_password (str):the new password string.
|
804
|
+
def update_user_password(self, old_password: str, new_password: str, user_name: str = "self") -> None:
|
805
|
+
"""Change the password of a user."""
|
806
|
+
body = {
|
807
|
+
"old_password": base64.b64encode(old_password.encode()).decode(),
|
808
|
+
"new_password": base64.b64encode(new_password.encode()).decode(),
|
809
|
+
}
|
947
810
|
|
948
|
-
Returns:
|
949
|
-
bool: success
|
950
|
-
"""
|
951
811
|
resp = self._request_http(
|
952
|
-
method=AppMeshClient.
|
812
|
+
method=AppMeshClient._Method.POST,
|
953
813
|
path=f"/appmesh/user/{user_name}/passwd",
|
954
|
-
body=
|
955
|
-
"old_password": base64.b64encode(old_password.encode()).decode(),
|
956
|
-
"new_password": base64.b64encode(new_password.encode()).decode(),
|
957
|
-
},
|
814
|
+
body=body,
|
958
815
|
)
|
816
|
+
|
959
817
|
if resp.status_code != HTTPStatus.OK:
|
960
818
|
raise Exception(resp.text)
|
961
|
-
return True
|
962
819
|
|
963
|
-
def add_user(self, user_name: str, user_json: dict) ->
|
964
|
-
"""Add a new user.
|
965
|
-
|
966
|
-
Args:
|
967
|
-
user_name (str): the user name.
|
968
|
-
user_json (dict): user definition, follow same user format from security.yaml.
|
969
|
-
|
970
|
-
Returns:
|
971
|
-
bool: success or failure.
|
972
|
-
"""
|
820
|
+
def add_user(self, user_name: str, user_json: dict) -> None:
|
821
|
+
"""Add a new user."""
|
973
822
|
resp = self._request_http(
|
974
|
-
method=AppMeshClient.
|
823
|
+
method=AppMeshClient._Method.PUT,
|
975
824
|
path=f"/appmesh/user/{user_name}",
|
976
825
|
body=user_json,
|
977
826
|
)
|
978
|
-
|
979
|
-
|
980
|
-
def delete_user(self, user_name: str) -> bool:
|
981
|
-
"""Delete a user.
|
982
|
-
|
983
|
-
Args:
|
984
|
-
user_name (str): the user name.
|
827
|
+
if resp.status_code != HTTPStatus.OK:
|
828
|
+
raise Exception(resp.text)
|
985
829
|
|
986
|
-
|
987
|
-
|
988
|
-
"""
|
830
|
+
def delete_user(self, user_name: str):
|
831
|
+
"""Delete a user."""
|
989
832
|
resp = self._request_http(
|
990
|
-
method=AppMeshClient.
|
833
|
+
method=AppMeshClient._Method.DELETE,
|
991
834
|
path=f"/appmesh/user/{user_name}",
|
992
835
|
)
|
993
|
-
|
994
|
-
|
995
|
-
def lock_user(self, user_name: str) -> bool:
|
996
|
-
"""Lock a user.
|
997
|
-
|
998
|
-
Args:
|
999
|
-
user_name (str): the user name.
|
836
|
+
if resp.status_code != HTTPStatus.OK:
|
837
|
+
raise Exception(resp.text)
|
1000
838
|
|
1001
|
-
|
1002
|
-
|
1003
|
-
"""
|
839
|
+
def lock_user(self, user_name: str) -> None:
|
840
|
+
"""Lock a user."""
|
1004
841
|
resp = self._request_http(
|
1005
|
-
method=AppMeshClient.
|
842
|
+
method=AppMeshClient._Method.POST,
|
1006
843
|
path=f"/appmesh/user/{user_name}/lock",
|
1007
844
|
)
|
845
|
+
|
1008
846
|
if resp.status_code != HTTPStatus.OK:
|
1009
847
|
raise Exception(resp.text)
|
1010
|
-
return resp.status_code == HTTPStatus.OK
|
1011
|
-
|
1012
|
-
def unlock_user(self, user_name: str) -> bool:
|
1013
|
-
"""Unlock a user.
|
1014
|
-
|
1015
|
-
Args:
|
1016
|
-
user_name (str): the user name.
|
1017
848
|
|
1018
|
-
|
1019
|
-
|
1020
|
-
"""
|
849
|
+
def unlock_user(self, user_name: str) -> None:
|
850
|
+
"""Unlock a user."""
|
1021
851
|
resp = self._request_http(
|
1022
|
-
method=AppMeshClient.
|
852
|
+
method=AppMeshClient._Method.POST,
|
1023
853
|
path=f"/appmesh/user/{user_name}/unlock",
|
1024
854
|
)
|
855
|
+
|
1025
856
|
if resp.status_code != HTTPStatus.OK:
|
1026
857
|
raise Exception(resp.text)
|
1027
|
-
return resp.status_code == HTTPStatus.OK
|
1028
858
|
|
1029
859
|
def view_users(self) -> dict:
|
1030
|
-
"""Get information about all users.
|
860
|
+
"""Get information about all users."""
|
861
|
+
resp = self._request_http(method=AppMeshClient._Method.GET, path="/appmesh/users")
|
1031
862
|
|
1032
|
-
Returns:
|
1033
|
-
dict: all user definition
|
1034
|
-
"""
|
1035
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/users")
|
1036
863
|
if resp.status_code != HTTPStatus.OK:
|
1037
864
|
raise Exception(resp.text)
|
865
|
+
|
1038
866
|
return resp.json()
|
1039
867
|
|
1040
868
|
def view_self(self) -> dict:
|
1041
|
-
"""Get information about the current user.
|
869
|
+
"""Get information about the current user."""
|
870
|
+
resp = self._request_http(method=AppMeshClient._Method.GET, path="/appmesh/user/self")
|
1042
871
|
|
1043
|
-
Returns:
|
1044
|
-
dict: user definition.
|
1045
|
-
"""
|
1046
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/self")
|
1047
872
|
if resp.status_code != HTTPStatus.OK:
|
1048
873
|
raise Exception(resp.text)
|
874
|
+
|
1049
875
|
return resp.json()
|
1050
876
|
|
1051
877
|
def view_groups(self) -> list:
|
1052
|
-
"""Get information about all user groups.
|
878
|
+
"""Get information about all user groups."""
|
879
|
+
resp = self._request_http(method=AppMeshClient._Method.GET, path="/appmesh/user/groups")
|
1053
880
|
|
1054
|
-
Returns:
|
1055
|
-
dict: user group array.
|
1056
|
-
"""
|
1057
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/groups")
|
1058
881
|
if resp.status_code != HTTPStatus.OK:
|
1059
882
|
raise Exception(resp.text)
|
883
|
+
|
1060
884
|
return resp.json()
|
1061
885
|
|
1062
886
|
def view_permissions(self) -> list:
|
1063
|
-
"""Get information about all available permissions.
|
887
|
+
"""Get information about all available permissions."""
|
888
|
+
resp = self._request_http(method=AppMeshClient._Method.GET, path="/appmesh/permissions")
|
1064
889
|
|
1065
|
-
Returns:
|
1066
|
-
dict: permission array
|
1067
|
-
"""
|
1068
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/permissions")
|
1069
890
|
if resp.status_code != HTTPStatus.OK:
|
1070
891
|
raise Exception(resp.text)
|
892
|
+
|
1071
893
|
return resp.json()
|
1072
894
|
|
1073
895
|
def view_user_permissions(self) -> list:
|
1074
|
-
"""Get information about the permissions of the current user.
|
896
|
+
"""Get information about the permissions of the current user."""
|
897
|
+
resp = self._request_http(method=AppMeshClient._Method.GET, path="/appmesh/user/permissions")
|
1075
898
|
|
1076
|
-
Returns:
|
1077
|
-
dict: user permission array.
|
1078
|
-
"""
|
1079
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/permissions")
|
1080
899
|
if resp.status_code != HTTPStatus.OK:
|
1081
900
|
raise Exception(resp.text)
|
901
|
+
|
1082
902
|
return resp.json()
|
1083
903
|
|
1084
904
|
def view_roles(self) -> list:
|
1085
|
-
"""Get information about all roles with permission definitions.
|
905
|
+
"""Get information about all roles with permission definitions."""
|
906
|
+
resp = self._request_http(method=AppMeshClient._Method.GET, path="/appmesh/roles")
|
1086
907
|
|
1087
|
-
Returns:
|
1088
|
-
dict: all role definition.
|
1089
|
-
"""
|
1090
|
-
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/roles")
|
1091
908
|
if resp.status_code != HTTPStatus.OK:
|
1092
909
|
raise Exception(resp.text)
|
1093
|
-
return resp.json()
|
1094
910
|
|
1095
|
-
|
1096
|
-
"""Update or add a role with defined permissions.
|
911
|
+
return resp.json()
|
1097
912
|
|
1098
|
-
|
1099
|
-
|
1100
|
-
|
913
|
+
def update_role(self, role_name: str, role_permission_json: dict) -> None:
|
914
|
+
"""Update or add a role with defined permissions."""
|
915
|
+
resp = self._request_http(method=AppMeshClient._Method.POST, path=f"/appmesh/role/{role_name}", body=role_permission_json)
|
1101
916
|
|
1102
|
-
Returns:
|
1103
|
-
bool: success or failure.
|
1104
|
-
"""
|
1105
|
-
resp = self._request_http(method=AppMeshClient.Method.POST, path=f"/appmesh/role/{role_name}", body=role_permission_json)
|
1106
917
|
if resp.status_code != HTTPStatus.OK:
|
1107
918
|
raise Exception(resp.text)
|
1108
|
-
return resp.status_code == HTTPStatus.OK
|
1109
|
-
|
1110
|
-
def delete_role(self, role_name: str) -> bool:
|
1111
|
-
"""Delete a user role.
|
1112
|
-
|
1113
|
-
Args:
|
1114
|
-
role_name (str): the role name.
|
1115
919
|
|
1116
|
-
|
1117
|
-
|
1118
|
-
"""
|
920
|
+
def delete_role(self, role_name: str) -> None:
|
921
|
+
"""Delete a user role."""
|
1119
922
|
resp = self._request_http(
|
1120
|
-
method=AppMeshClient.
|
923
|
+
method=AppMeshClient._Method.DELETE,
|
1121
924
|
path=f"/appmesh/role/{role_name}",
|
1122
925
|
)
|
926
|
+
|
1123
927
|
if resp.status_code != HTTPStatus.OK:
|
1124
928
|
raise Exception(resp.text)
|
1125
|
-
return resp.status_code == HTTPStatus.OK
|
1126
929
|
|
1127
930
|
########################################
|
1128
931
|
# Tag management
|
1129
932
|
########################################
|
1130
|
-
def add_tag(self, tag_name: str, tag_value: str) ->
|
1131
|
-
"""Add a new label.
|
1132
|
-
|
1133
|
-
Args:
|
1134
|
-
tag_name (str): the label name.
|
1135
|
-
tag_value (str): the label value.
|
1136
|
-
|
1137
|
-
Returns:
|
1138
|
-
bool: success or failure.
|
1139
|
-
"""
|
933
|
+
def add_tag(self, tag_name: str, tag_value: str) -> None:
|
934
|
+
"""Add a new label."""
|
1140
935
|
resp = self._request_http(
|
1141
|
-
AppMeshClient.
|
936
|
+
AppMeshClient._Method.PUT,
|
1142
937
|
query={"value": tag_value},
|
1143
938
|
path=f"/appmesh/label/{tag_name}",
|
1144
939
|
)
|
940
|
+
|
1145
941
|
if resp.status_code != HTTPStatus.OK:
|
1146
942
|
raise Exception(resp.text)
|
1147
|
-
return resp.status_code == HTTPStatus.OK
|
1148
943
|
|
1149
|
-
def delete_tag(self, tag_name: str) ->
|
1150
|
-
"""Delete a label.
|
944
|
+
def delete_tag(self, tag_name: str) -> None:
|
945
|
+
"""Delete a label."""
|
946
|
+
resp = self._request_http(AppMeshClient._Method.DELETE, path=f"/appmesh/label/{tag_name}")
|
1151
947
|
|
1152
|
-
Args:
|
1153
|
-
tag_name (str): the label name.
|
1154
|
-
|
1155
|
-
Returns:
|
1156
|
-
bool: success or failure.
|
1157
|
-
"""
|
1158
|
-
resp = self._request_http(AppMeshClient.Method.DELETE, path=f"/appmesh/label/{tag_name}")
|
1159
948
|
if resp.status_code != HTTPStatus.OK:
|
1160
949
|
raise Exception(resp.text)
|
1161
|
-
return resp.status_code == HTTPStatus.OK
|
1162
950
|
|
1163
951
|
def view_tags(self) -> dict:
|
1164
|
-
"""Get information about all labels.
|
952
|
+
"""Get information about all labels."""
|
953
|
+
resp = self._request_http(AppMeshClient._Method.GET, path="/appmesh/labels")
|
1165
954
|
|
1166
|
-
Returns:
|
1167
|
-
dict: label data.
|
1168
|
-
"""
|
1169
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/labels")
|
1170
955
|
if resp.status_code != HTTPStatus.OK:
|
1171
956
|
raise Exception(resp.text)
|
957
|
+
|
1172
958
|
return resp.json()
|
1173
959
|
|
1174
960
|
########################################
|
1175
|
-
#
|
961
|
+
# Prometheus metrics
|
1176
962
|
########################################
|
1177
|
-
def get_metrics(self):
|
1178
|
-
"""Get Prometheus metrics.
|
963
|
+
def get_metrics(self) -> str:
|
964
|
+
"""Get Prometheus metrics."""
|
965
|
+
resp = self._request_http(AppMeshClient._Method.GET, path="/appmesh/metrics")
|
1179
966
|
|
1180
|
-
Returns:
|
1181
|
-
str: prometheus metrics texts
|
1182
|
-
"""
|
1183
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/metrics")
|
1184
967
|
if resp.status_code != HTTPStatus.OK:
|
1185
968
|
raise Exception(resp.text)
|
969
|
+
|
1186
970
|
return resp.text
|
1187
971
|
|
1188
972
|
########################################
|
1189
973
|
# File management
|
1190
974
|
########################################
|
1191
975
|
def download_file(self, remote_file: str, local_file: str, preserve_permissions: bool = True) -> None:
|
1192
|
-
"""Download a remote file to the local system.
|
1193
|
-
|
1194
|
-
Args:
|
1195
|
-
remote_file (str): the remote file path.
|
1196
|
-
local_file (str): the local file path to be downloaded.
|
1197
|
-
preserve_permissions (bool): whether to apply file attributes (permissions, owner, group) to the local file.
|
1198
|
-
"""
|
1199
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={self.HTTP_HEADER_KEY_X_FILE_PATH: remote_file})
|
976
|
+
"""Download a remote file to the local system."""
|
977
|
+
resp = self._request_http(AppMeshClient._Method.GET, path="/appmesh/file/download", header={self._HTTP_HEADER_KEY_X_FILE_PATH: remote_file})
|
1200
978
|
resp.raise_for_status()
|
1201
979
|
|
1202
980
|
# Write the file content locally
|
1203
|
-
|
1204
|
-
|
981
|
+
local_path = Path(local_file)
|
982
|
+
with local_path.open("wb") as fp:
|
983
|
+
for chunk in resp.iter_content(chunk_size=8 * 1024):
|
1205
984
|
if chunk:
|
1206
985
|
fp.write(chunk)
|
1207
986
|
|
1208
987
|
# Apply file attributes (permissions, owner, group) if requested
|
1209
988
|
if preserve_permissions and sys.platform != "win32":
|
1210
989
|
if "X-File-Mode" in resp.headers:
|
1211
|
-
|
990
|
+
local_path.chmod(int(resp.headers["X-File-Mode"]))
|
991
|
+
|
1212
992
|
if "X-File-User" in resp.headers and "X-File-Group" in resp.headers:
|
1213
993
|
file_uid = int(resp.headers["X-File-User"])
|
1214
994
|
file_gid = int(resp.headers["X-File-Group"])
|
1215
|
-
|
995
|
+
with suppress(PermissionError):
|
1216
996
|
os.chown(path=local_file, uid=file_uid, gid=file_gid)
|
1217
|
-
except PermissionError:
|
1218
|
-
logging.warning(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
|
1219
997
|
|
1220
998
|
def upload_file(self, local_file: str, remote_file: str, preserve_permissions: bool = True) -> None:
|
1221
|
-
"""Upload a local file to the remote server.
|
1222
|
-
|
1223
|
-
|
1224
|
-
sudo apt install python3-pip
|
1225
|
-
pip3 install requests_toolbelt
|
1226
|
-
|
1227
|
-
Args:
|
1228
|
-
local_file (str): the local file path.
|
1229
|
-
remote_file (str): the target remote file to be uploaded.
|
1230
|
-
preserve_permissions (bool): whether to upload file attributes (permissions, owner, group) along with the file.
|
1231
|
-
"""
|
1232
|
-
if not os.path.exists(local_file):
|
999
|
+
"""Upload a local file to the remote server."""
|
1000
|
+
local_path = Path(local_file)
|
1001
|
+
if not local_path.exists():
|
1233
1002
|
raise FileNotFoundError(f"Local file not found: {local_file}")
|
1234
1003
|
|
1235
1004
|
from requests_toolbelt import MultipartEncoder
|
1236
1005
|
|
1237
|
-
with open(
|
1006
|
+
with local_path.open("rb") as fp:
|
1238
1007
|
encoder = MultipartEncoder(fields={"filename": os.path.basename(remote_file), "file": ("filename", fp, "application/octet-stream")})
|
1239
|
-
header = {self.HTTP_HEADER_KEY_X_FILE_PATH: parse.quote(remote_file), "Content-Type": encoder.content_type}
|
1240
1008
|
|
1241
|
-
|
1009
|
+
header = {self._HTTP_HEADER_KEY_X_FILE_PATH: parse.quote(remote_file), "Content-Type": encoder.content_type}
|
1010
|
+
|
1011
|
+
# Include file attributes if requested
|
1242
1012
|
if preserve_permissions:
|
1243
|
-
file_stat =
|
1013
|
+
file_stat = local_path.stat()
|
1244
1014
|
header["X-File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
1245
1015
|
header["X-File-User"] = str(file_stat.st_uid)
|
1246
1016
|
header["X-File-Group"] = str(file_stat.st_gid)
|
@@ -1248,7 +1018,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1248
1018
|
# Upload file with or without attributes
|
1249
1019
|
# https://stackoverflow.com/questions/22567306/python-requests-file-upload
|
1250
1020
|
resp = self._request_http(
|
1251
|
-
AppMeshClient.
|
1021
|
+
AppMeshClient._Method.POST_STREAM,
|
1252
1022
|
path="/appmesh/file/upload",
|
1253
1023
|
header=header,
|
1254
1024
|
body=encoder,
|
@@ -1258,13 +1028,14 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1258
1028
|
########################################
|
1259
1029
|
# Application run
|
1260
1030
|
########################################
|
1261
|
-
|
1031
|
+
@staticmethod
|
1032
|
+
def _parse_duration(timeout: Union[int, str]) -> int:
|
1033
|
+
"""Parse duration from int or ISO 8601 string."""
|
1262
1034
|
if isinstance(timeout, int):
|
1263
1035
|
return timeout
|
1264
|
-
|
1036
|
+
if isinstance(timeout, str):
|
1265
1037
|
return int(aniso8601.parse_duration(timeout).total_seconds())
|
1266
|
-
|
1267
|
-
raise TypeError(f"Invalid timeout type: {str(timeout)}")
|
1038
|
+
raise TypeError(f"Invalid timeout type: {timeout}")
|
1268
1039
|
|
1269
1040
|
def run_task(self, app_name: str, data: str, timeout: int = 300) -> str:
|
1270
1041
|
"""Client send an invocation message to a running App Mesh application and wait for result.
|
@@ -1273,20 +1044,20 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1273
1044
|
forward it to the specified running application instance.
|
1274
1045
|
|
1275
1046
|
Args:
|
1276
|
-
app_name
|
1277
|
-
data
|
1278
|
-
timeout
|
1047
|
+
app_name: Name of the target application (as registered in App Mesh).
|
1048
|
+
data: Payload to deliver to the application. Typically a string.
|
1049
|
+
timeout: Maximum time in seconds to wait for a response from the application. Defaults to 60 seconds.
|
1279
1050
|
|
1280
1051
|
Returns:
|
1281
1052
|
str: The HTTP response body returned by the remote application/service.
|
1282
1053
|
"""
|
1283
|
-
path = f"/appmesh/app/{app_name}/task"
|
1284
1054
|
resp = self._request_http(
|
1285
|
-
AppMeshClient.
|
1286
|
-
path=
|
1055
|
+
AppMeshClient._Method.POST,
|
1056
|
+
path=f"/appmesh/app/{app_name}/task",
|
1287
1057
|
body=data,
|
1288
1058
|
query={"timeout": str(timeout)},
|
1289
1059
|
)
|
1060
|
+
|
1290
1061
|
if resp.status_code != HTTPStatus.OK:
|
1291
1062
|
raise Exception(resp.text)
|
1292
1063
|
|
@@ -1296,36 +1067,35 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1296
1067
|
"""Client cancle a running task to a App Mesh application.
|
1297
1068
|
|
1298
1069
|
Args:
|
1299
|
-
app_name
|
1070
|
+
app_name: Name of the target application (as registered in App Mesh).
|
1300
1071
|
|
1301
1072
|
Returns:
|
1302
1073
|
bool: Task exist and cancled status.
|
1303
1074
|
"""
|
1304
|
-
path = f"/appmesh/app/{app_name}/task"
|
1305
1075
|
resp = self._request_http(
|
1306
|
-
AppMeshClient.
|
1307
|
-
path=
|
1076
|
+
AppMeshClient._Method.DELETE,
|
1077
|
+
path=f"/appmesh/app/{app_name}/task",
|
1308
1078
|
)
|
1309
1079
|
return resp.status_code == HTTPStatus.OK
|
1310
1080
|
|
1311
1081
|
def run_app_async(
|
1312
1082
|
self,
|
1313
1083
|
app: Union[App, str],
|
1314
|
-
max_time_seconds: Union[int, str] =
|
1315
|
-
life_cycle_seconds: Union[int, str] =
|
1084
|
+
max_time_seconds: Union[int, str] = _DURATION_TWO_DAYS_ISO,
|
1085
|
+
life_cycle_seconds: Union[int, str] = _DURATION_TWO_DAYS_HALF_ISO,
|
1316
1086
|
) -> AppRun:
|
1317
1087
|
"""Run an application asynchronously on a remote system without blocking the API.
|
1318
1088
|
|
1319
1089
|
Args:
|
1320
|
-
app
|
1090
|
+
app: An `App` instance or a shell command string.
|
1321
1091
|
- If `app` is a string, it is treated as a shell command for the remote run,
|
1322
1092
|
and an `App` instance is created as:
|
1323
1093
|
`App({"command": "<command_string>", "shell": True})`.
|
1324
1094
|
- If `app` is an `App` object, providing only the `name` attribute (without
|
1325
1095
|
a command) will run an existing application; otherwise, it is treated as a new application.
|
1326
|
-
max_time_seconds
|
1096
|
+
max_time_seconds: Maximum runtime for the remote process.
|
1327
1097
|
Accepts ISO 8601 duration format (e.g., 'P1Y2M3DT4H5M6S', 'P5W'). Defaults to `P2D`.
|
1328
|
-
life_cycle_seconds
|
1098
|
+
life_cycle_seconds: Maximum lifecycle time for the remote process.
|
1329
1099
|
Accepts ISO 8601 duration format. Defaults to `P2DT12H`.
|
1330
1100
|
|
1331
1101
|
Returns:
|
@@ -1334,64 +1104,71 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1334
1104
|
if isinstance(app, str):
|
1335
1105
|
app = App({"command": app, "shell": True})
|
1336
1106
|
|
1337
|
-
path = "/appmesh/app/run"
|
1338
1107
|
resp = self._request_http(
|
1339
|
-
AppMeshClient.
|
1108
|
+
AppMeshClient._Method.POST,
|
1340
1109
|
body=app.json(),
|
1341
|
-
path=
|
1110
|
+
path="/appmesh/app/run",
|
1342
1111
|
query={
|
1343
1112
|
"timeout": str(self._parse_duration(max_time_seconds)),
|
1344
1113
|
"lifecycle": str(self._parse_duration(life_cycle_seconds)),
|
1345
1114
|
},
|
1346
1115
|
)
|
1116
|
+
|
1347
1117
|
if resp.status_code != HTTPStatus.OK:
|
1348
1118
|
raise Exception(resp.text)
|
1349
1119
|
|
1350
|
-
|
1351
|
-
return AppRun(self,
|
1120
|
+
response_data = resp.json()
|
1121
|
+
return AppRun(self, response_data["name"], response_data["process_uuid"])
|
1352
1122
|
|
1353
|
-
def wait_for_async_run(self, run: AppRun, stdout_print: bool = True, timeout: int = 0) -> int:
|
1123
|
+
def wait_for_async_run(self, run: AppRun, stdout_print: bool = True, timeout: int = 0) -> Optional[int]:
|
1354
1124
|
"""Wait for an asynchronous run to finish.
|
1355
1125
|
|
1356
1126
|
Args:
|
1357
|
-
run
|
1358
|
-
stdout_print
|
1359
|
-
timeout
|
1127
|
+
run: asyncrized run result from run_async().
|
1128
|
+
stdout_print: print remote stdout to local or not.
|
1129
|
+
timeout : wait max timeout seconds and return if not finished, 0 means wait until finished
|
1360
1130
|
|
1361
1131
|
Returns:
|
1362
|
-
|
1132
|
+
return exit code if process finished, return None for timeout or exception.
|
1363
1133
|
"""
|
1364
|
-
if run:
|
1365
|
-
|
1366
|
-
|
1367
|
-
|
1368
|
-
|
1369
|
-
|
1370
|
-
|
1371
|
-
|
1372
|
-
|
1373
|
-
|
1374
|
-
|
1375
|
-
|
1376
|
-
|
1377
|
-
|
1378
|
-
|
1379
|
-
|
1380
|
-
|
1381
|
-
|
1382
|
-
|
1383
|
-
|
1384
|
-
|
1385
|
-
|
1386
|
-
|
1134
|
+
if not run:
|
1135
|
+
return None
|
1136
|
+
|
1137
|
+
last_output_position = 0
|
1138
|
+
start = datetime.now()
|
1139
|
+
interval = 1 if self.__class__.__name__ == "AppMeshClient" else 1000
|
1140
|
+
|
1141
|
+
while run.proc_uid:
|
1142
|
+
app_out = self.get_app_output(app_name=run.app_name, stdout_position=last_output_position, stdout_index=0, process_uuid=run.proc_uid, timeout=interval)
|
1143
|
+
|
1144
|
+
if app_out.output and stdout_print:
|
1145
|
+
print(app_out.output, end="", flush=True)
|
1146
|
+
|
1147
|
+
if app_out.out_position is not None:
|
1148
|
+
last_output_position = app_out.out_position
|
1149
|
+
|
1150
|
+
if app_out.exit_code is not None:
|
1151
|
+
# success
|
1152
|
+
with suppress(Exception):
|
1153
|
+
self.delete_app(run.app_name)
|
1154
|
+
return app_out.exit_code
|
1155
|
+
|
1156
|
+
if app_out.status_code != HTTPStatus.OK:
|
1157
|
+
# failed
|
1158
|
+
break
|
1159
|
+
|
1160
|
+
if timeout > 0 and (datetime.now() - start).seconds > timeout:
|
1161
|
+
# timeout
|
1162
|
+
break
|
1163
|
+
|
1387
1164
|
return None
|
1388
1165
|
|
1389
1166
|
def run_app_sync(
|
1390
1167
|
self,
|
1391
1168
|
app: Union[App, str],
|
1392
1169
|
stdout_print: bool = True,
|
1393
|
-
max_time_seconds: Union[int, str] =
|
1394
|
-
life_cycle_seconds: Union[int, str] =
|
1170
|
+
max_time_seconds: Union[int, str] = _DURATION_TWO_DAYS_ISO,
|
1171
|
+
life_cycle_seconds: Union[int, str] = _DURATION_TWO_DAYS_HALF_ISO,
|
1395
1172
|
) -> Tuple[Union[int, None], str]:
|
1396
1173
|
"""Synchronously run an application remotely, blocking until completion, and return the result.
|
1397
1174
|
|
@@ -1399,101 +1176,98 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1399
1176
|
If 'app' is App object, the name attribute is used to run an existing application if specified.
|
1400
1177
|
|
1401
1178
|
Args:
|
1402
|
-
app
|
1179
|
+
app: An App instance or a shell command string.
|
1403
1180
|
If a string, an App instance is created as:
|
1404
1181
|
`appmesh.App({"command": "<command_string>", "shell": True})`
|
1405
|
-
stdout_print
|
1406
|
-
max_time_seconds
|
1182
|
+
stdout_print: If True, prints the remote stdout locally. Defaults to True.
|
1183
|
+
max_time_seconds: Maximum runtime for the remote process.
|
1407
1184
|
Supports ISO 8601 duration format (e.g., 'P1Y2M3DT4H5M6S', 'P5W'). Defaults to DEFAULT_RUN_APP_TIMEOUT_SECONDS.
|
1408
|
-
life_cycle_seconds
|
1185
|
+
life_cycle_seconds: Maximum lifecycle time for the remote process.
|
1409
1186
|
Supports ISO 8601 duration format. Defaults to DEFAULT_RUN_APP_LIFECYCLE_SECONDS.
|
1410
1187
|
|
1411
1188
|
Returns:
|
1412
|
-
|
1189
|
+
Exit code of the process (None if unavailable) and the stdout text.
|
1413
1190
|
"""
|
1414
1191
|
if isinstance(app, str):
|
1415
1192
|
app = App({"command": app, "shell": True})
|
1416
1193
|
|
1417
|
-
path = "/appmesh/app/syncrun"
|
1418
1194
|
resp = self._request_http(
|
1419
|
-
AppMeshClient.
|
1195
|
+
AppMeshClient._Method.POST,
|
1420
1196
|
body=app.json(),
|
1421
|
-
path=
|
1197
|
+
path="/appmesh/app/syncrun",
|
1422
1198
|
query={
|
1423
1199
|
"timeout": str(self._parse_duration(max_time_seconds)),
|
1424
1200
|
"lifecycle": str(self._parse_duration(life_cycle_seconds)),
|
1425
1201
|
},
|
1426
1202
|
)
|
1203
|
+
|
1427
1204
|
exit_code = None
|
1428
1205
|
if resp.status_code == HTTPStatus.OK:
|
1429
1206
|
if stdout_print:
|
1430
1207
|
print(resp.text, end="")
|
1431
1208
|
if "X-Exit-Code" in resp.headers:
|
1432
|
-
exit_code = int(resp.headers
|
1209
|
+
exit_code = int(resp.headers["X-Exit-Code"])
|
1433
1210
|
elif stdout_print:
|
1434
1211
|
print(resp.text)
|
1435
1212
|
|
1436
1213
|
return exit_code, resp.text
|
1437
1214
|
|
1438
|
-
def _request_http(self, method:
|
1439
|
-
"""Make an HTTP request.
|
1440
|
-
|
1441
|
-
Args:
|
1442
|
-
method (Method): AppMeshClient.Method.
|
1443
|
-
path (str): URI patch str.
|
1444
|
-
query (dict, optional): HTTP query parameters.
|
1445
|
-
header (dict, optional): HTTP headers.
|
1446
|
-
body (_type_, optional): object to send in the body of the :class:`Request`.
|
1447
|
-
|
1448
|
-
Returns:
|
1449
|
-
requests.Response: HTTP response
|
1450
|
-
"""
|
1215
|
+
def _request_http(self, method: _Method, path: str, query: Optional[dict] = None, header: Optional[dict] = None, body=None) -> requests.Response:
|
1216
|
+
"""Make an HTTP request."""
|
1451
1217
|
rest_url = parse.urljoin(self.auth_server_url, path)
|
1452
1218
|
|
1453
1219
|
# Prepare headers
|
1454
|
-
|
1220
|
+
headers = header.copy() if header else {}
|
1455
1221
|
|
1456
|
-
|
1457
|
-
|
1458
|
-
|
1459
|
-
|
1460
|
-
|
1222
|
+
if self.cookie_file:
|
1223
|
+
# Cookie-based token
|
1224
|
+
csrf_token = self._get_cookie_value(self.session.cookies, self._COOKIE_CSRF_TOKEN)
|
1225
|
+
if csrf_token:
|
1226
|
+
headers[self._HTTP_HEADER_NAME_CSRF_TOKEN] = csrf_token
|
1227
|
+
else:
|
1228
|
+
# Api-based token
|
1229
|
+
access_token = self._get_access_token()
|
1230
|
+
if access_token:
|
1231
|
+
headers[self._HTTP_HEADER_KEY_AUTH] = f"Bearer {access_token}"
|
1461
1232
|
|
1462
|
-
if self.forward_to
|
1463
|
-
|
1464
|
-
|
1465
|
-
|
1466
|
-
|
1467
|
-
|
1233
|
+
if self.forward_to:
|
1234
|
+
target_host = self.forward_to
|
1235
|
+
if ":" not in target_host:
|
1236
|
+
port = parse.urlsplit(self.auth_server_url).port
|
1237
|
+
target_host = f"{target_host}:{port}"
|
1238
|
+
headers[self._HTTP_HEADER_KEY_X_TARGET_HOST] = target_host
|
1239
|
+
|
1240
|
+
headers[self._HTTP_HEADER_KEY_USER_AGENT] = self._HTTP_USER_AGENT
|
1468
1241
|
|
1469
1242
|
# Convert body to JSON string if it's a dict or list
|
1470
1243
|
if isinstance(body, (dict, list)):
|
1471
1244
|
body = json.dumps(body)
|
1472
|
-
|
1245
|
+
headers.setdefault("Content-Type", "application/json")
|
1473
1246
|
|
1474
1247
|
try:
|
1475
1248
|
request_kwargs = {
|
1476
1249
|
"url": rest_url,
|
1477
|
-
"headers":
|
1250
|
+
"headers": headers,
|
1478
1251
|
"cert": self.ssl_client_cert,
|
1479
1252
|
"verify": self.ssl_verify,
|
1480
1253
|
"timeout": self.rest_timeout,
|
1481
1254
|
}
|
1482
1255
|
|
1483
|
-
if method
|
1256
|
+
if method == AppMeshClient._Method.GET:
|
1484
1257
|
resp = self.session.get(params=query, **request_kwargs)
|
1485
|
-
elif method
|
1258
|
+
elif method == AppMeshClient._Method.POST:
|
1486
1259
|
resp = self.session.post(params=query, data=body, **request_kwargs)
|
1487
|
-
elif method
|
1260
|
+
elif method == AppMeshClient._Method.POST_STREAM:
|
1488
1261
|
resp = self.session.post(params=query, data=body, stream=True, **request_kwargs)
|
1489
|
-
elif method
|
1262
|
+
elif method == AppMeshClient._Method.DELETE:
|
1490
1263
|
resp = self.session.delete(**request_kwargs)
|
1491
|
-
elif method
|
1264
|
+
elif method == AppMeshClient._Method.PUT:
|
1492
1265
|
resp = self.session.put(params=query, data=body, **request_kwargs)
|
1493
1266
|
else:
|
1494
1267
|
raise Exception("Invalid http method", method)
|
1495
1268
|
|
1496
1269
|
# Wrap the response for encoding handling
|
1497
|
-
return AppMeshClient.
|
1270
|
+
return AppMeshClient._EncodingResponse(resp)
|
1271
|
+
|
1498
1272
|
except requests.exceptions.RequestException as e:
|
1499
|
-
raise Exception(f"HTTP request failed: {
|
1273
|
+
raise Exception(f"HTTP request failed: {e}") from e
|