Skip to content

Commit

Permalink
Merge pull request #343 from ministryofjustice/deploy-jupyter-lab
Browse files Browse the repository at this point in the history
allow JupyterLab deployment via API endpoint
  • Loading branch information
r4vi authored Oct 16, 2018
2 parents e4e97db + 462759b commit 9e7f980
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 44 deletions.
4 changes: 4 additions & 0 deletions control_panel_api/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ def is_enabled(value):
RSTUDIO_AUTH_CLIENT_ID = os.environ.get('RSTUDIO_AUTH_CLIENT_ID')
RSTUDIO_AUTH_CLIENT_SECRET = os.environ.get('RSTUDIO_AUTH_CLIENT_SECRET')

JUPYTER_LAB_AUTH_CLIENT_DOMAIN = os.environ.get('JUPYTER_LAB_AUTH_CLIENT_DOMAIN', OIDC_DOMAIN)
JUPYTER_LAB_AUTH_CLIENT_ID = os.environ.get('JUPYTER_LAB_AUTH_CLIENT_ID')
JUPYTER_LAB_AUTH_CLIENT_SECRET = os.environ.get('JUPYTER_LAB_AUTH_CLIENT_SECRET')

ELASTICSEARCH = {
'hosts': [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def test_not_logged_user_cant_deploy(self):
)
self.assertEqual(HTTP_403_FORBIDDEN, response.status_code)

@patch('control_panel_api.views.Tool', MagicMock())
@patch('control_panel_api.views.Tools', MagicMock())
def test_normal_user_can_deploy_tool(self):
self.client.force_login(self.normal_user)

Expand All @@ -43,7 +43,7 @@ def test_normal_user_can_deploy_tool(self):
)
self.assertEqual(HTTP_201_CREATED, response.status_code)

@patch('control_panel_api.views.Tool', MagicMock())
@patch('control_panel_api.views.Tools', MagicMock())
def test_superuser_can_deploy_tool(self):
self.client.force_login(self.superuser)

Expand Down
34 changes: 26 additions & 8 deletions control_panel_api/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.test import override_settings

from control_panel_api.models import User
from control_panel_api.tools import Tool, UnsupportedToolException
from control_panel_api.tools import RStudio, UnsupportedToolException, Tools, BaseTool, ToolsRepository, JupyterLab


class ToolsTestCase(TestCase):
Expand All @@ -16,18 +16,18 @@ class ToolsTestCase(TestCase):

def test_when_unsupported_tool_raises_error(self):
with self.assertRaises(UnsupportedToolException):
Tool('unsupported_tool')
Tools['unsupported_tool']()

@override_settings(
TOOLS_DOMAIN=TOOLS_DOMAIN,
RSTUDIO_AUTH_CLIENT_DOMAIN=TOOL_AUTH_CLIENT_DOMAIN,
RSTUDIO_AUTH_CLIENT_ID=TOOL_AUTH_CLIENT_ID,
RSTUDIO_AUTH_CLIENT_SECRET=TOOL_AUTH_CLIENT_SECRET,
RANDOTOOL_AUTH_CLIENT_DOMAIN=TOOL_AUTH_CLIENT_DOMAIN,
RANDOTOOL_AUTH_CLIENT_ID=TOOL_AUTH_CLIENT_ID,
RANDOTOOL_AUTH_CLIENT_SECRET=TOOL_AUTH_CLIENT_SECRET,
)
@patch('secrets.token_hex')
@patch('control_panel_api.tools.Helm.upgrade_release')
def test_deploy_for(self, mock_helm_upgrade_release, mock_token_hex):
tool_name = 'rstudio'
def test_deploy_for_generic(self, mock_helm_upgrade_release, mock_token_hex):
tool_name = 'randotool'
user = User(username='AlIcE')
username = user.username.lower()

Expand All @@ -38,7 +38,10 @@ def test_deploy_for(self, mock_helm_upgrade_release, mock_token_hex):
cookie_secret_tool
]

tool = Tool(tool_name)
class RandoTool(BaseTool):
name = 'randotool'

tool = RandoTool()
tool.deploy_for(user)

mock_helm_upgrade_release.assert_called_with(
Expand All @@ -54,3 +57,18 @@ def test_deploy_for(self, mock_helm_upgrade_release, mock_token_hex):
'--set', f'authProxy.auth0.clientId={self.TOOL_AUTH_CLIENT_ID}',
'--set', f'authProxy.auth0.clientSecret={self.TOOL_AUTH_CLIENT_SECRET}',
)


