From 3d5cacfffbb856b04ddb97bd630efc1a786ffbc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Thu, 28 Nov 2024 17:48:31 +0100 Subject: [PATCH] Warn users if the minimum version of Docker Desktop is not met This only happens on Windows and macOS. Fixes #693 --- dangerzone/gui/main_window.py | 48 ++++++++++++ dangerzone/isolation_provider/container.py | 30 +++++++- tests/gui/test_main_window.py | 54 ++++++++++++++ tests/isolation_provider/test_container.py | 87 ++++++++++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) diff --git a/dangerzone/gui/main_window.py b/dangerzone/gui/main_window.py index d03300a17..6aad2c12f 100644 --- a/dangerzone/gui/main_window.py +++ b/dangerzone/gui/main_window.py @@ -124,6 +124,7 @@ def __init__(self, dangerzone: DangerzoneGui) -> None: self.setWindowTitle("Dangerzone") self.setWindowIcon(self.dangerzone.get_window_icon()) + self.alert: Optional[Alert] = None self.setMinimumWidth(600) if platform.system() == "Darwin": @@ -226,6 +227,13 @@ def __init__(self, dangerzone: DangerzoneGui) -> None: # This allows us to make QSS rules conditional on the OS color mode. self.setProperty("OSColorMode", self.dangerzone.app.os_color_mode.value) + if hasattr(self.dangerzone.isolation_provider, "check_docker_desktop_version"): + is_version_valid, version = ( + self.dangerzone.isolation_provider.check_docker_desktop_version() + ) + if not is_version_valid: + self.handle_docker_desktop_version_check(is_version_valid, version) + self.show() def show_update_success(self) -> None: @@ -279,6 +287,46 @@ def toggle_updates_triggered(self) -> None: self.dangerzone.settings.set("updater_check", check) self.dangerzone.settings.save() + def handle_docker_desktop_version_check( + self, is_version_valid: bool, version: str + ) -> None: + hamburger_menu = self.hamburger_button.menu() + sep = hamburger_menu.insertSeparator(hamburger_menu.actions()[0]) + upgrade_action = QAction("Docker Desktop should be upgraded", hamburger_menu) + upgrade_action.setIcon( + QtGui.QIcon( + load_svg_image( + "hamburger_menu_update_dot_error.svg", width=64, height=64 + ) + ) + ) + + message = """ +

A new version of Docker Desktop is available. Please upgrade your system.

+

Visit the Docker Desktop website to download the latest version.

