From 8c1312bf836461085514fe5a41a77b034d778319 Mon Sep 17 00:00:00 2001 From: Omar Al-Ithawi Date: Thu, 12 Oct 2023 11:14:11 +0300 Subject: [PATCH] fix: disable `ulimits` in rootless docker mode --- tests/commands/test_dev.py | 17 ++++++++++++++++ tests/test_utils.py | 20 +++++++++++++++++++ tutor/commands/dev.py | 9 +++++++++ tutor/commands/local.py | 1 + .../local/docker-compose.ulimits.yml | 11 ++++++++++ tutor/templates/local/docker-compose.yml | 4 ---- tutor/utils.py | 14 +++++++++++++ 7 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tutor/templates/local/docker-compose.ulimits.yml diff --git a/tests/commands/test_dev.py b/tests/commands/test_dev.py index 0b962d723a3..7719ec54645 100644 --- a/tests/commands/test_dev.py +++ b/tests/commands/test_dev.py @@ -1,10 +1,27 @@ import unittest +from unittest.mock import patch from .base import TestCommandMixin +from tutor.commands import dev + class DevTests(unittest.TestCase, TestCommandMixin): def test_dev_help(self) -> None: result = self.invoke(["dev", "--help"]) self.assertEqual(0, result.exit_code) self.assertIsNone(result.exception) + + @patch("tutor.utils.is_docker_rootless") + def test_rootless_no_ulimits(self, mock_is_docker_rootless) -> None: + configs = { + 'DEV_PROJECT_NAME': 'maple', + } + + mock_is_docker_rootless.return_value = False + task_runner = dev.DevTaskRunner('/app', configs) + self.assertIn('/app/env/local/docker-compose.ulimits.yml', task_runner.docker_compose_files) + + mock_is_docker_rootless.return_value = True + task_runner = dev.DevTaskRunner('/app', configs) + self.assertNotIn('/app/env/local/docker-compose.ulimits.yml', task_runner.docker_compose_files) diff --git a/tests/test_utils.py b/tests/test_utils.py index 93d4a4859c8..2b44787e374 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,6 +3,7 @@ import tempfile import unittest from io import StringIO +import subprocess from typing import List, Tuple from unittest.mock import MagicMock, mock_open, patch @@ -241,6 +242,25 @@ def test_is_http(self) -> None: self.assertFalse(utils.is_http("home/user/")) self.assertFalse(utils.is_http("http-home/user/")) + @patch("subprocess.run") + def test_is_docker_rootless(self, mock_run) -> None: + # Mock rootless `docker info` output + utils.is_docker_rootless.cache_clear() + mock_run.return_value.stdout = 'some prefix\n rootless foo bar'.encode('utf-8') + self.assertTrue(utils.is_docker_rootless()) + + # Mock regular `docker info` output + utils.is_docker_rootless.cache_clear() + mock_run.return_value.stdout = 'some prefix, regular docker'.encode('utf-8') + self.assertFalse(utils.is_docker_rootless()) + + @patch("subprocess.run") + def test_is_docker_rootless_podman(self, mock_run) -> None: + """Test the `is_docker_rootless` when podman is used or any other error with `docker info`""" + utils.is_docker_rootless.cache_clear() + mock_run.side_effect = subprocess.CalledProcessError(1, "docker info") + self.assertFalse(utils.is_docker_rootless()) + def test_format_table(self) -> None: rows: List[Tuple[str, ...]] = [ ("a", "xyz", "value 1"), diff --git a/tutor/commands/dev.py b/tutor/commands/dev.py index 659e303dabb..388c60eca59 100644 --- a/tutor/commands/dev.py +++ b/tutor/commands/dev.py @@ -8,6 +8,7 @@ from tutor import hooks from tutor.commands import compose from tutor.types import Config, get_typed +from tutor import utils class DevTaskRunner(compose.ComposeTaskRunner): @@ -23,6 +24,14 @@ def __init__(self, root: str, config: Config): tutor_env.pathjoin(self.root, "local", "docker-compose.override.yml"), tutor_env.pathjoin(self.root, "dev", "docker-compose.override.yml"), ] + + + if not utils.is_docker_rootless(): + # Setting `ulimits` throws an error when running in rootless mode. + self.docker_compose_files += [ + tutor_env.pathjoin(self.root, "local", "docker-compose.ulimits.yml"), + ] + self.docker_compose_job_files += [ tutor_env.pathjoin(self.root, "local", "docker-compose.jobs.yml"), tutor_env.pathjoin(self.root, "dev", "docker-compose.jobs.yml"), diff --git a/tutor/commands/local.py b/tutor/commands/local.py index 320edaab796..7c3bcbf730d 100644 --- a/tutor/commands/local.py +++ b/tutor/commands/local.py @@ -17,6 +17,7 @@ def __init__(self, root: str, config: Config): self.project_name = get_typed(self.config, "LOCAL_PROJECT_NAME", str) self.docker_compose_files += [ tutor_env.pathjoin(self.root, "local", "docker-compose.yml"), + tutor_env.pathjoin(self.root, "local", "docker-compose.ulimits.yml"), tutor_env.pathjoin(self.root, "local", "docker-compose.prod.yml"), tutor_env.pathjoin(self.root, "local", "docker-compose.override.yml"), ] diff --git a/tutor/templates/local/docker-compose.ulimits.yml b/tutor/templates/local/docker-compose.ulimits.yml new file mode 100644 index 00000000000..04f5155af4f --- /dev/null +++ b/tutor/templates/local/docker-compose.ulimits.yml @@ -0,0 +1,11 @@ +# This file contains the `ulimits` configuration which is skipped on rootless Docker because +# it's not supported. +version: "{{ DOCKER_COMPOSE_VERSION }}" +services: + {% if RUN_ELASTICSEARCH -%} + elasticsearch: + ulimits: + memlock: + soft: -1 + hard: -1 + {%- endif %} diff --git a/tutor/templates/local/docker-compose.yml b/tutor/templates/local/docker-compose.yml index a2ace7c57bd..e9a3e121229 100644 --- a/tutor/templates/local/docker-compose.yml +++ b/tutor/templates/local/docker-compose.yml @@ -57,10 +57,6 @@ services: - bootstrap.memory_lock=true - discovery.type=single-node - "ES_JAVA_OPTS=-Xms{{ ELASTICSEARCH_HEAP_SIZE }} -Xmx{{ ELASTICSEARCH_HEAP_SIZE }}" - ulimits: - memlock: - soft: -1 - hard: -1 restart: unless-stopped user: "1000:1000" volumes: diff --git a/tutor/utils.py b/tutor/utils.py index 59adee43778..18dba364bad 100644 --- a/tutor/utils.py +++ b/tutor/utils.py @@ -192,6 +192,20 @@ def is_buildkit_enabled() -> bool: return False +@lru_cache(maxsize=None) +def is_docker_rootless() -> bool: + """ + A helper function to determine if Docker is running in rootless mode. + + - https://docs.docker.com/engine/security/rootless/ + """ + try: + results = subprocess.run(["docker", "info"], capture_output=True, check=True) + return "rootless" in results.stdout.decode() + except subprocess.CalledProcessError: + return False + + def docker_compose(*command: str) -> int: return execute("docker", "compose", *command)