appmesh 1.4.8__py3-none-any.whl → 1.5.1__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/http_client.py CHANGED
@@ -3,14 +3,18 @@
3
3
  import abc
4
4
  import base64
5
5
  import json
6
+ import logging
6
7
  import os
7
8
  from datetime import datetime
8
9
  from enum import Enum, unique
9
10
  from http import HTTPStatus
11
+ import threading
10
12
  from typing import Optional, Tuple, Union
11
13
  from urllib import parse
12
14
  import aniso8601
15
+ import jwt
13
16
  import requests
17
+ import time
14
18
  from .app import App
15
19
  from .app_run import AppRun
16
20
  from .app_output import AppOutput
@@ -102,6 +106,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
102
106
  DURATION_ONE_WEEK_ISO = "P1W"
103
107
  DURATION_TWO_DAYS_ISO = "P2D"
104
108
  DURATION_TWO_DAYS_HALF_ISO = "P2DT12H"
109
+ TOKEN_REFRESH_INTERVAL = 60
105
110
 
106
111
  DEFAULT_SSL_CA_CERT_PATH = "/opt/appmesh/ssl/ca.pem"
107
112
  DEFAULT_SSL_CLIENT_CERT_PATH = "/opt/appmesh/ssl/client.pem"
@@ -113,6 +118,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
113
118
  HTTP_USER_AGENT = "appmesh/python"
114
119
  HTTP_HEADER_KEY_USER_AGENT = "User-Agent"
115
120
  HTTP_HEADER_KEY_X_TARGET_HOST = "X-Target-Host"
121
+ HTTP_HEADER_KEY_X_FILE_PATH = "X-File-Path"
116
122
 
117
123
  @unique
118
124
  class Method(Enum):
@@ -131,7 +137,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
131
137
  rest_ssl_client_cert=(DEFAULT_SSL_CLIENT_CERT_PATH, DEFAULT_SSL_CLIENT_KEY_PATH) if os.path.exists(DEFAULT_SSL_CLIENT_CERT_PATH) else None,
132
138
  rest_timeout=(60, 300),
133
139
  jwt_token=None,
134
- oauth2_config=None,
140
+ oauth2=None,
141
+ auto_refresh_token=False,
135
142
  ):
136
143
  """Initialize an App Mesh HTTP client for interacting with the App Mesh server via secure HTTPS.
137
144
 
@@ -154,12 +161,18 @@ class AppMeshClient(metaclass=abc.ABCMeta):
154
161
  jwt_token (str, optional): JWT token for API authentication, used in headers to authorize requests where required.
155
162
 
156
163
  oauth2 (Dict[str, Any], optional): Keycloak configuration for oauth2 authentication:
157
- - auth_server_url: Keycloak server URL (e.g. "https://keycloak.example.com")
164
+ - server_url: Keycloak server URL (e.g. "https://keycloak.example.com/auth/")
158
165
  - realm: Keycloak realm
159
166
  - client_id: Keycloak client ID
160
167
  - client_secret: Keycloak client secret (optional)
161
- """
168
+ Using this parameter enables Keycloak integration for authentication. The 'python-keycloak' package
169
+ will be imported on-demand only when this parameter is, make sure package is installed (pip3 install python-keycloak).
162
170
 
