locust-cloud 1.0.17__py3-none-any.whl → 1.0.19__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/auth.py CHANGED
@@ -59,10 +59,10 @@ def register_auth(environment):
59
59
 
60
60
  return response
61
61
 
62
- @environment.web_ui.app.route("/authenticate")
62
+ @environment.web_ui.app.route("/authenticate", methods=["POST"])
63
63
  def login_submit():
64
- username = request.args.get("username")
65
- password = request.args.get("password")
64
+ username = request.form.get("username")
65
+ password = request.form.get("password")
66
66
 
67
67
  try:
68
68
  auth_response = requests.post(f"{LAMBDA}/auth/login", json={"username": username, "password": password})
locust_cloud/cloud.py CHANGED
@@ -1,5 +1,7 @@
1
+ import importlib.metadata
1
2
  import json
2
3
  import logging
4
+ import math
3
5
  import os
4
6
  import sys
5
7
  import time
@@ -13,20 +15,15 @@ import requests
13
15
  from botocore.exceptions import ClientError
14
16
  from locust_cloud.constants import (
15
17
  DEFAULT_CLUSTER_NAME,
18
+ DEFAULT_LAMBDA_URL,
16
19
  DEFAULT_NAMESPACE,
17
20
  DEFAULT_REGION_NAME,
18
- LAMBDA_URL,
21
+ USERS_PER_WORKER,
19
22
  )
20
23
  from locust_cloud.credential_manager import CredentialError, CredentialManager
21
24
 
22
- logging.basicConfig(
23
- format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
24
- level=logging.INFO,
25
- )
26
- logger = logging.getLogger(__name__)
27
-
28
-
29
25
  LOCUST_ENV_VARIABLE_IGNORE_LIST = ["LOCUST_BUILD_PATH", "LOCUST_SKIP_MONKEY_PATCH"]
26
+ __version__ = importlib.metadata.version("locust-cloud")
30
27
 
31
28
 
32
29
  class LocustTomlConfigParser(configargparse.TomlConfigParser):
@@ -67,9 +64,9 @@ parser = configargparse.ArgumentParser(
67
64
  configargparse.DefaultConfigFileParser,
68
65
  ]
69
66
  ),
70
- description="""A tool for Locust Cloud users to deploy clusters.
67
+ description="""Launches distributed Locust runs on locust.cloud infrastructure.
71
68
 
72
- Example: locust-cloud -f locust.py --aws-access-key-id 123 --aws-secret-access-key 456""",
69
+ Example: locust-cloud -f my_locustfile.py --aws-region-name us-east-1 --users 1000""",
73
70
  epilog="""Any parameters not listed here are forwarded to locust master unmodified, so go ahead and use things like --users, --host, --run-time, ...
74
71
  Locust config can also be set using config file (~/.locust.conf, locust.conf, pyproject.toml, ~/.cloud.conf or cloud.conf).
75
72
  Parameters specified on command line override env vars, which in turn override config files.""",
@@ -113,61 +110,104 @@ parser.add_argument(
113
110
  help="Sets the namespace for scoping the deployed cluster",
114
111
  env_var="KUBE_NAMESPACE",
115
112
  )
113
+ parser.add_argument(
114
+ "--lambda-url",
115
+ type=str,
116
+ default=DEFAULT_LAMBDA_URL,
117
+ help="Sets the namespace for scoping the deployed cluster",
118
+ env_var="LOCUST_CLOUD_LAMBDA",
119
+ )
116
120
  parser.add_argument(
117
121
  "--aws-access-key-id",
118
122
  type=str,
119
- help="Authentication for deploying with Locust Cloud",
123
+ help=configargparse.SUPPRESS,
120
124
  env_var="AWS_ACCESS_KEY_ID",
125
+ default=None,
121
126
  )
122
127
  parser.add_argument(
123
128
  "--aws-secret-access-key",
124
129
  type=str,
125
- help="Authentication for deploying with Locust Cloud",
130
+ help=configargparse.SUPPRESS,
126
131
  env_var="AWS_SECRET_ACCESS_KEY",
132
+ default=None,
133
+ )
134
+ parser.add_argument(
135
+ "--username",
136
+ type=str,
137
+ help="Authentication for deploying with Locust Cloud",
138
+ env_var="LOCUST_CLOUD_USERNAME",
139
+ default=None,
140
+ )
141
+ parser.add_argument(
142
+ "--password",
143
+ type=str,
144
+ help="Authentication for deploying with Locust Cloud",
145
+ env_var="LOCUST_CLOUD_PASSWORD",
146
+ default=None,
147
+ )
148
+ parser.add_argument(
149
+ "--loglevel",
150
+ "-L",
151
+ type=str,
152
+ help="Log level",
153
+ env_var="LOCUST_CLOUD_LOGLEVEL",
154
+ default="INFO",
155
+ )
156
+ parser.add_argument(
157
+ "--workers",
158
+ type=str,
159
+ help="Number of workers to use for the deployment",
160
+ env_var="LOCUST_CLOUD_WORKERS",
161
+ default=None,
127
162
  )
