diff --git a/.circleci/config.yml b/.circleci/config.yml index 79033e304..2c0b7f163 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -40,6 +40,17 @@ jobs: - run: command: cargo audit + load-test-checks: + docker: + - image: cimg/python:3.11 + working_directory: "~/autopush-rs" + steps: + - checkout: + path: ~/autopush-rs/ + - run: + name: isort, black, flake8 and mypy + command: make lint + test: docker: - image: circleci/python:3.10 @@ -212,6 +223,10 @@ workflows: filters: tags: only: /.*/ + - load-test-checks: + filters: + tags: + only: /.*/ - test: filters: tags: diff --git a/Makefile b/Makefile index 4e32d0d75..57dbc9e96 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,11 @@ SHELL := /bin/sh CARGO = cargo +LOAD_TEST_DIR := tests/load +POETRY := poetry --directory $(LOAD_TEST_DIR) +PYPROJECT_TOML := $(LOAD_TEST_DIR)/pyproject.toml +FLAKE8_CONFIG := $(LOAD_TEST_DIR)/.flake8 +STAGE_SERVER_URL := "wss://autopush.stage.mozaws.net" +STAGE_ENDPOINT_URL := "https://updates-autopush.stage.mozaws.net" .PHONY: ddb @@ -12,3 +18,25 @@ upgrade: echo "\n$(CARGO) install cargo-edit failed, continuing.." $(CARGO) upgrade $(CARGO) update + +lint: + $(POETRY) -V + $(POETRY) install + $(POETRY) run isort --sp $(PYPROJECT_TOML) -c $(LOAD_TEST_DIR) + $(POETRY) run black --quiet --diff --config $(PYPROJECT_TOML) --check $(LOAD_TEST_DIR) + $(POETRY) run flake8 --config $(FLAKE8_CONFIG) $(LOAD_TEST_DIR) + $(POETRY) run mypy $(LOAD_TEST_DIR) --config-file=$(PYPROJECT_TOML) + +load: + SERVER_URL=$(STAGE_SERVER_URL) ENDPOINT_URL=$(STAGE_ENDPOINT_URL) \ + docker-compose \ + -f $(LOAD_TEST_DIR)/docker-compose.yml \ + -p autopush-rs-load-tests \ + up --scale locust_worker=1 + +load-clean: + docker-compose \ + -f $(LOAD_TEST_DIR)/docker-compose.yml \ + -p autopush-rs-load-tests \ + down + docker rmi locust diff --git a/tests/load/Dockerfile b/tests/load/Dockerfile index 65b17234f..9bd8a9eda 100644 --- a/tests/load/Dockerfile +++ b/tests/load/Dockerfile @@ -20,14 +20,12 @@ ENV PATH="${PYTHON_VENV}/bin:${PATH}" RUN python -m pip install --upgrade pip -# Setup poetry and extract requirements +# Setup poetry and install requirements +ENV POETRY_VIRTUALENVS_CREATE=false \ + POETRY_VERSION=1.5.1 RUN python -m pip install --no-cache-dir --quiet poetry -WORKDIR /tmp -COPY pyproject.toml poetry.lock /tmp/ -RUN poetry export --no-interaction --output requirements.txt --without-hashes - -WORKDIR / -RUN python -m pip install -r /tmp/requirements.txt +COPY pyproject.toml poetry.lock ./ +RUN poetry install --without dev --no-interaction --no-ansi RUN useradd --create-home locust WORKDIR /home/locust diff --git a/tests/load/README.md b/tests/load/README.md index d123a5c0b..dc223d4f1 100644 --- a/tests/load/README.md +++ b/tests/load/README.md @@ -14,42 +14,39 @@ To install the dependencies execute: poetry install ``` -Contributors to this project are expected to execute the following tools for import -sorting, linting, style guide enforcement and static type checking. +Contributors to this project are expected to execute isort, black, flake8 and mypy for +import sorting, linting, style guide enforcement and static type checking respectively. Configurations are set in the `pyproject.toml` and `.flake8` files. -**[isort][4]** - ```shell -poetry run isort locustfile.py - ``` +The tools can be executed with the following command from the root directory: -**[black][5]** - ```shell -poetry run black locustfile.py - ``` +```shell +make lint +``` -**[flake8][6]** - ```shell -poetry run flake8 locustfile.py - ``` +## Local Execution +Follow the steps bellow to execute the load tests locally: -## Local Execution +### Setup Environment -There are 3 docker-compose files pertaining to the different environments autopush can run on: -`docker-compose.prod.yml` -`docker-compose.stage.yml` -`docker-compose.dev.yml` +#### 1. Configure Environment Variables -You can select which environment you want to run against by choosing the appropriate file. The environment will be setup for you with the correct URLs. +Environment variables, listed bellow or specified by [Locust][8], can be set in +`tests\load\docker-compose.yml`. -Ex: -```shell -docker-compose -f docker-compose.stage.yml up --build --scale locust_worker=1 -``` +| Environment Variable | Node(s) | Description | +|----------------------|------------------|---------------------------------| +| SERVER_URL | master & worker | The autopush web socket address | +| ENDPOINT_URL | master & worker | The autopush HTTP address | -This will run build and start the locust session for the Stage environment. +#### 2. Host Locust via Docker +Execute the following from the root directory: + +```shell +make load +``` ### Run Test Session @@ -72,10 +69,10 @@ the load test will stop automatically. #### 1. Remove Load Test Docker Containers -Execute the following from the `load` directory: +Execute the following from the root directory: + ```shell -docker-compose -f docker-compose.stage.yml down -docker rmi locust +make load-clean ``` [1]: https://python-poetry.org/docs/#installation @@ -83,4 +80,6 @@ docker rmi locust [3]: https://github.com/pyenv/pyenv-virtualenv#installation [4]: https://pycqa.github.io/isort/ [5]: https://black.readthedocs.io/en/stable/ -[6]: https://flake8.pycqa.org/en/latest/ \ No newline at end of file +[6]: https://flake8.pycqa.org/en/latest/ +[7]: https://mypy-lang.org/ +[8]: https://docs.locust.io/en/stable/configuration.html#environment-variables \ No newline at end of file diff --git a/tests/load/locustfile.py b/tests/load/locustfile.py index 65146d6c3..bd4bc4d26 100644 --- a/tests/load/locustfile.py +++ b/tests/load/locustfile.py @@ -1,8 +1,8 @@ import base64 import json import random -import time import string +import time import uuid from contextlib import closing from urllib.parse import urljoin, urlparse @@ -23,6 +23,7 @@ """ + @events.init_command_line_parser.add_listener def _(parser): parser.add_argument( @@ -42,7 +43,7 @@ def _(parser): class ConnectionTaskSet(TaskSet): - """ Create a fake "encrypted" message. + """Create a fake "encrypted" message. The server doesn't care about encryption. It does, however, apply a base64 encoding to the data (this is because it's possible to send pure @@ -51,6 +52,7 @@ class ConnectionTaskSet(TaskSet): padding. The max size we allow for a push message is 4K. """ + encrypted_data = base64.urlsafe_b64decode( "TestData" + "".join( @@ -65,11 +67,12 @@ class ConnectionTaskSet(TaskSet): @task def test_basic(self): - """ Perform a "basic" transaction test. + """Perform a "basic" transaction test. Desktop Autopush clients use a websocket connection to exchange JSON command and response messages. (See - [Autopush HTTP Endpoints for Notifications](https://mozilla-services.github.io/autopush-rs/http.html#push-service-http-api) + [Autopush HTTP Endpoints for Notifications] + (https://mozilla-services.github.io/autopush-rs/http.html#push-service-http-api) for details). This tests an "active" style connection @@ -83,12 +86,11 @@ def test_basic(self): # Create a connection to the Autoconnect server with closing( create_connection( - self.user.environment.parsed_options.websocket_url, - header={"Origin": "http://localhost:1337"}, - ssl=False, + self.user.environment.parsed_options.websocket_url, + header={"Origin": "http://localhost:1337"}, + ssl=False, ) ) as ws: - # Connections must say hello after connecting to the server, otherwise # the connection is quickly dropped. body = json.dumps(dict(messageType="hello", use_webpush=True)) @@ -138,7 +140,9 @@ def test_basic(self): # Send an "ack" message to make the server delete the message # Otherwise we would get the message re-sent to us on reconnect - ws.send(json.dumps(dict(messageType="ack", updates=dict(channelID=channel_id)))) + ws.send( + json.dumps(dict(messageType="ack", updates=dict(channelID=channel_id))) + ) self.user.environment.events.request.fire( request_type="WSS", @@ -151,7 +155,7 @@ def test_basic(self): @task def test_basic_topic(self): - """ Test a basic message transaction using a "topic". + """Test a basic message transaction using a "topic". "Topic" messages will replace prior, queued instances. A topic can be any UA defined, URL Safe base64 compliant string. Upon reconnection, @@ -167,11 +171,11 @@ def test_basic_topic(self): channel_id = str(uuid.uuid4()) # Create a connection to the Autoconnect server. - with closing ( + with closing( create_connection( - self.user.environment.parsed_options.websocket_url, - header={"Origin": "http://localhost:1337"}, - ssl=False, + self.user.environment.parsed_options.websocket_url, + header={"Origin": "http://localhost:1337"}, + ssl=False, ) ) as ws: # Connections must say hello after connecting to the server, otherwise @@ -241,7 +245,6 @@ def test_basic_topic(self): timeout=60, ) ) as ws: - start_time = time.time() # After we reconnect and say "Hello", we should start getting # any pending messages. @@ -351,12 +354,11 @@ def test_connect_stored(self): # Connect and register to get a unique endpoint. with closing( create_connection( - self.user.environment.parsed_options.websocket_url, - header={"Origin": "http://localhost:1337"}, - ssl=False, + self.user.environment.parsed_options.websocket_url, + header={"Origin": "http://localhost:1337"}, + ssl=False, ) ) as ws: - body = json.dumps(dict(messageType="hello", use_webpush=True)) ws.send(body) res = json.loads(ws.recv()) @@ -411,10 +413,10 @@ def test_connect_stored(self): try: with closing( create_connection( - self.user.environment.parsed_options.websocket_url, - header={"Origin": "http://localhost:1337"}, - ssl=False, - timeout=30, + self.user.environment.parsed_options.websocket_url, + header={"Origin": "http://localhost:1337"}, + ssl=False, + timeout=30, ) ) as ws: start_time = time.time() @@ -439,7 +441,7 @@ def test_connect_stored(self): finally: self.user.environment.events.request.fire( request_type="WSS", - name=f"WEBSOCKET test_connect_stored", + name="WEBSOCKET test_connect_stored", response_time=int((end_time - start_time) * 1000), response_length=len(res), exception=exception, @@ -504,7 +506,6 @@ def test_connect_forever(self): # NOTE: Not sure why we're specifying a Topic here, but sure...? self.headers.update({"Topic": "zyxw"}) while True: - # NOTE: This feels odd. # We send a notification to the client, but then immediately # drop the websocket connection. There's a small chance @@ -544,12 +545,12 @@ def test_connect_forever(self): ws.close() break - ## Hold a notification + # Hold a notification @task def test_notification_forever_unsubscribed(self): """ - Create an "active" connection, that we immediately turn "passive", then hold open for a period of time. - + Create an "active" connection, that we immediately turn "passive", then hold + open for a period of time. """ # A Channel ID is how the client User Agent differentiates between various @@ -564,7 +565,6 @@ def test_notification_forever_unsubscribed(self): ssl=False, ) ) as ws: - # Connections must say hello after connecting to the server, otherwise # the connection is quickly dropped. body = json.dumps(dict(messageType="hello", use_webpush=True)) @@ -582,7 +582,6 @@ def test_notification_forever_unsubscribed(self): body = json.dumps(dict(messageType="unregister", channelID=channel_id)) ws.send(body) while True: - # Send a Ping message with arbitrary text. This should result # in a Broadcast message response. ws.ping("hello") diff --git a/tests/load/pyproject.toml b/tests/load/pyproject.toml index ce7830120..d0312d3c3 100644 --- a/tests/load/pyproject.toml +++ b/tests/load/pyproject.toml @@ -38,5 +38,5 @@ flake8 = "^6.0.0" mypy = "^1.2.0" [build-system] -requires = ["poetry-core"] +requires = ["poetry-core>=1.5.1"] build-backend = "poetry.core.masonry.api" \ No newline at end of file