From 8d7e96555372f30b321ed4f75e8ac85b0296594d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Mon, 3 Mar 2025 12:59:36 +0100 Subject: [PATCH] Display the `{podman,docker} pull` progress when installing a new image The progressbars we see when using this same commands on the command line doesn't seem to be passed to the python process here, unfortunately. --- dangerzone/container_utils.py | 29 ++++++++++++----- dangerzone/gui/main_window.py | 32 ++++++++++++++++--- dangerzone/isolation_provider/base.py | 2 +- dangerzone/isolation_provider/container.py | 37 ++++++++++++++-------- dangerzone/updater/signatures.py | 15 ++++++--- 5 files changed, 84 insertions(+), 31 deletions(-) diff --git a/dangerzone/container_utils.py b/dangerzone/container_utils.py index 7c8f06d..26621fb 100644 --- a/dangerzone/container_utils.py +++ b/dangerzone/container_utils.py @@ -4,14 +4,16 @@ import platform import shutil import subprocess from pathlib import Path -from typing import List, Optional, Tuple +from typing import IO, Callable, List, Optional, Tuple from . import errors from .settings import Settings from .util import get_resource_path, get_subprocess_startupinfo OLD_CONTAINER_NAME = "dangerzone.rocks/dangerzone" -CONTAINER_NAME = "ghcr.io/freedomofpress/dangerzone/dangerzone" +CONTAINER_NAME = ( + "ghcr.io/almet/dangerzone/dangerzone" +) # FIXME: Change this to the correct container name log = logging.getLogger(__name__) @@ -228,16 +230,27 @@ def get_image_id_by_digest(digest: str) -> str: return process.stdout.decode().strip().split("\n")[0] -def container_pull(image: str, manifest_digest: str): +def container_pull(image: str, manifest_digest: str, callback: Callable): """Pull a container image from a registry.""" runtime = Runtime() cmd = [str(runtime.path), "pull", f"{image}@sha256:{manifest_digest}"] - try: - subprocess_run(cmd, check=True) - except subprocess.CalledProcessError as e: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + universal_newlines=True, + ) + + for line in process.stdout: # type: ignore + callback(line) + + process.wait() + if process.returncode != 0: raise errors.ContainerPullException( - f"Could not pull the container image: {e}" - ) from e + f"Could not pull the container image: {process.returncode}" + ) def get_local_image_digest(image: str) -> str: diff --git a/dangerzone/gui/main_window.py b/dangerzone/gui/main_window.py index 5924aae..2383222 100644 --- a/dangerzone/gui/main_window.py +++ b/dangerzone/gui/main_window.py @@ -1,3 +1,4 @@ +import io import logging import os import platform @@ -5,22 +6,24 @@ import tempfile import typing from multiprocessing.pool import ThreadPool from pathlib import Path -from typing import List, Optional +from typing import Callable, List, Optional # FIXME: See https://github.com/freedomofpress/dangerzone/issues/320 for more details. if typing.TYPE_CHECKING: from PySide2 import QtCore, QtGui, QtSvg, QtWidgets from PySide2.QtCore import Qt + from PySide2.QtGui import QTextCursor from PySide2.QtWidgets import QAction, QTextEdit else: try: from PySide6 import QtCore, QtGui, QtSvg, QtWidgets from PySide6.QtCore import Qt - from PySide6.QtGui import QAction + from PySide6.QtGui import QAction, QTextCursor from PySide6.QtWidgets import QTextEdit except ImportError: from PySide2 import QtCore, QtGui, QtSvg, QtWidgets from PySide2.QtCore import Qt + from PySide2.QtGui import QTextCursor from PySide2.QtWidgets import QAction, QTextEdit from .. import errors @@ -436,15 +439,21 @@ class MainWindow(QtWidgets.QMainWindow): class InstallContainerThread(QtCore.QThread): finished = QtCore.Signal(str) + process_stdout = QtCore.Signal(str) - def __init__(self, dangerzone: DangerzoneGui) -> None: + def __init__( + self, dangerzone: DangerzoneGui, callback: Optional[Callable] = None + ) -> None: super(InstallContainerThread, self).__init__() self.dangerzone = dangerzone def run(self) -> None: error = None try: - installed = self.dangerzone.isolation_provider.install() + should_upgrade = self.dangerzone.settings.get("updater_check_all") + installed = self.dangerzone.isolation_provider.install( + should_upgrade=bool(should_upgrade), callback=self.process_stdout.emit + ) except Exception as e: log.error("Container installation problem") error = format_exception(e) @@ -479,11 +488,20 @@ class TracebackWidget(QTextEdit): # Enable copying self.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.current_output = "" + def set_content(self, error: Optional[str] = None) -> None: if error: self.setPlainText(error) self.setVisible(True) + def process_output(self, line): + self.current_output += line + self.setText(self.current_output) + cursor = self.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.setTextCursor(cursor) + class WaitingWidgetContainer(WaitingWidget): # These are the possible states that the WaitingWidget can show. @@ -623,8 +641,14 @@ class WaitingWidgetContainer(WaitingWidget): "Installing the Dangerzone container image.