class TestToolsRepository(TestCase):

def test_repository_init(self):
tr = ToolsRepository(RStudio, JupyterLab)
self.assertDictEqual(tr.data, {
'rstudio': RStudio,
'jupyter-lab': JupyterLab
})

def test_getattr_exception(self):
tr = ToolsRepository()
with self.assertRaises(UnsupportedToolException):
tr['foo']()
9 changes: 5 additions & 4 deletions control_panel_api/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1004,9 +1004,10 @@ def test_create_when_invalid_tool_name(self):
)
self.assertEqual(HTTP_400_BAD_REQUEST, response.status_code)

@patch('control_panel_api.views.Tool', autospec=True)
def test_create_when_valid_tool_name(self, mock_tool):
mock_tool_instance = mock_tool.return_value
@patch('control_panel_api.views.Tools', autospec=True)
def test_create_when_valid_tool_name(self, mock_toolrepo):
mock_tool_cls = mock_toolrepo.__getitem__.return_value
mock_tool_instance = mock_tool_cls.return_value

tool_name = 'rstudio'
response = self.client.post(
Expand All @@ -1016,5 +1017,5 @@ def test_create_when_valid_tool_name(self, mock_tool):
)
self.assertEqual(HTTP_201_CREATED, response.status_code)

mock_tool.assert_called_with(tool_name)
mock_toolrepo.__getitem__.assert_called_with(tool_name)
mock_tool_instance.deploy_for.assert_called_with(self.normal_user)
123 changes: 97 additions & 26 deletions control_panel_api/tools.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
import secrets
from collections import UserDict

from django.conf import settings
from django.utils.functional import cached_property
from rest_framework.exceptions import APIException

from control_panel_api.helm import Helm
from control_panel_api.utils import sanitize_environment_variable


class HelmToolDeployMixin:

@cached_property
def helm(self):
return Helm()


class Auth0ClientConfigMixin:

def _get_auth_client_config(self, key):
setting_key = sanitize_environment_variable(
f'{self.name}_AUTH_CLIENT_{key}')
return getattr(settings, setting_key.upper())

@cached_property
def auth_client_domain(self):
return self._get_auth_client_config('domain')

@cached_property
def auth_client_id(self):
return self._get_auth_client_config('id')

@cached_property
def auth_client_secret(self):
return self._get_auth_client_config('secret')


SUPPORTED_TOOL_NAMES = [
'rstudio',
'jupyter-lab'
]


Expand All @@ -19,35 +50,22 @@ class UnsupportedToolException(APIException):
default_code = 'unsupported_tool'


class Tool(object):

def __init__(self, name):
if not name in SUPPORTED_TOOL_NAMES:
raise UnsupportedToolException
class BaseTool(HelmToolDeployMixin, Auth0ClientConfigMixin):
name = None

self.name = name
self.helm = Helm()
@property
def chart_name(self):
return f'mojanalytics/{self.name}'

self.auth_client_domain = self._get_auth_client_config('domain')
self.auth_client_id = self._get_auth_client_config('id')
self.auth_client_secret = self._get_auth_client_config('secret')
def release_name(self, username):
return f'{username}-{self.name}'

def deploy_for(self, user):
"""
Deploy the given tool in the user namespace.
>>> rstudio = Tool('rstudio')
>>> rstudio.deploy_for(alice)
"""

username = user.username.lower()
def deploy_params(self, user):
auth_proxy_cookie_secret = secrets.token_hex(32)
tool_cookie_secret = secrets.token_hex(32)
username = user.username.lower()

