diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8e84977 --- /dev/null +++ b/.flake8 @@ -0,0 +1,14 @@ +[flake8] +max-line-length = 120 +extend-ignore = + # Whitespace before ':' + E203, + # imported but unused + F401 +exclude = + .git, + __pycache__, + env, + .env, + venv, + .venv diff --git a/.github/workflows/.gitkeep b/.github/workflows/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..9bfaf9d --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,30 @@ +name: Pre-commit linters and code checks + +on: + pull_request: + paths-ignore: + - 'README.md' + push: + branches: + - develop + paths-ignore: + - 'README.md' + +jobs: + pre_commit: + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: 3.8 + + - name: Install dependencies + run: | + python -m pip install --upgrade pre-commit==3.3.3 + + - name: Run pre-commit + run: pre-commit run --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d0a9d1d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,52 @@ +default_stages: +- commit +repos: +# general hooks to verify or beautify code +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: [--maxkb=5000] + - id: trailing-whitespace + - id: check-json + - id: check-merge-conflict + - id: check-xml + - id: check-yaml + exclude: ^charts/ + - id: detect-private-key + - id: mixed-line-ending + - id: end-of-file-fixer + - id: pretty-format-json + args: [--autofix] + + +# autoformat code with black formatter +- repo: https://github.com/psf/black + rev: 23.11.0 + hooks: + - id: black + +# beautify and sort imports +- repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + additional_dependencies: [toml] + exclude: '^(.*?/)?migrations/.*\.py$' + + +# check code style +- repo: https://github.com/pycqa/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + # exclude added here because the .flake8 settings are not respected by pre-commit --all-files + exclude: __init__.py|.venv|.env|venv|env|__pycache__ + + +# static type checking +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.7.0 + hooks: + - id: mypy + additional_dependencies: [types-requests==2.25.9] diff --git a/README.md b/README.md index b680c01..536aa92 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Make changes in the develop branch, commit and submit pull requests to merge to ## Configuration options To configure the test runs, use the Locust configuration file ./source/locust.conf -For options, see https://docs.locust.io/en/stable/configuration.html +For options, see the [Locust docs](https://docs.locust.io/en/stable/configuration.html) ## Create tests @@ -31,20 +31,21 @@ Create locust tests in the /source/tests directory. ## Verify the setup by running a simple test +These tests do not need access to a host (URL) to test against. + ### Running tests using the command line (headlessly) Run the command: - locust --headless -f ./tests/simple.py --users 1 --run-time 10s --html ./reports/locust-report-simple.html + locust --headless -f ./tests/test_verify_setup.py --users 1 --run-time 10s --html ./reports/locust-report-verify-setup.html Open the generated html report and verify that there are no errors and that the statistics look reasonable. The report is created under /source/reports/ ### Using the Locust UI web client -Run the commands +Run the command - cd ./tests - locust --modern-ui --class-picker -f simple.py + locust --config locust-ui.conf --modern-ui --class-picker -f ./tests/test_verify_setup.py --html ./reports/locust-report-verify-setup-ui.html Open a browser tab at URL http://localhost:8089/ @@ -52,31 +53,53 @@ Paste in as host https://staging.serve-dev.scilifelab.se +## Verify the setup and access to a host (URL) + +Run the command: + + locust --headless -f ./tests/test_verify_host.py --users 1 --run-time 10s --html ./reports/locust-report-verify-host.html + +Open the generated html report and verify that there are no errors and that the statistics look reasonable. The report is created under /source/reports/ + + ## Running tests -If desired generate html reports by editing the report name in the below commands. +### Prepare for running tests + +Some of the tests require pre-existing test users that are named with the format "locust_test_user_"* +Therefore, before running the tests, run the script to create them in the test environment. This can be performed using the Django manage module while connected to the Serve studio pod. For example to add 10 test users, run: -### Run tests headlessly + python3 manage.py add_locust_users 10 + +When the tests are completed, you can remove the test users by running: + + python3 manage.py remove_locust_users + +Move into the source directory if not already there: cd ./source -### To run only the Website tests +- Copy the template environment file .env.template as .env +- Edit the followinf values in the .env file according to your needs. + + - SERVE_LOCUST_TEST_USER_PASS=(The password of the test locust users) + - SERVE_LOCUST_DO_CREATE_OBJECTS=(A boolean indicating whether to create objects in Serve such as projects and apps) - locust -f ./tests/website.py --html ./reports/locust-report-website.html +Set the environment values from the file -### To run only the API tests + set -o allexport; source .env; set +o allexport - locust -f ./tests/api.py --html ./reports/locust-report-api.html +If desired then generate html reports by editing the report name in the below commands. -### To run all tests, execute one of the below commands. Beware, please be nice to the system resources. +### To run the Normal test plan/scenario -The configuration file parameter here is not necessary but is included for extra intelligibility. +Use minimum 10 users for the Normal test plan - locust --config locust.conf --html ./reports/locust-report-all.html + locust --headless -f ./tests/test_plan_normal.py --html ./reports/locust-report-normal.html --users 10 --run-time 30s -This executes the same command as above. +### To run the Classroom test plan/scenario - locust --html ./reports/locust-report-all.html + locust --headless -f ./tests/test_plan_classroom.py --html ./reports/locust-report-classroom.html --users 1 --run-time 30s ## Tests under development @@ -84,21 +107,45 @@ This executes the same command as above. These tests are not yet ready to be used in a load testing session. The tests are located under directory /source/tests-dev/ -### To run only the AppViewer tests (using user apps as a non-authenticated user) +### To run only the AppViewer tests + +This test uses a non-authenticated user - locust -f ./tests-dev/appviewer.py --html ./reports/locust-report-appviewer.html --users 1 --run-time 20s + locust --headless -f ./tests-dev/appviewer.py --html ./reports/locust-report-appviewer.html --users 1 --run-time 20s ### To run only the test class requiring authentication These tests require a user account in Serve and a protected page such as a project page. -- Copy the template environment file .env.template as .env -- Edit the values in the .env file +Run the tests -Set the environment values from the file + locust --headless -f ./tests-dev/authenticated.py --html ./reports/locust-report-authenticated.html --users 1 --run-time 10s - set -o allexport; source .env; set +o allexport -Run the tests +## To run tests using a Docker base image + +Using provided Locust base image. To select which tests to execute, edit the file parameter -f as shown above. + + cd ./source + + docker run -p 8089:8089 -v $PWD:/mnt/locust locustio/locust -f /mnt/locust/tests/simple.py --config /mnt/locust/locust.conf --html /mnt/locust/reports/locust-report-from-docker.html + + +## To run k8s pod-creating tests + +The Locust app-viewer tests do not currently create pods on the cluster. In order to run such tests, configure and execute appviewer_requestshtml.py + + python3 ./tests-dev/appviewer_requestshtml.py + + +## Use the shell script to run tests + +The shell script can be used to execute multiple simultaneous types of tests (such as Locust plus a scripted test). + +Edit the configuration in locust.conf and in .env. If running the appviewer_requestshtml.py module, then also configure settings in this file. - locust -f ./tests-dev/authenticated.py --html ./reports/locust-report-authenticated.html --users 1 --run-time 10s +``` +$ cd ./source +$ chmod +x run_test_plan.sh +$ ./run_test_plan.sh +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8170592 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +# This file contains project information. +# It also contains settings for linters and code checkers isort, black and mypy. +# Note that these settings are not respected with pre-commit run --all-files +# In that case add configurations to the .pre-commit-config.yaml file. + +[project] +name = "serve-load-testing" +version = "1.0.0" +description = "Load testing of the SciLifeLab Serve platform." +requires-python = "=3.8" +keywords = ["load testing", "locust", "python"] + +[tool.isort] +profile = 'black' + +[tool.black] +line-length = 120 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + \.git + | \.mypy_cache + | \.venv + | venv + | migrations +)/ +''' + +[tool.mypy] +strict = false +python_version = "3.8" +ignore_missing_imports = true +warn_return_any = true +exclude = ["venv", ".venv", "examples"] + +[[tool.mypy.overrides]] +module = "*.migrations.*" +ignore_errors = true + +[[tool.mypy.overrides]] +module = [ + "flatten_json.*", + "guardian.*", + "tagulous.*", + "dash.*", + "markdown.*", + "pytz.*", + "requests.*", + "setuptools.*", + "yaml.*", + ] +ignore_missing_imports = true diff --git a/source/.env.template b/source/.env.template index 93269d0..1a662e7 100644 --- a/source/.env.template +++ b/source/.env.template @@ -1,3 +1,5 @@ SERVE_USERNAME=a_username@email.se SERVE_PASS=a_pass -PROTECTED_PAGE_RELATIVE_URL=/a/relative/url \ No newline at end of file +SERVE_LOCUST_TEST_USER_PASS=a_pass2 +SERVE_LOCUST_DO_CREATE_OBJECTS=True +PROTECTED_PAGE_RELATIVE_URL=/a/relative/url diff --git a/source/locust-ui.conf b/source/locust-ui.conf new file mode 100644 index 0000000..414747b --- /dev/null +++ b/source/locust-ui.conf @@ -0,0 +1,12 @@ +# locust.conf +locustfile = tests +headless = false +#master = true +#expect-workers = 5 +host = https://staging.serve-dev.scilifelab.se +users = 1 +spawn-rate = 1 +run-time = 10s +only-summary = true +csv = stats/locust +loglevel = INFO diff --git a/source/locust.conf b/source/locust.conf index 2c85b4d..334bbff 100644 --- a/source/locust.conf +++ b/source/locust.conf @@ -1,11 +1,12 @@ # locust.conf locustfile = tests -headless = true +#headless = true #master = true #expect-workers = 5 -host = https://staging.serve-dev.scilifelab.se +host = https://serve-dev.scilifelab.se users = 1 spawn-rate = 1 run-time = 10s -only-summary = true -csv = stats/locust \ No newline at end of file +#only-summary = true +csv = stats/locust +loglevel = INFO diff --git a/source/requirements.txt b/source/requirements.txt index e12344e..f6f1ef5 100644 --- a/source/requirements.txt +++ b/source/requirements.txt @@ -1,2 +1,2 @@ locust>=2.20.0 -requests-html>=0.10.0 \ No newline at end of file +requests-html>=0.10.0 diff --git a/source/run_test_plan.sh b/source/run_test_plan.sh new file mode 100755 index 0000000..8f225c7 --- /dev/null +++ b/source/run_test_plan.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -o errexit + +# Activate the virtual env +source ./.venv/bin/activate + +# Set the env variables from this project +set -o allexport; source .env; set +o allexport + +# Run 2 sets of tests in parallel: the non-locust user apps and the locust test plan + +python3 ./tests-dev/appviewer_requestshtml.py & \ +locust --headless -f ./tests/test_plan_normal.py --users 10 --run-time 60s --html ./reports/locust-report-normal.html diff --git a/source/tests-dev/appviewer.py b/source/tests-dev/appviewer.py index d9ad5d0..c9a35ae 100644 --- a/source/tests-dev/appviewer.py +++ b/source/tests-dev/appviewer.py @@ -1,36 +1,34 @@ """A Locust test file.""" -from locust import HttpUser, task, between +import logging import warnings -warnings.filterwarnings("ignore") +from locust import HttpUser, between, task + +logger = logging.getLogger(__name__) -APP_SHINYPROXY = "https://loadtest-shinyproxy2.staging.serve-dev.scilifelab.se/app/loadtest-shinyproxy2" +warnings.filterwarnings("ignore") class AppViewerUser(HttpUser): - """ Simulates a non-authenticated user that uses apps in Serve. - Note: This test is not completed. The response time statistics - do not currently reflect the true time taken to start the user app. + """Simulates a non-authenticated user that uses apps in Serve. + Note: This test is not completed. The response time statistics + do not currently reflect the true time taken to start the user app. """ + weight = 1 wait_time = between(4, 8) def on_start(self): self.client.verify = False # Don't to check if certificate is valid - @task - def browse_homepage(self): - self.client.get("/home/") - - @task(2) - def browse_apps(self): - self.client.get("/apps/") - @task def open_user_app(self): + """Note that this approach does not create any pods on k8s.""" + logger.info("executing task open_user_app, running on host: %s", self.host) + # ex: https://loadtest-shinyproxy2.staging.serve-dev.scilifelab.se/app/loadtest-shinyproxy2 + # from host: https://staging.serve-dev.scilifelab.se + APP_SHINYPROXY = self.host.replace("https://", "https://loadtest-shinyproxy2.") + APP_SHINYPROXY += "/app/loadtest-shinyproxy2" + logger.info("making GET request to URL: %s", APP_SHINYPROXY) self.client.get(APP_SHINYPROXY, name="user-app-shiny-proxy") - - @task - def browse_models(self): - self.client.get("/models/") diff --git a/source/tests-dev/appviewer_requestshtml.py b/source/tests-dev/appviewer_requestshtml.py new file mode 100644 index 0000000..796d9de --- /dev/null +++ b/source/tests-dev/appviewer_requestshtml.py @@ -0,0 +1,110 @@ +""" +Handles opening user apps. +This implementation does not use Locust and does not work with Locust. + +Note that shiny proxy pods are configured with +- heartbeat-rate=10s +- heartbeat-timeout=60s +""" + +import logging +import warnings +from time import sleep, time + +from requests_html import HTMLSession + +logger = logging.getLogger(__name__) + +warnings.filterwarnings("ignore") + + +# SETTINGS + +# The user app URLs to open in succession +URL_LIST = [ + # "https://loadtest-shinyproxy.serve-dev.scilifelab.se/app/loadtest-shinyproxy" + "https://loadtest-shinyproxy2.staging.serve-dev.scilifelab.se/app/loadtest-shinyproxy2" + # "https://demo-bayesianlinmod.serve.scilifelab.se/app/demo-bayesianlinmod", + # "https://demo-markovchain.serve.scilifelab.se/app/demo-markovchain" +] + +# The maximum number of apps are allowed to be opened +MAX_APPS_PER_APP_TYPE_LIMIT = 20 + +# The initial delay to wait before opening any user apps +INITIAL_DELAY_SECONDS = 10 + +# The amount of time to wait between opening up each consecutive user app type +DELAY_BETWEEN_USER_APP_TYPES_SECONDS = 10 + +# The wait time between opening user apps of the same app type +OPEN_APPS_WAIT_TIME_SECONDS = 2 + + +def apps_runner(n_requests: int = 1): + """ + Opens user apps. + This results in pods created. + + :param n_requests int: The number of instances of each app to be opened. + """ + + if n_requests > MAX_APPS_PER_APP_TYPE_LIMIT: + raise Exception(f"Too many instances of user apps requested to be opened. Max = {MAX_APPS_PER_APP_TYPE_LIMIT}") + + start_time = time() + n_fails = 0 + + sleep(INITIAL_DELAY_SECONDS) + logger.info("START apps_runner") + + for url in URL_LIST: + logger.info("AppViewer URL set to: %s", url) + + for i in range(1, n_requests + 1): + logger.debug("Iteration: %s", i) + try: + response = open_user_app_sync(url) + logger.debug("open_user_app_sync response = %s, %s", response.status_code, response.reason) + logger.debug(response.content) + + except Exception as ex: + n_fails += 1 + logger.warning("Unable to open a user app. Error: %s", ex) + + sleep(OPEN_APPS_WAIT_TIME_SECONDS) + + sleep(DELAY_BETWEEN_USER_APP_TYPES_SECONDS) + + duration_s = time() - start_time + logger.info("Duration (sec) for opening %s user apps = %s. Nr failures = %s", n_requests, duration_s, n_fails) + + +def open_user_app_sync(url: str): + """ + Opens a user app that requires js support as an anonymous user. + This results in a pod created on k8s when run via module. + :param url: The app URL. + """ + + logger.info("Making a GET request to url: %s", url) + + session = HTMLSession(verify=False) + r = session.get(url) + assert r.status_code == 200 + r.html.render() + return r + + +# Private functions + + +def __test_open_sync(n_requests): + apps_runner(n_requests) + + +if __name__ == "__main__": + loglevel = logging.getLevelName(logger.getEffectiveLevel()) + print(f"Begin running appviewer_requestshtml.py using logging level {loglevel}") + apps_runner(4) + print("Completed running appviewer_requestshtml.py") diff --git a/source/tests-dev/authenticated.py b/source/tests-dev/authenticated.py index 043403d..395c926 100644 --- a/source/tests-dev/authenticated.py +++ b/source/tests-dev/authenticated.py @@ -1,7 +1,11 @@ +import logging import os -from locust import HttpUser, task, between - import warnings + +from locust import HttpUser, between, task + +logger = logging.getLogger(__name__) + warnings.filterwarnings("ignore") @@ -11,38 +15,49 @@ class AuthenticatedUser(HttpUser): - """ Simulates an authenticated user with a user account in Serve. - """ + """Simulates an authenticated user with a user account in Serve.""" # For now only supports one fixed user fixed_count = 1 wait_time = between(2, 3) + is_authenticated = False + def on_start(self): - print("DEBUG: on start") + logger.debug("on start") self.client.verify = False # Don't check if certificate is valid self.get_token() self.login() def get_token(self): self.client.get("/accounts/login/") - self.csrftoken = self.client.cookies['csrftoken'] - print(f"DEBUG: self.csrftoken = {self.csrftoken}") + self.csrftoken = self.client.cookies["csrftoken"] + logger.debug("self.csrftoken = %s", self.csrftoken) def login(self): - print(f"DEBUG: Logging in as user {username}") + logger.info("Login as user %s", username) + login_data = dict(username=username, password=password, csrfmiddlewaretoken=self.csrftoken) - response = self.client.post( + + with self.client.post( url="/accounts/login/", data=login_data, headers={"Referer": "foo"}, - name="---ON START---LOGIN") - print(f"DEBUG: login response.status_code = {response.status_code}") + name="---ON START---LOGIN", + catch_response=True, + ) as response: + logger.debug("login response.status_code = %s, %s", response.status_code, response.reason) + # If login succeeds then url = /accounts/login/, else /projects/ + logger.debug("login response.url = %s", response.url) + if "/projects" in response.url: + self.is_authenticated = True + else: + response.failure(f"Login as user {username} failed. Response URL does not contain /projects") def logout(self): - print(f"DEBUG: Logging out user {username}") - logout_data = dict(username=username, csrfmiddlewaretoken=self.csrftoken) + logger.debug("Log out user %s", username) + # logout_data = dict(username=username, csrfmiddlewaretoken=self.csrftoken) self.client.get("/accounts/logout/", name="---ON STOP---LOGOUT") @task @@ -51,9 +66,27 @@ def browse_homepage(self): @task(2) def browse_protected_page(self): + if self.is_authenticated is False: + logger.debug("Skipping test browse_protected_page. User is not authenticated.") + return + request_data = dict(username=username, csrfmiddlewaretoken=self.csrftoken) - self.client.get(page_rel_url, data=request_data, headers={"Referer": "foo"}, verify=False) + + response = self.client.get(page_rel_url, data=request_data, headers={"Referer": "foo"}, verify=False) + + with self.client.get( + page_rel_url, + data=request_data, + headers={"Referer": "foo"}, + verify=False, + catch_response=True, + ) as response: + logger.debug("protected page response.status_code = %s, %s", response.status_code, response.reason) + # If login succeeds then url = ?, else ? + logger.debug("protected page %s", response.url) + if page_rel_url not in response.url: + response.failure("User failed to access protected page.") def on_stop(self): - print("DEBUG: on stop") + logger.debug("on stop. exec logout.") self.logout() diff --git a/source/tests-dev/register_user_account.py b/source/tests-dev/register_user_account.py new file mode 100644 index 0000000..3997ff8 --- /dev/null +++ b/source/tests-dev/register_user_account.py @@ -0,0 +1,92 @@ +import logging +import warnings + +from locust import HttpUser, task + +logger = logging.getLogger(__name__) + +warnings.filterwarnings("ignore") + + +class VisitingBaseUser(HttpUser): + """Base class for a visiting website user type that may also register a new user account in Serve. + Simulates a casual, visiting, non-authenticated user that browses the public pages. + Some of these users may register new user accounts, using emails with pattern: + locust_test_user_created_by_testrun_{i}@test.uu.net + """ + + # abstract = True + + user_type = "" + user_individual_id = 0 + local_individual_id = 0 + user_has_registered = False + + @classmethod + def get_user_id(cls): + """Increments the class property user_individual_id. + Used to assign a unique id to each individual of this user type. + """ + VisitingBaseUser.user_individual_id += 1 + return VisitingBaseUser.user_individual_id + + def on_start(self): + """Called when a User starts running.""" + self.client.verify = False # Don't check if certificate is valid + self.local_individual_id = VisitingBaseUser.get_user_id() + logger.info("ONSTART new user type %s, individual %s", self.user_type, self.local_individual_id) + self.email = "UNSET" + + # Tasks + + @task + def browse_homepage(self): + self.client.get("/home/") + + @task + def register_user(self): + """Register this user as a new user account.""" + if self.user_has_registered is False: + # Only attempt to register once + self.user_has_registered = True + + self.email = f"locust_test_user_created_by_testrun_{self.local_individual_id}@test.uu.net" + logger.info("Registering new user account using email %s", self.email) + + self.get_token() + + register_data = dict( + email=self.email, + why_account_needed="For load testing", + first_name="Delete", + last_name="Me", + affiliation="other", + department="Dept ABC", + password1="ac3ya89ni3wk", + password2="ac3ya89ni3wk", + note="", + csrfmiddlewaretoken=self.csrftoken, + ) + + with self.client.post( + url="/signup/", + data=register_data, + headers={"Referer": "foo"}, + name="---REGISTER-NEW-USER-ACCOUNT", + catch_response=True, + ) as response: + logger.debug("signup response.status_code = %s, %s", response.status_code, response.reason) + # If login succeeds then url = /accounts/login/ + logger.debug("signup response.url = %s", response.url) + if "/accounts/login" in response.url: + self.user_has_registered = True + else: + logger.info(response.content) + response.failure( + f"Register as new user {self.email} failed. Response URL does not contain /accounts/login" + ) + + def get_token(self): + self.client.get("/signup/") + self.csrftoken = self.client.cookies["csrftoken"] + logger.debug("self.csrftoken = %s", self.csrftoken) diff --git a/source/tests/api.py b/source/tests/api.py deleted file mode 100644 index a5db257..0000000 --- a/source/tests/api.py +++ /dev/null @@ -1,26 +0,0 @@ -"""A Locust test file.""" - -from locust import HttpUser, task, between -import warnings -warnings.filterwarnings("ignore") - - -class OpenAPIClientUser(HttpUser): - """ Simulates client systems making OpenAPI requests. """ - weight = 1 - wait_time = between(0.5, 2) - - def on_start(self): - self.client.verify = False # Don't to check if certificate is valid - - @task - def call_api_info(self): - self.client.get("/openapi/v1/api-info", verify=False) - - @task - def call_system_version(self): - self.client.get("/openapi/v1/system-version", verify=False) - - @task - def get_public_apps(self): - self.client.get("/openapi/v1/public-apps", verify=False) diff --git a/source/tests/base_user_types.py b/source/tests/base_user_types.py new file mode 100644 index 0000000..58584d5 --- /dev/null +++ b/source/tests/base_user_types.py @@ -0,0 +1,367 @@ +import logging +import os +import warnings + +from locust import HttpUser, between, task + +logger = logging.getLogger(__name__) + +warnings.filterwarnings("ignore") + + +SERVE_LOCUST_TEST_USER_PASS = os.environ.get("SERVE_LOCUST_TEST_USER_PASS") +SERVE_LOCUST_DO_CREATE_OBJECTS = bool(os.environ.get("SERVE_LOCUST_DO_CREATE_OBJECTS", False)) + + +class VisitingBaseUser(HttpUser): + """Base class for a visiting website user type that may also register a new user account in Serve. + Simulates a casual, visiting, non-authenticated user that browses the public pages. + Some of these users may register new user accounts, using emails with pattern: + locust_test_user_created_by_testrun_{i}@test.uu.net + """ + + abstract = True + + user_type = "" + user_individual_id = 0 + local_individual_id = 0 + user_has_registered = False + + @classmethod + def get_user_id(cls) -> int: + """Increments the class property user_individual_id. + Used to assign a unique id to each individual of this user type. + """ + VisitingBaseUser.user_individual_id += 1 + return VisitingBaseUser.user_individual_id + + def on_start(self): + """Called when a User starts running.""" + self.client.verify = False # Don't check if certificate is valid + self.local_individual_id = VisitingBaseUser.get_user_id() + logger.info("ONSTART new user type %s, individual %s", self.user_type, self.local_individual_id) + + # Tasks + + @task(3) + def browse_homepage(self): + self.client.get("/home/") + + @task + def browse_about(self): + self.client.get("/about/") + + @task + def browse_apps(self): + self.client.get("/apps/") + + @task + def browse_models(self): + self.client.get("/models/") + + @task + def browse_user_guide(self): + self.client.get("/docs/") + + @task + def register_user(self): + """Register this user as a new user account.""" + if not SERVE_LOCUST_DO_CREATE_OBJECTS: + logger.debug("Skipping register new user because env var SERVE_LOCUST_DO_CREATE_OBJECTS == False") + return + + if self.user_has_registered is False: + # Only attempt to register once + self.user_has_registered = True + + self.email = f"locust_test_user_created_by_testrun_{self.local_individual_id}@test.uu.net" + print(f"Registering new user account using email {self.email}") + + self.get_token() + + register_data = dict( + email=self.email, + why_account_needed="For load testing", + first_name="Delete", + last_name="Me", + affiliation="other", + department="Dept ABC", + password1="ac3ya89ni3wk", + password2="ac3ya89ni3wk", + note="", + csrfmiddlewaretoken=self.csrftoken, + ) + + with self.client.post( + url="/signup/", + data=register_data, + headers={"Referer": "foo"}, + name="---REGISTER-NEW-USER-ACCOUNT", + catch_response=True, + ) as response: + logger.debug("signup response.status_code = %s, %s", response.status_code, response.reason) + # If login succeeds then url = /accounts/login/ + logger.debug("signup response.url = %s", response.url) + if "/accounts/login" in response.url: + self.user_has_registered = True + else: + logger.debug(response.content) + response.failure( + f"Register as new user {self.email} failed. Response URL does not contain /accounts/login" + ) + + def get_token(self): + self.client.get("/signup/") + self.csrftoken = self.client.cookies["csrftoken"] + logger.debug("self.csrftoken = %s", self.csrftoken) + + +class PowerBaseUser(HttpUser): + """Base class for the power user type that logs into Serve using an existing user account, + then creates resources such as a project and app and finally deleted the app. + """ + + abstract = True + + user_type = "" + user_individual_id = 0 + local_individual_id = 0 + + username = "NOT_FOUND" + password = SERVE_LOCUST_TEST_USER_PASS + + project_url = "UNSET" + + is_authenticated = False + task_has_run = False + + @classmethod + def get_user_id(cls) -> int: + """Increments the class property user_individual_id. + Used to assign a unique id to each individual of this user type. + """ + PowerBaseUser.user_individual_id += 1 + return PowerBaseUser.user_individual_id + + def on_start(self): + """Called when a User starts running.""" + self.client.verify = False # Don't check if certificate is valid + self.local_individual_id = PowerBaseUser.get_user_id() + logger.info("ONSTART new user type %s, individual %s", self.user_type, self.local_individual_id) + # Use the pre-created test users for this: f"locust_test_user_{self.local_individual_id}@test.uu.net" + self.username = f"locust_test_user_{self.local_individual_id}@test.uu.net" + # self.username = "locust_test_persisted_user@test.uu.net" + + # Tasks + + @task + def power_user_task(self): + if self.task_has_run is True: + logger.debug("Skipping power user task for user %s. It has already been run.", self.local_individual_id) + return + + self.task_has_run = True + + logger.info("executing power user task") + + # Open the home page + self.client.get("/home/") + + # Open the login page and get the csrf token + self.get_token() + + # Login as user locust_test_user_{id}@test.uu.net + self.login() + + if self.is_authenticated is False: + return + + # Open user docs pages + self.client.get("/docs/") + + if SERVE_LOCUST_DO_CREATE_OBJECTS is False or SERVE_LOCUST_DO_CREATE_OBJECTS == "False": + logger.info( + "Skipping tasks that create and delete projects and apps because env var \ + SERVE_LOCUST_DO_CREATE_OBJECTS == False" + ) + return + else: + logger.info("Creating and deleting projects and apps as user %s", self.username) + + # Create project: locust_test_project_new_ + project_name = f"locust_test_project_new_{self.local_individual_id}" + self.create_project(project_name) + + # Open the project + logger.info("Opening project at URL %s", self.project_url) + self.client.get(self.project_url) + + # TODO: create JupyterLab app + + # TODO: open the app + + # TODO: delete the app + + # Delete the project + self.delete_project() + + # Logout the user + self.logout() + + def get_token(self, relative_url: str = "/accounts/login/"): + self.client.get(relative_url) + self.csrftoken = self.client.cookies["csrftoken"] + logger.debug("self.csrftoken = %s", self.csrftoken) + + def create_project(self, project_name: str): + # Update the csrf token + self.get_token("/projects/create?template=Default project") + + project_data = dict( + name=project_name, + template_id=1, + description="Project desc", + csrfmiddlewaretoken=self.csrftoken, + ) + + with self.client.post( + url="/projects/create?template=Default%20project", + data=project_data, + headers={"Referer": "foo"}, + name="---CREATE-NEW-PROJECT", + catch_response=True, + ) as response: + logger.debug("create project response.status_code = %s, %s", response.status_code, response.reason) + # If succeeds then url = // + logger.debug("create project response.url = %s", response.url) + if self.username in response.url and project_name in response.url: + logger.info("Successfully created project %s", project_name) + self.project_url = response.url + else: + logger.warning(response.content) + response.failure("Create project failed. Response URL does not contain username and project name.") + + def delete_project(self): + # Update the csrf token + self.get_token("/projects") + + delete_project_url = f"{self.project_url}/delete" + logger.info("Deleting the project at URL: %s", delete_project_url) + + delete_project_data = dict(csrfmiddlewaretoken=self.csrftoken) + + with self.client.get( + url=delete_project_url, + data=delete_project_data, + headers={"Referer": "foo"}, + name="---DELETE-PROJECT", + catch_response=True, + ) as response: + logger.debug("delete project response.status_code = %s, %s", response.status_code, response.reason) + # If succeeds then url = /projects/ + logger.debug("delete project response.url = %s", response.url) + if "/projects" in response.url: + logger.info("Successfully deleted project at %s", self.project_url) + else: + logger.warning(response.content) + response.failure("Delete project failed. Response URL does not contain /projects.") + + def login(self): + logger.info("Login as user %s", self.username) + + login_data = dict( + username=self.username, + password=self.password, + csrfmiddlewaretoken=self.csrftoken, + ) + + with self.client.post( + url="/accounts/login/", + data=login_data, + headers={"Referer": "foo"}, + name="---ON START---LOGIN", + catch_response=True, + ) as response: + logger.debug("login response.status_code = %s, %s", response.status_code, response.reason) + # If login succeeds then url = /accounts/login/, else /projects/ + logger.debug("login response.url = %s", response.url) + if "/projects" in response.url: + self.is_authenticated = True + else: + response.failure(f"Login as user {self.username} failed. Response URL does not contain /projects") + + def logout(self): + logger.debug("Logout user %s", self.username) + # logout_data = dict(username=self.username, csrfmiddlewaretoken=self.csrftoken) + self.client.get("/accounts/logout/", name="---ON STOP---LOGOUT") + + +class AppViewerUser(HttpUser): + """Base class for app viewer user that opens up a user app.""" + + abstract = True + + user_type = "" + + def on_start(self): + """Called when a User starts running.""" + self.client.verify = False # Don't to check if certificate is valid + + # Tasks + + @task + def open_user_app(self): + """Note that this approach does not actually create any resources on the k8s cluster.""" + logger.info("executing task open_user_app, running on host: %s", self.host) + + APP_SHINYPROXY = "UNSET" + + if self.host == "https://serve-dev.scilifelab.se": + # Dev + # ex: https://loadtest-shinyproxy.staging.serve-dev.scilifelab.se/app/loadtest-shinyproxy + # from host: https://staging.serve-dev.scilifelab.se + APP_SHINYPROXY = self.host.replace("https://", "https://loadtest-shinyproxy.") + APP_SHINYPROXY += "/app/loadtest-shinyproxy" + + elif "staging" in self.host: + # Staging + # ex: https://loadtest-shinyproxy2.staging.serve-dev.scilifelab.se/app/loadtest-shinyproxy2 + # from host: https://staging.serve-dev.scilifelab.se + APP_SHINYPROXY = self.host.replace("https://", "https://loadtest-shinyproxy2.") + APP_SHINYPROXY += "/app/loadtest-shinyproxy2" + + elif "serve.scilifelab.se" in self.host: + # Production + # ex: https://adhd-medication-sweden.serve.scilifelab.se/app/adhd-medication-sweden + APP_SHINYPROXY = self.host.replace("https://", "https://adhd-medication-sweden.") + APP_SHINYPROXY += "/app/adhd-medication-sweden" + + logger.debug("making GET request to URL: %s", APP_SHINYPROXY) + + self.client.get(APP_SHINYPROXY, name="user-app-shiny-proxy") + + +class OpenAPIClientBaseUser(HttpUser): + """Base class for API client system that makes API calls.""" + + abstract = True + + user_type = "" + + def on_start(self): + self.client.verify = False # Don't check if certificate is valid + logger.info("ONSTART new user type %s", self.user_type) + + # Tasks + + @task + def call_api_info(self): + self.client.get("/openapi/v1/api-info") + + @task + def call_system_version(self): + self.client.get("/openapi/v1/system-version") + + @task(3) + def get_public_apps(self): + self.client.get("/openapi/v1/public-apps") diff --git a/source/tests/simple.py b/source/tests/simple.py deleted file mode 100644 index 1969b67..0000000 --- a/source/tests/simple.py +++ /dev/null @@ -1,18 +0,0 @@ -"""A Locust test file.""" - -from locust import HttpUser, task, between -import warnings -warnings.filterwarnings("ignore") - - -class SimpleUser(HttpUser): - """ The main purpose of this test user is to verify the setup of the test framework. - """ - wait_time = between(1, 2) - - def on_start(self): - self.client.verify = False # Don't to check if certificate is valid - - @task(3) - def browse_homepage(self): - self.client.get("/home/") diff --git a/source/tests/test_plan_classroom.py b/source/tests/test_plan_classroom.py new file mode 100644 index 0000000..17b2aa0 --- /dev/null +++ b/source/tests/test_plan_classroom.py @@ -0,0 +1,41 @@ +"""Locust test file defining the test plan scenario for the classroom load.""" + +from base_user_types import ( + AppViewerUser, + OpenAPIClientBaseUser, + PowerBaseUser, + VisitingBaseUser, +) +from locust import between + + +class VisitingClassroomUser(VisitingBaseUser): + """Implements the VisitingBaseUser user type.""" + + user_type = "VisitingClassroomUser" + weight = 2 + wait_time = between(2, 3) + + +class PowerClassroomUser(PowerBaseUser): + """Implements the PowerBaseUser user type.""" + + user_type = "PowerClassroomUser" + weight = 6 + wait_time = between(1, 2) + + +class AppViewerClassroomUser(AppViewerUser): + """Implements the VisitingBaseUser user type.""" + + user_type = "AppViewerClassroomUser" + weight = 1 + wait_time = between(4, 8) + + +class OpenAPIClientClassroomUser(OpenAPIClientBaseUser): + """Implements the ApiBaseUser user type.""" + + user_type = "OpenAPIClientClassroomUser" + weight = 1 + wait_time = between(0.5, 2) diff --git a/source/tests/test_plan_normal.py b/source/tests/test_plan_normal.py new file mode 100644 index 0000000..de2b699 --- /dev/null +++ b/source/tests/test_plan_normal.py @@ -0,0 +1,41 @@ +"""Locust test file defining the test plan scenario for normal load.""" + +from base_user_types import ( + AppViewerUser, + OpenAPIClientBaseUser, + PowerBaseUser, + VisitingBaseUser, +) +from locust import between + + +class VisitingNormalUser(VisitingBaseUser): + """Implements the VisitingBaseUser user type.""" + + user_type = "VisitingNormalUser" + weight = 6 + wait_time = between(2, 3) + + +class PowerNormalUser(PowerBaseUser): + """Implements the PowerBaseUser user type.""" + + user_type = "PowerNormalUser" + weight = 1 + wait_time = between(1, 2) + + +class AppViewerNormalUser(AppViewerUser): + """Implements the VisitingBaseUser user type.""" + + user_type = "AppViewerNormalUser" + weight = 2 + wait_time = between(4, 8) + + +class OpenAPIClientNormalUser(OpenAPIClientBaseUser): + """Implements the ApiBaseUser user type.""" + + user_type = "OpenAPIClientNormalUser" + weight = 1 + wait_time = between(0.5, 2) diff --git a/source/tests/test_verify_host.py b/source/tests/test_verify_host.py new file mode 100644 index 0000000..10c22f2 --- /dev/null +++ b/source/tests/test_verify_host.py @@ -0,0 +1,22 @@ +"""Locust test file to verify the Locust test framework setup and access to the SUT host.""" + +import warnings + +from locust import HttpUser, between, task + +warnings.filterwarnings("ignore") + + +class VerifyHostUser(HttpUser): + """The main purpose of this test user is to verify the setup of the Locust test framework + and access to the host. + """ + + wait_time = between(1, 2) + + def on_start(self): + self.client.verify = False # Don't to check if certificate is valid + + @task(3) + def browse_homepage(self): + self.client.get("/home/") diff --git a/source/tests/test_verify_setup.py b/source/tests/test_verify_setup.py new file mode 100644 index 0000000..b2e1533 --- /dev/null +++ b/source/tests/test_verify_setup.py @@ -0,0 +1,23 @@ +"""Locust test file to verify the Locust test framework setup.""" + +import logging +import warnings + +from locust import HttpUser, between, task + +logger = logging.getLogger(__name__) + +warnings.filterwarnings("ignore") + + +class VerifyLocustUser(HttpUser): + """The main purpose of this test user is to verify the setup of the Locust test framework.""" + + wait_time = between(1, 2) + + def on_start(self): + self.client.verify = False # Don't to check if certificate is valid + + @task + def verify_task(self): + logger.info("executing simple task verify_task") diff --git a/source/tests/website.py b/source/tests/website.py deleted file mode 100644 index ba515f8..0000000 --- a/source/tests/website.py +++ /dev/null @@ -1,34 +0,0 @@ -"""A Locust test file.""" - -from locust import HttpUser, task, between -import warnings -warnings.filterwarnings("ignore") - - -# Define user/scenario types - -class VisitingUser(HttpUser): - """ Simulates a casual, visiting, non-authenticated user - that browses the public pages. - """ - weight = 2 - wait_time = between(1, 3) - - def on_start(self): - self.client.verify = False # Don't to check if certificate is valid - - @task(3) - def browse_homepage(self): - self.client.get("/home/") - - @task - def browse_about(self): - self.client.get("/about/") - - @task - def browse_apps(self): - self.client.get("/apps/") - - @task - def browse_models(self): - self.client.get("/models/")