locust-cloud 1.14.1__py3-none-any.whl → 1.14.3__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/apisession.py +108 -0
- locust_cloud/args.py +225 -0
- locust_cloud/cloud.py +21 -574
- locust_cloud/common.py +40 -0
- locust_cloud/web_login.py +77 -0
- locust_cloud/websocket.py +207 -0
- {locust_cloud-1.14.1.dist-info → locust_cloud-1.14.3.dist-info}/METADATA +1 -1
- locust_cloud-1.14.3.dist-info/RECORD +10 -0
- locust_cloud-1.14.1.dist-info/RECORD +0 -5
- {locust_cloud-1.14.1.dist-info → locust_cloud-1.14.3.dist-info}/WHEEL +0 -0
- {locust_cloud-1.14.1.dist-info → locust_cloud-1.14.3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,108 @@
|
|
1
|
+
import logging
|
2
|
+
import os
|
3
|
+
import sys
|
4
|
+
import time
|
5
|
+
|
6
|
+
import jwt
|
7
|
+
import requests
|
8
|
+
from locust_cloud.common import VALID_REGIONS, __version__, get_api_url, read_cloud_config, write_cloud_config
|
9
|
+
|
10
|
+
logger = logging.getLogger(__name__)
|
11
|
+
|
12
|
+
|
13
|
+
class ApiSession(requests.Session):
|
14
|
+
def __init__(self, non_interactive: bool) -> None:
|
15
|
+
super().__init__()
|
16
|
+
self.non_interactive = non_interactive
|
17
|
+
|
18
|
+
if non_interactive:
|
19
|
+
username = os.getenv("LOCUSTCLOUD_USERNAME")
|
20
|
+
password = os.getenv("LOCUSTCLOUD_PASSWORD")
|
21
|
+
region = os.getenv("LOCUSTCLOUD_REGION")
|
22
|
+
|
23
|
+
if not all([username, password, region]):
|
24
|
+
print(
|
25
|
+
"Running with --non-interactive requires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set."
|
26
|
+
)
|
27
|
+
sys.exit(1)
|
28
|
+
|
29
|
+
if region not in VALID_REGIONS:
|
30
|
+
print("Environment variable LOCUSTCLOUD_REGION needs to be set to one of", ", ".join(VALID_REGIONS))
|
31
|
+
sys.exit(1)
|
32
|
+
|
33
|
+
self.__configure_for_region(region)
|
34
|
+
response = requests.post(
|
35
|
+
self.__login_url,
|
36
|
+
json={"username": username, "password": password},
|
37
|
+
headers={"X-Client-Version": __version__},
|
38
|
+
)
|
39
|
+
if not response.ok:
|
40
|
+
print(f"Authentication failed: {response.text}")
|
41
|
+
sys.exit(1)
|
42
|
+
|
43
|
+
self.__refresh_token = response.json()["refresh_token"]
|
44
|
+
id_token = response.json()["cognito_client_id_token"]
|
45
|
+
|
46
|
+
else:
|
47
|
+
config = read_cloud_config()
|
48
|
+
|
49
|
+
if config.refresh_token_expires < time.time() + 24 * 60 * 60:
|
50
|
+
message = "You need to authenticate before proceeding. Please run:\n locust-cloud --login"
|
51
|
+
print(message)
|
52
|
+
sys.exit(1)
|
53
|
+
|
54
|
+
assert config.region
|
55
|
+
self.__configure_for_region(config.region)
|
56
|
+
self.__refresh_token = config.refresh_token
|
57
|
+
id_token = config.id_token
|
58
|
+
|
59
|
+
assert id_token
|
60
|
+
|
61
|
+
decoded = jwt.decode(id_token, options={"verify_signature": False})
|
62
|
+
self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
|
63
|
+
self.headers["Authorization"] = f"Bearer {id_token}"
|
64
|
+
|
65
|
+
self.__sub = decoded["sub"]
|
66
|
+
self.headers["X-Client-Version"] = __version__
|
67
|
+
|
68
|
+
def __configure_for_region(self, region: str) -> None:
|
69
|
+
self.__region = region
|
70
|
+
self.api_url = get_api_url(region)
|
71
|
+
self.__login_url = f"{self.api_url}/auth/login"
|
72
|
+
|
73
|
+
logger.debug(f"Lambda url: {self.api_url}")
|
74
|
+
|
75
|
+
def __ensure_valid_authorization_header(self) -> None:
|
76
|
+
if self.__expiry_time > time.time():
|
77
|
+
return
|
78
|
+
|
79
|
+
logger.info(f"Authenticating ({self.__region}, v{__version__})")
|
80
|
+
|
81
|
+
response = requests.post(
|
82
|
+
self.__login_url,
|
83
|
+
json={"user_sub_id": self.__sub, "refresh_token": self.__refresh_token},
|
84
|
+
headers={"X-Client-Version": __version__},
|
85
|
+
)
|
86
|
+
|
87
|
+
if not response.ok:
|
88
|
+
logger.error(f"Authentication failed: {response.text}")
|
89
|
+
sys.exit(1)
|
90
|
+
|
91
|
+
# TODO: Technically the /login endpoint can return a challenge for you
|
92
|
+
# to change your password.
|
93
|
+
# Now that we have a web based login flow we should force them to
|
94
|
+
# do a locust-cloud --login if we get that.
|
95
|
+
|
96
|
+
id_token = response.json()["cognito_client_id_token"]
|
97
|
+
decoded = jwt.decode(id_token, options={"verify_signature": False})
|
98
|
+
self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
|
99
|
+
self.headers["Authorization"] = f"Bearer {id_token}"
|
100
|
+
|
101
|
+
if not self.non_interactive:
|
102
|
+
config = read_cloud_config()
|
103
|
+
config.id_token = id_token
|
104
|
+
write_cloud_config(config)
|
105
|
+
|
106
|
+
def request(self, method, url, *args, **kwargs) -> requests.Response:
|
107
|
+
self.__ensure_valid_authorization_header()
|
108
|
+
return super().request(method, f"{self.api_url}{url}", *args, **kwargs)
|
locust_cloud/args.py
ADDED
@@ -0,0 +1,225 @@
|
|
1
|
+
import argparse
|
2
|
+
import base64
|
3
|
+
import gzip
|
4
|
+
import io
|
5
|
+
import os
|
6
|
+
import pathlib
|
7
|
+
import tomllib
|
8
|
+
from argparse import ArgumentTypeError, Namespace
|
9
|
+
from collections import OrderedDict
|
10
|
+
from collections.abc import Callable, Generator
|
11
|
+
from typing import IO, Any, cast
|
12
|
+
from zipfile import ZipFile
|
13
|
+
|
14
|
+
import configargparse
|
15
|
+
|
16
|
+
CWD = pathlib.Path.cwd()
|
17
|
+
|
18
|
+
|
19
|
+
class LocustTomlConfigParser(configargparse.TomlConfigParser):
|
20
|
+
def parse(self, stream: IO[str]) -> OrderedDict[str, Any]:
|
21
|
+
try:
|
22
|
+
config = tomllib.loads(stream.read())
|
23
|
+
except Exception as e:
|
24
|
+
raise configargparse.ConfigFileParserException(f"Couldn't parse TOML file: {e}")
|
25
|
+
|
26
|
+
result: OrderedDict[str, Any] = OrderedDict()
|
27
|
+
|
28
|
+
for section in self.sections:
|
29
|
+
data = configargparse.get_toml_section(config, section)
|
30
|
+
if data:
|
31
|
+
for key, value in data.items():
|
32
|
+
if isinstance(value, list):
|
33
|
+
result[key] = value
|
34
|
+
elif value is not None:
|
35
|
+
result[key] = str(value)
|
36
|
+
break
|
37
|
+
|
38
|
+
return result
|
39
|
+
|
40
|
+
|
41
|
+
def pipe(value: Any, *functions: Callable) -> Any:
|
42
|
+
for function in functions:
|
43
|
+
value = function(value)
|
44
|
+
|
45
|
+
return value
|
46
|
+
|
47
|
+
|
48
|
+
def valid_extra_files_path(file_path: str) -> pathlib.Path:
|
49
|
+
p = pathlib.Path(file_path).resolve()
|
50
|
+
|
51
|
+
if not CWD in p.parents:
|
52
|
+
raise ArgumentTypeError(f"Can only reference files under current working directory: {CWD}")
|
53
|
+
if not p.exists():
|
54
|
+
raise ArgumentTypeError(f"File not found: {file_path}")
|
55
|
+
return p
|
56
|
+
|
57
|
+
|
58
|
+
def transfer_encode(file_name: str, stream: IO[bytes]) -> dict[str, str]:
|
59
|
+
return {
|
60
|
+
"filename": file_name,
|
61
|
+
"data": pipe(
|
62
|
+
stream.read(),
|
63
|
+
gzip.compress,
|
64
|
+
base64.b64encode,
|
65
|
+
bytes.decode,
|
66
|
+
),
|
67
|
+
}
|
68
|
+
|
69
|
+
|
70
|
+
def transfer_encoded_file(file_path: str) -> dict[str, str]:
|
71
|
+
try:
|
72
|
+
with open(file_path, "rb") as f:
|
73
|
+
return transfer_encode(file_path, f)
|
74
|
+
except FileNotFoundError:
|
75
|
+
raise ArgumentTypeError(f"File not found: {file_path}")
|
76
|
+
|
77
|
+
|
78
|
+
def expanded(paths: list[pathlib.Path]) -> Generator[pathlib.Path, None, None]:
|
79
|
+
for path in paths:
|
80
|
+
if path.is_dir():
|
81
|
+
for root, _, file_names in os.walk(path):
|
82
|
+
for file_name in file_names:
|
83
|
+
yield pathlib.Path(root) / file_name
|
84
|
+
else:
|
85
|
+
yield path
|
86
|
+
|
87
|
+
|
88
|
+
def transfer_encoded_extra_files(paths: list[pathlib.Path]) -> dict[str, str]:
|
89
|
+
buffer = io.BytesIO()
|
90
|
+
|
91
|
+
with ZipFile(buffer, "w") as zf:
|
92
|
+
for path in set(expanded(paths)):
|
93
|
+
zf.write(path.relative_to(CWD))
|
94
|
+
|
95
|
+
buffer.seek(0)
|
96
|
+
return transfer_encode("extra-files.zip", buffer)
|
97
|
+
|
98
|
+
|
99
|
+
class MergeToTransferEncodedZip(argparse.Action):
|
100
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
101
|
+
paths = cast(list[pathlib.Path], values)
|
102
|
+
value = transfer_encoded_extra_files(paths)
|
103
|
+
setattr(namespace, self.dest, value)
|
104
|
+
|
105
|
+
|
106
|
+
parser = configargparse.ArgumentParser(
|
107
|
+
default_config_files=[
|
108
|
+
"~/.locust.conf",
|
109
|
+
"locust.conf",
|
110
|
+
"pyproject.toml",
|
111
|
+
"~/.cloud.conf",
|
112
|
+
"cloud.conf",
|
113
|
+
],
|
114
|
+
auto_env_var_prefix="LOCUSTCLOUD_",
|
115
|
+
formatter_class=configargparse.RawTextHelpFormatter,
|
116
|
+
config_file_parser_class=configargparse.CompositeConfigParser(
|
117
|
+
[
|
118
|
+
LocustTomlConfigParser(["tool.locust"]),
|
119
|
+
configargparse.DefaultConfigFileParser,
|
120
|
+
]
|
121
|
+
),
|
122
|
+
description="""Launches a distributed Locust runs on locust.cloud infrastructure.
|
123
|
+
|
124
|
+
Example: locust-cloud -f my_locustfile.py --users 1000 ...""",
|
125
|
+
epilog="""Any parameters not listed here are forwarded to locust master unmodified, so go ahead and use things like --users, --host, --run-time, ...
|
126
|
+
Locust config can also be set using config file (~/.locust.conf, locust.conf, pyproject.toml, ~/.cloud.conf or cloud.conf).
|
127
|
+
Parameters specified on command line override env vars, which in turn override config files.""",
|
128
|
+
add_config_file_help=False,
|
129
|
+
add_env_var_help=False,
|
130
|
+
add_help=False,
|
131
|
+
)
|
132
|
+
parser.add_argument(
|
133
|
+
"-h",
|
134
|
+
"--help",
|
135
|
+
action="help",
|
136
|
+
help=configargparse.SUPPRESS,
|
137
|
+
)
|
138
|
+
parser.add_argument(
|
139
|
+
"-V",
|
140
|
+
"--version",
|
141
|
+
action="store_true",
|
142
|
+
help=configargparse.SUPPRESS,
|
143
|
+
)
|
144
|
+
parser.add_argument(
|
145
|
+
"-f",
|
146
|
+
"--locustfile",
|
147
|
+
metavar="<filename>",
|
148
|
+
default="locustfile.py",
|
149
|
+
help="The Python file that contains your test. Defaults to 'locustfile.py'.",
|
150
|
+
env_var="LOCUST_LOCUSTFILE",
|
151
|
+
type=transfer_encoded_file,
|
152
|
+
)
|
153
|
+
parser.add_argument(
|
154
|
+
"-u",
|
155
|
+
"--users",
|
156
|
+
type=int,
|
157
|
+
default=1,
|
158
|
+
help="Number of users to launch. This is the same as the regular Locust argument, but also affects how many workers to launch.",
|
159
|
+
env_var="LOCUST_USERS",
|
160
|
+
)
|
161
|
+
advanced = parser.add_argument_group("advanced")
|
162
|
+
advanced.add_argument(
|
163
|
+
"--loglevel",
|
164
|
+
"-L",
|
165
|
+
type=str.upper,
|
166
|
+
help="Set --loglevel DEBUG for extra info.",
|
167
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
168
|
+
default="INFO",
|
169
|
+
)
|
170
|
+
advanced.add_argument(
|
171
|
+
"--requirements",
|
172
|
+
type=transfer_encoded_file,
|
173
|
+
help="Optional requirements.txt file that contains your external libraries.",
|
174
|
+
)
|
175
|
+
advanced.add_argument(
|
176
|
+
"--login",
|
177
|
+
action="store_true",
|
178
|
+
default=False,
|
179
|
+
help="Launch an interactive session to authenticate your user.\nOnce completed your credentials will be stored and automatically refreshed for quite a long time.\nOnce those expires you will be prompted to perform another login.",
|
180
|
+
)
|
181
|
+
advanced.add_argument(
|
182
|
+
"--non-interactive",
|
183
|
+
action="store_true",
|
184
|
+
default=False,
|
185
|
+
help="This can be set when, for example, running in a CI/CD environment to ensure no interactive steps while executing.\nRequires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set.",
|
186
|
+
)
|
187
|
+
parser.add_argument(
|
188
|
+
"--workers",
|
189
|
+
type=int,
|
190
|
+
help="Number of workers to use for the deployment. Defaults to number of users divided by 500, but the default may be customized for your account.",
|
191
|
+
default=None,
|
192
|
+
)
|
193
|
+
parser.add_argument(
|
194
|
+
"--delete",
|
195
|
+
action="store_true",
|
196
|
+
help="Delete a running cluster. Useful if locust-cloud was killed/disconnected or if there was an error.",
|
197
|
+
)
|
198
|
+
parser.add_argument(
|
199
|
+
"--image-tag",
|
200
|
+
type=str,
|
201
|
+
default=None,
|
202
|
+
help=configargparse.SUPPRESS, # overrides the locust-cloud docker image tag. for internal use
|
203
|
+
)
|
204
|
+
parser.add_argument(
|
205
|
+
"--mock-server",
|
206
|
+
action="store_true",
|
207
|
+
default=False,
|
208
|
+
help="Start a demo mock service and set --host parameter to point Locust towards it",
|
209
|
+
)
|
210
|
+
parser.add_argument(
|
211
|
+
"--profile",
|
212
|
+
type=str,
|
213
|
+
help="Set a profile to group the testruns together",
|
214
|
+
)
|
215
|
+
parser.add_argument(
|
216
|
+
"--extra-files",
|
217
|
+
action=MergeToTransferEncodedZip,
|
218
|
+
nargs="*",
|
219
|
+
type=valid_extra_files_path,
|
220
|
+
help="A list of extra files or directories to upload. Space-separated, e.g. --extra-files testdata.csv *.py my-directory/",
|
221
|
+
)
|
222
|
+
|
223
|
+
|
224
|
+
def parse_known_args(args: Any | None = None) -> tuple[Namespace, list[str]]:
|
225
|
+
return parser.parse_known_args(args)
|
locust_cloud/cloud.py
CHANGED
@@ -1,570 +1,31 @@
|
|
1
|
-
import base64
|
2
|
-
import gzip
|
3
|
-
import importlib.metadata
|
4
|
-
import json
|
5
1
|
import logging
|
6
2
|
import os
|
7
|
-
import pathlib
|
8
3
|
import sys
|
9
|
-
import threading
|
10
|
-
import time
|
11
|
-
import tomllib
|
12
|
-
import urllib.parse
|
13
|
-
import webbrowser
|
14
|
-
from argparse import Namespace
|
15
|
-
from collections import OrderedDict
|
16
|
-
from dataclasses import dataclass
|
17
|
-
from typing import IO, Any
|
18
4
|
|
19
|
-
import configargparse
|
20
|
-
import jwt
|
21
|
-
import platformdirs
|
22
5
|
import requests
|
23
|
-
import
|
24
|
-
import
|
6
|
+
from locust_cloud.apisession import ApiSession
|
7
|
+
from locust_cloud.args import parse_known_args
|
8
|
+
from locust_cloud.common import __version__
|
9
|
+
from locust_cloud.web_login import web_login
|
10
|
+
from locust_cloud.websocket import SessionMismatchError, Websocket, WebsocketTimeout
|
25
11
|
|
26
|
-
__version__ = importlib.metadata.version("locust-cloud")
|
27
|
-
|
28
|
-
|
29
|
-
class LocustTomlConfigParser(configargparse.TomlConfigParser):
|
30
|
-
def parse(self, stream: IO[str]) -> OrderedDict[str, Any]:
|
31
|
-
try:
|
32
|
-
config = tomllib.loads(stream.read())
|
33
|
-
except Exception as e:
|
34
|
-
raise configargparse.ConfigFileParserException(f"Couldn't parse TOML file: {e}")
|
35
|
-
|
36
|
-
result: OrderedDict[str, Any] = OrderedDict()
|
37
|
-
|
38
|
-
for section in self.sections:
|
39
|
-
data = configargparse.get_toml_section(config, section)
|
40
|
-
if data:
|
41
|
-
for key, value in data.items():
|
42
|
-
if isinstance(value, list):
|
43
|
-
result[key] = value
|
44
|
-
elif value is not None:
|
45
|
-
result[key] = str(value)
|
46
|
-
break
|
47
|
-
|
48
|
-
return result
|
49
|
-
|
50
|
-
|
51
|
-
parser = configargparse.ArgumentParser(
|
52
|
-
default_config_files=[
|
53
|
-
"~/.locust.conf",
|
54
|
-
"locust.conf",
|
55
|
-
"pyproject.toml",
|
56
|
-
"~/.cloud.conf",
|
57
|
-
"cloud.conf",
|
58
|
-
],
|
59
|
-
auto_env_var_prefix="LOCUSTCLOUD_",
|
60
|
-
formatter_class=configargparse.RawTextHelpFormatter,
|
61
|
-
config_file_parser_class=configargparse.CompositeConfigParser(
|
62
|
-
[
|
63
|
-
LocustTomlConfigParser(["tool.locust"]),
|
64
|
-
configargparse.DefaultConfigFileParser,
|
65
|
-
]
|
66
|
-
),
|
67
|
-
description="""Launches a distributed Locust runs on locust.cloud infrastructure.
|
68
|
-
|
69
|
-
Example: locust-cloud -f my_locustfile.py --users 1000 ...""",
|
70
|
-
epilog="""Any parameters not listed here are forwarded to locust master unmodified, so go ahead and use things like --users, --host, --run-time, ...
|
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
|
-
add_help=False,
|
76
|
-
)
|
77
|
-
parser.add_argument(
|
78
|
-
"-h",
|
79
|
-
"--help",
|
80
|
-
action="help",
|
81
|
-
help=configargparse.SUPPRESS,
|
82
|
-
)
|
83
|
-
parser.add_argument(
|
84
|
-
"-V",
|
85
|
-
"--version",
|
86
|
-
action="store_true",
|
87
|
-
help=configargparse.SUPPRESS,
|
88
|
-
)
|
89
|
-
parser.add_argument(
|
90
|
-
"-f",
|
91
|
-
"--locustfile",
|
92
|
-
metavar="<filename>",
|
93
|
-
default="locustfile.py",
|
94
|
-
help="The Python file that contains your test. Defaults to 'locustfile.py'.",
|
95
|
-
env_var="LOCUST_LOCUSTFILE",
|
96
|
-
)
|
97
|
-
parser.add_argument(
|
98
|
-
"-u",
|
99
|
-
"--users",
|
100
|
-
type=int,
|
101
|
-
default=1,
|
102
|
-
help="Number of users to launch. This is the same as the regular Locust argument, but also affects how many workers to launch.",
|
103
|
-
env_var="LOCUST_USERS",
|
104
|
-
)
|
105
|
-
advanced = parser.add_argument_group("advanced")
|
106
|
-
advanced.add_argument(
|
107
|
-
"--loglevel",
|
108
|
-
"-L",
|
109
|
-
type=str,
|
110
|
-
help="Set --loglevel DEBUG for extra info.",
|
111
|
-
default="INFO",
|
112
|
-
)
|
113
|
-
advanced.add_argument(
|
114
|
-
"--requirements",
|
115
|
-
type=str,
|
116
|
-
help="Optional requirements.txt file that contains your external libraries.",
|
117
|
-
)
|
118
|
-
advanced.add_argument(
|
119
|
-
"--login",
|
120
|
-
action="store_true",
|
121
|
-
default=False,
|
122
|
-
help=configargparse.SUPPRESS,
|
123
|
-
)
|
124
|
-
advanced.add_argument(
|
125
|
-
"--non-interactive",
|
126
|
-
action="store_true",
|
127
|
-
default=False,
|
128
|
-
help="This can be set when, for example, running in a CI/CD environment to ensure no interactive steps while executing.\nRequires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set.",
|
129
|
-
)
|
130
|
-
parser.add_argument(
|
131
|
-
"--workers",
|
132
|
-
type=int,
|
133
|
-
help="Number of workers to use for the deployment. Defaults to number of users divided by 500, but the default may be customized for your account.",
|
134
|
-
default=None,
|
135
|
-
)
|
136
|
-
parser.add_argument(
|
137
|
-
"--delete",
|
138
|
-
action="store_true",
|
139
|
-
help="Delete a running cluster. Useful if locust-cloud was killed/disconnected or if there was an error.",
|
140
|
-
)
|
141
|
-
parser.add_argument(
|
142
|
-
"--image-tag",
|
143
|
-
type=str,
|
144
|
-
default=None,
|
145
|
-
help=configargparse.SUPPRESS, # overrides the locust-cloud docker image tag. for internal use
|
146
|
-
)
|
147
|
-
parser.add_argument(
|
148
|
-
"--mock-server",
|
149
|
-
action="store_true",
|
150
|
-
default=False,
|
151
|
-
help="Start a demo mock service and set --host parameter to point Locust towards it",
|
152
|
-
)
|
153
|
-
parser.add_argument(
|
154
|
-
"--profile",
|
155
|
-
type=str,
|
156
|
-
help="Set a profile to group the testruns together",
|
157
|
-
)
|
158
|
-
|
159
|
-
options, locust_options = parser.parse_known_args()
|
160
|
-
|
161
|
-
options: Namespace
|
162
|
-
locust_options: list
|
163
|
-
|
164
|
-
logging.basicConfig(
|
165
|
-
format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
|
166
|
-
level=options.loglevel.upper(),
|
167
|
-
)
|
168
12
|
logger = logging.getLogger(__name__)
|
169
|
-
# Restore log level for other libs. Yes, this can be done more nicely
|
170
|
-
logging.getLogger("requests").setLevel(logging.INFO)
|
171
|
-
logging.getLogger("urllib3").setLevel(logging.INFO)
|
172
|
-
|
173
|
-
cloud_conf_file = pathlib.Path(platformdirs.user_config_dir(appname="locust-cloud")) / "config"
|
174
|
-
valid_regions = ["us-east-1", "eu-north-1"]
|
175
|
-
|
176
|
-
|
177
|
-
def get_api_url(region):
|
178
|
-
return os.environ.get("LOCUSTCLOUD_DEPLOYER_URL", f"https://api.{region}.locust.cloud/1")
|
179
|
-
|
180
|
-
|
181
|
-
@dataclass
|
182
|
-
class CloudConfig:
|
183
|
-
id_token: str | None = None
|
184
|
-
refresh_token: str | None = None
|
185
|
-
refresh_token_expires: int = 0
|
186
|
-
region: str | None = None
|
187
|
-
|
188
|
-
|
189
|
-
def read_cloud_config() -> CloudConfig:
|
190
|
-
if cloud_conf_file.exists():
|
191
|
-
with open(cloud_conf_file) as f:
|
192
|
-
return CloudConfig(**json.load(f))
|
193
|
-
|
194
|
-
return CloudConfig()
|
195
|
-
|
196
|
-
|
197
|
-
def write_cloud_config(config: CloudConfig) -> None:
|
198
|
-
cloud_conf_file.parent.mkdir(parents=True, exist_ok=True)
|
199
|
-
|
200
|
-
with open(cloud_conf_file, "w") as f:
|
201
|
-
json.dump(config.__dict__, f)
|
202
|
-
|
203
|
-
|
204
|
-
def web_login() -> None:
|
205
|
-
print("Enter the number for the region to authenticate against")
|
206
|
-
print()
|
207
|
-
for i, valid_region in enumerate(valid_regions, start=1):
|
208
|
-
print(f" {i}. {valid_region}")
|
209
|
-
print()
|
210
|
-
choice = input("> ")
|
211
|
-
try:
|
212
|
-
region_index = int(choice) - 1
|
213
|
-
assert 0 <= region_index < len(valid_regions)
|
214
|
-
except (ValueError, AssertionError):
|
215
|
-
print(f"Not a valid choice: '{choice}'")
|
216
|
-
sys.exit(1)
|
217
13
|
|
218
|
-
region = valid_regions[region_index]
|
219
14
|
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
authentication_url = response_data["authentication_url"]
|
225
|
-
result_url = response_data["result_url"]
|
226
|
-
except Exception as e:
|
227
|
-
print("Something went wrong trying to authorize the locust-cloud CLI:", str(e))
|
228
|
-
sys.exit(1)
|
229
|
-
|
230
|
-
message = f"""
|
231
|
-
Attempting to automatically open the SSO authorization page in your default browser.
|
232
|
-
If the browser does not open or you wish to use a different device to authorize this request, open the following URL:
|
233
|
-
|
234
|
-
{authentication_url}
|
235
|
-
""".strip()
|
236
|
-
print()
|
237
|
-
print(message)
|
238
|
-
|
239
|
-
webbrowser.open_new_tab(authentication_url)
|
240
|
-
|
241
|
-
while True: # Should there be some kind of timeout?
|
242
|
-
response = requests.get(result_url)
|
243
|
-
|
244
|
-
if not response.ok:
|
245
|
-
print("Oh no!")
|
246
|
-
print(response.text)
|
247
|
-
sys.exit(1)
|
248
|
-
|
249
|
-
data = response.json()
|
250
|
-
|
251
|
-
if data["state"] == "pending":
|
252
|
-
time.sleep(1)
|
253
|
-
continue
|
254
|
-
elif data["state"] == "failed":
|
255
|
-
print(f"\nFailed to authorize CLI: {data['reason']}")
|
256
|
-
sys.exit(1)
|
257
|
-
elif data["state"] == "authorized":
|
258
|
-
print("\nAuthorization succeded")
|
259
|
-
break
|
260
|
-
else:
|
261
|
-
print("\nGot unexpected response when authorizing CLI")
|
262
|
-
sys.exit(1)
|
263
|
-
|
264
|
-
config = CloudConfig(
|
265
|
-
id_token=data["id_token"],
|
266
|
-
refresh_token=data["refresh_token"],
|
267
|
-
refresh_token_expires=data["refresh_token_expires"],
|
268
|
-
region=region,
|
15
|
+
def configure_logging(loglevel: str) -> None:
|
16
|
+
logging.basicConfig(
|
17
|
+
format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
|
18
|
+
level=loglevel,
|
269
19
|
)
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
class ApiSession(requests.Session):
|
274
|
-
def __init__(self) -> None:
|
275
|
-
super().__init__()
|
276
|
-
|
277
|
-
if options.non_interactive:
|
278
|
-
username = os.getenv("LOCUSTCLOUD_USERNAME")
|
279
|
-
password = os.getenv("LOCUSTCLOUD_PASSWORD")
|
280
|
-
region = os.getenv("LOCUSTCLOUD_REGION")
|
281
|
-
|
282
|
-
if not all([username, password, region]):
|
283
|
-
print(
|
284
|
-
"Running with --non-interaction requires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set."
|
285
|
-
)
|
286
|
-
sys.exit(1)
|
287
|
-
|
288
|
-
if region not in valid_regions:
|
289
|
-
print("Environment variable LOCUSTCLOUD_REGION needs to be set to one of", ", ".join(valid_regions))
|
290
|
-
sys.exit(1)
|
291
|
-
|
292
|
-
self.__configure_for_region(region)
|
293
|
-
response = requests.post(
|
294
|
-
self.__login_url,
|
295
|
-
json={"username": username, "password": password},
|
296
|
-
headers={"X-Client-Version": __version__},
|
297
|
-
)
|
298
|
-
if not response.ok:
|
299
|
-
print(f"Authentication failed: {response.text}")
|
300
|
-
sys.exit(1)
|
301
|
-
|
302
|
-
self.__refresh_token = response.json()["refresh_token"]
|
303
|
-
id_token = response.json()["cognito_client_id_token"]
|
304
|
-
|
305
|
-
else:
|
306
|
-
config = read_cloud_config()
|
307
|
-
|
308
|
-
if config.refresh_token_expires < time.time() + 24 * 60 * 60:
|
309
|
-
message = "You need to authenticate before proceeding. Please run:\n locust-cloud --login"
|
310
|
-
print(message)
|
311
|
-
sys.exit(1)
|
312
|
-
|
313
|
-
assert config.region
|
314
|
-
self.__configure_for_region(config.region)
|
315
|
-
self.__refresh_token = config.refresh_token
|
316
|
-
id_token = config.id_token
|
317
|
-
|
318
|
-
assert id_token
|
319
|
-
|
320
|
-
decoded = jwt.decode(id_token, options={"verify_signature": False})
|
321
|
-
self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
|
322
|
-
self.headers["Authorization"] = f"Bearer {id_token}"
|
323
|
-
|
324
|
-
self.__sub = decoded["sub"]
|
325
|
-
self.headers["X-Client-Version"] = __version__
|
326
|
-
|
327
|
-
def __configure_for_region(self, region: str) -> None:
|
328
|
-
self.__region = region
|
329
|
-
self.api_url = get_api_url(region)
|
330
|
-
self.__login_url = f"{self.api_url}/auth/login"
|
331
|
-
|
332
|
-
logger.debug(f"Lambda url: {self.api_url}")
|
333
|
-
|
334
|
-
def __ensure_valid_authorization_header(self) -> None:
|
335
|
-
if self.__expiry_time > time.time():
|
336
|
-
return
|
337
|
-
|
338
|
-
logger.info(f"Authenticating ({self.__region}, v{__version__})")
|
339
|
-
|
340
|
-
response = requests.post(
|
341
|
-
self.__login_url,
|
342
|
-
json={"user_sub_id": self.__sub, "refresh_token": self.__refresh_token},
|
343
|
-
headers={"X-Client-Version": __version__},
|
344
|
-
)
|
345
|
-
|
346
|
-
if not response.ok:
|
347
|
-
logger.error(f"Authentication failed: {response.text}")
|
348
|
-
sys.exit(1)
|
349
|
-
|
350
|
-
# TODO: Technically the /login endpoint can return a challenge for you
|
351
|
-
# to change your password. Don't know how we should handle that
|
352
|
-
# in the cli.
|
353
|
-
|
354
|
-
id_token = response.json()["cognito_client_id_token"]
|
355
|
-
decoded = jwt.decode(id_token, options={"verify_signature": False})
|
356
|
-
self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
|
357
|
-
self.headers["Authorization"] = f"Bearer {id_token}"
|
358
|
-
|
359
|
-
if not options.non_interactive:
|
360
|
-
config = read_cloud_config()
|
361
|
-
config.id_token = id_token
|
362
|
-
write_cloud_config(config)
|
363
|
-
|
364
|
-
def request(self, method, url, *args, **kwargs) -> requests.Response:
|
365
|
-
self.__ensure_valid_authorization_header()
|
366
|
-
return super().request(method, f"{self.api_url}{url}", *args, **kwargs)
|
367
|
-
|
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()
|
20
|
+
# Restore log level for other libs. Yes, this can be done more nicely
|
21
|
+
logging.getLogger("requests").setLevel(logging.INFO)
|
22
|
+
logging.getLogger("urllib3").setLevel(logging.INFO)
|
565
23
|
|
566
24
|
|
567
25
|
def main() -> None:
|
26
|
+
options, locust_options = parse_known_args()
|
27
|
+
configure_logging(options.loglevel)
|
28
|
+
|
568
29
|
if options.version:
|
569
30
|
print(f"locust-cloud version {__version__}")
|
570
31
|
sys.exit(0)
|
@@ -579,7 +40,7 @@ def main() -> None:
|
|
579
40
|
pass
|
580
41
|
sys.exit()
|
581
42
|
|
582
|
-
session = ApiSession()
|
43
|
+
session = ApiSession(options.non_interactive)
|
583
44
|
websocket = Websocket()
|
584
45
|
|
585
46
|
if options.delete:
|
@@ -587,23 +48,6 @@ def main() -> None:
|
|
587
48
|
sys.exit()
|
588
49
|
|
589
50
|
try:
|
590
|
-
try:
|
591
|
-
with open(options.locustfile, "rb") as f:
|
592
|
-
locustfile_data = base64.b64encode(gzip.compress(f.read())).decode()
|
593
|
-
except FileNotFoundError:
|
594
|
-
logger.error(f"File not found: {options.locustfile}")
|
595
|
-
sys.exit(1)
|
596
|
-
|
597
|
-
requirements_data = None
|
598
|
-
|
599
|
-
if options.requirements:
|
600
|
-
try:
|
601
|
-
with open(options.requirements, "rb") as f:
|
602
|
-
requirements_data = base64.b64encode(gzip.compress(f.read())).decode()
|
603
|
-
except FileNotFoundError:
|
604
|
-
logger.error(f"File not found: {options.requirements}")
|
605
|
-
sys.exit(1)
|
606
|
-
|
607
51
|
logger.info("Deploying load generators")
|
608
52
|
locust_env_variables = [
|
609
53
|
{"name": env_variable, "value": os.environ[env_variable]}
|
@@ -625,7 +69,7 @@ def main() -> None:
|
|
625
69
|
{"name": "LOCUSTCLOUD_PROFILE", "value": options.profile},
|
626
70
|
*locust_env_variables,
|
627
71
|
],
|
628
|
-
"locustfile":
|
72
|
+
"locustfile": options.locustfile,
|
629
73
|
"user_count": options.users,
|
630
74
|
"mock_server": options.mock_server,
|
631
75
|
}
|
@@ -637,7 +81,10 @@ def main() -> None:
|
|
637
81
|
payload["worker_count"] = options.workers
|
638
82
|
|
639
83
|
if options.requirements:
|
640
|
-
payload["requirements"] =
|
84
|
+
payload["requirements"] = options.requirements
|
85
|
+
|
86
|
+
if options.extra_files:
|
87
|
+
payload["extra_files"] = options.extra_files
|
641
88
|
|
642
89
|
try:
|
643
90
|
response = session.post("/deploy", json=payload)
|
locust_cloud/common.py
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
import importlib.metadata
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
import pathlib
|
5
|
+
from dataclasses import dataclass
|
6
|
+
|
7
|
+
import platformdirs
|
8
|
+
|
9
|
+
__version__ = importlib.metadata.version("locust-cloud")
|
10
|
+
|
11
|
+
|
12
|
+
VALID_REGIONS = ["us-east-1", "eu-north-1"]
|
13
|
+
CLOUD_CONF_FILE = pathlib.Path(platformdirs.user_config_dir(appname="locust-cloud")) / "config"
|
14
|
+
|
15
|
+
|
16
|
+
@dataclass
|
17
|
+
class CloudConfig:
|
18
|
+
id_token: str | None = None
|
19
|
+
refresh_token: str | None = None
|
20
|
+
refresh_token_expires: int = 0
|
21
|
+
region: str | None = None
|
22
|
+
|
23
|
+
|
24
|
+
def get_api_url(region):
|
25
|
+
return os.environ.get("LOCUSTCLOUD_DEPLOYER_URL", f"https://api.{region}.locust.cloud/1")
|
26
|
+
|
27
|
+
|
28
|
+
def read_cloud_config() -> CloudConfig:
|
29
|
+
if CLOUD_CONF_FILE.exists():
|
30
|
+
with open(CLOUD_CONF_FILE) as f:
|
31
|
+
return CloudConfig(**json.load(f))
|
32
|
+
|
33
|
+
return CloudConfig()
|
34
|
+
|
35
|
+
|
36
|
+
def write_cloud_config(config: CloudConfig) -> None:
|
37
|
+
CLOUD_CONF_FILE.parent.mkdir(parents=True, exist_ok=True)
|
38
|
+
|
39
|
+
with open(CLOUD_CONF_FILE, "w") as f:
|
40
|
+
json.dump(config.__dict__, f)
|
@@ -0,0 +1,77 @@
|
|
1
|
+
import sys
|
2
|
+
import time
|
3
|
+
import webbrowser
|
4
|
+
|
5
|
+
import requests
|
6
|
+
from locust_cloud.common import VALID_REGIONS, CloudConfig, get_api_url, write_cloud_config
|
7
|
+
|
8
|
+
POLLING_FREQUENCY = 1
|
9
|
+
|
10
|
+
|
11
|
+
def web_login() -> None:
|
12
|
+
print("Enter the number for the region to authenticate against")
|
13
|
+
print()
|
14
|
+
for i, valid_region in enumerate(VALID_REGIONS, start=1):
|
15
|
+
print(f" {i}. {valid_region}")
|
16
|
+
print()
|
17
|
+
choice = input("> ")
|
18
|
+
try:
|
19
|
+
region_index = int(choice) - 1
|
20
|
+
assert 0 <= region_index < len(VALID_REGIONS)
|
21
|
+
except (ValueError, AssertionError):
|
22
|
+
print(f"Not a valid choice: '{choice}'")
|
23
|
+
sys.exit(1)
|
24
|
+
|
25
|
+
region = VALID_REGIONS[region_index]
|
26
|
+
|
27
|
+
try:
|
28
|
+
response = requests.post(f"{get_api_url(region)}/cli-auth")
|
29
|
+
response.raise_for_status()
|
30
|
+
response_data = response.json()
|
31
|
+
authentication_url = response_data["authentication_url"]
|
32
|
+
result_url = response_data["result_url"]
|
33
|
+
except Exception as e:
|
34
|
+
print("Something went wrong trying to authorize the locust-cloud CLI:", str(e))
|
35
|
+
sys.exit(1)
|
36
|
+
|
37
|
+
message = f"""
|
38
|
+
Attempting to automatically open the SSO authorization page in your default browser.
|
39
|
+
If the browser does not open or you wish to use a different device to authorize this request, open the following URL:
|
40
|
+
|
41
|
+
{authentication_url}
|
42
|
+
""".strip()
|
43
|
+
print()
|
44
|
+
print(message)
|
45
|
+
|
46
|
+
webbrowser.open_new_tab(authentication_url)
|
47
|
+
|
48
|
+
while True: # Should there be some kind of timeout?
|
49
|
+
response = requests.get(result_url)
|
50
|
+
|
51
|
+
if not response.ok:
|
52
|
+
print("Oh no!")
|
53
|
+
print(response.text)
|
54
|
+
sys.exit(1)
|
55
|
+
|
56
|
+
data = response.json()
|
57
|
+
|
58
|
+
if data["state"] == "pending":
|
59
|
+
time.sleep(POLLING_FREQUENCY)
|
60
|
+
continue
|
61
|
+
elif data["state"] == "failed":
|
62
|
+
print(f"\nFailed to authorize CLI: {data['reason']}")
|
63
|
+
sys.exit(1)
|
64
|
+
elif data["state"] == "authorized":
|
65
|
+
print("\nAuthorization succeded. Now you can re-run locust-cloud without the --login flag.")
|
66
|
+
break
|
67
|
+
else:
|
68
|
+
print("\nGot unexpected response when authorizing CLI")
|
69
|
+
sys.exit(1)
|
70
|
+
|
71
|
+
config = CloudConfig(
|
72
|
+
id_token=data["id_token"],
|
73
|
+
refresh_token=data["refresh_token"],
|
74
|
+
refresh_token_expires=data["refresh_token_expires"],
|
75
|
+
region=region,
|
76
|
+
)
|
77
|
+
write_cloud_config(config)
|
@@ -0,0 +1,207 @@
|
|
1
|
+
import logging
|
2
|
+
import sys
|
3
|
+
import threading
|
4
|
+
import urllib.parse
|
5
|
+
|
6
|
+
import socketio
|
7
|
+
import socketio.exceptions
|
8
|
+
|
9
|
+
logger = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
class SessionMismatchError(Exception):
|
13
|
+
pass
|
14
|
+
|
15
|
+
|
16
|
+
class WebsocketTimeout(Exception):
|
17
|
+
pass
|
18
|
+
|
19
|
+
|
20
|
+
class Websocket:
|
21
|
+
def __init__(self) -> None:
|
22
|
+
"""
|
23
|
+
This class was created to encapsulate all the logic involved in the websocket implementation.
|
24
|
+
The behaviour of the socketio client once a connection has been established
|
25
|
+
is to try to reconnect forever if the connection is lost.
|
26
|
+
The way this can be canceled is by setting the _reconnect_abort (threading.Event) on the client
|
27
|
+
in which case it will simply proceed with shutting down without giving any indication of an error.
|
28
|
+
This class handles timeouts for connection attempts as well as some logic around when the
|
29
|
+
socket can be shut down. See descriptions on the methods for further details.
|
30
|
+
"""
|
31
|
+
self.__shutdown_allowed = threading.Event()
|
32
|
+
self.__timeout_on_disconnect = True
|
33
|
+
self.initial_connect_timeout = 120
|
34
|
+
self.reconnect_timeout = 10
|
35
|
+
self.wait_timeout = 0
|
36
|
+
self.exception: None | Exception = None
|
37
|
+
|
38
|
+
self.sio = socketio.Client(handle_sigint=False)
|
39
|
+
self.sio._reconnect_abort = threading.Event()
|
40
|
+
# The _reconnect_abort value on the socketio client will be populated with a newly created threading.Event if it's not already set.
|
41
|
+
# There is no way to set this by passing it in the constructor.
|
42
|
+
# This event is the only way to interupt the retry logic when the connection is attempted.
|
43
|
+
|
44
|
+
self.sio.on("connect", self.__on_connect)
|
45
|
+
self.sio.on("disconnect", self.__on_disconnect)
|
46
|
+
self.sio.on("connect_error", self.__on_connect_error)
|
47
|
+
self.sio.on("events", self.__on_events)
|
48
|
+
|
49
|
+
self.__processed_events: set[int] = set()
|
50
|
+
|
51
|
+
def __set_connection_timeout(self, timeout) -> None:
|
52
|
+
"""
|
53
|
+
Start a threading.Timer that will set the threading.Event on the socketio client
|
54
|
+
that aborts any further attempts to reconnect, sets an exception on the websocket
|
55
|
+
that will be raised from the wait method and the threading.Event __shutdown_allowed
|
56
|
+
on the websocket that tells the wait method that it should stop blocking.
|
57
|
+
"""
|
58
|
+
|
59
|
+
def _timeout():
|
60
|
+
logger.debug(f"Websocket connection timed out after {timeout} seconds")
|
61
|
+
self.sio._reconnect_abort.set()
|
62
|
+
self.exception = WebsocketTimeout("Timed out connecting to locust master")
|
63
|
+
self.__shutdown_allowed.set()
|
64
|
+
|
65
|
+
self.__connect_timeout_timer = threading.Timer(timeout, _timeout)
|
66
|
+
self.__connect_timeout_timer.daemon = True
|
67
|
+
logger.debug(f"Setting websocket connection timeout to {timeout} seconds")
|
68
|
+
self.__connect_timeout_timer.start()
|
69
|
+
|
70
|
+
def connect(self, url, *, auth) -> None:
|
71
|
+
"""
|
72
|
+
Send along retry=True when initiating the socketio client connection
|
73
|
+
to make it use it's builtin logic for retrying failed connections that
|
74
|
+
is usually used for reconnections. This will retry forever.
|
75
|
+
When connecting start a timer to trigger disabling the retry logic and
|
76
|
+
raise a WebsocketTimeout exception.
|
77
|
+
"""
|
78
|
+
ws_connection_info = urllib.parse.urlparse(url)
|
79
|
+
self.__set_connection_timeout(self.initial_connect_timeout)
|
80
|
+
try:
|
81
|
+
self.sio.connect(
|
82
|
+
f"{ws_connection_info.scheme}://{ws_connection_info.netloc}",
|
83
|
+
auth=auth,
|
84
|
+
retry=True,
|
85
|
+
**{"socketio_path": ws_connection_info.path} if ws_connection_info.path else {},
|
86
|
+
)
|
87
|
+
except socketio.exceptions.ConnectionError:
|
88
|
+
if self.exception:
|
89
|
+
raise self.exception
|
90
|
+
|
91
|
+
raise
|
92
|
+
|
93
|
+
def shutdown(self) -> None:
|
94
|
+
"""
|
95
|
+
When shutting down the socketio client a disconnect event will fire.
|
96
|
+
Before doing so disable the behaviour of starting a threading.Timer
|
97
|
+
to handle timeouts on attempts to reconnect since no further such attempts
|
98
|
+
will be made.
|
99
|
+
If such a timer is already running, cancel it since the client is being shutdown.
|
100
|
+
"""
|
101
|
+
self.__timeout_on_disconnect = False
|
102
|
+
if hasattr(self, "__connect_timeout_timer"):
|
103
|
+
self.__connect_timeout_timer.cancel()
|
104
|
+
self.sio.shutdown()
|
105
|
+
|
106
|
+
def wait(self, timeout=False) -> bool:
|
107
|
+
"""
|
108
|
+
Block until the threading.Event __shutdown_allowed is set, with a timeout if indicated.
|
109
|
+
If an exception has been set on the websocket (from a connection timeout timer or the
|
110
|
+
__on_connect_error method), raise it.
|
111
|
+
"""
|
112
|
+
timeout = self.wait_timeout if timeout else None
|
113
|
+
logger.debug(f"Waiting for shutdown for {str(timeout)+'s' if timeout else 'ever'}")
|
114
|
+
res = self.__shutdown_allowed.wait(timeout)
|
115
|
+
if self.exception:
|
116
|
+
raise self.exception
|
117
|
+
return res
|
118
|
+
|
119
|
+
def __on_connect(self) -> None:
|
120
|
+
"""
|
121
|
+
This gets events whenever a connection is successfully established.
|
122
|
+
When this happens, cancel the running threading.Timer that would
|
123
|
+
abort reconnect attempts and raise a WebsocketTimeout exception.
|
124
|
+
The wait_timeout is originally set to zero when creating the websocket
|
125
|
+
but once a connection has been established this is raised to ensure
|
126
|
+
that the server is given the chance to send all the logs and an
|
127
|
+
official shutdown event.
|
128
|
+
"""
|
129
|
+
self.__connect_timeout_timer.cancel()
|
130
|
+
self.wait_timeout = 90
|
131
|
+
logger.debug("Websocket connected")
|
132
|
+
|
133
|
+
def __on_disconnect(self) -> None:
|
134
|
+
"""
|
135
|
+
This gets events whenever a connection is lost.
|
136
|
+
The socketio client will try to reconnect forever so,
|
137
|
+
unless the behaviour has been disabled, a threading.Timer
|
138
|
+
is started that will abort reconnect attempts and raise a
|
139
|
+
WebsocketTimeout exception.
|
140
|
+
"""
|
141
|
+
if self.__timeout_on_disconnect:
|
142
|
+
self.__set_connection_timeout(self.reconnect_timeout)
|
143
|
+
logger.debug("Websocket disconnected")
|
144
|
+
|
145
|
+
def __on_events(self, data):
|
146
|
+
"""
|
147
|
+
This gets events explicitly sent by the websocket server.
|
148
|
+
This will either be messages to print on stdout/stderr or
|
149
|
+
an indication that the CLI can shut down in which case the
|
150
|
+
threading.Event __shutdown_allowed gets set on the websocket
|
151
|
+
that tells the wait method that it should stop blocking.
|
152
|
+
"""
|
153
|
+
shutdown = False
|
154
|
+
shutdown_message = ""
|
155
|
+
|
156
|
+
if data["id"] in self.__processed_events:
|
157
|
+
logger.debug(f"Got duplicate data on websocket, id {data['id']}")
|
158
|
+
return
|
159
|
+
|
160
|
+
self.__processed_events.add(data["id"])
|
161
|
+
|
162
|
+
for event in data["events"]:
|
163
|
+
type = event["type"]
|
164
|
+
|
165
|
+
if type == "shutdown":
|
166
|
+
shutdown = True
|
167
|
+
shutdown_message = event["message"]
|
168
|
+
elif type == "stdout":
|
169
|
+
sys.stdout.write(event["message"])
|
170
|
+
elif type == "stderr":
|
171
|
+
sys.stderr.write(event["message"])
|
172
|
+
else:
|
173
|
+
raise Exception("Unexpected event type")
|
174
|
+
|
175
|
+
if shutdown:
|
176
|
+
logger.debug("Got shutdown from locust master")
|
177
|
+
if shutdown_message:
|
178
|
+
print(shutdown_message)
|
179
|
+
|
180
|
+
self.__shutdown_allowed.set()
|
181
|
+
|
182
|
+
def __on_connect_error(self, data) -> None:
|
183
|
+
"""
|
184
|
+
This gets events whenever there's an error during connection attempts.
|
185
|
+
The specific case that is handled below is triggered when the connection
|
186
|
+
is made with the auth parameter not matching the session ID on the server.
|
187
|
+
If this error occurs it's because the connection is attempted towards an
|
188
|
+
instance of locust not started by this CLI.
|
189
|
+
|
190
|
+
In that case:
|
191
|
+
Cancel the running threading.Timer that would abort reconnect attempts
|
192
|
+
and raise a WebsocketTimeout exception.
|
193
|
+
Set an exception on the websocket that will be raised from the wait method.
|
194
|
+
Cancel further reconnect attempts.
|
195
|
+
Set the threading.Event __shutdown_allowed on the websocket that tells the
|
196
|
+
wait method that it should stop blocking.
|
197
|
+
"""
|
198
|
+
# Do nothing if it's not the specific case we know how to deal with
|
199
|
+
if not (isinstance(data, dict) and data.get("message") == "Session mismatch"):
|
200
|
+
return
|
201
|
+
|
202
|
+
self.__connect_timeout_timer.cancel()
|
203
|
+
self.exception = SessionMismatchError(
|
204
|
+
"The session from this run of locust-cloud did not match the one on the server"
|
205
|
+
)
|
206
|
+
self.sio._reconnect_abort.set()
|
207
|
+
self.__shutdown_allowed.set()
|
@@ -0,0 +1,10 @@
|
|
1
|
+
locust_cloud/apisession.py,sha256=AoU0FGQbyH2qbaTmdyoIMBd_lwZimLtihK0gnpAV6c0,4091
|
2
|
+
locust_cloud/args.py,sha256=1SaMQ16f_3vaNqnAbjFiBL-6ietp1-qfV2LUjBk6b8k,7100
|
3
|
+
locust_cloud/cloud.py,sha256=Piq6J9pS6Tom1aoYwNmmt4Q9LrLFPKFZC6EqgOen3OE,5107
|
4
|
+
locust_cloud/common.py,sha256=cFrDVKpi9OEmH6giOuj9HoIUFSBArixNtNHzZIgDvPE,992
|
5
|
+
locust_cloud/web_login.py,sha256=1j2AQoEM6XVSDtE1q0Ryrs4jFEx07r9IQfZCoFAQXJg,2400
|
6
|
+
locust_cloud/websocket.py,sha256=lnVRsk0goAHIDNz9cT5xkYAjHSB5aqFyjR_m3X48qRM,8771
|
7
|
+
locust_cloud-1.14.3.dist-info/METADATA,sha256=zV3RuzeK3GU2qoW6TR7h38QfaAb2E-r28s9u-hXXYJY,497
|
8
|
+
locust_cloud-1.14.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
9
|
+
locust_cloud-1.14.3.dist-info/entry_points.txt,sha256=PGyAb4e3aTsGS3N3VGShDl6VzJaXy7QwsEgsLOC7V00,57
|
10
|
+
locust_cloud-1.14.3.dist-info/RECORD,,
|
@@ -1,5 +0,0 @@
|
|
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,,
|
File without changes
|
File without changes
|