self.helm.upgrade_release(
f'{username}-{self.name}',
f'mojanalytics/{self.name}',
'--namespace', user.k8s_namespace,
return [
'--set', f'username={username}',
'--set', f'aws.iamRole={user.iam_role_name}',
'--set', f'toolsDomain={settings.TOOLS_DOMAIN}',
Expand All @@ -56,8 +74,61 @@ def deploy_for(self, user):
'--set', f'authProxy.auth0.domain={self.auth_client_domain}',
'--set', f'authProxy.auth0.clientId={self.auth_client_id}',
'--set', f'authProxy.auth0.clientSecret={self.auth_client_secret}',
]

def deploy_for(self, user):
"""
Deploy the given tool in the user namespace.
>>> class RStudio(BaseTool):
... name = 'rstudio'
>>> rstudio = RStudio()
>>> rstudio.deploy_for(alice)
"""

username = user.username.lower()
deploy_params = self.deploy_params(user)
self.helm.upgrade_release(
self.release_name(username),
self.chart_name,
'--namespace', user.k8s_namespace,
*deploy_params
)

def _get_auth_client_config(self, key):
setting_key = f'{self.name}_AUTH_CLIENT_{key}'
return getattr(settings, setting_key.upper())

class RStudio(BaseTool):
name = 'rstudio'


class JupyterLab(BaseTool):
name = 'jupyter-lab'

def release_name(self, username):
return f'{self.name}-{username}'

def deploy_params(self, user):
auth_proxy_cookie_secret = secrets.token_hex(32)

return [
'--set', f'Username={user.username.lower()}',
'--set', f'aws.iamRole={user.iam_role_name}',
'--set', f'toolsDomain={settings.TOOLS_DOMAIN}',
'--set', f'cookie_secret={auth_proxy_cookie_secret}',
'--set', f'authProxy.auth0_domain={self.auth_client_domain}',
'--set', f'authProxy.auth0_client_id={self.auth_client_id}',
'--set', f'authProxy.auth0_client_secret={self.auth_client_secret}',
]


class ToolsRepository(UserDict):

def __init__(self, *tools):
super().__init__({toolcls.name: toolcls for toolcls in tools})

def __getitem__(self, item):
try:
return super().__getitem__(item)
except KeyError:
raise UnsupportedToolException()


Tools = ToolsRepository(RStudio, JupyterLab)
1 change: 1 addition & 0 deletions control_panel_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
url(r'^apps/(?P<pk>[0-9]+)/customers/(?P<user_id>[\w\d\|]+)/$', views.AppCustomersDetailAPIView.as_view(), name='appcustomers-detail'),
url(r'^k8s/.+', views.k8s_api_handler),
url(r'^tools/(?P<tool_name>[-a-z]+)/deployments/$', views.tool_deployments_list, name='tool-deployments-list'),
url(r'^tools/$', views.supported_tool_list, name='tool-list'),
url(r'^auth/', include('rest_framework.urls', namespace='rest_framework'))
]

Expand Down
14 changes: 14 additions & 0 deletions control_panel_api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,17 @@ def sanitize_dns_label(label):
label = re.sub(r'[^a-z0-9]*$', '', label)

return label

def sanitize_environment_variable(name):
"""
make a string with hyphens into a string where those hyphens have been replaced with _
:param name: name of variable to sanitize
:type str
:return: sanitized name
:rtype str
"""
# uppercase
name = name.upper()
# replace - with _
name = name.replace('-', '_', )
return name
15 changes: 11 additions & 4 deletions control_panel_api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from django_filters.rest_framework import DjangoFilterBackend
from elasticsearch import TransportError
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, detail_route, permission_classes
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import get_error_detail
from rest_framework.generics import GenericAPIView
Expand Down Expand Up @@ -59,7 +59,7 @@
UserS3BucketSerializer,
UserSerializer,
)
from control_panel_api.tools import Tool
from control_panel_api.tools import SUPPORTED_TOOL_NAMES, Tools

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -249,7 +249,7 @@ def perform_create(self, serializer):

instance.create_users3bucket(user=self.request.user)

@detail_route()
@action(detail=True)
def access_logs(self, request, pk=None):
query_params_serializer = S3BucketAccessLogsQueryParamsSerializer(
data=request.query_params)
Expand All @@ -270,12 +270,19 @@ class UserAppViewSet(viewsets.ModelViewSet):
queryset = UserApp.objects.all()
serializer_class = UserAppSerializer

@api_view(['GET'])
@permission_classes((ToolDeploymentPermissions,))
@handle_external_exceptions
def supported_tool_list(request):
tools = [{"name": n} for n in SUPPORTED_TOOL_NAMES]
return JsonResponse({"results": tools})


@api_view(['POST'])
@permission_classes((ToolDeploymentPermissions,))
@handle_external_exceptions
def tool_deployments_list(request, tool_name):
tool = Tool(tool_name)
tool = Tools[tool_name]()
tool.deploy_for(request.user)

return JsonResponse({}, status=status.HTTP_201_CREATED)

0 comments on commit 9e7f980

Please sign in to comment.