" "This might take a few minutes..." ) + self.traceback.setVisible(True) + self.install_container_t = InstallContainerThread(self.dangerzone) self.install_container_t.finished.connect(self.installation_finished) + + self.install_container_t.process_stdout.connect( + self.traceback.process_output + ) self.install_container_t.start() diff --git a/dangerzone/isolation_provider/base.py b/dangerzone/isolation_provider/base.py index 0a12be1..27d1c09 100644 --- a/dangerzone/isolation_provider/base.py +++ b/dangerzone/isolation_provider/base.py @@ -95,7 +95,7 @@ class IsolationProvider(ABC): return self.debug or getattr(sys, "dangerzone_dev", False) @abstractmethod - def install(self) -> bool: + def install(self, should_upgrade: bool, callback: Callable) -> bool: pass def convert( diff --git a/dangerzone/isolation_provider/container.py b/dangerzone/isolation_provider/container.py index a5bb6b7..50bf64d 100644 --- a/dangerzone/isolation_provider/container.py +++ b/dangerzone/isolation_provider/container.py @@ -3,7 +3,7 @@ import os import platform import shlex import subprocess -from typing import List, Tuple +from typing import Callable, List, Tuple from .. import container_utils, errors, updater from ..container_utils import Runtime @@ -94,27 +94,38 @@ class Container(IsolationProvider): return security_args @staticmethod - def install() -> bool: + def install( + should_upgrade: bool, callback: Callable, last_try: bool = False + ) -> bool: """Check if an update is available and install it if necessary.""" - # XXX Do this only if users have opted in to auto-updates - if False: # Comment this for now, just as an exemple of this can be implemented - # # Load the image tarball into the container runtime. + if not should_upgrade: + log.debug("Skipping container upgrade check as requested by the settings") + else: update_available, image_digest = updater.is_update_available( - container_utils.CONTAINER_NAME + container_utils.CONTAINER_NAME, + updater.DEFAULT_PUBKEY_LOCATION, ) if update_available and image_digest: + log.debug("Upgrading container image to %s", image_digest) updater.upgrade_container_image( container_utils.CONTAINER_NAME, image_digest, updater.DEFAULT_PUBKEY_LOCATION, + callback=callback, ) - for tag in old_tags: - tag = container_utils.CONTAINER_NAME + ":" + tag - container_utils.delete_image_tag(tag) - - updater.verify_local_image( - container_utils.CONTAINER_NAME, updater.DEFAULT_PUBKEY_LOCATION - ) + else: + log.debug("No update available for the container") + try: + updater.verify_local_image( + container_utils.CONTAINER_NAME, updater.DEFAULT_PUBKEY_LOCATION + ) + except errors.ImageNotPresentException: + if last_try: + raise + log.debug("Container image not found, trying to install it.") + return Container.install( + should_upgrade=should_upgrade, callback=callback, last_try=True + ) return True diff --git a/dangerzone/updater/signatures.py b/dangerzone/updater/signatures.py index 0c58b8c..5312c2c 100644 --- a/dangerzone/updater/signatures.py +++ b/dangerzone/updater/signatures.py @@ -9,7 +9,7 @@ from hashlib import sha256 from io import BytesIO from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory -from typing import Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple from .. import container_utils as runtime from .. import errors as dzerrors @@ -370,7 +370,9 @@ def load_and_verify_signatures( return signatures -def store_signatures(signatures: list[Dict], image_digest: str, pubkey: str) -> None: +def store_signatures( + signatures: list[Dict], image_digest: str, pubkey: str, update_logindex: bool = True +) -> None: """ Store signatures locally in the SIGNATURE_PATH folder, like this: @@ -415,7 +417,8 @@ def store_signatures(signatures: list[Dict], image_digest: str, pubkey: str) -> ) json.dump(signatures, f) - write_log_index(get_log_index_from_signatures(signatures)) + if update_logindex: + write_log_index(get_log_index_from_signatures(signatures)) def verify_local_image(image: str, pubkey: str) -> bool: @@ -479,14 +482,16 @@ def prepare_airgapped_archive(image_name: str, destination: str) -> None: archive.add(tmpdir, arcname=".") -def upgrade_container_image(image: str, manifest_digest: str, pubkey: str) -> str: +def upgrade_container_image( + image: str, manifest_digest: str, pubkey: str, callback: Callable +) -> str: """Verify and upgrade the image to the latest, if signed.""" update_available, remote_digest = registry.is_new_remote_image_available(image) if not update_available: raise errors.ImageAlreadyUpToDate("The image is already up to date") signatures = check_signatures_and_logindex(image, remote_digest, pubkey) - runtime.container_pull(image, manifest_digest) + runtime.container_pull(image, manifest_digest, callback=callback) # Store the signatures just now to avoid storing them unverified store_signatures(signatures, manifest_digest, pubkey)