Skip to content
Snippets Groups Projects
Commit e39d0393 authored by Lauri Himanen's avatar Lauri Himanen
Browse files

Merge branch '18-endpoint-to-know-upload-location' into 'develop'

Added paths mounting to the launch endpoint

Closes #18

See merge request !18
parents 96cfc491 1f9e559a
No related branches found
No related tags found
1 merge request!18Added paths mounting to the launch endpoint
Pipeline #112312 passed
......@@ -18,6 +18,9 @@
from typing import List, Dict, Optional, Any
from pydantic import BaseModel, validator, Field
from pathlib import Path
from north import config
# TODO This exemplifies pydantic models a little bit. But, this is just for demonstration.
# The models completed/rewritten and most descriptions are missing.
......@@ -48,6 +51,20 @@ tools_map: Dict[str, ToolModel] = {tool.name: tool for tool in all_tools}
class InstanceModel(BaseModel):
name: str
paths: List[str]
@validator('paths')
def validate_path(cls, paths): # pylint: disable=no-self-argument
for path in paths:
assert path[0] == '/', 'All paths should start with a leading /.'
isAllowed = False
for allowed_data_path in config.allowed_data_paths:
if Path(allowed_data_path) in Path(path).parents or path == allowed_data_path:
isAllowed = True
assert isAllowed, 'You can only request to mount allowed paths.'
return paths
@validator('name')
def validate_name(cls, name): # pylint: disable=no-self-argument
......@@ -63,6 +80,7 @@ class InstanceInDBModel(InstanceModel):
class InstanceResponseModel(BaseModel):
''' Response model with approprate instance information for the client '''
path: str = ''
channel_token: str
class Error(BaseModel):
......
......@@ -17,18 +17,21 @@
#
from typing import List
import asyncio
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, Depends
from collections import deque
from datetime import datetime, timedelta
import asyncio
import time
import docker
from docker import DockerClient
from docker.types import Mount
from fastapi import APIRouter
from fastapi.params import Depends
from north import config
from north.auth import create_channel_token
from north.app.routes.auth import token_launch
from north.app.models import InstanceModel, InstanceResponseModel, ChannelUnavailableError
......@@ -70,6 +73,13 @@ def get_available_channel() -> str:
) from channel_unavailable
def get_docker_mounts_from_paths(paths):
mounts = []
for path in paths:
mounts.append(Mount(target='/home/jovyan/work/' + Path(path).name, source=path, type='bind'))
return mounts
@router.get(
'/',
tags=[router_tag],
......@@ -101,6 +111,7 @@ async def get_instances():
async def post_instances(request: Request, instance: InstanceModel, token=Depends(token_launch)):
''' Create a new tool instance. '''
channel = get_available_channel()
channel_token = create_channel_token(int(channel)).token
path = f'{request.scope.get("root_path")}/container/{channel}/'
container_name = f'{config.docker_name_prefix}-{token.token}-{instance.name}'
......@@ -111,7 +122,8 @@ async def post_instances(request: Request, instance: InstanceModel, token=Depend
current_containers = docker_client.containers.list(**docker_name_prefix_filter)
if len(current_containers) != 0:
path = current_containers[0].labels['path']
return InstanceResponseModel(path=path)
channel_token = current_containers[0].labels['channel_token']
return InstanceResponseModel(path=path, channel_token=channel_token)
# We use an async function to run the container so that the API does not
# get blocked even if docker needs to do some heavier container startup
......@@ -128,9 +140,9 @@ async def post_instances(request: Request, instance: InstanceModel, token=Depend
name=container_name,
user="1000:1000",
group_add=["1000"],
labels={"path": path}
labels={"path": path, "channel_token": channel_token},
mounts=get_docker_mounts_from_paths(instance.paths)
)
loop = asyncio.get_event_loop()
loop.create_task(run_container())
asyncio.create_task(run_container())
return InstanceResponseModel(path=path)
return InstanceResponseModel(path=path, channel_token=channel_token)
......@@ -21,7 +21,7 @@ This config file is based on pydantic's
[settings management](https://pydantic-docs.helpmanual.io/usage/settings/).
'''
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, List
from pydantic import Field, BaseSettings
import yaml
import os.path
......@@ -53,6 +53,11 @@ class NorthConfig(BaseSettings):
description='The proxy host to test the proxy container during CI.'
)
allowed_data_paths: List[str] = Field(
['/'],
description='The list of host directory paths that are deemed safe to mount in the launched instances.'
)
secret: str = Field(
'this is a secret',
description='The secret for generating JWT tokens and other cryptographic material.')
......
......@@ -54,25 +54,27 @@ def test_get_instances(api):
@pytest.mark.parametrize('request_json, status_code', [
pytest.param({'name': 'jupyter'}, 200, id='ok'),
pytest.param({'name': 'doesnotexist'}, 422, id='tool-does-not-exist'),
pytest.param({}, 422, id='name-is-missing')
pytest.param({'name': 'jupyter', 'paths': ['/']}, 200, id='ok'),
pytest.param({'name': 'doesnotexist', 'paths': ['/']}, 422, id='tool-does-not-exist'),
pytest.param({'paths': ['/']}, 422, id='name-is-missing'),
pytest.param({'name': 'jupyter'}, 422, id='paths-is-missing')
])
def test_post_instances(api, request_json, status_code, docker_cleanup):
token = create_launch_token(paths=[''])
token = create_launch_token(paths=['/'])
response = api.post('instances/', headers=dict(Authorization=f'Bearer {token.token}'), json=request_json)
assert response.status_code == status_code
if status_code == 200:
assert response.json() == {"path": "/container/0/"}
assert response.json()['path'] == "/container/0/"
def test_post_instances_already_running(api, docker_cleanup):
token = create_launch_token(paths=[''])
response = api.post('instances/', headers=dict(Authorization=f'Bearer {token.token}'), json={'name': 'jupyter'})
token = create_launch_token(paths=['/'])
request_json = {'name': 'jupyter', 'paths': ['/']}
response = api.post('instances/', headers=dict(Authorization=f'Bearer {token.token}'), json=request_json)
assert response.status_code == 200
first_response = response.json()
# Now we make another request to test what happens if the user has an instance running
response = api.post('instances/', headers=dict(Authorization=f'Bearer {token.token}'), json={'name': 'jupyter'})
response = api.post('instances/', headers=dict(Authorization=f'Bearer {token.token}'), json=request_json)
assert response.status_code == 200
assert response.json() == first_response
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment