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 c517c5b3f9
commit 46b88716d3
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 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:

View file

@ -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.<br><br>"
"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()

View file

@ -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(

View file

@ -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

View file

@ -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
@ -369,7 +369,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:
@ -414,7 +416,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:
@ -478,14 +481,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)