Compare commits

..

4 commits

Author SHA1 Message Date
Alexis Métaireau
b3c0f76060
Allow to define a container_runtime_path 2025-03-24 18:03:32 +01:00
Alexis Métaireau
15e5fa7de8
Only check Docker version if the container runtime is set to docker 2025-03-24 17:52:03 +01:00
Alexis Métaireau
d14b209e79
Allow to read the container runtime from the settings
Add a few tests for this along the way, and update the end-user messages
about Docker/Podman to account for this change.
2025-03-24 17:36:29 +01:00
Alexis Métaireau
0a4140b713
Mock the settings rather than monkeypatching external modules 2025-03-24 16:20:04 +01:00
9 changed files with 160 additions and 112 deletions

View file

@ -1,4 +1,5 @@
import logging import logging
import os
import platform import platform
import shutil import shutil
import subprocess import subprocess
@ -14,14 +15,27 @@ log = logging.getLogger(__name__)
def get_runtime_name() -> str: def get_runtime_name() -> str:
if platform.system() == "Linux": settings = Settings()
runtime_name = "podman" try:
else: runtime_name = settings.get("container_runtime")
# Windows, Darwin, and unknown use docker for now, dangerzone-vm eventually except KeyError:
runtime_name = "docker" return "podman" if platform.system() == "Linux" else "docker"
return runtime_name return runtime_name
def get_runtime() -> str:
container_tech = get_runtime_name()
runtime = shutil.which(container_tech)
if runtime is None:
# Fallback to the container runtime path from the settings
settings = Settings()
runtime_path = settings.get("container_runtime_path")
if os.path.exists(runtime_path):
return runtime_path
raise errors.NoContainerTechException(container_tech)
return runtime
def get_runtime_version() -> Tuple[int, int]: def get_runtime_version() -> Tuple[int, int]:
"""Get the major/minor parts of the Docker/Podman version. """Get the major/minor parts of the Docker/Podman version.
@ -32,13 +46,14 @@ def get_runtime_version() -> Tuple[int, int]:
semver parser is an overkill. semver parser is an overkill.
""" """
# Get the Docker/Podman version, using a Go template. # Get the Docker/Podman version, using a Go template.
runtime = get_runtime_name() runtime_name = get_runtime_name()
if runtime == "podman":
if runtime_name == "podman":
query = "{{.Client.Version}}" query = "{{.Client.Version}}"
else: else:
query = "{{.Server.Version}}" query = "{{.Server.Version}}"
cmd = [runtime, "version", "-f", query] cmd = [get_runtime(), "version", "-f", query]
try: try:
version = subprocess.run( version = subprocess.run(
cmd, cmd,
@ -47,7 +62,7 @@ def get_runtime_version() -> Tuple[int, int]:
check=True, check=True,
).stdout.decode() ).stdout.decode()
except Exception as e: except Exception as e:
msg = f"Could not get the version of the {runtime.capitalize()} tool: {e}" msg = f"Could not get the version of the {runtime_name.capitalize()} tool: {e}"
raise RuntimeError(msg) from e raise RuntimeError(msg) from e
# Parse this version and return the major/minor parts, since we don't need the # Parse this version and return the major/minor parts, since we don't need the
@ -57,20 +72,12 @@ def get_runtime_version() -> Tuple[int, int]:
return (int(major), int(minor)) return (int(major), int(minor))
except Exception as e: except Exception as e:
msg = ( msg = (
f"Could not parse the version of the {runtime.capitalize()} tool" f"Could not parse the version of the {runtime_name.capitalize()} tool"
f" (found: '{version}') due to the following error: {e}" f" (found: '{version}') due to the following error: {e}"
) )
raise RuntimeError(msg) raise RuntimeError(msg)
def get_runtime() -> str:
container_tech = get_runtime_name()
runtime = shutil.which(container_tech)
if runtime is None:
raise errors.NoContainerTechException(container_tech)
return runtime
def list_image_tags() -> List[str]: def list_image_tags() -> List[str]:
"""Get the tags of all loaded Dangerzone images. """Get the tags of all loaded Dangerzone images.

View file

