diff --git a/.gitignore b/.gitignore index bd74bb7e8..485a0a1c6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.o *.ko *.oo +*.pyc # Libraries *.lib @@ -90,3 +91,6 @@ deps/libconfig/libconfig-1.4.9/ #re2 deps/re2/re2/ + +test/.vagrant +.DS_Store \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..b6381fac9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +docker-compose==1.1.0 +MySQL-python==1.2.5 +nose==1.3.6 +requests==2.4.3 \ No newline at end of file diff --git a/scenarios/1backend/docker-compose.yml b/scenarios/1backend/docker-compose.yml new file mode 100644 index 000000000..52c3528e6 --- /dev/null +++ b/scenarios/1backend/docker-compose.yml @@ -0,0 +1,21 @@ +proxysql: + build: ../base/proxysql + links: + - backend1hostgroup0 + ports: + # ProxySQL admin port for MySQL commands + - "6032:6032" + # ProxySQL main port + - "6033:6033" + # gdbserver + - "2345:2345" + privileged: true + +backend1hostgroup0: + build: ./mysql + environment: + MYSQL_ROOT_PASSWORD: root + expose: + - "3306" + ports: + - "13306:3306" diff --git a/scenarios/1backend/mysql/Dockerfile b/scenarios/1backend/mysql/Dockerfile new file mode 100644 index 000000000..7600b1b01 --- /dev/null +++ b/scenarios/1backend/mysql/Dockerfile @@ -0,0 +1,7 @@ +# We are creating a custom Dockerfile for MySQL as there is no easy way to +# move a file from host into the container. In our case, it's schema.sql +# There is a proposed improvement to "docker cp" but it's still being +# discussed (https://github.com/docker/docker/issues/5846). +FROM mysql:latest +ADD ./schema.sql /tmp/ +ADD ./import_schema.sh /tmp/ \ No newline at end of file diff --git a/scenarios/1backend/mysql/import_schema.sh b/scenarios/1backend/mysql/import_schema.sh new file mode 100644 index 000000000..fe993ee7e --- /dev/null +++ b/scenarios/1backend/mysql/import_schema.sh @@ -0,0 +1 @@ +cat /tmp/schema.sql | mysql -h 127.0.0.1 -u root -proot \ No newline at end of file diff --git a/scenarios/1backend/mysql/schema.sql b/scenarios/1backend/mysql/schema.sql new file mode 100644 index 000000000..419a12708 --- /dev/null +++ b/scenarios/1backend/mysql/schema.sql @@ -0,0 +1,18 @@ +DROP DATABASE IF EXISTS test; +CREATE DATABASE test; + +CREATE USER john@'%' IDENTIFIED BY 'doe'; +CREATE USER danny@'%' IDENTIFIED BY 'white'; + +GRANT ALL PRIVILEGES ON test.* TO 'john'@'%'; +GRANT ALL PRIVILEGES ON test.* TO 'danny'@'%'; +FLUSH PRIVILEGES; + +USE test; + +CREATE TABLE strings(value LONGTEXT); + +INSERT INTO strings(value) VALUES('a'); +INSERT INTO strings(value) VALUES('ab'); +INSERT INTO strings(value) VALUES('abc'); +INSERT INTO strings(value) VALUES('abcd'); \ No newline at end of file diff --git a/scenarios/base/proxysql/Dockerfile b/scenarios/base/proxysql/Dockerfile new file mode 100644 index 000000000..748b17b61 --- /dev/null +++ b/scenarios/base/proxysql/Dockerfile @@ -0,0 +1,27 @@ +# We're using Ubuntu 14:04 because ProxySQL compilation needs one of the latest +# g++ compilers. Also, it's a long term release. +FROM ubuntu:14.04 +MAINTAINER Andrei Ismail +RUN apt-get update && apt-get install -y\ + automake\ + cmake\ + make\ + g++\ + gcc\ + gdb\ + gdbserver\ + git\ + libmysqlclient-dev\ + libssl-dev\ + libtool + +RUN cd /opt; git clone https://github.com/akopytov/sysbench.git +RUN cd /opt/sysbench; ./autogen.sh; ./configure --bindir=/usr/bin; make; make install + +RUN cd /opt; git clone https://github.com/sysown/proxysql-0.2.git proxysql +RUN cd /opt/proxysql; make clean && make +RUN mkdir -p /var/run/proxysql +ADD ./proxysql.cnf /etc/ + +WORKDIR /opt/proxysql/src +CMD ["gdbserver", "0.0.0.0:2345", "/opt/proxysql/src/proxysql", "--initial"] \ No newline at end of file diff --git a/scenarios/base/proxysql/proxysql.cnf b/scenarios/base/proxysql/proxysql.cnf new file mode 100644 index 000000000..75ab20289 --- /dev/null +++ b/scenarios/base/proxysql/proxysql.cnf @@ -0,0 +1,24 @@ +datadir="/tmp" + +admin_variables = +{ + admin_credentials="admin:admin" + mysql_ifaces="0.0.0.0:6032" + refresh_interval=2000 + debug=true +} + +mysql_users = +( + { + username = "root" + password = "root" + default_hostgroup = 0 + }, + + { + username = "john" + password = "doe" + default_hostgroup = 0 + } +) \ No newline at end of file diff --git a/src/gdb-commands.txt b/src/gdb-commands.txt new file mode 100644 index 000000000..ea4d2ac3c --- /dev/null +++ b/src/gdb-commands.txt @@ -0,0 +1,3 @@ +set pagination off +target remote 0.0.0.0:2345 +continue \ No newline at end of file diff --git a/test/Vagrantfile b/test/Vagrantfile new file mode 100644 index 000000000..c7c1c01bf --- /dev/null +++ b/test/Vagrantfile @@ -0,0 +1,85 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# All Vagrant configuration is done below. The "2" in Vagrant.configure +# configures the configuration version (we support older styles for +# backwards compatibility). Please don't change it unless you know what +# you're doing. +Vagrant.configure(2) do |config| + # The most common configuration options are documented and commented below. + # For a complete reference, please see the online documentation at + # https://docs.vagrantup.com. + + # Every Vagrant development environment requires a box. You can search for + # boxes at https://atlas.hashicorp.com/search. + config.vm.box = "ubuntu-14.04" + + # Disable automatic box update checking. If you disable this, then + # boxes will only be checked for updates when the user runs + # `vagrant box outdated`. This is not recommended. + # config.vm.box_check_update = false + + # Create a forwarded port mapping which allows access to a specific port + # within the machine from a port on the host machine. In the example below, + # accessing "localhost:8080" will access port 80 on the guest machine. + # config.vm.network "forwarded_port", guest: 80, host: 8080 + + # Create a private network, which allows host-only access to the machine + # using a specific IP. + # config.vm.network "private_network", ip: "192.168.33.10" + + # Create a public network, which generally matched to bridged network. + # Bridged networks make the machine appear as another physical device on + # your network. + # config.vm.network "public_network" + + # Share an additional folder to the guest VM. The first argument is + # the path on the host to the actual folder. The second argument is + # the path on the guest to mount the folder. And the optional third + # argument is a set of non-required options. + # config.vm.synced_folder "../data", "/vagrant_data" + + # Provider-specific configuration so you can fine-tune various + # backing providers for Vagrant. These expose provider-specific options. + # Example for VirtualBox: + # + # config.vm.provider "virtualbox" do |vb| + # # Display the VirtualBox GUI when booting the machine + # vb.gui = true + # + # # Customize the amount of memory on the VM: + # vb.memory = "1024" + # end + # + # View the documentation for the provider you are using for more + # information on available options. + + # Define a Vagrant Push strategy for pushing to Atlas. Other push strategies + # such as FTP and Heroku are also available. See the documentation at + # https://docs.vagrantup.com/v2/push/atlas.html for more information. + # config.push.define "atlas" do |push| + # push.app = "YOUR_ATLAS_USERNAME/YOUR_APPLICATION_NAME" + # end + + # Enable provisioning with a shell script. Additional provisioners such as + # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the + # documentation for more information about their specific syntax and use. + config.vm.provision "shell", inline: <<-SHELL + sudo apt-get update + sudo apt-get install -y libmysqlclient-dev python python-dev wget + sudo wget -qO- https://bootstrap.pypa.io/ez_setup.py | python + sudo easy_install -U pip + cd /opt + sudo git clone https://github.com/aismail/proxysql-0.2.git proxysql + cd proxysql + sudo git checkout docker-black-box-tests + sudo pip install -r requirements.txt + SHELL + + config.vm.provision "shell", run: "always", inline: <<-SHELL + cd /opt/proxysql + sudo git pull origin docker-black-box-tests + sudo pip install -r requirements.txt + SHELL + +end diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/admin_test.py b/test/admin_test.py new file mode 100644 index 000000000..bd985b8dd --- /dev/null +++ b/test/admin_test.py @@ -0,0 +1,9 @@ +from proxysql_base_test import ProxySQLBaseTest + +class AdminTest(ProxySQLBaseTest): + + DOCKER_COMPOSE_FILE = "./scenarios/1backend" + + def test_stop_main_thread(self): + # This test will just assert that PROXYSQL STOP works correctly + self.run_query_proxysql_admin("PROXYSQL STOP") \ No newline at end of file diff --git a/test/authentication_test.py b/test/authentication_test.py new file mode 100644 index 000000000..53394fbf7 --- /dev/null +++ b/test/authentication_test.py @@ -0,0 +1,43 @@ +import MySQLdb +from MySQLdb import OperationalError +from nose.tools import raises + +from proxysql_base_test import ProxySQLBaseTest + +class AuthenticationTest(ProxySQLBaseTest): + + DOCKER_COMPOSE_FILE = "./scenarios/1backend" + + def test_existing_user_with_correct_password_works(self): + version1 = self.run_query_mysql( + "SELECT @@version_comment LIMIT 1", "test", + return_result=True, + username="john", password="doe") + + version2 = self.run_query_proxysql( + "SELECT @@version_comment LIMIT 1", "test", + return_result=True, + username="john", password="doe") + + self.assertEqual(version1, version2) + + @raises(OperationalError) + def test_existing_user_with_correct_password_but_not_registerd_within_proxysql_does_not_work(self): + version1 = self.run_query_proxysql( + "SELECT @@version_comment LIMIT 1", "test", + return_result=True, + username="danny", password="white") + + @raises(OperationalError) + def test_existing_user_with_incorrect_password_does_not_work(self): + version = self.run_query_proxysql( + "SELECT @@version_comment LIMIT 1", "test", + return_result=True, + username="john", password="doe2") + + @raises(OperationalError) + def test_inexisting_user_with_random_password_does_not_work(self): + version = self.run_query_proxysql( + "SELECT @@version_comment LIMIT 1", "test", + return_result=True, + username="johnny", password="randomdoe") \ No newline at end of file diff --git a/test/how_to.md b/test/how_to.md new file mode 100644 index 000000000..aa8089faa --- /dev/null +++ b/test/how_to.md @@ -0,0 +1,98 @@ +# How are the tests built? + +First off, a few words about how the infrastructure of the tests looks like. + +Tests are written in Python, and the services needed for running a test +(a ProxySQL instance and one or more MySQL instances) are specified in a +docker-compose.yml file and are started by using Docker's docker-compose. + +Tests are ran using nosetests (https://nose.readthedocs.org/en/latest/), +Python's de facto leader in terms of how tests are written and ran. The command +to run the tests is, from the root of the repository: + +```python +nosetests --nocapture +``` + +The "--nocapture" flag is present in order to have detailed output on what is +going on. Otherwise, the output will be suppressed by nosetests to give you only +a high-level report of how many tests passed and failed. + +# Where can I find the tests? + +Tests are grouped in scenarios. A __scenario__ specifies a configuration of +ProxySQL and MySQL backends, together with initial data to populate the MySQL +instances (a text file containing SQL queries). + +The folder "scenarios" found in the root folder of the repository contains +these scenarios. There is a "base" folder with common utilities, and then there +is one folder for each scenario. For example, "1backend" is the name for the +scenario of 1 ProxySQL proxy, and 1 MySQL backend. + +To create such a scenario, the simplest way to go about, is to copy-paste the +"1backend" scenario and modify it. Some of the important things to modify: +- docker-compose.yml. This is where the list of services is described, and + where you actuall specify how many MySQL backends there are, and which ports + they expose and how. Be careful, there is a naming convention +- mysql/schema.sql. This is where the MySQL backends get populated + +# How do I write a test? + +It's pretty simple. Once you have a working scenario, you write a class in +the top-level "test" folder, which inherits from ProxySQLBaseTest. One such +example is one_backend_test.py. The only thing which you should specify is +the docker-compose filename, and then start querying both the proxy and the +MySQL backends and testing assertions by using the `run_query_proxysql` and +`run_query_mysql' class methods. + +# How do I run the tests locally? + +1) install vagrant on the machine where you'll be running the tests + +2) vagrant box add ubuntu-14.04 ubuntu-14.04.box +(The ubuntu-14.04.box file is obtained from https://github.com/jose-lpa/packer-ubuntu_14.04/releases/download/v2.0/ubuntu-14.04.box) + +# This will actually install what is needed on the Vagrant box +3) cd proxysql/test; vagrant up; vagrant ssh -c "cd /opt/proxysql; nosetests --nocapture"; vagrant halt + +# How do I run the tests on a machine without internet connectivity? + +For that you need to prepare a Virtual box .box file with the latest state of +the code and the packages from a machine that has internet connectivity and +copy it over to the machine with no connectivity. + +To prepare the .box file: + +1) clone proxysql test repo locally, let's assume it's in ~/proxysql + +2) cd ~/proxysql/test; vagrant up + +This will update the machine with the latest master code. If you need to be +testing a different branch, you will have to do an extra step (step 3): + +3) vagrant ssh -c "cd /opt/proxysql/test; git checkout my-branch; git pull origin my-branch; sudo pip install -r requirements.txt" + +This will fetch the code for the given branch __and__ install the necessary +packages for running the tests on that branch (if there are any new packages). + +4) Package it all in a .box file + +vagrant package --output proxysql-tests.box + +This will generate a big .box file, approximately 1.1GB as of the writing of +this document. This file can be run without having internet connectivity. + +5) Copy the proxysql-tests.box to the machine where you want to run the tests + +6) vagrant box add proxysql-tests proxysql-tests.box (from the directory where +you copied the .box file and where you are planning to run the tests) + +7) vagrant init proxysql-tests; vagrant up + +8) vagrant up; vagrant ssh -c "cd /opt/proxysql; nosetests --nocapture"; vagrant halt + +to actually run the tests. + +NB: we are assuming that the only useful output from running the tests is +stdout. As we will add more tests to the test suite, this section will be +refined on how to retrieve the results as well. diff --git a/test/one_backend_test.py b/test/one_backend_test.py new file mode 100644 index 000000000..1a3cae11e --- /dev/null +++ b/test/one_backend_test.py @@ -0,0 +1,13 @@ +import MySQLdb + +from proxysql_base_test import ProxySQLBaseTest + +class OneBackendTest(ProxySQLBaseTest): + + DOCKER_COMPOSE_FILE = "./scenarios/1backend" + + def test_select_strings_returns_correct_result(self): + + rows = self.run_query_proxysql("SELECT * FROM strings", "test") + self.assertEqual(set([row[0] for row in rows]), + set(['a', 'ab', 'abc', 'abcd'])) \ No newline at end of file diff --git a/test/proxysql_base_test.py b/test/proxysql_base_test.py new file mode 100644 index 000000000..4e0f7ece3 --- /dev/null +++ b/test/proxysql_base_test.py @@ -0,0 +1,416 @@ +import random +import re +import subprocess +import time +from unittest import TestCase + +from docker import Client +from docker.utils import kwargs_from_env +import MySQLdb + +from proxysql_ping_thread import ProxySQL_Ping_Thread + +class ProxySQLBaseTest(TestCase): + + DOCKER_COMPOSE_FILE = None + PROXYSQL_ADMIN_PORT = 6032 + PROXYSQL_ADMIN_USERNAME = "admin" + PROXYSQL_ADMIN_PASSWORD = "admin" + PROXYSQL_RW_PORT = 6033 + PROXYSQL_RW_USERNAME = "root" + PROXYSQL_RW_PASSWORD = "root" + # TODO(andrei): make it possible to set this to False, and make False + # the default value. + INTERACTIVE_TEST = True + + @classmethod + def _startup_docker_services(cls): + """Start up all the docker services necessary to start this test. + + They are specified in the docker compose file specified in the variable + cls.DOCKER_COMPOSE_FILE. + """ + + # We have to perform docker-compose build + docker-compose up, + # instead of just doing the latter because of a bug which will give a + # 500 internal error for the Docker bug. When this is fixed, we should + # remove this first extra step. + subprocess.call(["docker-compose", "build"], cwd=cls.DOCKER_COMPOSE_FILE) + subprocess.call(["docker-compose", "up", "-d"], cwd=cls.DOCKER_COMPOSE_FILE) + + @classmethod + def _shutdown_docker_services(cls): + """Shut down all the docker services necessary to start this test. + + They are specified in the docker compose file specified in the variable + cls.DOCKER_COMPOSE_FILE. + """ + + subprocess.call(["docker-compose", "stop"], cwd=cls.DOCKER_COMPOSE_FILE) + subprocess.call(["docker-compose", "rm", "--force"], cwd=cls.DOCKER_COMPOSE_FILE) + + @classmethod + def _get_proxysql_container(cls): + """Out of all the started docker containers, select the one which + represents the proxy instance. + + Note that this only supports one proxy instance for now. This method + relies on interogating the Docker daemon via its REST API. + """ + + containers = Client(**kwargs_from_env()).containers() + for container in containers: + if 'proxysql' in container['Image']: + return container + + @classmethod + def _get_mysql_containers(cls): + """Out of all the started docker containers, select the ones which + represent the MySQL backend instances. + + This method relies on interogating the Docker daemon via its REST API. + """ + + result = [] + containers = Client(**kwargs_from_env()).containers() + for container in containers: + if 'proxysql' not in container['Image']: + result.append(container) + return result + + @classmethod + def _populate_mysql_containers_with_dump(cls): + """Populates the started MySQL backend containers with the specified + SQL dump file. + + The reason for doing this __after__ the containers are started is + because we want to keep them as generic as possible. + """ + + mysql_containers = cls._get_mysql_containers() + # We have already added the SQL dump to the container by using + # the ADD mysql command in the Dockerfile for mysql -- check it + # out. The standard agreed location is at /tmp/schema.sql. + # + # Unfortunately we can't do this step at runtime due to limitations + # on how transfer between host and container is supposed to work by + # design. See the Dockerfile for MySQL for more details. + for mysql_container in mysql_containers: + container_id = mysql_container['Names'][0][1:] + subprocess.call(["docker", "exec", container_id, "bash", "/tmp/import_schema.sh"]) + + @classmethod + def _extract_hostgroup_from_container_name(cls, container_name): + """MySQL backend containers are named using a naming convention: + backendXhostgroupY, where X and Y can be multi-digit numbers. + This extracts the value of the hostgroup from the container name. + + I made this choice because I wasn't able to find another easy way to + associate arbitrary metadata with a Docker container through the + docker compose file. + """ + + service_name = container_name.split('_')[1] + return int(re.search(r'BACKEND(\d+)HOSTGROUP(\d+)', service_name).group(2)) + + @classmethod + def _extract_port_number_from_uri(cls, uri): + """Given a Docker container URI (exposed as an environment variable by + the host linking mechanism), extract the TCP port number from it.""" + return int(uri.split(':')[2]) + + @classmethod + def _get_environment_variables_from_container(cls, container_name): + """Retrieve the environment variables from the given container. + + This is useful because the host linking mechanism will expose + connectivity information to the linked hosts by the use of environment + variables. + """ + + output = Client(**kwargs_from_env()).execute(container_name, 'env') + result = {} + lines = output.split('\n') + for line in lines: + line = line.strip() + if len(line) == 0: + continue + (k, v) = line.split('=') + result[k] = v + return result + + @classmethod + def _populate_proxy_configuration_with_backends(cls): + """Populate ProxySQL's admin information with the MySQL backends + and their associated hostgroups. + + This is needed because I do not want to hardcode this into the ProxySQL + config file of the test scenario, as it leaves more room for quick + iteration. + + In order to configure ProxySQL with the correct backends, we are using + the MySQL admin interface of ProxySQL, and inserting rows into the + `mysql_servers` table, which contains a list of which servers go into + which hostgroup. + """ + proxysql_container = cls._get_proxysql_container() + mysql_containers = cls._get_mysql_containers() + environment_variables = cls._get_environment_variables_from_container( + proxysql_container['Names'][0][1:]) + + proxy_admin_connection = MySQLdb.connect("127.0.0.1", + cls.PROXYSQL_ADMIN_USERNAME, + cls.PROXYSQL_ADMIN_PASSWORD, + port=cls.PROXYSQL_ADMIN_PORT) + cursor = proxy_admin_connection.cursor() + + for mysql_container in mysql_containers: + container_name = mysql_container['Names'][0][1:].upper() + port_uri = environment_variables['%s_PORT' % container_name] + port_no = cls._extract_port_number_from_uri(port_uri) + ip = environment_variables['%s_PORT_%d_TCP_ADDR' % (container_name, port_no)] + hostgroup = cls._extract_hostgroup_from_container_name(container_name) + cursor.execute("INSERT INTO mysql_servers(hostgroup_id, hostname, port, status) " + "VALUES(%d, '%s', %d, 'ONLINE')" % + (hostgroup, ip, port_no)) + + cursor.execute("LOAD MYSQL SERVERS TO RUNTIME") + cursor.close() + proxy_admin_connection.close() + + @classmethod + def setUpClass(cls): + # Always shutdown docker services because the previous test might have + # left them in limbo. + cls._shutdown_docker_services() + + cls._startup_docker_services() + + if cls.INTERACTIVE_TEST: + cls._compile_host_proxysql() + cls._connect_gdb_to_proxysql_within_container() + + # Sleep for 30 seconds because we want to populate the MySQL containers + # with SQL dumps, but there is a race condition because we do not know + # when the MySQL daemons inside them have actually started or not. + # TODO(andrei): find a better solution + time.sleep(30) + + cls._populate_mysql_containers_with_dump() + cls._populate_proxy_configuration_with_backends() + cls._start_proxysql_pings() + + @classmethod + def tearDownClass(cls): + if cls.INTERACTIVE_TEST: + cls._gdb_process.wait() + # It's essential that pings are stopped __after__ the gdb process has + # finished. This allows them to keep pinging ProxySQL in the background + # while it's stuck waiting for user interaction (user interaction needed + # in order to debug the problem causing it to crash). + cls._stop_proxysql_pings() + cls._shutdown_docker_services() + + def run_query_proxysql(self, query, db, return_result=True, + username=None, password=None, port=None): + """Run a query against the ProxySQL proxy and optionally return its + results as a set of rows.""" + username = username or ProxySQLBaseTest.PROXYSQL_RW_USERNAME + password = password or ProxySQLBaseTest.PROXYSQL_RW_PASSWORD + port = port or ProxySQLBaseTest.PROXYSQL_RW_PORT + proxy_connection = MySQLdb.connect("127.0.0.1", + username, + password, + port=port, + db=db) + cursor = proxy_connection.cursor() + cursor.execute(query) + if return_result: + rows = cursor.fetchall() + cursor.close() + proxy_connection.close() + if return_result: + return rows + + def run_query_proxysql_admin(self, query, return_result=True): + """Run a query against the ProxySQL admin. + + Note: we do not need to specify a db for this query, as it's always + against the "main" database. + TODO(andrei): revisit db assumption once stats databases from ProxySQL + are accessible via the MySQL interface. + """ + + return self.run_query_proxysql( + query, + # "main" database is hardcoded within the + # ProxySQL admin -- it contains the SQLite3 + # tables with metadata about servers and users + "main", + return_result, + username=ProxySQLBaseTest.PROXYSQL_ADMIN_USERNAME, + password=ProxySQLBaseTest.PROXYSQL_ADMIN_PASSWORD, + port=ProxySQLBaseTest.PROXYSQL_ADMIN_PORT + ) + + + def run_query_mysql(self, query, db, return_result=True, hostgroup=0, + username=None, password=None): + """Run a query against the MySQL backend and optionally return its + results as a set of rows. + + IMPORTANT: since the queries are actually ran against the MySQL backend, + that backend needs to expose its MySQL port to the outside through + docker compose's port mapping mechanism. + + This will actually parse the docker-compose configuration file to + retrieve the available backends and hostgroups and will pick a backend + from the specified hostgroup.""" + + # Figure out which are the containers for the specified hostgroup + mysql_backends = ProxySQLBaseTest._get_mysql_containers() + mysql_backends_in_hostgroup = [] + for backend in mysql_backends: + container_name = backend['Names'][0][1:].upper() + backend_hostgroup = ProxySQLBaseTest._extract_hostgroup_from_container_name(container_name) + + mysql_port_exposed=False + if not backend.get('Ports'): + continue + for exposed_port in backend.get('Ports', []): + if exposed_port['PrivatePort'] == 3306: + mysql_port_exposed = True + + if backend_hostgroup == hostgroup and mysql_port_exposed: + mysql_backends_in_hostgroup.append(backend) + + if len(mysql_backends_in_hostgroup) == 0: + raise Exception('No backends with a publicly exposed port were ' + 'found in hostgroup %d' % hostgroup) + + # Pick a random container, extract its connection details + container = random.choice(mysql_backends_in_hostgroup) + for exposed_port in container.get('Ports', []): + if exposed_port['PrivatePort'] == 3306: + mysql_port = exposed_port['PublicPort'] + + username = username or ProxySQLBaseTest.PROXYSQL_RW_USERNAME + password = password or ProxySQLBaseTest.PROXYSQL_RW_PASSWORD + mysql_connection = MySQLdb.connect("127.0.0.1", + username, + password, + port=mysql_port, + db=db) + cursor = mysql_connection.cursor() + cursor.execute(query) + if return_result: + rows = cursor.fetchall() + cursor.close() + mysql_connection.close() + if return_result: + return rows + + def run_sysbench_proxysql(self, threads=4, time=60, db="test", + username=None, password=None, port=None): + """Runs a sysbench test with the given parameters against the given + ProxySQL instance. + + In this case, due to better encapsulation and reduced latency to + ProxySQL, we are assuming that sysbench is installed on the same + container with it. + """ + + proxysql_container_id = ProxySQLBaseTest._get_proxysql_container()['Id'] + username = username or ProxySQLBaseTest.PROXYSQL_RW_USERNAME + password = password or ProxySQLBaseTest.PROXYSQL_RW_PASSWORD + port = port or ProxySQLBaseTest.PROXYSQL_RW_PORT + + params = [ + "sysbench", + "--test=/opt/sysbench/sysbench/tests/db/oltp.lua", + "--num-threads=%d" % threads, + "--max-requests=0", + "--max-time=%d" % time, + "--mysql-user=%s" % username, + "--mysql-password=%s" % password, + "--mysql-db=%s" % db, + "--db-driver=mysql", + "--oltp-tables-count=4", + "--oltp-read-only=on", + "--oltp-skip-trx=on", + "--report-interval=1", + "--oltp-point-selects=100", + "--oltp-table-size=400000", + "--mysql-host=127.0.0.1", + "--mysql-port=%s" % port + ] + + ProxySQLBaseTest.run_bash_command_within_proxysql(params + ["prepare"]) + ProxySQLBaseTest.run_bash_command_within_proxysql(params + ["run"]) + ProxySQLBaseTest.run_bash_command_within_proxysql(params + ["cleanup"]) + + @classmethod + def run_bash_command_within_proxysql(cls, params): + """Run a bash command given as an array of tokens within the ProxySQL + container. + + This is useful in a lot of scenarios: + - running sysbench against the ProxySQL instance + - getting environment variables from the ProxySQL container + - running various debugging commands against the ProxySQL instance + """ + + proxysql_container_id = ProxySQLBaseTest._get_proxysql_container()['Id'] + exec_params = ["docker", "exec", proxysql_container_id] + params + subprocess.call(exec_params) + + @classmethod + def _compile_host_proxysql(cls): + """Compile ProxySQL on the Docker host from which we're running the + tests. + + This is used for remote debugging, because that's how the + gdb + gdbserver pair works: + - local gdb with access to the binary with debug symbols + - remote gdbserver which wraps the remote binary so that it can be + debugged when it crashes. + """ + subprocess.call(["make", "clean"]) + subprocess.call(["make"]) + + @classmethod + def _connect_gdb_to_proxysql_within_container(cls): + """Connect a local gdb running on the docker host to the remote + ProxySQL binary for remote debugging. + + This is useful in interactive mode, where we want to stop at a failing + test and prompt the developer to debug the failing instance. + + Note: gdb is ran in a separate process because otherwise it will block + the test running process, and it will not be able to run queries anymore + and make assertions. However, we save the process handle so that we can + shut down the process later on. + """ + + cls._gdb_process = subprocess.Popen(["gdb", "--command=gdb-commands.txt", + "./proxysql"], + cwd="./src") + + @classmethod + def _start_proxysql_pings(cls): + """During the running of the tests, the test suite will continuously + monitor the ProxySQL daemon in order to check that it's up. + + This special thread will do exactly that.""" + + cls.ping_thread = ProxySQL_Ping_Thread(username=cls.PROXYSQL_RW_USERNAME, + password=cls.PROXYSQL_RW_PASSWORD, + hostname="127.0.0.1", + port=cls.PROXYSQL_RW_PORT) + cls.ping_thread.start() + + @classmethod + def _stop_proxysql_pings(cls): + """Stop the special thread which pings the ProxySQL daemon.""" + cls.ping_thread.stop() + cls.ping_thread.join() \ No newline at end of file diff --git a/test/proxysql_ping_thread.py b/test/proxysql_ping_thread.py new file mode 100644 index 000000000..8a09f40a6 --- /dev/null +++ b/test/proxysql_ping_thread.py @@ -0,0 +1,88 @@ +from email.mime.text import MIMEText +import smtplib +from threading import Thread +import time + +import MySQLdb + +class ProxySQL_Ping_Thread(Thread): + """ProxySQL_Ping_Thread's purpose is to do a continuous health check of the + ProxySQL daemon when tests are running against it. When it has crashed + or it's simply not responding anymore, it will send an e-mail to draw the + attention of the developer so that he or she will examine the situation. + + This is because the test suite is designed to be long running and we want + to find out as quickly as possible when the tests ran into trouble without + continuously keeping an eye on the tests. + """ + + FAILED_CONNECTIONS_BEFORE_ALERT = 3 + + def __init__(self, username, password, + hostname="127.0.0.1", port=6033, db="test", + ping_command="SELECT @@version_comment LIMIT 1", + interval=60, **kwargs): + self.username = username + self.password = password + self.hostname = hostname + self.port = port + self.db = db + self.ping_command = ping_command + self.interval = interval + self.running = False + self.failed_connections = 0 + super(ProxySQL_Ping_Thread, self).__init__(**kwargs) + + def run(self): + self.running = True + + while self.running: + time.sleep(self.interval) + + if not self.running: + return + + try: + connection = MySQLdb.connect(self.hostname, + self.username, + self.password, + port=self.port, + db=self.db, + connect_timeout=30) + cursor = connection.cursor() + cursor.execute(self.ping_command) + rows = cursor.fetchall() + cursor.close() + connection.close() + print("ProxySQL server @ %s:%d responded to query %s with %r" % + (self.hostname, self.port, self.ping_command, rows)) + self.failed_connections = 0 + except: + self.failed_connections = self.failed_connections + 1 + if self.failed_connections >= ProxySQL_Ping_Thread.FAILED_CONNECTIONS_BEFORE_ALERT: + self.send_error_email() + self.running = False + return + + def stop(self): + self.running = False + + def send_error_email(self): + msg = MIMEText("ProxySQL daemon stopped responding during tests.\n" + "Please check if it has crashed and you have been left with a gdb console on!") + + # me == the sender's email address + # you == the recipient's email address + msg['Subject'] = 'Daemon has stopped responding' + msg['From'] = 'ProxySQL Tests ' + msg['To'] = 'Andrei-Adnan Ismail ' + + # Send the message via our own SMTP server, but don't include the + # envelope header. + s = smtplib.SMTP('smtp.gmail.com', 587) + s.ehlo() + s.starttls() + s.login('proxysql.tests', 'pr0xysql') + s.sendmail('proxysql.tests@gmail.com', ['iandrei@gmail.com'], msg.as_string()) + s.quit() + diff --git a/test/sysbench_test.py b/test/sysbench_test.py new file mode 100644 index 000000000..a6c883e03 --- /dev/null +++ b/test/sysbench_test.py @@ -0,0 +1,8 @@ +from proxysql_base_test import ProxySQLBaseTest + +class SysBenchTest(ProxySQLBaseTest): + + DOCKER_COMPOSE_FILE = "./scenarios/1backend" + + def test_proxy_doesnt_crash_under_mild_sysbench_load(self): + self.run_sysbench_proxysql() \ No newline at end of file