appmesh 1.4.7__py3-none-any.whl → 1.5.0__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,18 +3,21 @@
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
17
- from .keycloak import KeycloakClient
18
21
 
19
22
 
20
23
  class AppMeshClient(metaclass=abc.ABCMeta):
@@ -103,6 +106,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
103
106
  DURATION_ONE_WEEK_ISO = "P1W"
104
107
  DURATION_TWO_DAYS_ISO = "P2D"
105
108
  DURATION_TWO_DAYS_HALF_ISO = "P2DT12H"
109
+ TOKEN_REFRESH_INTERVAL = 60
106
110
 
107
111
  DEFAULT_SSL_CA_CERT_PATH = "/opt/appmesh/ssl/ca.pem"
108
112
  DEFAULT_SSL_CLIENT_CERT_PATH = "/opt/appmesh/ssl/client.pem"
@@ -114,6 +118,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
114
118
  HTTP_USER_AGENT = "appmesh/python"
115
119
  HTTP_HEADER_KEY_USER_AGENT = "User-Agent"
116
120
  HTTP_HEADER_KEY_X_TARGET_HOST = "X-Target-Host"
121
+ HTTP_HEADER_KEY_X_FILE_PATH = "X-File-Path"
117
122
 
118
123
  @unique
119
124
  class Method(Enum):
@@ -132,7 +137,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
132
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,
133
138
  rest_timeout=(60, 300),
134
139
  jwt_token=None,
135
- oauth2_config=None,
140
+ oauth2=None,
141
+ auto_refresh_token=False,
136
142
  ):
137
143
  """Initialize an App Mesh HTTP client for interacting with the App Mesh server via secure HTTPS.
138
144
 
@@ -145,9 +151,9 @@ class AppMeshClient(metaclass=abc.ABCMeta):
145
151
  - `str`: Path to a custom CA certificate or directory for verification. This option allows custom CA configuration,
146
152
  which may be necessary in environments requiring specific CA chains that differ from the default system CAs.
147
153
 
148
- rest_ssl_client_cert (Union[tuple, str], optional): Specifies a client certificate for mutual TLS authentication:
149
- - If a `str`, provides the path to a PEM file with both client certificate and private key.
150
- - If a `tuple`, contains two paths as (`cert`, `key`), where `cert` is the certificate file and `key` is the private key file.
154
+ ssl_client_cert (Union[str, Tuple[str, str]], optional): Path to the SSL client certificate and key. Can be:
155
+ - `str`: A path to a single PEM file containing both the client certificate and private key.
156
+ - `tuple`: A pair of paths (`cert_file`, `key_file`), where `cert_file` is the client certificate file path and `key_file` is the private key file path.
151
157
 
152
158
  rest_timeout (tuple, optional): HTTP connection timeouts for API requests, as `(connect_timeout, read_timeout)`.
153
159
  The default is `(60, 300)`, where `60` seconds is the maximum time to establish a connection and `300` seconds for the maximum read duration.
@@ -155,14 +161,20 @@ class AppMeshClient(metaclass=abc.ABCMeta):
155
161
  jwt_token (str, optional): JWT token for API authentication, used in headers to authorize requests where required.
156
162
 
157
163
  oauth2 (Dict[str, Any], optional): Keycloak configuration for oauth2 authentication:
158
- - server_url: Keycloak server URL (e.g. "https://keycloak.example.com")
164
+ - server_url: Keycloak server URL (e.g. "https://keycloak.example.com/auth/")
159
165
  - realm: Keycloak realm
160
166
  - client_id: Keycloak client ID
161
167
  - client_secret: Keycloak client secret (optional)
162
- """
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).
163
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()
164
176
  self.session = requests.Session()
165
- self.server_url = rest_url
177
+ self.auth_server_url = rest_url
166
178
  self._jwt_token = jwt_token
167
179
  self.ssl_verify = rest_ssl_verify
168
180
  self.ssl_client_cert = rest_ssl_client_cert
@@ -170,32 +182,147 @@ class AppMeshClient(metaclass=abc.ABCMeta):
170
182
  self._forward_to = None
171
183
 
172
184
  # Keycloak integration
173
- self._keycloak_client = None
174
- if oauth2_config:
175
- self._keycloak_client = KeycloakClient(
176
- server_url=oauth2_config.get("server_url"),
177
- realm=oauth2_config.get("realm"),
178
- client_id=oauth2_config.get("client_id"),
179
- client_secret=oauth2_config.get("client_secret"),
180
- ssl_verify=oauth2_config.get("ssl_verify", self.ssl_verify),
181
- timeout=oauth2_config.get("timeout", (30, 60)),
182
- )
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))
289
+
184
290
  def close(self):
185
291
  """Close the session and release resources."""
186
- if hasattr(self, 'session'):
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
298
+ if hasattr(self, "session") and self.session:
187
299
  self.session.close()