171
+ auto_refresh_token (bool, optional): Enable automatic token refresh before expiration.
172
+ When enabled, a background timer will monitor token expiration and attempt to refresh
173
+ the token before it expires. This works with both native App Mesh tokens and Keycloak tokens.
174
+ """
175
+ self._ensure_logging_configured()
163
176
  self.session = requests.Session()
164
177
  self.auth_server_url = rest_url
165
178
  self._jwt_token = jwt_token
@@ -169,27 +182,131 @@ class AppMeshClient(metaclass=abc.ABCMeta):
169
182
  self._forward_to = None
170
183
 
171
184
  # Keycloak integration
172
- self._keycloak_client = None
173
- if oauth2_config:
174
- from .keycloak import KeycloakClient # lazy import
175
-
176
- self._keycloak_client = KeycloakClient(
177
- auth_server_url=oauth2_config.get("auth_server_url"),
178
- realm=oauth2_config.get("realm"),
179
- client_id=oauth2_config.get("client_id"),
180
- client_secret=oauth2_config.get("client_secret"),
181
- ssl_verify=oauth2_config.get("ssl_verify", self.ssl_verify),
182
- timeout=oauth2_config.get("timeout", (30, 60)),
183
- )
185
+ self._keycloak_openid = None
186
+ if oauth2:
187
+ try:
188
+ from keycloak import KeycloakOpenID
189
+
190
+ self._keycloak_openid = KeycloakOpenID(
191
+ server_url=oauth2.get("auth_server_url"),
192
+ client_id=oauth2.get("client_id"),
193
+ realm_name=oauth2.get("realm"),
194
+ client_secret_key=oauth2.get("client_secret"),
195
+ verify=self.ssl_verify,
196
+ timeout=rest_timeout,
197
+ )
198
+ except ImportError:
199
+ logging.error("Keycloak package not installed. Install with: pip install python-keycloak")
200
+ raise Exception("Keycloak integration requested but python-keycloak package is not installed")
201
+
202
+ # Token auto-refresh
203
+ self._token_refresh_timer = None
204
+ self._auto_refresh_token = auto_refresh_token
205
+ if auto_refresh_token and jwt_token:
206
+ self._schedule_token_refresh()
207
+
208
+ @staticmethod
209
+ def _ensure_logging_configured():
210
+ """Ensure logging is configured. If no handlers are configured, add a default console handler."""
211
+ if not logging.root.handlers:
212
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
213
+
214
+ def _check_and_refresh_token(self):
215
+ """Check and refresh token if needed, then schedule next check.
216
+
217
+ This method is triggered by the refresh timer and will:
218
+ 1. Check if token needs refresh based on expiration time
219
+ 2. Refresh the token if needed
220
+ 3. Schedule the next refresh check
221
+ """
222
+ if not self.jwt_token:
223
+ return
224
+
225
+ # Check if token needs refresh
226
+ needs_refresh = True
227
+ time_to_expiry = float("inf")
228
+
229
+ # Check token expiration directly from JWT
230
+ try:
231
+ decoded_token = jwt.decode(self.jwt_token if isinstance(self.jwt_token, str) else self.jwt_token.get("access_token", ""), options={"verify_signature": False})
232
+ expiry = decoded_token.get("exp", 0)
233
+ current_time = time.time()
234
+ time_to_expiry = expiry - current_time
235
+ # Refresh if token expires within 5 minutes
236
+ needs_refresh = time_to_expiry < 300
237
+ except Exception as e:
238
+ logging.debug("Failed to parse JWT token for expiration check: %s", str(e))
239
+
240
+ # Refresh token if needed
241
+ if needs_refresh:
242
+ try:
243
+ self.renew_token()
244
+ logging.info("Token successfully refreshed")
245
+ except Exception as e:
246
+ logging.error("Token refresh failed: %s", str(e))
247
+
248
+ # Schedule next check if auto-refresh is still enabled
249
+ if self._auto_refresh_token and self.jwt_token:
250
+ self._schedule_token_refresh(time_to_expiry)
251
+
252
+ def _schedule_token_refresh(self, time_to_expiry=None):
253
+ """Schedule next token refresh check.
254
+
255
+ Args:
256
+ time_to_expiry (float, optional): Time in seconds until token expiration.
257
+ When provided, helps calculate optimal refresh timing.
258
+
259
+ Calculates appropriate check interval:
260
+ - If token expires soon (within 5 minutes), refresh immediately
261
+ - Otherwise schedule refresh for the earlier of:
262
+ 1. 5 minutes before expiration
263
+ 2. 60 seconds from now
264
+ """
265
+ # Cancel existing timer if any
266
+ if self._token_refresh_timer:
267
+ self._token_refresh_timer.cancel()
268
+ self._token_refresh_timer = None
269
+
270
+ try:
271
+ # Default to checking after 60 seconds
272
+ check_interval = self.TOKEN_REFRESH_INTERVAL
273
+
274
+ # Calculate more precise check time if expiry is known
275
+ if time_to_expiry is not None:
276
+ if time_to_expiry <= 300: # Expires within 5 minutes
277
+ check_interval = 1 # Almost immediate refresh
278
+ else:
279
+ # Check at earlier of 5 minutes before expiry or regular interval
280
+ check_interval = min(time_to_expiry - 300, self.TOKEN_REFRESH_INTERVAL)
281
+
282
+ # Create timer to execute refresh check
283
+ self._token_refresh_timer = threading.Timer(check_interval, self._check_and_refresh_token)
284
+ self._token_refresh_timer.daemon = True
285
+ self._token_refresh_timer.start()
286
+ logging.debug("Auto-refresh: Next token check scheduled in %.1f seconds", check_interval)
287
+ except Exception as e:
288
+ logging.error("Auto-refresh: Failed to schedule token refresh: %s", str(e))
184
289
 
185
290
  def close(self):
186
291
  """Close the session and release resources."""
292
+ # Cancel token refresh timer
293
+ if hasattr(self, "_token_refresh_timer") and self._token_refresh_timer:
294
+ self._token_refresh_timer.cancel()
295
+ self._token_refresh_timer = None
296
+
297
+ # Close the session
187
298
  if hasattr(self, "session") and self.session:
188
299
  self.session.close()
189
300
  self.session = None
190
- if self._keycloak_client and hasattr(self._keycloak_client, "close"):
191
- self._keycloak_client.close()
192
- self._keycloak_client = None
301
+
302
+ # Logout from Keycloak if needed
303
+ if hasattr(self, "_keycloak_openid") and self._keycloak_openid and hasattr(self, "_jwt_token") and self._jwt_token and isinstance(self._jwt_token, dict) and "refresh_token" in self._jwt_token:
304
+ try:
305
+ self._keycloak_openid.logout(self._jwt_token.get("refresh_token"))
306
+ except Exception as e:
307
+ logging.warning("Failed to logout from Keycloak: %s", str(e))
308
+ finally:
309
+ self._keycloak_openid = None
193
310
 
194
311
  def __enter__(self):
195
312
  """Support for context manager protocol."""
@@ -312,8 +429,17 @@ class AppMeshClient(metaclass=abc.ABCMeta):
312
429
  str: JWT token.
313
430
  """
