Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tests #21

Merged
merged 13 commits into from
May 14, 2020
12 changes: 11 additions & 1 deletion .github/workflows/build.yml → .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: CI
name: Tests

on:
push:
Expand All @@ -10,11 +10,18 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: [3.6, 3.7, 3.8]

steps:
- uses: actions/checkout@v2
- name: Install node
uses: actions/setup-node@v1
with:
node-version: '10.x'
- name: Install configurable-http-proxy
run: npm -g install configurable-http-proxy
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
Expand All @@ -24,3 +31,6 @@ jobs:
python -m pip install --upgrade pip
python -m pip install -r dev-requirements.txt
python -m pip install -e .
- name: Run Tests
run: |
python -m pytest --cov
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jupyterhub.sqlite
jupyterhub-proxy.pid
*.egg-info
MANIFEST
.coverage

# IDE
.vscode
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,13 @@ python -m jupyterhub -f jupyterhub_config.py --debug
```

Open https://localhost:8000 in a web browser.

## Tests

Tests are located in the [tests](./tests) folder.

To run the tests:

```bash
python -m pytest --cov
```
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# tljh-repo2docker

![Github Actions Status](https://github.com/plasmabio/tljh-repo2docker/workflows/CI/badge.svg)
![Github Actions Status](https://github.com/plasmabio/tljh-repo2docker/workflows/Tests/badge.svg)

TLJH plugin to build and use Docker images as user environments. The Docker images are built using [`repo2docker`](https://repo2docker.readthedocs.io/en/latest/).

Expand Down
6 changes: 6 additions & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
git+https://github.com/jupyterhub/the-littlest-jupyterhub
notebook
pytest
pytest-aiohttp
pytest-asyncio
pytest-cov
requests-mock
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
python_files = test_*.py
Empty file added tests/__init__.py
Empty file.
72 changes: 72 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import asyncio
import os
import sys

import pytest

from aiodocker import Docker, DockerError
from jupyterhub.tests.conftest import (
io_loop,
event_loop,
db,
pytest_collection_modifyitems,
)
from jupyterhub.tests.mocking import MockHub
from tljh_repo2docker import Repo2DockerSpawner
from tljh_repo2docker.builder import BuildHandler
from tljh_repo2docker.images import ImagesHandler


@pytest.fixture(scope='module')
def minimal_repo():
return "https://github.com/jtpio/test-binder"


@pytest.fixture(scope='module')
def image_name():
return "tljh-repo2docker-test:master"


@pytest.mark.asyncio
@pytest.fixture(scope='module')
async def remove_test_image(image_name):
docker = Docker()
try:
await docker.images.delete(image_name)
except DockerError:
pass
await docker.close()


@pytest.fixture(scope='module')
def app(request, io_loop):
"""
Adapted from:
https://github.com/jupyterhub/jupyterhub/blob/8a3790b01ff944c453ffcc0486149e2a58ffabea/jupyterhub/tests/conftest.py#L74
"""
mocked_app = MockHub.instance()
mocked_app.spawner_class = Repo2DockerSpawner
mocked_app.template_paths.insert(
0, os.path.join(os.path.dirname(__file__), "../tljh_repo2docker", "templates")
)
mocked_app.extra_handlers.extend([
(r"environments", ImagesHandler),
(r"api/environments", BuildHandler),
])

async def make_app():
await mocked_app.initialize([])
await mocked_app.start()

def fin():
# disconnect logging during cleanup because pytest closes captured FDs prematurely
mocked_app.log.handlers = []
MockHub.clear_instance()
try:
mocked_app.stop()
except Exception as e:
print("Error stopping Hub: %s" % e, file=sys.stderr)

request.addfinalizer(fin)
io_loop.run_sync(make_app)
return mocked_app
56 changes: 56 additions & 0 deletions tests/test_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import pytest

from aiodocker import Docker, DockerError

from .utils import add_environment, wait_for_image, remove_environment


@pytest.mark.asyncio
async def test_add_environment(app, remove_test_image, minimal_repo, image_name):
name, ref = image_name.split(":")
r = await add_environment(app, repo=minimal_repo, name=name, ref=ref)
assert r.status_code == 200
image = await wait_for_image(image_name=image_name)
assert (
image["ContainerConfig"]["Labels"]["tljh_repo2docker.image_name"] == image_name
)


@pytest.mark.asyncio
async def test_delete_environment(app, remove_test_image, minimal_repo, image_name):
name, ref = image_name.split(":")
await add_environment(app, repo=minimal_repo, name=name, ref=ref)
await wait_for_image(image_name=image_name)
r = await remove_environment(app, image_name=image_name)
assert r.status_code == 200

# make sure the image does not exist anymore
docker = Docker()
with pytest.raises(DockerError):
await docker.images.inspect(image_name)
await docker.close()


@pytest.mark.asyncio
async def test_delete_unknown_environment(app, remove_test_image):
r = await remove_environment(app, image_name="image-not-found:12345")
assert r.status_code == 404


async def test_no_repo(app):
r = await add_environment(app, repo="")
assert r.status_code == 400


@pytest.mark.parametrize(
"memory, cpu", [("abcded", ""), ("", "abcde"),],
)
async def test_wrong_limits(app, minimal_repo, memory, cpu):
r = await add_environment(app, repo=minimal_repo, memory=memory, cpu=cpu)
assert r.status_code == 400
assert "must be a number" in r.text


async def test_wrong_name(app, minimal_repo):
r = await add_environment(app, repo=minimal_repo, name="#WRONG_NAME#")
assert r.status_code == 400
42 changes: 42 additions & 0 deletions tests/test_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import pytest

from jupyterhub.tests.utils import get_page

from .utils import add_environment, wait_for_image


@pytest.mark.asyncio
async def test_images_list_admin(app):
cookies = await app.login_user('admin')
r = await get_page('environments', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert 'Repository' in r.text


@pytest.mark.asyncio
async def test_images_list_not_admin(app):
cookies = await app.login_user('wash')
r = await get_page('environments', app, cookies=cookies, allow_redirects=False)
assert r.status_code == 403


@pytest.mark.asyncio
async def test_spawn_page(app, remove_test_image, minimal_repo, image_name):
cookies = await app.login_user('admin')

# go to the spawn page
r = await get_page('spawn', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert minimal_repo not in r.text

# add a new envionment
name, ref = image_name.split(":")
r = await add_environment(app, repo=minimal_repo, name=name, ref=ref)
assert r.status_code == 200
await wait_for_image(image_name=image_name)

# the environment should be on the page
r = await get_page('spawn', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 200
assert minimal_repo in r.text
47 changes: 47 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import asyncio
import json

from aiodocker import Docker, DockerError
from jupyterhub.tests.utils import api_request


async def add_environment(
app, *, repo, ref="master", name="tljh-repo2docker-test", memory="", cpu=""
):
"""Use the POST endpoint to add a new environment"""
r = await api_request(
app,
"environments",
method="post",
data=json.dumps(
{"repo": repo, "ref": ref, "name": name, "memory": memory, "cpu": cpu,}
),
)
return r


async def wait_for_image(*, image_name):
"""wait until an image is built"""
count, retries = 0, 60 * 10
image = None
docker = Docker()
while count < retries:
await asyncio.sleep(1)
try:
image = await docker.images.inspect(image_name)
except DockerError:
count += 1
continue
else:
break

await docker.close()
return image


async def remove_environment(app, *, image_name):
"""Use the DELETE endpoint to remove an environment"""
r = await api_request(
app, "environments", method="delete", data=json.dumps({"name": image_name,}),
)
return r
41 changes: 25 additions & 16 deletions tljh_repo2docker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os

from aiodocker import Docker
from dockerspawner import DockerSpawner
from jinja2 import Environment, BaseLoader
from jupyter_client.localinterfaces import public_ips
Expand All @@ -11,7 +12,8 @@
from traitlets.config import Configurable

from .builder import BuildHandler
from .images import list_images, docker, ImagesHandler
from .docker import list_images
from .images import ImagesHandler

# Default CPU period
# See: https://docs.docker.com/config/containers/resource_constraints/#limit-a-containers-access-to-memory#configure-the-default-cfs-scheduler
Expand Down Expand Up @@ -128,9 +130,15 @@ async def set_limits(self):
Set the user environment limits if they are defined in the image
"""
imagename = self.user_options.get("image")
docker = Docker()
image = await docker.images.inspect(imagename)
mem_limit = image["ContainerConfig"]["Labels"].get("tljh_repo2docker.mem_limit", None)
cpu_limit = image["ContainerConfig"]["Labels"].get("tljh_repo2docker.cpu_limit", None)
await docker.close()
mem_limit = image["ContainerConfig"]["Labels"].get(
"tljh_repo2docker.mem_limit", None
)
cpu_limit = image["ContainerConfig"]["Labels"].get(
"tljh_repo2docker.cpu_limit", None
)

# override the spawner limits if defined in the image
if mem_limit:
Expand Down Expand Up @@ -177,21 +185,22 @@ def tljh_custom_jupyterhub_config(c):
cpu_limit = limits["cpu"]
mem_limit = limits["memory"]

c.JupyterHub.tornado_settings.update({
'default_cpu_limit': cpu_limit,
'default_mem_limit': mem_limit
})
c.JupyterHub.tornado_settings.update(
{"default_cpu_limit": cpu_limit, "default_mem_limit": mem_limit}
)

# register the handlers to manage the user images
c.JupyterHub.extra_handlers.extend([
(r"environments", ImagesHandler),
(r"api/environments", BuildHandler),
(
r"environments-static/(.*)",
CacheControlStaticFilesHandler,
{"path": os.path.join(os.path.dirname(__file__), "static")},
),
])
c.JupyterHub.extra_handlers.extend(
[
(r"environments", ImagesHandler),
(r"api/environments", BuildHandler),
(
r"environments-static/(.*)",
CacheControlStaticFilesHandler,
{"path": os.path.join(os.path.dirname(__file__), "static")},
),
]
)


@hookimpl
Expand Down
Loading