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 +16 -0
- locust_cloud/auth.py +145 -2
- locust_cloud/cloud.py +33 -43
- locust_cloud/timescale/exporter.py +2 -1
- locust_cloud/timescale/queries.py +13 -1
- locust_cloud/webui/dist/assets/{index-DYD7wLyS.js → index-BpUoQtjh.js} +75 -75
- locust_cloud/webui/dist/index.html +1 -1
- locust_cloud/webui/tsconfig.tsbuildinfo +1 -1
- {locust_cloud-1.11.1.dist-info → locust_cloud-1.11.2.dist-info}/METADATA +1 -1
- {locust_cloud-1.11.1.dist-info → locust_cloud-1.11.2.dist-info}/RECORD +12 -12
- {locust_cloud-1.11.1.dist-info → locust_cloud-1.11.2.dist-info}/WHEEL +0 -0
- {locust_cloud-1.11.1.dist-info → locust_cloud-1.11.2.dist-info}/entry_points.txt +0 -0
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=
|
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
|
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
|
-
|
222
|
-
|
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
|
-
|
242
|
+
requirements_data = None
|
243
|
+
|
237
244
|
if options.requirements:
|
238
|
-
logger.info(f"Uploading {options.requirements}")
|
239
245
|
try:
|
240
|
-
|
241
|
-
|
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"
|
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
|
}
|