314
431
  # Keycloak authentication if configured
315
- if self._keycloak_client:
316
- self.jwt_token = self._keycloak_client.authenticate(user_name, user_pwd)
432
+ if self._keycloak_openid:
433
+ self.jwt_token = self._keycloak_openid.token(
434
+ username=user_name,
435
+ password=user_pwd,
436
+ totp=totp_code if totp_code else None,
437
+ grant_type="password", # grant type for token request: "password" / "client_credentials" / "refresh_token"
438
+ scope="openid", # what information to include in the token, such as "openid profile email"
439
+ )
440
+
441
+ if self._auto_refresh_token:
442
+ self._schedule_token_refresh()
317
443
  return self.jwt_token
318
444
 
319
445
  # Standard App Mesh authentication
@@ -323,18 +449,22 @@ class AppMeshClient(metaclass=abc.ABCMeta):
323
449
  path="/appmesh/login",
324
450
  header={
325
451
  "Authorization": "Basic " + base64.b64encode((user_name + ":" + user_pwd).encode()).decode(),
326
- "Expire-Seconds": self._parse_duration(timeout_seconds),
327
- **({"Audience": audience} if audience else {}),
452
+ "X-Expire-Seconds": str(self._parse_duration(timeout_seconds)),
453
+ **({"X-Audience": audience} if audience else {}),
454
+ # **({"X-Totp-Code": totp_code} if totp_code else {}),
328
455
  },
329
456
  )
330
457
  if resp.status_code == HTTPStatus.OK:
331
- if "Access-Token" in resp.json():
332
- self.jwt_token = resp.json()["Access-Token"]
333
- elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED and "Totp-Challenge" in resp.json():
334
- challenge = resp.json()["Totp-Challenge"]
458
+ if "access_token" in resp.json():
459
+ self.jwt_token = resp.json()["access_token"]
460
+ elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED and "totp_challenge" in resp.json():
461
+ challenge = resp.json()["totp_challenge"]
335
462
  self.validate_totp(user_name, challenge, totp_code, timeout_seconds)
336
463
  else:
337
464
  raise Exception(resp.text)
465
+
466
+ if self._auto_refresh_token:
467
+ self._schedule_token_refresh()
338
468
  return self.jwt_token
339
469
 
340
470
  def validate_totp(self, username: str, challenge: str, code: str, timeout: Union[int, str] = DURATION_ONE_WEEK_ISO) -> str:
@@ -357,17 +487,16 @@ class AppMeshClient(metaclass=abc.ABCMeta):
357
487
  resp = self._request_http(
358
488
  AppMeshClient.Method.POST,
359
489
  path="/appmesh/totp/validate",
360
- header={
361
- "Username": base64.b64encode(username.encode()).decode(),
362
- "Totp": code,
363
- "Totp-Challenge": base64.b64encode(challenge.encode()).decode(),
364
- "Expire-Seconds": self._parse_duration(timeout),
490
+ body={
491
+ "user_name": username,
492
+ "totp_code": code,
493
+ "totp_challenge": challenge,
494
+ "expire_seconds": self._parse_duration(timeout),
365
495
  },
366
496
  )
367
- if resp.status_code == HTTPStatus.OK:
368
- if "Access-Token" in resp.json():
369
- self.jwt_token = resp.json()["Access-Token"]
370
- return self.jwt_token
497
+ if resp.status_code == HTTPStatus.OK and "access_token" in resp.json():
498
+ self.jwt_token = resp.json()["access_token"]
499
+ return self.jwt_token
371
500
  raise Exception(resp.text)
372
501
 
373
502
  def logoff(self) -> bool:
@@ -376,15 +505,30 @@ class AppMeshClient(metaclass=abc.ABCMeta):
376
505
  Returns:
377
506
  bool: logoff success or failure.
378
507
  """
379
- if self.jwt_token:
508
+ result = False
509
+ # Handle Keycloak logout if configured
510
+ if self._keycloak_openid and self.jwt_token and isinstance(self.jwt_token, dict) and "refresh_token" in self.jwt_token:
511
+ refresh_token = self.jwt_token.get("refresh_token")
512
+ self._keycloak_openid.logout(refresh_token)
513
+ self.jwt_token = None
514
+ result = True
515
+
516
+ # Standard App Mesh logout
517
+ if self.jwt_token and isinstance(self.jwt_token, str):
380
518
  resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/self/logoff")
381
519
  self.jwt_token = None
382
- return resp.status_code == HTTPStatus.OK
383
- return True
520
+ result = resp.status_code == HTTPStatus.OK
521
+
522
+ # Cancel token refresh timer
523
+ if self._token_refresh_timer:
524
+ self._token_refresh_timer.cancel()
525
+ self._token_refresh_timer = None
526
+
527
+ return result
384
528
 
385
529
  def authentication(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
386
530
  """Deprecated: Use authenticate() instead."""
387
- return self.authenticate(token, permission)
531
+ return self.authenticate(token, permission, audience)
388
532
 
389
533
  def authenticate(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
390
534
  """Authenticate with a token and verify permission if specified.
@@ -403,8 +547,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
403
547
  old_token = self.jwt_token
404
548
  self.jwt_token = token
405
549
  headers = {
406
- **({"Audience": audience} if audience else {}),
407
- **({"Auth-Permission": permission} if permission else {}),
550
+ **({"X-Audience": audience} if audience else {}),
551
+ **({"X-Permission": permission} if permission else {}),
408
552
  }
409
553
  resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/auth", header=headers)
410
554
  if resp.status_code != HTTPStatus.OK:
@@ -419,27 +563,44 @@ class AppMeshClient(metaclass=abc.ABCMeta):
419
563
  timeout_seconds (int | str, optional): token expire timeout of seconds. support ISO 8601 durations (e.g., 'P1Y2M3DT4H5M6S' 'P1W').
420
564
 
421
565
  Returns:
422
- str: The new JWT token if renew success, the old token will be blocked.
566
+ str: The new JWT token. The old token will be invalidated.
567
+
568
+ Raises:
569
+ Exception: If token renewal fails or no token exists to renew
423
570
  """
424
- # Refresh the Keycloak token
425
- if self._keycloak_client:
426
- self.jwt_token = self._keycloak_client.refresh_tokens()
571
+ # Ensure token exists
572
+ if not self.jwt_token:
573
+ raise Exception("No token to renew")
574
+
575
+ try:
576
+ # Handle Keycloak token (dictionary format)
577
+ if self._keycloak_openid and isinstance(self.jwt_token, dict) and "refresh_token" in self.jwt_token:
578
+ new_token = self._keycloak_openid.refresh_token(self.jwt_token.get("refresh_token"))
579
+ self.jwt_token = new_token
580
+
581
+ # Handle App Mesh token (string format)
582
+ elif isinstance(self.jwt_token, str):
583
+ resp = self._request_http(
584
+ AppMeshClient.Method.POST,
585
+ path="/appmesh/token/renew",
586
+ header={"X-Expire-Seconds": str(self._parse_duration(timeout))},
587
+ )
588
+ if resp.status_code == HTTPStatus.OK:
589
+ if "access_token" in resp.json():
590
+ new_token = resp.json()["access_token"]
591
+ self.jwt_token = new_token
592
+ else:
593
+ raise Exception("Token renewal response missing access_token")
594
+ else:
595
+ raise Exception(resp.text)
596
+ else:
597
+ raise Exception("Unsupported token format")
598
+
427
599
  return self.jwt_token
428
600
 
429
- # Refresh the App Mesh token
430
- assert self.jwt_token
431
- resp = self._request_http(
432
- AppMeshClient.Method.POST,
433
- path="/appmesh/token/renew",
434
- header={
435
- "Expire-Seconds": self._parse_duration(timeout),
436
- },
437
- )
438
- if resp.status_code == HTTPStatus.OK:
439
- if "Access-Token" in resp.json():
440
- self.jwt_token = resp.json()["Access-Token"]
441
- return self.jwt_token
442
- raise Exception(resp.text)
601
+ except Exception as e:
602
+ logging.error("Token renewal failed: %s", str(e))
603
+ raise Exception(f"Token renewal failed: {str(e)}") from e
443
604
 
444
605
  def get_totp_secret(self) -> str:
445
606
  """Generate TOTP secret for the current user and return MFA URI.
@@ -449,7 +610,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
449
610
  """
450
611
  resp = self._request_http(method=AppMeshClient.Method.POST, path="/appmesh/totp/secret")
451
612
  if resp.status_code == HTTPStatus.OK:
452
- totp_uri = base64.b64decode(resp.json()["Mfa-Uri"]).decode()
613
+ totp_uri = base64.b64decode(resp.json()["mfa_uri"]).decode()
453
614
  return self._parse_totp_uri(totp_uri).get("secret")
454
615
  raise Exception(resp.text)
455
616
 
@@ -465,11 +626,11 @@ class AppMeshClient(metaclass=abc.ABCMeta):
465
626
  resp = self._request_http(
466
627
  method=AppMeshClient.Method.POST,
467
628
  path="/appmesh/totp/setup",
468
- header={"Totp": totp_code},
629
+ header={"X-Totp-Code": totp_code},
469
630
  )
470
631
  if resp.status_code == HTTPStatus.OK:
471
- if "Access-Token" in resp.json():
472
- self.jwt_token = resp.json()["Access-Token"]
632
+ if "access_token" in resp.json():
633
+ self.jwt_token = resp.json()["access_token"]
473
634
  return self.jwt_token
474
635
  else:
475
636
  raise Exception(resp.text)
@@ -576,8 +737,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
576
737
  "timeout": str(timeout),
577
738
  },
578
739
  )
579
- out_position = int(resp.headers["Output-Position"]) if "Output-Position" in resp.headers else None
580
- exit_code = int(resp.headers["Exit-Code"]) if "Exit-Code" in resp.headers else None
740
+ out_position = int(resp.headers["X-Output-Position"]) if "X-Output-Position" in resp.headers else None
741
+ exit_code = int(resp.headers["X-Exit-Code"]) if "X-Exit-Code" in resp.headers else None
581
742
  return AppOutput(status_code=resp.status_code, output=resp.text, out_position=out_position, exit_code=exit_code)
582
743
 
583
744
  def check_app_health(self, app_name: str) -> bool:
@@ -728,7 +889,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
728
889
  resp = self._request_http(
729
890
  method=AppMeshClient.Method.POST,
730
891
  path=f"/appmesh/user/{user_name}/passwd",
731
- header={"New-Password": base64.b64encode(new_password.encode())},
892
+ body={"new_password": base64.b64encode(new_password.encode()).decode()},
732
893
  )
733
894
  if resp.status_code != HTTPStatus.OK:
734
895
  raise Exception(resp.text)
@@ -817,6 +978,9 @@ class AppMeshClient(metaclass=abc.ABCMeta):
817
978
  Returns:
818
979
  dict: user definition.
819
980
  """
981
+ if self._keycloak_openid and isinstance(self.jwt_token, dict) and "access_token" in self.jwt_token:
982
+ return self._keycloak_openid.userinfo(self.jwt_token.get("access_token"))
983
+
820
984
  resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/self")
821
985
  if resp.status_code != HTTPStatus.OK:
822
986
  raise Exception(resp.text)
@@ -970,7 +1134,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
970
1134
  local_file (str): the local file path to be downloaded.
971
1135
  apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
972
1136
  """
973
- resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={"File-Path": remote_file})
1137
+ resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={self.HTTP_HEADER_KEY_X_FILE_PATH: remote_file})
974
1138
  resp.raise_for_status()
975
1139
 
976
1140
  # Write the file content locally
@@ -981,15 +1145,15 @@ class AppMeshClient(metaclass=abc.ABCMeta):
981
1145
 
982
1146
  # Apply file attributes (permissions, owner, group) if requested
983
1147
  if apply_file_attributes:
984
- if "File-Mode" in resp.headers:
985
- os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
986
- if "File-User" in resp.headers and "File-Group" in resp.headers:
987
- file_uid = int(resp.headers["File-User"])
988
- file_gid = int(resp.headers["File-Group"])
1148
+ if "X-File-Mode" in resp.headers:
1149
+ os.chmod(path=local_file, mode=int(resp.headers["X-File-Mode"]))
1150
+ if "X-File-User" in resp.headers and "X-File-Group" in resp.headers:
1151
+ file_uid = int(resp.headers["X-File-User"])
1152
+ file_gid = int(resp.headers["X-File-Group"])
989
1153
  try:
990
1154
  os.chown(path=local_file, uid=file_uid, gid=file_gid)
991
1155
  except PermissionError:
992
- print(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
1156
+ logging.warning(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
993
1157
 
994
1158
  def upload_file(self, local_file: str, remote_file: str, apply_file_attributes: bool = True) -> None:
995
1159
  """Upload a local file to the remote server. Optionally, the remote file will have the same permission as the local file.
@@ -1010,14 +1174,14 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1010
1174
 
1011
1175
  with open(file=local_file, mode="rb") as fp:
1012
1176
  encoder = MultipartEncoder(fields={"filename": os.path.basename(remote_file), "file": ("filename", fp, "application/octet-stream")})
1013
- header = {"File-Path": parse.quote(remote_file), "Content-Type": encoder.content_type}
1177
+ header = {self.HTTP_HEADER_KEY_X_FILE_PATH: parse.quote(remote_file), "Content-Type": encoder.content_type}
1014
1178
 
1015
1179
  # Include file attributes (permissions, owner, group) if requested
1016
1180
  if apply_file_attributes:
1017
1181
  file_stat = os.stat(local_file)
1018
- header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
1019
- header["File-User"] = str(file_stat.st_uid)
1020
- header["File-Group"] = str(file_stat.st_gid)
1182
+ header["X-File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
1183
+ header["X-File-User"] = str(file_stat.st_uid)
1184
+ header["X-File-Group"] = str(file_stat.st_gid)
1021
1185
 
1022
1186
  # Upload file with or without attributes
1023
1187
  # https://stackoverflow.com/questions/22567306/python-requests-file-upload
@@ -1032,11 +1196,11 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1032
1196
  ########################################
1033
1197
  # Application run
1034
1198
  ########################################
1035
- def _parse_duration(self, timeout) -> str:
1199
+ def _parse_duration(self, timeout) -> int:
1036
1200
  if isinstance(timeout, int):
1037
- return str(timeout)
1201
+ return timeout
1038
1202
  elif isinstance(timeout, str):
1039
- return str(int(aniso8601.parse_duration(timeout).total_seconds()))
1203
+ return int(aniso8601.parse_duration(timeout).total_seconds())
1040
1204
  else:
1041
1205
  raise TypeError(f"Invalid timeout type: {str(timeout)}")
1042
1206
 
@@ -1072,8 +1236,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1072
1236
  body=app.json(),
1073
1237
  path=path,
1074
1238
  query={
1075
- "timeout": self._parse_duration(max_time_seconds),
1076
- "lifecycle": self._parse_duration(life_cycle_seconds),
1239
+ "timeout": str(self._parse_duration(max_time_seconds)),
1240
+ "lifecycle": str(self._parse_duration(life_cycle_seconds)),
1077
1241
  },
1078
1242
  )
1079
1243
  if resp.status_code != HTTPStatus.OK:
@@ -1152,16 +1316,16 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1152
1316
  body=app.json(),
1153
1317
  path=path,
1154
1318
  query={
1155
- "timeout": self._parse_duration(max_time_seconds),
1156
- "lifecycle": self._parse_duration(life_cycle_seconds),
1319
+ "timeout": str(self._parse_duration(max_time_seconds)),
1320
+ "lifecycle": str(self._parse_duration(life_cycle_seconds)),
1157
1321
  },
1158
1322
  )
1159
1323
  exit_code = None
1160
1324
  if resp.status_code == HTTPStatus.OK:
1161
1325
  if stdout_print:
1162
1326
  print(resp.text, end="")
1163
- if "Exit-Code" in resp.headers:
1164
- exit_code = int(resp.headers.get("Exit-Code"))
1327
+ if "X-Exit-Code" in resp.headers:
1328
+ exit_code = int(resp.headers.get("X-Exit-Code"))
1165
1329
  elif stdout_print:
1166
1330
  print(resp.text)
1167
1331
 
@@ -1180,18 +1344,12 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1180
1344
  Returns:
1181
1345
  requests.Response: HTTP response
1182
1346
  """
1183
- # Try to refresh token via Keycloak if using Keycloak and token needs refreshing
1184
- if self._keycloak_client and self.jwt_token and not self._keycloak_client.validate_token():
1185
- try:
1186
- self.jwt_token = self._keycloak_client.get_active_token()
1187
- except Exception as e:
1188
- print(f"Token refresh failed: {str(e)}")
1189
-
1190
1347
  rest_url = parse.urljoin(self.auth_server_url, path)
1191
1348
 
1192
1349
  header = {} if header is None else header
1193
1350
  if self.jwt_token:
1194
- header["Authorization"] = "Bearer " + self.jwt_token
1351
+ token = self.jwt_token["access_token"] if isinstance(self.jwt_token, dict) and "access_token" in self.jwt_token else self.jwt_token
1352
+ header["Authorization"] = "Bearer " + token
1195
1353
  if self.forward_to and len(self.forward_to) > 0:
1196
1354
  if ":" in self.forward_to:
1197
1355
  header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forward_to
appmesh/tcp_client.py CHANGED
@@ -153,7 +153,7 @@ class AppMeshClientTCP(AppMeshClient):
153
153
  local_file (str): the local file path to be downloaded.
154
154
  apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
155
155
  """
