Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add base url path param #2925

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions locust/argument_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,6 @@ def get_empty_argument_parser(add_help=True, default_config_files=DEFAULT_CONFIG
help="The Python file or module that contains your test, e.g. 'my_test.py'. Accepts multiple comma-separated .py files, a package name/directory or a url to a remote locustfile. Defaults to 'locustfile'.",
env_var="LOCUST_LOCUSTFILE",
)

parser.add_argument(
"--config",
is_config_file_arg=True,
Expand Down Expand Up @@ -365,7 +364,7 @@ def setup_parser_arguments(parser):
parser.add_argument(
"-H",
"--host",
metavar="<base url>",
metavar="<host>",
help="Host to load test, in the following format: https://www.example.com",
env_var="LOCUST_HOST",
)
Expand Down Expand Up @@ -612,6 +611,14 @@ def setup_parser_arguments(parser):
env_var="LOCUST_MASTER_NODE_PORT",
)

web_ui_group.add_argument(
"--base-url",
type=str,
default="",
help="Base URL for the web interface (e.g., '/locust'). Default is empty (root path).",
env_var="BASE_URL",
)

tag_group = parser.add_argument_group(
"Tag options",
"Locust tasks can be tagged using the @tag decorator. These options let specify which tasks to include or exclude during a test.",
Expand Down
10 changes: 7 additions & 3 deletions locust/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def __init__(
self.stats = RequestStats()
"""Reference to RequestStats instance"""
self.host = host
"""Base URL of the target system"""
"""Host of the target system"""
self.reset_stats = reset_stats
"""Determines if stats should be reset once all simulated users have been spawned"""
if stop_timeout is not None:
Expand Down Expand Up @@ -131,7 +131,7 @@ def create_local_runner(self) -> LocalRunner:
"""
return self._create_runner(LocalRunner)

def create_master_runner(self, master_bind_host="*", master_bind_port=5557) -> MasterRunner:
def create_master_runner(self, master_bind_host="*", master_bind_port=5557, base_url="") -> MasterRunner:
"""
Create a :class:`MasterRunner <locust.runners.MasterRunner>` instance for this Environment

Expand All @@ -143,9 +143,10 @@ def create_master_runner(self, master_bind_host="*", master_bind_port=5557) -> M
MasterRunner,
master_bind_host=master_bind_host,
master_bind_port=master_bind_port,
base_url=base_url,
)

def create_worker_runner(self, master_host: str, master_port: int) -> WorkerRunner:
def create_worker_runner(self, master_host: str, master_port: int, base_url: str = "") -> WorkerRunner:
"""
Create a :class:`WorkerRunner <locust.runners.WorkerRunner>` instance for this Environment

Expand All @@ -159,12 +160,14 @@ def create_worker_runner(self, master_host: str, master_port: int) -> WorkerRunn
WorkerRunner,
master_host=master_host,
master_port=master_port,
base_url=base_url,
)

def create_web_ui(
self,
host="",
port=8089,
base_url: str = "/",
web_login: bool = False,
tls_cert: str | None = None,
tls_key: str | None = None,
Expand Down Expand Up @@ -199,6 +202,7 @@ def create_web_ui(
delayed_start=delayed_start,
userclass_picker_is_active=userclass_picker_is_active,
build_path=build_path,
base_url=base_url,
)
return self.web_ui

Expand Down
17 changes: 12 additions & 5 deletions locust/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from .user.inspectuser import print_task_ratio, print_task_ratio_json
from .util.load_locustfile import load_locustfile
from .util.timespan import parse_timespan
from .util.url import normalize_base_url

# import external plugins if installed to allow for registering custom arguments etc
try:
Expand Down Expand Up @@ -169,6 +170,8 @@ def is_valid_percentile(parameter):
# parse all command line options
options = parse_options()

# Normalize the base URL
options.base_url = normalize_base_url(options.base_url)
if options.headful:
options.headless = False

Expand Down Expand Up @@ -445,11 +448,14 @@ def ensure_user_class_name(config):
runner = environment.create_master_runner(
master_bind_host=options.master_bind_host,
master_bind_port=options.master_bind_port,
base_url=options.base_url,
)
elif options.worker:
try:
runner = environment.create_worker_runner(options.master_host, options.master_port)
logger.debug("Connected to locust master: %s:%s", options.master_host, options.master_port)
runner = environment.create_worker_runner(options.master_host, options.master_port, options.base_url)
logger.debug(
"Connected to locust master: %s:%s%s", options.master_host, options.master_port, options.base_url
)
except OSError as e:
logger.error("Failed to connect to the Locust master: %s", e)
sys.exit(-1)
Expand Down Expand Up @@ -491,20 +497,21 @@ def ensure_user_class_name(config):
else:
web_host = options.web_host
if web_host:
logger.info(f"Starting web interface at {protocol}://{web_host}:{options.web_port}")
logger.info(f"Starting web interface at {protocol}://{web_host}:{options.web_port}{options.base_url}")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think url doesn't actually make sense in this case, URL refers to the entire composition (e.g. protocol://domain:port/path?query=params), in this case base_path would be more accurate

if options.web_host_display_name:
logger.info(f"Starting web interface at {options.web_host_display_name}")
else:
if os.name == "nt":
logger.info(
f"Starting web interface at {protocol}://localhost:{options.web_port} (accepting connections from all network interfaces)"
f"Starting web interface at {protocol}://localhost:{options.web_port}{options.base_url} (accepting connections from all network interfaces)"
)
else:
logger.info(f"Starting web interface at {protocol}://0.0.0.0:{options.web_port}")
logger.info(f"Starting web interface at {protocol}://0.0.0.0:{options.web_port}{options.base_url}")

web_ui = environment.create_web_ui(
host=web_host,
port=options.web_port,
base_url=options.base_url,
web_login=options.web_login,
tls_cert=options.tls_cert,
tls_key=options.tls_key,
Expand Down
10 changes: 6 additions & 4 deletions locust/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ class MasterRunner(DistributedRunner):
:class:`WorkerRunners <WorkerRunner>` will aggregated.
"""

def __init__(self, environment, master_bind_host, master_bind_port) -> None:
def __init__(self, environment, master_bind_host, master_bind_port, base_url) -> None:
"""
:param environment: Environment instance
:param master_bind_host: Host/interface to use for incoming worker connections
Expand All @@ -656,6 +656,7 @@ def __init__(self, environment, master_bind_host, master_bind_port) -> None:
self.spawning_completed = False
self.worker_indexes: dict[str, int] = {}
self.worker_index_max = 0
self.base_url = base_url

self.clients = WorkerNodes()
try:
Expand Down Expand Up @@ -1201,7 +1202,7 @@ class WorkerRunner(DistributedRunner):
# the worker index is set on ACK, if master provided it (masters <= 2.10.2 do not provide it)
worker_index = -1

def __init__(self, environment: Environment, master_host: str, master_port: int) -> None:
def __init__(self, environment: Environment, master_host: str, master_port: int, base_url: str = "") -> None:
"""
:param environment: Environment instance
:param master_host: Host/IP to use for connection to the master
Expand All @@ -1216,6 +1217,7 @@ def __init__(self, environment: Environment, master_host: str, master_port: int)
self.client_id = socket.gethostname() + "_" + uuid4().hex
self.master_host = master_host
self.master_port = master_port
self.base_url = base_url
self.logs: list[str] = []
self.worker_cpu_warning_emitted = False
self._users_dispatcher: UsersDispatcher | None = None
Expand Down Expand Up @@ -1475,11 +1477,11 @@ def connect_to_master(self):
if not success:
if self.retry < 3:
logger.debug(
f"Failed to connect to master {self.master_host}:{self.master_port}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
f"Failed to connect to master {self.master_host}:{self.master_port}{self.base_url}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
)
else:
logger.warning(
f"Failed to connect to master {self.master_host}:{self.master_port}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
f"Failed to connect to master {self.master_host}:{self.master_port}{self.base_url}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
)
if self.retry > CONNECT_RETRY_COUNT:
raise ConnectionError()
Expand Down
2 changes: 1 addition & 1 deletion locust/test/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def test_web_ui_reference_on_environment(self):

def test_web_ui_no_runner(self):
env = Environment()
web_ui = WebUI(env, "127.0.0.1", 0)
web_ui = WebUI(env, "127.0.0.1", 0, base_url="")
gevent.sleep(0.01)
try:
response = requests.get("http://127.0.0.1:%i/" % web_ui.server.server_port)
Expand Down
8 changes: 8 additions & 0 deletions locust/util/url.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,11 @@ def is_url(url: str) -> bool:
return False
except ValueError:
return False


def normalize_base_url(url: str) -> str:
"""Normalize the base URL to ensure consistent format."""
if not url:
return "/"
url = url.strip("/")
return f"/{url}" if url else "/"
35 changes: 18 additions & 17 deletions locust/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def __init__(
environment: Environment,
host: str,
port: int,
base_url: str,
web_login: bool = False,
tls_cert: str | None = None,
tls_key: str | None = None,
Expand All @@ -117,6 +118,7 @@ def __init__(
self.environment = environment
self.host = host
self.port = port
self.base_url = base_url
self.tls_cert = tls_cert
self.tls_key = tls_key
self.userclass_picker_is_active = userclass_picker_is_active
Expand All @@ -134,7 +136,6 @@ def __init__(
self.app.static_url_path = "/assets/"
# ensures static js files work on Windows
mimetypes.add_type("application/javascript", ".js")

if self.web_login:
self._login_manager = LoginManager()
self._login_manager.init_app(self.app)
Expand All @@ -155,7 +156,7 @@ def handle_exception(error):
)
return make_response(error_message, error_code)

@app.route("/assets/<path:path>")
@app.route(f"{self.base_url}/assets/<path:path>")
def send_assets(path):
directory = (
os.path.join(self.app.template_folder, "assets")
Expand All @@ -165,7 +166,7 @@ def send_assets(path):

return send_from_directory(directory, path)

@app.route("/")
@app.route(f"{self.base_url}/")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If self.base_url is / (as it is by default) we will end up with @app.route("//") right?

@self.auth_required_if_enabled
def index() -> str | Response:
if not environment.runner:
Expand All @@ -174,7 +175,7 @@ def index() -> str | Response:

return render_template("index.html", template_args=self.template_args)

@app.route("/swarm", methods=["POST"])
@app.route(f"{self.base_url}/swarm", methods=["POST"])
@self.auth_required_if_enabled
def swarm() -> Response:
assert request.method == "POST"
Expand Down Expand Up @@ -288,7 +289,7 @@ def swarm() -> Response:
else:
return jsonify({"success": False, "message": "No runner", "host": environment.host})

@app.route("/stop")
@app.route(f"{self.base_url}/stop")
@self.auth_required_if_enabled
def stop() -> Response:
if self._swarm_greenlet is not None:
Expand All @@ -298,7 +299,7 @@ def stop() -> Response:
environment.runner.stop()
return jsonify({"success": True, "message": "Test stopped"})

@app.route("/stats/reset")
@app.route(f"{self.base_url}/stats/reset")
@self.auth_required_if_enabled
def reset_stats() -> str:
environment.events.reset_stats.fire()
Expand All @@ -307,7 +308,7 @@ def reset_stats() -> str:
environment.runner.exceptions = {}
return "ok"

@app.route("/stats/report")
@app.route(f"{self.base_url}/stats/report")
@self.auth_required_if_enabled
def stats_report() -> Response:
theme = request.args.get("theme", "")
Expand Down Expand Up @@ -345,15 +346,15 @@ def _download_csv_response(csv_data: str, filename_prefix: str) -> Response:
)
return response

@app.route("/stats/requests/csv")
@app.route(f"{self.base_url}/stats/requests/csv")
@self.auth_required_if_enabled
def request_stats_csv() -> Response:
data = StringIO()
writer = csv.writer(data)
self.stats_csv_writer.requests_csv(writer)
return _download_csv_response(data.getvalue(), "requests")

@app.route("/stats/requests_full_history/csv")
@app.route(f"{self.base_url}/stats/requests_full_history/csv")
@self.auth_required_if_enabled
def request_stats_full_history_csv() -> Response:
options = self.environment.parsed_options
Expand All @@ -371,15 +372,15 @@ def request_stats_full_history_csv() -> Response:

return make_response("Error: Server was not started with option to generate full history.", 404)

@app.route("/stats/failures/csv")
@app.route(f"{self.base_url}/stats/failures/csv")
@self.auth_required_if_enabled
def failures_stats_csv() -> Response:
data = StringIO()
writer = csv.writer(data)
self.stats_csv_writer.failures_csv(writer)
return _download_csv_response(data.getvalue(), "failures")

@app.route("/stats/requests")
@app.route(f"{self.base_url}/stats/requests")
@self.auth_required_if_enabled
@memoize(timeout=DEFAULT_CACHE_TIME, dynamic_timeout=True)
def request_stats() -> Response:
Expand Down Expand Up @@ -449,7 +450,7 @@ def request_stats() -> Response:

return jsonify(report)

@app.route("/exceptions")
@app.route(f"{self.base_url}/exceptions")
@self.auth_required_if_enabled
def exceptions() -> Response:
return jsonify(
Expand All @@ -466,15 +467,15 @@ def exceptions() -> Response:
}
)

@app.route("/exceptions/csv")
@app.route(f"{self.base_url}/exceptions/csv")
@self.auth_required_if_enabled
def exceptions_csv() -> Response:
data = StringIO()
writer = csv.writer(data)
self.stats_csv_writer.exceptions_csv(writer)
return _download_csv_response(data.getvalue(), "exceptions")

@app.route("/tasks")
@app.route(f"{self.base_url}/tasks")
@self.auth_required_if_enabled
def tasks() -> dict[str, dict[str, dict[str, float]]]:
runner = self.environment.runner
Expand All @@ -494,12 +495,12 @@ def tasks() -> dict[str, dict[str, dict[str, float]]]:
}
return task_data

@app.route("/logs")
@app.route(f"{self.base_url}/logs")
@self.auth_required_if_enabled
def logs():
return jsonify({"master": get_logs(), "workers": self.environment.worker_logs})

@app.route("/login")
@app.route(f"{self.base_url}/login")
def login():
if not self.web_login:
return redirect(url_for("index"))
Expand All @@ -509,7 +510,7 @@ def login():
auth_args=self.auth_args,
)

@app.route("/user", methods=["POST"])
@app.route(f"{self.base_url}/user", methods=["POST"])
def update_user():
assert request.method == "POST"

Expand Down