locust-cloud 1.8.0__py3-none-any.whl → 1.9.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/auth.py CHANGED
@@ -1,17 +1,20 @@
1
1
  import logging
2
2
  import os
3
3
  from datetime import UTC, datetime, timedelta
4
- from typing import TypedDict
4
+ from typing import Any, TypedDict, cast
5
5
 
6
6
  import locust.env
7
7
  import requests
8
8
  import werkzeug
9
- from flask import redirect, request, url_for
9
+ from flask import Blueprint, redirect, request, session, url_for
10
10
  from flask_login import UserMixin, login_user
11
+ from locust.html import render_template_from
11
12
  from locust_cloud import __version__
12
- from locust_cloud.constants import DEFAULT_DEPLOYER_URL
13
13
 
14
- DEPLOYER_URL = os.environ.get("LOCUSTCLOUD_DEPLOYER_URL", DEFAULT_DEPLOYER_URL)
14
+ REGION = os.environ.get("AWS_DEFAULT_REGION")
15
+ DEPLOYER_URL = f"https://api.{REGION}.locust.cloud/1"
16
+ ALLOW_SIGNUP = os.environ.get("ALLOW_SIGNUP", True)
17
+
15
18
 
16
19
  logger = logging.getLogger(__name__)
17
20
 
@@ -47,6 +50,9 @@ def register_auth(environment: locust.env.Environment):
47
50
  environment.web_ui.app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
48
51
  environment.web_ui.app.debug = False
49
52
 
53
+ web_base_path = environment.parsed_options.web_base_path
54
+ auth_blueprint = Blueprint("locust_cloud_auth", __name__, url_prefix=web_base_path)
55
+
50
56
  def load_user(user_sub_id: str):
51
57
  username = request.cookies.get("username")
52
58
  refresh_token = request.cookies.get("user_token")
@@ -58,11 +64,19 @@ def register_auth(environment: locust.env.Environment):
58
64
  return None
59
65
 
60
66
  environment.web_ui.login_manager.user_loader(load_user)
61
- environment.web_ui.auth_args = {
62
- "username_password_callback": "/authenticate",
63
- }
67
+ environment.web_ui.auth_args = cast(
68
+ Any,
69
+ {
70
+ "username_password_callback": f"{web_base_path}/authenticate",
71
+ },
72
+ )
73
+
74
+ if ALLOW_SIGNUP:
75
+ environment.web_ui.auth_args["auth_providers"] = [
76
+ {"label": "Sign Up", "callback_url": f"{web_base_path}/signup"}
77
+ ]
64
78
 
65
- @environment.web_ui.app.route("/authenticate", methods=["POST"])
79
+ @auth_blueprint.route("/authenticate", methods=["POST"])
66
80
  def login_submit():
67
81
  username = request.form.get("username", "")
68
82
  password = request.form.get("password")
@@ -77,19 +91,168 @@ def register_auth(environment: locust.env.Environment):
77
91
  auth_response.raise_for_status()
78
92
 
79
93
  credentials = auth_response.json()
80
- response = redirect(url_for("index"))
94
+ response = redirect(url_for("locust.index"))
81
95
  response = set_credentials(username, credentials, response)
82
96
  login_user(AuthUser(credentials["user_sub_id"]))
83
97
 
84
98
  return response
85
99
  except requests.exceptions.HTTPError as e:
86
100
  if e.response.status_code == 401:
87
- environment.web_ui.auth_args["error"] = "Invalid username or password"
101
+ session["auth_error"] = "Invalid username or password"
88
102
  else:
89
103
  logger.error(f"Unknown response from auth: {e.response.status_code} {e.response.text}")
90
104
 
