Skip to content

Commit

Permalink
Upgraded Locust, create new user type Studentm and small fixes (#16)
Browse files Browse the repository at this point in the history
* Upgraded the deployment image version.

* Fixed TCP port in the production overlay manifest file.

* Fixed logout task

* Created a draft version of a load test script of a test user that creates user apps in Serve.

* Installed additional pip libraries

* Created user type Student as a subtype of PowerUser. This user created JupyterLab notebooks and is used in the classroom test plan.

* Version 1.1.0 of the Serve Locust project.
  • Loading branch information
alfredeen authored Oct 9, 2024
1 parent e0e3e6c commit bc7c457
Show file tree
Hide file tree
Showing 13 changed files with 421 additions and 41 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This repository contains two branches:
- develop

Make changes in the develop branch, commit and submit pull requests to merge to main.
Regularly sync develop with main using rebase to retain a clean commit history.

## Setup for local development

Expand Down Expand Up @@ -126,7 +127,7 @@ Or using the Web UI

### To run the Classroom test plan/scenario

locust --headless -f ./tests/test_plan_classroom.py --html ./reports/locust-report-classroom.html --users 1 --run-time 30s
locust --headless -f ./tests/test_plan_classroom.py --html ./reports/locust-report-classroom.html --users 10 --run-time 30s

## Tests under development

Expand Down
2 changes: 1 addition & 1 deletion manifests/base/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: locust
image: ghcr.io/scilifelabdatacentre/serve-load-testing:main-20240503-1109
image: ghcr.io/scilifelabdatacentre/serve-load-testing:main-20241004-0900
ports:
- containerPort: 8089
envFrom:
Expand Down
2 changes: 1 addition & 1 deletion manifests/built/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: locust
image: ghcr.io/scilifelabdatacentre/serve-load-testing:main-20240503-1109
image: ghcr.io/scilifelabdatacentre/serve-load-testing:main-20241004-0900
ports:
- containerPort: 8089
envFrom:
Expand Down
2 changes: 1 addition & 1 deletion manifests/overlays/production/patch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ spec:
selector:
app: locust
ports:
- protocol: TCP
- name: web
protocol: TCP
port: 80
targetPort: 8089
type: ClusterIP
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

[project]
name = "serve-load-testing"
version = "1.0.1"
version = "1.1.0"
description = "Load testing of the SciLifeLab Serve platform."
requires-python = "=3.12"
keywords = ["load testing", "locust", "python"]
Expand Down
8 changes: 6 additions & 2 deletions source/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# Use the Locust image as the base image
# It in turn is based on python:3.11-slim
FROM locustio/locust:2.31.8

WORKDIR /home/locust

# Install the locust-plugins dashboards plugin
RUN pip3 install --no-cache-dir locust-plugins[dashboards]==4.5.3
# Install the locust-plugins dashboards plugin etc
RUN pip3 install --no-cache-dir --upgrade pip==23.3.2 \
&& pip3 install --no-cache-dir lxml_html_clean==0.2.2 \
&& pip3 install --no-cache-dir requests-html==0.10.0 \
&& pip3 install --no-cache-dir locust-plugins[dashboards]==4.5.3

# Copy the Locust files into the container
COPY --chown=locust:locust locust-ui.conf locust.conf
Expand Down
1 change: 1 addition & 0 deletions source/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
locust>=2.31.8
lxml_html_clean>=0.2.2
requests-html>=0.10.0
279 changes: 279 additions & 0 deletions source/tests-dev/appcreator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import logging
import os
import time
import warnings

from locust import HttpUser, between, task
from lxml import etree
from requests_html import HTML, AsyncHTMLSession

# import asyncio
# from io import StringIO
# import requests

logger = logging.getLogger(__name__)

warnings.filterwarnings("ignore")

username = os.environ.get("SERVE_USERNAME")
password = os.environ.get("SERVE_PASS")


# Utility functions
def make_url(url: str):
"""Ensures that the URL contains exactly one trailing slash."""
return url.rstrip("/") + "/"


class CreatingUser(HttpUser):
"""Simulates an authenticated, power user that creates a project and app."""

# For now only supports one fixed user
fixed_count = 1

wait_time = between(2, 3)

project_url = "UNSET"

is_authenticated = False
task_has_run = False

def on_start(self):
logger.debug("on start")
self.client.verify = False # Don't check if certificate is valid
self.get_token()
self.login()

# Tasks

@task
def create_task(self):
if self.task_has_run is True:
logger.debug("Skipping create task for user %s. It has already been run.", username)
return

self.task_has_run = True

logger.info("executing create task")

# The user should already be logged in
if self.is_authenticated is False:
logger.warning(f"The user {self.username} is not authenticated but should be. Ending task.")
return

# Create a project
logger.info("Creating projects and apps as user %s", username)
project_name = "locust-appcreator-project-" + time.strftime("%Y%m%d-%H%M%S")
self.create_project(project_name)

# Open the project
logger.info("Opening project at URL %s", self.project_url)
self.client.get(self.project_url)

# Create an app
app_name = "locust-jupyterlab-app"
self._create_app(project_name, app_name)

# Successful POST is redirected to the projects page

def _create_app(self, project_name: str, app_name: str):
# Update the csrf token
app_create_url = self.project_url + "apps/create/jupyter-lab?from=overview"
logger.info(f"Using this URL to create a JL notebook app: {app_create_url}")
self.get_token(app_create_url)

# Fetch the web page content
# response = requests.get(app_create_url, data=dict(csrfmiddlewaretoken=self.csrftoken))
# response = requests.post(app_create_url, data=dict(csrfmiddlewaretoken=self.csrftoken))

# First make a dummy POST to the form to get the html and parse out the select option values
app_data = dict(csrfmiddlewaretoken=self.csrftoken)

html_content = ""
with self.client.post(
url=app_create_url,
data=app_data,
headers={"Referer": "foo"},
name="---CREATE-NEW-APP-JUPYTERLAB",
catch_response=True,
) as response:
logger.debug("create JupyterLab app response.status_code = %s, %s", response.status_code, response.reason)
html_content = response.content

# Parse the HTML content
parser = etree.HTMLParser()
tree = etree.fromstring(html_content, parser)

# Must first get the volume, flavor, and environment values from the form
volume = None
flavor = None
environment = None

# Extract the form values of the option elements using XPath
# Flavor: <select name="flavor" class="form-control" rows="3" id="id_flavor">
# <option value="28" selected>2 vCPU, 4 GB RAM</option></select>
el_volume = tree.xpath('//select[@name="volume"]/option')
el_flavor = tree.xpath('//select[@name="flavor"]/option')
el_environment = tree.xpath('//select[@name="environment"]/option')

if el_volume:
volume = el_volume[0].get("value")
else:
print("Option element VOLUME not found")

if el_flavor:
flavor = el_flavor[0].get("value")
else:
print("Option element FLAVOR not found")

if el_environment:
environment = el_environment[0].get("value")
else:
print("Option element ENVIRONMENT not found")

print(f"The parsed form values to use are: volume={volume}, flavor={flavor}, environment={environment}")

# To create the app, perform a POST submit to a URL with pattern:
# https://serve-dev.scilifelab.se/projects/locust-appcreator-project-20241007-145428-sib/apps/create/jupyter-lab?from=overview

# sesn = requests_html.HTMLSession(verify=False)
# resp = sesn.get(app_create_url)
# assert resp.status_code == 200

# Render JavaScript
# resp.html.render()

# Parse the HTML content
# html = HTML(html=html_raw)

# Find the option element and extract its value
# Downloads Chromium
# from https://storage.googleapis.com/chromium-browser-snapshots/Linux_x64/1181205/chrome-linux.zip
# asyncio.run(fetch_and_render(app_create_url))

app_data = dict(
name=app_name,
volume=volume,
access="project",
flavor=flavor,
environment=environment,
description="Project desc",
csrfmiddlewaretoken=self.csrftoken,
)

with self.client.post(
url=app_create_url,
data=app_data,
headers={"Referer": "foo"},
name="---CREATE-NEW-APP-JUPYTERLAB",
catch_response=True,
) as response:
logger.debug("create JupyterLab app response.status_code = %s, %s", response.status_code, response.reason)
# If succeeds then url = /projects/<project-name>/
logger.debug("create JupyterLab app response.url = %s", response.url)
if project_name in response.url and "create/jupyter-lab" not in response.url:
# The returned URL should NOT be back at the create app page
logger.info("Successfully created JupyterLab app %s", app_name)
else:
logger.warning(f"Create JupyterLab app failed. Response URL {response.url} does not indicate success.")
logger.debug(response.content)
response.failure("Create JupyterLab app failed. Response URL does not indicate success.")

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 = /projects/<project-name>/
logger.debug("create project response.url = %s", response.url)
if project_name in response.url:
logger.info("Successfully created project %s", project_name)
self.project_url = make_url(response.url)
else:
logger.warning(
f"Create project failed. Response URL {response.url} does not contain project name {project_name}"
)
# logger.debug(response.content)
response.failure("Create project failed. Response URL does not contain project name.")

def login(self):
logger.info("Login as user %s", username)

login_data = dict(username=username, password=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 {username} failed. Response URL does not contain /projects")

def logout(self):
if self.is_authenticated:
logger.debug("Logout user %s", username)
logout_data = dict(username=username, csrfmiddlewaretoken=self.csrftoken)
with self.client.post(
"/accounts/logout/",
data=logout_data,
headers={"Referer": "foo"},
name="---ON STOP---LOGOUT",
catch_response=True,
):
pass

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 on_stop(self):
logger.debug("on stop. exec logout.")
self.logout()


# This method is not completed and not used
async def fetch_and_render(url):
"""Fetches the html from a dynamic web page so that it can be parsed.
This however requires downloading of the Chromium browser."""

# Create an asynchronous session
session = AsyncHTMLSession()
response = await session.get(url)

# Render JavaScript
await response.html.arender()

# Parse the HTML content
html = response.html

# Find the option element and extract its value
option_element = html.find('select[name="flavor"] option[selected]', first=True)
if option_element:
option_value = option_element.attrs["value"]
print(f"Option value: {option_value}")
else:
print("Option element not found")

await session.close()
14 changes: 11 additions & 3 deletions source/tests-dev/authenticated.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,17 @@ def login(self):
response.failure(f"Login as user {username} failed. Response URL does not contain /projects")

def logout(self):
logger.debug("Log out user %s", username)
# logout_data = dict(username=username, csrfmiddlewaretoken=self.csrftoken)
self.client.get("/accounts/logout/", name="---ON STOP---LOGOUT")
if self.is_authenticated:
logger.debug("Logout user %s", self.username)
logout_data = dict(username=self.username, csrfmiddlewaretoken=self.csrftoken)
with self.client.post(
"/accounts/logout/",
data=logout_data,
headers={"Referer": "foo"},
name="---ON STOP---LOGOUT",
catch_response=True,
):
pass

@task
def browse_homepage(self):
Expand Down
Loading

0 comments on commit bc7c457

Please sign in to comment.