diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..592c34ed3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +nose==1.3.6 +docker-compose==1.1.0 +requests==2.4.3 +MySQL-python==1.2.5 \ 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..7a3bb3607 --- /dev/null +++ b/scenarios/1backend/docker-compose.yml @@ -0,0 +1,16 @@ +proxysql: + build: ../base/proxysql + links: + - backend1hostgroup0 + ports: + - "6032:6032" + - "6033:6033" + +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..19785d9af --- /dev/null +++ b/scenarios/1backend/mysql/schema.sql @@ -0,0 +1,12 @@ +DROP DATABASE IF EXISTS test; + +CREATE DATABASE test; + +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/docker/Dockerfile b/scenarios/base/proxysql/Dockerfile similarity index 72% rename from docker/Dockerfile rename to scenarios/base/proxysql/Dockerfile index 0d2935ebb..c4514f6ee 100644 --- a/docker/Dockerfile +++ b/scenarios/base/proxysql/Dockerfile @@ -11,7 +11,10 @@ RUN apt-get update && apt-get install -y\ libssl-dev\ libmysqlclient-dev -RUN cd /opt; git clone https://github.com/sysown/proxysql-0.2.git -RUN cd /opt/proxysql-0.2; make clean && make +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 -RUN cp /opt/proxysql-0.2/proxysql.cfg /etc \ No newline at end of file +ADD ./proxysql.cnf /etc/ + +WORKDIR /opt/proxysql/src +CMD ["/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..b50345d01 --- /dev/null +++ b/scenarios/base/proxysql/proxysql.cnf @@ -0,0 +1,18 @@ +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 + } +) \ No newline at end of file diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/how_to.md b/test/how_to.md new file mode 100644 index 000000000..1af9899f8 --- /dev/null +++ b/test/how_to.md @@ -0,0 +1,46 @@ +# 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. \ No newline at end of file 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..4332d3a72 --- /dev/null +++ b/test/proxysql_base_test.py @@ -0,0 +1,270 @@ +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 + +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" + + @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() + + # 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() + + @classmethod + def tearDownClass(cls): + cls._shutdown_docker_services() + + def run_query_proxysql(self, query, db, return_result=True): + """Run a query against the ProxySQL proxy and optionally return its + results as a set of rows.""" + proxy_connection = MySQLdb.connect("127.0.0.1", + ProxySQLBaseTest.PROXYSQL_RW_USERNAME, + ProxySQLBaseTest.PROXYSQL_RW_PASSWORD, + port=ProxySQLBaseTest.PROXYSQL_RW_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_mysql(self, query, db, return_result=True, hostgroup=0): + """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'] + + mysql_connection = MySQLdb.connect("127.0.0.1", + # Warning: this assumes that ProxySQL + # and all the backends have the same + # credentials. + # TODO(andrei): revisit this assumption + # in authentication tests. + ProxySQLBaseTest.PROXYSQL_RW_USERNAME, + ProxySQLBaseTest.PROXYSQL_RW_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 \ No newline at end of file