128
163
 
129
164
  options, locust_options = parser.parse_known_args()
130
-
131
- username = os.environ.get("LOCUST_CLOUD_USERNAME")
132
- password = os.environ.get("LOCUST_CLOUD_PASSWORD")
165
+ logging.basicConfig(
166
+ format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
167
+ level=options.loglevel.upper(),
168
+ )
169
+ logger = logging.getLogger(__name__)
170
+ # Restore log level for other libs. Yes, this can be done more nicely
171
+ logging.getLogger("botocore").setLevel(logging.INFO)
172
+ logging.getLogger("boto3").setLevel(logging.INFO)
173
+ logging.getLogger("s3transfer").setLevel(logging.INFO)
174
+ logging.getLogger("requests").setLevel(logging.INFO)
175
+ logging.getLogger("urllib3").setLevel(logging.INFO)
133
176
 
134
177
 
135
178
  def main() -> None:
136
179
  s3_bucket = f"{options.kube_cluster_name}-{options.kube_namespace}"
137
180
  deployed_pods: list[Any] = []
181
+ worker_count = options.workers or math.ceil(locust_options.users / USERS_PER_WORKER)
182
+ worker_count = worker_count if worker_count > 2 else 2
138
183
 
139
184
  try:
140
- if options.aws_access_key_id and options.aws_secret_access_key:
141
- credential_manager = CredentialManager(
142
- lambda_url=LAMBDA_URL,
143
- region_name=options.aws_region_name,
144
- access_key=options.aws_access_key_id,
145
- secret_key=options.aws_secret_access_key,
146
- )
147
- elif username and password:
148
- credential_manager = CredentialManager(
149
- lambda_url=LAMBDA_URL,
150
- username=username,
151
- password=password,
152
- region_name=options.aws_region_name,
153
- )
154
- else:
185
+ if not (
186
+ (options.username and options.password) or (options.aws_access_key_id and options.aws_secret_access_key)
187
+ ):
155
188
  logger.error(
156
- "Authentication is required to use Locust Cloud. Provide either AWS credentials or set the LOCUST_CLOUD_USERNAME and LOCUST_CLOUD_PASSWORD environment variables."
189
+ "Authentication is required to use Locust Cloud. Please ensure the LOCUST_CLOUD_USERNAME and LOCUST_CLOUD_PASSWORD environment variables are set."
157
190
  )
158
191
  sys.exit(1)
159
192
 
193
+ logger.info(f"Logging you into Locust Cloud ({options.lambda_url}, {options.aws_region_name}, v{__version__})")
194
+
195
+ credential_manager = CredentialManager(
196
+ lambda_url=options.lambda_url,
197
+ access_key=options.aws_access_key_id,
198
+ secret_key=options.aws_secret_access_key,
199
+ username=options.username,
200
+ password=options.password,
201
+ region_name=options.aws_region_name,
202
+ )
203
+
160
204
  credentials = credential_manager.get_current_credentials()
161
- cognito_client_id_token: str = credentials["cognito_client_id_token"]
205
+ cognito_client_id_token = credentials["cognito_client_id_token"]
162
206
  aws_access_key_id = credentials.get("access_key")
163
207
  aws_secret_access_key = credentials.get("secret_key")
164
- aws_session_token = credentials.get("token")
165
-
166
- if not all([aws_access_key_id, aws_secret_access_key]):
167
- logger.error("Authentication failed: Missing AWS credentials.")
168
- sys.exit(1)
208
+ aws_session_token = credentials.get("token", "")
169
209
 
170
- logger.info(f"Uploading {options.locustfile} to S3 bucket {s3_bucket}...")
210
+ logger.info(f"Uploading {options.locustfile}")
171
211
  s3 = credential_manager.session.client("s3")
172
212
  try:
173
213
  s3.upload_file(options.locustfile, s3_bucket, os.path.basename(options.locustfile))