188
- if self._keycloak_client and hasattr(self._keycloak_client, 'close'):
189
- self._keycloak_client.close()
190
-
300
+ self.session = 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
310
+
191
311
  def __enter__(self):
192
312
  """Support for context manager protocol."""
193
313
  return self
194
-
314
+
195
315
  def __exit__(self, exc_type, exc_val, exc_tb):
196
316
  """Support for context manager protocol, ensuring resources are released."""
197
317
  self.close()
198
318
 
319
+ def __del__(self):
320
+ """Ensure resources are properly released when the object is garbage collected."""
321
+ try:
322
+ self.close()
323
+ except Exception:
324
+ pass # Avoid exceptions during garbage collection
325
+
199
326
  @property
200
327
  def jwt_token(self) -> str:
201
328
  """Get the current JWT (JSON Web Token) used for authentication.
@@ -302,8 +429,17 @@ class AppMeshClient(metaclass=abc.ABCMeta):
302
429
  str: JWT token.
303
430
  """
304
431
  # Keycloak authentication if configured
305
- if self._keycloak_client:
306
- 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()
307
443
  return self.jwt_token
308
444
 
309
445
  # Standard App Mesh authentication
@@ -313,18 +449,22 @@ class AppMeshClient(metaclass=abc.ABCMeta):
313
449
  path="/appmesh/login",
314
450
  header={
315
451
  "Authorization": "Basic " + base64.b64encode((user_name + ":" + user_pwd).encode()).decode(),
316
- "Expire-Seconds": self._parse_duration(timeout_seconds),
317
- **({"Audience": audience} if audience else {}),
452
+ "X-Expire-Seconds": self._parse_duration(timeout_seconds),
453
+ **({"X-Audience": audience} if audience else {}),
454
+ # **({"X-Totp-Code": totp_code} if totp_code else {}),
318
455
  },
319
456
  )
320
457
  if resp.status_code == HTTPStatus.OK:
321
- if "Access-Token" in resp.json():
322
- self.jwt_token = resp.json()["Access-Token"]
323
- elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED and "Totp-Challenge" in resp.json():
324
- 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"]
325
462
  self.validate_totp(user_name, challenge, totp_code, timeout_seconds)
326
463
  else:
327
464
  raise Exception(resp.text)
465
+
466
+ if self._auto_refresh_token:
467
+ self._schedule_token_refresh()
328
468
  return self.jwt_token
329
469
 
330
470
  def validate_totp(self, username: str, challenge: str, code: str, timeout: Union[int, str] = DURATION_ONE_WEEK_ISO) -> str:
@@ -347,17 +487,16 @@ class AppMeshClient(metaclass=abc.ABCMeta):
347
487
  resp = self._request_http(
348
488
  AppMeshClient.Method.POST,
349
489
  path="/appmesh/totp/validate",
350
- header={
351
- "Username": base64.b64encode(username.encode()).decode(),
352
- "Totp": code,
353
- "Totp-Challenge": base64.b64encode(challenge.encode()).decode(),
354
- "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),
355
495
  },
356
496
  )
357
- if resp.status_code == HTTPStatus.OK:
358
- if "Access-Token" in resp.json():
359
- self.jwt_token = resp.json()["Access-Token"]
360
- 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
361
500
  raise Exception(resp.text)
362
501
 
363
502
  def logoff(self) -> bool:
@@ -366,15 +505,30 @@ class AppMeshClient(metaclass=abc.ABCMeta):
366
505
  Returns:
367
506
  bool: logoff success or failure.
368
507
  """
369
- 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):
370
518
  resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/self/logoff")
371
519
  self.jwt_token = None
372
- return resp.status_code == HTTPStatus.OK
373
- 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
374
528
 
375
529
  def authentication(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
376
530
  """Deprecated: Use authenticate() instead."""
377
- return self.authenticate(token, permission)
531
+ return self.authenticate(token, permission, audience)
378
532
 
379
533
  def authenticate(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
380
534
  """Authenticate with a token and verify permission if specified.
@@ -393,8 +547,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
393
547
  old_token = self.jwt_token
394
548
  self.jwt_token = token
395
549
  headers = {
396
- **({"Audience": audience} if audience else {}),
397
- **({"Auth-Permission": permission} if permission else {}),
550
+ **({"X-Audience": audience} if audience else {}),
551
+ **({"X-Permission": permission} if permission else {}),
398
552
  }
399
553
  resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/auth", header=headers)
400
554
  if resp.status_code != HTTPStatus.OK:
@@ -409,27 +563,46 @@ class AppMeshClient(metaclass=abc.ABCMeta):
409
563
  timeout_seconds (int | str, optional): token expire timeout of seconds. support ISO 8601 durations (e.g., 'P1Y2M3DT4H5M6S' 'P1W').
