This commit is contained in:
Alex Pyrgiotis 2024-12-04 17:35:27 +01:00 committed by GitHub
commit 74f3cde9bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 374 additions and 294 deletions

View file

@ -74,6 +74,8 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get current date - name: Get current date
id: date id: date
@ -83,7 +85,7 @@ jobs:
id: cache-container-image id: cache-container-image
uses: actions/cache@v4 uses: actions/cache@v4
with: 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: | path: |
share/container.tar.gz share/container.tar.gz
share/image-id.txt share/image-id.txt
@ -95,6 +97,7 @@ jobs:
python3 ./install/common/build-image.py python3 ./install/common/build-image.py
echo ${{ github.token }} | podman login ghcr.io -u USERNAME --password-stdin echo ${{ github.token }} | podman login ghcr.io -u USERNAME --password-stdin
gunzip -c share/container.tar.gz | podman load gunzip -c share/container.tar.gz | podman load
tag=$(cat share/image-id.txt)
podman push \ podman push \
dangerzone.rocks/dangerzone \ dangerzone.rocks/dangerzone:$tag \
${{ env.IMAGE_REGISTRY }}/dangerzone/dangerzone ${{ env.IMAGE_REGISTRY }}/dangerzone/dangerzone:tag

View file

@ -48,6 +48,8 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get current date - name: Get current date
id: date id: date
@ -57,7 +59,7 @@ jobs:
id: cache-container-image id: cache-container-image
uses: actions/cache@v4 uses: actions/cache@v4
with: 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: |- path: |-
share/container.tar.gz share/container.tar.gz
share/image-id.txt share/image-id.txt
@ -221,7 +223,7 @@ jobs:
- name: Restore container cache - name: Restore container cache
uses: actions/cache/restore@v4 uses: actions/cache/restore@v4
with: 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: |- path: |-
share/container.tar.gz share/container.tar.gz
share/image-id.txt share/image-id.txt
@ -245,7 +247,7 @@ jobs:
install-deb: install-deb:
name: "install-deb (${{ matrix.distro }} ${{ matrix.version }})" name: "install-deb (${{ matrix.distro }} ${{ matrix.version }})"
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- build-deb - build-deb
strategy: strategy:
matrix: matrix:
@ -328,7 +330,7 @@ jobs:
- name: Restore container image - name: Restore container image
uses: actions/cache/restore@v4 uses: actions/cache/restore@v4
with: 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: |- path: |-
share/container.tar.gz share/container.tar.gz
share/image-id.txt share/image-id.txt
@ -423,7 +425,7 @@ jobs:
- name: Restore container image - name: Restore container image
uses: actions/cache/restore@v4 uses: actions/cache/restore@v4
with: 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: |- path: |-
share/container.tar.gz share/container.tar.gz
share/image-id.txt share/image-id.txt

View file

@ -14,17 +14,24 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install container build dependencies - name: Install container build dependencies
run: sudo apt install pipx && pipx install poetry run: sudo apt install pipx && pipx install poetry
- name: Build container image - name: Build container image
run: python3 ./install/common/build-image.py --runtime docker --no-save 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 # NOTE: Scan first without failing, else we won't be able to read the scan
# report. # report.
- name: Scan container image (no fail) - name: Scan container image (no fail)
uses: anchore/scan-action@v5 uses: anchore/scan-action@v5
id: scan_container id: scan_container
with: with:
image: "dangerzone.rocks/dangerzone:latest" image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}"
fail-build: false fail-build: false
only-fixed: false only-fixed: false
severity-cutoff: critical severity-cutoff: critical
@ -38,7 +45,7 @@ jobs:
- name: Scan container image - name: Scan container image
uses: anchore/scan-action@v5 uses: anchore/scan-action@v5
with: with:
image: "dangerzone.rocks/dangerzone:latest" image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}"
fail-build: true fail-build: true
only-fixed: false only-fixed: false
severity-cutoff: critical severity-cutoff: critical

View file

