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