@ -574,8 +574,15 @@ class WaitingWidgetContainer(WaitingWidget):
self.finished.emit() self.finished.emit()
def state_change(self, state: str, error: Optional[str] = None) -> None: def state_change(self, state: str, error: Optional[str] = None) -> None:
custom_runtime = self.dangerzone.settings.custom_runtime_specified()
if state == "not_installed": if state == "not_installed":
if platform.system() == "Linux": if custom_runtime:
self.show_error(
"<strong>We could not find the container runtime defined in your settings</strong><br><br>"
"Please check your settings, install it if needed, and retry."
)
elif platform.system() == "Linux":
self.show_error( self.show_error(
"<strong>Dangerzone requires Podman</strong><br><br>" "<strong>Dangerzone requires Podman</strong><br><br>"
"Install it and retry." "Install it and retry."
@ -588,7 +595,12 @@ class WaitingWidgetContainer(WaitingWidget):
) )
elif state == "not_running": elif state == "not_running":
if platform.system() == "Linux": if custom_runtime:
self.show_error(
"<strong>We were unable to start the container runtime defined in your settings</strong><br><br>"
"Please check your settings, install it if needed, and retry."
)
elif platform.system() == "Linux":
# "not_running" here means that the `podman image ls` command failed. # "not_running" here means that the `podman image ls` command failed.
message = ( message = (
"<strong>Dangerzone requires Podman</strong><br><br>" "<strong>Dangerzone requires Podman</strong><br><br>"

View file

@ -143,7 +143,10 @@ class Container(IsolationProvider):
def check_docker_desktop_version(self) -> Tuple[bool, str]: def check_docker_desktop_version(self) -> Tuple[bool, str]:
# On windows and darwin, check that the minimum version is met # On windows and darwin, check that the minimum version is met
version = "" version = ""
if platform.system() != "Linux": runtime_is_docker = container_utils.get_runtime_name() == "docker"
platform_is_not_linux = platform.system() != "Linux"
if runtime_is_docker and platform_is_not_linux:
with subprocess.Popen( with subprocess.Popen(
["docker", "version", "--format", "{{.Server.Platform.Name}}"], ["docker", "version", "--format", "{{.Server.Platform.Name}}"],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,

View file

@ -10,76 +10,84 @@ from .util import get_config_dir, get_version
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
SETTINGS_FILENAME = get_config_dir() / "settings.json" SETTINGS_FILENAME: str = "settings.json"
SETTINGS: dict = {}
def generate_default_settings() -> Dict[str, Any]: class Settings:
return { settings: Dict[str, Any]
"save": True,
"archive": True,
"ocr": True,
"ocr_language": "English",
"open": True,
"open_app": None,
"safe_extension": SAFE_EXTENSION,
"updater_check": None,
"updater_last_check": None, # last check in UNIX epoch (secs since 1970)
# FIXME: How to invalidate those if they change upstream?
"updater_latest_version": get_version(),
"updater_latest_changelog": "",
"updater_errors": 0,
}
def __init__(self) -> None:
self.settings_filename = get_config_dir() / SETTINGS_FILENAME
self.default_settings: Dict[str, Any] = self.generate_default_settings()
self.load()
def get(key: str) -> Any: @classmethod
return SETTINGS[key] def generate_default_settings(cls) -> Dict[str, Any]:
return {
"save": True,
"archive": True,
"ocr": True,
"ocr_language": "English",
"open": True,
"open_app": None,
"safe_extension": SAFE_EXTENSION,
"updater_check": None,
"updater_last_check": None, # last check in UNIX epoch (secs since 1970)
# FIXME: How to invalidate those if they change upstream?
"updater_latest_version": get_version(),
"updater_latest_changelog": "",
"updater_errors": 0,
}
def custom_runtime_specified(self) -> bool:
return "container_runtime" in self.settings
def set(key: str, val: Any, autosave: bool = False) -> None: def get(self, key: str) -> Any:
try: return self.settings[key]
old_val = SETTINGS.get(key)
except KeyError:
old_val = None
SETTINGS[key] = val
if autosave and val != old_val:
save()
def set(self, key: str, val: Any, autosave: bool = False) -> None:
def get_updater_settings() -> Dict[str, Any]:
return {key: val for key, val in SETTINGS.items() if key.startswith("updater_")}
def load() -> None:
default_settings = default_settings()
if SETTINGS_FILENAME.is_file():
# If the settings file exists, load it
try: try:
with SETTINGS_FILENAME.open("r") as settings_file: old_val = self.get(key)
SETTINGS = json.load(settings_file) except KeyError:
old_val = None
self.settings[key] = val
if autosave and val != old_val:
self.save()
# If it's missing any fields, add them from the default settings def get_updater_settings(self) -> Dict[str, Any]:
for key in default_settings: return {
if key not in SETTINGS: key: val for key, val in self.settings.items() if key.startswith("updater_")
SETTINGS[key] = default_settings[key] }
elif key == "updater_latest_version":
if version.parse(get_version()) > version.parse(get(key)):
set(key, get_version())
except Exception: def load(self) -> None:
log.error("Error loading settings, falling back to default") if os.path.isfile(self.settings_filename):
SETTINGS = default_settings self.settings = self.default_settings
else: # If the settings file exists, load it
# Save with default settings try:
log.info("Settings file doesn't exist, starting with default") with open(self.settings_filename, "r") as settings_file:
SETTINGS = default_settings self.settings = json.load(settings_file)
save() # If it's missing any fields, add them from the default settings
for key in self.default_settings:
if key not in self.settings:
self.settings[key] = self.default_settings[key]
elif key == "updater_latest_version":
if version.parse(get_version()) > version.parse(self.get(key)):
self.set(key, get_version())
except Exception:
log.error("Error loading settings, falling back to default")
self.settings = self.default_settings
def save() -> None: else:
SETTINGS_FILENAME.parent.mkdir(parents=True, exist_ok=True) # Save with default settings
with SETTINGS_FILENAME.open("w") as settings_file: log.info("Settings file doesn't exist, starting with default")
json.dump(SETTINGS, settings_file, indent=4) self.settings = self.default_settings
self.save()
def save(self) -> None:
self.settings_filename.parent.mkdir(parents=True, exist_ok=True)
with self.settings_filename.open("w") as settings_file:
json.dump(self.settings, settings_file, indent=4)

View file

@ -21,34 +21,25 @@ def get_qt_app() -> Application:
def generate_isolated_updater( def generate_isolated_updater(
tmp_path: Path, tmp_path: Path,
monkeypatch: MonkeyPatch, mocker: MockerFixture,
app_mocker: Optional[MockerFixture] = None, mock_app: bool = False,
) -> UpdaterThread: ) -> UpdaterThread:
"""Generate an Updater class with its own settings.""" """Generate an Updater class with its own settings."""
if app_mocker: app = mocker.MagicMock() if mock_app else get_qt_app()
app = app_mocker.MagicMock()
else:
app = get_qt_app()
dummy = Dummy() dummy = Dummy()
# XXX: We can monkey-patch global state without wrapping it in a context manager, or mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
# worrying that it will leak between tests, for two reasons:
#
# 1. Parallel tests in PyTest take place in different processes.
# 2. The monkeypatch fixture tears down the monkey-patch after each test ends.
monkeypatch.setattr(util, "get_config_dir", lambda: tmp_path)
dangerzone = DangerzoneGui(app, isolation_provider=dummy) dangerzone = DangerzoneGui(app, isolation_provider=dummy)
updater = UpdaterThread(dangerzone) updater = UpdaterThread(dangerzone)
return updater return updater
@pytest.fixture @pytest.fixture
def updater( def updater(tmp_path: Path, mocker: MockerFixture) -> UpdaterThread:
tmp_path: Path, monkeypatch: MonkeyPatch, mocker: MockerFixture return generate_isolated_updater(tmp_path, mocker, mock_app=True)
) -> UpdaterThread:
return generate_isolated_updater(tmp_path, monkeypatch, mocker)
@pytest.fixture @pytest.fixture
def qt_updater(tmp_path: Path, monkeypatch: MonkeyPatch) -> UpdaterThread: def qt_updater(tmp_path: Path, mocker: MockerFixture) -> UpdaterThread:
return generate_isolated_updater(tmp_path, monkeypatch) return generate_isolated_updater(tmp_path, mocker, mock_app=False)

View file

@ -48,9 +48,7 @@ def test_default_updater_settings(updater: UpdaterThread) -> None:
) )
def test_pre_0_4_2_settings( def test_pre_0_4_2_settings(tmp_path: Path, mocker: MockerFixture) -> None:
tmp_path: Path, monkeypatch: MonkeyPatch, mocker: MockerFixture
) -> None:
"""Check settings of installations prior to 0.4.2. """Check settings of installations prior to 0.4.2.
Check that installations that have been upgraded from a version < 0.4.2 to >= 0.4.2 Check that installations that have been upgraded from a version < 0.4.2 to >= 0.4.2
@ -58,7 +56,7 @@ def test_pre_0_4_2_settings(
in their settings.json file. in their settings.json file.
""" """
save_settings(tmp_path, default_settings_0_4_1()) save_settings(tmp_path, default_settings_0_4_1())
updater = generate_isolated_updater(tmp_path, monkeypatch, mocker) updater = generate_isolated_updater(tmp_path, mocker, mock_app=True)
assert ( assert (
updater.dangerzone.settings.get_updater_settings() == default_updater_settings() updater.dangerzone.settings.get_updater_settings() == default_updater_settings()
) )
@ -83,12 +81,10 @@ def test_post_0_4_2_settings(
# version is 0.4.3. # version is 0.4.3.
expected_settings = default_updater_settings() expected_settings = default_updater_settings()
expected_settings["updater_latest_version"] = "0.4.3" expected_settings["updater_latest_version"] = "0.4.3"
monkeypatch.setattr( monkeypatch.setattr(settings, "get_version", lambda: "0.4.3")
settings, "get_version", lambda: expected_settings["updater_latest_version"]
)
# Ensure that the Settings class will correct the latest version field to 0.4.3. # Ensure that the Settings class will correct the latest version field to 0.4.3.
updater = generate_isolated_updater(tmp_path, monkeypatch, mocker) updater = generate_isolated_updater(tmp_path, mocker, mock_app=True)
assert updater.dangerzone.settings.get_updater_settings() == expected_settings assert updater.dangerzone.settings.get_updater_settings() == expected_settings
# Simulate an updater check that found a newer Dangerzone version (e.g., 0.4.4). # Simulate an updater check that found a newer Dangerzone version (e.g., 0.4.4).
@ -118,9 +114,7 @@ def test_linux_no_check(updater: UpdaterThread, monkeypatch: MonkeyPatch) -> Non
assert updater.dangerzone.settings.get_updater_settings() == expected_settings assert updater.dangerzone.settings.get_updater_settings() == expected_settings
def test_user_prompts( def test_user_prompts(updater: UpdaterThread, mocker: MockerFixture) -> None:
updater: UpdaterThread, monkeypatch: MonkeyPatch, mocker: MockerFixture
) -> None:
"""Test prompting users to ask them if they want to enable update checks.""" """Test prompting users to ask them if they want to enable update checks."""
# First run # First run
# #
@ -370,8 +364,6 @@ def test_update_errors(
def test_update_check_prompt( def test_update_check_prompt(
qtbot: QtBot, qtbot: QtBot,
qt_updater: UpdaterThread, qt_updater: UpdaterThread,
monkeypatch: MonkeyPatch,
mocker: MockerFixture,
) -> None: ) -> None:
"""Test that the prompt to enable update checks works properly.""" """Test that the prompt to enable update checks works properly."""
# Force Dangerzone to check immediately for updates # Force Dangerzone to check immediately for updates

View file

@ -87,7 +87,7 @@ class TestContainer(IsolationProviderTest):
) -> None: ) -> None:
"""When an image keep being not installed, it should return False""" """When an image keep being not installed, it should return False"""
fp.register_subprocess( fp.register_subprocess(
["podman", "version", "-f", "{{.Client.Version}}"], [container_utils.get_runtime(), "version", "-f", "{{.Client.Version}}"],
stdout="4.0.0", stdout="4.0.0",
) )

View file

@ -0,0 +1,28 @@
from pathlib import Path
from pytest_mock import MockerFixture
from dangerzone.container_utils import get_runtime_name
from dangerzone.settings import Settings
def test_get_runtime_name_from_settings(mocker: MockerFixture, tmp_path: Path) -> None:
mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
settings = Settings()
settings.set("container_runtime", "new-kid-on-the-block", autosave=True)
assert get_runtime_name() == "new-kid-on-the-block"
def test_get_runtime_name_linux(mocker: MockerFixture) -> None:
mocker.patch("platform.system", return_value="Linux")
assert get_runtime_name() == "podman"
def test_get_runtime_name_non_linux(mocker: MockerFixture) -> None:
mocker.patch("platform.system", return_value="Windows")
assert get_runtime_name() == "docker"
mocker.patch("platform.system", return_value="Something else")
assert get_runtime_name() == "docker"

View file

@ -21,6 +21,13 @@ def default_settings_0_4_1() -> dict:
} }
def save_settings(tmp_path: Path, settings: dict) -> None:
"""Mimic the way Settings save a dictionary to a settings.json file."""
settings_filename = tmp_path / "settings.json"
with open(settings_filename, "w") as settings_file:
json.dump(settings, settings_file, indent=4)
def test_no_settings_file_creates_new_one( def test_no_settings_file_creates_new_one(
tmp_path: Path, tmp_path: Path,
mocker: MockerFixture, mocker: MockerFixture,