From fc8f3bc7d81985cf46f78c3dd8a7a028ed6cbdda Mon Sep 17 00:00:00 2001 From: Stefano Alberto Russo Date: Sun, 16 Jan 2022 20:18:16 +0100 Subject: [PATCH] Added supprt for setting environment variables for containers. --- .../rosetta/core_app/computing_managers.py | 54 ++++++++++++++++--- .../migrations/0031_container_env_vars.py | 25 +++++++++ .../webapp/code/rosetta/core_app/models.py | 3 ++ .../core_app/templates/add_software.html | 7 +++ .../templates/components/container.html | 6 +++ .../code/rosetta/core_app/tests/test_utils.py | 34 ++++++++++++ .../webapp/code/rosetta/core_app/utils.py | 12 +++-- .../webapp/code/rosetta/core_app/views.py | 10 +++- 8 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 services/webapp/code/rosetta/core_app/migrations/0031_container_env_vars.py create mode 100644 services/webapp/code/rosetta/core_app/tests/test_utils.py diff --git a/services/webapp/code/rosetta/core_app/computing_managers.py b/services/webapp/code/rosetta/core_app/computing_managers.py index f065b31..4889817 100644 --- a/services/webapp/code/rosetta/core_app/computing_managers.py +++ b/services/webapp/code/rosetta/core_app/computing_managers.py @@ -1,5 +1,5 @@ from .models import TaskStatuses, KeyPair, Task, Storage -from .utils import os_shell, get_ssh_access_mode_credentials +from .utils import os_shell, get_ssh_access_mode_credentials, sanitize_container_env_vars from .exceptions import ErrorMessage, ConsistencyException from django.conf import settings @@ -107,6 +107,15 @@ class InternalStandaloneComputingManager(StandaloneComputingManager): if not task.requires_proxy and task.password: run_command += ' -eAUTH_PASS={} '.format(task.password) + # Env vars if any + if task.container.env_vars: + + # Sanitize again just in case the DB got somehow compromised: + env_vars = sanitize_container_env_vars(task.container.env_vars) + + for env_var in env_vars: + run_command += ' -e{}={} '.format(env_var, env_vars[env_var]) + # User data volume #run_command += ' -v {}/user-{}:/data'.format(settings.LOCAL_USER_DATA_DIR, task.user.id) @@ -204,8 +213,19 @@ class SSHStandaloneComputingManager(StandaloneComputingManager, SSHComputingMana # Set pass if any authstring = '' if not task.requires_proxy_auth and task.password: - authstring = ' export SINGULARITYENV_AUTH_PASS={} && '.format(task.password) + authstring = ' && export SINGULARITYENV_AUTH_PASS={} '.format(task.password) + + # Env vars if any + if task.container.env_vars: + varsstring = '' + # Sanitize again just in case the DB got somehow compromised: + env_vars = sanitize_container_env_vars(task.container.env_vars) + for env_var in env_vars: + varsstring += ' && export SINGULARITYENV_{}={} '.format(env_var, env_vars[env_var]) + else: + varsstring = '' + # Handle storages (binds) binds = '' storages = Storage.objects.filter(computing=self.computing) @@ -241,7 +261,7 @@ class SSHStandaloneComputingManager(StandaloneComputingManager, SSHComputingMana run_command = 'ssh -o LogLevel=ERROR -i {} -4 -o StrictHostKeyChecking=no {}@{} '.format(computing_keys.private_key_file, computing_user, computing_host) run_command += '/bin/bash -c \'"rm -rf /tmp/{}_data && mkdir -p /tmp/{}_data/tmp && mkdir -p /tmp/{}_data/home && chmod 700 /tmp/{}_data && '.format(task.uuid, task.uuid, task.uuid, task.uuid) run_command += 'wget {}/api/v1/base/agent/?task_uuid={} -O /tmp/{}_data/agent.py &> /dev/null && export BASE_PORT=\$(python /tmp/{}_data/agent.py 2> /tmp/{}_data/task.log) && '.format(webapp_conn_string, task.uuid, task.uuid, task.uuid, task.uuid) - run_command += 'export SINGULARITY_NOHTTPS=true && export SINGULARITYENV_BASE_PORT=\$BASE_PORT && {} '.format(authstring) + run_command += 'export SINGULARITY_NOHTTPS=true && export SINGULARITYENV_BASE_PORT=\$BASE_PORT {} {} &&'.format(authstring, varsstring) run_command += 'exec nohup singularity run {} --pid --writable-tmpfs --no-home --home=/home/metauser --workdir /tmp/{}_data/tmp -B/tmp/{}_data/home:/home --containall --cleanenv '.format(binds, task.uuid, task.uuid) # Container part @@ -254,7 +274,18 @@ class SSHStandaloneComputingManager(StandaloneComputingManager, SSHComputingMana authstring = '' if not task.requires_proxy_auth and task.password: authstring = ' -e AUTH_PASS={} '.format(task.password) + + # Env vars if any + if task.container.env_vars: + varsstring = '' + # Sanitize again just in case the DB got somehow compromised: + env_vars = sanitize_container_env_vars(task.container.env_vars) + for env_var in env_vars: + varsstring += ' -e {}={} '.format(env_var, env_vars[env_var]) + else: + varsstring = '' + # Handle storages (binds) binds = '' storages = Storage.objects.filter(computing=self.computing) @@ -293,7 +324,7 @@ class SSHStandaloneComputingManager(StandaloneComputingManager, SSHComputingMana run_command = 'ssh -o LogLevel=ERROR -i {} -4 -o StrictHostKeyChecking=no {}@{} '.format(computing_keys.private_key_file, computing_user, computing_host) run_command += '/bin/bash -c \'"rm -rf /tmp/{}_data && mkdir /tmp/{}_data && chmod 700 /tmp/{}_data && '.format(task.uuid, task.uuid, task.uuid) run_command += 'wget {}/api/v1/base/agent/?task_uuid={} -O /tmp/{}_data/agent.py &> /dev/null && export TASK_PORT=\$(python /tmp/{}_data/agent.py 2> /tmp/{}_data/task.log) && '.format(webapp_conn_string, task.uuid, task.uuid, task.uuid, task.uuid) - run_command += '{} {} run -p \$TASK_PORT:{} {} {} '.format(prefix, container_engine, task.container.interface_port, authstring, binds) + run_command += '{} {} run -p \$TASK_PORT:{} {} {} {} '.format(prefix, container_engine, task.container.interface_port, authstring, varsstring, binds) if container_engine == 'podman': run_command += '--network=private --uts=private ' #run_command += '-d -t {}/{}:{}'.format(task.container.registry, task.container.image_name, task.container.image_tag) @@ -435,8 +466,19 @@ class SlurmSSHClusterComputingManager(ClusterComputingManager, SSHComputingManag # Set pass if any authstring = '' if not task.requires_proxy_auth and task.password: - authstring = ' export SINGULARITYENV_AUTH_PASS={} && '.format(task.password) + authstring = ' && export SINGULARITYENV_AUTH_PASS={} '.format(task.password) + + # Env vars if any + if task.container.env_vars: + varsstring = '' + # Sanitize again just in case the DB got somehow compromised: + env_vars = sanitize_container_env_vars(task.container.env_vars) + for env_var in env_vars: + varsstring += ' && export SINGULARITYENV_{}={} '.format(env_var, env_vars[env_var]) + else: + varsstring = '' + # Handle storages (binds) binds = '' storages = Storage.objects.filter(computing=self.computing) @@ -471,7 +513,7 @@ class SlurmSSHClusterComputingManager(ClusterComputingManager, SSHComputingManag run_command = 'ssh -o LogLevel=ERROR -i {} -4 -o StrictHostKeyChecking=no {}@{} '.format(computing_keys.private_key_file, computing_user, computing_host) run_command += '\'bash -c "echo \\"#!/bin/bash\nwget {}/api/v1/base/agent/?task_uuid={} -O \$HOME/agent_{}.py &> \$HOME/{}.log && export BASE_PORT=\\\\\\$(python \$HOME/agent_{}.py 2> \$HOME/{}.log) && '.format(webapp_conn_string, task.uuid, task.uuid, task.uuid, task.uuid, task.uuid) - run_command += 'export SINGULARITY_NOHTTPS=true && export SINGULARITYENV_BASE_PORT=\\\\\\$BASE_PORT && {} '.format(authstring) + run_command += 'export SINGULARITY_NOHTTPS=true && export SINGULARITYENV_BASE_PORT=\\\\\\$BASE_PORT {} {} && '.format(authstring, varsstring) run_command += 'rm -rf /tmp/{}_data && mkdir -p /tmp/{}_data/tmp &>> \$HOME/{}.log && mkdir -p /tmp/{}_data/home &>> \$HOME/{}.log && chmod 700 /tmp/{}_data && '.format(task.uuid, task.uuid, task.uuid, task.uuid, task.uuid, task.uuid) run_command += 'exec nohup singularity run {} --pid --writable-tmpfs --no-home --home=/home/metauser --workdir /tmp/{}_data/tmp -B/tmp/{}_data/home:/home --containall --cleanenv '.format(binds, task.uuid, task.uuid) diff --git a/services/webapp/code/rosetta/core_app/migrations/0031_container_env_vars.py b/services/webapp/code/rosetta/core_app/migrations/0031_container_env_vars.py new file mode 100644 index 0000000..813af15 --- /dev/null +++ b/services/webapp/code/rosetta/core_app/migrations/0031_container_env_vars.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.1 on 2022-01-16 18:40 + +from django.conf import settings +from django.db import migrations + +# Load database-dependent JSON field +if 'sqlite' in settings.DATABASES['default']['ENGINE']: + from rosetta.core_app.fields import JSONField +else: + from django.contrib.postgres.fields import JSONField + + +class Migration(migrations.Migration): + + dependencies = [ + ('core_app', '0030_auto_20211218_2355'), + ] + + operations = [ + migrations.AddField( + model_name='container', + name='env_vars', + field=JSONField(blank=True, null=True, verbose_name='Container env vars'), + ), + ] diff --git a/services/webapp/code/rosetta/core_app/models.py b/services/webapp/code/rosetta/core_app/models.py index 5a886ac..291e663 100644 --- a/services/webapp/code/rosetta/core_app/models.py +++ b/services/webapp/code/rosetta/core_app/models.py @@ -141,6 +141,9 @@ class Container(models.Model): supports_interface_auth = models.BooleanField('Supports interface auth', default=False) # AUTH_USER / AUTH_PASS interface_auth_user = models.CharField('Interface auth fixed user if any', max_length=36, blank=True, null=True) + # Env vars for some container control + env_vars = JSONField('Container env vars', blank=True, null=True) + class Meta: ordering = ['name'] diff --git a/services/webapp/code/rosetta/core_app/templates/add_software.html b/services/webapp/code/rosetta/core_app/templates/add_software.html index 9487623..a18dd06 100644 --- a/services/webapp/code/rosetta/core_app/templates/add_software.html +++ b/services/webapp/code/rosetta/core_app/templates/add_software.html @@ -135,6 +135,13 @@ + + Environment variables + + ​ + + + diff --git a/services/webapp/code/rosetta/core_app/templates/components/container.html b/services/webapp/code/rosetta/core_app/templates/components/container.html index 49a6065..ec99ff5 100644 --- a/services/webapp/code/rosetta/core_app/templates/components/container.html +++ b/services/webapp/code/rosetta/core_app/templates/components/container.html @@ -104,6 +104,12 @@ {% endif %} + {% if container.env_vars %} + + Env vars +
{{container.env_vars}}
+ + {% endif %} {% if container.user %} diff --git a/services/webapp/code/rosetta/core_app/tests/test_utils.py b/services/webapp/code/rosetta/core_app/tests/test_utils.py new file mode 100644 index 0000000..f48fbde --- /dev/null +++ b/services/webapp/code/rosetta/core_app/tests/test_utils.py @@ -0,0 +1,34 @@ +import json + +from django.contrib.auth.models import User + +from .common import BaseAPITestCase +from ..utils import sanitize_container_env_vars + +class TestUtils(BaseAPITestCase): + + def setUp(self): + pass + + def test_sanitize_user_env_vars(self): + '''Test sanitize use env vars''' + + # Basic + env_vars = {'myvar': 'a'} + self.assertEqual(sanitize_container_env_vars(env_vars),env_vars) + + # Allowed specia + env_vars = {'myvar': '/a_directory/a-test'} + self.assertEqual(sanitize_container_env_vars(env_vars),env_vars) + + # Potential malicious + env_vars = {'myvar': '$(rm -rf)'} + with self.assertRaises(ValueError): + sanitize_container_env_vars(env_vars) + + + + + + + diff --git a/services/webapp/code/rosetta/core_app/utils.py b/services/webapp/code/rosetta/core_app/utils.py index 1818e42..b48a29d 100644 --- a/services/webapp/code/rosetta/core_app/utils.py +++ b/services/webapp/code/rosetta/core_app/utils.py @@ -1,4 +1,5 @@ import os +import re import hashlib import traceback import hashlib @@ -732,8 +733,13 @@ def get_ssh_access_mode_credentials(computing, user): +def sanitize_container_env_vars(env_vars): + + for env_var in env_vars: + + # Check only alphanumeric chars, slashed, dashes and underscores + if not re.match("^[/A-Za-z0-9_-]*$", env_vars[env_var]): + raise ValueError('Value "{}" for env var "{}" is not valid: only alphanumeric, slashes, dashes and underscores are.'.format(env_vars[env_var], env_var)) - - - + return env_vars diff --git a/services/webapp/code/rosetta/core_app/views.py b/services/webapp/code/rosetta/core_app/views.py index 4605b3c..3961f73 100644 --- a/services/webapp/code/rosetta/core_app/views.py +++ b/services/webapp/code/rosetta/core_app/views.py @@ -11,7 +11,7 @@ from django.contrib.auth.models import User from django.shortcuts import redirect from django.db.models import Q from .models import Profile, LoginToken, Task, TaskStatuses, Container, Computing, KeyPair, Page -from .utils import send_email, format_exception, timezonize, os_shell, booleanize, debug_param, get_task_tunnel_host, get_task_proxy_host, random_username, setup_tunnel_and_proxy, finalize_user_creation, get_md5 +from .utils import send_email, format_exception, timezonize, os_shell, booleanize, get_task_tunnel_host, get_task_proxy_host, random_username, setup_tunnel_and_proxy, finalize_user_creation, sanitize_container_env_vars from .decorators import public_view, private_view from .exceptions import ErrorMessage @@ -951,6 +951,11 @@ def add_software(request): else: container_supports_pass_auth = False + # Environment variables + container_env_vars = request.POST.get('container_env_vars', None) + if container_env_vars: + container_env_vars = sanitize_container_env_vars(json.loads(container_env_vars)) + # Log #logger.debug('Creating new container object with image="{}", type="{}", registry="{}", ports="{}"'.format(container_image, container_type, container_registry, container_ports)) @@ -968,7 +973,8 @@ def add_software(request): interface_protocol = container_interface_protocol, interface_transport = container_interface_transport, supports_custom_interface_port = container_supports_custom_interface_port, - supports_interface_auth = container_supports_pass_auth) + supports_interface_auth = container_supports_pass_auth, + env_vars = container_env_vars) # Set added switch data['added'] = True -- GitLab