locust-cloud 1.11.1__py3-none-any.whl → 1.11.2__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/__init__.py CHANGED
@@ -57,6 +57,13 @@ def add_arguments(parser: LocustArgumentParser):
57
57
  default=False,
58
58
  action="store_true",
59
59
  )
60
+ locust_cloud.add_argument(
61
+ "--allow-forgot-password",
62
+ env_var="LOCUSTCLOUD_FORGOT_PASSWORD",
63
+ help=configargparse.SUPPRESS,
64
+ default=False,
65
+ action="store_true",
66
+ )
60
67
  locust_cloud.add_argument(
61
68
  "--graph-viewer",
62
69
  env_var="LOCUSTCLOUD_GRAPH_VIEWER",
@@ -71,6 +78,12 @@ def add_arguments(parser: LocustArgumentParser):
71
78
  help=configargparse.SUPPRESS,
72
79
  default=f"https://api.{REGION}.locust.cloud/1",
73
80
  )
81
+ locust_cloud.add_argument(
82
+ "--profile",
83
+ type=str,
84
+ env_var="LOCUSTCLOUD_PROFILE",
85
+ help=configargparse.SUPPRESS,
86
+ )
74
87
 
75
88
 
76
89
  def set_autocommit(conn: psycopg.Connection):
@@ -103,6 +116,9 @@ def on_locust_init(environment: locust.env.Environment, **_args):
103
116
  Exporter(environment, pool)
104
117
 
105
118
  if environment.web_ui:
119
+ environment.web_ui.template_args["locustVersion"] = locust.__version__
120
+ environment.web_ui.template_args["locustCloudVersion"] = __version__
121
+
106
122
  if environment.parsed_options.graph_viewer:
107
123
  environment.web_ui.template_args["isGraphViewer"] = True
108
124
 
locust_cloud/auth.py CHANGED
@@ -66,10 +66,15 @@ def register_auth(environment: locust.env.Environment):
66
66
  },
67
67
  )
68
68
 
69
+ environment.web_ui.auth_args["auth_providers"] = []
69
70
  if environment.parsed_options.allow_signup:
70
- environment.web_ui.auth_args["auth_providers"] = [
71
+ environment.web_ui.auth_args["auth_providers"].append(
71
72
  {"label": "Sign Up", "callback_url": f"{web_base_path}/signup"}
72
- ]
73
+ )
74
+ if environment.parsed_options.allow_forgot_password:
75
+ environment.web_ui.auth_args["auth_providers"].append(
76
+ {"label": "Forgot Password?", "callback_url": f"{web_base_path}/forgot-password"}
77
+ )
73
78
 
74
79
  @auth_blueprint.route("/authenticate", methods=["POST"])
75
80
  def login_submit():
@@ -87,6 +92,11 @@ def register_auth(environment: locust.env.Environment):
87
92
 
88
93
  credentials = auth_response.json()
89
94
 
95
+ if credentials.get("challenge_session"):
96
+ session["challenge_session"] = credentials.get("challenge_session")
97
+ session["username"] = username
98
+ return redirect(url_for("locust_cloud_auth.password_reset"))
99
+
90
100
  if os.getenv("CUSTOMER_ID", "") and credentials["customer_id"] != os.getenv("CUSTOMER_ID", ""):
91
101
  session["auth_error"] = "Invalid login for this deployment"
92
102
  return redirect(url_for("locust.login"))
@@ -263,4 +273,137 @@ def register_auth(environment: locust.env.Environment):
263
273
 
264
274
  return redirect(url_for("locust_cloud_auth.signup"))
265
275
 
