adelton

Running Django tests in container

Jan Pazdziora

2016-05-22


Abstract

Running Django tests is possible without installing additional package on the workstation, by using containers. We explore steps that lead to working setup, to make it easy to use the approach on different projects as well.

Running Django tests

Working on multiple open-source projects written in multiple programming languages, frameworks, and environments, I often face the requirement to install additional packages to test changes I might be doing to my local repository checkout. Since I try to minimize the amount of software installed on my workstation, I would have to start a virtual machine (VM), install the required bits there, and solve the mounting of my working tree into the VM.

Lately, I use containers for that task and the process got easier and faster. Let's look at how test execution can be isolated into container-based environment. If you are not interested in the thought process, just find the final Dockerfile at the end.

For the Django project, the guidance for running tests includes

$ cd django-repo/tests
$ PYTHONPATH=..:$PYTHONPATH ./runtests.py

Executing these commands on my system leads to

Please install test dependencies first: 
$ pip install -r requirements/py2.txt

and some people including me are not inclined to do that. But we'll be happy to meet those requirements in separate chroot, and containers are really just chroots with additional features.

Access to working tree from container

We want to minimize the access we give to the processes executing the tests, so we will run the processes as user nobody:

$ docker run -u nobody ...

For that to work, all the parent directories of the Django checkout directory need to be at least accessible by other since nobody usually is not member of any useful groups — we look for drwx--x--x in ls -l output.

However, attempt to run a quick check fails:

$ docker run -u nobody -v $PWD:/django:ro -ti fedora cat /django/tests/requirements/py2.txt
cat: /django/tests/requirements/py2.txt: Permission denied

My host is Fedora and my docker daemon is SELinux enabled — there is

OPTIONS='--selinux-enabled --icc=false'

in /etc/sysconfig/docker. By default each container gets SELinux type svirt_lxc_net_t and unique MLS values. The ps axZ might show for example

system_u:system_r:svirt_lxc_net_t:s0:c119,c856

for processes running in container, where the c119,c856 part would be different for every container to isolate them frome ach other. And type svirt_lxc_net_t is not allowed to access content in user home directories, SELinux type user_home_t.

One solution is to change the SELinux label of the project git working tree to a context that containerized applications would be able to access, for example tmp_t. Note that plain

$ chcon -R -t tmp_t .

will change the type but subsequent restorecon will likely change it back to user_home_t. To prevent that from happening, either use semanage fcontext ... to specify this new type for path where the checkout is located, or store the working trees in separate location outside of home directory where they won't be relabelled.

Another possibility is to use spc_t SELinux type for the container processes with

$ docker run --security-opt=label:disable ...

That type is similar to unconfined_t by having wide set of operations allowed.

Installing requirements into the container image

Our containerized processes are now able to access the Django tests/ directory but we still need to prepare the container environment a bit because the default Fedora image does not even contain python:

$ docker run -u nobody --security-opt=label:disable -v $PWD:/django:ro --rm -ti \
    fedora bash -c 'cd /django/tests && PYTHONPATH=..:$PYTHONPATH ./runtests.py'
/usr/bin/env: python: No such file or directory

It's time to create container image which will have python installed. With tests/Dockerfile

FROM fedora
RUN dnf install -y python && dnf clean all

we can build image django-tests with

$ docker build -t django-tests tests
[ ... see python installed ... ]
Successfully built 9273aa7438bc

Now we can repeat our ./runtests.py attempt, just using the new image with python instead of the vanilla Fedora base image:

$ docker run -u nobody --security-opt=label:disable -v $PWD:/django:ro --rm -ti \
    fedora django-tests -c 'cd /django/tests && PYTHONPATH=..:$PYTHONPATH ./runtests.py'
Please install test dependencies first: 
$ pip install -r requirements/py2.txt

We are at the same point we were at the beginning on the host, except we will happily install the needed software in the container.

We could certainly run the pip install command before running ./runtests.py in run-time but that would mean installing the packages over and over again upon every test run. It usually takes me more than one test execution to get my patch right, so making that part faster is a reasonable goal. We can run the pip install in build-time and make the installed packages part of the container image:

FROM fedora
RUN dnf install -y python python-devel python-cffi gcc redhat-rpm-config && dnf clean all
ADD requirements/* /django-requirements/
RUN pip install -r /django-requirements/py2.txt

We take advantage of the fact that during docker build, the tests/ subdirectory is available as build context, so, we can copy the tests/requirements/ files to the image and run pip install based on them.

However, the above is likely to fail since to build the python modules from sources, -devel rpm packages for the C libraries are often needed. Let's try to install as many packages as possible as rpms, and only then fallback to installing with pip:

RUN mkdir /django-requirements && echo -e '#!/bin/bash          \n \
        r () {                                                  \n \
                echo "# Reading $1" >&2                         \n \
                while read i ; do                               \n \
                        s="${i#-r }"                            \n \
                        if [ "$i" != "$s" ] ; then              \n \
                                r "$s"                          \n \
                                echo "# Back to $1" >&2         \n \
                        elif [ "$i" == "${i/##/}" ] ; then      \n \
                                echo "python-$i"                \n \
                                echo "python-${i,,}"            \n \
                        fi                                      \n \
                done < "$1"                                     \n \
        }                                                       \n \
        readarray -t p < <( r "$1")                             \n \                                                    
        dnf install -y --setopt=strict=0 python-devel gcc redhat-rpm-config "${p[@]}"'  \
                > /django-requirements/dnf-install-python-requirements
ADD requirements/* /django-requirements/
RUN ( cd /django-requirements && bash ./dnf-install-python-requirements py2.txt )
RUN pip install -r /django-requirements/py2.txt

We also have to install some additional packages (python-devel, gcc) needed to compile from sources.

To make the test execution easier, let's make a simple script which will set the PYTHONPATH accordingly:

RUN ( echo '#!/bin/bash' ; echo 'cd /django/tests && PYTHONPATH=.. ./runtests.py "$@"' ) > /bin/django-runtests
RUN chmod a+x /bin/django-runtests

We also might want to stop python from attempting to write .pyc files with

ENV PYTHONDONTWRITEBYTECODE 1

which is bound to fail anyway, especially when we force the execution as nobody:

USER nobody

Final Dockerfile

The whole tests/Dockerfile will thus be

FROM fedora
RUN mkdir /django-requirements && echo -e '#!/bin/bash		\n \
	r () {							\n \
		echo "# Reading $1" >&2				\n \
		while read i ; do				\n \
			s="${i#-r }"				\n \
			if [ "$i" != "$s" ] ; then		\n \
				r "$s"				\n \
				echo "# Back to $1" >&2		\n \
			elif [ "$i" == "${i/##/}" ] ; then	\n \
				echo "python-$i"		\n \
				echo "python-${i,,}"		\n \
			fi					\n \
		done < "$1"					\n \
	}							\n \
	readarray -t p < <( r "$1")				\n \
	EXTRA="python-devel gcc redhat-rpm-config"		\n \
	dnf install -y --setopt=strict=0 $EXTRA "${p[@]}" && dnf clean all'	\
		> /django-requirements/dnf-install-python-requirements
ADD requirements/* /django-requirements/
RUN ( cd /django-requirements && bash ./dnf-install-python-requirements py2.txt )
RUN pip install -r /django-requirements/py2.txt
RUN ( echo '#!/bin/bash' ; echo 'cd /django/tests && PYTHONPATH=.. ./runtests.py "$@"' ) > /bin/django-runtests
RUN chmod a+x /bin/django-runtests
ENV PYTHONDONTWRITEBYTECODE 1
USER nobody

We can build the final image with

$ docker build -t django-tests tests

and run the tests for example using

$ docker run --rm -ti --security-opt=label:disable -v $PWD:/django:ro django-tests /bin/django-runtests -v2 admin_views
Testing against Django installed in '/django/django' with up to 4 processes
Importing application admin_views
Creating test database for alias 'default' (':memory:')...
[ ... ]
Ran 296 tests in 19.500s
OK (skipped=22)
[ ... ]

Versions used

The steps above were working on Fedora 23 with docker-1.10.3-16.gita41254f.fc23.x86_64 and Django 1.9.x and master (before 1.10) branches.

Conclusion

Using containers to run tests can not just be faster than using virtual machines and keep workstation tidy from dependencies of various projects, it can also be used to easily test the project on different operating systems (OS), with different versions of libraries, taking into account OS packages (rpms, in case of Fedora, CentOS, or Red Hat Enterprise Linux) that might be installed on machines where the project will be deployed.