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.
This commit is contained in:
Alexis Métaireau 2025-03-03 12:59:36 +01:00
parent bdceee53d0
commit 8d7e965553
No known key found for this signature in database
GPG key ID: C65C7A89A8FFC56E
5 changed files with 84 additions and 31 deletions

View file

@ -4,14 +4,16 @@ import platform
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple from typing import IO, Callable, List, Optional, Tuple
from . import errors from . import errors
from .settings import Settings from .settings import Settings
from .util import get_resource_path, get_subprocess_startupinfo from .util import get_resource_path, get_subprocess_startupinfo
OLD_CONTAINER_NAME = "dangerzone.rocks/dangerzone" 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__) 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] 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.""" """Pull a container image from a registry."""
runtime = Runtime() runtime = Runtime()
cmd = [str(runtime.path), "pull", f"{image}@sha256:{manifest_digest}"] cmd = [str(runtime.path), "pull", f"{image}@sha256:{manifest_digest}"]
try: process = subprocess.Popen(
subprocess_run(cmd, check=True) cmd,
except subprocess.CalledProcessError as e: 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( raise errors.ContainerPullException(
f"Could not pull the container image: {e}" f"Could not pull the container image: {process.returncode}"
) from e )
def get_local_image_digest(image: str) -> str: def get_local_image_digest(image: str) -> str:

View file

@ -1,3 +1,4 @@
import io
import logging import logging
import os import os
import platform import platform
@ -5,22 +6,24 @@ import tempfile
import typing import typing
from multiprocessing.pool import ThreadPool from multiprocessing.pool import ThreadPool
from pathlib import Path 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. # FIXME: See https://github.com/freedomofpress/dangerzone/issues/320 for more details.
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from PySide2 import QtCore, QtGui, QtSvg, QtWidgets from PySide2 import QtCore, QtGui, QtSvg, QtWidgets
from PySide2.QtCore import Qt from PySide2.QtCore import Qt
from PySide2.QtGui import QTextCursor
from PySide2.QtWidgets import QAction, QTextEdit from PySide2.QtWidgets import QAction, QTextEdit
else: else:
try: try:
from PySide6 import QtCore, QtGui, QtSvg, QtWidgets from PySide6 import QtCore, QtGui, QtSvg, QtWidgets
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QAction from PySide6.QtGui import QAction, QTextCursor
from PySide6.QtWidgets import QTextEdit from PySide6.QtWidgets import QTextEdit
except ImportError: except ImportError:
from PySide2 import QtCore, QtGui, QtSvg, QtWidgets from PySide2 import QtCore, QtGui, QtSvg, QtWidgets
from PySide2.QtCore import Qt from PySide2.QtCore import Qt
from PySide2.QtGui import QTextCursor
from PySide2.QtWidgets import QAction, QTextEdit from PySide2.QtWidgets import QAction, QTextEdit
from .. import errors from .. import errors
@ -436,15 +439,21 @@ class MainWindow(QtWidgets.QMainWindow):
class InstallContainerThread(QtCore.QThread): class InstallContainerThread(QtCore.QThread):
finished = QtCore.Signal(str) 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__() super(InstallContainerThread, self).__init__()
self.dangerzone = dangerzone self.dangerzone = dangerzone
def run(self) -> None: def run(self) -> None:
error = None error = None
try: 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: except Exception as e:
log.error("Container installation problem") log.error("Container installation problem")
error = format_exception(e) error = format_exception(e)
@ -479,11 +488,20 @@ class TracebackWidget(QTextEdit):
# Enable copying # Enable copying
self.setTextInteractionFlags(Qt.TextSelectableByMouse) self.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.current_output = ""
def set_content(self, error: Optional[str] = None) -> None: def set_content(self, error: Optional[str] = None) -> None:
if error: if error:
self.setPlainText(error) self.setPlainText(error)
self.setVisible(True) 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): class WaitingWidgetContainer(WaitingWidget):
# These are the possible states that the WaitingWidget can show. # These are the possible states that the WaitingWidget can show.
@ -623,8 +641,14 @@ class WaitingWidgetContainer(WaitingWidget):
"Installing the Dangerzone container image.<br><br>" "Installing the Dangerzone container image.<br><br>"
"This might take a few minutes..." "This might take a few minutes..."
) )
self.traceback.setVisible(True)
self.install_container_t = InstallContainerThread(self.dangerzone) self.install_container_t = InstallContainerThread(self.dangerzone)
self.install_container_t.finished.connect(self.installation_finished) 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() self.install_container_t.start()

View file

@ -95,7 +95,7 @@ class IsolationProvider(ABC):
return self.debug or getattr(sys, "dangerzone_dev", False) return self.debug or getattr(sys, "dangerzone_dev", False)
@abstractmethod @abstractmethod
def install(self) -> bool: def install(self, should_upgrade: bool, callback: Callable) -> bool:
pass pass
def convert( def convert(

View file

@ -3,7 +3,7 @@ import os
import platform import platform
import shlex import shlex
import subprocess import subprocess
from typing import List, Tuple from typing import Callable, List, Tuple
from .. import container_utils, errors, updater from .. import container_utils, errors, updater
from ..container_utils import Runtime from ..container_utils import Runtime
@ -94,27 +94,38 @@ class Container(IsolationProvider):
return security_args return security_args
@staticmethod @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.""" """Check if an update is available and install it if necessary."""
# XXX Do this only if users have opted in to auto-updates if not should_upgrade:
if False: # Comment this for now, just as an exemple of this can be implemented log.debug("Skipping container upgrade check as requested by the settings")
# # Load the image tarball into the container runtime. else:
update_available, image_digest = updater.is_update_available( 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: if update_available and image_digest:
log.debug("Upgrading container image to %s", image_digest)
updater.upgrade_container_image( updater.upgrade_container_image(
container_utils.CONTAINER_NAME, container_utils.CONTAINER_NAME,
image_digest, image_digest,
updater.DEFAULT_PUBKEY_LOCATION, updater.DEFAULT_PUBKEY_LOCATION,
callback=callback,
) )
for tag in old_tags: else:
tag = container_utils.CONTAINER_NAME + ":" + tag log.debug("No update available for the container")
container_utils.delete_image_tag(tag) try:
updater.verify_local_image( updater.verify_local_image(
container_utils.CONTAINER_NAME, updater.DEFAULT_PUBKEY_LOCATION 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 return True

View file

@ -9,7 +9,7 @@ from hashlib import sha256
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory 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 container_utils as runtime
from .. import errors as dzerrors from .. import errors as dzerrors
@ -370,7 +370,9 @@ def load_and_verify_signatures(
return 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: Store signatures locally in the SIGNATURE_PATH folder, like this:
@ -415,6 +417,7 @@ def store_signatures(signatures: list[Dict], image_digest: str, pubkey: str) ->
) )
json.dump(signatures, f) json.dump(signatures, f)
if update_logindex:
write_log_index(get_log_index_from_signatures(signatures)) write_log_index(get_log_index_from_signatures(signatures))
@ -479,14 +482,16 @@ def prepare_airgapped_archive(image_name: str, destination: str) -> None:
archive.add(tmpdir, arcname=".") 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.""" """Verify and upgrade the image to the latest, if signed."""
update_available, remote_digest = registry.is_new_remote_image_available(image) update_available, remote_digest = registry.is_new_remote_image_available(image)
if not update_available: if not update_available:
raise errors.ImageAlreadyUpToDate("The image is already up to date") raise errors.ImageAlreadyUpToDate("The image is already up to date")
signatures = check_signatures_and_logindex(image, remote_digest, pubkey) 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 the signatures just now to avoid storing them unverified
store_signatures(signatures, manifest_digest, pubkey) store_signatures(signatures, manifest_digest, pubkey)