+ Keeping Docker Desktop up to date allows you to have more confidence that your documents are processed safely. + """ + self.alert = Alert( + self.dangerzone, + title="Upgrade Docker Desktop", + message=message, + ok_text="Ok", + has_cancel=False, + ) + + def _launch_alert() -> None: + if self.alert: + self.alert.launch() + + upgrade_action.triggered.connect(_launch_alert) + hamburger_menu.insertAction(sep, upgrade_action) + + self.hamburger_button.setIcon( + QtGui.QIcon( + load_svg_image("hamburger_menu_update_error.svg", width=64, height=64) + ) + ) + def handle_updates(self, report: UpdateReport) -> None: """Handle update reports from the update checker thread. diff --git a/dangerzone/isolation_provider/container.py b/dangerzone/isolation_provider/container.py index 27383ac6d..0213cde9c 100644 --- a/dangerzone/isolation_provider/container.py +++ b/dangerzone/isolation_provider/container.py @@ -3,7 +3,7 @@ import platform import shlex import subprocess -from typing import List +from typing import List, Tuple from .. import container_utils, errors from ..document import Document @@ -11,7 +11,10 @@ from .base import IsolationProvider, terminate_process_group TIMEOUT_KILL = 5 # Timeout in seconds until the kill command returns. - +MINIMUM_DOCKER_DESKTOP = { + "Darwin": "4.36.0", + "Windows": "4.36.0", +} # Define startupinfo for subprocesses if platform.system() == "Windows": @@ -121,6 +124,7 @@ def should_wait_install() -> bool: def is_available() -> bool: container_runtime = container_utils.get_runtime() runtime_name = container_utils.get_runtime_name() + # Can we run `docker/podman image ls` without an error with subprocess.Popen( [container_runtime, "image", "ls"], @@ -135,6 +139,28 @@ def is_available() -> bool: ) return True + def check_docker_desktop_version(self) -> Tuple[bool, str]: + # On windows and darwin, check that the minimum version is met + version = "" + if platform.system() != "Linux": + with subprocess.Popen( + ["docker", "version", "--format", "{{.Server.Platform.Name}}"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + startupinfo=get_subprocess_startupinfo(), + ) as p: + stdout, stderr = p.communicate() + if p.returncode != 0: + # When an error occurs, consider that the check went + # through, as we're checking for installation compatibiliy + # somewhere else already + return True, version + # The output is like "Docker Desktop 4.35.1 (173168)" + version = stdout.decode().replace("Docker Desktop", "").split()[0] + if version < MINIMUM_DOCKER_DESKTOP[platform.system()]: + return False, version + return True, version + def doc_to_pixels_container_name(self, document: Document) -> str: """Unique container name for the doc-to-pixels phase.""" return f"dangerzone-doc-to-pixels-{document.id}" diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py index ccc5db15d..e4fc12732 100644 --- a/tests/gui/test_main_window.py +++ b/tests/gui/test_main_window.py @@ -587,3 +587,57 @@ def test_installation_failure_return_false(qtbot: QtBot, mocker: MockerFixture) assert "the following error occured" in widget.label.text() assert "The image cannot be found" in widget.traceback.toPlainText() + + +def test_up_to_date_docker_desktop_does_nothing( + qtbot: QtBot, mocker: MockerFixture +) -> None: + # Setup install to return False + mock_app = mocker.MagicMock() + dummy = mocker.MagicMock(spec=Container) + dummy.check_docker_desktop_version.return_value = (True, "1.0.0") + dz = DangerzoneGui(mock_app, dummy) + + window = MainWindow(dz) + qtbot.addWidget(window) + + menu_actions = window.hamburger_button.menu().actions() + assert "Docker Desktop should be upgraded" not in [ + a.toolTip() for a in menu_actions + ] + + +def test_outdated_docker_desktop_displays_warning( + qtbot: QtBot, mocker: MockerFixture +) -> None: + # Setup install to return False + mock_app = mocker.MagicMock() + dummy = mocker.MagicMock(spec=Container) + dummy.check_docker_desktop_version.return_value = (False, "1.0.0") + + dz = DangerzoneGui(mock_app, dummy) + + load_svg_spy = mocker.spy(main_window_module, "load_svg_image") + + window = MainWindow(dz) + qtbot.addWidget(window) + + menu_actions = window.hamburger_button.menu().actions() + assert menu_actions[0].toolTip() == "Docker Desktop should be upgraded" + + # Check that the hamburger icon has changed with the expected SVG image. + assert load_svg_spy.call_count == 4 + assert ( + load_svg_spy.call_args_list[2].args[0] == "hamburger_menu_update_dot_error.svg" + ) + + alert_spy = mocker.spy(window.alert, "launch") + + # Clicking the menu item should open a warning message + def _check_alert_displayed() -> None: + alert_spy.assert_any_call() + if window.alert: + window.alert.close() + + QtCore.QTimer.singleShot(0, _check_alert_displayed) + menu_actions[0].trigger() diff --git a/tests/isolation_provider/test_container.py b/tests/isolation_provider/test_container.py index 15a393ffa..a43e24b68 100644 --- a/tests/isolation_provider/test_container.py +++ b/tests/isolation_provider/test_container.py @@ -1,4 +1,5 @@ import os +import platform import pytest from pytest_mock import MockerFixture @@ -108,6 +109,92 @@ def test_install_raises_if_still_not_installed( with pytest.raises(errors.ImageNotPresentException): provider.install() + @pytest.mark.skipif( + platform.system() not in ("Windows", "Darwin"), + reason="macOS and Windows specific", + ) + def test_old_docker_desktop_version_is_detected( + self, mocker: MockerFixture, provider: Container, fp: FakeProcess + ) -> None: + fp.register_subprocess( + [ + "docker", + "version", + "--format", + "{{.Server.Platform.Name}}", + ], + stdout="Docker Desktop 1.0.0 (173100)", + ) + + mocker.patch( + "dangerzone.isolation_provider.container.MINIMUM_DOCKER_DESKTOP", + {"Darwin": "1.0.1", "Windows": "1.0.1"}, + ) + assert (False, "1.0.0") == provider.check_docker_desktop_version() + + @pytest.mark.skipif( + platform.system() not in ("Windows", "Darwin"), + reason="macOS and Windows specific", + ) + def test_up_to_date_docker_desktop_version_is_detected( + self, mocker: MockerFixture, provider: Container, fp: FakeProcess + ) -> None: + fp.register_subprocess( + [ + "docker", + "version", + "--format", + "{{.Server.Platform.Name}}", + ], + stdout="Docker Desktop 1.0.1 (173100)", + ) + + # Require version 1.0.1 + mocker.patch( + "dangerzone.isolation_provider.container.MINIMUM_DOCKER_DESKTOP", + {"Darwin": "1.0.1", "Windows": "1.0.1"}, + ) + assert (True, "1.0.1") == provider.check_docker_desktop_version() + + fp.register_subprocess( + [ + "docker", + "version", + "--format", + "{{.Server.Platform.Name}}", + ], + stdout="Docker Desktop 2.0.0 (173100)", + ) + assert (True, "2.0.0") == provider.check_docker_desktop_version() + + @pytest.mark.skipif( + platform.system() not in ("Windows", "Darwin"), + reason="macOS and Windows specific", + ) + def test_docker_desktop_version_failure_returns_true( + self, mocker: MockerFixture, provider: Container, fp: FakeProcess + ) -> None: + fp.register_subprocess( + [ + "docker", + "version", + "--format", + "{{.Server.Platform.Name}}", + ], + stderr="Oopsie", + returncode=1, + ) + assert provider.check_docker_desktop_version() == (True, "") + + @pytest.mark.skipif( + platform.system() != "Linux", + reason="Linux specific", + ) + def test_linux_skips_desktop_version_check_returns_true( + self, mocker: MockerFixture, provider: Container + ) -> None: + assert (True, "") == provider.check_docker_desktop_version() + class TestContainerTermination(IsolationProviderTermination): pass