locust-cloud 1.11.8__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 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
@@ -119,6 +128,7 @@ def on_locust_init(environment: locust.env.Environment, **_args):
119
128
  if environment.web_ui:
120
129
  environment.web_ui.template_args["locustVersion"] = locust.__version__
121
130
  environment.web_ui.template_args["locustCloudVersion"] = __version__
131
+ environment.web_ui.template_args["webBasePath"] = environment.parsed_options.web_base_path
122
132
 
123
133
  if environment.parsed_options.graph_viewer:
124
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
- return redirect("https://docs.locust.cloud/")
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
- from botocore.exceptions import ClientError
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
- deployments = response.json().get("deployments", [])
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
- logger.info("Waiting for pods to be ready...")
318
+ ws_connection_info = urllib.parse.urlparse(log_ws_url)
319
+ sio = socketio.Client(handle_sigint=False)
329
320
 
330
- log_stream: str | None = None
331
- while log_stream is None:
332
- try:
333
- client = credential_manager.session.client("logs")
334
- response = client.describe_log_streams(
335
- logGroupName=log_group_name,
336
- logStreamNamePrefix=f"from-fluent-bit-kube.var.log.containers.{master_pod_name}",
337
- )
338
- all_streams = response.get("logStreams", [])
339
- if all_streams:
340
- log_stream = all_streams[0].get("logStreamName")
341
- else:
342
- time.sleep(1)
343
- except ClientError as e:
344
- logger.error(f"Error describing log streams: {e}")
345
- time.sleep(5)
346
- logger.debug("Pods are ready, switching to Locust logs")
347
-
348
- timestamp = int((datetime.now(UTC) - timedelta(minutes=5)).timestamp() * 1000)
349
-
350
- while True:
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
- client = credential_manager.session.client("logs")
353
- response = client.get_log_events(
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
- locust_shutdown = False
360
- for event in response.get("events", []):
361
- message = event.get("message", "")
362
- event_timestamp = event.get("timestamp", timestamp) + 1
363
- try:
364
- message_json = json.loads(message)
365
- if "log" in message_json:
366
- print(message_json["log"])
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
- finally:
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._clear() # make sure we dont capture additional ctrl-c:s
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()
@@ -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(queries[query], sql_params)
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()