410
564
 
411
565
  Returns:
412
- 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
413
570
  """
414
- # Refresh the Keycloak token
415
- if self._keycloak_client:
416
- 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={
587
+ "X-Expire-Seconds": self._parse_duration(timeout),
588
+ },
589
+ )
590
+ if resp.status_code == HTTPStatus.OK:
591
+ if "access_token" in resp.json():
592
+ new_token = resp.json()["access_token"]
593
+ self.jwt_token = new_token
594
+ else:
595
+ raise Exception("Token renewal response missing access_token")
596
+ else:
597
+ raise Exception(resp.text)
598
+ else:
599
+ raise Exception("Unsupported token format")
600
+
417
601
  return self.jwt_token
418
602
 
419
- # Refresh the App Mesh token
420
- assert self.jwt_token
421
- resp = self._request_http(
422
- AppMeshClient.Method.POST,
423
- path="/appmesh/token/renew",
424
- header={
425
- "Expire-Seconds": self._parse_duration(timeout),
426
- },
427
- )
428
- if resp.status_code == HTTPStatus.OK:
429
- if "Access-Token" in resp.json():
430
- self.jwt_token = resp.json()["Access-Token"]
431
- return self.jwt_token
432
- raise Exception(resp.text)
603
+ except Exception as e:
604
+ logging.error("Token renewal failed: %s", str(e))
605
+ raise Exception(f"Token renewal failed: {str(e)}") from e
433
606
 
434
607
  def get_totp_secret(self) -> str:
435
608
  """Generate TOTP secret for the current user and return MFA URI.
@@ -439,7 +612,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
439
612
  """
440
613
  resp = self._request_http(method=AppMeshClient.Method.POST, path="/appmesh/totp/secret")
441
614
  if resp.status_code == HTTPStatus.OK:
442
- totp_uri = base64.b64decode(resp.json()["Mfa-Uri"]).decode()
615
+ totp_uri = base64.b64decode(resp.json()["mfa_uri"]).decode()
443
616
  return self._parse_totp_uri(totp_uri).get("secret")
444
617
  raise Exception(resp.text)
445
618
 
@@ -455,11 +628,11 @@ class AppMeshClient(metaclass=abc.ABCMeta):
455
628
  resp = self._request_http(
456
629
  method=AppMeshClient.Method.POST,
457
630
  path="/appmesh/totp/setup",
458
- header={"Totp": totp_code},
631
+ header={"X-Totp-Code": totp_code},
459
632
  )
460
633
  if resp.status_code == HTTPStatus.OK:
461
- if "Access-Token" in resp.json():
462
- self.jwt_token = resp.json()["Access-Token"]
634
+ if "access_token" in resp.json():
635
+ self.jwt_token = resp.json()["access_token"]
463
636
  return self.jwt_token
464
637
  else:
465
638
  raise Exception(resp.text)
@@ -566,8 +739,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
566
739
  "timeout": str(timeout),
567
740
  },
568
741
  )
569
- out_position = int(resp.headers["Output-Position"]) if "Output-Position" in resp.headers else None
570
- exit_code = int(resp.headers["Exit-Code"]) if "Exit-Code" in resp.headers else None
742
+ out_position = int(resp.headers["X-Output-Position"]) if "X-Output-Position" in resp.headers else None
743
+ exit_code = int(resp.headers["X-Exit-Code"]) if "X-Exit-Code" in resp.headers else None
571
744
  return AppOutput(status_code=resp.status_code, output=resp.text, out_position=out_position, exit_code=exit_code)
572
745
 
573
746
  def check_app_health(self, app_name: str) -> bool:
@@ -718,7 +891,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
718
891
  resp = self._request_http(
719
892
  method=AppMeshClient.Method.POST,
720
893
  path=f"/appmesh/user/{user_name}/passwd",
721
- header={"New-Password": base64.b64encode(new_password.encode())},
894
+ body={"new_password": base64.b64encode(new_password.encode()).decode()},
722
895
  )
723
896
  if resp.status_code != HTTPStatus.OK:
724
897
  raise Exception(resp.text)
@@ -807,6 +980,9 @@ class AppMeshClient(metaclass=abc.ABCMeta):
807
980
  Returns:
808
981
  dict: user definition.
809
982
  """
983
+ if self._keycloak_openid and isinstance(self.jwt_token, dict) and "access_token" in self.jwt_token:
984
+ return self._keycloak_openid.userinfo(self.jwt_token.get("access_token"))
985
+
810
986
  resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/self")
811
987
  if resp.status_code != HTTPStatus.OK:
812
988
  raise Exception(resp.text)
@@ -960,7 +1136,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
960
1136
  local_file (str): the local file path to be downloaded.
961
1137
  apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