91
- environment.web_ui.auth_args["error"] = (
92
- "Unknown error during authentication, check logs and/or contact support"
93
- )
105
+ session["auth_error"] = "Unknown error during authentication, check logs and/or contact support"
106
+
107
+ return redirect(url_for("locust.login"))
108
+
109
+ @auth_blueprint.route("/signup")
110
+ def signup():
111
+ if not ALLOW_SIGNUP:
112
+ return redirect(url_for("locust.login"))
113
+
114
+ if session.get("username"):
115
+ sign_up_args = {
116
+ "custom_form": {
117
+ "inputs": [
118
+ {
119
+ "label": "Confirmation Code",
120
+ "name": "confirmation_code",
121
+ },
122
+ ],
123
+ "callback_url": f"{web_base_path}/confirm-signup",
124
+ "submit_button_text": "Confirm Email",
125
+ },
126
+ }
127
+ else:
128
+ sign_up_args = {
129
+ "custom_form": {
130
+ "inputs": [
131
+ {
132
+ "label": "Username",
133
+ "name": "username",
134
+ },
135
+ {
136
+ "label": "Password",
137
+ "name": "password",
138
+ "is_secret": True,
139
+ },
140
+ {
141
+ "label": "Access Code",
142
+ "name": "access_code",
143
+ },
144
+ {
145
+ "label": "I consent to:\n\n1. Only test your own website/service or our example example target\n\n2. Only use locust-cloud for its intended purpose: to load test other sites/services.\n\n3. Not attempt to circumvent your account limitations (e.g. max user count or max request count)\n\n4. Not use personal data (real names, addresses etc) in your tests.",
146
+ "name": "consent",
147
+ "default_value": False,
148
+ },
149
+ ],
150
+ "callback_url": f"{web_base_path}/create-account",
151
+ "submit_button_text": "Sign Up",
152
+ },
153
+ }
154
+
155
+ if session.get("auth_info"):
156
+ sign_up_args["info"] = session["auth_info"]
157
+ if session.get("auth_sign_up_error"):
158
+ sign_up_args["error"] = session["auth_sign_up_error"]
159
+
160
+ return render_template_from(
161
+ "auth.html",
162
+ auth_args=sign_up_args,
163
+ )
164
+
165
+ @auth_blueprint.route("/create-account", methods=["POST"])
166
+ def create_account():
167
+ if not ALLOW_SIGNUP:
168
+ return redirect(url_for("locust.login"))
169
+
170
+ session["auth_sign_up_error"] = ""
171
+ session["auth_info"] = ""
172
+
173
+ username = request.form.get("username", "")
174
+ password = request.form.get("password")
175
+ access_code = request.form.get("access_code")
176
+ has_consented = request.form.get("consent")
177
+
178
+ if not has_consented:
179
+ session["auth_sign_up_error"] = "Please accept the terms and conditions to create an account."
180
+
181
+ return redirect(url_for("locust_cloud_auth.signup"))
182
+
183
+ try:
184
+ auth_response = requests.post(
185
+ f"{DEPLOYER_URL}/auth/signup",
186
+ json={"username": username, "password": password, "access_code": access_code},
187
+ )
188
+
189
+ auth_response.raise_for_status()
190
+
191
+ session["user_sub"] = auth_response.json().get("user_sub")
192
+ session["username"] = username
193
+ session["auth_info"] = (
194
+ "Please check your email and enter the confirmation code. If you didn't get a code after one minute, you can [request a new one](/resend-code)"
195
+ )
196
+
197
+ return redirect(url_for("locust_cloud_auth.signup"))
198
+ except requests.exceptions.HTTPError as e:
199
+ message = e.response.json().get("Message", "An unexpected error occured. Please try again.")
200
+ session["auth_info"] = ""
201
+ session["auth_sign_up_error"] = message
202
+
203
+ return redirect(url_for("locust_cloud_auth.signup"))
204
+
205
+ @auth_blueprint.route("/resend-code")
206
+ def resend_code():
207
+ try:
208
+ auth_response = requests.post(
209
+ "http://localhost:8000/1/auth/resend-confirmation",
210
+ json={"username": session["username"]},
211
+ )
212
+
213
+ auth_response.raise_for_status()
214
+
215
+ session["auth_sign_up_error"] = ""
216
+ session["auth_info"] = "Confirmation code sent, please check your email."
217
+
218
+ return redirect(url_for("locust_cloud_auth.signup"))
219
+ except requests.exceptions.HTTPError as e:
220
+ message = e.response.json().get("Message", "An unexpected error occured. Please try again.")
221
+ session["auth_info"] = ""
222
+ session["auth_sign_up_error"] = message
223
+
224
+ return redirect(url_for("locust_cloud_auth.signup"))
225
+
226
+ @auth_blueprint.route("/confirm-signup", methods=["POST"])
227
+ def confirm_signup():
228
+ if not ALLOW_SIGNUP:
229
+ return redirect(url_for("locust.login"))
230
+
231
+ session["auth_sign_up_error"] = ""
232
+ confirmation_code = request.form.get("confirmation_code")
233
+
234
+ try:
235
+ auth_response = requests.post(
236
+ f"{DEPLOYER_URL}/auth/confirm-signup",
237
+ json={
238
+ "username": session.get("username"),
239
+ "user_sub": session["user_sub"],
240
+ "confirmation_code": confirmation_code,
241
+ },
242
+ )
243
+
244
+ auth_response.raise_for_status()
245
+
246
+ session["username"] = None
247
+ session["auth_info"] = "Account created successfully!"
248
+ session["auth_sign_up_error"] = ""
249
+
250
+ return redirect(url_for("locust.login"))
251
+ except requests.exceptions.HTTPError as e:
252
+ message = e.response.json().get("Message", "An unexpected error occured. Please try again.")
253
+ session["auth_info"] = ""
254
+ session["auth_sign_up_error"] = message
255
+
256
+ return redirect(url_for("locust_cloud_auth.signup"))
94
257
 