276
+ @auth_blueprint.route("/forgot-password")
277
+ def forgot_password():
278
+ if not environment.parsed_options.allow_forgot_password:
279
+ return redirect(url_for("locust.login"))
280
+
281
+ forgot_password_args = {
282
+ "custom_form": {
283
+ "inputs": [
284
+ {
285
+ "label": "Username",
286
+ "name": "username",
287
+ "is_required": True,
288
+ "type": "email",
289
+ },
290
+ ],
291
+ "callback_url": f"{web_base_path}/send-forgot-password",
292
+ "submit_button_text": "Reset Password",
293
+ },
294
+ "info": "Enter your email and we will send a code to reset your password",
295
+ }
296
+
297
+ if session.get("auth_error"):
298
+ forgot_password_args["error"] = session["auth_error"]
299
+
300
+ return render_template_from("auth.html", auth_args=forgot_password_args)
301
+
302
+ @auth_blueprint.route("/send-forgot-password", methods=["POST"])
303
+ def send_forgot_password():
304
+ if not environment.parsed_options.allow_forgot_password:
305
+ return redirect(url_for("locust.login"))
306
+
307
+ try:
308
+ username = request.form.get("username", "")
309
+
310
+ auth_response = requests.post(
311
+ f"{environment.parsed_options.deployer_url}/auth/forgot-password",
312
+ json={"username": username},
313
+ )
314
+
315
+ auth_response.raise_for_status()
316
+
317
+ session["username"] = username
318
+
319
+ return redirect(url_for("locust_cloud_auth.password_reset"))
320
+ except requests.exceptions.HTTPError as e:
321
+ message = e.response.json().get("Message", "An unexpected error occured. Please try again.")
322
+ session["auth_info"] = ""
323
+ session["auth_error"] = message
324
+
325
+ return redirect(url_for("locust_cloud_auth.forgot_password"))
326
+
327
+ @auth_blueprint.route("/password-reset")
328
+ def password_reset():
329
+ if not environment.parsed_options.allow_forgot_password and not session.get("challenge_session"):
330
+ return redirect(url_for("locust.login"))
331
+
332
+ if session.get("challenge_session"):
333
+ reset_password_args = {
334
+ "custom_form": {
335
+ "inputs": [
336
+ {
337
+ "label": "New Password",
338
+ "name": "new_password",
339
+ "is_required": True,
340
+ "is_secret": True,
341
+ },
342
+ ],
343
+ "callback_url": f"{web_base_path}/confirm-reset-password",
344
+ "submit_button_text": "Reset Password",
345
+ },
346
+ "info": "You must set a new password",
347
+ }
348
+ else:
349
+ reset_password_args = {
350
+ "custom_form": {
351
+ "inputs": [
352
+ {
353
+ "label": "Confirmation Code",
354
+ "name": "confirmation_code",
355
+ "is_required": True,
356
+ },
357
+ {
358
+ "label": "New Password",
359
+ "name": "new_password",
360
+ "is_required": True,
361
+ "is_secret": True,
362
+ },
363
+ ],
364
+ "callback_url": f"{web_base_path}/confirm-reset-password",
365
+ "submit_button_text": "Reset Password",
366
+ },
367
+ "info": "Enter your the confirmation code that was sent to your email",
368
+ }
369
+
370
+ if session.get("auth_error"):
371
+ reset_password_args["error"] = session["auth_error"]
372
+
373
+ return render_template_from("auth.html", auth_args=reset_password_args)
374
+
375
+ @auth_blueprint.route("/confirm-reset-password", methods=["POST"])
376
+ def confirm_reset_password():
377
+ if not environment.parsed_options.allow_forgot_password and not session.get("challenge_session"):
378
+ return redirect(url_for("locust.login"))
379
+
380
+ try:
381
+ username = session["username"]
382
+ confirmation_code = request.form.get("confirmation_code")
383
+ new_password = request.form.get("new_password")
384
+
385
+ auth_response = requests.post(
386
+ f"{environment.parsed_options.deployer_url}/auth/password-reset",
387
+ json={
388
+ "username": username,
389
+ "confirmation_code": confirmation_code,
390
+ "new_password": new_password,
391
+ "challenge_session": session.get("challenge_session"),
392
+ },
393
+ )
394
+
395
+ auth_response.raise_for_status()
396
+
397
+ session["username"] = ""
398
+ session["auth_info"] = "Password reset successfully! Please login"
399
+ session["auth_error"] = ""
400
+
401
+ return redirect(url_for("locust.login"))
402
+ except requests.exceptions.HTTPError as e:
403
+ message = e.response.json().get("Message", "An unexpected error occured. Please try again.")
404
+ session["auth_info"] = ""
405
+ session["auth_error"] = message
406
+
407
+ return redirect(url_for("locust_cloud_auth.password_reset"))
408
+
266
409
  environment.web_ui.app.register_blueprint(auth_blueprint)
locust_cloud/cloud.py CHANGED
@@ -1,3 +1,5 @@
1
+ import base64
2
+ import gzip
1
3
  import json
2
4
  import logging
3
5
  import os
@@ -70,6 +72,12 @@ parser.add_argument(
70
72
  action="help",
71
73
  help=configargparse.SUPPRESS,
72
74
  )