962
1138
  """
963
- resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={"File-Path": remote_file})
1139
+ resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={self.HTTP_HEADER_KEY_X_FILE_PATH: remote_file})
964
1140
  resp.raise_for_status()
965
1141
 
966
1142
  # Write the file content locally
@@ -971,15 +1147,15 @@ class AppMeshClient(metaclass=abc.ABCMeta):
971
1147
 
972
1148
  # Apply file attributes (permissions, owner, group) if requested
973
1149
  if apply_file_attributes:
974
- if "File-Mode" in resp.headers:
975
- os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
976
- if "File-User" in resp.headers and "File-Group" in resp.headers:
977
- file_uid = int(resp.headers["File-User"])
978
- file_gid = int(resp.headers["File-Group"])
1150
+ if "X-File-Mode" in resp.headers:
1151
+ os.chmod(path=local_file, mode=int(resp.headers["X-File-Mode"]))
1152
+ if "X-File-User" in resp.headers and "X-File-Group" in resp.headers:
1153
+ file_uid = int(resp.headers["X-File-User"])
1154
+ file_gid = int(resp.headers["X-File-Group"])
979
1155
  try:
980
1156
  os.chown(path=local_file, uid=file_uid, gid=file_gid)
981
1157
  except PermissionError:
982
- print(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
1158
+ logging.warning(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
983
1159
 
984
1160
  def upload_file(self, local_file: str, remote_file: str, apply_file_attributes: bool = True) -> None:
985
1161
  """Upload a local file to the remote server. Optionally, the remote file will have the same permission as the local file.
@@ -1000,14 +1176,14 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1000
1176
 
1001
1177
  with open(file=local_file, mode="rb") as fp:
1002
1178
  encoder = MultipartEncoder(fields={"filename": os.path.basename(remote_file), "file": ("filename", fp, "application/octet-stream")})
1003
- header = {"File-Path": parse.quote(remote_file), "Content-Type": encoder.content_type}
1179
+ header = {self.HTTP_HEADER_KEY_X_FILE_PATH: parse.quote(remote_file), "Content-Type": encoder.content_type}
1004
1180
 
1005
1181
  # Include file attributes (permissions, owner, group) if requested
1006
1182
  if apply_file_attributes:
1007
1183
  file_stat = os.stat(local_file)
1008
- header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
1009
- header["File-User"] = str(file_stat.st_uid)
1010
- header["File-Group"] = str(file_stat.st_gid)
1184
+ header["X-File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
1185
+ header["X-File-User"] = str(file_stat.st_uid)
1186
+ header["X-File-Group"] = str(file_stat.st_gid)
1011
1187
 
1012
1188
  # Upload file with or without attributes
1013
1189
  # https://stackoverflow.com/questions/22567306/python-requests-file-upload
@@ -1090,12 +1266,15 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1090
1266
  while len(run.proc_uid) > 0:
1091
1267
  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)
1092
1268
  if app_out.output and stdout_print:
1093
- print(app_out.output, end="")
1269
+ print(app_out.output, end="", flush=True)
1094
1270
  if app_out.out_position is not None:
1095
1271
  last_output_position = app_out.out_position
1096
1272
  if app_out.exit_code is not None:
1097
1273
  # success
1098
- self.delete_app(run.app_name)
1274
+ try:
1275
+ self.delete_app(run.app_name)
1276
+ except Exception:
1277
+ pass
1099
1278
  return app_out.exit_code
1100
1279
  if app_out.status_code != HTTPStatus.OK:
1101
1280
  # failed
@@ -1147,8 +1326,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1147
1326
  if resp.status_code == HTTPStatus.OK:
1148
1327
  if stdout_print:
1149
1328
  print(resp.text, end="")
1150
- if "Exit-Code" in resp.headers:
1151
- exit_code = int(resp.headers.get("Exit-Code"))
1329
+ if "X-Exit-Code" in resp.headers:
1330
+ exit_code = int(resp.headers.get("X-Exit-Code"))
1152
1331
  elif stdout_print:
1153
1332
  print(resp.text)
1154
1333
 
@@ -1167,33 +1346,39 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1167
1346
  Returns:
1168
1347
  requests.Response: HTTP response
