diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 92440e7e2..35f9597ff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,6 +74,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Get current date id: date @@ -83,7 +85,7 @@ jobs: id: cache-container-image uses: actions/cache@v4 with: - key: v2-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} + key: v3-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} path: | share/container.tar.gz share/image-id.txt @@ -95,6 +97,7 @@ jobs: python3 ./install/common/build-image.py echo ${{ github.token }} | podman login ghcr.io -u USERNAME --password-stdin gunzip -c share/container.tar.gz | podman load + tag=$(cat share/image-id.txt) podman push \ - dangerzone.rocks/dangerzone \ - ${{ env.IMAGE_REGISTRY }}/dangerzone/dangerzone + dangerzone.rocks/dangerzone:$tag \ + ${{ env.IMAGE_REGISTRY }}/dangerzone/dangerzone:tag diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb32c6a11..4af11d27c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Get current date id: date @@ -57,7 +59,7 @@ jobs: id: cache-container-image uses: actions/cache@v4 with: - key: v2-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} + key: v3-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} path: |- share/container.tar.gz share/image-id.txt @@ -225,7 +227,7 @@ jobs: - name: Restore container cache uses: actions/cache/restore@v4 with: - key: v2-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} + key: v3-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} path: |- share/container.tar.gz share/image-id.txt @@ -249,7 +251,7 @@ jobs: install-deb: name: "install-deb (${{ matrix.distro }} ${{ matrix.version }})" runs-on: ubuntu-latest - needs: + needs: - build-deb strategy: matrix: @@ -332,7 +334,7 @@ jobs: - name: Restore container image uses: actions/cache/restore@v4 with: - key: v2-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} + key: v3-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} path: |- share/container.tar.gz share/image-id.txt @@ -427,7 +429,7 @@ jobs: - name: Restore container image uses: actions/cache/restore@v4 with: - key: v2-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} + key: v3-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/common.py', 'dangerzone/conversion/doc_to_pixels.py', 'dangerzone/conversion/pixels_to_pdf.py', 'poetry.lock', 'gvisor_wrapper/entrypoint.py') }} path: |- share/container.tar.gz share/image-id.txt diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index d9f397be1..d98510927 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -14,17 +14,24 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install container build dependencies run: sudo apt install pipx && pipx install poetry - name: Build container image run: python3 ./install/common/build-image.py --runtime docker --no-save + - name: Get image tag + id: tag + run: | + tag=$(docker images dangerzone.rocks/dangerzone --format '{{ .Tag }}') + echo "tag=$tag" >> $GITHUB_OUTPUT # NOTE: Scan first without failing, else we won't be able to read the scan # report. - name: Scan container image (no fail) uses: anchore/scan-action@v5 id: scan_container with: - image: "dangerzone.rocks/dangerzone:latest" + image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}" fail-build: false only-fixed: false severity-cutoff: critical @@ -38,7 +45,7 @@ jobs: - name: Scan container image uses: anchore/scan-action@v5 with: - image: "dangerzone.rocks/dangerzone:latest" + image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}" fail-build: true only-fixed: false severity-cutoff: critical diff --git a/.github/workflows/scan_released.yml b/.github/workflows/scan_released.yml index 2bba78cbe..0333e4925 100644 --- a/.github/workflows/scan_released.yml +++ b/.github/workflows/scan_released.yml @@ -24,13 +24,18 @@ jobs: CONTAINER_FILENAME=container-${VERSION:1}-${{ matrix.arch }}.tar.gz wget https://github.com/freedomofpress/dangerzone/releases/download/${VERSION}/${CONTAINER_FILENAME} -O ${CONTAINER_FILENAME} docker load -i ${CONTAINER_FILENAME} + - name: Get image tag + id: tag + run: | + tag=$(docker images dangerzone.rocks/dangerzone --format '{{ .Tag }}') + echo "tag=$tag" >> $GITHUB_OUTPUT # NOTE: Scan first without failing, else we won't be able to read the scan # report. - name: Scan container image (no fail) uses: anchore/scan-action@v5 id: scan_container with: - image: "dangerzone.rocks/dangerzone:latest" + image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}" fail-build: false only-fixed: false severity-cutoff: critical @@ -44,7 +49,7 @@ jobs: - name: Scan container image uses: anchore/scan-action@v5 with: - image: "dangerzone.rocks/dangerzone:latest" + image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}" fail-build: true only-fixed: false severity-cutoff: critical diff --git a/QA.md b/QA.md index dff778095..0f0a76090 100644 --- a/QA.md +++ b/QA.md @@ -107,9 +107,9 @@ Close the Dangerzone application and get the container image for that version. For example: ``` -$ docker images dangerzone.rocks/dangerzone:latest +$ docker images dangerzone.rocks/dangerzone REPOSITORY TAG IMAGE ID CREATED SIZE -dangerzone.rocks/dangerzone latest +dangerzone.rocks/dangerzone ``` Then run the version under QA and ensure that the settings remain changed. @@ -118,9 +118,9 @@ Afterwards check that new docker image was installed by running the same command and seeing the following differences: ``` -$ docker images dangerzone.rocks/dangerzone:latest +$ docker images dangerzone.rocks/dangerzone REPOSITORY TAG IMAGE ID CREATED SIZE -dangerzone.rocks/dangerzone latest +dangerzone.rocks/dangerzone ``` #### 4. Dangerzone successfully installs the container image diff --git a/RELEASE.md b/RELEASE.md index 8d4c0baca..39f9564ff 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -126,7 +126,7 @@ Here is what you need to do: ``` - [ ] Build the container image and the OCR language data - + ```bash poetry run ./install/common/build-image.py poetry run ./install/common/download-tessdata.py @@ -142,12 +142,10 @@ Here is what you need to do: poetry run ./install/macos/build-app.py ``` -- [ ] Make sure that the build application works with the containerd graph - driver (see [#933](https://github.com/freedomofpress/dangerzone/issues/933)) - [ ] Sign the application bundle, and notarize it - + You need to run this command as the account that has access to the code signing certificate - + This command assumes that you have created, and stored in the Keychain, an application password associated with your Apple Developer ID, which will be used specifically for `notarytool`. @@ -212,9 +210,6 @@ The Windows release is performed in a Windows 11 virtual machine (as opposed to - [ ] Copy the container image into the VM > [!IMPORTANT] > Instead of running `python .\install\windows\build-image.py` in the VM, run the build image script on the host (making sure to build for `linux/amd64`). Copy `share/container.tar.gz` and `share/image-id.txt` from the host into the `share` folder in the VM. - > Also, don't forget to add the supplementary image ID (see - > [#933](https://github.com/freedomofpress/dangerzone/issues/933)) in - > `share/image-id.txt`) - [ ] Run `poetry run .\install\windows\build-app.bat` - [ ] When you're done you will have `dist\Dangerzone.msi` @@ -269,7 +264,7 @@ or create your own locally with: ./dev_scripts/env.py --distro fedora --version 41 build-dev # Build the latest container (skip if already built): -./dev_scripts/env.py --distro fedora --version 41 run --dev bash -c "cd dangerzone && poetry run ./install/common/build-image.py" +./dev_scripts/env.py --distro fedora --version 41 run --dev bash -c "cd dangerzone && poetry run ./install/common/build-image.py" # Create a .rpm: ./dev_scripts/env.py --distro fedora --version 41 run --dev bash -c "cd dangerzone && ./install/linux/build-rpm.py" diff --git a/dangerzone/container_utils.py b/dangerzone/container_utils.py new file mode 100644 index 000000000..99c9a0803 --- /dev/null +++ b/dangerzone/container_utils.py @@ -0,0 +1,149 @@ +import gzip +import logging +import platform +import shutil +import subprocess +from typing import List, Tuple + +from . import errors +from .util import get_resource_path, get_subprocess_startupinfo + +CONTAINER_NAME = "dangerzone.rocks/dangerzone" + +log = logging.getLogger(__name__) + + +def get_runtime_name() -> str: + if platform.system() == "Linux": + runtime_name = "podman" + else: + # Windows, Darwin, and unknown use docker for now, dangerzone-vm eventually + runtime_name = "docker" + return runtime_name + + +def get_runtime_version() -> Tuple[int, int]: + """Get the major/minor parts of the Docker/Podman version. + + Some of the operations we perform in this module rely on some Podman features + that are not available across all of our platforms. In order to have a proper + fallback, we need to know the Podman version. More specifically, we're fine with + just knowing the major and minor version, since writing/installing a full-blown + semver parser is an overkill. + """ + # Get the Docker/Podman version, using a Go template. + runtime = get_runtime_name() + if runtime == "podman": + query = "{{.Client.Version}}" + else: + query = "{{.Server.Version}}" + + cmd = [runtime, "version", "-f", query] + try: + version = subprocess.run( + cmd, + startupinfo=get_subprocess_startupinfo(), + capture_output=True, + check=True, + ).stdout.decode() + except Exception as e: + msg = f"Could not get the version of the {runtime.capitalize()} tool: {e}" + raise RuntimeError(msg) from e + + # Parse this version and return the major/minor parts, since we don't need the + # rest. + try: + major, minor, _ = version.split(".", 3) + return (int(major), int(minor)) + except Exception as e: + msg = ( + f"Could not parse the version of the {runtime.capitalize()} tool" + f" (found: '{version}') due to the following error: {e}" + ) + 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]: + """Get the tags of all loaded Dangerzone images. + + This method returns a mapping of image tags to image IDs, for all Dangerzone + images. This can be useful when we want to find which are the local image tags, + and which image ID does the "latest" tag point to. + """ + return ( + subprocess.check_output( + [ + get_runtime(), + "image", + "list", + "--format", + "{{ .Tag }}", + CONTAINER_NAME, + ], + text=True, + startupinfo=get_subprocess_startupinfo(), + ) + .strip() + .split() + ) + + +def delete_image_tag(tag: str) -> None: + """Delete a Dangerzone image tag.""" + name = CONTAINER_NAME + ":" + tag + log.warning(f"Deleting old container image: {name}") + try: + subprocess.check_output( + [get_runtime(), "rmi", "--force", name], + startupinfo=get_subprocess_startupinfo(), + ) + except Exception as e: + log.warning( + f"Couldn't delete old container image '{name}', so leaving it there." + f" Original error: {e}" + ) + + +def get_expected_tag() -> str: + """Get the tag of the Dangerzone image tarball from the image-id.txt file.""" + with open(get_resource_path("image-id.txt")) as f: + return f.read().strip() + + +def load_image_tarball() -> None: + log.info("Installing Dangerzone container image...") + p = subprocess.Popen( + [get_runtime(), "load"], + stdin=subprocess.PIPE, + startupinfo=get_subprocess_startupinfo(), + ) + + chunk_size = 4 << 20 + compressed_container_path = get_resource_path("container.tar.gz") + with gzip.open(compressed_container_path) as f: + while True: + chunk = f.read(chunk_size) + if len(chunk) > 0: + if p.stdin: + p.stdin.write(chunk) + else: + break + _, err = p.communicate() + if p.returncode < 0: + if err: + error = err.decode() + else: + error = "No output" + raise errors.ImageInstallationException( + f"Could not install container image: {error}" + ) + + log.info("Successfully installed container image from") diff --git a/dangerzone/errors.py b/dangerzone/errors.py index a55f508ca..d8e1759f6 100644 --- a/dangerzone/errors.py +++ b/dangerzone/errors.py @@ -117,3 +117,26 @@ def wrapper(*args, **kwargs): # type: ignore sys.exit(1) return cast(F, wrapper) + + +#### Container-related errors + + +class ImageNotPresentException(Exception): + pass + + +class ImageInstallationException(Exception): + pass + + +class NoContainerTechException(Exception): + def __init__(self, container_tech: str) -> None: + super().__init__(f"{container_tech} is not installed") + + +class NotAvailableContainerTechException(Exception): + def __init__(self, container_tech: str, error: str) -> None: + self.error = error + self.container_tech = container_tech + super().__init__(f"{container_tech} is not available") diff --git a/dangerzone/gui/main_window.py b/dangerzone/gui/main_window.py index e292ff0b5..d03300a17 100644 --- a/dangerzone/gui/main_window.py +++ b/dangerzone/gui/main_window.py @@ -25,13 +25,7 @@ from .. import errors from ..document import SAFE_EXTENSION, Document -from ..isolation_provider.container import ( - Container, - NoContainerTechException, - NotAvailableContainerTechException, -) -from ..isolation_provider.dummy import Dummy -from ..isolation_provider.qubes import Qubes, is_qubes_native_conversion +from ..isolation_provider.qubes import is_qubes_native_conversion from ..util import format_exception, get_resource_path, get_version from .logic import Alert, CollapsibleBox, DangerzoneGui, UpdateDialog from .updater import UpdateReport @@ -197,14 +191,11 @@ def __init__(self, dangerzone: DangerzoneGui) -> None: header_layout.addWidget(self.hamburger_button) header_layout.addSpacing(15) - if isinstance(self.dangerzone.isolation_provider, Container): + if self.dangerzone.isolation_provider.should_wait_install(): # Waiting widget replaces content widget while container runtime isn't available self.waiting_widget: WaitingWidget = WaitingWidgetContainer(self.dangerzone) self.waiting_widget.finished.connect(self.waiting_finished) - - elif isinstance(self.dangerzone.isolation_provider, Dummy) or isinstance( - self.dangerzone.isolation_provider, Qubes - ): + else: # Don't wait with dummy converter and on Qubes. self.waiting_widget = WaitingWidget() self.dangerzone.is_waiting_finished = True @@ -500,11 +491,11 @@ def check_state(self) -> None: error: Optional[str] = None try: - self.dangerzone.isolation_provider.is_runtime_available() - except NoContainerTechException as e: + self.dangerzone.isolation_provider.is_available() + except errors.NoContainerTechException as e: log.error(str(e)) state = "not_installed" - except NotAvailableContainerTechException as e: + except errors.NotAvailableContainerTechException as e: log.error(str(e)) state = "not_running" error = e.error diff --git a/dangerzone/isolation_provider/base.py b/dangerzone/isolation_provider/base.py index 6a55a20f1..fd1bd6ac7 100644 --- a/dangerzone/isolation_provider/base.py +++ b/dangerzone/isolation_provider/base.py @@ -93,10 +93,6 @@ def __init__(self) -> None: else: self.proc_stderr = subprocess.DEVNULL - @staticmethod - def is_runtime_available() -> bool: - return True - @abstractmethod def install(self) -> bool: pass @@ -258,6 +254,16 @@ def get_proc_exception( ) return errors.exception_from_error_code(error_code) + @abstractmethod + def should_wait_install(self) -> bool: + """Whether this isolation provider takes a lot of time to install.""" + pass + + @abstractmethod + def is_available(self) -> bool: + """Whether the backing implementation of the isolation provider is available.""" + pass + @abstractmethod def get_max_parallel_conversions(self) -> int: pass diff --git a/dangerzone/isolation_provider/container.py b/dangerzone/isolation_provider/container.py index 94f894de2..1a083851f 100644 --- a/dangerzone/isolation_provider/container.py +++ b/dangerzone/isolation_provider/container.py @@ -1,12 +1,11 @@ -import gzip import logging import os import platform import shlex -import shutil import subprocess -from typing import List, Tuple +from typing import List +from .. import container_utils, errors from ..document import Document from ..util import get_resource_path, get_subprocess_startupinfo from .base import IsolationProvider, terminate_process_group @@ -25,88 +24,8 @@ log = logging.getLogger(__name__) -class NoContainerTechException(Exception): - def __init__(self, container_tech: str) -> None: - super().__init__(f"{container_tech} is not installed") - - -class NotAvailableContainerTechException(Exception): - def __init__(self, container_tech: str, error: str) -> None: - self.error = error - self.container_tech = container_tech - super().__init__(f"{container_tech} is not available") - - -class ImageNotPresentException(Exception): - pass - - -class ImageInstallationException(Exception): - pass - - class Container(IsolationProvider): # Name of the dangerzone container - CONTAINER_NAME = "dangerzone.rocks/dangerzone" - - @staticmethod - def get_runtime_name() -> str: - if platform.system() == "Linux": - runtime_name = "podman" - else: - # Windows, Darwin, and unknown use docker for now, dangerzone-vm eventually - runtime_name = "docker" - return runtime_name - - @staticmethod - def get_runtime_version() -> Tuple[int, int]: - """Get the major/minor parts of the Docker/Podman version. - - Some of the operations we perform in this module rely on some Podman features - that are not available across all of our platforms. In order to have a proper - fallback, we need to know the Podman version. More specifically, we're fine with - just knowing the major and minor version, since writing/installing a full-blown - semver parser is an overkill. - """ - # Get the Docker/Podman version, using a Go template. - runtime = Container.get_runtime_name() - if runtime == "podman": - query = "{{.Client.Version}}" - else: - query = "{{.Server.Version}}" - - cmd = [runtime, "version", "-f", query] - try: - version = subprocess.run( - cmd, - startupinfo=get_subprocess_startupinfo(), - capture_output=True, - check=True, - ).stdout.decode() - except Exception as e: - msg = f"Could not get the version of the {runtime.capitalize()} tool: {e}" - raise RuntimeError(msg) from e - - # Parse this version and return the major/minor parts, since we don't need the - # rest. - try: - major, minor, _ = version.split(".", 3) - return (int(major), int(minor)) - except Exception as e: - msg = ( - f"Could not parse the version of the {runtime.capitalize()} tool" - f" (found: '{version}') due to the following error: {e}" - ) - raise RuntimeError(msg) - - @staticmethod - def get_runtime() -> str: - container_tech = Container.get_runtime_name() - runtime = shutil.which(container_tech) - if runtime is None: - raise NoContainerTechException(container_tech) - return runtime - @staticmethod def get_runtime_security_args() -> List[str]: """Security options applicable to the outer Dangerzone container. @@ -127,12 +46,12 @@ def get_runtime_security_args() -> List[str]: * Do not log the container's output. * Do not map the host user to the container, with `--userns nomap` (available from Podman 4.1 onwards) - - This particular argument is specified in `start_doc_to_pixels_proc()`, but - should move here once #748 is merged. """ - if Container.get_runtime_name() == "podman": + if container_utils.get_runtime_name() == "podman": security_args = ["--log-driver", "none"] security_args += ["--security-opt", "no-new-privileges"] + if container_utils.get_runtime_version() >= (4, 1): + security_args += ["--userns", "nomap"] else: security_args = ["--security-opt=no-new-privileges:true"] @@ -156,51 +75,52 @@ def get_runtime_security_args() -> List[str]: @staticmethod def install() -> bool: + """Install the container image tarball, or verify that it's already installed. + + Perform the following actions: + 1. Get the tags of any locally available images that match Dangerzone's image + name. + 2. Get the expected image tag from the image-id.txt file. + - If this tag is present in the local images, then we can return. + - Else, prune the older container images and continue. + 3. Load the image tarball and make sure it matches the expected tag. """ - Make sure the podman container is installed. Linux only. - """ - if Container.is_container_installed(): - return True + old_tags = container_utils.list_image_tags() + expected_tag = container_utils.get_expected_tag() - # Load the container into podman - log.info("Installing Dangerzone container image...") - - p = subprocess.Popen( - [Container.get_runtime(), "load"], - stdin=subprocess.PIPE, - startupinfo=get_subprocess_startupinfo(), - ) + if expected_tag not in old_tags: + # Prune older container images. + log.info( + f"Could not find a Dangerzone container image with tag '{expected_tag}'" + ) + for tag in old_tags: + container_utils.delete_image_tag(tag) + else: + return True - chunk_size = 4 << 20 - compressed_container_path = get_resource_path("container.tar.gz") - with gzip.open(compressed_container_path) as f: - while True: - chunk = f.read(chunk_size) - if len(chunk) > 0: - if p.stdin: - p.stdin.write(chunk) - else: - break - _, err = p.communicate() - if p.returncode < 0: - if err: - error = err.decode() - else: - error = "No output" - raise ImageInstallationException( - f"Could not install container image: {error}" + # Load the image tarball into the container runtime. + container_utils.load_image_tarball() + + # Check that the container image has the expected image tag. + # See https://github.com/freedomofpress/dangerzone/issues/988 for an example + # where this was not the case. + new_tags = container_utils.list_image_tags() + if expected_tag not in new_tags: + raise errors.ImageNotPresentException( + f"Could not find expected tag '{expected_tag}' after loading the" + " container image tarball" ) - if not Container.is_container_installed(raise_on_error=True): - return False + return True - log.info("Container image installed") + @staticmethod + def should_wait_install() -> bool: return True @staticmethod - def is_runtime_available() -> bool: - container_runtime = Container.get_runtime() - runtime_name = Container.get_runtime_name() + 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"], @@ -210,60 +130,10 @@ def is_runtime_available() -> bool: ) as p: _, stderr = p.communicate() if p.returncode != 0: - raise NotAvailableContainerTechException(runtime_name, stderr.decode()) - return True - - @staticmethod - def is_container_installed(raise_on_error: bool = False) -> bool: - """ - See if the container is installed. - """ - # Get the image id - with open(get_resource_path("image-id.txt")) as f: - expected_image_ids = f.read().strip().split() - - # See if this image is already installed - installed = False - found_image_id = subprocess.check_output( - [ - Container.get_runtime(), - "image", - "list", - "--format", - "{{.ID}}", - Container.CONTAINER_NAME, - ], - text=True, - startupinfo=get_subprocess_startupinfo(), - ) - found_image_id = found_image_id.strip() - - if found_image_id in expected_image_ids: - installed = True - elif found_image_id == "": - if raise_on_error: - raise ImageNotPresentException( - "Image is not listed after installation. Bailing out." + raise errors.NotAvailableContainerTechException( + runtime_name, stderr.decode() ) - else: - msg = ( - f"{Container.CONTAINER_NAME} images found, but IDs do not match." - f" Found: {found_image_id}, Expected: {','.join(expected_image_ids)}" - ) - if raise_on_error: - raise ImageNotPresentException(msg) - log.info(msg) - log.info("Deleting old dangerzone container image") - - try: - subprocess.check_output( - [Container.get_runtime(), "rmi", "--force", found_image_id], - startupinfo=get_subprocess_startupinfo(), - ) - except Exception: - log.warning("Couldn't delete old container image, so leaving it there") - - return installed + return True def doc_to_pixels_container_name(self, document: Document) -> str: """Unique container name for the doc-to-pixels phase.""" @@ -295,21 +165,22 @@ def exec_container( self, command: List[str], name: str, - extra_args: List[str] = [], ) -> subprocess.Popen: - container_runtime = self.get_runtime() + container_runtime = container_utils.get_runtime() security_args = self.get_runtime_security_args() enable_stdin = ["-i"] set_name = ["--name", name] prevent_leakage_args = ["--rm"] + image_name = [ + container_utils.CONTAINER_NAME + ":" + container_utils.get_expected_tag() + ] args = ( ["run"] + security_args + prevent_leakage_args + enable_stdin + set_name - + extra_args - + [self.CONTAINER_NAME] + + image_name + command ) args = [container_runtime] + args @@ -325,7 +196,7 @@ def kill_container(self, name: str) -> None: connected to the Docker daemon, and killing it will just close the associated standard streams. """ - container_runtime = self.get_runtime() + container_runtime = container_utils.get_runtime() cmd = [container_runtime, "kill", name] try: # We do not check the exit code of the process here, since the container may @@ -358,15 +229,8 @@ def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen: "-m", "dangerzone.conversion.doc_to_pixels", ] - # NOTE: Using `--userns nomap` is available only on Podman >= 4.1.0. - # XXX: Move this under `get_runtime_security_args()` once #748 is merged. - extra_args = [] - if Container.get_runtime_name() == "podman": - if Container.get_runtime_version() >= (4, 1): - extra_args += ["--userns", "nomap"] - name = self.doc_to_pixels_container_name(document) - return self.exec_container(command, name=name, extra_args=extra_args) + return self.exec_container(command, name=name) def terminate_doc_to_pixels_proc( self, document: Document, p: subprocess.Popen @@ -389,7 +253,7 @@ def ensure_stop_doc_to_pixels_proc( # type: ignore [no-untyped-def] # after a podman kill / docker kill invocation, this will likely be the case, # else the container runtime (Docker/Podman) has experienced a problem, and we # should report it. - container_runtime = self.get_runtime() + container_runtime = container_utils.get_runtime() name = self.doc_to_pixels_container_name(document) all_containers = subprocess.run( [container_runtime, "ps", "-a"], @@ -411,11 +275,11 @@ def get_max_parallel_conversions(self) -> int: if cpu_count is not None: n_cpu = cpu_count - elif self.get_runtime_name() == "docker": + elif container_utils.get_runtime_name() == "docker": # For Windows and MacOS containers run in VM # So we obtain the CPU count for the VM n_cpu_str = subprocess.check_output( - [self.get_runtime(), "info", "--format", "{{.NCPU}}"], + [container_utils.get_runtime(), "info", "--format", "{{.NCPU}}"], text=True, startupinfo=get_subprocess_startupinfo(), ) diff --git a/dangerzone/isolation_provider/dummy.py b/dangerzone/isolation_provider/dummy.py index 9ebc345fa..fac973fa8 100644 --- a/dangerzone/isolation_provider/dummy.py +++ b/dangerzone/isolation_provider/dummy.py @@ -39,6 +39,14 @@ def __init__(self) -> None: def install(self) -> bool: return True + @staticmethod + def is_available() -> bool: + return True + + @staticmethod + def should_wait_install() -> bool: + return False + def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen: cmd = [ sys.executable, diff --git a/dangerzone/isolation_provider/qubes.py b/dangerzone/isolation_provider/qubes.py index 61a7c8d61..02f80029b 100644 --- a/dangerzone/isolation_provider/qubes.py +++ b/dangerzone/isolation_provider/qubes.py @@ -21,6 +21,14 @@ class Qubes(IsolationProvider): def install(self) -> bool: return True + @staticmethod + def is_available() -> bool: + return True + + @staticmethod + def should_wait_install() -> bool: + return False + def get_max_parallel_conversions(self) -> int: return 1 diff --git a/dev_scripts/qa.py b/dev_scripts/qa.py index 5039bbd92..dfc352b30 100755 --- a/dev_scripts/qa.py +++ b/dev_scripts/qa.py @@ -127,9 +127,9 @@ version. For example: ``` -$ docker images dangerzone.rocks/dangerzone:latest +$ docker images dangerzone.rocks/dangerzone REPOSITORY TAG IMAGE ID CREATED SIZE -dangerzone.rocks/dangerzone latest +dangerzone.rocks/dangerzone ``` Then run the version under QA and ensure that the settings remain changed. @@ -138,9 +138,9 @@ and seeing the following differences: ``` -$ docker images dangerzone.rocks/dangerzone:latest +$ docker images dangerzone.rocks/dangerzone REPOSITORY TAG IMAGE ID CREATED SIZE -dangerzone.rocks/dangerzone latest +dangerzone.rocks/dangerzone ``` #### 4. Dangerzone successfully installs the container image diff --git a/install/common/build-image.py b/install/common/build-image.py index 9f2dcc8de..3e2ab712c 100644 --- a/install/common/build-image.py +++ b/install/common/build-image.py @@ -2,12 +2,13 @@ import gzip import os import platform +import secrets import subprocess import sys from pathlib import Path BUILD_CONTEXT = "dangerzone/" -TAG = "dangerzone.rocks/dangerzone:latest" +IMAGE_NAME = "dangerzone.rocks/dangerzone" REQUIREMENTS_TXT = "container-pip-requirements.txt" if platform.system() in ["Darwin", "Windows"]: CONTAINER_RUNTIME = "docker" @@ -44,8 +45,31 @@ def main(): ) args = parser.parse_args() + tarball_path = Path("share") / "container.tar.gz" + image_id_path = Path("share") / "image-id.txt" + print(f"Building for architecture '{ARCH}'") + # Designate a unique tag for this image, depending on the Git commit it was created + # from: + # 1. If created from a Git tag (e.g., 0.8.0), the image tag will be `0.8.0`. + # 2. If created from a commit, it will be something like `0.8.0-31-g6bdaa7a`. + # 3. If the contents of the Git repo are dirty, we will append a unique identifier + # for this run, something like `0.8.0-31-g6bdaa7a-fdcb` or `0.8.0-fdcb`. + dirty_ident = secrets.token_hex(2) + tag = ( + subprocess.check_output( + ["git", "describe", "--long", "--first-parent", f"--dirty=-{dirty_ident}"], + ) + .decode() + .strip()[1:] # remove the "v" prefix of the tag. + ) + image_name_tagged = IMAGE_NAME + ":" + tag + + print(f"Will tag the container image as '{image_name_tagged}'") + with open(image_id_path, "w") as f: + f.write(tag) + print("Exporting container pip dependencies") with ContainerPipDependencies(): if not args.use_cache: @@ -59,6 +83,7 @@ def main(): check=True, ) + # Build the container image, and tag it with the calculated tag print("Building container image") cache_args = [] if args.use_cache else ["--no-cache"] subprocess.run( @@ -74,7 +99,7 @@ def main(): "-f", "Dockerfile", "--tag", - TAG, + image_name_tagged, ], check=True, ) @@ -85,7 +110,7 @@ def main(): [ CONTAINER_RUNTIME, "save", - TAG, + image_name_tagged, ], stdout=subprocess.PIPE, ) @@ -93,7 +118,7 @@ def main(): print("Compressing container image") chunk_size = 4 << 20 with gzip.open( - "share/container.tar.gz", + tarball_path, "wb", compresslevel=args.compress_level, ) as gzip_f: @@ -105,21 +130,6 @@ def main(): break cmd.wait(5) - print("Looking up the image id") - image_id = subprocess.check_output( - [ - args.runtime, - "image", - "list", - "--format", - "{{.ID}}", - TAG, - ], - text=True, - ) - with open("share/image-id.txt", "w") as f: - f.write(image_id) - class ContainerPipDependencies: """Generates PIP dependencies within container""" diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py index ff4507564..03b78a832 100644 --- a/tests/gui/test_main_window.py +++ b/tests/gui/test_main_window.py @@ -10,6 +10,7 @@ from pytest_subprocess import FakeProcess from pytestqt.qtbot import QtBot +from dangerzone import errors from dangerzone.document import Document from dangerzone.gui import MainWindow from dangerzone.gui import main_window as main_window_module @@ -25,11 +26,8 @@ WaitingWidgetContainer, ) from dangerzone.gui.updater import UpdateReport, UpdaterThread -from dangerzone.isolation_provider.container import ( - Container, - NoContainerTechException, - NotAvailableContainerTechException, -) +from dangerzone.isolation_provider.container import Container +from dangerzone.isolation_provider.dummy import Dummy from .test_updater import assert_report_equal, default_updater_settings @@ -510,9 +508,9 @@ def test_not_available_container_tech_exception( ) -> None: # Setup mock_app = mocker.MagicMock() - dummy = mocker.MagicMock() - - dummy.is_runtime_available.side_effect = NotAvailableContainerTechException( + dummy = Dummy() + fn = mocker.patch.object(dummy, "is_available") + fn.side_effect = errors.NotAvailableContainerTechException( "podman", "podman image ls logs" ) @@ -535,7 +533,7 @@ def test_no_container_tech_exception(qtbot: QtBot, mocker: MockerFixture) -> Non dummy = mocker.MagicMock() # Raise - dummy.is_runtime_available.side_effect = NoContainerTechException("podman") + dummy.is_available.side_effect = errors.NoContainerTechException("podman") dz = DangerzoneGui(mock_app, dummy) widget = WaitingWidgetContainer(dz) diff --git a/tests/isolation_provider/test_container.py b/tests/isolation_provider/test_container.py index 3fb324322..15a393ffa 100644 --- a/tests/isolation_provider/test_container.py +++ b/tests/isolation_provider/test_container.py @@ -4,12 +4,8 @@ from pytest_mock import MockerFixture from pytest_subprocess import FakeProcess -from dangerzone.isolation_provider.container import ( - Container, - ImageInstallationException, - ImageNotPresentException, - NotAvailableContainerTechException, -) +from dangerzone import container_utils, errors +from dangerzone.isolation_provider.container import Container from dangerzone.isolation_provider.qubes import is_qubes_native_conversion from .base import IsolationProviderTermination, IsolationProviderTest @@ -27,31 +23,27 @@ def provider() -> Container: class TestContainer(IsolationProviderTest): - def test_is_runtime_available_raises( - self, provider: Container, fp: FakeProcess - ) -> None: + def test_is_available_raises(self, provider: Container, fp: FakeProcess) -> None: """ NotAvailableContainerTechException should be raised when the "podman image ls" command fails. """ fp.register_subprocess( - [provider.get_runtime(), "image", "ls"], + [container_utils.get_runtime(), "image", "ls"], returncode=-1, stderr="podman image ls logs", ) - with pytest.raises(NotAvailableContainerTechException): - provider.is_runtime_available() + with pytest.raises(errors.NotAvailableContainerTechException): + provider.is_available() - def test_is_runtime_available_works( - self, provider: Container, fp: FakeProcess - ) -> None: + def test_is_available_works(self, provider: Container, fp: FakeProcess) -> None: """ No exception should be raised when the "podman image ls" can return properly. """ fp.register_subprocess( - [provider.get_runtime(), "image", "ls"], + [container_utils.get_runtime(), "image", "ls"], ) - provider.is_runtime_available() + provider.is_available() def test_install_raise_if_image_cant_be_installed( self, mocker: MockerFixture, provider: Container, fp: FakeProcess @@ -59,17 +51,17 @@ def test_install_raise_if_image_cant_be_installed( """When an image installation fails, an exception should be raised""" fp.register_subprocess( - [provider.get_runtime(), "image", "ls"], + [container_utils.get_runtime(), "image", "ls"], ) # First check should return nothing. fp.register_subprocess( [ - provider.get_runtime(), + container_utils.get_runtime(), "image", "list", "--format", - "{{.ID}}", + "{{ .Tag }}", "dangerzone.rocks/dangerzone", ], occurrences=2, @@ -79,11 +71,11 @@ def test_install_raise_if_image_cant_be_installed( mocker.patch("gzip.open", mocker.mock_open(read_data="")) fp.register_subprocess( - [provider.get_runtime(), "load"], + [container_utils.get_runtime(), "load"], returncode=-1, ) - with pytest.raises(ImageInstallationException): + with pytest.raises(errors.ImageInstallationException): provider.install() def test_install_raises_if_still_not_installed( @@ -92,17 +84,17 @@ def test_install_raises_if_still_not_installed( """When an image keep being not installed, it should return False""" fp.register_subprocess( - [provider.get_runtime(), "image", "ls"], + [container_utils.get_runtime(), "image", "ls"], ) # First check should return nothing. fp.register_subprocess( [ - provider.get_runtime(), + container_utils.get_runtime(), "image", "list", "--format", - "{{.ID}}", + "{{ .Tag }}", "dangerzone.rocks/dangerzone", ], occurrences=2, @@ -111,9 +103,9 @@ def test_install_raises_if_still_not_installed( # Patch gzip.open and podman load so that it works mocker.patch("gzip.open", mocker.mock_open(read_data="")) fp.register_subprocess( - [provider.get_runtime(), "load"], + [container_utils.get_runtime(), "load"], ) - with pytest.raises(ImageNotPresentException): + with pytest.raises(errors.ImageNotPresentException): provider.install()