locust-cloud 1.12.3__py3-none-any.whl → 1.13.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.
locust_cloud/cloud.py CHANGED
@@ -1,21 +1,29 @@
1
1
  import base64
2
2
  import gzip
3
+ import importlib.metadata
4
+ import json
3
5
  import logging
4
6
  import os
7
+ import pathlib
5
8
  import sys
9
+ import threading
6
10
  import time
7
11
  import tomllib
8
12
  import urllib.parse
13
+ import webbrowser
9
14
  from argparse import Namespace
10
15
  from collections import OrderedDict
16
+ from dataclasses import dataclass
11
17
  from typing import IO, Any
12
18
 
13
19
  import configargparse
20
+ import jwt
21
+ import platformdirs
14
22
  import requests
15
23
  import socketio
16
24
  import socketio.exceptions
17
- from locust_cloud import __version__
18
- from locust_cloud.credential_manager import CredentialError, CredentialManager
25
+
26
+ __version__ = importlib.metadata.version("locust-cloud")
19
27
 
20
28
 
21
29
  class LocustTomlConfigParser(configargparse.TomlConfigParser):
@@ -49,7 +57,7 @@ parser = configargparse.ArgumentParser(
49
57
  "cloud.conf",
50
58
  ],
51
59
  auto_env_var_prefix="LOCUSTCLOUD_",
52
- formatter_class=configargparse.RawDescriptionHelpFormatter,
60
+ formatter_class=configargparse.RawTextHelpFormatter,
53
61
  config_file_parser_class=configargparse.CompositeConfigParser(
54
62
  [
55
63
  LocustTomlConfigParser(["tool.locust"]),
@@ -108,36 +116,16 @@ advanced.add_argument(
108
116
  help="Optional requirements.txt file that contains your external libraries.",
109
117
  )
110
118
  advanced.add_argument(
111
- "--region",
112
- type=str,
113
- default=os.environ.get("AWS_DEFAULT_REGION"),
114
- help="Sets the AWS region to use for the deployed cluster, e.g. us-east-1. It defaults to use AWS_DEFAULT_REGION env var, like AWS tools.",
115
- )
116
- parser.add_argument(
117
- "--aws-access-key-id",
118
- type=str,
119
- help=configargparse.SUPPRESS,
120
- env_var="AWS_ACCESS_KEY_ID",
121
- default=None,
122
- )
123
- parser.add_argument(
124
- "--aws-secret-access-key",
125
- type=str,
126
- help=configargparse.SUPPRESS,
127
- env_var="AWS_SECRET_ACCESS_KEY",
128
- default=None,
129
- )
130
- parser.add_argument(
131
- "--username",
132
- type=str,
119
+ "--login",
120
+ action="store_true",
121
+ default=False,
133
122
  help=configargparse.SUPPRESS,
134
- default=os.getenv("LOCUST_CLOUD_USERNAME", None), # backwards compatitibility for dmdb
135
123
  )
136
- parser.add_argument(
137
- "--password",
138
- type=str,
139
- help=configargparse.SUPPRESS,
140
- default=os.getenv("LOCUST_CLOUD_PASSWORD", None), # backwards compatitibility for dmdb
124
+ advanced.add_argument(
125
+ "--non-interactive",
126
+ action="store_true",
127
+ default=False,
128
+ help="This can be set when, for example, running in a CI/CD environment to ensure no interactive steps while executing.\nRequires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set.",
141
129
  )
142
130
  parser.add_argument(
143
131
  "--workers",
@@ -153,7 +141,7 @@ parser.add_argument(
153
141
  parser.add_argument(
154
142
  "--image-tag",
155
143
  type=str,
156
- default="latest",
144
+ default=None,
157
145
  help=configargparse.SUPPRESS, # overrides the locust-cloud docker image tag. for internal use
158
146
  )
159
147
  parser.add_argument(
@@ -169,6 +157,7 @@ parser.add_argument(
169
157
  )
170
158
 
171
159
  options, locust_options = parser.parse_known_args()
160
+
172
161
  options: Namespace
173
162
  locust_options: list
174
163
 
@@ -183,51 +172,222 @@ logging.getLogger("boto3").setLevel(logging.INFO)
183
172
  logging.getLogger("requests").setLevel(logging.INFO)
184
173
  logging.getLogger("urllib3").setLevel(logging.INFO)
185
174
 
175
+ cloud_conf_file = pathlib.Path(platformdirs.user_config_dir(appname="locust-cloud")) / "config"
176
+ valid_regions = ["us-east-1", "eu-north-1"]
186
177
 
187
- api_url = os.environ.get("LOCUSTCLOUD_DEPLOYER_URL", f"https://api.{options.region}.locust.cloud/1")
188
178
 
179
+ def get_api_url(region):
180
+ return os.environ.get("LOCUSTCLOUD_DEPLOYER_URL", f"https://api.{region}.locust.cloud/1")
189
181
 
190
- def main() -> None:
191
- if options.version:
192
- print(f"locust-cloud version {__version__}")
193
- sys.exit(0)
194
182
 
195
- if not options.region:
196
- logger.error(
197
- "Setting a region is required to use Locust Cloud. Please ensure the AWS_DEFAULT_REGION env variable or the --region flag is set."
198
- )
183
+ @dataclass
184
+ class CloudConfig:
185
+ id_token: str | None = None
186
+ refresh_token: str | None = None
187
+ refresh_token_expires: int = 0
188
+ region: str | None = None
189
+
190
+
191
+ def read_cloud_config() -> CloudConfig:
192
+ if cloud_conf_file.exists():
193
+ with open(cloud_conf_file) as f:
194
+ return CloudConfig(**json.load(f))
195
+
196
+ return CloudConfig()
197
+
198
+
199
+ def write_cloud_config(config: CloudConfig) -> None:
200
+ cloud_conf_file.parent.mkdir(parents=True, exist_ok=True)
201
+
202
+ with open(cloud_conf_file, "w") as f:
203
+ json.dump(config.__dict__, f)
204
+
205
+
206
+ def web_login() -> None:
207
+ print("Enter the number for the region to authenticate against")
208
+ print()
209
+ for i, valid_region in enumerate(valid_regions, start=1):
210
+ print(f" {i}. {valid_region}")
211
+ print()
212
+ choice = input("> ")
213
+ try:
214
+ region_index = int(choice) - 1
215
+ assert 0 <= region_index < len(valid_regions)
216
+ except (ValueError, AssertionError):
217
+ print(f"Not a valid choice: '{choice}'")
199
218
  sys.exit(1)
200
- if options.region:
201
- os.environ["AWS_DEFAULT_REGION"] = options.region
202
219
 
203
- if not ((options.username and options.password) or (options.aws_access_key_id and options.aws_secret_access_key)):
204
- logger.error(
205
- "Authentication is required to use Locust Cloud. Please ensure the LOCUSTCLOUD_USERNAME and LOCUSTCLOUD_PASSWORD environment variables are set."
206
- )
220
+ region = valid_regions[region_index]
221
+
222
+ try:
223
+ response = requests.post(f"{get_api_url(region)}/cli-auth")
224
+ response.raise_for_status()
225
+ response_data = response.json()
226
+ authentication_url = response_data["authentication_url"]
227
+ result_url = response_data["result_url"]
228
+ except Exception as e:
229
+ print("Something went wrong trying to authorize the locust-cloud CLI:", str(e))
207
230
  sys.exit(1)
231
+
232
+ message = f"""
233
+ Attempting to automatically open the SSO authorization page in your default browser.
234
+ If the browser does not open or you wish to use a different device to authorize this request, open the following URL:
235
+
236
+ {authentication_url}
237
+ """.strip()
238
+ print()
239
+ print(message)
240
+
241
+ webbrowser.open_new_tab(authentication_url)
242
+
243
+ while True: # Should there be some kind of timeout?
244
+ response = requests.get(result_url)
245
+
246
+ if not response.ok:
247
+ print("Oh no!")
248
+ print(response.text)
249
+ sys.exit(1)
250
+
251
+ data = response.json()
252
+
253
+ if data["state"] == "pending":
254
+ time.sleep(1)
255
+ continue
256
+ elif data["state"] == "failed":
257
+ print(f"\nFailed to authorize CLI: {data['reason']}")
258
+ sys.exit(1)
259
+ elif data["state"] == "authorized":
260
+ print("\nAuthorization succeded")
261
+ break
262
+ else:
263
+ print("\nGot unexpected response when authorizing CLI")
264
+ sys.exit(1)
265
+
266
+ config = CloudConfig(
267
+ id_token=data["id_token"],
268
+ refresh_token=data["refresh_token"],
269
+ refresh_token_expires=data["refresh_token_expires"],
270
+ region=region,
271
+ )
272
+ write_cloud_config(config)
273
+
274
+
275
+ class ApiSession(requests.Session):
276
+ def __init__(self) -> None:
277
+ super().__init__()
278
+
279
+ if options.non_interactive:
280
+ username = os.getenv("LOCUSTCLOUD_USERNAME")
281
+ password = os.getenv("LOCUSTCLOUD_PASSWORD")
282
+ region = os.getenv("LOCUSTCLOUD_REGION")
283
+
284
+ if not all([username, password, region]):
285
+ print(
286
+ "Running with --non-interaction requires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set."
287
+ )
288
+ sys.exit(1)
289
+
290
+ if region not in valid_regions:
291
+ print("Environment variable LOCUSTCLOUD_REGION needs to be set to one of", ", ".join(valid_regions))
292
+ sys.exit(1)
293
+
294
+ self.__configure_for_region(region)
295
+ response = requests.post(
296
+ self.__login_url,
297
+ json={"username": username, "password": password},
298
+ headers={"X-Client-Version": __version__},
299
+ )
300
+ if not response.ok:
301
+ print(f"Authentication failed: {response.text}")
302
+ sys.exit(1)
303
+
304
+ self.__refresh_token = response.json()["refresh_token"]
305
+ id_token = response.json()["cognito_client_id_token"]
306
+
307
+ else:
308
+ config = read_cloud_config()
309
+
310
+ if config.refresh_token_expires < time.time() + 24 * 60 * 60:
311
+ message = "You need to authenticate before proceeding. Please run:\n locust-cloud --login"
312
+ print(message)
313
+ sys.exit(1)
314
+
315
+ assert config.region
316
+ self.__configure_for_region(config.region)
317
+ self.__refresh_token = config.refresh_token
318
+ id_token = config.id_token
319
+
320
+ assert id_token
321
+
322
+ decoded = jwt.decode(id_token, options={"verify_signature": False})
323
+ self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
324
+ self.headers["Authorization"] = f"Bearer {id_token}"
325
+
326
+ self.__sub = decoded["sub"]
327
+ self.headers["X-Client-Version"] = __version__
328
+
329
+ def __configure_for_region(self, region: str) -> None:
330
+ self.__region = region
331
+ self.api_url = get_api_url(region)
332
+ self.__login_url = f"{self.api_url}/auth/login"
333
+
334
+ logger.debug(f"Lambda url: {self.api_url}")
335
+
336
+ def __ensure_valid_authorization_header(self) -> None:
337
+ if self.__expiry_time > time.time():
338
+ return
339
+
340
+ logger.info(f"Authenticating ({self.__region}, v{__version__})")
341
+
342
+ response = requests.post(
343
+ self.__login_url,
344
+ json={"user_sub_id": self.__sub, "refresh_token": self.__refresh_token},
345
+ headers={"X-Client-Version": __version__},
346
+ )
347
+
348
+ if not response.ok:
349
+ logger.error(f"Authentication failed: {response.text}")
350
+ sys.exit(1)
351
+
352
+ # TODO: Technically the /login endpoint can return a challenge for you
353
+ # to change your password. Don't know how we should handle that
354
+ # in the cli.
355
+
356
+ id_token = response.json()["cognito_client_id_token"]
357
+ decoded = jwt.decode(id_token, options={"verify_signature": False})
358
+ self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
359
+ self.headers["Authorization"] = f"Bearer {id_token}"
360
+
361
+ if not options.non_interactive:
362
+ config = read_cloud_config()
363
+ config.id_token = id_token
364
+ write_cloud_config(config)
365
+
366
+ def request(self, method, url, *args, **kwargs) -> requests.Response:
367
+ self.__ensure_valid_authorization_header()
368
+ return super().request(method, f"{self.api_url}{url}", *args, **kwargs)
369
+
370
+
371
+ def main() -> None:
372
+ if options.version:
373
+ print(f"locust-cloud version {__version__}")
374
+ sys.exit(0)
208
375
  if not options.locustfile:
209
376
  logger.error("A locustfile is required to run a test.")
210
377
  sys.exit(1)
211
378
 
212
- try:
213
- logger.info(f"Authenticating ({options.region}, v{__version__})")
214
- logger.debug(f"Lambda url: {api_url}")
215
- credential_manager = CredentialManager(
216
- lambda_url=api_url,
217
- access_key=options.aws_access_key_id,
218
- secret_key=options.aws_secret_access_key,
219
- username=options.username,
220
- password=options.password,
221
- )
379
+ if options.login:
380
+ try:
381
+ web_login()
382
+ except KeyboardInterrupt:
383
+ pass
384
+ sys.exit()
222
385
 
223
- credentials = credential_manager.get_current_credentials()
224
- cognito_client_id_token = credentials["cognito_client_id_token"]
225
- aws_access_key_id = credentials.get("access_key")
226
- aws_secret_access_key = credentials.get("secret_key")
227
- aws_session_token = credentials.get("token", "")
386
+ session = ApiSession()
228
387
 
388
+ try:
229
389
  if options.delete:
230
- delete(credential_manager)
390
+ delete(session)
231
391
  return
232
392
 
233
393
  try:
@@ -249,47 +409,41 @@ def main() -> None:
249
409
 
250
410
  logger.info("Deploying load generators")
251
411
  locust_env_variables = [
252
- {"name": env_variable, "value": str(os.environ[env_variable])}
412
+ {"name": env_variable, "value": os.environ[env_variable]}
253
413
  for env_variable in os.environ
254
414
  if env_variable.startswith("LOCUST_")
255
- and not env_variable
256
- in [
415
+ and env_variable
416
+ not in [
257
417
  "LOCUST_LOCUSTFILE",
258
418
  "LOCUST_USERS",
259
419
  "LOCUST_WEB_HOST_DISPLAY_NAME",
260
420
  "LOCUST_SKIP_MONKEY_PATCH",
261
421
  ]
262
- and os.environ[env_variable]
263
422
  ]
264
- deploy_endpoint = f"{api_url}/deploy"
265
423
  payload = {
266
424
  "locust_args": [
267
425
  {"name": "LOCUST_USERS", "value": str(options.users)},
268
426
  {"name": "LOCUST_FLAGS", "value": " ".join(locust_options)},
269
- {"name": "LOCUSTCLOUD_DEPLOYER_URL", "value": api_url},
427
+ {"name": "LOCUSTCLOUD_DEPLOYER_URL", "value": session.api_url},
270
428
  {"name": "LOCUSTCLOUD_PROFILE", "value": options.profile},
271
429
  *locust_env_variables,
272
430
  ],
273
431
  "locustfile": {"filename": options.locustfile, "data": locustfile_data},
274
432
  "user_count": options.users,
275
- "image_tag": options.image_tag,
276
433
  "mock_server": options.mock_server,
277
434
  }
435
+
436
+ if options.image_tag is not None:
437
+ payload["image_tag"] = options.image_tag
438
+
278
439
  if options.workers is not None:
279
440
  payload["worker_count"] = options.workers
441
+
280
442
  if options.requirements:
281
443
  payload["requirements"] = {"filename": options.requirements, "data": requirements_data}
282
- headers = {
283
- "Authorization": f"Bearer {cognito_client_id_token}",
284
- "Content-Type": "application/json",
285
- "AWS_ACCESS_KEY_ID": aws_access_key_id,
286
- "AWS_SECRET_ACCESS_KEY": aws_secret_access_key,
287
- "AWS_SESSION_TOKEN": aws_session_token,
288
- "X-Client-Version": __version__,
289
- }
444
+
290
445
  try:
291
- # logger.info(payload) # might be useful when debugging sometimes
292
- response = requests.post(deploy_endpoint, json=payload, headers=headers)
446
+ response = session.post("/deploy", json=payload)
293
447
  except requests.exceptions.RequestException as e:
294
448
  logger.error(f"Failed to deploy the load generators: {e}")
295
449
  sys.exit(1)
@@ -304,32 +458,28 @@ def main() -> None:
304
458
  f"HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
305
459
  )
306
460
  sys.exit(1)
307
- except CredentialError as ce:
308
- logger.error(f"Credential error: {ce}")
309
- sys.exit(1)
461
+
310
462
  except KeyboardInterrupt:
463
+ # TODO: This would potentially leave a deployment running, combine with try-catch below?
311
464
  logger.debug("Interrupted by user")
312
465
  sys.exit(0)
313
466
 
314
467
  logger.debug("Load generators deployed successfully!")
315
468
  logger.info("Waiting for pods to be ready...")
316
469
 
470
+ shutdown_allowed = threading.Event()
471
+ shutdown_allowed.set()
472
+ reconnect_aborted = threading.Event()
473
+ connect_timeout = threading.Timer(2 * 60, reconnect_aborted.set)
474
+ sio = socketio.Client(handle_sigint=False)
475
+
317
476
  try:
318
477
  ws_connection_info = urllib.parse.urlparse(log_ws_url)
319
- sio = socketio.Client(handle_sigint=False)
320
-
321
- run = True
322
-
323
- def wait():
324
- logger.debug("Waiting for shutdown event")
325
- while run:
326
- time.sleep(0.1)
327
-
328
- logger.debug("Shutting down websocket connection")
329
- sio.shutdown()
330
478
 
331
479
  @sio.event
332
480
  def connect():
481
+ shutdown_allowed.clear()
482
+ connect_timeout.cancel()
333
483
  logger.debug("Websocket connection established, switching to Locust logs")
334
484
 
335
485
  @sio.event
@@ -350,55 +500,40 @@ def main() -> None:
350
500
  if message:
351
501
  print(message)
352
502
 
353
- nonlocal run
354
- run = False
355
-
356
- for _ in range(5 * 60): # try for 5 minutes
357
- try:
358
- sio.connect(
359
- f"{ws_connection_info.scheme}://{ws_connection_info.netloc}", socketio_path=ws_connection_info.path
360
- )
361
- break
362
- except socketio.exceptions.ConnectionError:
363
- time.sleep(1)
364
-
365
- else: # no break
366
- raise Exception("Failed to obtain socket connection")
367
-
368
- wait()
503
+ shutdown_allowed.set()
504
+
505
+ # The _reconnect_abort value on the socketio client will be populated with a newly created threading.Event if it's not already set.
506
+ # There is no way to set this by passing it in the constructor.
507
+ # This event is the only way to interupt the retry logic when the connection is attempted.
508
+ sio._reconnect_abort = reconnect_aborted
509
+ connect_timeout.start()
510
+ sio.connect(
511
+ f"{ws_connection_info.scheme}://{ws_connection_info.netloc}",
512
+ socketio_path=ws_connection_info.path,
513
+ retry=True,
514
+ )
515
+ logger.debug("Waiting for shutdown")
516
+ shutdown_allowed.wait()
369
517
 
370
518
  except KeyboardInterrupt:
371
519
  logger.debug("Interrupted by user")
372
- delete(credential_manager)
373
- wait()
520
+ delete(session)
521
+ shutdown_allowed.wait(timeout=90)
374
522
  except Exception as e:
375
523
  logger.exception(e)
376
- delete(credential_manager)
524
+ delete(session)
377
525
  sys.exit(1)
378
526
  else:
379
- delete(credential_manager)
527
+ delete(session)
528
+ finally:
529
+ sio.shutdown()
380
530
 
381
531
 
382
- def delete(credential_manager):
532
+ def delete(session):
383
533
  try:
384
534
  logger.info("Tearing down Locust cloud...")
385
- credential_manager.refresh_credentials()
386
- refreshed_credentials = credential_manager.get_current_credentials()
387
-
388
- headers = {
389
- "AWS_ACCESS_KEY_ID": refreshed_credentials.get("access_key", ""),
390
- "AWS_SECRET_ACCESS_KEY": refreshed_credentials.get("secret_key", ""),
391
- "Authorization": f"Bearer {refreshed_credentials.get('cognito_client_id_token', '')}",
392
- "X-Client-Version": __version__,
393
- }
394
-
395
- token = refreshed_credentials.get("token")
396
- if token:
397
- headers["AWS_SESSION_TOKEN"] = token
398
-
399
- response = requests.delete(
400
- f"{api_url}/teardown",
401
- headers=headers,
535
+ response = session.delete(
536
+ "/teardown",
402
537
  )
403
538
 
404
539
  if response.status_code == 200:
@@ -410,7 +545,7 @@ def delete(credential_manager):
410
545
  except Exception as e:
411
546
  logger.error(f"Could not automatically tear down Locust Cloud: {e.__class__.__name__}:{e}")
412
547
 
413
- logger.info("Done! ✨")
548
+ logger.info("Done! ✨") # FIXME: Should probably not say it's done since at this point it could still be running
414
549
 
415
550
 
416
551
  if __name__ == "__main__":
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: locust-cloud
3
- Version: 1.12.3
3
+ Version: 1.13.0
4
4
  Summary: Locust Cloud
5
5
  Project-URL: Homepage, https://locust.cloud
6
6
  Requires-Python: >=3.11
7
7
  Requires-Dist: boto3==1.34.125
8
- Requires-Dist: gevent-websocket==0.10.1
9
- Requires-Dist: locust>=2.32.5.dev10
10
- Requires-Dist: psycopg[binary,pool]>=3.2.1
8
+ Requires-Dist: configargparse==1.7
9
+ Requires-Dist: platformdirs>=4.3.6
11
10
  Requires-Dist: pyjwt>=2.0
12
11
  Requires-Dist: python-socketio[client]==5.11.4
13
12
  Description-Content-Type: text/markdown
@@ -0,0 +1,5 @@
1
+ locust_cloud/cloud.py,sha256=r0lWoP1QiT1Klkrecsr6LyPD_l1GMW2BFGRCRVkLowM,18124
2
+ locust_cloud-1.13.0.dist-info/METADATA,sha256=STWxyZOw0qE003r-Pf-8RU3KPFK3yjH_NsSo5ffoHh0,496
3
+ locust_cloud-1.13.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
+ locust_cloud-1.13.0.dist-info/entry_points.txt,sha256=PGyAb4e3aTsGS3N3VGShDl6VzJaXy7QwsEgsLOC7V00,57
5
+ locust_cloud-1.13.0.dist-info/RECORD,,