1169
1348
  """
1170
- # Try to refresh token via Keycloak if using Keycloak and token needs refreshing
1171
- if self._keycloak_client and self.jwt_token and not self._keycloak_client.validate_token():
1172
- self.jwt_token = self._keycloak_client.get_active_token()
1173
-
1174
- rest_url = parse.urljoin(self.server_url, path)
1349
+ rest_url = parse.urljoin(self.auth_server_url, path)
1175
1350
 
1176
1351
  header = {} if header is None else header
1177
1352
  if self.jwt_token:
1178
- header["Authorization"] = "Bearer " + self.jwt_token
1353
+ token = self.jwt_token["access_token"] if isinstance(self.jwt_token, dict) and "access_token" in self.jwt_token else self.jwt_token
1354
+ header["Authorization"] = "Bearer " + token
1179
1355
  if self.forward_to and len(self.forward_to) > 0:
1180
1356
  if ":" in self.forward_to:
1181
1357
  header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forward_to
1182
1358
  else:
1183
- header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forward_to + ":" + str(parse.urlsplit(self.server_url).port)
1359
+ header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forward_to + ":" + str(parse.urlsplit(self.auth_server_url).port)
1184
1360
  header[self.HTTP_HEADER_KEY_USER_AGENT] = self.HTTP_USER_AGENT
1185
1361
 
1186
- if method is AppMeshClient.Method.GET:
1187
- return self.session.get(url=rest_url, params=query, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1188
- elif method is AppMeshClient.Method.POST:
1189
- return self.session.post(
1190
- url=rest_url, params=query, headers=header, data=json.dumps(body) if type(body) in (dict, list) else body, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout
1191
- )
1192
- elif method is AppMeshClient.Method.POST_STREAM:
1193
- return self.session.post(url=rest_url, params=query, headers=header, data=body, cert=self.ssl_client_cert, verify=self.ssl_verify, stream=True, timeout=self.rest_timeout)
1194
- elif method is AppMeshClient.Method.DELETE:
1195
- return self.session.delete(url=rest_url, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1196
- elif method is AppMeshClient.Method.PUT:
1197
- return self.session.put(url=rest_url, params=query, headers=header, json=body, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1198
- else:
1199
- raise Exception("Invalid http method", method)
1362
+ try:
1363
+ if method is AppMeshClient.Method.GET:
1364
+ return self.session.get(url=rest_url, params=query, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1365
+ elif method is AppMeshClient.Method.POST:
1366
+ return self.session.post(
1367
+ url=rest_url,
1368
+ params=query,
1369
+ headers=header,
1370
+ data=json.dumps(body) if type(body) in (dict, list) else body,
1371
+ cert=self.ssl_client_cert,
1372
+ verify=self.ssl_verify,
1373
+ timeout=self.rest_timeout,
1374
+ )
1375
+ elif method is AppMeshClient.Method.POST_STREAM:
1376
+ return self.session.post(url=rest_url, params=query, headers=header, data=body, cert=self.ssl_client_cert, verify=self.ssl_verify, stream=True, timeout=self.rest_timeout)
1377
+ elif method is AppMeshClient.Method.DELETE:
1378
+ return self.session.delete(url=rest_url, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1379
+ elif method is AppMeshClient.Method.PUT:
1380
+ return self.session.put(url=rest_url, params=query, headers=header, json=body, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1381
+ else:
1382
+ raise Exception("Invalid http method", method)
1383
+ except requests.exceptions.RequestException as e:
1384
+ raise Exception(f"HTTP request failed: {str(e)}")
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
 
appmesh/tcp_transport.py CHANGED
@@ -31,9 +31,9 @@ class TCPTransport:
31
31
  **Note**: Unlike HTTP requests, TCP connections cannot automatically retrieve intermediate or public CA certificates.
32
32
  When `rest_ssl_verify` is a path, it explicitly identifies a CA issuer to ensure certificate validation.
33
33
 
34
- ssl_client_cert (Union[str, Tuple[str, str]], optional): Path to the SSL client certificate and key. If a `str`,
35
- it should be the path to a PEM file containing both the client certificate and private key. If a `tuple`, it should
36
- be a pair of paths: (`cert`, `key`), where `cert` is the client certificate file and `key` is the private key file.
34
+ ssl_client_cert (Union[str, Tuple[str, str]], optional): Path to the SSL client certificate and key. Can be:
35
+ - `str`: A path to a single PEM file containing both the client certificate and private key.
36
+ - `tuple`: A pair of paths (`cert_file`, `key_file`), where `cert_file` is the client certificate file path and `key_file` is the private key file path.
37
37
 
38
38
  tcp_address (Tuple[str, int], optional): Address and port for establishing a TCP connection to the server.
39
39
  Defaults to `("localhost", 6059)`.
@@ -83,21 +83,31 @@ class TCPTransport:
83
83
 
84
84
  if self.ssl_client_cert is not None:
85
85
  # Load client-side certificate and private key
86
- context.load_cert_chain(certfile=self.ssl_client_cert[0], keyfile=self.ssl_client_cert[1])
86
+ if isinstance(self.ssl_client_cert, tuple) and len(self.ssl_client_cert) == 2:
87
+ context.load_cert_chain(certfile=self.ssl_client_cert[0], keyfile=self.ssl_client_cert[1])
88
+ elif isinstance(self.ssl_client_cert, str):
89
+ # Handle case where cert and key are in the same file
90
+ context.load_cert_chain(certfile=self.ssl_client_cert)
91
+ else:
92
+ raise ValueError("ssl_client_cert must be a string filepath or a tuple of (cert_file, key_file)")
87
93
 
88
94
  # Create a TCP socket
89
95
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
90
96
  sock.setblocking(True)
91
97
  sock.settimeout(30) # Connection timeout set to 30 seconds
92
- # Wrap the socket with SSL/TLS
93
- ssl_socket = context.wrap_socket(sock, server_hostname=self.tcp_address[0])
94
- # Connect to the server
95
- ssl_socket.connect(self.tcp_address)
96
- # Disable Nagle's algorithm
97
- ssl_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
98
- # After connecting, set separate timeout for recv/send
99
- # ssl_socket.settimeout(20) # 20 seconds for recv/send
100
- self._socket = ssl_socket
98
+ try:
99
+ # Wrap the socket with SSL/TLS
100
+ ssl_socket = context.wrap_socket(sock, server_hostname=self.tcp_address[0])
101
+ # Connect to the server
102
+ ssl_socket.connect(self.tcp_address)
103
+ # Disable Nagle's algorithm
104
+ ssl_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
105
+ # After connecting, set separate timeout for recv/send
106
+ # ssl_socket.settimeout(20) # 20 seconds for recv/send
107
+ self._socket = ssl_socket
108
+ except (socket.error, ssl.SSLError) as e:
109
+ sock.close()
110
+ raise RuntimeError(f"Failed to connect to {self.tcp_address}: {e}")
101
111
 
102
112
  def close(self) -> None:
103
113
  """Close socket connection"""
@@ -115,23 +125,37 @@ class TCPTransport:
115
125
 
116
126
  def send_message(self, data) -> None:
117
127
  """Send a message with a prefixed header indicating its length"""
118
- length = len(data)
119
- # Pack the header into 8 bytes using big-endian format
120
- self._socket.sendall(struct.pack("!II", self.TCP_MESSAGE_MAGIC, length))
121
- if length > 0:
122
- self._socket.sendall(data)
128
+ if self._socket is None:
129
+ raise RuntimeError("Cannot send message: not connected")
130
+
131
+ try:
132
+ length = len(data)
133
+ # Pack the header into 8 bytes using big-endian format
134
+ self._socket.sendall(struct.pack("!II", self.TCP_MESSAGE_MAGIC, length))
135
+ if length > 0:
136
+ self._socket.sendall(data)
137
+ except (socket.error, ssl.SSLError) as e:
138
+ self.close()
139
+ raise RuntimeError(f"Error sending message: {e}")
123
140
 
124
141
  def receive_message(self) -> Optional[bytearray]:
125
142
  """Receive a message with a prefixed header indicating its length and validate it"""
126
- # Unpack the data (big-endian format)
127
- magic, length = struct.unpack("!II", self._recvall(self.TCP_MESSAGE_HEADER_LENGTH))
128
- if magic != self.TCP_MESSAGE_MAGIC:
129
- raise ValueError(f"Invalid message: incorrect magic number 0x{magic:X}.")
130
- if length > self.TCP_MAX_BLOCK_SIZE:
131
- raise ValueError(f"Message size {length} exceeds the maximum allowed size of {self.TCP_MAX_BLOCK_SIZE} bytes.")
132
- if length > 0:
133
- return self._recvall(length)
134
- return None
143
+ if self._socket is None:
144
+ raise RuntimeError("Cannot receive message: not connected")
145
+
146
+ try:
147
+ # Unpack the data (big-endian format)
148
+ magic, length = struct.unpack("!II", self._recvall(self.TCP_MESSAGE_HEADER_LENGTH))
149
+ if magic != self.TCP_MESSAGE_MAGIC:
150
+ raise ValueError(f"Invalid message: incorrect magic number 0x{magic:X}.")
151
+ if length > self.TCP_MAX_BLOCK_SIZE:
152
+ raise ValueError(f"Message size {length} exceeds the maximum allowed size of {self.TCP_MAX_BLOCK_SIZE} bytes.")
153
+ if length > 0:
154
+ return self._recvall(length)
155
+ return None
156
+ except (socket.error, ssl.SSLError) as e:
157
+ self.close()
158
+ raise RuntimeError(f"Error receiving message: {e}")
135
159
 
136
160
  def _recvall(self, length: int) -> bytearray:
137
161
  """socket recv data with fixed length