@@ -176,17 +216,17 @@ def main() -> None:
176
216
  Params={"Bucket": s3_bucket, "Key": os.path.basename(options.locustfile)},
177
217
  ExpiresIn=3600,
178
218
  )
179
- logger.info(f"Uploaded {options.locustfile} successfully.")
219
+ logger.debug(f"Uploaded {options.locustfile} successfully")
180
220
  except FileNotFoundError:
181
221
  logger.error(f"File not found: {options.locustfile}")
182
222
  sys.exit(1)
183
223
  except ClientError as e:
184
- logger.error(f"Failed to upload {options.locustfile} to S3: {e}")
224
+ logger.error(f"Failed to upload {options.locustfile}: {e}")
185
225
  sys.exit(1)
186
226
 
187
227
  requirements_url = ""
188
228
  if options.requirements:
189
- logger.info(f"Uploading {options.requirements} to S3 bucket {s3_bucket} as requirements.txt...")
229
+ logger.info(f"Uploading {options.requirements}")
190
230
  try:
191
231
  s3.upload_file(options.requirements, s3_bucket, "requirements.txt")
192
232
  requirements_url = s3.generate_presigned_url(
@@ -194,44 +234,46 @@ def main() -> None:
194
234
  Params={"Bucket": s3_bucket, "Key": "requirements.txt"},
195
235
  ExpiresIn=3600,
196
236
  )
197
- logger.info(f"Uploaded {options.requirements} successfully.")
237
+ logger.debug(f"Uploaded {options.requirements} successfully")
198
238
  except FileNotFoundError:
199
239
  logger.error(f"File not found: {options.requirements}")
200
240
  sys.exit(1)
201
241
  except ClientError as e:
202
- logger.error(f"Failed to upload {options.requirements} to S3: {e}")
242
+ logger.error(f"Failed to upload {options.requirements}: {e}")
203
243
  sys.exit(1)
204
244
 
205
- logger.info("Deploying load generators via API Gateway...")
245
+ logger.info("Deploying load generators")
206
246
  locust_env_variables = [
207
247
  {"name": env_variable, "value": str(os.environ[env_variable])}
208
248
  for env_variable in os.environ
209
249
  if env_variable.startswith("LOCUST_") and os.environ[env_variable]
210
250
  ]
211
- deploy_endpoint = f"{LAMBDA_URL}/{options.kube_cluster_name}"
251
+ deploy_endpoint = f"{options.lambda_url}/{options.kube_cluster_name}"
212
252
  payload = {
213
253
  "locust_args": [
214
254
  {"name": "LOCUST_LOCUSTFILE", "value": locustfile_url},
215
255
  {"name": "LOCUST_REQUIREMENTS_URL", "value": requirements_url},
216
256
  {"name": "LOCUST_FLAGS", "value": " ".join(locust_options)},
257
+ {"name": "LOCUST_WEB_HOST_DISPLAY_NAME", "value": "_____"},
217
258
  *locust_env_variables,
218
- ]
259
+ ],
260
+ "worker_count": worker_count,
219
261
  }
220
262
  headers = {
221
263
  "Authorization": f"Bearer {cognito_client_id_token}",
222
264
  "Content-Type": "application/json",
223
265
  "AWS_ACCESS_KEY_ID": aws_access_key_id,
224
266
  "AWS_SECRET_ACCESS_KEY": aws_secret_access_key,
225
- "AWS_SESSION_TOKEN": aws_session_token if aws_session_token else "",
267
+ "AWS_SESSION_TOKEN": aws_session_token,
226
268
  }
227
269
  try:
228
270
  response = requests.post(deploy_endpoint, json=payload, headers=headers)
229
271
  except requests.exceptions.RequestException as e:
230
- logger.error(f"HTTP request failed: {e}")
272
+ logger.error(f"Failed to deploy the load generators: {e}")
231
273
  sys.exit(1)
274
+
232
275
  if response.status_code == 200:
233
276
  deployed_pods = response.json().get("pods", [])
234
- logger.info("Load generators deployed successfully.")
235
277
  else:
236
278
  logger.error(
237
279
  f"HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
@@ -241,13 +283,20 @@ def main() -> None:
241
283
  logger.error(f"Credential error: {ce}")
242
284
  sys.exit(1)
243
285
 
286
+ log_group_name = f"/eks/{options.kube_cluster_name}-{options.kube_namespace}"
287
+ master_pod_name = next((pod for pod in deployed_pods if "master" in pod), None)
288
+
289
+ if not master_pod_name:
290
+ logger.error(
291
+ "Master pod not found among deployed pods. Something went wrong during the load generator deployment, please try again or contact an administrator for assistance"
292
+ )
293
+ sys.exit(1)
294
+
295
+ logger.debug("Load generators deployed successfully!")
296
+
244
297
  try:
245
298
  logger.info("Waiting for pods to be ready...")
246
- log_group_name = f"/eks/{options.kube_cluster_name}-{options.kube_namespace}"
247
- master_pod_name = next((pod for pod in deployed_pods if "master" in pod), None)
248
- if not master_pod_name:
249
- logger.error("Master pod not found among deployed pods.")
250
- sys.exit(1)
299
+
251
300
  log_stream: str | None = None
252
301
  while log_stream is None:
253
302
  try:
@@ -264,7 +313,7 @@ def main() -> None:
264
313
  except ClientError as e:
265
314
  logger.error(f"Error describing log streams: {e}")
266
315
  time.sleep(5)
267
- logger.info("Pods are ready, switching to Locust logs.")
316
+ logger.debug("Pods are ready, switching to Locust logs")
268
317
 
269
318
  timestamp = int((datetime.now(UTC) - timedelta(minutes=5)).timestamp() * 1000)
270
319
 
@@ -291,10 +340,10 @@ def main() -> None:
291
340
  except ClientError as e:
292
341
  error_code = e.response.get("Error", {}).get("Code", "")
293
342
  if error_code == "ExpiredTokenException":
294
- logger.warning("AWS session token expired during log streaming. Refreshing credentials...")
343
+ logger.debug("AWS session token expired during log streaming. Refreshing credentials.")
295
344
  time.sleep(5)
296
345
  except KeyboardInterrupt:
297
- logger.debug("Interrupted by user.")
346
+ logger.debug("Interrupted by user")
298
347
  except Exception as e:
299
348
  logger.exception(e)
300
349
  sys.exit(1)
@@ -315,28 +364,26 @@ def main() -> None:
315
364
  headers["AWS_SESSION_TOKEN"] = token
316
365
 
317
366
  response = requests.delete(
318
- f"{LAMBDA_URL}/{options.kube_cluster_name}",
367
+ f"{options.lambda_url}/{options.kube_cluster_name}",
319
368
  headers=headers,
320
369
  params={"namespace": options.kube_namespace} if options.kube_namespace else {},
321
370
  )
322
371
 
323
372
  if response.status_code != 200:
324
373
  logger.error(
325
- f"HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
374
+ f"Could not automatically tear down Locust Cloud: HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
326
375
  )
327
- else:
328
- logger.info("Cluster teardown initiated successfully.")
329
376
  except Exception as e:
330
377
  logger.error(f"Could not automatically tear down Locust Cloud: {e}")
331
378
 
332
379
  try:
333
- logger.info(f"Cleaning up S3 bucket: {s3_bucket}")
380
+ logger.info("Cleaning up locust files")
334
381
  s3 = credential_manager.session.resource("s3")
335
382
  bucket = s3.Bucket(s3_bucket)
336
383
  bucket.objects.all().delete()
337
- logger.info(f"S3 bucket {s3_bucket} cleaned up successfully.")
384
+ logger.info("Done! ")
338
385
  except ClientError as e:
339
- logger.error(f"Failed to clean up S3 bucket {s3_bucket}: {e}")
386
+ logger.error(f"Failed to clean up locust files: {e}")
340
387
  sys.exit(1)
341
388
 
342
389
 
locust_cloud/constants.py CHANGED
@@ -1,4 +1,5 @@
1
1
  DEFAULT_REGION_NAME = "us-east-1"
2
2
  DEFAULT_CLUSTER_NAME = "dmdb"
3
3
  DEFAULT_NAMESPACE = "default"
4
- LAMBDA_URL = "https://api.locust.cloud/1"
4
+ DEFAULT_LAMBDA_URL = "https://api.locust.cloud/1"
5
+ USERS_PER_WORKER = 500
@@ -10,10 +10,6 @@ from botocore.credentials import RefreshableCredentials
10
10
  from botocore.session import Session as BotocoreSession
11
11
  from locust_cloud.constants import DEFAULT_REGION_NAME
12
12
 
13
- logging.basicConfig(
14
- format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
15
- level=logging.INFO,
16
- )
17
13
  logger = logging.getLogger(__name__)
18
14
 
19
15