diff --git a/control_panel_api/settings/base.py b/control_panel_api/settings/base.py index 659683232..6400d1b8d 100644 --- a/control_panel_api/settings/base.py +++ b/control_panel_api/settings/base.py @@ -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': [ { diff --git a/control_panel_api/tests/permissions/test_tooldeployment_permissions.py b/control_panel_api/tests/permissions/test_tooldeployment_permissions.py index 31db37da7..2586eee59 100644 --- a/control_panel_api/tests/permissions/test_tooldeployment_permissions.py +++ b/control_panel_api/tests/permissions/test_tooldeployment_permissions.py @@ -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) @@ -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) diff --git a/control_panel_api/tests/test_tools.py b/control_panel_api/tests/test_tools.py index c0bd53f4a..bdc03605c 100644 --- a/control_panel_api/tests/test_tools.py +++ b/control_panel_api/tests/test_tools.py @@ -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): @@ -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() @@ -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( @@ -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']() diff --git a/control_panel_api/tests/test_views.py b/control_panel_api/tests/test_views.py index f2069174f..b2661ecdb 100644 --- a/control_panel_api/tests/test_views.py +++ b/control_panel_api/tests/test_views.py @@ -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( @@ -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) diff --git a/control_panel_api/tools.py b/control_panel_api/tools.py index 950b04ce7..526ccdbcf 100644 --- a/control_panel_api/tools.py +++ b/control_panel_api/tools.py @@ -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' ] @@ -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}', @@ -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) diff --git a/control_panel_api/urls.py b/control_panel_api/urls.py index d7246075d..8d0a2213f 100644 --- a/control_panel_api/urls.py +++ b/control_panel_api/urls.py @@ -23,6 +23,7 @@ url(r'^apps/(?P[0-9]+)/customers/(?P[\w\d\|]+)/$', views.AppCustomersDetailAPIView.as_view(), name='appcustomers-detail'), url(r'^k8s/.+', views.k8s_api_handler), url(r'^tools/(?P[-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')) ] diff --git a/control_panel_api/utils.py b/control_panel_api/utils.py index 4c7f4daaf..4c497b892 100644 --- a/control_panel_api/utils.py +++ b/control_panel_api/utils.py @@ -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 diff --git a/control_panel_api/views.py b/control_panel_api/views.py index ab567574c..0a8547cfc 100644 --- a/control_panel_api/views.py +++ b/control_panel_api/views.py @@ -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 @@ -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__) @@ -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) @@ -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)