156
- header = {"File-Path": remote_file}
156
+ header = {AppMeshClient.HTTP_HEADER_KEY_X_FILE_PATH: remote_file}
157
157
  header[self.HTTP_HEADER_KEY_X_RECV_FILE_SOCKET] = "true"
158
158
  resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header=header)
159
159
 
@@ -169,11 +169,11 @@ class AppMeshClientTCP(AppMeshClient):
169
169
  fp.write(chunk_data)
170
170
 
171
171
  if apply_file_attributes:
172
- if "File-Mode" in resp.headers:
173
- os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
174
- if "File-User" in resp.headers and "File-Group" in resp.headers:
175
- file_uid = int(resp.headers["File-User"])
176
- file_gid = int(resp.headers["File-Group"])
172
+ if "X-File-Mode" in resp.headers:
173
+ os.chmod(path=local_file, mode=int(resp.headers["X-File-Mode"]))
174
+ if "X-File-User" in resp.headers and "X-File-Group" in resp.headers:
175
+ file_uid = int(resp.headers["X-File-User"])
176
+ file_gid = int(resp.headers["X-File-Group"])
177
177
  try:
178
178
  os.chown(path=local_file, uid=file_uid, gid=file_gid)
179
179
  except PermissionError:
@@ -195,14 +195,14 @@ class AppMeshClientTCP(AppMeshClient):
195
195
  raise FileNotFoundError(f"Local file not found: {local_file}")
196
196
 
197
197
  with open(file=local_file, mode="rb") as fp:
198
- header = {"File-Path": remote_file, "Content-Type": "text/plain"}
198
+ header = {AppMeshClient.HTTP_HEADER_KEY_X_FILE_PATH: remote_file, "Content-Type": "text/plain"}
199
199
  header[self.HTTP_HEADER_KEY_X_SEND_FILE_SOCKET] = "true"
200
200
 
201
201
  if apply_file_attributes:
202
202
  file_stat = os.stat(local_file)
