From c1c80772fe5f1d4cea0993bc900b09f8b3e0d7e7 Mon Sep 17 00:00:00 2001 From: Christoph Kuhnke Date: Thu, 14 Mar 2024 17:42:18 +0100 Subject: [PATCH] Fixed non-root-user access (#244) * Fixed non-root-user access * fixed create_image and test * Removed variable re-declaration which caused an infinite loop in ansible :) * re-ordered setuid and setgid commands uid must be changes last, otherwise user is no longer permitted to change groups or gid. * Updated notebook-tests use user ubuntu in Dockerfile use sudo for moving test files change user to juypter for actual test execution * Updated entrypoint implemented new strategy as specified in ticket comment https://github.com/exasol/ai-lab/issues/241#issuecomment-1983837761 * Made entrypoint fail on insufficient group permissions for docker.sock added test verifying exception in container.logs() [run-notebook-tests] Co-authored-by: Torsten Kilias --- doc/changes/changes_1.1.0.md | 2 +- .../ds/sandbox/lib/dss_docker/create_image.py | 10 +- .../ansible/ai_lab_docker_playbook.yml | 7 +- .../sandbox/runtime/ansible/ec2_playbook.yml | 1 + .../runtime/ansible/general_setup_tasks.yml | 6 +- .../ansible/roles/docker/tasks/main.yml | 4 +- .../roles/entrypoint/files/entrypoint.py | 168 +++++++++++++++++- test/docker/container.py | 42 ++++- test/docker/dss_docker_image.py | 26 +++ test/docker/exec_run.py | 2 +- .../test_create_dss_docker_image.py | 85 ++++++++- .../test_notebooks_in_dss_docker_image.py | 39 ++-- ...test_cloud.py => disabled_nbtest_cloud.py} | 0 test/unit/entrypoint/entrypoint_mock.py | 5 + test/unit/entrypoint/test_file_permissions.py | 57 ++++++ test/unit/entrypoint/test_main.py | 53 ++++-- .../entrypoint/test_start_jupyter_server.py | 20 +-- test/unit/entrypoint/test_user_class.py | 121 +++++++++++++ test/unit/test_dss_docker_image.py | 64 ++++--- 19 files changed, 622 insertions(+), 90 deletions(-) rename test/notebooks/{nbtest_cloud.py => disabled_nbtest_cloud.py} (100%) create mode 100644 test/unit/entrypoint/entrypoint_mock.py create mode 100644 test/unit/entrypoint/test_file_permissions.py create mode 100644 test/unit/entrypoint/test_user_class.py diff --git a/doc/changes/changes_1.1.0.md b/doc/changes/changes_1.1.0.md index 0557f20b..c8fefa70 100644 --- a/doc/changes/changes_1.1.0.md +++ b/doc/changes/changes_1.1.0.md @@ -22,7 +22,7 @@ n/a ## Bug Fixes -n/a +* #241: Fixed non-root-user access ## Documentation diff --git a/exasol/ds/sandbox/lib/dss_docker/create_image.py b/exasol/ds/sandbox/lib/dss_docker/create_image.py index 2b5d2cd8..54c029a3 100644 --- a/exasol/ds/sandbox/lib/dss_docker/create_image.py +++ b/exasol/ds/sandbox/lib/dss_docker/create_image.py @@ -40,14 +40,20 @@ def jupyter(): command = get_fact(facts, "jupyter", "command") if command is None: return [] + docker_group = get_fact(facts, "docker_group") port = get_fact(facts, "jupyter", "port") user_name = get_fact(facts, "jupyter", "user") + group = get_fact(facts, "jupyter", "group") + user_home = get_fact(facts, "jupyter", "home") password = get_fact(facts, "jupyter", "password") logfile = get_fact(facts, "jupyter", "logfile") return [ + "--home", user_home, "--jupyter-server", command, "--port", port, "--user", user_name, + "--group", group, + "--docker-group", docker_group, "--password", password, "--jupyter-logfile", logfile, ] @@ -55,7 +61,7 @@ def jupyter(): entrypoint = get_fact(facts, "entrypoint") if entrypoint is None: return ["sleep", "infinity"] - entry_cmd = ["python3", entrypoint] + entry_cmd = ["sudo", "python3", entrypoint] folder = get_fact(facts, "notebook_folder") if not folder: return entry_cmd + jupyter() @@ -162,10 +168,10 @@ def _commit_container( notebook_folder_initial = get_fact(facts, "notebook_folder", "initial") conf = { "Entrypoint": entrypoint(facts), - "User": get_fact(facts, "jupyter", "user"), "Cmd": [], "Volumes": {notebook_folder_final: {}, }, "ExposedPorts": {f"{port}/tcp": {}}, + "User": get_fact(facts, "docker_user"), "Env": [ f"VIRTUAL_ENV={virtualenv}", f"NOTEBOOK_FOLDER_FINAL={notebook_folder_final}", diff --git a/exasol/ds/sandbox/runtime/ansible/ai_lab_docker_playbook.yml b/exasol/ds/sandbox/runtime/ansible/ai_lab_docker_playbook.yml index 6ea0d3c7..e140b20c 100644 --- a/exasol/ds/sandbox/runtime/ansible/ai_lab_docker_playbook.yml +++ b/exasol/ds/sandbox/runtime/ansible/ai_lab_docker_playbook.yml @@ -31,9 +31,10 @@ gather_facts: true vars: ansible_python_interpreter: python3 - user_name: "jupyter" - user_group: "jupyter" - user_home: "/home/jupyter" + user_name: jupyter + user_group: jupyter + docker_group: docker + user_home: /home/jupyter initial_notebook_folder: "{{ user_home }}/notebook-defaults" need_sudo: yes docker_integration_test: true diff --git a/exasol/ds/sandbox/runtime/ansible/ec2_playbook.yml b/exasol/ds/sandbox/runtime/ansible/ec2_playbook.yml index fa3cd2a0..5426eea4 100644 --- a/exasol/ds/sandbox/runtime/ansible/ec2_playbook.yml +++ b/exasol/ds/sandbox/runtime/ansible/ec2_playbook.yml @@ -4,6 +4,7 @@ ansible_python_interpreter: /usr/bin/python3 user_name: jupyter user_home: /home/jupyter + docker_group: docker user_group: jupyter initial_notebook_folder: "{{ user_home }}/notebooks" need_sudo: yes diff --git a/exasol/ds/sandbox/runtime/ansible/general_setup_tasks.yml b/exasol/ds/sandbox/runtime/ansible/general_setup_tasks.yml index 88380567..116e5bb6 100644 --- a/exasol/ds/sandbox/runtime/ansible/general_setup_tasks.yml +++ b/exasol/ds/sandbox/runtime/ansible/general_setup_tasks.yml @@ -4,11 +4,15 @@ ansible.builtin.set_fact: dss_facts: entrypoint: "{{user_home}}/entrypoint.py" + docker_user: "{{ ansible_user }}" + docker_group: "{{ docker_group }}" jupyter: virtualenv: "{{ jupyter_virtualenv }}" command: "{{ jupyter_virtualenv }}/bin/jupyter-lab" port: "49494" user: "{{ user_name }}" + group: "{{ user_group }}" + home: "{{ user_home }}" password: "{{ lookup('ansible.builtin.env', 'JUPYTER_LAB_PASSWORD', default='ailab') }}" logfile: "{{ user_home }}/jupyter-server.log" notebook_folder: @@ -40,7 +44,7 @@ owner: "{{ user_name }}" group: "{{ user_group }}" recurse: true - become: "{{ need_sudo }}" + become: "{{need_sudo}}" - name: Clear pip Cache ansible.builtin.file: path: /root/.cache/pip diff --git a/exasol/ds/sandbox/runtime/ansible/roles/docker/tasks/main.yml b/exasol/ds/sandbox/runtime/ansible/roles/docker/tasks/main.yml index 3975e411..4463822d 100644 --- a/exasol/ds/sandbox/runtime/ansible/roles/docker/tasks/main.yml +++ b/exasol/ds/sandbox/runtime/ansible/roles/docker/tasks/main.yml @@ -28,7 +28,7 @@ - name: Adding docker users (for use without sudo) user: - name: "{{user_name}}" + name: "{{ user_name }}" append: yes - groups: docker + groups: "{{ docker_group }}" become: "{{need_sudo}}" diff --git a/exasol/ds/sandbox/runtime/ansible/roles/entrypoint/files/entrypoint.py b/exasol/ds/sandbox/runtime/ansible/roles/entrypoint/files/entrypoint.py index 96873c8b..1005a13e 100644 --- a/exasol/ds/sandbox/runtime/ansible/roles/entrypoint/files/entrypoint.py +++ b/exasol/ds/sandbox/runtime/ansible/roles/entrypoint/files/entrypoint.py @@ -1,18 +1,27 @@ import argparse import logging +import grp import os +import pwd import re import resource import shutil import subprocess +import stat import sys import time from inspect import cleandoc from pathlib import Path +from typing import List, TextIO _logger = logging.getLogger(__name__) +_logger.setLevel(logging.DEBUG) +logging.basicConfig( + format="%(asctime)s %(levelname)-7s %(filename)s: %(message)s", + datefmt="%Y-%m-%d %X", +) def arg_parser(): @@ -39,6 +48,18 @@ def arg_parser(): "--user", type=str, help="user name for running jupyter server", ) + parser.add_argument( + "--group", type=str, + help="user group for running jupyter server", + ) + parser.add_argument( + "--docker-group", type=str, + help="user group for accessing Docker socket", + ) + parser.add_argument( + "--home", type=str, + help="home directory of user running jupyter server", + ) parser.add_argument( "--password", type=str, help="initial default password for Jupyter server", @@ -55,6 +76,7 @@ def arg_parser(): def start_jupyter_server( + home_directory: str, binary_path: str, port: int, notebook_dir: str, @@ -70,10 +92,9 @@ def start_jupyter_server( def exit_on_error(rc): if rc is not None and rc != 0: log_messages = logfile.read_text() - print( + _logger.error( f"Jupyter Server terminated with error code {rc}," f" Logfile {logfile} contains:\n{log_messages}", - flush=True, ) sys.exit(rc) @@ -83,8 +104,11 @@ def exit_on_error(rc): "--no-browser", "--allow-root", ] + + env = os.environ.copy() + env["HOME"] = home_directory with open(logfile, "w") as f: - p = subprocess.Popen(command_line, stdout=f, stderr=f) + p = subprocess.Popen(command_line, stdout=f, stderr=f, env=env) url = "http://:" localhost_url = url.replace("", "localhost").replace("", str(port)) @@ -110,7 +134,7 @@ def exit_on_error(rc): time.sleep(poll_sleep) line = f.readline() if re.search(regexp, line): - print(success_message, flush=True) + _logger.info(success_message) break exit_on_error(p.poll()) exit_on_error(p.wait()) @@ -151,29 +175,161 @@ def ensure_file(src: Path, dst: Path): def disable_core_dumps(): resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + _logger.info("Disabled coredumps") -def sleep_inifinity(): +def sleep_infinity(): while True: time.sleep(1) +class Group: + def __init__(self, name: str, id: int = None): + self.name = name + self._id = id + + @property + def id(self): + if self._id is None: + self._id = grp.getgrnam(self.name).gr_gid + return self._id + + def __eq__(self, other) -> bool: + if not isinstance(other, Group): + return False + return other.name == self.name + + def __repr__(self): + return f"Group(name='{self.name}', id={self._id})" + + +class FileInspector: + def __init__(self, path: Path): + self._path = path + self._stat = path.stat() if path.exists() else None + + @property + def group_id(self) -> int: + if self._stat is None: + raise FileNotFoundError(self._path) + return self._stat.st_gid + + def is_group_accessible(self) -> bool: + if self._stat is None: + _logger.debug(f"File not found {self._path}") + return False + permissions = stat.filemode(self._stat.st_mode) + if permissions[4:6] == "rw": + return True + raise PermissionError( + "No rw permissions for group in" + f" {permissions} {self._path}." + ) + + +class GroupAccess: + """ + If there is already a group with group-ID `gid`, then add the user to + this group, otherwise change the group ID to `gid` for the specified group + name. The other group is expected to exist already and user to be added + to it. + """ + def __init__(self, user: str, group: Group): + self._user = user + self._group = group + + def _find_group_name(self, id: int) -> str: + try: + return grp.getgrgid(id).gr_name + except KeyError: + return None + + def _run(self, command: str) -> int: + _logger.debug(f"Executing {command}") + return subprocess.run(command.split()).returncode + + def enable(self) -> Group: + gid = self._group.id + existing = self._find_group_name(gid) + if existing: + self._run(f"usermod --append --groups {existing} {self._user}") + return Group(existing, gid) + else: + self._run(f"groupmod -g {gid} {self._group.name}") + return self._group + + +class User: + def __init__(self, user_name: str, group: Group, docker_group: Group): + self.name = user_name + self._id = None + self.group = group + self.docker_group = docker_group + + @property + def is_specified(self) -> bool: + return bool( + self.name + and self.group.name + and self.docker_group.name + ) + + @property + def id(self): + if self._id is None: + self._id = pwd.getpwnam(self.name).pw_uid + return self._id + + def enable_group_access(self, path: Path): + file = FileInspector(path) + if file.is_group_accessible(): + group = GroupAccess( + self.name, + Group(self.docker_group.name, file.group_id), + ).enable() + os.setgroups([group.id]) + self.docker_group = group + _logger.info(f"Enabled access to {path}") + return self + + def switch_to(self): + gid = self.group.id + uid = self.id + os.setresgid(gid, gid, gid) + os.setresuid(uid, uid, uid) + _logger.debug( + f"uid = {os.getresuid()}" + f" gid = {os.getresgid()}" + f" extra groups = {os.getgroups()}" + ) + _logger.info(f"Switched uid/gid to {self.name}/{self.group.name}") + return self + + def main(): args = arg_parser().parse_args() + user = User(args.user, Group(args.group), Group(args.docker_group)) + if user.is_specified: + user.enable_group_access(Path("/var/run/docker.sock")).switch_to() if args.notebook_defaults and args.notebooks: copy_rec( args.notebook_defaults, args.notebooks, args.warning_as_error, ) + _logger.info( + "Copied notebooks from" + f" {args.notebook_defaults} to {args.notebooks}") disable_core_dumps() if (args.jupyter_server and args.notebooks and args.jupyter_logfile and args.user + and args.home and args.password ): start_jupyter_server( + args.home, args.jupyter_server, args.port, args.notebooks, @@ -182,7 +338,7 @@ def main(): args.password, ) else: - sleep_inifinity() + sleep_infinity() if __name__ == "__main__": diff --git a/test/docker/container.py b/test/docker/container.py index f0a4356a..7c425f9d 100644 --- a/test/docker/container.py +++ b/test/docker/container.py @@ -1,7 +1,13 @@ import re -from typing import Union import docker +from datetime import timedelta + +from re import Pattern +from tenacity import Retrying +from tenacity.wait import wait_fixed +from tenacity.stop import stop_after_delay +from typing import Generator, Union from docker.models.containers import Container from docker.models.images import Image @@ -12,7 +18,8 @@ def sanitize_test_name(test_name: str): return test_name -def container(request, base_name: str, image: Union[Image, str], start: bool = True, **kwargs) -> Container: +def container(request, base_name: str, image: Union[Image, str], start: bool = True, **kwargs) \ + -> Generator[Container, None, None]: """ Create a Docker container based on the specified Docker image. """ @@ -34,3 +41,34 @@ def container(request, base_name: str, image: Union[Image, str], start: bool = T finally: client.containers.get(container_name).remove(force=True) client.close() + + +def wait_for( + container: Container, + log_message: Union[str, Pattern], + timeout: timedelta = timedelta(seconds=5), +): + """ + Wait until container log contains the specified string or regular + expression. + """ + for attempt in Retrying( + wait=wait_fixed(timeout/10), + stop=stop_after_delay(timeout), + ): + with attempt: + logs = container.logs().decode("utf-8").strip() + if isinstance(log_message, Pattern): + matches = log_message.search(logs) + else: + matches = log_message in logs + if not matches: + raise Exception() + +DOCKER_SOCKET_CONTAINER = "/var/run/docker.sock" + +def wait_for_socket_access(container: Container): + wait_for( + container, + f"entrypoint.py: Enabled access to {DOCKER_SOCKET_CONTAINER}", + ) diff --git a/test/docker/dss_docker_image.py b/test/docker/dss_docker_image.py index aa0efce6..51cbe6b7 100644 --- a/test/docker/dss_docker_image.py +++ b/test/docker/dss_docker_image.py @@ -1,5 +1,7 @@ import docker +import os import pytest +import stat from exasol.ds.sandbox.lib.dss_docker import DssDockerImage @@ -52,3 +54,27 @@ def dss_docker_image(request): finally: if not keep_image: docker.from_env().images.remove(testee.image_name, force=True) + + +@pytest.fixture +def fake_docker_socket_on_host(tmp_path): + socket = tmp_path / "socket.txt" + socket.touch() + os.chmod(socket, 0o660) + return socket + + +@pytest.fixture +def accessible_file(fake_docker_socket_on_host): + socket = fake_docker_socket_on_host + mode = os.stat(socket).st_mode | stat.S_IRGRP | stat.S_IWGRP + os.chmod(socket, mode) + return socket + + +@pytest.fixture +def non_accessible_file(fake_docker_socket_on_host): + socket = fake_docker_socket_on_host + mode = stat.S_IRUSR | stat.S_IWUSR + os.chmod(socket, mode) + return socket diff --git a/test/docker/exec_run.py b/test/docker/exec_run.py index 4790337a..2d0b29fc 100644 --- a/test/docker/exec_run.py +++ b/test/docker/exec_run.py @@ -49,7 +49,7 @@ def handle_output(output: Union[bytes, Iterator[bytes]], print_output: bool): output_string = None if print_output and isinstance(output, Iterator): for chunk in output: - print(decode_bytes(chunk)) + print(decode_bytes(chunk), flush=True) else: output_string = decode_bytes(output) return output_string diff --git a/test/integration/test_create_dss_docker_image.py b/test/integration/test_create_dss_docker_image.py index 4e190915..b64e9da6 100644 --- a/test/integration/test_create_dss_docker_image.py +++ b/test/integration/test_create_dss_docker_image.py @@ -1,5 +1,6 @@ import docker import io +import os import pytest import re import requests @@ -8,14 +9,31 @@ import time import typing -from tenacity.retry import retry_if_exception_type +from tenacity.retry import ( + retry_if_exception_type, + retry_if_not_result, +) +from tenacity import Retrying +from docker.models.containers import Container +from pathlib import Path +from re import Pattern +from contextlib import contextmanager from tenacity.wait import wait_fixed from tenacity.stop import stop_after_delay -from typing import Set +from typing import Set, Tuple from datetime import datetime, timedelta from exasol.ds.sandbox.lib.dss_docker import DssDockerImage from exasol.ds.sandbox.lib.logging import set_log_level from exasol.ds.sandbox.lib import pretty_print +from test.docker.container import ( + container, + DOCKER_SOCKET_CONTAINER, + wait_for, + wait_for_socket_access, +) + + +DOCKER_SOCKET_HOST = "/var/run/docker.sock" @pytest.fixture @@ -27,6 +45,9 @@ def dss_docker_container(dss_docker_image, jupyter_port): name=dss_docker_image.container_name, detach=True, ports=mapped_ports, + volumes={DOCKER_SOCKET_HOST: { + 'bind': DOCKER_SOCKET_CONTAINER, + 'mode': 'rw', }, }, ) container.start() try: @@ -64,9 +85,10 @@ def request_with_retry(url: str) -> requests.Response: assert response.status_code == 200 -def test_install_notebook_connector(dss_docker_container): +def test_import_notebook_connector(dss_docker_container): container = dss_docker_container - command = '/home/jupyter/jupyterenv/bin/python -c "import exasol.nb_connector.secret_store"' + command = ('/home/jupyter/jupyterenv/bin/python' + ' -c "import exasol.nb_connector.secret_store"') exit_code, output = container.exec_run(command) output = output.decode('utf-8').strip() assert exit_code == 0, f'Got output "{output}".' @@ -76,6 +98,7 @@ def test_install_notebooks(dss_docker_container): def filename_set(string: str) -> Set[str]: return set(re.split(r'\s+', string.strip())) + wait_for(dss_docker_container, "entrypoint.py: Copied notebooks") exit_code, output = dss_docker_container.exec_run( "ls --indicator-style=slash /home/jupyter/notebooks" ) @@ -90,3 +113,57 @@ def filename_set(string: str) -> Set[str]: sagemaker/ """) assert actual.issuperset(expected) + + +def test_docker_socket_access(dss_docker_container): + wait_for_socket_access(dss_docker_container) + exit_code, output = dss_docker_container.exec_run("docker ps", user="jupyter") + output = output.decode("utf-8").strip() + assert exit_code == 0 and re.match(r"^CONTAINER ID +IMAGE .*", output) + + +@pytest.fixture +def dss_container_context(request, dss_docker_image): + @contextmanager + def context(docker_socket_host: Path): + yield from container( + request, + base_name="C", + image=dss_docker_image.image_name, + volumes={docker_socket_host: { + 'bind': DOCKER_SOCKET_CONTAINER, + 'mode': 'rw', }, }, + ) + return context + + +def test_insufficient_group_permissions_on_docker_socket(dss_container_context, non_accessible_file): + """ + This test cannot wait for a specifc log message but only for the + container's entrypoint _trying_ to access the Docker socket. + + The test expects the trial to fail and verifies the failure based on + the exception added to the Docker log. + """ + socket = non_accessible_file + with dss_container_context(socket) as container: + time.sleep(1) + expected = "PermissionError: No rw permissions for group in -rw------- /var/run/docker.sock." + assert expected in container.logs().decode("utf-8") + + +def test_docker_socket_on_host_touched(dss_container_context, fake_docker_socket_on_host): + """ + Verify that when mounting the docker socket from the host's file + system into the container, the permissions and owner of the original + socket in the host's file system remain unchanged. + + The test uses a fake_docker_socket_on_host to maximize the chance of + potential changes. + """ + socket = fake_docker_socket_on_host + stat_before = socket.stat() + with dss_container_context(socket) as container: + wait_for_socket_access(container) + + assert stat_before == socket.stat() diff --git a/test/notebook_test_runner/test_notebooks_in_dss_docker_image.py b/test/notebook_test_runner/test_notebooks_in_dss_docker_image.py index c0312304..08106c91 100644 --- a/test/notebook_test_runner/test_notebooks_in_dss_docker_image.py +++ b/test/notebook_test_runner/test_notebooks_in_dss_docker_image.py @@ -1,5 +1,6 @@ import io import os +import time from inspect import cleandoc from pathlib import Path @@ -8,7 +9,7 @@ from test.docker.exec_run import exec_command from test.docker.image import image from test.docker.in_memory_build_context import InMemoryBuildContext -from test.docker.container import container +from test.docker.container import container, wait_for_socket_access, wait_for TEST_RESOURCE_PATH = Path(__file__).parent.parent / "notebooks" @@ -19,9 +20,11 @@ def notebook_test_dockerfile_content(dss_docker_image) -> str: f""" FROM {dss_docker_image.image_name} COPY notebooks/* /tmp/notebooks/ - RUN mv /tmp/notebooks/* "$NOTEBOOK_FOLDER_INITIAL" && rmdir /tmp/notebooks/ + RUN sudo mv /tmp/notebooks/* "$NOTEBOOK_FOLDER_INITIAL" && sudo rmdir /tmp/notebooks/ + RUN sudo chown -R jupyter:jupyter "$NOTEBOOK_FOLDER_INITIAL" WORKDIR $NOTEBOOK_FOLDER_INITIAL - RUN "$VIRTUAL_ENV/bin/python3" -m pip install -r test_dependencies.txt + RUN sudo "$VIRTUAL_ENV/bin/python3" -m pip install -r test_dependencies.txt + RUN sudo chown -R jupyter:jupyter "$VIRTUAL_ENV" """ ) @@ -46,16 +49,18 @@ def notebook_test_image(request, notebook_test_build_context): @pytest.fixture() def notebook_test_container(request, notebook_test_image): yield from container( - request, - base_name="notebook_test_container", - image=notebook_test_image, - volumes={ - '/var/run/docker.sock': { - 'bind': '/var/run/docker.sock', - 'mode': 'rw', - }, - }, - ) + request, base_name="notebook_test_container", image=notebook_test_image, + volumes={'/var/run/docker.sock': { + 'bind': '/var/run/docker.sock', + 'mode': 'rw', }, }, ) + + +@pytest.fixture() +def notebook_test_container_with_log(notebook_test_container): + wait_for_socket_access(notebook_test_container) + logs = notebook_test_container.logs().decode("utf-8").strip() + print(f"Container Logs: {logs or '(empty)'}", flush=True) + yield notebook_test_container def ignored_warnings(): @@ -67,7 +72,7 @@ def ignored_warnings(): ] } args = "" - for category,messages in warnings.items(): + for category, messages in warnings.items(): for m in messages: args += f' -W "ignore:{m}:{category}"' return args @@ -81,8 +86,8 @@ def ignored_warnings(): if python_file.is_file() ] ) -def test_notebook(notebook_test_container, notebook_test_file): - container = notebook_test_container +def test_notebook(notebook_test_container_with_log, notebook_test_file): + container = notebook_test_container_with_log command_echo_virtual_env = 'bash -c "echo $VIRTUAL_ENV"' virtual_env = exec_command(command_echo_virtual_env, container) command_run_test = ( @@ -93,4 +98,4 @@ def test_notebook(notebook_test_container, notebook_test_file): environ = os.environ.copy() environ["NBTEST_ACTIVE"] = "TRUE" nbtest_environ = {key: value for key, value in environ.items() if key.startswith("NBTEST_")} - exec_command(command_run_test, container, print_output=True, environment=nbtest_environ) + exec_command(command_run_test, container, print_output=True, environment=nbtest_environ, user="jupyter") diff --git a/test/notebooks/nbtest_cloud.py b/test/notebooks/disabled_nbtest_cloud.py similarity index 100% rename from test/notebooks/nbtest_cloud.py rename to test/notebooks/disabled_nbtest_cloud.py diff --git a/test/unit/entrypoint/entrypoint_mock.py b/test/unit/entrypoint/entrypoint_mock.py new file mode 100644 index 00000000..24e12aac --- /dev/null +++ b/test/unit/entrypoint/entrypoint_mock.py @@ -0,0 +1,5 @@ +def entrypoint_method(name: str) -> str: + return ( + "exasol.ds.sandbox.runtime.ansible.roles" + f".entrypoint.files.entrypoint.{name}" + ) diff --git a/test/unit/entrypoint/test_file_permissions.py b/test/unit/entrypoint/test_file_permissions.py new file mode 100644 index 00000000..5ccd7ab0 --- /dev/null +++ b/test/unit/entrypoint/test_file_permissions.py @@ -0,0 +1,57 @@ +import grp +import pytest +import os +import re +import stat +import subprocess + +from pathlib import Path +from exasol.ds.sandbox.runtime.ansible.roles.entrypoint.files import entrypoint +from unittest.mock import MagicMock + + +def test_file_inspector_non_existing_file(mocker): + mocker.patch("os.stat") + # need to mock os.path.exists as os.path.exists seems to call os.stat :) + mocker.patch("os.path.exists", return_value=False) + testee = entrypoint.FileInspector(Path("/non/existing/file")) + actual = testee.is_group_accessible() + assert actual == False + assert not os.stat.called + + +def test_file_inspector_group_id_non_existing_file(): + testee = entrypoint.FileInspector(Path("/non/existing/file")) + with pytest.raises(FileNotFoundError): + testee.group_id + + +def test_file_inspector_group_accessible(accessible_file): + testee = entrypoint.FileInspector(accessible_file) + assert testee.is_group_accessible() + + +def test_file_inspector_insufficient_group_permissions(non_accessible_file): + testee = entrypoint.FileInspector(non_accessible_file) + with pytest.raises(PermissionError, match="No rw permissions for group") as err: + testee.is_group_accessible() + +def test_group_access_enable_existing_group(mocker): + grdb_entry = MagicMock(gr_name="existing") + mocker.patch("grp.getgrgid", return_value=grdb_entry) + mocker.patch("subprocess.run") + testee = entrypoint.GroupAccess("jennifer", entrypoint.Group("other", 666)) + actual = testee.enable() + usermod = mocker.call("usermod --append --groups existing jennifer".split()) + assert entrypoint.Group("existing", 666) == actual \ + and usermod == subprocess.run.call_args + + +def test_group_access_enable_unknown_gid(mocker): + mocker.patch("grp.getgrgid", side_effect=KeyError) + mocker.patch("subprocess.run") + testee = entrypoint.GroupAccess("jennifer", entrypoint.Group("other", 666)) + actual = testee.enable() + groupmod = mocker.call("groupmod -g 666 other".split()) + assert entrypoint.Group("other", 666) == actual\ + and groupmod == subprocess.run.call_args diff --git a/test/unit/entrypoint/test_main.py b/test/unit/entrypoint/test_main.py index 8e6768e9..6b2ecfac 100644 --- a/test/unit/entrypoint/test_main.py +++ b/test/unit/entrypoint/test_main.py @@ -1,21 +1,40 @@ import pytest +from unittest.mock import MagicMock, create_autospec from exasol.ds.sandbox.runtime.ansible.roles.entrypoint.files import entrypoint from pathlib import Path - -def entrypoint_method(name: str) -> str: - return ( - "exasol.ds.sandbox.runtime.ansible.roles" - f".entrypoint.files.entrypoint.{name}" - ) +from test.unit.entrypoint.entrypoint_mock import entrypoint_method def test_no_args(mocker): mocker.patch("sys.argv", ["app"]) - mocker.patch(entrypoint_method("sleep_inifinity")) + mocker.patch(entrypoint_method("sleep_infinity")) + entrypoint.main() + assert entrypoint.sleep_infinity.called + + +def test_user_arg(mocker): + mocker.patch("sys.argv", [ + "app", + "--user", "jennifer", + "--group", "users", + "--docker-group", "docker", + ]) + user = create_autospec(entrypoint.User) + mocker.patch(entrypoint_method("User"), return_value=user) + user.enable_group_access.return_value = user + mocker.patch(entrypoint_method("sleep_infinity")) entrypoint.main() - assert entrypoint.sleep_inifinity.called + assert entrypoint.User.called + args = entrypoint.User.call_args = mocker.call( + "jennifer", + entrypoint.Group("users"), + entrypoint.Group("docker"), + ) + assert user.enable_group_access.called + assert user.enable_group_access.call_args == mocker.call(Path("/var/run/docker.sock")) + assert user.switch_to.called @pytest.mark.parametrize("warning_as_error", [True, False]) @@ -27,7 +46,7 @@ def test_copy_args_valid(mocker, warning_as_error ): "--notebooks", "destination", ] + extra_args) mocker.patch(entrypoint_method("copy_rec")) - mocker.patch(entrypoint_method("sleep_inifinity")) + mocker.patch(entrypoint_method("sleep_infinity")) entrypoint.main() assert entrypoint.copy_rec.called expected = mocker.call(Path("source"), Path("destination"), warning_as_error) @@ -41,6 +60,7 @@ def test_jupyter(mocker): logfile = Path("/root/jupyter-server.log") mocker.patch("sys.argv", [ "app", + "--home", "home-directory", "--notebooks", str(notebook_folder), "--jupyter-server", jupyter, "--port", port, @@ -48,10 +68,19 @@ def test_jupyter(mocker): "--password", "pwd", "--jupyter-logfile", str(logfile), ]) + mocker.patch(entrypoint_method("User")) mocker.patch(entrypoint_method("start_jupyter_server")) - mocker.patch(entrypoint_method("sleep_inifinity")) + mocker.patch(entrypoint_method("sleep_infinity")) entrypoint.main() assert entrypoint.start_jupyter_server.called - expected = mocker.call(jupyter, int(port), notebook_folder, logfile, "usr", "pwd") + expected = mocker.call( + "home-directory", + jupyter, + int(port), + notebook_folder, + logfile, + "usr", + "pwd", + ) assert entrypoint.start_jupyter_server.call_args == expected - assert not entrypoint.sleep_inifinity.called + assert not entrypoint.sleep_infinity.called diff --git a/test/unit/entrypoint/test_start_jupyter_server.py b/test/unit/entrypoint/test_start_jupyter_server.py index 0f569f77..26295f10 100644 --- a/test/unit/entrypoint/test_start_jupyter_server.py +++ b/test/unit/entrypoint/test_start_jupyter_server.py @@ -33,6 +33,7 @@ def create_script(self, before="", after=""): def run(self): entrypoint.start_jupyter_server( + "home", self.script, "port", "notebooks", @@ -44,25 +45,22 @@ def run(self): return self -def test_success(tmp_path, capsys): +def test_success(tmp_path, caplog): testee = Testee(tmp_path).create_script().run() - captured = capsys.readouterr() - assert "Server for Jupyter has been started successfully." in captured.out + assert "Server for Jupyter has been started successfully." in caplog.text -def test_early_error(tmp_path, capsys): +def test_early_error(tmp_path, caplog): with pytest.raises(SystemExit) as ex: testee = Testee(tmp_path).create_script(before="exit 22").run() assert ex.value.code == 22 - captured = capsys.readouterr() - assert "Server for Jupyter has been started successfully." not in captured.out - assert "Jupyter Server terminated with error code 22" in captured.out + assert "Server for Jupyter has been started successfully." not in caplog.text + assert "Jupyter Server terminated with error code 22" in caplog.text -def test_late_error(tmp_path, capsys): +def test_late_error(tmp_path, caplog): with pytest.raises(SystemExit) as ex: testee = Testee(tmp_path).create_script(after="exit 23").run() assert ex.value.code == 23 - captured = capsys.readouterr() - assert "Server for Jupyter has been started successfully." in captured.out - assert "Jupyter Server terminated with error code 23" in captured.out + assert "Server for Jupyter has been started successfully." in caplog.text + assert "Jupyter Server terminated with error code 23" in caplog.text diff --git a/test/unit/entrypoint/test_user_class.py b/test/unit/entrypoint/test_user_class.py new file mode 100644 index 00000000..92a1c64b --- /dev/null +++ b/test/unit/entrypoint/test_user_class.py @@ -0,0 +1,121 @@ +import grp +import os +import pwd +import pytest +import unittest + +from pathlib import Path +from unittest.mock import MagicMock, create_autospec +from exasol.ds.sandbox.runtime.ansible.roles.entrypoint.files import entrypoint +from test.unit.entrypoint.entrypoint_mock import entrypoint_method + + +@pytest.fixture +def user(): + return entrypoint.User( + "jennifer", + entrypoint.Group("users", 901), + entrypoint.Group("docker", 902), + ) + + +@pytest.fixture +def user_with_id(mocker, user): + user._id = 100 + return user + + +def test_group(mocker): + mocker.patch("grp.getgrnam") + testee = entrypoint.Group("my-group").id + assert grp.getgrnam.called + assert grp.getgrnam.call_args == mocker.call("my-group") + + +@pytest.mark.parametrize( + "user, group, docker, expected", [ + (None, "group", "docker", False), + ("user", None, "docker", False), + ("user", "group", None, False), + ("user", "group", "docker_group", True), + ]) +def test_user_specified(user, group, docker, expected): + testee = entrypoint.User( + user, + entrypoint.Group(group), + entrypoint.Group(docker), + ) + assert expected == testee.is_specified + + +def test_uid(mocker, user): + passwd_struct = MagicMock(pw_uid=444) + mocker.patch("pwd.getpwnam", return_value=passwd_struct) + assert 444 == user.id \ + and pwd.getpwnam.called \ + and pwd.getpwnam.call_args == mocker.call("jennifer") + + +def test_enable_file_absent(mocker, user): + mocker.patch(entrypoint_method("GroupAccess")) + user.enable_group_access(Path("/non/existing/path")) + assert not entrypoint.GroupAccess.called + + +def test_enable_non_accessible_file(mocker, user, non_accessible_file): + mocker.patch(entrypoint_method("GroupAccess")) + with pytest.raises(PermissionError, match="No rw permissions for group"): + user.enable_group_access(non_accessible_file) + assert not entrypoint.GroupAccess.called + + +def group_access(mocker, group: entrypoint.Group, find_result: str) -> entrypoint.GroupAccess: + mocker.patch("subprocess.run") + if find_result is None: + mocker.patch("grp.getgrgid", side_effect=KeyError) + else: + grp_struct = MagicMock(gr_name=find_result) + mocker.patch("grp.getgrgid", return_value=grp_struct) + group_access = entrypoint.GroupAccess("user_name", group) + return group_access + + +def test_enable_existing_group(mocker, user, accessible_file): + gid = entrypoint.FileInspector(accessible_file).group_id + group = entrypoint.Group(user.docker_group.name, gid) + mocker.patch( + entrypoint_method("GroupAccess"), + return_value=group_access(mocker, group, group.name), + ) + mocker.patch("os.setgroups") + user.enable_group_access(accessible_file) + assert os.setgroups.called and \ + os.setgroups.call_args == mocker.call([user.docker_group.id]) and \ + user.docker_group == group + + +def test_enable_unknown_group(mocker, user, accessible_file): + group_name = user.docker_group.name + gid = entrypoint.FileInspector(accessible_file).group_id + group = entrypoint.Group(group_name, gid) + mocker.patch( + entrypoint_method("GroupAccess"), + return_value=group_access(mocker, group, None), + ) + mocker.patch("os.setgroups") + user.enable_group_access(accessible_file) + assert os.setgroups.called and \ + os.setgroups.call_args == mocker.call([user.docker_group.id]) and \ + user.docker_group == entrypoint.Group(group_name, gid) + + +def test_switch_to(mocker, user_with_id): + mocker.patch("os.setresuid") + mocker.patch("os.setresgid") + user_with_id.switch_to() + assert os.setresuid.called + uid = user_with_id.id + assert os.setresuid.call_args == mocker.call(uid, uid, uid) + assert os.setresgid.called + gid = user_with_id.group.id + assert os.setresgid.call_args == mocker.call(gid, gid, gid) diff --git a/test/unit/test_dss_docker_image.py b/test/unit/test_dss_docker_image.py index e9cf9dcc..2e852a54 100644 --- a/test/unit/test_dss_docker_image.py +++ b/test/unit/test_dss_docker_image.py @@ -5,7 +5,7 @@ from exasol.ds.sandbox.lib.dss_docker import create_image, DockerRegistry from exasol.ds.sandbox.lib.config import AI_LAB_VERSION from exasol.ds.sandbox.lib.dss_docker.create_image import DssDockerImage - +from typing import Dict, List @pytest.fixture def sample_repo(): @@ -75,39 +75,47 @@ def test_entrypoint_default(facts): def test_entrypoint_with_copy_args(): - jupyter = "/home/jupyter/jupyterenv/bin/jupyter-lab" - port = "port" - entrypoint = "/path/to/entrypoint.py" - initial = "/path/to/initial" - final = "/path/to/final" - user = "jupyter-user-name" - password = "jupyter-default-password" - logfile = "/path/to/jupyter-server.log" facts = { "dss_facts": { + "docker_group": "docker-group-name", "jupyter": { - "command": jupyter, - "port": port, - "user": user, - "password": password, - "logfile": logfile, + "command": "/home/jupyter/jupyterenv/bin/jupyter-lab", + "port": "port", + "user": "jupyter-user-name", + "group": "jupyter-group-name", + "home": "/home/user", + "password": "jupyter-default-password", + "logfile": "/path/to/jupyter-server.log", }, - "entrypoint": entrypoint, + "entrypoint": "/path/to/entrypoint.py", "notebook_folder": { - "initial": initial, - "final": final, + "initial": "/path/to/initial", + "final": "/path/to/final", }}} - assert create_image.entrypoint(facts) == [ - "python3", - entrypoint, - "--notebook-defaults", initial, - "--notebooks", final, - "--jupyter-server", jupyter, - "--port", port, - "--user", user, - "--password", password, - "--jupyter-logfile", logfile, - ] + + def fact(*args): + return create_image.get_fact(facts, *args) + + expected = { + "--docker-group": fact("docker_group"), + "--notebook-defaults": fact("notebook_folder", "initial"), + "--notebooks": fact("notebook_folder", "final"), + "--home": fact("jupyter", "home"), + "--jupyter-server": fact("jupyter", "command"), + "--port": fact("jupyter", "port"), + "--user": fact("jupyter", "user"), + "--group": fact("jupyter", "group"), + "--password": fact("jupyter", "password"), + "--jupyter-logfile": fact("jupyter", "logfile"), + } + actual = create_image.entrypoint(facts) + + def as_dict(lst: List[str]) -> Dict[str, str]: + return { lst[i]: lst[i + 1] for i in range(0, len(lst), 2) } + + assert [ "sudo", "python3", fact("entrypoint") ] == actual[:3] \ + and expected == as_dict(actual[3:]) + @pytest.fixture def mocked_docker_image():