@@ -156,11 +180,14 @@ class TCPTransport:
156
180
 
157
181
  while bytes_received < length:
158
182
  # Use recv_into to read directly into our buffer
159
- chunk_size = self._socket.recv_into(view[bytes_received:], length - bytes_received)
183
+ try:
184
+ chunk_size = self._socket.recv_into(view[bytes_received:], length - bytes_received)
160
185
 
161
- if chunk_size == 0:
162
- raise EOFError("Connection closed by peer")
186
+ if chunk_size == 0:
187
+ raise EOFError("Connection closed by peer")
163
188
 
164
- bytes_received += chunk_size
189
+ bytes_received += chunk_size
190
+ except socket.timeout:
191
+ raise socket.timeout(f"Socket operation timed out after receiving {bytes_received}/{length} bytes")
165
192
 
166
193
  return buffer
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: appmesh
3
- Version: 1.4.7
3
+ Version: 1.5.0
4
4
  Summary: Client SDK for App Mesh
5
5
  Home-page: https://github.com/laoshanxi/app-mesh
6
6
  Author: laoshanxi
@@ -0,0 +1,13 @@
1
+ appmesh/__init__.py,sha256=vgiSdMzlzDwgHxBMDoFaKWb77g2nJVciRf4z_ssAlwE,431
2
+ appmesh/app.py,sha256=9Q-SOOej-MH13BU5Dv2iTa-p-sECCJQp6ZX9DjWWmwE,10526
3
+ appmesh/app_output.py,sha256=JK_TMKgjvaw4n_ys_vmN5S4MyWVZpmD7NlKz_UyMIM8,1015
4
+ appmesh/app_run.py,sha256=9ISKGZ3k3kkbQvSsPfRfkOLqD9xhbqNOM7ork9F4w9c,1712
5
+ appmesh/appmesh_client.py,sha256=0ltkqHZUq094gKneYmC0bEZCP0X9kHTp9fccKdWFWP0,339
6
+ appmesh/http_client.py,sha256=2qrJYd9MpPZz5RNWTQIgxAFmHjn0rNsxLnKt1Ajn7hU,55810
7
+ appmesh/tcp_client.py,sha256=Id1aIKVWncTSZiKRVa4sgwo1tFX2wRqOLiTnI9-dNkE,11001
8
+ appmesh/tcp_messages.py,sha256=w1Kehz_aX4X2CYAUsy9mFVJRhxnLQwwc6L58W4YkQqs,969
9
+ appmesh/tcp_transport.py,sha256=-XDTQbsKL3yCbguHeW2jNqXpYgnCyHsH4rwcaJ46AS8,8645
10
+ appmesh-1.5.0.dist-info/METADATA,sha256=AsGZC2UcCoZNNn-fPmKnP8rmGujy65aqvnPxKH6Ix1Y,11663
11
+ appmesh-1.5.0.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
12
+ appmesh-1.5.0.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
13
+ appmesh-1.5.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.2)
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,232 +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
- 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
- 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.server_url = 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.server_url}/realms/{self.realm}/protocol/openid-connect/token"
56
- self.userinfo_endpoint = f"{self.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 authenticate(self, username: str, password: str) -> str:
62
- """Authenticate with username and password.
63
-
64
- Args:
65
- username (str): Username
66
- password (str): Password
67
-
68
- Returns:
69
- str: Access token
70
-
71
- Raises:
72
- Exception: If authentication fails
73
- """
74
- data = {
75
- "client_id": self.client_id,
76
- "grant_type": "password",
77
- "username": username,
78
- "password": password,
79
- }
80
-
81
- if self.client_secret:
82
- data["client_secret"] = self.client_secret
83
-
84
- try:
85
- response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
86
-
87
- if response.status_code != 200:
88
- raise Exception(f"Authentication failed: {response.text}")
89
-
90
- token_data = response.json()
91
- self._update_token_data(token_data)
92
- return self.access_token
93
-
94
- except requests.RequestException as e:
95
- raise Exception(f"Failed to connect to Keycloak: {str(e)}")
96
-
97
- def refresh_tokens(self) -> str:
98
- """Refresh the access token using the refresh token.
99
-
100
- Returns:
101
- str: New access token
102
-
103
- Raises:
104
- Exception: If refresh fails
105
- """
106
- if not self.refresh_token:
107
- raise Exception("No refresh token available")
108
-
109
- data = {
110
- "client_id": self.client_id,
111
- "grant_type": "refresh_token",
112
- "refresh_token": self.refresh_token,
113
- }
114
-
115
- if self.client_secret:
116
- data["client_secret"] = self.client_secret
117
-
118
- try:
119
- response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
120
-
121
- if response.status_code != 200:
122
- raise Exception(f"Token refresh failed: {response.text}")
123
-
124
- token_data = response.json()
125
- self._update_token_data(token_data)
126
- return self.access_token
127
-
128
- except requests.RequestException as e:
129
- raise Exception(f"Failed to connect to Keycloak: {str(e)}")
130
-
131
- def validate_token(self) -> bool:
132
- """Check if the current token is valid and not expired.
133
-
134
- Returns:
135
- bool: True if valid, False otherwise
136
- """
137
- if not self.access_token:
138
- return False
139
-
140
- # Check if the token is expired based on our local expiry time
141
- if time.time() > self.token_expires_at:
142
- return False
143
-
144
- return True
145
-
146
- def get_active_token(self) -> str:
147
- """Get a valid access token, refreshing if necessary.
148
-
149
- Returns:
150
- str: Valid access token
151
-
152
- Raises:
153
- Exception: If no valid token can be obtained
154
- """
155
- if self.validate_token():
156
- return self.access_token
157
-
158
- if self.refresh_token:
159
- try:
160
- return self.refresh_tokens()
161
- except Exception:
162
- pass
163
-
164
- raise Exception("No valid token available and unable to refresh")
165
-
166
- def get_user_info(self) -> Dict[str, Any]:
167
- """Get information about the authenticated user.
168
-
169
- Returns:
170
- Dict[str, Any]: User information
171
-
172
- Raises:
173
- Exception: If request fails
174
- """
175
- if not self.access_token:
176
- raise Exception("Not authenticated")
177
-
178
- headers = {"Authorization": f"Bearer {self.access_token}"}
179
-
180
- try:
181
- response = self.session.get(self.userinfo_endpoint, headers=headers, verify=self.ssl_verify, timeout=self.timeout)
182
-
183
- if response.status_code != 200:
184
- raise Exception(f"Failed to get user info: {response.text}")
185
-
186
- return response.json()
187
-
188
- except requests.RequestException as e:
189
- raise Exception(f"Failed to connect to Keycloak: {str(e)}")
190
-
191
- def _update_token_data(self, token_data: Dict[str, Any]) -> None:
192
- """Update the stored token data.
193
-
194
- Args:
195
- token_data (Dict[str, Any]): Token data from Keycloak
196
- """
197
- self.access_token = token_data["access_token"]
198
- self.refresh_token = token_data.get("refresh_token")
199
-
200
- # Calculate token expiration time with configurable buffer
201
- expires_in = token_data.get("expires_in", 300)
202
- self.token_expires_at = time.time() + expires_in - self.token_refresh_threshold
203
-
204
- def close(self) -> None:
205
- """Close the session and release resources."""
206
- if hasattr(self, 'session'):
207
- self.session.close()
208
-
209
- @staticmethod
210
- def decode_token(token: str) -> Dict[str, Any]:
211
- """Decode a JWT token without verification.
212
-
213
- Args:
214
- token (str): JWT token
215
-
216
- Returns:
217
- Dict[str, Any]: Decoded token payload
218
- """
219
- parts = token.split(".")
220
- if len(parts) != 3:
221
- raise Exception("Invalid token format")
222
-
223
- # Decode the payload (second part)
224
- payload = parts[1]
225
- # Add padding if necessary
226
- payload += "=" * (4 - len(payload) % 4) if len(payload) % 4 else ""
227
-
228
- try:
229
- decoded = base64.b64decode(payload)
230
- return json.loads(decoded)
231
- except Exception as e:
232
- raise Exception(f"Failed to decode token: {str(e)}")
@@ -1,14 +0,0 @@
1
- appmesh/__init__.py,sha256=vgiSdMzlzDwgHxBMDoFaKWb77g2nJVciRf4z_ssAlwE,431
2
- appmesh/app.py,sha256=9Q-SOOej-MH13BU5Dv2iTa-p-sECCJQp6ZX9DjWWmwE,10526
3
- appmesh/app_output.py,sha256=JK_TMKgjvaw4n_ys_vmN5S4MyWVZpmD7NlKz_UyMIM8,1015
4
- appmesh/app_run.py,sha256=9ISKGZ3k3kkbQvSsPfRfkOLqD9xhbqNOM7ork9F4w9c,1712
5
- appmesh/appmesh_client.py,sha256=0ltkqHZUq094gKneYmC0bEZCP0X9kHTp9fccKdWFWP0,339
6
- appmesh/http_client.py,sha256=dtrZryrkJ-dmT_NQE5v0fZPE00-PCD9VB9xL1Wu8ORI,47403
7
- appmesh/keycloak.py,sha256=siyifDaH3EASIti8s0DzxBo477m8eCRmzKwK6dqGRDY,7497
8
- appmesh/tcp_client.py,sha256=RkHl5s8jE333BJOgxJqJ_fvjbdRQza7ciV49vLT6YO4,10923
9
- appmesh/tcp_messages.py,sha256=w1Kehz_aX4X2CYAUsy9mFVJRhxnLQwwc6L58W4YkQqs,969
10
- appmesh/tcp_transport.py,sha256=UMGby2oKV4k7lyXZUMSOe2Je34fb1w7nTkxEpatKLKg,7256
11
- appmesh-1.4.7.dist-info/METADATA,sha256=Tinrv6mkVtpVQi-ZZm_rTpCWLu-NoTEUvL-dBAQVaZg,11663
12
- appmesh-1.4.7.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
13
- appmesh-1.4.7.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
14
- appmesh-1.4.7.dist-info/RECORD,,