@ -24,13 +24,18 @@ jobs:
CONTAINER_FILENAME=container-${VERSION:1}-${{ matrix.arch }}.tar.gz CONTAINER_FILENAME=container-${VERSION:1}-${{ matrix.arch }}.tar.gz
wget https://github.com/freedomofpress/dangerzone/releases/download/${VERSION}/${CONTAINER_FILENAME} -O ${CONTAINER_FILENAME} wget https://github.com/freedomofpress/dangerzone/releases/download/${VERSION}/${CONTAINER_FILENAME} -O ${CONTAINER_FILENAME}
docker load -i ${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 # NOTE: Scan first without failing, else we won't be able to read the scan
# report. # report.
- name: Scan container image (no fail) - name: Scan container image (no fail)
uses: anchore/scan-action@v5 uses: anchore/scan-action@v5
id: scan_container id: scan_container
with: with:
image: "dangerzone.rocks/dangerzone:latest" image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}"
fail-build: false fail-build: false
only-fixed: false only-fixed: false
severity-cutoff: critical severity-cutoff: critical
@ -44,7 +49,7 @@ jobs:
- name: Scan container image - name: Scan container image
uses: anchore/scan-action@v5 uses: anchore/scan-action@v5
with: with:
image: "dangerzone.rocks/dangerzone:latest" image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}"
fail-build: true fail-build: true
only-fixed: false only-fixed: false
severity-cutoff: critical severity-cutoff: critical

8
QA.md
View file

@ -107,9 +107,9 @@ Close the Dangerzone application and get the container image for that
version. For example: version. For example:
``` ```
$ docker images dangerzone.rocks/dangerzone:latest $ docker images dangerzone.rocks/dangerzone
REPOSITORY TAG IMAGE ID CREATED SIZE REPOSITORY TAG IMAGE ID CREATED SIZE
dangerzone.rocks/dangerzone latest <image ID> <date> <size> dangerzone.rocks/dangerzone <tag> <image ID> <date> <size>
``` ```
Then run the version under QA and ensure that the settings remain changed. 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: and seeing the following differences:
``` ```
$ docker images dangerzone.rocks/dangerzone:latest $ docker images dangerzone.rocks/dangerzone
REPOSITORY TAG IMAGE ID CREATED SIZE REPOSITORY TAG IMAGE ID CREATED SIZE
dangerzone.rocks/dangerzone latest <different ID> <newer date> <different size> dangerzone.rocks/dangerzone <other tag> <different ID> <newer date> <different size>
``` ```
#### 4. Dangerzone successfully installs the container image #### 4. Dangerzone successfully installs the container image

View file

