locust-cloud 1.0.0__py3-none-any.whl → 1.0.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
@@ -1,3 +1,4 @@
1
+ from locust_cloud.auth import register_auth
1
2
  from locust_cloud.timescale.exporter import Timescale
2
3
 
3
4
  import os
@@ -47,3 +48,6 @@ def on_locust_init(environment, **args):
47
48
  pg_database=PG_DATABASE,
48
49
  pg_port=PG_PORT,
49
50
  )
51
+
52
+ if environment.web_ui:
53
+ register_auth(environment)
locust_cloud/auth.py ADDED
@@ -0,0 +1,87 @@
1
+ import datetime
2
+ import os
3
+
4
+ import requests
5
+ from flask import redirect, request, url_for
6
+ from flask_login import UserMixin, current_user, login_user
7
+
8
+ LAMBDA = "http://127.0.0.1:8000/1"
9
+
10
+
11
+ class AuthUser(UserMixin):
12
+ def __init__(self, user_sub_id):
13
+ self.user_sub_id = user_sub_id
14
+
15
+ def get_id(self):
16
+ return self.user_sub_id
17
+
18
+
19
+ def set_credentials(credentials, response):
20
+ id_token = credentials["cognito_client_id_token"]
21
+ user_sub_id = credentials["user_sub_id"]
22
+ refresh_token = credentials["refresh_token"]
23
+
24
+ response.set_cookie("cognito_token", id_token, expires=datetime.datetime.utcnow() + datetime.timedelta(days=1))
25
+ response.set_cookie("user_token", refresh_token, expires=datetime.datetime.utcnow() + datetime.timedelta(days=365))
26
+ response.set_cookie("user_sub_id", user_sub_id, expires=datetime.datetime.utcnow() + datetime.timedelta(days=365))
27
+
28
+ return response
29
+
30
+
31
+ def load_user(user_sub_id):
32
+ refresh_token = request.cookies.get("user_token")
33
+
34
+ if refresh_token:
35
+ return AuthUser(user_sub_id)
36
+
37
+ return None
38
+
39
+
40
+ def register_auth(environment):
41
+ environment.web_ui.app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
42
+ environment.web_ui.app.debug = False
43
+ environment.web_ui.login_manager.user_loader(load_user)
44
+ environment.web_ui.auth_args = {
45
+ "username_password_callback": "/authenticate",
46
+ }
47
+
48
+ @environment.web_ui.app.after_request
49
+ def refresh_handler(response):
50
+ if request.path == "/" and current_user:
51
+ refresh_token = request.cookies.get("user_token")
52
+ user_sub_id = request.cookies.get("user_sub_id")
53
+ if user_sub_id and refresh_token:
54
+ auth_response = requests.post(
55
+ f"{LAMBDA}/auth/login", json={"user_sub_id": user_sub_id, "refresh_token": refresh_token}
56
+ )
57
+ credentials = auth_response.json()
58
+ response = set_credentials(credentials, response)
59
+
60
+ return response
61
+
62
+ @environment.web_ui.app.route("/authenticate")
63
+ def login_submit():
64
+ username = request.args.get("username")
65
+ password = request.args.get("password")
66
+
67
+ try:
68
+ auth_response = requests.post(f"{LAMBDA}/auth/login", json={"username": username, "password": password})
69
+
70
+ if auth_response.status_code == 200:
71
+ credentials = auth_response.json()
72
+ response = redirect(url_for("index"))
73
+ response = set_credentials(credentials, response)
74
+ login_user(AuthUser(credentials["user_sub_id"]))
75
+
76
+ return response
77
+
78
+ environment.web_ui.auth_args = {**environment.web_ui.auth_args, "error": "Invalid username or password"}
79
+
80
+ return redirect(url_for("login"))
81
+ except Exception:
82
+ environment.web_ui.auth_args = {
83
+ **environment.web_ui.auth_args,
84
+ "error": "An unknown error occured, please try again",
85
+ }
86
+
87
+ return redirect(url_for("login"))
locust_cloud/cloud.py ADDED
@@ -0,0 +1,399 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import sys
5
+ import time
6
+ import tomllib
7
+ from collections import OrderedDict
8
+ from datetime import datetime, timedelta
9
+
10
+ import boto3
11
+ import configargparse
12
+ import requests
13
+ from botocore.exceptions import ClientError
14
+
15
+ LAMBDA = "https://deployer.locust.cloud/1"
16
+ DEFAULT_CLUSTER_NAME = "locust"
17
+ DEFAULT_REGION_NAME = "eu-north-1"
18
+
19
+
20
+ class LocustTomlConfigParser(configargparse.TomlConfigParser):
21
+ def parse(self, stream):
22
+ try:
23
+ config = tomllib.loads(stream.read())
24
+ except Exception as e:
25
+ raise configargparse.ConfigFileParserException(f"Couldn't parse TOML file: {e}")
26
+
27
+ # convert to dict and filter based on section names
28
+ result = OrderedDict()
29
+
30
+ for section in self.sections:
31
+ data = configargparse.get_toml_section(config, section)
32
+ if data:
33
+ for key, value in data.items():
34
+ if isinstance(value, list):
35
+ result[key] = value
36
+ elif value is None:
37
+ pass
38
+ else:
39
+ result[key] = str(value)
40
+ break
41
+
42
+ return result
43
+
44
+
45
+ logging.basicConfig(
46
+ format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
47
+ level=logging.INFO,
48
+ )
49
+
50
+ parser = configargparse.ArgumentParser(
51
+ default_config_files=[
52
+ "~/.locust.conf",
53
+ "locust.conf",
54
+ "pyproject.toml",
55
+ "~/.cloud.conf",
56
+ "cloud.conf",
57
+ ],
58
+ auto_env_var_prefix="LOCUST_",
59
+ formatter_class=configargparse.RawDescriptionHelpFormatter,
60
+ config_file_parser_class=configargparse.CompositeConfigParser(
61
+ [
62
+ LocustTomlConfigParser(["tool.locust"]),
63
+ configargparse.DefaultConfigFileParser,
64
+ ]
65
+ ),
66
+ description="""A tool for Locust Cloud users to deploy clusters.
67
+
68
+ Example: locust-cloud -f locust.py --aws-access-key-id 123 --aws-secret-access-key 456""",
69
+ epilog="""Any parameters not listed here are forwarded to locust master unmodified, so go ahead and use things like --users, --host, --run-time, ...
70
+
71
+ Locust config can also be set using config file (~/.locust.conf, locust.conf, pyproject.toml, ~/.cloud.conf or cloud.conf).
72
+ Parameters specified on command line override env vars, which in turn override config files.""",
73
+ add_config_file_help=False,
74
+ add_env_var_help=False,
75
+ )
76
+
77
+ parser.add_argument(
78
+ "-f",
79
+ "--locustfile",
80
+ metavar="<filename>",
81
+ default="locustfile.py",
82
+ help="The Python file or module that contains your test, e.g. 'my_test.py'. Defaults to 'locustfile'.",
83
+ env_var="LOCUST_LOCUSTFILE",
84
+ )
85
+ parser.add_argument(
86
+ "-r",
87
+ "--requirements",
88
+ type=str,
89
+ help="Optional requirements.txt file that contains your external libraries.",
90
+ env_var="LOCUST_REQUIREMENTS",
91
+ )
92
+ parser.add_argument(
93
+ "--aws-access-key-id",
94
+ type=str,
95
+ help="Authentication for deploying with Locust Cloud",
96
+ env_var="AWS_ACCESS_KEY_ID",
97
+ )
98
+ parser.add_argument(
99
+ "--aws-secret-access-key",
100
+ type=str,
101
+ help="Authentication for deploying with Locust Cloud",
102
+ env_var="AWS_SECRET_ACCESS_KEY",
103
+ )
104
+ parser.add_argument(
105
+ "--username",
106
+ type=str,
107
+ help="Authentication for deploying with Locust Cloud",
108
+ env_var="LOCUST_CLOUD_USERNAME",
109
+ )
110
+ parser.add_argument(
111
+ "--password",
112
+ type=str,
113
+ help="Authentication for deploying with Locust Cloud",
114
+ env_var="LOCUST_CLOUD_PASSWORD",
115
+ )
116
+ parser.add_argument(
117
+ "--aws-region-name",
118
+ type=str,
119
+ default=DEFAULT_REGION_NAME,
120
+ help="Sets the region to use for the deployed cluster",
121
+ env_var="AWS_REGION_NAME",
122
+ )
123
+ parser.add_argument(
124
+ "--kube-cluster-name",
125
+ type=str,
126
+ default=DEFAULT_CLUSTER_NAME,
127
+ help="Sets the name of the kubernetes cluster",
128
+ env_var="KUBE_CLUSTER_NAME",
129
+ )
130
+ parser.add_argument(
131
+ "--kube-namespace",
132
+ type=str,
133
+ default="default",
134
+ help="Sets the namespace for scoping the deployed cluster",
135
+ env_var="KUBE_NAMESPACE",
136
+ )
137
+
138
+ options, locust_options = parser.parse_known_args()
139
+
140
+
141
+ def main():
142
+ aws_access_key_id = options.aws_access_key_id
143
+ aws_secret_access_key = options.aws_secret_access_key
144
+ aws_session_token = None
145
+
146
+ if not ((options.aws_access_key_id and options.aws_secret_access_key) or (options.username and options.password)):
147
+ logging.error(
148
+ "Authentication is required to use Locust Cloud. Ensure your username and password are provided, or provide an aws_access_key_id and aws_secret_access_key directly."
149
+ )
150
+ sys.exit(1)
151
+
152
+ if options.username and options.password:
153
+ if options.aws_access_key_id or options.aws_secret_access_key:
154
+ logging.info("A username and password have been provided, the AWS keys will be ignored.")
155
+ logging.info("Authenticating...")
156
+ response = requests.post(
157
+ f"{LAMBDA}/auth/login", json={"username": options.username, "password": options.password}
158
+ )
159
+
160
+ if response.status_code == 200:
161
+ credentials = response.json()
162
+ aws_access_key_id = credentials["aws_access_key_id"]
163
+ aws_secret_access_key = credentials["aws_secret_access_key"]
164
+ aws_session_token = credentials["aws_session_token"]
165
+ cognito_client_id_token = credentials["cognito_client_id_token"]
166
+ else:
167
+ logging.error(
168
+ f"HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
169
+ )
170
+ sys.exit(1)
171
+
172
+ s3_bucket = f"{options.kube_cluster_name}-{options.kube_namespace}"
173
+
174
+ try:
175
+ session = boto3.session.Session(
176
+ region_name=options.aws_region_name,
177
+ aws_access_key_id=aws_access_key_id,
178
+ aws_secret_access_key=aws_secret_access_key,
179
+ aws_session_token=aws_session_token,
180
+ )
181
+ locustfile_url = upload_file(
182
+ session,
183
+ s3_bucket=s3_bucket,
184
+ region_name=options.aws_region_name,
185
+ filename=options.locustfile,
186
+ )
187
+ requirements_url = ""
188
+ if options.requirements:
189
+ requirements_url = upload_file(
190
+ session,
191
+ s3_bucket=s3_bucket,
192
+ region_name=options.aws_region_name,
193
+ filename=options.requirements,
194
+ remote_filename="requirements.txt",
195
+ )
196
+
197
+ deployed_pods = deploy(
198
+ aws_access_key_id,
199
+ aws_secret_access_key,
200
+ aws_session_token,
201
+ cognito_client_id_token,
202
+ locustfile_url,
203
+ region_name=options.aws_region_name,
204
+ cluster_name=options.kube_cluster_name,
205
+ namespace=options.kube_namespace,
206
+ requirements=requirements_url,
207
+ )
208
+ stream_pod_logs(
209
+ session,
210
+ deployed_pods=deployed_pods,
211
+ cluster_name=options.kube_cluster_name,
212
+ namespace=options.kube_namespace,
213
+ )
214
+ except KeyboardInterrupt:
215
+ pass
216
+ except Exception as e:
217
+ logging.exception(e)
218
+ sys.exit(1)
219
+
220
+ try:
221
+ logging.info("Tearing down Locust cloud...")
222
+ teardown_cluster(
223
+ aws_access_key_id,
224
+ aws_secret_access_key,
225
+ aws_session_token,
226
+ cognito_client_id_token,
227
+ region_name=options.aws_region_name,
228
+ cluster_name=options.kube_cluster_name,
229
+ namespace=options.kube_namespace,
230
+ )
231
+ teardown_s3(
232
+ session,
233
+ s3_bucket=s3_bucket,
234
+ )
235
+ except Exception as e:
236
+ logging.error(f"Could not automatically tear down Locust Cloud: {e}")
237
+ sys.exit(1)
238
+
239
+
240
+ def upload_file(session, s3_bucket, region_name, filename, remote_filename=None):
241
+ if not remote_filename:
242
+ remote_filename = filename.split("/")[-1]
243
+
244
+ logging.debug(f"Uploading {remote_filename}...")
245
+
246
+ s3 = session.client("s3")
247
+
248
+ try:
249
+ s3.upload_file(filename, s3_bucket, remote_filename)
250
+
251
+ presigned_url = s3.generate_presigned_url(
252
+ ClientMethod="get_object",
253
+ Params={"Bucket": s3_bucket, "Key": remote_filename},
254
+ ExpiresIn=3600, # 1 hour
255
+ )
256
+
257
+ return presigned_url
258
+ except FileNotFoundError:
259
+ logging.error(f"Could not find '{filename}'")
260
+ sys.exit(1)
261
+
262
+
263
+ def deploy(
264
+ aws_access_key_id,
265
+ aws_secret_access_key,
266
+ aws_session_token,
267
+ cognito_client_id_token,
268
+ locustfile,
269
+ region_name=None,
270
+ cluster_name=DEFAULT_CLUSTER_NAME,
271
+ namespace=None,
272
+ requirements="",
273
+ ):
274
+ logging.info("Deploying load generators...")
275
+ locust_env_variables = [
276
+ {"name": env_variable, "value": str(os.environ[env_variable])}
277
+ for env_variable in os.environ
278
+ if env_variable.startswith("LOCUST_")
279
+ ]
280
+
281
+ response = requests.post(
282
+ f"{LAMBDA}/{cluster_name}",
283
+ headers={
284
+ "AWS_ACCESS_KEY_ID": aws_access_key_id,
285
+ "AWS_SECRET_ACCESS_KEY": aws_secret_access_key,
286
+ "AWS_SESSION_TOKEN": aws_session_token,
287
+ "Authorization": f"Bearer {cognito_client_id_token}",
288
+ },
289
+ json={
290
+ "locust_args": [
291
+ {
292
+ "name": "LOCUST_LOCUSTFILE",
293
+ "value": locustfile,
294
+ },
295
+ {"name": "LOCUST_REQUIREMENTS_URL", "value": requirements},
296
+ {"name": "LOCUST_FLAGS", "value": " ".join(locust_options)},
297
+ *locust_env_variables,
298
+ ]
299
+ },
300
+ params={"region_name": region_name, "namespace": namespace},
301
+ )
302
+
303
+ if response.status_code != 200:
304
+ if response.json().get("message"):
305
+ sys.stderr.write(f"{response.json().get('message')}\n")
306
+ else:
307
+ sys.stderr.write("An unkown error occured during deployment. Please contact an administrator\n")
308
+
309
+ sys.exit(1)
310
+
311
+ logging.info("Load generators deployed.")
312
+ return response.json()["pods"]
313
+
314
+
315
+ def stream_pod_logs(
316
+ session,
317
+ deployed_pods,
318
+ cluster_name=DEFAULT_CLUSTER_NAME,
319
+ namespace=None,
320
+ ):
321
+ logging.info("Waiting for pods to be ready...")
322
+ client = session.client("logs")
323
+
324
+ log_group_name = f"/eks/{cluster_name}-{namespace}"
325
+ master_pod_name = [pod_name for pod_name in deployed_pods if "master" in pod_name][0]
326
+
327
+ log_stream = None
328
+ while log_stream is None:
329
+ try:
330
+ response = client.describe_log_streams(
331
+ logGroupName=log_group_name,
332
+ logStreamNamePrefix=f"from-fluent-bit-kube.var.log.containers.{master_pod_name}",
333
+ )
334
+ all_streams = response.get("logStreams")
335
+ if all_streams:
336
+ log_stream = all_streams[0].get("logStreamName")
337
+ else:
338
+ time.sleep(1)
339
+ except ClientError:
340
+ # log group name does not exist yet
341
+ time.sleep(1)
342
+ continue
343
+
344
+ logging.info("Pods are ready, switching to Locust logs.")
345
+
346
+ timestamp = int((datetime.now() - timedelta(minutes=5)).timestamp())
347
+ while True:
348
+ response = client.get_log_events(
349
+ logGroupName=log_group_name,
350
+ logStreamName=log_stream,
351
+ startTime=timestamp,
352
+ startFromHead=True,
353
+ )
354
+
355
+ for event in response["events"]:
356
+ message = event["message"]
357
+ timestamp = event["timestamp"] + 1
358
+
359
+ try:
360
+ message = json.loads(message)
361
+ if "log" in message:
362
+ print(message["log"])
363
+ except json.JSONDecodeError:
364
+ pass
365
+
366
+ time.sleep(5)
367
+
368
+
369
+ def teardown_cluster(
370
+ aws_access_key_id,
371
+ aws_secret_access_key,
372
+ aws_session_token,
373
+ cognito_client_id_token,
374
+ region_name=None,
375
+ cluster_name=DEFAULT_CLUSTER_NAME,
376
+ namespace=None,
377
+ ):
378
+ response = requests.delete(
379
+ f"{LAMBDA}/{cluster_name}",
380
+ headers={
381
+ "AWS_ACCESS_KEY_ID": aws_access_key_id,
382
+ "AWS_SECRET_ACCESS_KEY": aws_secret_access_key,
383
+ "AWS_SESSION_TOKEN": aws_session_token,
384
+ "Authorization": f"Bearer {cognito_client_id_token}",
385
+ },
386
+ params={"region_name": region_name, "namespace": namespace},
387
+ )
388
+
389
+ if response.status_code != 200:
390
+ logging.error(
391
+ f"HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
392
+ )
393
+ sys.exit(1)
394
+
395
+
396
+ def teardown_s3(session, s3_bucket):
397
+ s3 = session.resource("s3")
398
+ bucket = s3.Bucket(s3_bucket)
399
+ bucket.objects.delete()