203
- header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
204
- header["File-User"] = str(file_stat.st_uid)
205
- header["File-Group"] = str(file_stat.st_gid)
203
+ header["X-File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
204
+ header["X-File-User"] = str(file_stat.st_uid)
205
+ header["X-File-Group"] = str(file_stat.st_gid)
206
206
 
207
207
  resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/file/upload", header=header)
208
208
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: appmesh
3
- Version: 1.4.8
3
+ Version: 1.5.1
4
4
  Summary: Client SDK for App Mesh
5
5
  Home-page: https://github.com/laoshanxi/app-mesh
6
6
  Author: laoshanxi
@@ -3,12 +3,11 @@ appmesh/app.py,sha256=9Q-SOOej-MH13BU5Dv2iTa-p-sECCJQp6ZX9DjWWmwE,10526
3
3
  appmesh/app_output.py,sha256=JK_TMKgjvaw4n_ys_vmN5S4MyWVZpmD7NlKz_UyMIM8,1015
4
4
  appmesh/app_run.py,sha256=9ISKGZ3k3kkbQvSsPfRfkOLqD9xhbqNOM7ork9F4w9c,1712
5
5
  appmesh/appmesh_client.py,sha256=0ltkqHZUq094gKneYmC0bEZCP0X9kHTp9fccKdWFWP0,339
6
- appmesh/http_client.py,sha256=S90Ldndx3wfUYE2Ln31fnKKBNXbDROQd3FAzFTEdZtA,48314
7
- appmesh/keycloak.py,sha256=BJZ35FPO0C2wDeDhCcd6cE4ba9BF4UZ-4o5QelmbGHg,8036
8
- appmesh/tcp_client.py,sha256=RkHl5s8jE333BJOgxJqJ_fvjbdRQza7ciV49vLT6YO4,10923
6
+ appmesh/http_client.py,sha256=BMnzxZE2N-P3tLGkf0LKrqCJRWCPYciw-gXj5z_YTbE,55783
7
+ appmesh/tcp_client.py,sha256=Id1aIKVWncTSZiKRVa4sgwo1tFX2wRqOLiTnI9-dNkE,11001
9
8
  appmesh/tcp_messages.py,sha256=w1Kehz_aX4X2CYAUsy9mFVJRhxnLQwwc6L58W4YkQqs,969
10
9
  appmesh/tcp_transport.py,sha256=-XDTQbsKL3yCbguHeW2jNqXpYgnCyHsH4rwcaJ46AS8,8645
11
- appmesh-1.4.8.dist-info/METADATA,sha256=_Lmle-Icw-PkTf5yUvx3wHgaH0usfqAcVD2eePzjYS8,11663
12
- appmesh-1.4.8.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
13
- appmesh-1.4.8.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
14
- appmesh-1.4.8.dist-info/RECORD,,
10
+ appmesh-1.5.1.dist-info/METADATA,sha256=86gy_IbptvkM4JXYK0zBX-soylSlBA8ggkx6hoIiJZA,11663
11
+ appmesh-1.5.1.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
12
+ appmesh-1.5.1.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
13
+ appmesh-1.5.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (76.0.0)
2
+ Generator: setuptools (79.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
appmesh/keycloak.py DELETED
@@ -1,248 +0,0 @@
1
- # Keycloak authentication client for App Mesh Python SDK
2
-
3
- import base64
4
- import json
5
- import time
6
- from typing import Dict, Optional, Tuple, Any
7
- import requests
8
-
9
-
10
- class KeycloakClient:
11
- """
12
- Client for authenticating with Keycloak and obtaining tokens for App Mesh.
13
-
14
- This class handles the OAuth2/OIDC workflow with Keycloak, including:
15
- - Direct authentication with username/password
16
- - Token refresh
17
- - Token validation
18
- """
19
-
20
- def __init__(
21
- self,
22
- auth_server_url: str,
23
- realm: str,
24
- client_id: str,
25
- client_secret: Optional[str] = None,
26
- ssl_verify: bool = True,
27
- timeout: Tuple[int, int] = (10, 60),
28
- token_refresh_threshold: int = 30,
29
- ):
30
- """Initialize Keycloak client.
31
-
32
- Args:
33
- auth_server_url (str): Keycloak server URL (e.g. https://keycloak.example.com/auth)
34
- realm (str): Keycloak realm name
35
- client_id (str): Client ID registered in Keycloak
36
- client_secret (Optional[str], optional): Client secret if using confidential client. Defaults to None.
37
- ssl_verify (bool, optional): Verify SSL certificates. Defaults to True.
38
- timeout (Tuple[int, int], optional): Connection and read timeouts. Defaults to (10, 60).
39
- token_refresh_threshold (int, optional): Seconds before token expiry to trigger refresh. Defaults to 30.
40
- """
41
- self.auth_server_url = auth_server_url.rstrip("/")
42
- self.realm = realm
43
- self.client_id = client_id
44
- self.client_secret = client_secret
45
- self.ssl_verify = ssl_verify
46
- self.timeout = timeout
47
- self.token_refresh_threshold = token_refresh_threshold
48
-
49
- # Token storage
50
- self.access_token = None
51
- self.refresh_token = None
52
- self.token_expires_at = 0
53
-
54
- # Construct the token endpoint URL
55
- self.token_endpoint = f"{self.auth_server_url}/realms/{self.realm}/protocol/openid-connect/token"
56
- self.userinfo_endpoint = f"{self.auth_server_url}/realms/{self.realm}/protocol/openid-connect/userinfo"
57
-
58
- # Create a session for connection pooling
59
- self.session = requests.Session()
60
-
61
- def __enter__(self):
62
- """Support for context manager protocol."""
63
- return self
64
-
65
- def __exit__(self, exc_type, exc_val, exc_tb):
66
- """Clean up resources when exiting context."""
67
- self.close()
68
-
69
- def __del__(self):
70
- """Ensure resources are properly released when the object is garbage collected."""
71
- try:
72
- self.close()
73
- except Exception:
74
- pass # Avoid exceptions during garbage collection
75
-
76
- def authenticate(self, username: str, password: str) -> str:
77
- """Authenticate with username and password.
78
-
79
- Args:
80
- username (str): Username
81
- password (str): Password
82
-
83
- Returns:
84
- str: Access token
85
-
86
- Raises:
87
- Exception: If authentication fails
88
- """
89
- data = {
90
- "client_id": self.client_id,
91
- "grant_type": "password",
92
- "username": username,
93
- "password": password,
94
- }
95
-
96
- if self.client_secret:
97
- data["client_secret"] = self.client_secret
98
-
99
- try:
100
- response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
101
-
102
- if response.status_code != 200:
103
- raise Exception(f"Authentication failed: {response.text}")
104
-
105
- token_data = response.json()
106
- self._update_token_data(token_data)
107
- return self.access_token
108
-
109
- except requests.RequestException as e:
110
- raise Exception(f"Failed to connect to Keycloak: {str(e)}")
111
-
112
- def refresh_tokens(self) -> str:
113
- """Refresh the access token using the refresh token.
114
-
115
- Returns:
116
- str: New access token
117
-
118
- Raises:
119
- Exception: If refresh fails
120
- """
121
- if not self.refresh_token:
122
- raise Exception("No refresh token available")
123
-
124
- data = {
125
- "client_id": self.client_id,
126
- "grant_type": "refresh_token",
127
- "refresh_token": self.refresh_token,
128
- }
129
-
130
- if self.client_secret:
131
- data["client_secret"] = self.client_secret
132
-
133
- try:
134
- response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
135
-
136
- if response.status_code != 200:
137
- raise Exception(f"Token refresh failed: {response.text}")
138
-
139
- token_data = response.json()
140
- self._update_token_data(token_data)
141
- return self.access_token
142
-
143
- except requests.RequestException as e:
144
- raise Exception(f"Failed to connect to Keycloak: {str(e)}")
145
-
146
- def validate_token(self) -> bool:
147
- """Check if the current token is valid and not expired.
148
-
149
- Returns:
150
- bool: True if valid, False otherwise
151
- """
152
- if not self.access_token:
153
- return False
154
-
155
- # Check if the token is expired based on our local expiry time
156
- if time.time() > self.token_expires_at:
157
- return False
158
-
159
- return True
160
-
161
- def get_active_token(self) -> str:
162
- """Get a valid access token, refreshing if necessary.
163
-
164
- Returns:
165
- str: Valid access token
166
-
167
- Raises:
168
- Exception: If no valid token can be obtained
169
- """
170
- if self.validate_token():
171
- return self.access_token
172
-
173
- if self.refresh_token:
174
- try:
175
- return self.refresh_tokens()
176
- except Exception:
177
- pass
178
-
179
- raise Exception("No valid token available and unable to refresh")
180
-
181
- def get_user_info(self) -> Dict[str, Any]:
182
- """Get information about the authenticated user.
183
-
184
- Returns:
185
- Dict[str, Any]: User information
186
-
187
- Raises:
188
- Exception: If request fails
189
- """
190
- if not self.access_token:
191
- raise Exception("Not authenticated")
192
-
193
- headers = {"Authorization": f"Bearer {self.access_token}"}
194
-
195
- try:
196
- response = self.session.get(self.userinfo_endpoint, headers=headers, verify=self.ssl_verify, timeout=self.timeout)
197
-
198
- if response.status_code != 200:
199
- raise Exception(f"Failed to get user info: {response.text}")
200
-
201
- return response.json()
202
-
203
- except requests.RequestException as e:
204
- raise Exception(f"Failed to connect to Keycloak: {str(e)}")
205
-
206
- def _update_token_data(self, token_data: Dict[str, Any]) -> None:
207
- """Update the stored token data.
208
-
209
- Args:
210
- token_data (Dict[str, Any]): Token data from Keycloak
211
- """
212
- self.access_token = token_data["access_token"]
213
- self.refresh_token = token_data.get("refresh_token")
214
-
215
- # Calculate token expiration time with configurable buffer
216
- expires_in = token_data.get("expires_in", 300)
217
- self.token_expires_at = time.time() + expires_in - self.token_refresh_threshold
218
-
219
- def close(self) -> None:
220
- """Close the session and release resources."""
221
- if hasattr(self, "session") and self.session:
222
- self.session.close()
223
- self.session = None
224
-
225
- @staticmethod
226
- def decode_token(token: str) -> Dict[str, Any]:
227
- """Decode a JWT token without verification.
228
-
229
- Args:
230
- token (str): JWT token
231
-
232
- Returns:
233
- Dict[str, Any]: Decoded token payload
234
- """
235
- parts = token.split(".")
236
- if len(parts) != 3:
237
- raise Exception("Invalid token format")
238
-
239
- # Decode the payload (second part)
240
- payload = parts[1]
241
- # Add padding if necessary
242
- payload += "=" * (4 - len(payload) % 4) if len(payload) % 4 else ""
243
-
244
- try:
245
- decoded = base64.b64decode(payload)
246
- return json.loads(decoded)
247
- except Exception as e:
248
- raise Exception(f"Failed to decode token: {str(e)}")