diff --git a/scripts/common.Makefile b/scripts/common.Makefile index abbe2a1d6cf..0dc78b889dd 100644 --- a/scripts/common.Makefile +++ b/scripts/common.Makefile @@ -15,6 +15,13 @@ # defaults .DEFAULT_GOAL := help +# Colors +BLUE=\033[0;34m +GREEN=\033[0;32m +YELLOW=\033[0;33m +RED=\033[0;31m +NC=\033[0m # No Color + # Use bash not sh SHELL := /bin/bash diff --git a/tests/performance/.gitignore b/tests/performance/.gitignore new file mode 100644 index 00000000000..e83a47c47af --- /dev/null +++ b/tests/performance/.gitignore @@ -0,0 +1,8 @@ +# Ignore credentials file +.auth-credentials.env + +# Ignore all locust configuration files +.locust.conf* + +# ignore test reports +*.html diff --git a/tests/performance/Dockerfile b/tests/performance/Dockerfile deleted file mode 100644 index 5f13ae3a88f..00000000000 --- a/tests/performance/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ - -ARG LOCUST_VERSION=latest -FROM locustio/locust:${LOCUST_VERSION} - - -RUN pip3 --version && \ - pip3 install \ - faker \ - locust-plugins \ - locust-plugins[dashboards] \ - pydantic \ - pydantic-settings \ - tenacity &&\ - pip3 freeze --verbose diff --git a/tests/performance/Makefile b/tests/performance/Makefile index 48d4c168e54..240dd3556a2 100644 --- a/tests/performance/Makefile +++ b/tests/performance/Makefile @@ -1,101 +1,153 @@ # -# Targets for DEVELOPMENT for performance test web-api +# Targets for DEVELOPMENT of tests/public-api # include ../../scripts/common.Makefile - -LOCUST_VERSION=2.29.1 -export LOCUST_VERSION - -ENV_FILE=$(shell pwd)/.env -export ENV_FILE - -KERNEL_NAME=$(shell uname -s) - -NETWORK_NAME=dashboards_timenet - -TEST_IMAGE_NAME := itisfoundation/performance-tests -TEST_IMAGE_TAG := v0 - -# UTILS -# NOTE: keep short arguments for `cut` so it works in both BusyBox (alpine) AND Ubuntu -get_my_ip := $(shell (hostname --all-ip-addresses || hostname -i) 2>/dev/null | cut -d " " -f 1) - -# Check that given variables are set and all have non-empty values, -# die with an error otherwise. -# -# Params: -# 1. Variable name(s) to test. -# 2. (optional) Error message to print. -check_defined = \ - $(strip $(foreach 1,$1, \ - $(call __check_defined,$1,$(strip $(value 2))))) -__check_defined = \ - $(if $(value $1),, \ - $(error Undefined $1$(if $2, ($2)))) - - - -.PHONY: build -build: ## builds distributed osparc locust docker image - docker \ - buildx build \ - --load \ - --build-arg LOCUST_VERSION=$(LOCUST_VERSION) \ - --tag itisfoundation/locust:$(LOCUST_VERSION) \ - --tag local/locust:latest \ - . - -.PHONY: push -push: - docker push itisfoundation/locust:$(LOCUST_VERSION) - -.PHONY: test-up test-down -test-up: ## runs osparc locust. Locust and test configuration are specified in ENV_FILE - @if [ ! -f $${ENV_FILE} ]; then echo "You must generate a .env file before running tests!!! See the README..." && exit 1; fi; - @if ! docker network ls | grep -q $(NETWORK_NAME); then \ - docker network create $(NETWORK_NAME); \ - echo "Created docker network $(NETWORK_NAME)"; \ +include ../../scripts/common-package.Makefile + + + +.PHONY: install-dev install-prod install-ci +install-dev install-prod install-ci: _check_venv_active ## install app in development/production or CI mode + # installing in $(subst install-,,$@) mode + @uv pip sync requirements/$(subst install-,,$@).txt + + +# Configuration files and default values +LOCUST_CONFIG_FILE := .locust.conf +AUTH_CREDS_FILE := .auth-credentials.env + +# Default Database settings +PG_HOST := 127.0.0.1 +PG_PORT := 5432 +PG_USER := postgres +PG_PASSWORD := password + +# Default Grafana settings +GRAFANA_URL := http://127.0.0.1:3000/ + +# Default Locust test settings +DEFAULT_PROCESSES := 4 +DEFAULT_USERS := 10 +DEFAULT_SPAWN_RATE := 1 +DEFAULT_RUN_TIME := 1m +DEFAULT_HOST := http://127.0.0.1:9081 + + + +define create_locust_config + @if [ ! -f $(LOCUST_CONFIG_FILE) ]; then \ + printf "$(YELLOW)First time setup: Creating Locust configuration file$(NC)\n"; \ + printf "Available locust files in locustfiles/:\n"; \ + find locustfiles -maxdepth 1 -type f -name '*.py' ! -name '__init__.py' -printf ' %p\n'; \ + find locustfiles -mindepth 2 -type f -name 'workflow.py' -printf ' %p\n'; \ + read -p "Locust file to use [./locustfiles/deployment_max_rps_single_endpoint.py]: " locustfile; \ + locustfile=$${locustfile:-./locustfiles/deployment_max_rps_single_endpoint.py}; \ + read -p "Number of processes [$(DEFAULT_PROCESSES)]: " processes; \ + processes=$${processes:-$(DEFAULT_PROCESSES)}; \ + read -p "Number of users [$(DEFAULT_USERS)]: " users; \ + users=$${users:-$(DEFAULT_USERS)}; \ + read -p "Spawn rate [$(DEFAULT_SPAWN_RATE)]: " spawn_rate; \ + spawn_rate=$${spawn_rate:-$(DEFAULT_SPAWN_RATE)}; \ + read -p "Run time [$(DEFAULT_RUN_TIME)]: " run_time; \ + run_time=$${run_time:-$(DEFAULT_RUN_TIME)}; \ + read -p "Host to load test [$(DEFAULT_HOST)]: " host; \ + host=$${host:-$(DEFAULT_HOST)}; \ + echo; \ + echo "# Locust configuration file - Autogenerated" > $(LOCUST_CONFIG_FILE); \ + echo "[locust]" >> $(LOCUST_CONFIG_FILE); \ + echo "locustfile = $$locustfile" >> $(LOCUST_CONFIG_FILE); \ + echo "host = $$host" >> $(LOCUST_CONFIG_FILE); \ + echo "users = $$users" >> $(LOCUST_CONFIG_FILE); \ + echo "spawn-rate = $$spawn_rate" >> $(LOCUST_CONFIG_FILE); \ + echo "run-time = $$run_time" >> $(LOCUST_CONFIG_FILE); \ + echo "processes = $$processes" >> $(LOCUST_CONFIG_FILE); \ + echo "loglevel = INFO" >> $(LOCUST_CONFIG_FILE); \ + echo "" >> $(LOCUST_CONFIG_FILE); \ + printf "$(GREEN)Locust configuration file created. It won't be asked again.$(NC)\n"; \ + else \ + printf "$(GREEN)Using existing Locust configuration file $(LOCUST_CONFIG_FILE)$(NC)\n"; \ fi - docker compose --file docker-compose.yml up --scale worker=4 --exit-code-from=master - -test-down: ## stops and removes osparc locust containers - @docker compose --file docker-compose.yml down - -.PHONY: dashboards-up dashboards-down - -dashboards-up: ## Create Grafana dashboard for inspecting locust results. See dashboard on localhost:3000 - @echo "View your dashboard on localhost:3000" - @if docker network ls | grep -q $(NETWORK_NAME); then \ - docker network rm $(NETWORK_NAME); \ - echo "Removed docker network $(NETWORK_NAME)"; \ +endef + +# Function to prompt for credentials if they don't exist +define prompt_for_credentials + @if [ ! -f $(AUTH_CREDS_FILE) ]; then \ + printf "$(YELLOW)First time setup: Please enter the deployment credentials$(NC)\n"; \ + read -p "Username: " username; \ + read -sp "Password: " password; \ + echo; \ + echo "SC_USER_NAME=$$username" > $(AUTH_CREDS_FILE); \ + echo "SC_PASSWORD=$$password" >> $(AUTH_CREDS_FILE); \ + read -p "osparc Username (required if login in osparc is necessary, press enter to skip): " osparc_username; \ + if [ ! -z "$$osparc_username" ]; then \ + read -sp "osparc Password: " osparc_password; \ + echo; \ + echo "OSPARC_USER_NAME=$$osparc_username" >> $(AUTH_CREDS_FILE); \ + echo "OSPARC_PASSWORD=$$osparc_password" >> $(AUTH_CREDS_FILE); \ + fi; \ + printf "$(GREEN)Credentials saved. They won't be asked again.$(NC)\n"; \ + else \ + printf "$(GREEN)Using cached credentials from $(AUTH_CREDS_FILE)$(NC)\n"; \ fi - @if [[ "$(KERNEL_NAME)" == "Linux" ]]; then \ - ( sleep 3 && xdg-open http://localhost:3000 ) & \ +endef + + +test-deployment: _check_venv_active ## runs deployment test on deploy + @$(call prompt_for_credentials) + @$(call create_locust_config) + @printf "$(YELLOW)Starting Locust...$(NC)\n" + @xdg-open http://localhost:8089/ & + @export $$(cat $(AUTH_CREDS_FILE) | xargs) && \ + locust --config $(LOCUST_CONFIG_FILE) + +test-deployment-ci: _check_venv_active $(AUTH_CREDS_FILE) $(LOCUST_CONFIG_FILE) ## runs deployment test on CI, expects all config and credentials files to be present, does not prompt + @printf "$(YELLOW)Starting Locust headless...$(NC)\n" + @export $$(cat $(AUTH_CREDS_FILE) | xargs) && \ + locust --config $(LOCUST_CONFIG_FILE) \ + --headless \ + --html test_report_{u}users_{r}userspawnrate_{t}s.html + +test-deployment-with-grafana: _check_venv_active grafana-dashboards-up ## runs deployment test with Grafana integration + @$(call prompt_for_credentials) + @$(call create_locust_config) + @printf "$(YELLOW)Starting Locust with Grafana integration...$(NC)\n" + @xdg-open $(GRAFANA_URL) + @export $$(cat $(AUTH_CREDS_FILE) | xargs) && \ + locust --config $(LOCUST_CONFIG_FILE) \ + --grafana-url $(GRAFANA_URL) \ + --pghost $(PG_HOST) \ + --pgport $(PG_PORT) \ + --pguser $(PG_USER) \ + --pgpassword=$(PG_PASSWORD) \ + --headless \ + --timescale + + + + +clear-credentials: ## Clear the cached authentication credentials + @if [ -f $(AUTH_CREDS_FILE) ]; then \ + rm $(AUTH_CREDS_FILE); \ + printf "$(GREEN)Credentials cleared.$(NC)\n"; \ + else \ + printf "$(YELLOW)No credentials file found.$(NC)\n"; \ fi - @locust-compose up - - -dashboards-down: ## stops and removes Grafana dashboard and Timescale postgress containers - @locust-compose down - -.PHONY: install-ci install-dev -install-dev: - @uv pip install -r requirements/requirements-dev.txt -install-ci: - @uv pip install -r requirements/requirements-ci.txt +clear: down clear-credentials clear-locust-config ## Clear all cached data including credentials and Locust configuration files +clear-locust-config: ## Clear all Locust configuration files + @for config_file in $(LOCUST_CONFIG_FILE) $(LOCUST_CONFIG_FILE)_light $(LOCUST_CONFIG_FILE)_heavy; do \ + if [ -f $$config_file ]; then \ + rm $$config_file; \ + printf "$(GREEN)$$config_file cleared.$(NC)\n"; \ + fi; \ + done + @if [ ! -f $(LOCUST_CONFIG_FILE) ] && [ ! -f $(LOCUST_CONFIG_FILE)_light ] && [ ! -f $(LOCUST_CONFIG_FILE)_heavy ]; then \ + printf "$(YELLOW)No Locust configuration files found.$(NC)\n"; \ + fi -.PHONY: config -config: ## Create config for your locust tests - @$(call check_defined, input, please define inputs when calling $@ - e.g. ```make $@ input="--help"```) - @uv run locust_settings.py $(input) | tee "${ENV_FILE}" - - -.PHONY: build-test-image push-test-image -build-test-image: _check_venv_active ## Build test image - docker build --build-arg PYTHON_VERSION="$(shell python --version | cut -d' ' -f2)" --build-arg UV_VERSION="$(shell uv --version | cut -d' ' -f2)" -t ${TEST_IMAGE_NAME}:${TEST_IMAGE_TAG} ./docker - -push-test-image: ## Push test image to dockerhub - docker push ${TEST_IMAGE_NAME}:${TEST_IMAGE_TAG} +grafana-dashboards-up: ## start grafana dashboards for locust + @locust-compose up -d +down grafana-dashboards-down: ## stop grafana dashboards for locust + @locust-compose down diff --git a/tests/performance/README.md b/tests/performance/README.md index d3a94d548c9..80f9c5c15ed 100644 --- a/tests/performance/README.md +++ b/tests/performance/README.md @@ -1,33 +1,91 @@ -# performance testing using [locust.io](https://docs.locust.io/en/stable/index.html) +# osparc-simcore Performance Test Suite -Locust allows simple testing of endpoints, and checks for response time, response type. It also allows to create useful reports. +This directory contains performance testing tools and scripts for osparc-simcore, using [Locust](https://locust.io/) for load testing. The suite is designed for both interactive (developer) and CI (automation) usage, with robust credential/config prompts and support for result visualization in Grafana. -## configuration +## Makefile Targets -In the [locust_files] folder are located the test files. +All main operations are managed via the provided `Makefile`. Ensure you have a Python virtual environment activated and all dependencies installed before running the targets. -## Usage +### Main Targets -1. All settings are passed to the locust container as environment variables in `.env`. To generate locust env vars, run `make config` with appropriate `input`. To see what the possible settings are, run `make config input="--help"`. E.g. you could run -```bash -make config input="--LOCUST_HOST=https://api.osparc-master.speag.com ---LOCUST_USERS=100 --LOCUST_RUN_TIME=0:10:00 --LOCUST_LOCUSTFILE=locust_files/platform_ping_test.py" -``` -This will validate your settings and you should be good to go once you see a the settings printed in your terminal. +- **`make test-deployment`** + Interactively prompts for credentials and Locust configuration, then runs a Locust test. If required files are missing, you will be prompted for: + - SC (Simcore) username and password + - Optionally, OSPARC username and password (press Enter to skip) + - Locust configuration (target host, users, etc.) + - Locust file selection: a list of available `.py` files (excluding `__init__.py`) and `workflow.py` in subfolders will be shown; enter the path to the desired file. -2. Once you have all settings setup you run your test script using the Make `test` recipe: -```bash -make test-up -``` +- **`make test-deployment-with-grafana`** + Like `test-deployment`, but also starts Grafana dashboards for monitoring. Prompts for credentials and configuration if needed. + +- **`make test-deployment-ci`** + Runs a Locust test in CI mode. Expects `.auth-credentials.env` and `.locust.conf` to already exist (no prompts). Fails if these files are missing. Use this for automation and CI pipelines. + +- **`make clear-credentials`** + Removes cached credentials (`.auth-credentials.env`). + +- **`make clear-locust-config`** + Removes Locust configuration files (`.locust.conf`). + +- **`make clear`** + Removes both credentials and Locust config files. + + + +## Locust File Selection + +When prompted to select a Locust file, the script will list all available `.py` files (excluding `__init__.py`) and any `workflow.py` in subfolders. Enter the path to the file you wish to use (e.g., `locustfiles/deployment_max_rps_single_endpoint.py`). + +## Visualizing Test Results with Locust UI -3. If you want to clean up after your tests (remove docker containers) you run `make test-down` +When running Locust (`make test-deployment`), open the following website in your browser to visualize the performance test dashboards: + +- [http://127.0.0.1:8089/](http://127.0.0.1:8089/) + +## Visualizing Test Results with Grafana + +When running with Grafana integration (`make test-deployment-with-grafana`), open the following website in your browser to visualize the performance test dashboards: + +- [http://127.0.0.1:3000/](http://127.0.0.1:3000/) + +## Example: Running in CI + +To use the `test-deployment-ci` target in a CI pipeline (e.g., GitLab CI), you must first generate the required files non-interactively. For example: + +```sh +# Set credentials as environment variables (in CI, use CI/CD secrets) +export SC_USER_NAME=youruser +export SC_PASSWORD=yourpass +# Optionally for osparc login +export OSPARC_USER_NAME=osparcuser +export OSPARC_PASSWORD=osparcpass + +# Create the credentials file +cat < .auth-credentials.env +SC_USER_NAME=$SC_USER_NAME +SC_PASSWORD=$SC_PASSWORD +OSPARC_USER_NAME=$OSPARC_USER_NAME +OSPARC_PASSWORD=$OSPARC_PASSWORD +EOF + +# Create a Locust config file (adjust locustfile path as needed) +cat < .locust.conf +[locust] +locustfile = ./locustfiles/deployment_max_rps_single_endpoint.py +host = http://127.0.0.1:9081 +users = 10 +spawn-rate = 1 +run-time = 5m +processes = 4 +loglevel = INFO +EOF + +# Run the CI target +make test-deployment-ci +``` -## Dashboards for visualization -- You can visualize the results of your tests (in real time) in a collection of beautiful [Grafana dashboards](https://github.com/SvenskaSpel/locust-plugins/tree/master/locust_plugins/dashboards). -- To do this, run `make dashboards-up`. If you are on linux you should see your browser opening `localhost:3000`, where you can view the dashboards. If the browser doesn't open automatically, do it manually and navigate to `localhost:3000`.The way you tell locust to send test results to the database/grafana is by ensuring `LOCUST_TIMESCALE=1` (see how to generate settings in [usage](#usage)) -- When you are done you run `make dashboards-down` to clean up. -- If you are using VPN you will need to forward port 3000 to your local machine to view the dashboard. +In a GitLab CI YAML job, you can use these steps in the `script:` section, using CI/CD variables for secrets. +--- -## Tricky settings 🚨 -- `LOCUST_TIMESCALE` tells locust whether or not to send data to the database associated with visualizing the results. If you are not using the Grafana [dashboards](#dashboards-for-visualization) you should set `LOCUST_TIMESCALE=0`. +For more details, see the Makefile and comments in this directory. If you encounter issues or need to update the workflow, please refer to the latest Makefile and scripts for guidance. diff --git a/tests/performance/locust_report/.gitkeep b/tests/performance/common/__init__.py similarity index 100% rename from tests/performance/locust_report/.gitkeep rename to tests/performance/common/__init__.py diff --git a/tests/performance/common/auth_settings.py b/tests/performance/common/auth_settings.py new file mode 100644 index 00000000000..ad8c58886bf --- /dev/null +++ b/tests/performance/common/auth_settings.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from pydantic import Field, SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class DeploymentAuth(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + SC_USER_NAME: Annotated[str, Field(examples=[""])] + SC_PASSWORD: Annotated[SecretStr, Field(examples=[""])] + + def to_auth(self) -> tuple[str, str]: + return (self.SC_USER_NAME, self.SC_PASSWORD.get_secret_value()) + + +class OsparcAuth(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + OSPARC_USER_NAME: Annotated[str, Field(examples=[""])] + OSPARC_PASSWORD: Annotated[SecretStr, Field(examples=[""])] diff --git a/tests/performance/common/base_user.py b/tests/performance/common/base_user.py new file mode 100644 index 00000000000..21b835c84cd --- /dev/null +++ b/tests/performance/common/base_user.py @@ -0,0 +1,140 @@ +import json +import logging +from typing import Any + +import locust_plugins +from locust import FastHttpUser, events +from locust.argument_parser import LocustArgumentParser +from locust.env import Environment + +from .auth_settings import DeploymentAuth, OsparcAuth + +_logger = logging.getLogger(__name__) + +# NOTE: 'import locust_plugins' is necessary to use --check-fail-ratio +# this assert is added to avoid that pycln pre-commit hook does not +# remove the import (the tool assumes the import is not necessary) +assert locust_plugins # nosec + + +class OsparcUserBase(FastHttpUser): + """ + Base class for Locust users that provides common functionality. + This class can be extended by specific user classes to implement + different behaviors or tasks. + """ + + abstract = True # This class is abstract and won't be instantiated by Locust + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.deploy_auth = DeploymentAuth() + _logger.debug( + "Using deployment auth with username: %s", self.deploy_auth.SC_USER_NAME + ) + + def authenticated_get(self, url, **kwargs): + """Make GET request with deployment auth""" + kwargs.setdefault("auth", self.deploy_auth.to_auth()) + return self.client.get(url, **kwargs) + + def authenticated_post(self, url, **kwargs): + """Make POST request with deployment auth""" + kwargs.setdefault("auth", self.deploy_auth.to_auth()) + return self.client.post(url, **kwargs) + + def authenticated_put(self, url, **kwargs): + """Make PUT request with deployment auth""" + kwargs.setdefault("auth", self.deploy_auth.to_auth()) + return self.client.put(url, **kwargs) + + def authenticated_delete(self, url, **kwargs): + """Make DELETE request with deployment auth""" + kwargs.setdefault("auth", self.deploy_auth.to_auth()) + return self.client.delete(url, **kwargs) + + def authenticated_patch(self, url, **kwargs): + """Make PATCH request with deployment auth""" + kwargs.setdefault("auth", self.deploy_auth.to_auth()) + return self.client.patch(url, **kwargs) + + +@events.init_command_line_parser.add_listener +def _(parser: LocustArgumentParser) -> None: + parser.add_argument( + "--requires-login", + action="store_true", + default=False, + help="Indicates if the user requires login before accessing the endpoint", + ) + + +@events.init.add_listener +def _(environment: Environment, **_kwargs: Any) -> None: + # Only log the parsed options, as the full environment is not JSON serializable + options_dict: dict[str, Any] = vars(environment.parsed_options) + _logger.debug("Testing environment options: %s", json.dumps(options_dict, indent=2)) + + +class OsparcWebUserBase(OsparcUserBase): + """ + Base class for web users in Locust that provides common functionality. + This class can be extended by specific web user classes to implement + different behaviors or tasks. + """ + + abstract = True # This class is abstract and won't be instantiated by Locust + requires_login = False # Default value, can be overridden by subclasses + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Determine if login is required once during initialization + self._login_required = ( + getattr(self.__class__, "requires_login", False) + or self.environment.parsed_options.requires_login + ) + + # Initialize auth if login is required + if self._login_required: + self.osparc_auth = OsparcAuth() + _logger.info( + "Using OsparcAuth for login with username: %s", + self.osparc_auth.OSPARC_USER_NAME, + ) + + def on_start(self) -> None: + """ + Called when a web user starts. Can be overridden by subclasses + to implement custom startup behavior, such as logging in. + """ + if self._login_required: + self._login() + + def on_stop(self) -> None: + """ + Called when a web user stops. Can be overridden by subclasses + to implement custom shutdown behavior, such as logging out. + """ + if self._login_required: + self._logout() + + def _login(self) -> None: + # Implement login logic here + response = self.authenticated_post( + "/v0/auth/login", + json={ + "email": self.osparc_auth.OSPARC_USER_NAME, + "password": self.osparc_auth.OSPARC_PASSWORD.get_secret_value(), + }, + ) + response.raise_for_status() + _logger.debug( + "Logged in user with email: %s", self.osparc_auth.OSPARC_USER_NAME + ) + + def _logout(self) -> None: + # Implement logout logic here + self.authenticated_post("/v0/auth/logout") + _logger.debug( + "Logged out user with email: %s", self.osparc_auth.OSPARC_USER_NAME + ) diff --git a/tests/performance/docker-compose.yml b/tests/performance/docker-compose.yml deleted file mode 100644 index 683f73d5898..00000000000 --- a/tests/performance/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -services: - master: - image: itisfoundation/locust:${LOCUST_VERSION} - ports: - - "8089:8089" - volumes: - - ./locust_files:/home/locust/locust_files - - ./locust_report:/home/locust/locust_report - command: > - --master --html=locust_report/locust_html.html - env_file: - - ${ENV_FILE} - networks: - - dashboards_timenet - - worker: - image: itisfoundation/locust:${LOCUST_VERSION} - volumes: - - ./locust_files/:/home/locust/locust_files - command: > - --worker --master-host master - env_file: - - ${ENV_FILE} - networks: - - dashboards_timenet - -networks: - dashboards_timenet : - external: true - name: dashboards_timenet diff --git a/tests/performance/docker/Dockerfile b/tests/performance/docker/Dockerfile deleted file mode 100644 index 0c6ed36dc81..00000000000 --- a/tests/performance/docker/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -# syntax=docker/dockerfile:1 - - -FROM cruizba/ubuntu-dind:latest AS base -ARG PYTHON_VERSION -LABEL maintainer=bisgaard-itis - -# Sets utf-8 encoding for Python et al -ENV LANG=C.UTF-8 -ENV UV_PYTHON=${PYTHON_VERSION} - -# Turns off writing .pyc files; superfluous on an ephemeral container. -ENV PYTHONDONTWRITEBYTECODE=1 \ - VIRTUAL_ENV=/home/scu/.venv - -# Ensures that the python and pip executables used in the image will be -# those from our virtualenv. -ENV PATH="${VIRTUAL_ENV}/bin:$PATH" - -ENV SC_BUILD_TARGET=build - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - git \ - jq - -# install UV https://docs.astral.sh/uv/guides/integration/docker/#installing-uv -COPY --from=ghcr.io/astral-sh/uv:0.4 /uv /uvx /bin/ - -RUN uv venv "${VIRTUAL_ENV}" - -WORKDIR /test diff --git a/tests/performance/locust_files/catalog_services.py b/tests/performance/locust_files/catalog_services.py deleted file mode 100644 index 9b38588ecc7..00000000000 --- a/tests/performance/locust_files/catalog_services.py +++ /dev/null @@ -1,57 +0,0 @@ -# -# SEE https://docs.locust.io/en/stable/quickstart.html -# - -import logging -from time import time - -import faker -from locust import task -from locust.contrib.fasthttp import FastHttpUser - -logging.basicConfig(level=logging.INFO) - -fake = faker.Faker() - - -class WebApiUser(FastHttpUser): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.email = fake.email() - - @task() - def get_services_with_details(self): - start = time() - with self.client.get( - "/v0/services?user_id=1&details=true", - headers={ - "x-simcore-products-name": "osparc", - }, - catch_response=True, - ) as response: - response.raise_for_status() - num_services = len(response.json()) - print(f"got {num_services} WITH DETAILS in {time() - start}s") - response.success() - - @task() - def get_services_without_details(self): - start = time() - with self.client.get( - "/v0/services?user_id=1&details=false", - headers={ - "x-simcore-products-name": "osparc", - }, - catch_response=True, - ) as response: - response.raise_for_status() - num_services = len(response.json()) - print(f"got {num_services} in {time() - start}s") - response.success() - - def on_start(self): - print("Created User ", self.email) - - def on_stop(self): - print("Stopping", self.email) diff --git a/tests/performance/locust_files/director_services.py b/tests/performance/locust_files/director_services.py deleted file mode 100644 index 0ad10faccb8..00000000000 --- a/tests/performance/locust_files/director_services.py +++ /dev/null @@ -1,31 +0,0 @@ -# -# SEE https://docs.locust.io/en/stable/quickstart.html -# - -import logging - -from locust import task -from locust.contrib.fasthttp import FastHttpUser - -logging.basicConfig(level=logging.INFO) - - -class WebApiUser(FastHttpUser): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.user_id = "my_user_id" - - @task() - def get_services(self): - self.client.get( - f"v0/services?user_id={self.user_id}", - headers={ - "x-simcore-products-name": "osparc", - }, - ) - - def on_start(self): # pylint: disable=no-self-use - print("Created User ") - - def on_stop(self): # pylint: disable=no-self-use - print("Stopping") diff --git a/tests/performance/locust_files/locustfile.py b/tests/performance/locust_files/locustfile.py deleted file mode 100644 index ed67dcfb831..00000000000 --- a/tests/performance/locust_files/locustfile.py +++ /dev/null @@ -1,76 +0,0 @@ -# -# SEE https://docs.locust.io/en/stable/quickstart.html -# - -import logging -from uuid import UUID - -import faker -from locust import HttpUser, between, task -from pydantic import Field -from pydantic_settings import BaseSettings - -logging.basicConfig(level=logging.INFO) - -fake = faker.Faker() - - -class TemplateSettings(BaseSettings): - TEMPLATE_PROJECT_ID: UUID = Field( - default=..., examples=["8de6acbe-ee58-46cd-8858-b925b96bc698"] - ) - - -class WebApiUser(HttpUser): - wait_time = between( - 1, 2.5 - ) # simulated users wait between 1 and 2.5 seconds after each task (see below) is executed. - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.template_id = TemplateSettings().TEMPLATE_PROJECT_ID - self.email = fake.email() - self.count = 0 - - @task - def health_check(self): - self.client.get("/v0/health") - - @task(weight=5) - def create_project_from_template(self): - self.count += 1 - - # WARNING: this template needs to be created and shared with everybody - self.client.post( - f"/v0/projects?from_study={self.template_id}", - json={ - "uuid": "", - "name": f"TEST #{self.count}", - "description": f"{__name__}-{self.email}", - "prjOwner": self.email, - "accessRights": {}, - "creationDate": "2021-04-28T14:46:53.674Z", - "lastChangeDate": "2021-04-28T14:46:53.674Z", - "thumbnail": "", - "workbench": {}, - }, - ) - - def on_start(self): - print("Created User ", self.email) - self.client.post( - "/v0/auth/register", - json={ - "email": self.email, - "password": "my secret", - "confirm": "my secret", - }, - ) - self.client.post( - "/v0/auth/login", json={"email": self.email, "password": "my secret"} - ) - - def on_stop(self): - self.client.post("/v0/auth/logout") - print("Stopping", self.email) diff --git a/tests/performance/locust_files/platform_ping_test.py b/tests/performance/locust_files/platform_ping_test.py deleted file mode 100644 index bde1befd105..00000000000 --- a/tests/performance/locust_files/platform_ping_test.py +++ /dev/null @@ -1,53 +0,0 @@ -# -# SEE https://docs.locust.io/en/stable/quickstart.html -# - -import logging - -import locust_plugins -from locust import task -from locust.contrib.fasthttp import FastHttpUser -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict - -logging.basicConfig(level=logging.INFO) - - -# NOTE: 'import locust_plugins' is necessary to use --check-fail-ratio -# this assert is added to avoid that pycln pre-commit hook does not -# remove the import (the tool assumes the import is not necessary) -assert locust_plugins # nosec - - -class MonitoringBasicAuth(BaseSettings): - model_config = SettingsConfigDict(extra="ignore") - SC_USER_NAME: str = Field(default=..., examples=[""]) - SC_PASSWORD: str = Field(default=..., examples=[""]) - - -class WebApiUser(FastHttpUser): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - _auth = MonitoringBasicAuth() - self.auth = ( - _auth.SC_USER_NAME, - _auth.SC_PASSWORD, - ) - - @task(10) - def get_root(self): - self.client.get("", auth=self.auth) - - @task(10) - def get_root_slash(self): - self.client.get("/", auth=self.auth) - - @task(1) - def get_health(self): - self.client.get("/v0/health", auth=self.auth) - - def on_start(self): # pylint: disable=no-self-use - print("Created locust user") - - def on_stop(self): # pylint: disable=no-self-use - print("Stopping locust user") diff --git a/tests/performance/locust_files/user_basic_calls.py b/tests/performance/locust_files/user_basic_calls.py deleted file mode 100644 index d40114086d6..00000000000 --- a/tests/performance/locust_files/user_basic_calls.py +++ /dev/null @@ -1,93 +0,0 @@ -# -# SEE https://docs.locust.io/en/stable/quickstart.html -# - -import logging -import time - -import faker -from locust import task -from locust.contrib.fasthttp import FastHttpUser - -logging.basicConfig(level=logging.INFO) - -fake = faker.Faker() - - -class WebApiUser(FastHttpUser): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.timeout = 20000 - self.email = fake.email() - self.password = "my_secret" - - def timed_get_request(self, route, resource_label): - start = round(time.time() * 1000) - with self.client.get(route, catch_response=True) as response: - response_time = round(time.time() * 1000) - start - print( - "get", - resource_label, - "status", - response.status_code, - "timing", - response_time, - ) - if response.status_code == 200: - response.success() - else: - response.failure("Got wrong response" + str(response.status_code)) - if response_time > self.timeout: - print("TOO SLOW " + str(response_time / 1000) + " seconds") - return response - - @task - def get_studies(self): - route = "v0/projects?type=user&offset=0&limit=20" - resource_label = "studies" - self.timed_get_request(route, resource_label) - - @task - def get_templates(self): - route = "v0/projects?type=template&offset=0&limit=20" - resource_label = "templates" - self.timed_get_request(route, resource_label) - - @task - def get_services(self): - route = "v0/catalog/services" - resource_label = "services" - self.timed_get_request(route, resource_label) - - def register(self, username, password): - print("Register User ", username) - self.client.post( - "v0/auth/register", - json={ - "email": username, - "password": password, - "confirm": password, - }, - ) - - def login(self, username, password): - print("Log in User ", username) - self.client.post( - "v0/auth/login", - json={ - "email": username, - "password": password, - }, - ) - - def logout(self, username): - print("Log out User ", username) - self.client.post("v0/auth/logout", catch_response=True) - - def on_start(self): - self.register(self.email, self.password) - self.login(self.email, self.password) - - def on_stop(self): - self.logout(self.email) diff --git a/tests/performance/locust_files/webserver_services.py b/tests/performance/locust_files/webserver_services.py deleted file mode 100644 index c08b5acea8d..00000000000 --- a/tests/performance/locust_files/webserver_services.py +++ /dev/null @@ -1,70 +0,0 @@ -# -# SEE https://docs.locust.io/en/stable/quickstart.html -# - -import logging -import urllib -import urllib.parse - -import faker -import locust -from dotenv import load_dotenv -from locust.contrib.fasthttp import FastHttpUser - -logging.basicConfig(level=logging.INFO) - -fake = faker.Faker() - -load_dotenv() # take environment variables from .env - - -class WebApiUser(FastHttpUser): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.email = fake.email() - - @locust.task - def list_latest_services(self): - base_url = "/v0/catalog/services/-/latest" - params = {"offset": 20, "limit": 20} - - while True: - response = self.client.get(base_url, params=params) - response.raise_for_status() - - page = response.json() - - # Process the current page data here - next_link = page["_links"].get("next") - if not next_link: - break - - # Update base_url and params for the next request - parsed_next = urllib.parse.urlparse(next_link) - base_url = parsed_next.path - params = dict(urllib.parse.parse_qsl(parsed_next.query)) - - def on_start(self): - print("Created User ", self.email) - password = "testtesttest" # noqa: S105 - - self.client.post( - "/v0/auth/register", - json={ - "email": self.email, - "password": password, - "confirm": password, - }, - ) - self.client.post( - "/v0/auth/login", - json={ - "email": self.email, - "password": password, - }, - ) - - def on_stop(self): - self.client.post("/v0/auth/logout") - print("Stopping", self.email) diff --git a/tests/performance/locust_settings.py b/tests/performance/locust_settings.py deleted file mode 100644 index f2cfbe5a1a6..00000000000 --- a/tests/performance/locust_settings.py +++ /dev/null @@ -1,156 +0,0 @@ -# pylint:disable=unused-argument -# pylint: disable=no-self-use - -import importlib.util -import inspect -import json -import sys -from datetime import timedelta -from pathlib import Path -from types import ModuleType -from typing import Final - -from parse import Result, parse -from pydantic import ( - AnyHttpUrl, - Field, - NonNegativeInt, - PositiveFloat, - PositiveInt, - SerializationInfo, - field_serializer, - field_validator, -) -from pydantic_settings import BaseSettings, SettingsConfigDict - -_TEST_DIR: Final[Path] = Path(__file__).parent.resolve() -_LOCUST_FILES_DIR: Final[Path] = _TEST_DIR / "locust_files" -assert _TEST_DIR.is_dir() -assert _LOCUST_FILES_DIR.is_dir() - - -def _get_settings_classes(file_path: Path) -> list[type[BaseSettings]]: - assert file_path.is_file() - module_name = file_path.stem - spec = importlib.util.spec_from_file_location(module_name, file_path) - if spec is None or spec.loader is None: - msg = f"Invalid {file_path=}" - raise ValueError(msg) - - module: ModuleType = importlib.util.module_from_spec(spec) - - # Execute the module in its own namespace - try: - spec.loader.exec_module(module) - except Exception as e: - msg = f"Failed to load module {module_name} from {file_path}" - raise ValueError(msg) from e - - # Filter subclasses of BaseSettings - settings_classes = [ - obj - for _, obj in inspect.getmembers(module, inspect.isclass) - if issubclass(obj, BaseSettings) and obj is not BaseSettings - ] - - return settings_classes - - -class LocustSettings(BaseSettings): - model_config = SettingsConfigDict(cli_parse_args=True, cli_ignore_unknown_args=True) - - LOCUST_CHECK_AVG_RESPONSE_TIME: PositiveInt = Field(default=200) - LOCUST_CHECK_FAIL_RATIO: PositiveFloat = Field(default=0.01, ge=0.0, le=1.0) - LOCUST_HEADLESS: bool = Field(default=True) - LOCUST_HOST: AnyHttpUrl = Field( - default=..., - examples=["https://api.osparc-master.speag.com"], - ) - LOCUST_LOCUSTFILE: Path = Field( - default=..., - description="Test file. Path should be relative to `locust_files` dir", - ) - LOCUST_PRINT_STATS: bool = Field(default=True) - LOCUST_RUN_TIME: timedelta - LOCUST_SPAWN_RATE: PositiveInt = Field(default=20) - - # Timescale: Log and graph results using TimescaleDB and Grafana dashboards - # SEE https://github.com/SvenskaSpel/locust-plugins/tree/master/locust_plugins/dashboards - # - LOCUST_TIMESCALE: NonNegativeInt = Field( - default=1, - ge=0, - le=1, - description="Send locust data to Timescale db for reading in Grafana dashboards", - ) - LOCUST_USERS: PositiveInt = Field( - default=..., - description="Number of locust users you want to spawn", - ) - - PGHOST: str = Field(default="postgres") - PGPASSWORD: str = Field(default="password") - PGPORT: int = Field(default=5432) - PGUSER: str = Field(default="postgres") - - @field_validator("LOCUST_RUN_TIME", mode="before") - @classmethod - def _validate_run_time(cls, v: str) -> str | timedelta: - result = parse("{hour:d}h{min:d}m{sec:d}s", v) - if not isinstance(result, Result): - return v - hour = result.named.get("hour") - _min = result.named.get("min") - sec = result.named.get("sec") - if hour is None or _min is None or sec is None: - msg = "Could not parse time" - raise ValueError(msg) - return timedelta(hours=hour, minutes=_min, seconds=sec) - - @field_validator("LOCUST_LOCUSTFILE", mode="after") - @classmethod - def _validate_locust_file(cls, v: Path) -> Path: - v = v.resolve() - if not v.is_file(): - msg = f"{v} must be an existing file" - raise ValueError(msg) - if not v.is_relative_to(_LOCUST_FILES_DIR): - msg = f"{v} must be a test file relative to {_LOCUST_FILES_DIR}" - raise ValueError(msg) - - # NOTE: CHECK that all the env-vars are defined for this test - # _check_load_and_instantiate_settings_classes(f"{v}") - - return v.relative_to(_TEST_DIR) - - @field_serializer("LOCUST_RUN_TIME") - def _serialize_run_time(self, td: timedelta, info: SerializationInfo) -> str: - total_seconds = int(td.total_seconds()) - hours, remainder = divmod(total_seconds, 3600) - minutes, seconds = divmod(remainder, 60) - return f"{hours}h{minutes}m{seconds}s" - - @field_serializer("LOCUST_HOST") - def _serialize_host(self, url: AnyHttpUrl, info: SerializationInfo) -> str: - # added as a temporary fix for https://github.com/pydantic/pydantic/issues/7186 - s = f"{url}" - return s.rstrip("/") - - -if __name__ == "__main__": - locust_settings = LocustSettings() - - arguments = dict( - arg.removeprefix("--").split("=") for arg in sys.argv if arg.startswith("--") - ) - - settings_objects = [locust_settings] - for sclass in _get_settings_classes(locust_settings.LOCUST_LOCUSTFILE): - settings_objects.append(sclass(**arguments)) - - env_vars = [] - for obj in settings_objects: - env_vars += [ - f"{key}={val}" for key, val in json.loads(obj.model_dump_json()).items() - ] - print("\n".join(env_vars)) diff --git a/tests/performance/locustfiles/__init__.py b/tests/performance/locustfiles/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/performance/locustfiles/deployment_max_rps_single_endpoint.py b/tests/performance/locustfiles/deployment_max_rps_single_endpoint.py new file mode 100644 index 00000000000..0b798b6eaab --- /dev/null +++ b/tests/performance/locustfiles/deployment_max_rps_single_endpoint.py @@ -0,0 +1,31 @@ +# +# SEE https://docs.locust.io/en/stable/quickstart.html +# +# This script allows testing the maximum RPS against a single endpoint. +# Usage: +# locust -f deployment_max_rps_single_endpoint.py --endpoint /v0/health +# +# If no endpoint is specified, the root endpoint ("/") will be used by default. +# + + +from common.base_user import OsparcWebUserBase +from locust import events, task +from locust.argument_parser import LocustArgumentParser + + +# Register the custom argument with Locust's parser +@events.init_command_line_parser.add_listener +def _(parser: LocustArgumentParser) -> None: + parser.add_argument( + "--endpoint", + type=str, + default="/", + help="The endpoint to test (e.g., /v0/health)", + ) + + +class WebApiUser(OsparcWebUserBase): + @task + def get_endpoint(self) -> None: + self.authenticated_get(self.environment.parsed_options.endpoint) diff --git a/tests/performance/locust_files/functions/workflow.py b/tests/performance/locustfiles/functions/workflow.py similarity index 79% rename from tests/performance/locust_files/functions/workflow.py rename to tests/performance/locustfiles/functions/workflow.py index 15bcfb4e742..63c75f642b3 100644 --- a/tests/performance/locust_files/functions/workflow.py +++ b/tests/performance/locustfiles/functions/workflow.py @@ -6,20 +6,12 @@ from typing import Annotated, Any from urllib.parse import quote -from locust import HttpUser, run_single_user, task +from common.base_user import OsparcWebUserBase +from locust import run_single_user, task from pydantic import BaseModel, Field -from pydantic_settings import BaseSettings, SettingsConfigDict -from requests.auth import HTTPBasicAuth from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential from urllib3 import PoolManager, Retry - -class UserSettings(BaseSettings): - model_config = SettingsConfigDict(extra="ignore") - OSPARC_API_KEY: Annotated[str, Field()] # required, no default - OSPARC_API_SECRET: Annotated[str, Field()] # required, no default - - _SOLVER_KEY = "simcore/services/comp/osparc-python-runner" _SOLVER_VERSION = "1.2.0" @@ -61,13 +53,11 @@ class Function(BaseModel): solver_version: Annotated[str, Field()] = _SOLVER_VERSION -class MetaModelingUser(HttpUser): +class MetaModelingUser(OsparcWebUserBase): + # This overrides the class attribute in OsparcWebUserBase + requires_login = True + def __init__(self, *args, **kwargs): - self._user_settings = UserSettings() - self._auth = HTTPBasicAuth( - username=self._user_settings.OSPARC_API_KEY, - password=self._user_settings.OSPARC_API_SECRET, - ) retry_strategy = Retry( total=4, backoff_factor=4.0, @@ -98,28 +88,24 @@ def __init__(self, *args, **kwargs): def on_stop(self) -> None: if self._script is not None: - self.client.delete( + self.authenticated_delete( f"/v0/files/{self._script.get('id')}", name="/v0/files/[file_id]", - auth=self._auth, ) if self._input_json is not None: - self.client.delete( + self.authenticated_delete( f"/v0/files/{self._input_json.get('id')}", name="/v0/files/[file_id]", - auth=self._auth, ) if self._function_uid is not None: - self.client.delete( + self.authenticated_delete( f"/v0/functions/{self._function_uid}", name="/v0/functions/[function_uid]", - auth=self._auth, ) if self._run_uid is not None: - self.client.delete( + self.authenticated_delete( f"/v0/function_jobs/{self._run_uid}", name="/v0/function_jobs/[function_run_uid]", - auth=self._auth, ) @task @@ -139,17 +125,14 @@ def run_function(self): description="Test function", default_inputs={"input_1": self._script}, ) - response = self.client.post( - "/v0/functions", json=_function.model_dump(), auth=self._auth - ) + response = self.authenticated_post("/v0/functions", json=_function.model_dump()) response.raise_for_status() self._function_uid = response.json().get("uid") assert self._function_uid is not None - response = self.client.post( + response = self.authenticated_post( f"/v0/functions/{self._function_uid}:run", json={"input_2": self._input_json}, - auth=self._auth, name="/v0/functions/[function_uid]:run", ) response.raise_for_status() @@ -160,9 +143,8 @@ def run_function(self): self.wait_until_done() - response = self.client.get( + response = self.authenticated_get( f"/v0/solvers/{quote(_SOLVER_KEY, safe='')}/releases/{_SOLVER_VERSION}/jobs/{self._solver_job_uid}/outputs", - auth=self._auth, name="/v0/solvers/[solver_key]/releases/[solver_version]/jobs/[solver_job_id]/outputs", ) response.raise_for_status() @@ -174,9 +156,8 @@ def run_function(self): reraise=False, ) def wait_until_done(self): - response = self.client.get( + response = self.authenticated_get( f"/v0/function_jobs/{self._run_uid}/status", - auth=self._auth, name="/v0/function_jobs/[function_run_uid]/status", ) response.raise_for_status() @@ -187,9 +168,7 @@ def upload_file(self, file: Path) -> dict[str, str]: assert file.is_file() with file.open(mode="rb") as f: files = {"file": f} - response = self.client.put( - "/v0/files/content", files=files, auth=self._auth - ) + response = self.authenticated_put("/v0/files/content", files=files) response.raise_for_status() assert response.json().get("id") is not None return response.json() diff --git a/tests/performance/locust_files/metamodeling/passer.py b/tests/performance/locustfiles/metamodeling/passer.py similarity index 100% rename from tests/performance/locust_files/metamodeling/passer.py rename to tests/performance/locustfiles/metamodeling/passer.py diff --git a/tests/performance/locust_files/metamodeling/study_template.png b/tests/performance/locustfiles/metamodeling/study_template.png similarity index 100% rename from tests/performance/locust_files/metamodeling/study_template.png rename to tests/performance/locustfiles/metamodeling/study_template.png diff --git a/tests/performance/locust_files/metamodeling/workflow.py b/tests/performance/locustfiles/metamodeling/workflow.py similarity index 100% rename from tests/performance/locust_files/metamodeling/workflow.py rename to tests/performance/locustfiles/metamodeling/workflow.py diff --git a/tests/performance/locustfiles/webserver_services.py b/tests/performance/locustfiles/webserver_services.py new file mode 100644 index 00000000000..103faa220a0 --- /dev/null +++ b/tests/performance/locustfiles/webserver_services.py @@ -0,0 +1,71 @@ +# +# SEE https://docs.locust.io/en/stable/quickstart.html +# + +import json +import logging +import urllib +import urllib.parse +from typing import Any + +import locust +from common.base_user import OsparcWebUserBase +from locust import events +from locust.env import Environment + +_logger = logging.getLogger(__name__) + + +@events.init.add_listener +def _(environment: Environment, **_kwargs: Any) -> None: + """ + Log the testing environment options when Locust initializes. + + Args: + environment: The Locust environment + _kwargs: Additional keyword arguments + """ + # Log that this test requires login + _logger.info( + "This test requires login (requires_login=True class attribute is set)." + ) + + # Only log the parsed options, as the full environment is not JSON serializable + assert ( + environment.parsed_options is not None + ), "Environment parsed options must not be None" + options_dict: dict[str, Any] = vars(environment.parsed_options) + _logger.info("Testing environment options: %s", json.dumps(options_dict, indent=2)) + + +class WebApiUser(OsparcWebUserBase): + """Web API user that always requires login regardless of command line flags.""" + + # This overrides the class attribute in OsparcWebUserBase + requires_login = True + + @locust.task + def list_latest_services(self): + base_url = "/v0/catalog/services/-/latest" + params = {"offset": 0, "limit": 20} + page_num = 0 + while True: + response = self.authenticated_get( + base_url, params=params, name=f"{base_url}/{page_num}" + ) + response.raise_for_status() + + page = response.json() + + # Process the current page data here + next_link = page["data"]["_links"].get("next") + if not next_link: + break + + # Update base_url and params for the next request + page_num += 1 + parsed_next = urllib.parse.urlparse(next_link) + base_url = parsed_next.path + params = dict(urllib.parse.parse_qsl(parsed_next.query)) + + _logger.info(params) diff --git a/tests/performance/requirements/Makefile b/tests/performance/requirements/Makefile new file mode 100644 index 00000000000..3f25442b790 --- /dev/null +++ b/tests/performance/requirements/Makefile @@ -0,0 +1,6 @@ +# +# Targets to pip-compile requirements +# +include ../../../requirements/base.Makefile + +# Add here any extra explicit dependency: e.g. _migration.txt: _base.txt diff --git a/tests/performance/requirements/_base.in b/tests/performance/requirements/_base.in new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/performance/requirements/_base.txt b/tests/performance/requirements/_base.txt index a018e48718c..e69de29bb2d 100644 --- a/tests/performance/requirements/_base.txt +++ b/tests/performance/requirements/_base.txt @@ -1,5 +0,0 @@ -locust-plugins -parse -pydantic -pydantic-settings -tenacity diff --git a/tests/performance/requirements/_test.in b/tests/performance/requirements/_test.in new file mode 100644 index 00000000000..c2ffb26a7f9 --- /dev/null +++ b/tests/performance/requirements/_test.in @@ -0,0 +1,5 @@ +locust +locust-plugins[dashboards] +pydantic +pydantic-settings +tenacity diff --git a/tests/performance/requirements/_test.txt b/tests/performance/requirements/_test.txt new file mode 100644 index 00000000000..03a956a7cd7 --- /dev/null +++ b/tests/performance/requirements/_test.txt @@ -0,0 +1,126 @@ +annotated-types==0.7.0 + # via pydantic +bidict==0.23.1 + # via python-socketio +blinker==1.9.0 + # via flask +brotli==1.1.0 + # via geventhttpclient +certifi==2025.4.26 + # via + # geventhttpclient + # requests +charset-normalizer==3.4.2 + # via requests +click==8.2.1 + # via flask +configargparse==1.7.1 + # via + # locust + # locust-cloud +flask==3.1.1 + # via + # flask-cors + # flask-login + # locust +flask-cors==6.0.0 + # via locust +flask-login==0.6.3 + # via locust +gevent==24.11.1 + # via + # geventhttpclient + # locust + # locust-cloud +geventhttpclient==2.3.3 + # via locust +greenlet==3.2.2 + # via gevent +h11==0.16.0 + # via wsproto +idna==3.10 + # via requests +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +locust==2.37.5 + # via + # -r requirements/_test.in + # locust-plugins +locust-cloud==1.21.8 + # via locust +locust-plugins==4.7.0 + # via -r requirements/_test.in +markupsafe==3.0.2 + # via + # flask + # jinja2 + # werkzeug +msgpack==1.1.0 + # via locust +platformdirs==4.3.8 + # via locust-cloud +psutil==7.0.0 + # via locust +psycogreen==1.0.2 + # via locust-plugins +psycopg2-binary==2.9.10 + # via locust-plugins +pydantic==2.11.5 + # via + # -r requirements/_test.in + # pydantic-settings +pydantic-core==2.33.2 + # via pydantic +pydantic-settings==2.9.1 + # via -r requirements/_test.in +python-dotenv==1.1.0 + # via pydantic-settings +python-engineio==4.12.1 + # via python-socketio +python-socketio==5.13.0 + # via locust-cloud +pyzmq==26.4.0 + # via locust +requests==2.32.3 + # via + # locust + # python-socketio +setuptools==80.9.0 + # via + # locust + # zope-event + # zope-interface +simple-websocket==1.1.0 + # via python-engineio +tenacity==9.1.2 + # via -r requirements/_test.in +typing-extensions==4.13.2 + # via + # locust-plugins + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.1 + # via + # pydantic + # pydantic-settings +urllib3==2.4.0 + # via + # geventhttpclient + # requests +websocket-client==1.8.0 + # via python-socketio +werkzeug==3.1.3 + # via + # flask + # flask-cors + # flask-login + # locust +wsproto==1.2.0 + # via simple-websocket +zope-event==5.0 + # via gevent +zope-interface==7.2 + # via gevent diff --git a/tests/performance/requirements/_tools.in b/tests/performance/requirements/_tools.in new file mode 100644 index 00000000000..053402891e2 --- /dev/null +++ b/tests/performance/requirements/_tools.in @@ -0,0 +1,5 @@ +--constraint ../../../requirements/constraints.txt + +--constraint _test.txt + +--requirement ../../../requirements/devenv.txt diff --git a/tests/performance/requirements/_tools.txt b/tests/performance/requirements/_tools.txt new file mode 100644 index 00000000000..096d2a1e3f9 --- /dev/null +++ b/tests/performance/requirements/_tools.txt @@ -0,0 +1,81 @@ +astroid==3.3.10 + # via pylint +black==25.1.0 + # via -r requirements/../../../requirements/devenv.txt +build==1.2.2.post1 + # via pip-tools +bump2version==1.0.1 + # via -r requirements/../../../requirements/devenv.txt +cfgv==3.4.0 + # via pre-commit +click==8.2.1 + # via + # -c requirements/_test.txt + # black + # pip-tools +dill==0.4.0 + # via pylint +distlib==0.3.9 + # via virtualenv +filelock==3.18.0 + # via virtualenv +identify==2.6.12 + # via pre-commit +isort==6.0.1 + # via + # -r requirements/../../../requirements/devenv.txt + # pylint +mccabe==0.7.0 + # via pylint +mypy==1.15.0 + # via -r requirements/../../../requirements/devenv.txt +mypy-extensions==1.1.0 + # via + # black + # mypy +nodeenv==1.9.1 + # via pre-commit +packaging==25.0 + # via + # black + # build +pathspec==0.12.1 + # via black +pip==25.1.1 + # via pip-tools +pip-tools==7.4.1 + # via -r requirements/../../../requirements/devenv.txt +platformdirs==4.3.8 + # via + # -c requirements/_test.txt + # black + # pylint + # virtualenv +pre-commit==4.2.0 + # via -r requirements/../../../requirements/devenv.txt +pylint==3.3.7 + # via -r requirements/../../../requirements/devenv.txt +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pyyaml==6.0.2 + # via + # -c requirements/../../../requirements/constraints.txt + # pre-commit +ruff==0.11.11 + # via -r requirements/../../../requirements/devenv.txt +setuptools==80.9.0 + # via + # -c requirements/_test.txt + # pip-tools +tomlkit==0.13.2 + # via pylint +typing-extensions==4.13.2 + # via + # -c requirements/_test.txt + # mypy +virtualenv==20.31.2 + # via pre-commit +wheel==0.45.1 + # via pip-tools diff --git a/tests/performance/requirements/ci.txt b/tests/performance/requirements/ci.txt new file mode 100644 index 00000000000..ef1707fdd17 --- /dev/null +++ b/tests/performance/requirements/ci.txt @@ -0,0 +1,3 @@ + +--requirement _test.txt +--requirement _tools.txt diff --git a/tests/performance/requirements/dev.txt b/tests/performance/requirements/dev.txt new file mode 100644 index 00000000000..1526c9da28b --- /dev/null +++ b/tests/performance/requirements/dev.txt @@ -0,0 +1,23 @@ +# Shortcut to install all packages needed to develop 'models-library' +# +# - As ci.txt but with current and repo packages in develop (edit) mode +# +# Usage: +# pip install -r requirements/dev.txt +# + +# NOTE: This folder must be previously generated as follows +# +# make devenv +# source .venv/bin/activate +# cd services/api-server +# make install-dev +# make python-client +# + +# installs base + tests requirements +--requirement _base.txt +--requirement _test.txt +--requirement _tools.txt + +# installs this repo's packages diff --git a/tests/performance/requirements/prod.txt b/tests/performance/requirements/prod.txt new file mode 100644 index 00000000000..3e35081c283 --- /dev/null +++ b/tests/performance/requirements/prod.txt @@ -0,0 +1 @@ +--requirement _test.txt diff --git a/tests/performance/requirements/requirements-ci.txt b/tests/performance/requirements/requirements-ci.txt deleted file mode 100644 index dc7dd1458a8..00000000000 --- a/tests/performance/requirements/requirements-ci.txt +++ /dev/null @@ -1 +0,0 @@ ---requirement _base.txt diff --git a/tests/performance/requirements/requirements-dev.txt b/tests/performance/requirements/requirements-dev.txt deleted file mode 100644 index dc7dd1458a8..00000000000 --- a/tests/performance/requirements/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ ---requirement _base.txt