locust-cloud 1.0.17__py3-none-any.whl → 1.0.19__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 +3 -3
- locust_cloud/cloud.py +111 -64
- locust_cloud/constants.py +2 -1
- locust_cloud/credential_manager.py +0 -4
- locust_cloud/webui/dist/assets/{index-BG65KOq7.js → index-Cj0_NN88.js} +84 -84
- locust_cloud/webui/dist/index.html +1 -1
- {locust_cloud-1.0.17.dist-info → locust_cloud-1.0.19.dist-info}/METADATA +1 -1
- {locust_cloud-1.0.17.dist-info → locust_cloud-1.0.19.dist-info}/RECORD +10 -10
- {locust_cloud-1.0.17.dist-info → locust_cloud-1.0.19.dist-info}/WHEEL +0 -0
- {locust_cloud-1.0.17.dist-info → locust_cloud-1.0.19.dist-info}/entry_points.txt +0 -0
locust_cloud/auth.py
CHANGED
@@ -59,10 +59,10 @@ def register_auth(environment):
|
|
59
59
|
|
60
60
|
return response
|
61
61
|
|
62
|
-
@environment.web_ui.app.route("/authenticate")
|
62
|
+
@environment.web_ui.app.route("/authenticate", methods=["POST"])
|
63
63
|
def login_submit():
|
64
|
-
username = request.
|
65
|
-
password = request.
|
64
|
+
username = request.form.get("username")
|
65
|
+
password = request.form.get("password")
|
66
66
|
|
67
67
|
try:
|
68
68
|
auth_response = requests.post(f"{LAMBDA}/auth/login", json={"username": username, "password": password})
|
locust_cloud/cloud.py
CHANGED
@@ -1,5 +1,7 @@
|
|
1
|
+
import importlib.metadata
|
1
2
|
import json
|
2
3
|
import logging
|
4
|
+
import math
|
3
5
|
import os
|
4
6
|
import sys
|
5
7
|
import time
|
@@ -13,20 +15,15 @@ import requests
|
|
13
15
|
from botocore.exceptions import ClientError
|
14
16
|
from locust_cloud.constants import (
|
15
17
|
DEFAULT_CLUSTER_NAME,
|
18
|
+
DEFAULT_LAMBDA_URL,
|
16
19
|
DEFAULT_NAMESPACE,
|
17
20
|
DEFAULT_REGION_NAME,
|
18
|
-
|
21
|
+
USERS_PER_WORKER,
|
19
22
|
)
|
20
23
|
from locust_cloud.credential_manager import CredentialError, CredentialManager
|
21
24
|
|
22
|
-
logging.basicConfig(
|
23
|
-
format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
|
24
|
-
level=logging.INFO,
|
25
|
-
)
|
26
|
-
logger = logging.getLogger(__name__)
|
27
|
-
|
28
|
-
|
29
25
|
LOCUST_ENV_VARIABLE_IGNORE_LIST = ["LOCUST_BUILD_PATH", "LOCUST_SKIP_MONKEY_PATCH"]
|
26
|
+
__version__ = importlib.metadata.version("locust-cloud")
|
30
27
|
|
31
28
|
|
32
29
|
class LocustTomlConfigParser(configargparse.TomlConfigParser):
|
@@ -67,9 +64,9 @@ parser = configargparse.ArgumentParser(
|
|
67
64
|
configargparse.DefaultConfigFileParser,
|
68
65
|
]
|
69
66
|
),
|
70
|
-
description="""
|
67
|
+
description="""Launches distributed Locust runs on locust.cloud infrastructure.
|
71
68
|
|
72
|
-
Example: locust-cloud -f
|
69
|
+
Example: locust-cloud -f my_locustfile.py --aws-region-name us-east-1 --users 1000""",
|
73
70
|
epilog="""Any parameters not listed here are forwarded to locust master unmodified, so go ahead and use things like --users, --host, --run-time, ...
|
74
71
|
Locust config can also be set using config file (~/.locust.conf, locust.conf, pyproject.toml, ~/.cloud.conf or cloud.conf).
|
75
72
|
Parameters specified on command line override env vars, which in turn override config files.""",
|
@@ -113,61 +110,104 @@ parser.add_argument(
|
|
113
110
|
help="Sets the namespace for scoping the deployed cluster",
|
114
111
|
env_var="KUBE_NAMESPACE",
|
115
112
|
)
|
113
|
+
parser.add_argument(
|
114
|
+
"--lambda-url",
|
115
|
+
type=str,
|
116
|
+
default=DEFAULT_LAMBDA_URL,
|
117
|
+
help="Sets the namespace for scoping the deployed cluster",
|
118
|
+
env_var="LOCUST_CLOUD_LAMBDA",
|
119
|
+
)
|
116
120
|
parser.add_argument(
|
117
121
|
"--aws-access-key-id",
|
118
122
|
type=str,
|
119
|
-
help=
|
123
|
+
help=configargparse.SUPPRESS,
|
120
124
|
env_var="AWS_ACCESS_KEY_ID",
|
125
|
+
default=None,
|
121
126
|
)
|
122
127
|
parser.add_argument(
|
123
128
|
"--aws-secret-access-key",
|
124
129
|
type=str,
|
125
|
-
help=
|
130
|
+
help=configargparse.SUPPRESS,
|
126
131
|
env_var="AWS_SECRET_ACCESS_KEY",
|
132
|
+
default=None,
|
133
|
+
)
|
134
|
+
parser.add_argument(
|
135
|
+
"--username",
|
136
|
+
type=str,
|
137
|
+
help="Authentication for deploying with Locust Cloud",
|
138
|
+
env_var="LOCUST_CLOUD_USERNAME",
|
139
|
+
default=None,
|
140
|
+
)
|
141
|
+
parser.add_argument(
|
142
|
+
"--password",
|
143
|
+
type=str,
|
144
|
+
help="Authentication for deploying with Locust Cloud",
|
145
|
+
env_var="LOCUST_CLOUD_PASSWORD",
|
146
|
+
default=None,
|
147
|
+
)
|
148
|
+
parser.add_argument(
|
149
|
+
"--loglevel",
|
150
|
+
"-L",
|
151
|
+
type=str,
|
152
|
+
help="Log level",
|
153
|
+
env_var="LOCUST_CLOUD_LOGLEVEL",
|
154
|
+
default="INFO",
|
155
|
+
)
|
156
|
+
parser.add_argument(
|
157
|
+
"--workers",
|
158
|
+
type=str,
|
159
|
+
help="Number of workers to use for the deployment",
|
160
|
+
env_var="LOCUST_CLOUD_WORKERS",
|
161
|
+
default=None,
|
127
162
|
)
|
128
163
|
|
129
164
|
options, locust_options = parser.parse_known_args()
|
130
|
-
|
131
|
-
|
132
|
-
|
165
|
+
logging.basicConfig(
|
166
|
+
format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
|
167
|
+
level=options.loglevel.upper(),
|
168
|
+
)
|
169
|
+
logger = logging.getLogger(__name__)
|
170
|
+
# Restore log level for other libs. Yes, this can be done more nicely
|
171
|
+
logging.getLogger("botocore").setLevel(logging.INFO)
|
172
|
+
logging.getLogger("boto3").setLevel(logging.INFO)
|
173
|
+
logging.getLogger("s3transfer").setLevel(logging.INFO)
|
174
|
+
logging.getLogger("requests").setLevel(logging.INFO)
|
175
|
+
logging.getLogger("urllib3").setLevel(logging.INFO)
|
133
176
|
|
134
177
|
|
135
178
|
def main() -> None:
|
136
179
|
s3_bucket = f"{options.kube_cluster_name}-{options.kube_namespace}"
|
137
180
|
deployed_pods: list[Any] = []
|
181
|
+
worker_count = options.workers or math.ceil(locust_options.users / USERS_PER_WORKER)
|
182
|
+
worker_count = worker_count if worker_count > 2 else 2
|
138
183
|
|
139
184
|
try:
|
140
|
-
if
|
141
|
-
|
142
|
-
|
143
|
-
region_name=options.aws_region_name,
|
144
|
-
access_key=options.aws_access_key_id,
|
145
|
-
secret_key=options.aws_secret_access_key,
|
146
|
-
)
|
147
|
-
elif username and password:
|
148
|
-
credential_manager = CredentialManager(
|
149
|
-
lambda_url=LAMBDA_URL,
|
150
|
-
username=username,
|
151
|
-
password=password,
|
152
|
-
region_name=options.aws_region_name,
|
153
|
-
)
|
154
|
-
else:
|
185
|
+
if not (
|
186
|
+
(options.username and options.password) or (options.aws_access_key_id and options.aws_secret_access_key)
|
187
|
+
):
|
155
188
|
logger.error(
|
156
|
-
"Authentication is required to use Locust Cloud.
|
189
|
+
"Authentication is required to use Locust Cloud. Please ensure the LOCUST_CLOUD_USERNAME and LOCUST_CLOUD_PASSWORD environment variables are set."
|
157
190
|
)
|
158
191
|
sys.exit(1)
|
159
192
|
|
193
|
+
logger.info(f"Logging you into Locust Cloud ({options.lambda_url}, {options.aws_region_name}, v{__version__})")
|
194
|
+
|
195
|
+
credential_manager = CredentialManager(
|
196
|
+
lambda_url=options.lambda_url,
|
197
|
+
access_key=options.aws_access_key_id,
|
198
|
+
secret_key=options.aws_secret_access_key,
|
199
|
+
username=options.username,
|
200
|
+
password=options.password,
|
201
|
+
region_name=options.aws_region_name,
|
202
|
+
)
|
203
|
+
|
160
204
|
credentials = credential_manager.get_current_credentials()
|
161
|
-
cognito_client_id_token
|
205
|
+
cognito_client_id_token = credentials["cognito_client_id_token"]
|
162
206
|
aws_access_key_id = credentials.get("access_key")
|
163
207
|
aws_secret_access_key = credentials.get("secret_key")
|
164
|
-
aws_session_token = credentials.get("token")
|
165
|
-
|
166
|
-
if not all([aws_access_key_id, aws_secret_access_key]):
|
167
|
-
logger.error("Authentication failed: Missing AWS credentials.")
|
168
|
-
sys.exit(1)
|
208
|
+
aws_session_token = credentials.get("token", "")
|
169
209
|
|
170
|
-
logger.info(f"Uploading {options.locustfile}
|
210
|
+
logger.info(f"Uploading {options.locustfile}")
|
171
211
|
s3 = credential_manager.session.client("s3")
|
172
212
|
try:
|
173
213
|
s3.upload_file(options.locustfile, s3_bucket, os.path.basename(options.locustfile))
|
@@ -176,17 +216,17 @@ def main() -> None:
|
|
176
216
|
Params={"Bucket": s3_bucket, "Key": os.path.basename(options.locustfile)},
|
177
217
|
ExpiresIn=3600,
|
178
218
|
)
|
179
|
-
logger.
|
219
|
+
logger.debug(f"Uploaded {options.locustfile} successfully")
|
180
220
|
except FileNotFoundError:
|
181
221
|
logger.error(f"File not found: {options.locustfile}")
|
182
222
|
sys.exit(1)
|
183
223
|
except ClientError as e:
|
184
|
-
logger.error(f"Failed to upload {options.locustfile}
|
224
|
+
logger.error(f"Failed to upload {options.locustfile}: {e}")
|
185
225
|
sys.exit(1)
|
186
226
|
|
187
227
|
requirements_url = ""
|
188
228
|
if options.requirements:
|
189
|
-
logger.info(f"Uploading {options.requirements}
|
229
|
+
logger.info(f"Uploading {options.requirements}")
|
190
230
|
try:
|
191
231
|
s3.upload_file(options.requirements, s3_bucket, "requirements.txt")
|
192
232
|
requirements_url = s3.generate_presigned_url(
|
@@ -194,44 +234,46 @@ def main() -> None:
|
|
194
234
|
Params={"Bucket": s3_bucket, "Key": "requirements.txt"},
|
195
235
|
ExpiresIn=3600,
|
196
236
|
)
|
197
|
-
logger.
|
237
|
+
logger.debug(f"Uploaded {options.requirements} successfully")
|
198
238
|
except FileNotFoundError:
|
199
239
|
logger.error(f"File not found: {options.requirements}")
|
200
240
|
sys.exit(1)
|
201
241
|
except ClientError as e:
|
202
|
-
logger.error(f"Failed to upload {options.requirements}
|
242
|
+
logger.error(f"Failed to upload {options.requirements}: {e}")
|
203
243
|
sys.exit(1)
|
204
244
|
|
205
|
-
logger.info("Deploying load generators
|
245
|
+
logger.info("Deploying load generators")
|
206
246
|
locust_env_variables = [
|
207
247
|
{"name": env_variable, "value": str(os.environ[env_variable])}
|
208
248
|
for env_variable in os.environ
|
209
249
|
if env_variable.startswith("LOCUST_") and os.environ[env_variable]
|
210
250
|
]
|
211
|
-
deploy_endpoint = f"{
|
251
|
+
deploy_endpoint = f"{options.lambda_url}/{options.kube_cluster_name}"
|
212
252
|
payload = {
|
213
253
|
"locust_args": [
|
214
254
|
{"name": "LOCUST_LOCUSTFILE", "value": locustfile_url},
|
215
255
|
{"name": "LOCUST_REQUIREMENTS_URL", "value": requirements_url},
|
216
256
|
{"name": "LOCUST_FLAGS", "value": " ".join(locust_options)},
|
257
|
+
{"name": "LOCUST_WEB_HOST_DISPLAY_NAME", "value": "_____"},
|
217
258
|
*locust_env_variables,
|
218
|
-
]
|
259
|
+
],
|
260
|
+
"worker_count": worker_count,
|
219
261
|
}
|
220
262
|
headers = {
|
221
263
|
"Authorization": f"Bearer {cognito_client_id_token}",
|
222
264
|
"Content-Type": "application/json",
|
223
265
|
"AWS_ACCESS_KEY_ID": aws_access_key_id,
|
224
266
|
"AWS_SECRET_ACCESS_KEY": aws_secret_access_key,
|
225
|
-
"AWS_SESSION_TOKEN": aws_session_token
|
267
|
+
"AWS_SESSION_TOKEN": aws_session_token,
|
226
268
|
}
|
227
269
|
try:
|
228
270
|
response = requests.post(deploy_endpoint, json=payload, headers=headers)
|
229
271
|
except requests.exceptions.RequestException as e:
|
230
|
-
logger.error(f"
|
272
|
+
logger.error(f"Failed to deploy the load generators: {e}")
|
231
273
|
sys.exit(1)
|
274
|
+
|
232
275
|
if response.status_code == 200:
|
233
276
|
deployed_pods = response.json().get("pods", [])
|
234
|
-
logger.info("Load generators deployed successfully.")
|
235
277
|
else:
|
236
278
|
logger.error(
|
237
279
|
f"HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
|
@@ -241,13 +283,20 @@ def main() -> None:
|
|
241
283
|
logger.error(f"Credential error: {ce}")
|
242
284
|
sys.exit(1)
|
243
285
|
|
286
|
+
log_group_name = f"/eks/{options.kube_cluster_name}-{options.kube_namespace}"
|
287
|
+
master_pod_name = next((pod for pod in deployed_pods if "master" in pod), None)
|
288
|
+
|
289
|
+
if not master_pod_name:
|
290
|
+
logger.error(
|
291
|
+
"Master pod not found among deployed pods. Something went wrong during the load generator deployment, please try again or contact an administrator for assistance"
|
292
|
+
)
|
293
|
+
sys.exit(1)
|
294
|
+
|
295
|
+
logger.debug("Load generators deployed successfully!")
|
296
|
+
|
244
297
|
try:
|
245
298
|
logger.info("Waiting for pods to be ready...")
|
246
|
-
|
247
|
-
master_pod_name = next((pod for pod in deployed_pods if "master" in pod), None)
|
248
|
-
if not master_pod_name:
|
249
|
-
logger.error("Master pod not found among deployed pods.")
|
250
|
-
sys.exit(1)
|
299
|
+
|
251
300
|
log_stream: str | None = None
|
252
301
|
while log_stream is None:
|
253
302
|
try:
|
@@ -264,7 +313,7 @@ def main() -> None:
|
|
264
313
|
except ClientError as e:
|
265
314
|
logger.error(f"Error describing log streams: {e}")
|
266
315
|
time.sleep(5)
|
267
|
-
logger.
|
316
|
+
logger.debug("Pods are ready, switching to Locust logs")
|
268
317
|
|
269
318
|
timestamp = int((datetime.now(UTC) - timedelta(minutes=5)).timestamp() * 1000)
|
270
319
|
|
@@ -291,10 +340,10 @@ def main() -> None:
|
|
291
340
|
except ClientError as e:
|
292
341
|
error_code = e.response.get("Error", {}).get("Code", "")
|
293
342
|
if error_code == "ExpiredTokenException":
|
294
|
-
logger.
|
343
|
+
logger.debug("AWS session token expired during log streaming. Refreshing credentials.")
|
295
344
|
time.sleep(5)
|
296
345
|
except KeyboardInterrupt:
|
297
|
-
logger.debug("Interrupted by user
|
346
|
+
logger.debug("Interrupted by user")
|
298
347
|
except Exception as e:
|
299
348
|
logger.exception(e)
|
300
349
|
sys.exit(1)
|
@@ -315,28 +364,26 @@ def main() -> None:
|
|
315
364
|
headers["AWS_SESSION_TOKEN"] = token
|
316
365
|
|
317
366
|
response = requests.delete(
|
318
|
-
f"{
|
367
|
+
f"{options.lambda_url}/{options.kube_cluster_name}",
|
319
368
|
headers=headers,
|
320
369
|
params={"namespace": options.kube_namespace} if options.kube_namespace else {},
|
321
370
|
)
|
322
371
|
|
323
372
|
if response.status_code != 200:
|
324
373
|
logger.error(
|
325
|
-
f"HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
|
374
|
+
f"Could not automatically tear down Locust Cloud: HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
|
326
375
|
)
|
327
|
-
else:
|
328
|
-
logger.info("Cluster teardown initiated successfully.")
|
329
376
|
except Exception as e:
|
330
377
|
logger.error(f"Could not automatically tear down Locust Cloud: {e}")
|
331
378
|
|
332
379
|
try:
|
333
|
-
logger.info(
|
380
|
+
logger.info("Cleaning up locust files")
|
334
381
|
s3 = credential_manager.session.resource("s3")
|
335
382
|
bucket = s3.Bucket(s3_bucket)
|
336
383
|
bucket.objects.all().delete()
|
337
|
-
logger.info(
|
384
|
+
logger.info("Done! ✨")
|
338
385
|
except ClientError as e:
|
339
|
-
logger.error(f"Failed to clean up
|
386
|
+
logger.error(f"Failed to clean up locust files: {e}")
|
340
387
|
sys.exit(1)
|
341
388
|
|
342
389
|
|
locust_cloud/constants.py
CHANGED
@@ -10,10 +10,6 @@ from botocore.credentials import RefreshableCredentials
|
|
10
10
|
from botocore.session import Session as BotocoreSession
|
11
11
|
from locust_cloud.constants import DEFAULT_REGION_NAME
|
12
12
|
|
13
|
-
logging.basicConfig(
|
14
|
-
format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
|
15
|
-
level=logging.INFO,
|
16
|
-
)
|
17
13
|
logger = logging.getLogger(__name__)
|
18
14
|
|
19
15
|
|