locust-cloud 1.13.0__py3-none-any.whl → 1.14.1__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/cloud.py +227 -66
- {locust_cloud-1.13.0.dist-info → locust_cloud-1.14.1.dist-info}/METADATA +2 -2
- locust_cloud-1.14.1.dist-info/RECORD +5 -0
- locust_cloud-1.13.0.dist-info/RECORD +0 -5
- {locust_cloud-1.13.0.dist-info → locust_cloud-1.14.1.dist-info}/WHEEL +0 -0
- {locust_cloud-1.13.0.dist-info → locust_cloud-1.14.1.dist-info}/entry_points.txt +0 -0
locust_cloud/cloud.py
CHANGED
@@ -167,8 +167,6 @@ logging.basicConfig(
|
|
167
167
|
)
|
168
168
|
logger = logging.getLogger(__name__)
|
169
169
|
# Restore log level for other libs. Yes, this can be done more nicely
|
170
|
-
logging.getLogger("botocore").setLevel(logging.INFO)
|
171
|
-
logging.getLogger("boto3").setLevel(logging.INFO)
|
172
170
|
logging.getLogger("requests").setLevel(logging.INFO)
|
173
171
|
logging.getLogger("urllib3").setLevel(logging.INFO)
|
174
172
|
|
@@ -368,6 +366,204 @@ class ApiSession(requests.Session):
|
|
368
366
|
return super().request(method, f"{self.api_url}{url}", *args, **kwargs)
|
369
367
|
|
370
368
|
|
369
|
+
class SessionMismatchError(Exception):
|
370
|
+
pass
|
371
|
+
|
372
|
+
|
373
|
+
class WebsocketTimeout(Exception):
|
374
|
+
pass
|
375
|
+
|
376
|
+
|
377
|
+
class Websocket:
|
378
|
+
def __init__(self) -> None:
|
379
|
+
"""
|
380
|
+
This class was created to encapsulate all the logic involved in the websocket implementation.
|
381
|
+
The behaviour of the socketio client once a connection has been established
|
382
|
+
is to try to reconnect forever if the connection is lost.
|
383
|
+
The way this can be canceled is by setting the _reconnect_abort (threading.Event) on the client
|
384
|
+
in which case it will simply proceed with shutting down without giving any indication of an error.
|
385
|
+
This class handles timeouts for connection attempts as well as some logic around when the
|
386
|
+
socket can be shut down. See descriptions on the methods for further details.
|
387
|
+
"""
|
388
|
+
self.__shutdown_allowed = threading.Event()
|
389
|
+
self.__timeout_on_disconnect = True
|
390
|
+
self.initial_connect_timeout = 120
|
391
|
+
self.reconnect_timeout = 10
|
392
|
+
self.wait_timeout = 0
|
393
|
+
self.exception: None | Exception = None
|
394
|
+
|
395
|
+
self.sio = socketio.Client(handle_sigint=False)
|
396
|
+
self.sio._reconnect_abort = threading.Event()
|
397
|
+
# The _reconnect_abort value on the socketio client will be populated with a newly created threading.Event if it's not already set.
|
398
|
+
# There is no way to set this by passing it in the constructor.
|
399
|
+
# This event is the only way to interupt the retry logic when the connection is attempted.
|
400
|
+
|
401
|
+
self.sio.on("connect", self.__on_connect)
|
402
|
+
self.sio.on("disconnect", self.__on_disconnect)
|
403
|
+
self.sio.on("connect_error", self.__on_connect_error)
|
404
|
+
self.sio.on("events", self.__on_events)
|
405
|
+
|
406
|
+
self.__processed_events: set[int] = set()
|
407
|
+
|
408
|
+
def __set_connection_timeout(self, timeout) -> None:
|
409
|
+
"""
|
410
|
+
Start a threading.Timer that will set the threading.Event on the socketio client
|
411
|
+
that aborts any further attempts to reconnect, sets an exception on the websocket
|
412
|
+
that will be raised from the wait method and the threading.Event __shutdown_allowed
|
413
|
+
on the websocket that tells the wait method that it should stop blocking.
|
414
|
+
"""
|
415
|
+
|
416
|
+
def _timeout():
|
417
|
+
logger.debug(f"Websocket connection timed out after {timeout} seconds")
|
418
|
+
self.sio._reconnect_abort.set()
|
419
|
+
self.exception = WebsocketTimeout("Timed out connecting to locust master")
|
420
|
+
self.__shutdown_allowed.set()
|
421
|
+
|
422
|
+
self.__connect_timeout_timer = threading.Timer(timeout, _timeout)
|
423
|
+
self.__connect_timeout_timer.daemon = True
|
424
|
+
logger.debug(f"Setting websocket connection timeout to {timeout} seconds")
|
425
|
+
self.__connect_timeout_timer.start()
|
426
|
+
|
427
|
+
def connect(self, url, *, auth) -> None:
|
428
|
+
"""
|
429
|
+
Send along retry=True when initiating the socketio client connection
|
430
|
+
to make it use it's builtin logic for retrying failed connections that
|
431
|
+
is usually used for reconnections. This will retry forever.
|
432
|
+
When connecting start a timer to trigger disabling the retry logic and
|
433
|
+
raise a WebsocketTimeout exception.
|
434
|
+
"""
|
435
|
+
ws_connection_info = urllib.parse.urlparse(url)
|
436
|
+
self.__set_connection_timeout(self.initial_connect_timeout)
|
437
|
+
try:
|
438
|
+
self.sio.connect(
|
439
|
+
f"{ws_connection_info.scheme}://{ws_connection_info.netloc}",
|
440
|
+
auth=auth,
|
441
|
+
retry=True,
|
442
|
+
**{"socketio_path": ws_connection_info.path} if ws_connection_info.path else {},
|
443
|
+
)
|
444
|
+
except socketio.exceptions.ConnectionError:
|
445
|
+
if self.exception:
|
446
|
+
raise self.exception
|
447
|
+
|
448
|
+
raise
|
449
|
+
|
450
|
+
def shutdown(self) -> None:
|
451
|
+
"""
|
452
|
+
When shutting down the socketio client a disconnect event will fire.
|
453
|
+
Before doing so disable the behaviour of starting a threading.Timer
|
454
|
+
to handle timeouts on attempts to reconnect since no further such attempts
|
455
|
+
will be made.
|
456
|
+
If such a timer is already running, cancel it since the client is being shutdown.
|
457
|
+
"""
|
458
|
+
self.__timeout_on_disconnect = False
|
459
|
+
if hasattr(self, "__connect_timeout_timer"):
|
460
|
+
self.__connect_timeout_timer.cancel()
|
461
|
+
self.sio.shutdown()
|
462
|
+
|
463
|
+
def wait(self, timeout=False) -> bool:
|
464
|
+
"""
|
465
|
+
Block until the threading.Event __shutdown_allowed is set, with a timeout if indicated.
|
466
|
+
If an exception has been set on the websocket (from a connection timeout timer or the
|
467
|
+
__on_connect_error method), raise it.
|
468
|
+
"""
|
469
|
+
timeout = self.wait_timeout if timeout else None
|
470
|
+
logger.debug(f"Waiting for shutdown for {str(timeout)+'s' if timeout else 'ever'}")
|
471
|
+
res = self.__shutdown_allowed.wait(timeout)
|
472
|
+
if self.exception:
|
473
|
+
raise self.exception
|
474
|
+
return res
|
475
|
+
|
476
|
+
def __on_connect(self) -> None:
|
477
|
+
"""
|
478
|
+
This gets events whenever a connection is successfully established.
|
479
|
+
When this happens, cancel the running threading.Timer that would
|
480
|
+
abort reconnect attempts and raise a WebsocketTimeout exception.
|
481
|
+
The wait_timeout is originally set to zero when creating the websocket
|
482
|
+
but once a connection has been established this is raised to ensure
|
483
|
+
that the server is given the chance to send all the logs and an
|
484
|
+
official shutdown event.
|
485
|
+
"""
|
486
|
+
self.__connect_timeout_timer.cancel()
|
487
|
+
self.wait_timeout = 90
|
488
|
+
logger.debug("Websocket connected")
|
489
|
+
|
490
|
+
def __on_disconnect(self) -> None:
|
491
|
+
"""
|
492
|
+
This gets events whenever a connection is lost.
|
493
|
+
The socketio client will try to reconnect forever so,
|
494
|
+
unless the behaviour has been disabled, a threading.Timer
|
495
|
+
is started that will abort reconnect attempts and raise a
|
496
|
+
WebsocketTimeout exception.
|
497
|
+
"""
|
498
|
+
if self.__timeout_on_disconnect:
|
499
|
+
self.__set_connection_timeout(self.reconnect_timeout)
|
500
|
+
logger.debug("Websocket disconnected")
|
501
|
+
|
502
|
+
def __on_events(self, data):
|
503
|
+
"""
|
504
|
+
This gets events explicitly sent by the websocket server.
|
505
|
+
This will either be messages to print on stdout/stderr or
|
506
|
+
an indication that the CLI can shut down in which case the
|
507
|
+
threading.Event __shutdown_allowed gets set on the websocket
|
508
|
+
that tells the wait method that it should stop blocking.
|
509
|
+
"""
|
510
|
+
shutdown = False
|
511
|
+
shutdown_message = ""
|
512
|
+
|
513
|
+
if data["id"] in self.__processed_events:
|
514
|
+
logger.debug(f"Got duplicate data on websocket, id {data['id']}")
|
515
|
+
return
|
516
|
+
|
517
|
+
self.__processed_events.add(data["id"])
|
518
|
+
|
519
|
+
for event in data["events"]:
|
520
|
+
type = event["type"]
|
521
|
+
|
522
|
+
if type == "shutdown":
|
523
|
+
shutdown = True
|
524
|
+
shutdown_message = event["message"]
|
525
|
+
elif type == "stdout":
|
526
|
+
sys.stdout.write(event["message"])
|
527
|
+
elif type == "stderr":
|
528
|
+
sys.stderr.write(event["message"])
|
529
|
+
else:
|
530
|
+
raise Exception("Unexpected event type")
|
531
|
+
|
532
|
+
if shutdown:
|
533
|
+
logger.debug("Got shutdown from locust master")
|
534
|
+
if shutdown_message:
|
535
|
+
print(shutdown_message)
|
536
|
+
|
537
|
+
self.__shutdown_allowed.set()
|
538
|
+
|
539
|
+
def __on_connect_error(self, data) -> None:
|
540
|
+
"""
|
541
|
+
This gets events whenever there's an error during connection attempts.
|
542
|
+
The specific case that is handled below is triggered when the connection
|
543
|
+
is made with the auth parameter not matching the session ID on the server.
|
544
|
+
If this error occurs it's because the connection is attempted towards an
|
545
|
+
instance of locust not started by this CLI.
|
546
|
+
|
547
|
+
In that case:
|
548
|
+
Cancel the running threading.Timer that would abort reconnect attempts
|
549
|
+
and raise a WebsocketTimeout exception.
|
550
|
+
Set an exception on the websocket that will be raised from the wait method.
|
551
|
+
Cancel further reconnect attempts.
|
552
|
+
Set the threading.Event __shutdown_allowed on the websocket that tells the
|
553
|
+
wait method that it should stop blocking.
|
554
|
+
"""
|
555
|
+
# Do nothing if it's not the specific case we know how to deal with
|
556
|
+
if not (isinstance(data, dict) and data.get("message") == "Session mismatch"):
|
557
|
+
return
|
558
|
+
|
559
|
+
self.__connect_timeout_timer.cancel()
|
560
|
+
self.exception = SessionMismatchError(
|
561
|
+
"The session from this run of locust-cloud did not match the one on the server"
|
562
|
+
)
|
563
|
+
self.sio._reconnect_abort.set()
|
564
|
+
self.__shutdown_allowed.set()
|
565
|
+
|
566
|
+
|
371
567
|
def main() -> None:
|
372
568
|
if options.version:
|
373
569
|
print(f"locust-cloud version {__version__}")
|
@@ -384,12 +580,13 @@ def main() -> None:
|
|
384
580
|
sys.exit()
|
385
581
|
|
386
582
|
session = ApiSession()
|
583
|
+
websocket = Websocket()
|
387
584
|
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
return
|
585
|
+
if options.delete:
|
586
|
+
delete(session)
|
587
|
+
sys.exit()
|
392
588
|
|
589
|
+
try:
|
393
590
|
try:
|
394
591
|
with open(options.locustfile, "rb") as f:
|
395
592
|
locustfile_data = base64.b64encode(gzip.compress(f.read())).decode()
|
@@ -448,9 +645,7 @@ def main() -> None:
|
|
448
645
|
logger.error(f"Failed to deploy the load generators: {e}")
|
449
646
|
sys.exit(1)
|
450
647
|
|
451
|
-
if response.status_code
|
452
|
-
log_ws_url = response.json()["log_ws_url"]
|
453
|
-
else:
|
648
|
+
if response.status_code != 200:
|
454
649
|
try:
|
455
650
|
logger.error(f"{response.json()['Message']} (HTTP {response.status_code}/{response.reason})")
|
456
651
|
except Exception:
|
@@ -459,66 +654,33 @@ def main() -> None:
|
|
459
654
|
)
|
460
655
|
sys.exit(1)
|
461
656
|
|
462
|
-
|
463
|
-
|
464
|
-
logger.debug("
|
465
|
-
sys.exit(0)
|
466
|
-
|
467
|
-
logger.debug("Load generators deployed successfully!")
|
468
|
-
logger.info("Waiting for pods to be ready...")
|
469
|
-
|
470
|
-
shutdown_allowed = threading.Event()
|
471
|
-
shutdown_allowed.set()
|
472
|
-
reconnect_aborted = threading.Event()
|
473
|
-
connect_timeout = threading.Timer(2 * 60, reconnect_aborted.set)
|
474
|
-
sio = socketio.Client(handle_sigint=False)
|
475
|
-
|
476
|
-
try:
|
477
|
-
ws_connection_info = urllib.parse.urlparse(log_ws_url)
|
478
|
-
|
479
|
-
@sio.event
|
480
|
-
def connect():
|
481
|
-
shutdown_allowed.clear()
|
482
|
-
connect_timeout.cancel()
|
483
|
-
logger.debug("Websocket connection established, switching to Locust logs")
|
484
|
-
|
485
|
-
@sio.event
|
486
|
-
def disconnect():
|
487
|
-
logger.debug("Websocket disconnected")
|
488
|
-
|
489
|
-
@sio.event
|
490
|
-
def stderr(message):
|
491
|
-
sys.stderr.write(message)
|
492
|
-
|
493
|
-
@sio.event
|
494
|
-
def stdout(message):
|
495
|
-
sys.stdout.write(message)
|
657
|
+
log_ws_url = response.json()["log_ws_url"]
|
658
|
+
session_id = response.json()["session_id"]
|
659
|
+
logger.debug(f"Session ID is {session_id}")
|
496
660
|
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
print(message)
|
502
|
-
|
503
|
-
shutdown_allowed.set()
|
504
|
-
|
505
|
-
# The _reconnect_abort value on the socketio client will be populated with a newly created threading.Event if it's not already set.
|
506
|
-
# There is no way to set this by passing it in the constructor.
|
507
|
-
# This event is the only way to interupt the retry logic when the connection is attempted.
|
508
|
-
sio._reconnect_abort = reconnect_aborted
|
509
|
-
connect_timeout.start()
|
510
|
-
sio.connect(
|
511
|
-
f"{ws_connection_info.scheme}://{ws_connection_info.netloc}",
|
512
|
-
socketio_path=ws_connection_info.path,
|
513
|
-
retry=True,
|
661
|
+
logger.info("Waiting for load generators to be ready...")
|
662
|
+
websocket.connect(
|
663
|
+
log_ws_url,
|
664
|
+
auth=session_id,
|
514
665
|
)
|
515
|
-
|
516
|
-
shutdown_allowed.wait()
|
666
|
+
websocket.wait()
|
517
667
|
|
518
668
|
except KeyboardInterrupt:
|
519
669
|
logger.debug("Interrupted by user")
|
520
670
|
delete(session)
|
521
|
-
|
671
|
+
try:
|
672
|
+
websocket.wait(timeout=True)
|
673
|
+
except (WebsocketTimeout, SessionMismatchError) as e:
|
674
|
+
logger.error(str(e))
|
675
|
+
sys.exit(1)
|
676
|
+
except WebsocketTimeout as e:
|
677
|
+
logger.error(str(e))
|
678
|
+
delete(session)
|
679
|
+
sys.exit(1)
|
680
|
+
except SessionMismatchError as e:
|
681
|
+
# In this case we do not trigger the teardown since the running instance is not ours
|
682
|
+
logger.error(str(e))
|
683
|
+
sys.exit(1)
|
522
684
|
except Exception as e:
|
523
685
|
logger.exception(e)
|
524
686
|
delete(session)
|
@@ -526,7 +688,8 @@ def main() -> None:
|
|
526
688
|
else:
|
527
689
|
delete(session)
|
528
690
|
finally:
|
529
|
-
|
691
|
+
logger.debug("Shutting down websocket")
|
692
|
+
websocket.shutdown()
|
530
693
|
|
531
694
|
|
532
695
|
def delete(session):
|
@@ -545,8 +708,6 @@ def delete(session):
|
|
545
708
|
except Exception as e:
|
546
709
|
logger.error(f"Could not automatically tear down Locust Cloud: {e.__class__.__name__}:{e}")
|
547
710
|
|
548
|
-
logger.info("Done! ✨") # FIXME: Should probably not say it's done since at this point it could still be running
|
549
|
-
|
550
711
|
|
551
712
|
if __name__ == "__main__":
|
552
713
|
main()
|
@@ -1,14 +1,14 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: locust-cloud
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.14.1
|
4
4
|
Summary: Locust Cloud
|
5
5
|
Project-URL: Homepage, https://locust.cloud
|
6
6
|
Requires-Python: >=3.11
|
7
|
-
Requires-Dist: boto3==1.34.125
|
8
7
|
Requires-Dist: configargparse==1.7
|
9
8
|
Requires-Dist: platformdirs>=4.3.6
|
10
9
|
Requires-Dist: pyjwt>=2.0
|
11
10
|
Requires-Dist: python-socketio[client]==5.11.4
|
11
|
+
Requires-Dist: requests==2.32.3
|
12
12
|
Description-Content-Type: text/markdown
|
13
13
|
|
14
14
|
# Locust Cloud
|
@@ -0,0 +1,5 @@
|
|
1
|
+
locust_cloud/cloud.py,sha256=2zRfdvnrSqEBzCspEtL3H2h0H-Y2gIHs4GmZUX-P9bw,25357
|
2
|
+
locust_cloud-1.14.1.dist-info/METADATA,sha256=wJHNidr7_UQ39nTxdPml1Dd7lcL-X4vfnIxbjGMn9i0,497
|
3
|
+
locust_cloud-1.14.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
4
|
+
locust_cloud-1.14.1.dist-info/entry_points.txt,sha256=PGyAb4e3aTsGS3N3VGShDl6VzJaXy7QwsEgsLOC7V00,57
|
5
|
+
locust_cloud-1.14.1.dist-info/RECORD,,
|
@@ -1,5 +0,0 @@
|
|
1
|
-
locust_cloud/cloud.py,sha256=r0lWoP1QiT1Klkrecsr6LyPD_l1GMW2BFGRCRVkLowM,18124
|
2
|
-
locust_cloud-1.13.0.dist-info/METADATA,sha256=STWxyZOw0qE003r-Pf-8RU3KPFK3yjH_NsSo5ffoHh0,496
|
3
|
-
locust_cloud-1.13.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
4
|
-
locust_cloud-1.13.0.dist-info/entry_points.txt,sha256=PGyAb4e3aTsGS3N3VGShDl6VzJaXy7QwsEgsLOC7V00,57
|
5
|
-
locust_cloud-1.13.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|