locust-cloud 1.11.7__py3-none-any.whl → 1.12.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/__init__.py +11 -0
- locust_cloud/auth.py +23 -3
- locust_cloud/cloud.py +58 -69
- locust_cloud/socket_logging.py +103 -0
- locust_cloud/timescale/exporter.py +1 -1
- locust_cloud/timescale/queries.py +1 -1
- locust_cloud/timescale/query.py +6 -1
- locust_cloud/webui/dist/assets/index-BoNYN-22.js +329 -0
- locust_cloud/webui/dist/index.html +1 -1
- locust_cloud/webui/eslint.config.mjs +82 -0
- locust_cloud/webui/package.json +15 -12
- locust_cloud/webui/tsconfig.tsbuildinfo +1 -1
- locust_cloud/webui/yarn.lock +627 -661
- {locust_cloud-1.11.7.dist-info → locust_cloud-1.12.0.dist-info}/METADATA +3 -1
- locust_cloud-1.12.0.dist-info/RECORD +24 -0
- locust_cloud/webui/.eslintrc +0 -41
- locust_cloud/webui/dist/assets/index-j3NU-8ht.js +0 -329
- locust_cloud-1.11.7.dist-info/RECORD +0 -23
- {locust_cloud-1.11.7.dist-info → locust_cloud-1.12.0.dist-info}/WHEEL +0 -0
- {locust_cloud-1.11.7.dist-info → locust_cloud-1.12.0.dist-info}/entry_points.txt +0 -0
locust_cloud/__init__.py
CHANGED
@@ -3,6 +3,15 @@ import os
|
|
3
3
|
import sys
|
4
4
|
|
5
5
|
os.environ["LOCUST_SKIP_MONKEY_PATCH"] = "1"
|
6
|
+
|
7
|
+
from locust_cloud.socket_logging import setup_socket_logging
|
8
|
+
|
9
|
+
if os.environ.get("LOCUST_MODE_MASTER") == "1":
|
10
|
+
major, minor, *rest = os.environ["LOCUSTCLOUD_CLIENT_VERSION"].split(".")
|
11
|
+
|
12
|
+
if int(major) > 1 or int(major) == 1 and int(minor) >= 12:
|
13
|
+
setup_socket_logging()
|
14
|
+
|
6
15
|
__version__ = importlib.metadata.version("locust-cloud")
|
7
16
|
|
8
17
|
import logging
|
@@ -83,6 +92,7 @@ def add_arguments(parser: LocustArgumentParser):
|
|
83
92
|
type=str,
|
84
93
|
env_var="LOCUSTCLOUD_PROFILE",
|
85
94
|
help=configargparse.SUPPRESS,
|
95
|
+
default=None,
|
86
96
|
)
|
87
97
|
|
88
98
|
|
@@ -118,6 +128,7 @@ def on_locust_init(environment: locust.env.Environment, **_args):
|
|
118
128
|
if environment.web_ui:
|
119
129
|
environment.web_ui.template_args["locustVersion"] = locust.__version__
|
120
130
|
environment.web_ui.template_args["locustCloudVersion"] = __version__
|
131
|
+
environment.web_ui.template_args["webBasePath"] = environment.parsed_options.web_base_path
|
121
132
|
|
122
133
|
if environment.parsed_options.graph_viewer:
|
123
134
|
environment.web_ui.template_args["isGraphViewer"] = True
|
locust_cloud/auth.py
CHANGED
@@ -7,7 +7,7 @@ import locust.env
|
|
7
7
|
import requests
|
8
8
|
import werkzeug
|
9
9
|
from flask import Blueprint, redirect, request, session, url_for
|
10
|
-
from flask_login import UserMixin, login_user
|
10
|
+
from flask_login import UserMixin, login_required, login_user, logout_user
|
11
11
|
from locust.html import render_template_from
|
12
12
|
from locust_cloud import __version__
|
13
13
|
|
@@ -95,6 +95,9 @@ def register_auth(environment: locust.env.Environment):
|
|
95
95
|
if credentials.get("challenge_session"):
|
96
96
|
session["challenge_session"] = credentials.get("challenge_session")
|
97
97
|
session["username"] = username
|
98
|
+
|
99
|
+
session["auth_error"] = ""
|
100
|
+
|
98
101
|
return redirect(url_for("locust_cloud_auth.password_reset"))
|
99
102
|
if os.getenv("CUSTOMER_ID", "") and credentials.get("customer_id") != os.getenv("CUSTOMER_ID", ""):
|
100
103
|
session["auth_error"] = "Invalid login for this deployment"
|
@@ -405,12 +408,14 @@ def register_auth(environment: locust.env.Environment):
|
|
405
408
|
auth_response.raise_for_status()
|
406
409
|
|
407
410
|
session["username"] = ""
|
408
|
-
session["auth_info"] = "Password reset successfully! Please login"
|
409
411
|
session["auth_error"] = ""
|
410
412
|
|
411
413
|
if session.get("challenge_session"):
|
412
414
|
session["challenge_session"] = ""
|
413
|
-
|
415
|
+
|
416
|
+
return redirect(url_for("locust_cloud_auth.password_reset_success"))
|
417
|
+
|
418
|
+
session["auth_info"] = "Password reset successfully! Please login"
|
414
419
|
|
415
420
|
return redirect(url_for("locust.login"))
|
416
421
|
except requests.exceptions.HTTPError as e:
|
@@ -420,4 +425,19 @@ def register_auth(environment: locust.env.Environment):
|
|
420
425
|
|
421
426
|
return redirect(url_for("locust_cloud_auth.password_reset"))
|
422
427
|
|
428
|
+
@auth_blueprint.route("/password-reset-success")
|
429
|
+
def password_reset_success():
|
430
|
+
return render_template_from(
|
431
|
+
"auth.html",
|
432
|
+
auth_args={
|
433
|
+
"info": "Password successfully set! Please review the [documentation](https://docs.locust.cloud/) and start your first testrun! If you have already ran some tests, you may also [login](/login)"
|
434
|
+
},
|
435
|
+
)
|
436
|
+
|
437
|
+
@auth_blueprint.route("/logout", methods=["POST"])
|
438
|
+
@login_required
|
439
|
+
def logout():
|
440
|
+
logout_user()
|
441
|
+
return redirect(url_for("locust.login"))
|
442
|
+
|
423
443
|
environment.web_ui.app.register_blueprint(auth_blueprint)
|
locust_cloud/cloud.py
CHANGED
@@ -1,19 +1,19 @@
|
|
1
1
|
import base64
|
2
2
|
import gzip
|
3
|
-
import json
|
4
3
|
import logging
|
5
4
|
import os
|
6
5
|
import sys
|
7
6
|
import time
|
8
7
|
import tomllib
|
8
|
+
import urllib.parse
|
9
9
|
from argparse import Namespace
|
10
10
|
from collections import OrderedDict
|
11
|
-
from datetime import UTC, datetime, timedelta
|
12
11
|
from typing import IO, Any
|
13
12
|
|
14
13
|
import configargparse
|
15
14
|
import requests
|
16
|
-
|
15
|
+
import socketio
|
16
|
+
import socketio.exceptions
|
17
17
|
from locust_cloud import __version__
|
18
18
|
from locust_cloud.credential_manager import CredentialError, CredentialManager
|
19
19
|
|
@@ -200,8 +200,6 @@ def main() -> None:
|
|
200
200
|
if options.region:
|
201
201
|
os.environ["AWS_DEFAULT_REGION"] = options.region
|
202
202
|
|
203
|
-
deployments: list[Any] = []
|
204
|
-
|
205
203
|
if not ((options.username and options.password) or (options.aws_access_key_id and options.aws_secret_access_key)):
|
206
204
|
logger.error(
|
207
205
|
"Authentication is required to use Locust Cloud. Please ensure the LOCUSTCLOUD_USERNAME and LOCUSTCLOUD_PASSWORD environment variables are set."
|
@@ -297,7 +295,7 @@ def main() -> None:
|
|
297
295
|
sys.exit(1)
|
298
296
|
|
299
297
|
if response.status_code == 200:
|
300
|
-
|
298
|
+
log_ws_url = response.json()["log_ws_url"]
|
301
299
|
else:
|
302
300
|
try:
|
303
301
|
logger.error(f"{response.json()['Message']} (HTTP {response.status_code}/{response.reason})")
|
@@ -313,80 +311,71 @@ def main() -> None:
|
|
313
311
|
logger.debug("Interrupted by user")
|
314
312
|
sys.exit(0)
|
315
313
|
|
316
|
-
log_group_name = "/eks/dmdb-default" if options.region == "us-east-1" else "/eks/locust-default"
|
317
|
-
master_pod_name = next((deployment for deployment in deployments if "master" in deployment), None)
|
318
|
-
|
319
|
-
if not master_pod_name:
|
320
|
-
logger.error(
|
321
|
-
"Master pod not found among deployed pods. Something went wrong during the load generator deployment, please try again or contact an administrator for assistance"
|
322
|
-
)
|
323
|
-
sys.exit(1)
|
324
|
-
|
325
314
|
logger.debug("Load generators deployed successfully!")
|
315
|
+
logger.info("Waiting for pods to be ready...")
|
326
316
|
|
327
317
|
try:
|
328
|
-
|
318
|
+
ws_connection_info = urllib.parse.urlparse(log_ws_url)
|
319
|
+
sio = socketio.Client(handle_sigint=False)
|
329
320
|
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
321
|
+
run = True
|
322
|
+
|
323
|
+
def wait():
|
324
|
+
logger.debug("Waiting for shutdown event")
|
325
|
+
while run:
|
326
|
+
time.sleep(0.1)
|
327
|
+
|
328
|
+
logger.debug("Shutting down websocket connection")
|
329
|
+
sio.shutdown()
|
330
|
+
|
331
|
+
@sio.event
|
332
|
+
def connect():
|
333
|
+
logger.debug("Websocket connection established, switching to Locust logs")
|
334
|
+
|
335
|
+
@sio.event
|
336
|
+
def disconnect():
|
337
|
+
logger.debug("Websocket disconnected")
|
338
|
+
|
339
|
+
@sio.event
|
340
|
+
def stderr(message):
|
341
|
+
sys.stderr.write(message)
|
342
|
+
|
343
|
+
@sio.event
|
344
|
+
def stdout(message):
|
345
|
+
sys.stdout.write(message)
|
346
|
+
|
347
|
+
@sio.event
|
348
|
+
def shutdown(message):
|
349
|
+
logger.debug("Got shutdown from locust master")
|
350
|
+
if message:
|
351
|
+
print(message)
|
352
|
+
|
353
|
+
nonlocal run
|
354
|
+
run = False
|
355
|
+
|
356
|
+
for _ in range(5 * 60): # try for 5 minutes
|
351
357
|
try:
|
352
|
-
|
353
|
-
|
354
|
-
logGroupName=log_group_name,
|
355
|
-
logStreamName=log_stream,
|
356
|
-
startTime=timestamp,
|
357
|
-
startFromHead=True,
|
358
|
+
sio.connect(
|
359
|
+
f"{ws_connection_info.scheme}://{ws_connection_info.netloc}", socketio_path=ws_connection_info.path
|
358
360
|
)
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
if "Shutting down (exit code" in message_json["log"]:
|
369
|
-
locust_shutdown = True
|
370
|
-
|
371
|
-
except json.JSONDecodeError:
|
372
|
-
print(message)
|
373
|
-
timestamp = event_timestamp
|
374
|
-
|
375
|
-
if locust_shutdown:
|
376
|
-
break
|
377
|
-
|
378
|
-
time.sleep(5)
|
379
|
-
except ClientError as e:
|
380
|
-
error_code = e.response.get("Error", {}).get("Code", "")
|
381
|
-
if error_code == "ExpiredTokenException":
|
382
|
-
logger.debug("AWS session token expired during log streaming. Refreshing credentials.")
|
383
|
-
time.sleep(5)
|
361
|
+
break
|
362
|
+
except socketio.exceptions.ConnectionError:
|
363
|
+
time.sleep(1)
|
364
|
+
|
365
|
+
else: # no break
|
366
|
+
raise Exception("Failed to obtain socket connection")
|
367
|
+
|
368
|
+
wait()
|
369
|
+
|
384
370
|
except KeyboardInterrupt:
|
385
371
|
logger.debug("Interrupted by user")
|
372
|
+
delete(credential_manager)
|
373
|
+
wait()
|
386
374
|
except Exception as e:
|
387
375
|
logger.exception(e)
|
376
|
+
delete(credential_manager)
|
388
377
|
sys.exit(1)
|
389
|
-
|
378
|
+
else:
|
390
379
|
delete(credential_manager)
|
391
380
|
|
392
381
|
|
@@ -0,0 +1,103 @@
|
|
1
|
+
import atexit
|
2
|
+
import logging
|
3
|
+
import os
|
4
|
+
import sys
|
5
|
+
from collections import deque
|
6
|
+
from contextlib import suppress
|
7
|
+
|
8
|
+
import flask
|
9
|
+
import gevent
|
10
|
+
import gevent.pywsgi
|
11
|
+
import geventwebsocket.handler
|
12
|
+
import socketio
|
13
|
+
|
14
|
+
|
15
|
+
def setup_socket_logging():
|
16
|
+
"""
|
17
|
+
Set up a separate server listening for incomming websocket connections.
|
18
|
+
Because it listens on a separate port from the webui and the ALB only
|
19
|
+
accepts connections on port 443, this will be exposed on a different
|
20
|
+
path (/<customer-id>/socket-logs) with a separate target group.
|
21
|
+
The targetgroup will want to do health checks before exposing the server
|
22
|
+
so a small flask app exposing the health check endpoint is added to the
|
23
|
+
server in addition to the websocket stuff.
|
24
|
+
"""
|
25
|
+
|
26
|
+
logger = logging.getLogger(__name__)
|
27
|
+
logger.propagate = False
|
28
|
+
logger.addHandler(logging.NullHandler())
|
29
|
+
|
30
|
+
# This app will use a logger with the same name as the application
|
31
|
+
# which means it will pick up the one set up above.
|
32
|
+
healthcheck_app = flask.Flask(__name__)
|
33
|
+
|
34
|
+
# /login is the health check endpoint currently configured for the
|
35
|
+
# ALB controller in kubernetes.
|
36
|
+
# See cloud-onboarder/kubernetes/alb-load-balancer-ingress.yaml
|
37
|
+
@healthcheck_app.route("/login")
|
38
|
+
def healthcheck():
|
39
|
+
return ""
|
40
|
+
|
41
|
+
sio = socketio.Server(async_handlers=True, always_connect=True, async_mode="gevent", cors_allowed_origins="*")
|
42
|
+
sio_app = socketio.WSGIApp(sio, healthcheck_app, socketio_path=f"/{os.environ['CUSTOMER_ID']}/socket-logs")
|
43
|
+
message_queue = deque(maxlen=500)
|
44
|
+
|
45
|
+
class QueueCopyStream:
|
46
|
+
def __init__(self, name, original):
|
47
|
+
self.name = name
|
48
|
+
self.original = original
|
49
|
+
|
50
|
+
def write(self, message):
|
51
|
+
self.original.write(message)
|
52
|
+
message_queue.append((self.name, message))
|
53
|
+
|
54
|
+
def flush(self):
|
55
|
+
self.original.flush()
|
56
|
+
|
57
|
+
def connected_sid():
|
58
|
+
for rooms in sio.manager.rooms.values():
|
59
|
+
for clients in rooms.values():
|
60
|
+
for sid in clients.keys():
|
61
|
+
return sid
|
62
|
+
|
63
|
+
def emitter():
|
64
|
+
while True:
|
65
|
+
while message_queue and (sid := connected_sid()):
|
66
|
+
name, message = message_queue[0]
|
67
|
+
|
68
|
+
with suppress(TimeoutError):
|
69
|
+
sio.call(name, message, to=sid, timeout=5)
|
70
|
+
message_queue.popleft()
|
71
|
+
|
72
|
+
if name == "shutdown":
|
73
|
+
return
|
74
|
+
|
75
|
+
gevent.sleep(1)
|
76
|
+
|
77
|
+
emitter_greenlet = gevent.spawn(emitter)
|
78
|
+
|
79
|
+
sys.stderr = QueueCopyStream("stderr", sys.stderr)
|
80
|
+
sys.stdout = QueueCopyStream("stdout", sys.stdout)
|
81
|
+
|
82
|
+
@atexit.register
|
83
|
+
def notify_shutdown(*args, **kwargs):
|
84
|
+
message_queue.append(("shutdown", ""))
|
85
|
+
emitter_greenlet.join(timeout=30)
|
86
|
+
|
87
|
+
class WebSocketHandlerWithoutLogging(geventwebsocket.handler.WebSocketHandler):
|
88
|
+
"""
|
89
|
+
Subclassing WebSocketHandler so it doesn't set a logger on
|
90
|
+
the server that I've explicitly configured not to have one.
|
91
|
+
"""
|
92
|
+
|
93
|
+
@property
|
94
|
+
def logger(self):
|
95
|
+
return logger
|
96
|
+
|
97
|
+
@gevent.spawn
|
98
|
+
def start_websocket_server():
|
99
|
+
server = gevent.pywsgi.WSGIServer(
|
100
|
+
("", 1095), sio_app, log=None, error_log=None, handler_class=WebSocketHandlerWithoutLogging
|
101
|
+
)
|
102
|
+
server.serve_forever()
|
103
|
+
gevent.get_hub().join()
|
@@ -147,7 +147,7 @@ class Exporter:
|
|
147
147
|
|
148
148
|
def on_quit(self, exit_code, **kwargs):
|
149
149
|
self._finished = True
|
150
|
-
atexit.
|
150
|
+
atexit.unregister(self.log_stop_test_run) # make sure we dont capture additional ctrl-c:s
|
151
151
|
self._background.join(timeout=10)
|
152
152
|
if getattr(self, "_update_end_time_task", False):
|
153
153
|
self._update_end_time_task.kill()
|
locust_cloud/timescale/query.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
import logging
|
2
|
+
from datetime import UTC
|
2
3
|
|
3
4
|
from flask import Blueprint, make_response, request
|
4
5
|
from flask_login import login_required
|
@@ -22,6 +23,7 @@ def register_query(environment, pool):
|
|
22
23
|
results = []
|
23
24
|
try:
|
24
25
|
if query and queries[query]:
|
26
|
+
sql = queries[query]
|
25
27
|
# start_time = time.perf_counter()
|
26
28
|
with pool.connection() as conn:
|
27
29
|
# get_conn_time = (time.perf_counter() - start_time) * 1000
|
@@ -38,8 +40,11 @@ def register_query(environment, pool):
|
|
38
40
|
f"UI asked for too long time interval. Start was {sql_params['start']}, end was {sql_params['end']}"
|
39
41
|
)
|
40
42
|
return []
|
43
|
+
if start_time >= datetime(2024, 10, 30, 11, tzinfo=UTC):
|
44
|
+
# when runs before this go out of scope, we can just update the query
|
45
|
+
sql = sql.replace("FROM requests_summary_view", "FROM requests_summary_view_v1")
|
41
46
|
|
42
|
-
cursor = conn.execute(
|
47
|
+
cursor = conn.execute(sql, sql_params)
|
43
48
|
# exec_time = (time.perf_counter() - start_time) * 1000
|
44
49
|
assert cursor
|
45
50
|
# start_time = time.perf_counter()
|