diff --git a/tests/integrations/docker/conftest.py b/tests/integrations/docker/conftest.py index f2eecf872..2a2a33a12 100644 --- a/tests/integrations/docker/conftest.py +++ b/tests/integrations/docker/conftest.py @@ -1,15 +1,17 @@ import subprocess -from unittest.mock import MagicMock +from pathlib import PurePosixPath +from unittest.mock import MagicMock, call import pytest from briefcase.config import AppConfig from briefcase.integrations.base import ToolCache from briefcase.integrations.docker import DockerAppContext +from briefcase.integrations.docker import Path as PathForMockingDocker @pytest.fixture -def mock_tools(mock_tools) -> ToolCache: +def mock_tools(mock_tools, tmp_path, monkeypatch) -> ToolCache: # Mock stdlib subprocess module mock_tools.subprocess._subprocess = MagicMock(spec_set=subprocess) @@ -19,6 +21,9 @@ def mock_tools(mock_tools) -> ToolCache: mock_tools.os.getuid.return_value = "37" mock_tools.os.getgid.return_value = "42" + # Set the cwd for Docker to pytest's temp dir for user mapping inspection + monkeypatch.setattr(PathForMockingDocker, "cwd", MagicMock(return_value=tmp_path)) + # Mock return values for run run_result = MagicMock(spec=subprocess.CompletedProcess, returncode=3) mock_tools.subprocess._subprocess.run.return_value = run_result @@ -66,3 +71,38 @@ def mock_docker_app_context(tmp_path, my_app, mock_tools) -> DockerAppContext: mock_docker_app_context.tools.subprocess._subprocess.Popen.reset_mock() return mock_docker_app_context + + +@pytest.fixture +def user_mapping_run_calls(tmp_path) -> list: + return [ + call( + [ + "docker", + "run", + "--rm", + "--volume", + f"{tmp_path / 'build'}:/host_write_test:z", + "alpine", + "touch", + PurePosixPath("/host_write_test/container_write_test"), + ], + check=True, + stream_output=False, + ), + call( + [ + "docker", + "run", + "--rm", + "--volume", + f"{tmp_path / 'build'}:/host_write_test:z", + "alpine", + "rm", + "-f", + PurePosixPath("/host_write_test/container_write_test"), + ], + check=True, + stream_output=False, + ), + ] diff --git a/tests/integrations/docker/test_DockerAppContext__verify.py b/tests/integrations/docker/test_DockerAppContext__verify.py index fc754b9b4..fc8f10875 100644 --- a/tests/integrations/docker/test_DockerAppContext__verify.py +++ b/tests/integrations/docker/test_DockerAppContext__verify.py @@ -82,7 +82,7 @@ def test_success(mock_tools, first_app_config, verify_kwargs): def test_docker_verify_fail(mock_tools, first_app_config, verify_kwargs): """Failure if Docker cannot be verified.""" mock_tools.subprocess = MagicMock(spec_set=Subprocess) - # Mock the existence of Docker. + # Mock the absence of Docker mock_tools.subprocess.check_output.side_effect = FileNotFoundError with pytest.raises(BriefcaseCommandError, match="Briefcase requires Docker"): @@ -99,9 +99,13 @@ def test_docker_image_build_fail(mock_tools, first_app_config, verify_kwargs): "github.com/docker/buildx v0.10.2 00ed17d\n", ] - mock_tools.subprocess.run.side_effect = subprocess.CalledProcessError( - returncode=80, cmd=["docker" "build"] - ) + mock_tools.subprocess.run.side_effect = [ + # Mock the user mapping inspection calls + "", + "", + # Mock the image build failing + subprocess.CalledProcessError(returncode=80, cmd=["docker" "build"]), + ] with pytest.raises( BriefcaseCommandError, diff --git a/tests/integrations/docker/test_Docker__cache_image.py b/tests/integrations/docker/test_Docker__cache_image.py new file mode 100644 index 000000000..d2c0aef94 --- /dev/null +++ b/tests/integrations/docker/test_Docker__cache_image.py @@ -0,0 +1,71 @@ +import subprocess +from unittest.mock import MagicMock, call + +import pytest + +from briefcase.exceptions import BriefcaseCommandError +from briefcase.integrations.base import ToolCache +from briefcase.integrations.docker import Docker +from briefcase.integrations.subprocess import Subprocess + + +@pytest.fixture +def mock_tools(mock_tools) -> ToolCache: + mock_tools.subprocess = MagicMock(spec_set=Subprocess) + mock_tools.docker = Docker(mock_tools) + return mock_tools + + +def test_cache_image(mock_tools, user_mapping_run_calls): + """A Docker image can be cached.""" + # mock image not being cached in Docker + mock_tools.subprocess.check_output.return_value = "" + + # Cache an image + mock_tools.docker.cache_image("ubuntu:jammy") + + # Confirms that image is not available + mock_tools.subprocess.check_output.assert_called_once_with( + ["docker", "images", "-q", "ubuntu:jammy"] + ) + + # Image is pulled and cached + mock_tools.subprocess.run.assert_has_calls( + user_mapping_run_calls + [call(["docker", "pull", "ubuntu:jammy"], check=True)] + ) + + +def test_cache_image_already_cached(mock_tools, user_mapping_run_calls): + """A Docker image is not pulled if it is already cached.""" + # mock image already cached in Docker + mock_tools.subprocess.check_output.return_value = "99284ca6cea0" + + # Cache an image + mock_tools.docker.cache_image("ubuntu:jammy") + + # Confirms that image is not available + mock_tools.subprocess.check_output.assert_called_once_with( + ["docker", "images", "-q", "ubuntu:jammy"] + ) + + # Image is not pulled and cached + mock_tools.subprocess.run.assert_has_calls(user_mapping_run_calls) + + +def test_cache_bad_image(mock_tools): + """If an image is invalid, an exception raised.""" + # mock image not being cached in Docker + mock_tools.subprocess.check_output.return_value = "" + + # Mock a Docker failure due to a bad image + mock_tools.subprocess.run.side_effect = subprocess.CalledProcessError( + returncode=125, + cmd="docker...", + ) + + # Try to cache an image that doesn't exist: + with pytest.raises( + BriefcaseCommandError, + match=r"Unable to obtain the Docker image for ubuntu:does-not-exist.", + ): + mock_tools.docker.cache_image("ubuntu:does-not-exist") diff --git a/tests/integrations/docker/test_Docker__check_output.py b/tests/integrations/docker/test_Docker__check_output.py index 31887ce95..8cce147e2 100644 --- a/tests/integrations/docker/test_Docker__check_output.py +++ b/tests/integrations/docker/test_Docker__check_output.py @@ -1,4 +1,5 @@ -from unittest.mock import MagicMock +import subprocess +from unittest.mock import MagicMock, call import pytest @@ -16,10 +17,41 @@ def mock_tools(mock_tools) -> ToolCache: def test_check_output(mock_tools): """A command can be invoked on a bare Docker image.""" + # mock image already being cached in Docker + mock_tools.subprocess.check_output.side_effect = ["1ed313b0551f", "output"] # Run the command in a container mock_tools.docker.check_output(["cmd", "arg1", "arg2"], image_tag="ubuntu:jammy") - mock_tools.subprocess.check_output.assert_called_once_with( - ["docker", "run", "--rm", "ubuntu:jammy", "cmd", "arg1", "arg2"] + mock_tools.subprocess.check_output.assert_has_calls( + [ + # Verify image is cached in Docker + call(["docker", "images", "-q", "ubuntu:jammy"]), + # Run command in Docker using image + call(["docker", "run", "--rm", "ubuntu:jammy", "cmd", "arg1", "arg2"]), + ] + ) + + +def test_check_output_fail(mock_tools): + """Any subprocess errors are passed back through directly.""" + # mock image already being cached in Docker and check_output() call fails + mock_tools.subprocess.check_output.side_effect = [ + "1ed313b0551f", + subprocess.CalledProcessError(returncode=1, cmd=["cmd", "arg1", "arg2"]), + ] + + # The CalledProcessError surfaces from Docker().check_output() + with pytest.raises(subprocess.CalledProcessError): + mock_tools.docker.check_output( + ["cmd", "arg1", "arg2"], image_tag="ubuntu:jammy" + ) + + mock_tools.subprocess.check_output.assert_has_calls( + [ + # Verify image is cached in Docker + call(["docker", "images", "-q", "ubuntu:jammy"]), + # Command errors in Docker using image + call(["docker", "run", "--rm", "ubuntu:jammy", "cmd", "arg1", "arg2"]), + ] ) diff --git a/tests/integrations/docker/test_Docker__prepare.py b/tests/integrations/docker/test_Docker__prepare.py deleted file mode 100644 index be3abc556..000000000 --- a/tests/integrations/docker/test_Docker__prepare.py +++ /dev/null @@ -1,52 +0,0 @@ -import subprocess -from unittest.mock import MagicMock - -import pytest - -from briefcase.exceptions import BriefcaseCommandError -from briefcase.integrations.base import ToolCache -from briefcase.integrations.docker import Docker -from briefcase.integrations.subprocess import Subprocess - - -@pytest.fixture -def mock_tools(mock_tools) -> ToolCache: - mock_tools.subprocess = MagicMock(spec_set=Subprocess) - mock_tools.docker = Docker(mock_tools) - return mock_tools - - -def test_prepare(mock_tools): - """A docker image can be prepared.""" - - # Prepare an image - mock_tools.docker.prepare("ubuntu:jammy") - - mock_tools.subprocess.run.assert_called_once_with( - ["docker", "run", "--rm", "ubuntu:jammy", "printf", ""], - check=True, - stream_output=False, - ) - - -def test_prepare_bad_image(mock_tools): - """If an image is invalid, an exception raised,""" - # Mock a Docker failure due to a bad image - mock_tools.subprocess.run.side_effect = subprocess.CalledProcessError( - returncode=125, - cmd="docker...", - ) - - # Try to prepare an image that doesn't exist: - with pytest.raises( - BriefcaseCommandError, - match=r"Unable to obtain the Docker base image ubuntu:does-not-exist.", - ): - mock_tools.docker.prepare("ubuntu:does-not-exist") - - # The subprocess call was made. - mock_tools.subprocess.run.assert_called_once_with( - ["docker", "run", "--rm", "ubuntu:does-not-exist", "printf", ""], - check=True, - stream_output=False, - ) diff --git a/tests/integrations/docker/test_Docker__verify.py b/tests/integrations/docker/test_Docker__verify.py index 712869a85..af83b40aa 100644 --- a/tests/integrations/docker/test_Docker__verify.py +++ b/tests/integrations/docker/test_Docker__verify.py @@ -47,7 +47,9 @@ def test_docker_install_url(host_os): assert host_os in Docker.DOCKER_INSTALL_URL -def test_docker_exists(mock_tools, valid_docker_version, capsys): +def test_docker_exists( + mock_tools, valid_docker_version, user_mapping_run_calls, capsys, tmp_path +): """If docker exists, the Docker wrapper is returned.""" # Mock the return value of Docker Version mock_tools.subprocess.check_output.side_effect = [ @@ -70,6 +72,8 @@ def test_docker_exists(mock_tools, valid_docker_version, capsys): ] ) + mock_tools.subprocess.run.assert_has_calls(user_mapping_run_calls) + # No console output output = capsys.readouterr() assert output.out == "" @@ -89,7 +93,7 @@ def test_docker_doesnt_exist(mock_tools): mock_tools.subprocess.check_output.assert_called_with(["docker", "--version"]) -def test_docker_failure(mock_tools, capsys): +def test_docker_failure(mock_tools, user_mapping_run_calls, capsys): """If docker failed during execution, the Docker wrapper is returned with a warning.""" # Mock the return value of Docker Version @@ -116,6 +120,8 @@ def test_docker_failure(mock_tools, capsys): ] ) + mock_tools.subprocess.run.assert_has_calls(user_mapping_run_calls) + # console output output = capsys.readouterr() assert "** WARNING: Unable to determine if Docker is installed" in output.out @@ -136,7 +142,7 @@ def test_docker_bad_version(mock_tools, capsys): Docker.verify(mock_tools) -def test_docker_unknown_version(mock_tools, capsys): +def test_docker_unknown_version(mock_tools, user_mapping_run_calls, capsys): """If docker exists but the version string doesn't make sense, the Docker wrapper is returned with a warning.""" # Mock a bad return value of `docker --version` @@ -156,6 +162,8 @@ def test_docker_unknown_version(mock_tools, capsys): ] ) + mock_tools.subprocess.run.assert_has_calls(user_mapping_run_calls) + # console output output = capsys.readouterr() assert "** WARNING: Unable to determine the version of Docker" in output.out diff --git a/tests/platforms/linux/appimage/test_create.py b/tests/platforms/linux/appimage/test_create.py index 90670d323..2c70a01c4 100644 --- a/tests/platforms/linux/appimage/test_create.py +++ b/tests/platforms/linux/appimage/test_create.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from briefcase.console import Console, Log @@ -86,7 +88,7 @@ def test_finalize_nodocker(create_command, first_app_config, capsys): "manylinux, tag, host_arch, context", [ # Fallback. - (None, None, "x86_64", {}), + (None, None, "x86_64", {"use_non_root_user": True}), # x86_64 architecture, all versions # Explicit tag ( @@ -96,6 +98,7 @@ def test_finalize_nodocker(create_command, first_app_config, capsys): { "manylinux_image": "manylinux1_x86_64:2023-03-05-271004f", "vendor_base": "centos", + "use_non_root_user": True, }, ), # Explicit latest @@ -103,14 +106,22 @@ def test_finalize_nodocker(create_command, first_app_config, capsys): "manylinux2010", "latest", "x86_64", - {"manylinux_image": "manylinux2010_x86_64:latest", "vendor_base": "centos"}, + { + "manylinux_image": "manylinux2010_x86_64:latest", + "vendor_base": "centos", + "use_non_root_user": True, + }, ), # Implicit latest ( "manylinux2014", None, "x86_64", - {"manylinux_image": "manylinux2014_x86_64:latest", "vendor_base": "centos"}, + { + "manylinux_image": "manylinux2014_x86_64:latest", + "vendor_base": "centos", + "use_non_root_user": True, + }, ), ( "manylinux_2_24", @@ -119,6 +130,7 @@ def test_finalize_nodocker(create_command, first_app_config, capsys): { "manylinux_image": "manylinux_2_24_x86_64:latest", "vendor_base": "debian", + "use_non_root_user": True, }, ), ( @@ -128,6 +140,7 @@ def test_finalize_nodocker(create_command, first_app_config, capsys): { "manylinux_image": "manylinux_2_28_x86_64:latest", "vendor_base": "almalinux", + "use_non_root_user": True, }, ), # non x86 architecture @@ -138,14 +151,24 @@ def test_finalize_nodocker(create_command, first_app_config, capsys): { "manylinux_image": "manylinux2014_aarch64:latest", "vendor_base": "centos", + "use_non_root_user": True, }, ), ], ) def test_output_format_template_context( - create_command, first_app_config, manylinux, tag, host_arch, context + create_command, + first_app_config, + manylinux, + tag, + host_arch, + context, ): """The template context reflects the manylinux name, tag and architecture.""" + # Mock Docker user mapping setting for `use_non_root_user` + create_command.tools.docker = MagicMock() + create_command.tools.docker.is_users_mapped = False + if manylinux: first_app_config.manylinux = manylinux if tag: diff --git a/tests/platforms/linux/appimage/test_mixin.py b/tests/platforms/linux/appimage/test_mixin.py index ac4bd513e..ec491486f 100644 --- a/tests/platforms/linux/appimage/test_mixin.py +++ b/tests/platforms/linux/appimage/test_mixin.py @@ -113,6 +113,7 @@ def test_verify_linux_docker(create_command, tmp_path, first_app_config, monkeyp mock__version_compat = MagicMock(spec=Docker._version_compat) mock__user_access = MagicMock(spec=Docker._user_access) mock__buildx_installed = MagicMock(spec=Docker._buildx_installed) + mock__is_user_mapping_enabled = MagicMock(spec=Docker._is_user_mapping_enabled) monkeypatch.setattr( briefcase.platforms.linux.appimage.Docker, "_version_compat", @@ -128,6 +129,11 @@ def test_verify_linux_docker(create_command, tmp_path, first_app_config, monkeyp "_buildx_installed", mock__buildx_installed, ) + monkeypatch.setattr( + briefcase.platforms.linux.appimage.Docker, + "_is_user_mapping_enabled", + mock__is_user_mapping_enabled, + ) mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify) monkeypatch.setattr( briefcase.platforms.linux.appimage.DockerAppContext, @@ -143,6 +149,7 @@ def test_verify_linux_docker(create_command, tmp_path, first_app_config, monkeyp mock__version_compat.assert_called_with(tools=create_command.tools) mock__user_access.assert_called_with(tools=create_command.tools) mock__buildx_installed.assert_called_with(tools=create_command.tools) + mock__is_user_mapping_enabled.assert_called_with(None) assert isinstance(create_command.tools.docker, Docker) mock_docker_app_context_verify.assert_called_with( tools=create_command.tools, @@ -181,6 +188,7 @@ def test_verify_non_linux_docker( mock__version_compat = MagicMock(spec=Docker._version_compat) mock__user_access = MagicMock(spec=Docker._user_access) mock__buildx_installed = MagicMock(spec=Docker._buildx_installed) + mock__is_user_mapping_enabled = MagicMock(spec=Docker._is_user_mapping_enabled) monkeypatch.setattr( briefcase.platforms.linux.appimage.Docker, "_version_compat", @@ -196,6 +204,11 @@ def test_verify_non_linux_docker( "_buildx_installed", mock__buildx_installed, ) + monkeypatch.setattr( + briefcase.platforms.linux.appimage.Docker, + "_is_user_mapping_enabled", + mock__is_user_mapping_enabled, + ) mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify) monkeypatch.setattr( briefcase.platforms.linux.appimage.DockerAppContext, @@ -211,6 +224,7 @@ def test_verify_non_linux_docker( mock__version_compat.assert_called_with(tools=create_command.tools) mock__user_access.assert_called_with(tools=create_command.tools) mock__buildx_installed.assert_called_with(tools=create_command.tools) + mock__is_user_mapping_enabled.assert_called_with(None) assert isinstance(create_command.tools.docker, Docker) mock_docker_app_context_verify.assert_called_with( tools=create_command.tools, diff --git a/tests/platforms/linux/system/test_create.py b/tests/platforms/linux/system/test_create.py index 5475f3ecb..fb903dba6 100644 --- a/tests/platforms/linux/system/test_create.py +++ b/tests/platforms/linux/system/test_create.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from briefcase.exceptions import UnsupportedHostError @@ -66,7 +68,7 @@ def test_supported_host_os_without_docker(create_command): def test_output_format_template_context(create_command, first_app_config): - "The template context contains additional deb-specific properties" + """The template context contains additional deb-specific properties.""" # Add some properties defined in config finalization first_app_config.python_version_tag = "3.X" first_app_config.target_image = "somevendor:surprising" @@ -75,6 +77,10 @@ def test_output_format_template_context(create_command, first_app_config): first_app_config.target_vendor_base = "basevendor" first_app_config.glibc_version = "2.42" + # Mock Docker user mapping setting for `use_non_root_user` + create_command.tools.docker = MagicMock() + create_command.tools.docker.is_users_mapped = False + # Add a long description first_app_config.long_description = "This is a long\ndescription." @@ -87,4 +93,5 @@ def test_output_format_template_context(create_command, first_app_config): "python_version": "3.X", "docker_base_image": "somevendor:surprising", "vendor_base": "basevendor", + "use_non_root_user": True, } diff --git a/tests/platforms/linux/system/test_mixin__finalize_app_config.py b/tests/platforms/linux/system/test_mixin__finalize_app_config.py index ecb5f8b4d..8c84afe44 100644 --- a/tests/platforms/linux/system/test_mixin__finalize_app_config.py +++ b/tests/platforms/linux/system/test_mixin__finalize_app_config.py @@ -30,9 +30,6 @@ def test_docker(create_command, first_app_config): # Finalize the app config create_command.finalize_app_config(first_app_config) - # The base image has been prepared - create_command.tools.docker.prepare.assert_called_once_with("somevendor:surprising") - # The app's image, vendor and codename have been constructed from the target image assert first_app_config.target_image == "somevendor:surprising" assert first_app_config.target_vendor == "somevendor" diff --git a/tests/platforms/linux/system/test_mixin__verify.py b/tests/platforms/linux/system/test_mixin__verify.py index 88fb0ab3d..3ad4dfb16 100644 --- a/tests/platforms/linux/system/test_mixin__verify.py +++ b/tests/platforms/linux/system/test_mixin__verify.py @@ -52,6 +52,7 @@ def test_linux_docker(create_command, tmp_path, first_app_config, monkeypatch): mock__version_compat = MagicMock(spec=Docker._version_compat) mock__user_access = MagicMock(spec=Docker._user_access) mock__buildx_installed = MagicMock(spec=Docker._buildx_installed) + mock__is_user_mapping_enabled = MagicMock(spec=Docker._is_user_mapping_enabled) monkeypatch.setattr( briefcase.platforms.linux.system.Docker, "_version_compat", @@ -67,6 +68,11 @@ def test_linux_docker(create_command, tmp_path, first_app_config, monkeypatch): "_buildx_installed", mock__buildx_installed, ) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_is_user_mapping_enabled", + mock__is_user_mapping_enabled, + ) mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify) monkeypatch.setattr( briefcase.platforms.linux.system.DockerAppContext, @@ -83,6 +89,7 @@ def test_linux_docker(create_command, tmp_path, first_app_config, monkeypatch): mock__version_compat.assert_called_with(tools=create_command.tools) mock__user_access.assert_called_with(tools=create_command.tools) mock__buildx_installed.assert_called_with(tools=create_command.tools) + mock__is_user_mapping_enabled.assert_called_with("somevendor:surprising") assert isinstance(create_command.tools.docker, Docker) mock_docker_app_context_verify.assert_called_with( tools=create_command.tools, @@ -132,6 +139,7 @@ def test_non_linux_docker(create_command, first_app_config, monkeypatch, tmp_pat mock__version_compat = MagicMock(spec=Docker._version_compat) mock__user_access = MagicMock(spec=Docker._user_access) mock__buildx_installed = MagicMock(spec=Docker._buildx_installed) + mock__is_user_mapping_enabled = MagicMock(spec=Docker._is_user_mapping_enabled) monkeypatch.setattr( briefcase.platforms.linux.system.Docker, "_version_compat", @@ -147,6 +155,11 @@ def test_non_linux_docker(create_command, first_app_config, monkeypatch, tmp_pat "_buildx_installed", mock__buildx_installed, ) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_is_user_mapping_enabled", + mock__is_user_mapping_enabled, + ) mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify) monkeypatch.setattr( briefcase.platforms.linux.system.DockerAppContext, @@ -163,6 +176,7 @@ def test_non_linux_docker(create_command, first_app_config, monkeypatch, tmp_pat mock__version_compat.assert_called_with(tools=create_command.tools) mock__user_access.assert_called_with(tools=create_command.tools) mock__buildx_installed.assert_called_with(tools=create_command.tools) + mock__is_user_mapping_enabled.assert_called_with("somevendor:surprising") assert isinstance(create_command.tools.docker, Docker) mock_docker_app_context_verify.assert_called_with( tools=create_command.tools,