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 +4 -0
- locust_cloud/auth.py +87 -0
- locust_cloud/cloud.py +399 -0
- locust_cloud/timescale/exporter.py +49 -119
- locust_cloud/webui/dist/assets/{index-C-nNEnAR.js → index-CCS97q-k.js} +139 -86
- locust_cloud/webui/dist/index.html +3 -2
- locust_cloud/webui/index.html +2 -1
- locust_cloud/webui/tsconfig.tsbuildinfo +1 -1
- {locust_cloud-1.0.0.dist-info → locust_cloud-1.0.2.dist-info}/METADATA +17 -3
- locust_cloud-1.0.2.dist-info/RECORD +19 -0
- locust_cloud-1.0.2.dist-info/entry_points.txt +2 -0
- locust_cloud-1.0.0.dist-info/RECORD +0 -16
- {locust_cloud-1.0.0.dist-info → locust_cloud-1.0.2.dist-info}/WHEEL +0 -0
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()
|