@ -126,7 +126,7 @@ Here is what you need to do:
``` ```
- [ ] Build the container image and the OCR language data - [ ] Build the container image and the OCR language data
```bash ```bash
poetry run ./install/common/build-image.py poetry run ./install/common/build-image.py
poetry run ./install/common/download-tessdata.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 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 - [ ] Sign the application bundle, and notarize it
You need to run this command as the account that has access to the code signing certificate 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 This command assumes that you have created, and stored in the Keychain, an
application password associated with your Apple Developer ID, which will be application password associated with your Apple Developer ID, which will be
used specifically for `notarytool`. 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 - [ ] Copy the container image into the VM
> [!IMPORTANT] > [!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. > 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` - [ ] Run `poetry run .\install\windows\build-app.bat`
- [ ] When you're done you will have `dist\Dangerzone.msi` - [ ] 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 ./dev_scripts/env.py --distro fedora --version 41 build-dev
# Build the latest container (skip if already built): # 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: # Create a .rpm:
./dev_scripts/env.py --distro fedora --version 41 run --dev bash -c "cd dangerzone && ./install/linux/build-rpm.py" ./dev_scripts/env.py --distro fedora --version 41 run --dev bash -c "cd dangerzone && ./install/linux/build-rpm.py"

View file

@ -0,0 +1,168 @@
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 add_image_tag(cur_tag: str, new_tag: str) -> None:
"""Add a tag to an existing Dangerzone image."""
cur_image_name = CONTAINER_NAME + ":" + cur_tag
new_image_name = CONTAINER_NAME + ":" + new_tag
subprocess.check_output(
[
get_runtime(),
"tag",
cur_image_name,
new_image_name,
],
startupinfo=get_subprocess_startupinfo(),
)
log.info(
f"Successfully tagged container image '{cur_image_name}' as '{new_image_name}'"
)
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")

View file

@ -117,3 +117,26 @@ def handle_document_errors(func: F) -> F:
sys.exit(1) sys.exit(1)
return cast(F, wrapper) 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")

View file

@ -25,13 +25,7 @@ else:
from .. import errors from .. import errors
from ..document import SAFE_EXTENSION, Document from ..document import SAFE_EXTENSION, Document
from ..isolation_provider.container import ( from ..isolation_provider.qubes import is_qubes_native_conversion
Container,
NoContainerTechException,
NotAvailableContainerTechException,
)
from ..isolation_provider.dummy import Dummy
from ..isolation_provider.qubes import Qubes, is_qubes_native_conversion
from ..util import format_exception, get_resource_path, get_version from ..util import format_exception, get_resource_path, get_version
from .logic import Alert, CollapsibleBox, DangerzoneGui, UpdateDialog from .logic import Alert, CollapsibleBox, DangerzoneGui, UpdateDialog
from .updater import UpdateReport from .updater import UpdateReport
@ -197,14 +191,11 @@ class MainWindow(QtWidgets.QMainWindow):
header_layout.addWidget(self.hamburger_button) header_layout.addWidget(self.hamburger_button)
header_layout.addSpacing(15) 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 # Waiting widget replaces content widget while container runtime isn't available
self.waiting_widget: WaitingWidget = WaitingWidgetContainer(self.dangerzone) self.waiting_widget: WaitingWidget = WaitingWidgetContainer(self.dangerzone)
self.waiting_widget.finished.connect(self.waiting_finished) self.waiting_widget.finished.connect(self.waiting_finished)
else:
elif isinstance(self.dangerzone.isolation_provider, Dummy) or isinstance(
self.dangerzone.isolation_provider, Qubes
):
# Don't wait with dummy converter and on Qubes. # Don't wait with dummy converter and on Qubes.
self.waiting_widget = WaitingWidget() self.waiting_widget = WaitingWidget()
self.dangerzone.is_waiting_finished = True self.dangerzone.is_waiting_finished = True
@ -500,11 +491,11 @@ class WaitingWidgetContainer(WaitingWidget):
error: Optional[str] = None error: Optional[str] = None
try: try:
self.dangerzone.isolation_provider.is_runtime_available() self.dangerzone.isolation_provider.is_available()
except NoContainerTechException as e: except errors.NoContainerTechException as e:
log.error(str(e)) log.error(str(e))
state = "not_installed" state = "not_installed"
except NotAvailableContainerTechException as e: except errors.NotAvailableContainerTechException as e:
log.error(str(e)) log.error(str(e))
state = "not_running" state = "not_running"
error = e.error error = e.error

View file

@ -93,10 +93,6 @@ class IsolationProvider(ABC):
else: else:
self.proc_stderr = subprocess.DEVNULL self.proc_stderr = subprocess.DEVNULL
@staticmethod
def is_runtime_available() -> bool:
return True
@abstractmethod @abstractmethod
def install(self) -> bool: def install(self) -> bool:
pass pass
@ -258,6 +254,16 @@ class IsolationProvider(ABC):
) )
return errors.exception_from_error_code(error_code) 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 @abstractmethod
def get_max_parallel_conversions(self) -> int: def get_max_parallel_conversions(self) -> int:
pass pass

View file

@ -1,12 +1,11 @@
import gzip
import logging import logging
import os import os
import platform import platform
import shlex import shlex
import shutil
import subprocess import subprocess
from typing import List, Tuple from typing import List
from .. import container_utils, errors
from ..document import Document from ..document import Document
from ..util import get_resource_path, get_subprocess_startupinfo from ..util import get_resource_path, get_subprocess_startupinfo
from .base import IsolationProvider, terminate_process_group from .base import IsolationProvider, terminate_process_group
@ -25,88 +24,8 @@ else:
log = logging.getLogger(__name__) 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): class Container(IsolationProvider):
# Name of the dangerzone container # 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 @staticmethod
def get_runtime_security_args() -> List[str]: def get_runtime_security_args() -> List[str]:
"""Security options applicable to the outer Dangerzone container. """Security options applicable to the outer Dangerzone container.
@ -127,12 +46,12 @@ class Container(IsolationProvider):
* Do not log the container's output. * Do not log the container's output.
* Do not map the host user to the container, with `--userns nomap` (available * Do not map the host user to the container, with `--userns nomap` (available
from Podman 4.1 onwards) 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 = ["--log-driver", "none"]
security_args += ["--security-opt", "no-new-privileges"] security_args += ["--security-opt", "no-new-privileges"]
if container_utils.get_runtime_version() >= (4, 1):
security_args += ["--userns", "nomap"]
else: else:
security_args = ["--security-opt=no-new-privileges:true"] security_args = ["--security-opt=no-new-privileges:true"]
@ -156,51 +75,52 @@ class Container(IsolationProvider):
@staticmethod @staticmethod
def install() -> bool: 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. old_tags = container_utils.list_image_tags()
""" expected_tag = container_utils.get_expected_tag()
if Container.is_container_installed():
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 return True
# Load the container into podman # Load the image tarball into the container runtime.
log.info("Installing Dangerzone container image...") container_utils.load_image_tarball()
p = subprocess.Popen( # Check that the container image has the expected image tag.
[Container.get_runtime(), "load"], # See https://github.com/freedomofpress/dangerzone/issues/988 for an example
stdin=subprocess.PIPE, # where this was not the case.
startupinfo=get_subprocess_startupinfo(), new_tags = container_utils.list_image_tags()
) if expected_tag not in new_tags:
raise errors.ImageNotPresentException(
chunk_size = 4 << 20 f"Could not find expected tag '{expected_tag}' after loading the"
compressed_container_path = get_resource_path("container.tar.gz") " container image tarball"
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}"
) )
if not Container.is_container_installed(raise_on_error=True):
return False
log.info("Container image installed")
return True return True
@staticmethod @staticmethod
def is_runtime_available() -> bool: def should_wait_install() -> bool:
container_runtime = Container.get_runtime() return True
runtime_name = Container.get_runtime_name()
@staticmethod
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 # Can we run `docker/podman image ls` without an error
with subprocess.Popen( with subprocess.Popen(
[container_runtime, "image", "ls"], [container_runtime, "image", "ls"],
@ -210,61 +130,11 @@ class Container(IsolationProvider):
) as p: ) as p:
_, stderr = p.communicate() _, stderr = p.communicate()
if p.returncode != 0: if p.returncode != 0:
raise NotAvailableContainerTechException(runtime_name, stderr.decode()) raise errors.NotAvailableContainerTechException(
runtime_name, stderr.decode()
)
return True 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."
)
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
def doc_to_pixels_container_name(self, document: Document) -> str: def doc_to_pixels_container_name(self, document: Document) -> str:
"""Unique container name for the doc-to-pixels phase.""" """Unique container name for the doc-to-pixels phase."""
return f"dangerzone-doc-to-pixels-{document.id}" return f"dangerzone-doc-to-pixels-{document.id}"
@ -295,21 +165,22 @@ class Container(IsolationProvider):
self, self,
command: List[str], command: List[str],
name: str, name: str,
extra_args: List[str] = [],
) -> subprocess.Popen: ) -> subprocess.Popen:
container_runtime = self.get_runtime() container_runtime = container_utils.get_runtime()
security_args = self.get_runtime_security_args() security_args = self.get_runtime_security_args()
enable_stdin = ["-i"] enable_stdin = ["-i"]
set_name = ["--name", name] set_name = ["--name", name]
prevent_leakage_args = ["--rm"] prevent_leakage_args = ["--rm"]
image_name = [
container_utils.CONTAINER_NAME + ":" + container_utils.get_expected_tag()
]
args = ( args = (
["run"] ["run"]
+ security_args + security_args
+ prevent_leakage_args + prevent_leakage_args
+ enable_stdin + enable_stdin
+ set_name + set_name
+ extra_args + image_name
+ [self.CONTAINER_NAME]
+ command + command
) )
args = [container_runtime] + args args = [container_runtime] + args
@ -325,7 +196,7 @@ class Container(IsolationProvider):
connected to the Docker daemon, and killing it will just close the associated connected to the Docker daemon, and killing it will just close the associated
standard streams. standard streams.
""" """
container_runtime = self.get_runtime() container_runtime = container_utils.get_runtime()
cmd = [container_runtime, "kill", name] cmd = [container_runtime, "kill", name]
try: try:
# We do not check the exit code of the process here, since the container may # We do not check the exit code of the process here, since the container may
@ -358,15 +229,8 @@ class Container(IsolationProvider):
"-m", "-m",
"dangerzone.conversion.doc_to_pixels", "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) 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( def terminate_doc_to_pixels_proc(
self, document: Document, p: subprocess.Popen self, document: Document, p: subprocess.Popen
@ -389,7 +253,7 @@ class Container(IsolationProvider):
# after a podman kill / docker kill invocation, this will likely be the case, # 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 # else the container runtime (Docker/Podman) has experienced a problem, and we
# should report it. # should report it.
container_runtime = self.get_runtime() container_runtime = container_utils.get_runtime()
name = self.doc_to_pixels_container_name(document) name = self.doc_to_pixels_container_name(document)
all_containers = subprocess.run( all_containers = subprocess.run(
[container_runtime, "ps", "-a"], [container_runtime, "ps", "-a"],
@ -411,11 +275,11 @@ class Container(IsolationProvider):
if cpu_count is not None: if cpu_count is not None:
n_cpu = cpu_count 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 # For Windows and MacOS containers run in VM
# So we obtain the CPU count for the VM # So we obtain the CPU count for the VM
n_cpu_str = subprocess.check_output( n_cpu_str = subprocess.check_output(
[self.get_runtime(), "info", "--format", "{{.NCPU}}"], [container_utils.get_runtime(), "info", "--format", "{{.NCPU}}"],
text=True, text=True,
startupinfo=get_subprocess_startupinfo(), startupinfo=get_subprocess_startupinfo(),
) )

View file

@ -39,6 +39,14 @@ class Dummy(IsolationProvider):
def install(self) -> bool: def install(self) -> bool:
return True 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: def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen:
cmd = [ cmd = [
sys.executable, sys.executable,

View file

@ -21,6 +21,14 @@ class Qubes(IsolationProvider):
def install(self) -> bool: def install(self) -> bool:
return True return True
@staticmethod
def is_available() -> bool:
return True
@staticmethod
def should_wait_install() -> bool:
return False
def get_max_parallel_conversions(self) -> int: def get_max_parallel_conversions(self) -> int:
return 1 return 1

View file

@ -127,9 +127,9 @@ Close the Dangerzone application and get the container image for that
version. For example: version. For example:
``` ```
$ docker images dangerzone.rocks/dangerzone:latest $ docker images dangerzone.rocks/dangerzone
REPOSITORY TAG IMAGE ID CREATED SIZE REPOSITORY TAG IMAGE ID CREATED SIZE
dangerzone.rocks/dangerzone latest <image ID> <date> <size> dangerzone.rocks/dangerzone <tag> <image ID> <date> <size>
``` ```
Then run the version under QA and ensure that the settings remain changed. Then run the version under QA and ensure that the settings remain changed.
@ -138,9 +138,9 @@ Afterwards check that new docker image was installed by running the same command
and seeing the following differences: and seeing the following differences:
``` ```
$ docker images dangerzone.rocks/dangerzone:latest $ docker images dangerzone.rocks/dangerzone
REPOSITORY TAG IMAGE ID CREATED SIZE REPOSITORY TAG IMAGE ID CREATED SIZE
dangerzone.rocks/dangerzone latest <different ID> <newer date> <different size> dangerzone.rocks/dangerzone <other tag> <different ID> <newer date> <different size>
``` ```
#### 4. Dangerzone successfully installs the container image #### 4. Dangerzone successfully installs the container image

View file

@ -2,12 +2,13 @@ import argparse
import gzip import gzip
import os import os
import platform import platform
import secrets
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
BUILD_CONTEXT = "dangerzone/" BUILD_CONTEXT = "dangerzone/"
TAG = "dangerzone.rocks/dangerzone:latest" IMAGE_NAME = "dangerzone.rocks/dangerzone"
REQUIREMENTS_TXT = "container-pip-requirements.txt" REQUIREMENTS_TXT = "container-pip-requirements.txt"
if platform.system() in ["Darwin", "Windows"]: if platform.system() in ["Darwin", "Windows"]:
CONTAINER_RUNTIME = "docker" CONTAINER_RUNTIME = "docker"
@ -44,8 +45,31 @@ def main():
) )
args = parser.parse_args() 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}'") 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") print("Exporting container pip dependencies")
with ContainerPipDependencies(): with ContainerPipDependencies():
if not args.use_cache: if not args.use_cache:
@ -59,6 +83,7 @@ def main():
check=True, check=True,
) )
# Build the container image, and tag it with the calculated tag
print("Building container image") print("Building container image")
cache_args = [] if args.use_cache else ["--no-cache"] cache_args = [] if args.use_cache else ["--no-cache"]
subprocess.run( subprocess.run(
@ -74,7 +99,7 @@ def main():
"-f", "-f",
"Dockerfile", "Dockerfile",
"--tag", "--tag",
TAG, image_name_tagged,
], ],
check=True, check=True,
) )
@ -85,7 +110,7 @@ def main():
[ [
CONTAINER_RUNTIME, CONTAINER_RUNTIME,
"save", "save",
TAG, image_name_tagged,
], ],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
) )
@ -93,7 +118,7 @@ def main():
print("Compressing container image") print("Compressing container image")
chunk_size = 4 << 20 chunk_size = 4 << 20
with gzip.open( with gzip.open(
"share/container.tar.gz", tarball_path,
"wb", "wb",
compresslevel=args.compress_level, compresslevel=args.compress_level,
) as gzip_f: ) as gzip_f:
@ -105,21 +130,6 @@ def main():
break break
cmd.wait(5) 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: class ContainerPipDependencies:
"""Generates PIP dependencies within container""" """Generates PIP dependencies within container"""

View file

@ -10,6 +10,7 @@ from pytest_mock import MockerFixture
from pytest_subprocess import FakeProcess from pytest_subprocess import FakeProcess
from pytestqt.qtbot import QtBot from pytestqt.qtbot import QtBot
from dangerzone import errors
from dangerzone.document import Document from dangerzone.document import Document
from dangerzone.gui import MainWindow from dangerzone.gui import MainWindow
from dangerzone.gui import main_window as main_window_module from dangerzone.gui import main_window as main_window_module
@ -25,11 +26,8 @@ from dangerzone.gui.main_window import (
WaitingWidgetContainer, WaitingWidgetContainer,
) )
from dangerzone.gui.updater import UpdateReport, UpdaterThread from dangerzone.gui.updater import UpdateReport, UpdaterThread
from dangerzone.isolation_provider.container import ( from dangerzone.isolation_provider.container import Container
Container, from dangerzone.isolation_provider.dummy import Dummy
NoContainerTechException,
NotAvailableContainerTechException,
)
from .test_updater import assert_report_equal, default_updater_settings from .test_updater import assert_report_equal, default_updater_settings
@ -510,9 +508,9 @@ def test_not_available_container_tech_exception(
) -> None: ) -> None:
# Setup # Setup
mock_app = mocker.MagicMock() mock_app = mocker.MagicMock()
dummy = mocker.MagicMock() dummy = Dummy()
fn = mocker.patch.object(dummy, "is_available")
dummy.is_runtime_available.side_effect = NotAvailableContainerTechException( fn.side_effect = errors.NotAvailableContainerTechException(
"podman", "podman image ls logs" "podman", "podman image ls logs"
) )
@ -535,7 +533,7 @@ def test_no_container_tech_exception(qtbot: QtBot, mocker: MockerFixture) -> Non
dummy = mocker.MagicMock() dummy = mocker.MagicMock()
# Raise # Raise
dummy.is_runtime_available.side_effect = NoContainerTechException("podman") dummy.is_available.side_effect = errors.NoContainerTechException("podman")
dz = DangerzoneGui(mock_app, dummy) dz = DangerzoneGui(mock_app, dummy)
widget = WaitingWidgetContainer(dz) widget = WaitingWidgetContainer(dz)

View file

@ -4,12 +4,8 @@ import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from pytest_subprocess import FakeProcess from pytest_subprocess import FakeProcess
from dangerzone.isolation_provider.container import ( from dangerzone import container_utils, errors
Container, from dangerzone.isolation_provider.container import Container
ImageInstallationException,
ImageNotPresentException,
NotAvailableContainerTechException,
)
from dangerzone.isolation_provider.qubes import is_qubes_native_conversion from dangerzone.isolation_provider.qubes import is_qubes_native_conversion
from .base import IsolationProviderTermination, IsolationProviderTest from .base import IsolationProviderTermination, IsolationProviderTest
@ -27,31 +23,27 @@ def provider() -> Container:
class TestContainer(IsolationProviderTest): class TestContainer(IsolationProviderTest):
def test_is_runtime_available_raises( def test_is_available_raises(self, provider: Container, fp: FakeProcess) -> None:
self, provider: Container, fp: FakeProcess
) -> None:
""" """
NotAvailableContainerTechException should be raised when NotAvailableContainerTechException should be raised when
the "podman image ls" command fails. the "podman image ls" command fails.
""" """
fp.register_subprocess( fp.register_subprocess(
[provider.get_runtime(), "image", "ls"], [container_utils.get_runtime(), "image", "ls"],
returncode=-1, returncode=-1,
stderr="podman image ls logs", stderr="podman image ls logs",
) )
with pytest.raises(NotAvailableContainerTechException): with pytest.raises(errors.NotAvailableContainerTechException):
provider.is_runtime_available() provider.is_available()
def test_is_runtime_available_works( def test_is_available_works(self, provider: Container, fp: FakeProcess) -> None:
self, provider: Container, fp: FakeProcess
) -> None:
""" """
No exception should be raised when the "podman image ls" can return properly. No exception should be raised when the "podman image ls" can return properly.
""" """
fp.register_subprocess( 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( def test_install_raise_if_image_cant_be_installed(
self, mocker: MockerFixture, provider: Container, fp: FakeProcess self, mocker: MockerFixture, provider: Container, fp: FakeProcess
@ -59,17 +51,17 @@ class TestContainer(IsolationProviderTest):
"""When an image installation fails, an exception should be raised""" """When an image installation fails, an exception should be raised"""
fp.register_subprocess( fp.register_subprocess(
[provider.get_runtime(), "image", "ls"], [container_utils.get_runtime(), "image", "ls"],
) )
# First check should return nothing. # First check should return nothing.
fp.register_subprocess( fp.register_subprocess(
[ [
provider.get_runtime(), container_utils.get_runtime(),
"image", "image",
"list", "list",
"--format", "--format",
"{{.ID}}", "{{ .Tag }}",
"dangerzone.rocks/dangerzone", "dangerzone.rocks/dangerzone",
], ],
occurrences=2, occurrences=2,
@ -79,11 +71,11 @@ class TestContainer(IsolationProviderTest):
mocker.patch("gzip.open", mocker.mock_open(read_data="")) mocker.patch("gzip.open", mocker.mock_open(read_data=""))
fp.register_subprocess( fp.register_subprocess(
[provider.get_runtime(), "load"], [container_utils.get_runtime(), "load"],
returncode=-1, returncode=-1,
) )
with pytest.raises(ImageInstallationException): with pytest.raises(errors.ImageInstallationException):
provider.install() provider.install()
def test_install_raises_if_still_not_installed( def test_install_raises_if_still_not_installed(
@ -92,17 +84,17 @@ class TestContainer(IsolationProviderTest):
"""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(
[provider.get_runtime(), "image", "ls"], [container_utils.get_runtime(), "image", "ls"],
) )
# First check should return nothing. # First check should return nothing.
fp.register_subprocess( fp.register_subprocess(
[ [
provider.get_runtime(), container_utils.get_runtime(),
"image", "image",
"list", "list",
"--format", "--format",
"{{.ID}}", "{{ .Tag }}",
"dangerzone.rocks/dangerzone", "dangerzone.rocks/dangerzone",
], ],
occurrences=2, occurrences=2,
@ -111,9 +103,9 @@ class TestContainer(IsolationProviderTest):
# Patch gzip.open and podman load so that it works # Patch gzip.open and podman load so that it works
mocker.patch("gzip.open", mocker.mock_open(read_data="")) mocker.patch("gzip.open", mocker.mock_open(read_data=""))
fp.register_subprocess( fp.register_subprocess(
[provider.get_runtime(), "load"], [container_utils.get_runtime(), "load"],
) )
with pytest.raises(ImageNotPresentException): with pytest.raises(errors.ImageNotPresentException):
provider.install() provider.install()