95
- return redirect(url_for("login"))
258
+ environment.web_ui.app.register_blueprint(auth_blueprint)
locust_cloud/cloud.py CHANGED
@@ -1,6 +1,5 @@
1
1
  import json
2
2
  import logging
3
- import math
4
3
  import os
5
4
  import sys
6
5
  import time
@@ -14,13 +13,6 @@ import configargparse
14
13
  import requests
15
14
  from botocore.exceptions import ClientError
16
15
  from locust_cloud import __version__
17
- from locust_cloud.constants import (
18
- DEFAULT_CLUSTER_NAME,
19
- DEFAULT_DEPLOYER_URL,
20
- DEFAULT_NAMESPACE,
21
- DEFAULT_REGION_NAME,
22
- USERS_PER_WORKER,
23
- )
24
16
  from locust_cloud.credential_manager import CredentialError, CredentialManager
25
17
 
26
18
 
@@ -110,29 +102,9 @@ advanced.add_argument(
110
102
  advanced.add_argument(
111
103
  "--region",
112
104
  type=str,
113
- default=os.environ.get("AWS_DEFAULT_REGION", DEFAULT_REGION_NAME),
105
+ default=os.environ.get("AWS_DEFAULT_REGION"),
114
106
  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
107
  )
116
- advanced.add_argument(
117
- "--kube-cluster-name",
118
- type=str,
119
- default=DEFAULT_CLUSTER_NAME,
120
- help=configargparse.SUPPRESS,
121
- env_var="KUBE_CLUSTER_NAME",
122
- )
123
- advanced.add_argument(
124
- "--kube-namespace",
125
- type=str,
126
- default=DEFAULT_NAMESPACE,
127
- help=configargparse.SUPPRESS,
128
- env_var="KUBE_NAMESPACE",
129
- )
130
- parser.add_argument(
131
- "--deployer-url",
132
- type=str,
133
- default=DEFAULT_DEPLOYER_URL,
134
- help=configargparse.SUPPRESS,
135
- )
136
108
  parser.add_argument(
137
109
  "--aws-access-key-id",
138
110
  type=str,
@@ -162,7 +134,7 @@ parser.add_argument(
162
134
  parser.add_argument(
163
135
  "--workers",
164
136
  type=int,
165
- help=f"Number of workers to use for the deployment. Defaults to number of users divided by {USERS_PER_WORKER}.",
137
+ help="Number of workers to use for the deployment. Defaults to number of users divided by 500, but the default may be customized for your account.",
166
138
  default=None,
167
139
  )
168
140
  parser.add_argument(
@@ -194,25 +166,30 @@ logging.getLogger("requests").setLevel(logging.INFO)
194
166
  logging.getLogger("urllib3").setLevel(logging.INFO)
195
167
 
196
168
 
169
+ api_url = f"https://api.{options.region}.locust.cloud/1"
170
+
171
+
197
172
  def main() -> None:
198
- s3_bucket = f"{options.kube_cluster_name}-{options.kube_namespace}"
173
+ if not options.region:
174
+ logger.error(
175
+ "Setting a region is required to use Locust Cloud. Please ensure the AWS_DEFAULT_REGION env variable or the --region flag is set."
176
+ )
177
+ sys.exit(1)
178
+
179
+ s3_bucket = "dmdb-default" if options.region == "us-east-1" else "locust-default"
199
180
  deployments: list[Any] = []
200
- worker_count: int = max(options.workers or math.ceil(options.users / USERS_PER_WORKER), 2)
201
- os.environ["AWS_DEFAULT_REGION"] = options.region
202
181
 
203
- try:
204
- if not (
205
- (options.username and options.password) or (options.aws_access_key_id and options.aws_secret_access_key)
206
- ):
207
- logger.error(
208
- "Authentication is required to use Locust Cloud. Please ensure the LOCUST_CLOUD_USERNAME and LOCUST_CLOUD_PASSWORD environment variables are set."
209
- )
210
- sys.exit(1)
182
+ if not ((options.username and options.password) or (options.aws_access_key_id and options.aws_secret_access_key)):
183
+ logger.error(
184
+ "Authentication is required to use Locust Cloud. Please ensure the LOCUST_CLOUD_USERNAME and LOCUST_CLOUD_PASSWORD environment variables are set."
185
+ )
186
+ sys.exit(1)
211
187
 
212
- logger.info(f"Authenticating ({os.environ['AWS_DEFAULT_REGION']}, v{__version__})")
213
- logger.debug(f"Lambda url: {options.deployer_url}")
188
+ try:
189
+ logger.info(f"Authenticating ({options.region}, v{__version__})")
190
+ logger.debug(f"Lambda url: {api_url}")
214
191
  credential_manager = CredentialManager(
215
- lambda_url=options.deployer_url,
192
+ lambda_url=api_url,
216
193
  access_key=options.aws_access_key_id,
217
194
  secret_key=options.aws_secret_access_key,
218
195
  username=options.username,
@@ -279,20 +256,21 @@ def main() -> None:
279
256
  ]
280
257
  and os.environ[env_variable]
281
258
  ]
282
- deploy_endpoint = f"{options.deployer_url}/{options.kube_cluster_name}"
259
+ deploy_endpoint = f"{api_url}/deploy"
283
260
  payload = {
284
261
  "locust_args": [
285
262
  {"name": "LOCUST_LOCUSTFILE", "value": locustfile_url},
286
263
  {"name": "LOCUST_USERS", "value": str(options.users)},
287
264
  {"name": "LOCUST_FLAGS", "value": " ".join(locust_options)},
288
265
  {"name": "LOCUSTCLOUD_REQUIREMENTS_URL", "value": requirements_url},
289
- {"name": "LOCUSTCLOUD_DEPLOYER_URL", "value": options.deployer_url},
266
+ {"name": "LOCUSTCLOUD_DEPLOYER_URL", "value": api_url},
290
267
  *locust_env_variables,
291
268
  ],
292
- "worker_count": worker_count,
293
269
  "user_count": options.users,
294
270
  "image_tag": options.image_tag,
295
271
  }
272
+ if options.workers is not None:
273
+ payload["worker_count"] = options.workers
296
274
  headers = {
297
275
  "Authorization": f"Bearer {cognito_client_id_token}",
298
276
  "Content-Type": "application/json",
@@ -325,7 +303,7 @@ def main() -> None:
325
303
  logger.debug("Interrupted by user")
326
304
  sys.exit(0)
327
305
 
328
- log_group_name = f"/eks/{options.kube_cluster_name}-{options.kube_namespace}"
306
+ log_group_name = "/eks/dmdb-default" if options.region == "us-east-1" else "/eks/locust-default"
329
307
  master_pod_name = next((deployment for deployment in deployments if "master" in deployment), None)
330
308
 
331
309
  if not master_pod_name:
@@ -420,9 +398,8 @@ def delete(s3_bucket, credential_manager):
420
398
  headers["AWS_SESSION_TOKEN"] = token
421
399
 
422
400
  response = requests.delete(
423
- f"{options.deployer_url}/{options.kube_cluster_name}",
401
+ f"{api_url}/teardown",
424
402
  headers=headers,
425
- params={"namespace": options.kube_namespace} if options.kube_namespace else {},
426
403
  )
427
404
 
428
405
  if response.status_code != 200:
@@ -100,13 +100,16 @@ class CredentialManager:
100
100
 
101
101
  except requests.exceptions.HTTPError as http_err:
102
102
  response = http_err.response
103
- if response is not None and response.status_code == 401:
103
+ if response is None:
104
+ raise CredentialError("Response was None?!") from http_err
105
+
106
+ if response.status_code == 401:
104
107
  raise CredentialError("Incorrect username or password.") from http_err
105
108
  else:
106
109
  if js := response.json():
107
110
  if message := js.get("Message"):
108
111
  raise CredentialError(message)
109
- error_info = f"HTTP {response.status_code} {response.reason}" if response else "No response received."
112
+ error_info = f"HTTP {response.status_code} {response.reason}"
110
113
  raise CredentialError(f"HTTP error occurred while obtaining credentials: {error_info}") from http_err
111
114
  except requests.exceptions.RequestException as req_err:
112
115
  raise CredentialError(f"Request exception occurred while obtaining credentials: {req_err}") from req_err
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from flask import make_response, request
3
+ from flask import Blueprint, make_response, request
4
4
  from flask_login import login_required
5
5
  from locust_cloud.timescale.queries import queries
6
6
 
@@ -12,7 +12,11 @@ def adapt_timestamp(result):
12
12
 
13
13
 
14
14
  def register_query(environment, pool):
15
- @environment.web_ui.app.route("/cloud-stats/<query>", methods=["POST"])
15
+ cloud_stats_blueprint = Blueprint(
16
+ "locust_cloud_stats", __name__, url_prefix=environment.parsed_options.web_base_path
17
+ )
18
+
19
+ @cloud_stats_blueprint.route("/cloud-stats/<query>", methods=["POST"])
16
20
  @login_required
17
21
  def query(query):
18
22
  results = []
@@ -57,7 +61,9 @@ def register_query(environment, pool):
57
61
  return results
58
62
  else:
59
63
  logger.warning(f"Received invalid query key: '{query}'")
60
- return make_response("Invalid query key", 401)
64
+ return make_response({"error": "Invalid query key"}, 401)
61
65
  except Exception as e:
62
66
  logger.info(f"Error executing UI query '{query}': {e}", exc_info=True)
63
- return make_response("Error executing query", 401)
67
+ return make_response({"error": "Error executing query"}, 401)
68
+
69
+ environment.web_ui.app.register_blueprint(cloud_stats_blueprint)
@@ -29,7 +29,7 @@
29
29
  "group": "internal",
30
30
  },
31
31
  {
32
- "pattern": "{components,hooks,redux,utils}/**",
32
+ "pattern": "{components,hooks,redux,types,utils}/**",
33
33
  "group": "internal",
34
34
  },
35
35
  ],