75
+ parser.add_argument(
76
+ "-V",
77
+ "--version",
78
+ action="store_true",
79
+ help=configargparse.SUPPRESS,
80
+ )
73
81
  parser.add_argument(
74
82
  "-f",
75
83
  "--locustfile",
@@ -152,7 +160,12 @@ parser.add_argument(
152
160
  "--mock-server",
153
161
  action="store_true",
154
162
  default=False,
155
- help=configargparse.SUPPRESS,
163
+ help="Start a demo mock service and set --host parameter to point Locust towards it",
164
+ )
165
+ parser.add_argument(
166
+ "--profile",
167
+ type=str,
168
+ help="Set a profile to group the testruns together",
156
169
  )
157
170
 
158
171
  options, locust_options = parser.parse_known_args()
@@ -167,7 +180,6 @@ logger = logging.getLogger(__name__)
167
180
  # Restore log level for other libs. Yes, this can be done more nicely
168
181
  logging.getLogger("botocore").setLevel(logging.INFO)
169
182
  logging.getLogger("boto3").setLevel(logging.INFO)
170
- logging.getLogger("s3transfer").setLevel(logging.INFO)
171
183
  logging.getLogger("requests").setLevel(logging.INFO)
172
184
  logging.getLogger("urllib3").setLevel(logging.INFO)
173
185
 
@@ -176,6 +188,10 @@ api_url = os.environ.get("LOCUSTCLOUD_DEPLOYER_URL", f"https://api.{options.regi
176
188
 
177
189
 
178
190
  def main() -> None:
191
+ if options.version:
192
+ print(f"locust-cloud version {__version__}")
193
+ sys.exit(0)
194
+
179
195
  if not options.region:
180
196
  logger.error(
181
197
  "Setting a region is required to use Locust Cloud. Please ensure the AWS_DEFAULT_REGION env variable or the --region flag is set."
@@ -184,14 +200,16 @@ def main() -> None:
184
200
  if options.region:
185
201
  os.environ["AWS_DEFAULT_REGION"] = options.region
186
202
 
187
- s3_bucket = "dmdb-default" if options.region == "us-east-1" else "locust-default"
188
203
  deployments: list[Any] = []
189
204
 
190
205
  if not ((options.username and options.password) or (options.aws_access_key_id and options.aws_secret_access_key)):
191
206
  logger.error(
192
- "Authentication is required to use Locust Cloud. Please ensure the LOCUST_CLOUD_USERNAME and LOCUST_CLOUD_PASSWORD environment variables are set."
207
+ "Authentication is required to use Locust Cloud. Please ensure the LOCUSTCLOUD_USERNAME and LOCUSTCLOUD_PASSWORD environment variables are set."
193
208
  )
194
209
  sys.exit(1)
210
+ if not options.locustfile:
211
+ logger.error("A locustfile is required to run a test.")
212
+ sys.exit(1)
195
213
 
196
214
  try:
197
215
  logger.info(f"Authenticating ({options.region}, v{__version__})")
@@ -214,43 +232,22 @@ def main() -> None:
214
232
  delete(credential_manager)
215
233
  return
216
234
 
217
- logger.info(f"Uploading {options.locustfile}")
218
- logger.debug(f"... to {s3_bucket}")
219
- s3 = credential_manager.session.client("s3")
220
235
  try:
221
- filename = options.username + "__" + os.path.basename(options.locustfile)
222
- s3.upload_file(options.locustfile, s3_bucket, filename)
223
- locustfile_url = s3.generate_presigned_url(
224
- ClientMethod="get_object",
225
- Params={"Bucket": s3_bucket, "Key": filename},
226
- ExpiresIn=3600,
227
- )
228
- logger.debug(f"Uploaded {options.locustfile} successfully")
236
+ with open(options.locustfile, "rb") as f:
237
+ locustfile_data = base64.b64encode(gzip.compress(f.read())).decode()
229
238
  except FileNotFoundError:
230
239
  logger.error(f"File not found: {options.locustfile}")
231
240
  sys.exit(1)
232
- except ClientError as e:
233
- logger.error(f"Failed to upload {options.locustfile}: {e}")
234
- sys.exit(1)
235
241
 
236
- requirements_url = ""
242
+ requirements_data = None
243
+
237
244
  if options.requirements:
238
- logger.info(f"Uploading {options.requirements}")
239
245
  try:
240
- filename = options.username + "__" + "requirements.txt"
241
- s3.upload_file(options.requirements, s3_bucket, filename)
242
- requirements_url = s3.generate_presigned_url(
243
- ClientMethod="get_object",
244
- Params={"Bucket": s3_bucket, "Key": filename},
245
- ExpiresIn=3600,
246
- )
247
- logger.debug(f"Uploaded {options.requirements} successfully")
246
+ with open(options.requirements, "rb") as f:
247
+ requirements_data = base64.b64encode(gzip.compress(f.read())).decode()
248
248
  except FileNotFoundError:
249
249
  logger.error(f"File not found: {options.requirements}")
250
250
  sys.exit(1)
251
- except ClientError as e:
252
- logger.error(f"Failed to upload {options.requirements}: {e}")
253
- sys.exit(1)
254
251
 
255
252
  logger.info("Deploying load generators")
256
253
  locust_env_variables = [
@@ -269,19 +266,21 @@ def main() -> None:
269
266
  deploy_endpoint = f"{api_url}/deploy"
270
267
  payload = {
271
268
  "locust_args": [
272
- {"name": "LOCUST_LOCUSTFILE", "value": locustfile_url},
273
269
  {"name": "LOCUST_USERS", "value": str(options.users)},
274
270
  {"name": "LOCUST_FLAGS", "value": " ".join(locust_options)},
275
- {"name": "LOCUSTCLOUD_REQUIREMENTS_URL", "value": requirements_url},
276
271
  {"name": "LOCUSTCLOUD_DEPLOYER_URL", "value": api_url},
272
+ {"name": "LOCUSTCLOUD_PROFILE", "value": options.profile},
277
273
  *locust_env_variables,
278
274
  ],
275
+ "locustfile": {"filename": options.locustfile, "data": locustfile_data},
279
276
  "user_count": options.users,
280
277
  "image_tag": options.image_tag,
281
278
  "mock_server": options.mock_server,
282
279
  }
283
280
  if options.workers is not None:
284
281
  payload["worker_count"] = options.workers
282
+ if options.requirements:
283
+ payload["requirements"] = {"filename": options.requirements, "data": requirements_data}
285
284
  headers = {
286
285
  "Authorization": f"Bearer {cognito_client_id_token}",
287
286
  "Content-Type": "application/json",
@@ -301,7 +300,7 @@ def main() -> None:
301
300
  deployments = response.json().get("deployments", [])
302
301
  else:
303
302
  try:
304
- logger.error(f"Error when deploying: {response.json()['Message']}")
303
+ logger.error(f"{response.json()['Message']} (HTTP {response.status_code}/{response.reason})")
305
304
  except Exception:
306
305
  logger.error(
307
306
  f"HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
@@ -421,15 +420,6 @@ def delete(credential_manager):
421
420
  except Exception as e:
422
421
  logger.error(f"Could not automatically tear down Locust Cloud: {e.__class__.__name__}:{e}")
423
422
 
424
- try:
425
- logger.debug("Cleaning up locustfiles")
426
- # s3 = credential_manager.session.resource("s3")
427
- # bucket = s3.Bucket(s3_bucket)
428
- # bucket.objects.all().delete()
429
- except ClientError as e:
430
- logger.debug(f"Failed to clean up locust files: {e}")
431
- # sys.exit(1)
432
-
433
423
  logger.info("Done! ✨")
434
424
 
435
425
 
@@ -217,7 +217,7 @@ class Exporter:
217
217
  cmd = sys.argv[1:]
218
218
  with self.pool.connection() as conn:
219
219
  conn.execute(
220
- "INSERT INTO testruns (id, num_users, worker_count, username, locustfile, arguments, customer) VALUES (%s,%s,%s,%s,%s,%s,current_user)",
220
+ "INSERT INTO testruns (id, num_users, worker_count, username, locustfile, profile, arguments, customer) VALUES (%s,%s,%s,%s,%s,%s,%s,current_user)",
221
221
  (
222
222
  self._run_id,
223
223
  self.env.runner.target_user_count if self.env.runner else 1,
@@ -229,6 +229,7 @@ class Exporter:
229
229
  else 0,
230
230
  self.env.web_ui.template_args.get("username", "") if self.env.web_ui else "",
231
231
  self.env.parsed_locustfiles[0].split("/")[-1].split("__")[-1],
232
+ self.env.parsed_options.profile,
232
233
  " ".join(cmd),
233
234
  ),
234
235
  )
@@ -183,7 +183,9 @@ ORDER BY 1,2
183
183
  testruns = """
184
184
  SELECT
185
185
  id as "runId",
186
- end_time as "endTime"
186
+ end_time as "endTime",
187
+ locustfile,
188
+ profile
187
189
  FROM testruns
188
190
  ORDER BY id DESC
189
191
  """
@@ -277,6 +279,15 @@ FROM customers
277
279
  WHERE id = current_user
278
280
  """
279
281
 
282
+ profiles = """
283
+ SELECT DISTINCT
284
+ CASE
285
+ WHEN profile IS NOT NULL THEN profile
286
+ ELSE locustfile
287
+ END AS profile
288
+ FROM testruns
289
+ """
290
+
280
291
  queries: dict["str", LiteralString] = {
281
292
  "request-names": request_names,
282
293
  "requests": requests_query,
@@ -297,4 +308,5 @@ queries: dict["str", LiteralString] = {
297
308
  "testruns-response-time": testruns_response_time,
298
309
  "total-vuh": total_vuh,
299
310
  "customer": customer,
311
+ "profiles": profiles,
300
312
  }