Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Independent container updates #1080

Open
wants to merge 86 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
81ee267
Add a `dangerzone-image` CLI script
almet Feb 11, 2025
3d28ae2
Download and verify cosign signatures
almet Feb 11, 2025
197325b
Add the ability to download diffoci for multiple platforms
almet Feb 11, 2025
f60c43f
Publish and attest multi-architecture container images
apyrgio Feb 11, 2025
af55d26
Add documentation for independent container updates
almet Feb 11, 2025
5c9a38d
(WIP) Check for container updates rather than using `image-id.txt`
almet Feb 11, 2025
27647cc
fixup! Download and verify cosign signatures
almet Feb 12, 2025
f6562ae
fixup! Download and verify cosign signatures
almet Feb 12, 2025
7002ab8
fixup! Download and verify cosign signatures
almet Feb 12, 2025
6aff845
fixup! Download and verify cosign signatures
almet Feb 12, 2025
db33038
fixup! Download and verify cosign signatures
almet Feb 12, 2025
5001328
fixup! Download and verify cosign signatures
almet Feb 12, 2025
22d235c
fixup! Download and verify cosign signatures
almet Feb 12, 2025
5a4ddb1
fixup! Download and verify cosign signatures
almet Feb 12, 2025
1e9e468
fixup! Download and verify cosign signatures
almet Feb 12, 2025
379c9f8
fixup! Add a `dangerzone-image` CLI script
almet Feb 12, 2025
d667c28
fixup! Add a `dangerzone-image` CLI script
almet Feb 12, 2025
431f0cb
fixup! Add a `dangerzone-image` CLI script
almet Feb 12, 2025
aac6c63
fixup! Add a `dangerzone-image` CLI script
almet Feb 12, 2025
ccae6c5
fixup! Add a `dangerzone-image` CLI script
almet Feb 12, 2025
5202d79
fixup! Add a `dangerzone-image` CLI script
almet Feb 12, 2025
9889710
fixup! Add a `dangerzone-image` CLI script
almet Feb 12, 2025
668ee71
fixup! Add a `dangerzone-image` CLI script
almet Feb 12, 2025
0724f86
fixup! Publish and attest multi-architecture container images
almet Feb 12, 2025
537d23e
fixup! Publish and attest multi-architecture container images
almet Feb 12, 2025
5acb302
fixup! Publish and attest multi-architecture container images
almet Feb 12, 2025
e078e9b
fixup! 1e9e468e3766eb8fd518a94589f9acdd6b3081ac
almet Feb 12, 2025
60674ea
fixup! (WIP) Check for container updates rather than using `image-id.…
almet Feb 12, 2025
835970b
fixup! (WIP) Check for container updates rather than using `image-id.…
almet Feb 12, 2025
a540fc5
(WIP) Add tests
almet Feb 12, 2025
0f2d81d
(WIP) some more tests
almet Feb 13, 2025
b4818ce
fixup! (WIP) some more tests
almet Feb 25, 2025
35704b8
fixup! (WIP) some more tests
almet Feb 25, 2025
b37815a
fixup! (WIP) some more tests
almet Feb 25, 2025
c9c301d
fixup! (WIP) some more tests
almet Feb 25, 2025
df3efa8
fixup! 6aff84549364a548fbf738d5e62a00e8b35c9105
almet Feb 25, 2025
a4fa6aa
fixup! (WIP) Add tests
almet Feb 25, 2025
ecb3d87
fixup! (WIP) Add tests
almet Feb 25, 2025
fb89f00
fixup! (WIP) Add tests
almet Feb 25, 2025
83418f0
fixup! (WIP) Add tests
almet Feb 25, 2025
3e861cc
fixup! (WIP) Add tests
almet Feb 25, 2025
b5bfbb5
fixup! (WIP) Add tests
almet Feb 25, 2025
ab51a71
fixup! (WIP) Add tests
almet Feb 25, 2025
43cb02b
fixup! (WIP) Add tests
almet Feb 25, 2025
ec4028b
fixup! (WIP) Add tests
almet Feb 25, 2025
4621902
fixup! (WIP) Add tests
almet Feb 25, 2025
cf7a3db
fixup! (WIP) Add tests
almet Feb 25, 2025
9bf663f
fixup! (WIP) Add tests
almet Feb 25, 2025
01f7b37
fixup! (WIP) Add tests
almet Feb 25, 2025
0c063b5
fixup! (WIP) Add tests
almet Feb 25, 2025
7baddd0
fixup! (WIP) Add tests
almet Feb 25, 2025
8381b2f
fixup! (WIP) Add tests
almet Feb 25, 2025
7e28319
fixup! 35704b8a1854711720fe2bacd3416cb712c076a4
almet Feb 25, 2025
d5d3038
fixup! Download and verify cosign signatures
almet Feb 25, 2025
33ee158
fixup! Download and verify cosign signatures
almet Feb 25, 2025
7f83505
fixup! Download and verify cosign signatures
almet Feb 25, 2025
4073a62
fixup! Download and verify cosign signatures
almet Feb 25, 2025
30ec1f1
fixup! Download and verify cosign signatures
almet Feb 25, 2025
2476ed6
fixup! Download and verify cosign signatures
almet Feb 25, 2025
4a4bf7c
fixup! 3e861cc0cda83b5a58a4457918726835bc9fd68b
almet Feb 25, 2025
bba427d
fixup! 83418f09f20ea1fdd5e808bd0750d1dfb07b389a
almet Feb 25, 2025
43f6d89
fixup! b37815a96c044cbdea8fb50c8106f6a70dd11345
almet Feb 25, 2025
d93c99f
fixup! b4818ce854d191c5ecca93ef22ce5b2a3f6c8d07
almet Feb 25, 2025
7e4cd66
fixup! b4818ce854d191c5ecca93ef22ce5b2a3f6c8d07
almet Feb 25, 2025
22d01a4
fixup! c9c301d83330f07852856725fbe8b5502caa1371
almet Feb 25, 2025
49c4cee
make the signature tests pass
almet Feb 25, 2025
356d848
fixup! Add a `dangerzone-image` CLI script
almet Feb 25, 2025
3d579c8
fixup! Add a `dangerzone-image` CLI script
almet Feb 25, 2025
f175739
fixup! Add a `dangerzone-image` CLI script
almet Feb 25, 2025
3ea4917
fixup! Add a `dangerzone-image` CLI script
almet Feb 25, 2025
760948b
Add tests for registry
almet Feb 25, 2025
c313c6d
FIXUP: Use the digest when pulling the container
almet Feb 26, 2025
53a7028
Introduce a `subprocess_run` utility function
almet Feb 26, 2025
f00f962
FIXUP: Use exceptions to ease the flow
almet Feb 26, 2025
3f6c134
FIXUP: Use user data dir rather than config
almet Feb 26, 2025
49b54aa
FIXUP: throw rather than bools
almet Feb 26, 2025
a82ba28
FIXUP: Add a comment to update the DEFAULT_LOG_INDEX with releases
almet Feb 26, 2025
2aeb53a
fixup! Download and verify cosign signatures
almet Feb 26, 2025
cff3ac2
fixup! Download and verify cosign signatures
almet Feb 26, 2025
c405eb9
Provide an `is_update_available` function
almet Feb 26, 2025
f1dac59
FIXUP commit for signature tests
almet Feb 26, 2025
7eb54c3
Split updater GUI code from the code checking for release updates
almet Feb 27, 2025
264f1d1
Replace the `updater_check` setting by `updater_check_all`
almet Mar 1, 2025
052c352
Add a `dangerzone-image store-signature` CLI command
almet Mar 3, 2025
5bd5157
Display the `{podman,docker} pull` progress when installing a new image
almet Mar 3, 2025
bbac103
Allow a different runtime on `dangerzone-image` commands.
almet Mar 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions .github/workflows/release-container-image.yml
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up, I'll make changes in this file, based on the findings in https://github.com/freedomofpress/repro-build. Nothing too radical, the changes in our build-image.py script will be far more reaching. I'll add those as new commits, if you don't mind.

Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
name: Release multi-arch container image

on:
workflow_dispatch:
push:
branches:
- main
- "test/**"
schedule:
- cron: "0 0 * * *" # Run every day at 00:00 UTC.

env:
REGISTRY: ghcr.io/${{ github.repository_owner }}
REGISTRY_USER: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ github.token }}
IMAGE_NAME: dangerzone/dangerzone

jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- uses: actions/checkout@v4

- name: Get current date
id: date
run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT

- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV

- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: ./dangerzone/
file: Dockerfile
build-args: |
DEBIAN_ARCHIVE_DATE=${{ steps.date.outputs.date }}
## Remove potentially incorrect Docker provenance.
#provenance: false
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}",push-by-digest=true,name-canonical=true,push=true

- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"

- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1

merge:
runs-on: ubuntu-latest
needs:
- build
outputs:
digest: ${{ steps.image.outputs.digest }}
image: ${{ steps.image.outputs.image }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Compute image tag
id: tag
run: |
DATE=$(date +'%Y%m%d')
TAG=$(git describe --long --first-parent | tail -c +2)
echo "tag=${DATE}-${TAG}" >> $GITHUB_OUTPUT

- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true

- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

#- name: Docker meta
# id: meta
# uses: docker/metadata-action@v5
# with:
# images: |
# ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# tags: |
# type=ref,event=branch
# type=ref,event=pr
# type=semver,pattern={{version}}
# type=semver,pattern={{major}}.{{minor}}

- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
DIGESTS=$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
docker buildx imagetools create -t ${IMAGE} ${DIGESTS}

- name: Inspect image
id: image
run: |
# NOTE: Set the image as an output because the `env` context is not
# available to the inputs of a reusable workflow call.
image_name="${REGISTRY}/${IMAGE_NAME}"
echo "image=$image_name" >> "$GITHUB_OUTPUT"
docker buildx imagetools inspect ${image_name}:${{ steps.tag.outputs.tag }}
digest=$(docker buildx imagetools inspect ${image_name}:${{ steps.tag.outputs.tag }} --format "{{json .Manifest}}" | jq -r '.digest')
echo "digest=$digest" >> "$GITHUB_OUTPUT"

# This step calls the container workflow to generate provenance and push it to
# the container registry.
provenance:
needs:
- merge
permissions:
actions: read # for detecting the Github Actions environment.
id-token: write # for creating OIDC tokens for signing.
packages: write # for uploading attestations.
uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
with:
digest: ${{ needs.merge.outputs.digest }}
image: ${{ needs.merge.outputs.image }}
registry-username: ${{ github.actor }}
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}
93 changes: 79 additions & 14 deletions dangerzone/container_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,22 @@
import platform
import shutil
import subprocess
from typing import List, Tuple
from typing import List, Optional, Tuple

from . import errors
from .util import get_resource_path, get_subprocess_startupinfo

CONTAINER_NAME = "dangerzone.rocks/dangerzone"
OLD_CONTAINER_NAME = "dangerzone.rocks/dangerzone"
CONTAINER_NAME = "ghcr.io/freedomofpress/dangerzone/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
return "podman"
# Windows, Darwin, and unknown use docker for now, dangerzone-vm eventually
return "docker"


def get_runtime_version() -> Tuple[int, int]:
Expand Down Expand Up @@ -112,13 +111,7 @@ def delete_image_tag(tag: str) -> None:
)


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:
def load_image_tarball_from_gzip() -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on reproducible builds, I started to realize that our container image becomes marginally smaller if we compress it with gzip. That could be because Buildkit compresses the files in each layer, but I'm not sure yet. Anyway, what I'm getting at is that this code path is a strong candidate for ✂️ .

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat, let's cut these hairy hairs!

log.info("Installing Dangerzone container image...")
p = subprocess.Popen(
[get_runtime(), "load"],
Expand Down Expand Up @@ -147,3 +140,75 @@ def load_image_tarball() -> None:
)

log.info("Successfully installed container image from")


def load_image_tarball_from_tar(tarball_path: str) -> None:
cmd = [get_runtime(), "load", "-i", tarball_path]
subprocess.run(cmd, startupinfo=get_subprocess_startupinfo(), check=True)

log.info("Successfully installed container image from %s", tarball_path)


def tag_image_by_digest(digest: str, tag: str) -> None:
"""Tag a container image by digest.
The sha256: prefix should be omitted from the digest.
"""
image_id = get_image_id_by_digest(digest)
cmd = [get_runtime(), "tag", image_id, tag]
log.debug(" ".join(cmd))
subprocess.run(cmd, startupinfo=get_subprocess_startupinfo(), check=True)


def get_image_id_by_digest(digest: str) -> str:
"""Get an image ID from a digest.
The sha256: prefix should be omitted from the digest.
"""
cmd = [
get_runtime(),
"images",
"-f",
f"digest=sha256:{digest}",
"--format",
"{{.Id}}",
]
log.debug(" ".join(cmd))
process = subprocess.run(
cmd, startupinfo=get_subprocess_startupinfo(), check=True, capture_output=True
)
# In case we have multiple lines, we only want the first one.
return process.stdout.decode().strip().split("\n")[0]


def container_pull(image: str) -> bool:
"""Pull a container image from a registry."""
cmd = [get_runtime_name(), "pull", f"{image}"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cmd = [get_runtime_name(), "pull", f"{image}"]
cmd = [get_runtime_name(), "pull", image]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've actually implemented the container pull by digest instead, see c313c6d

process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
process.communicate()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do we want to achieve with .communicate(). Is it to enable some sort of progress report in the future? In any case, I think a progress_fn and iterating the stdout lines will do.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also fixed by c313c6d. Also, I've decided to introduce a subprocess_run function to avoid forgetting windows-specific arguments when calling .run, see 53a7028

return process.returncode == 0


def get_local_image_digest(image: str) -> str:
"""
Returns a image hash from a local image name
"""
# Get the image hash from the podman images command, as
# podman inspect returns a the digest of the architecture-bound image
cmd = [get_runtime_name(), "images", image, "--format", "{{.Digest}}"]
log.debug(" ".join(cmd))
try:
result = subprocess.run(cmd, capture_output=True, check=True)
lines = result.stdout.decode().strip().split("\n")
if len(lines) != 1:
raise errors.MultipleImagesFoundException(
f"Expected a single line of output, got {len(lines)} lines"
)
image_digest = lines[0].replace("sha256:", "")
if not image_digest:
raise errors.ImageNotPresentException(
f"The image {image} does not exist locally"
)
Comment on lines +223 to +226
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I get what you're talking about, you're handling here the case where the image exists locally, but Podman/Docker somehow don't return a digest for it. That's a weird one indeed, I've seen it while testing things out, but never could figure out why it happens.

At any rate, I'd change the message here to show that the image exists locally, but the container engine reports no digest for it. Solely to not get confused with the error message below.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We rely on podman images --format "{{.Digest}}" to avoid the problem you're mentioning, yes, but this isn't why we're raising twice the same exception.

I agree this isn't ideal, but I don't really see a way around it. Maybe my eyes are too close to the screen: if you see another way to do it let me know :-)

return image_digest
except subprocess.CalledProcessError as e:
raise errors.ImageNotPresentException(
f"The image {image} does not exist locally"
)
4 changes: 4 additions & 0 deletions dangerzone/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ class ImageNotPresentException(Exception):
pass


class MultipleImagesFoundException(Exception):
pass


class ImageInstallationException(Exception):
pass

Expand Down
61 changes: 25 additions & 36 deletions dangerzone/isolation_provider/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import subprocess
from typing import List, Tuple

from .. import container_utils, errors
from .. import container_utils, errors, updater
from ..document import Document
from ..util import get_resource_path, get_subprocess_startupinfo
from .base import IsolationProvider, terminate_process_group
Expand Down Expand Up @@ -78,41 +78,24 @@ 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.
"""
old_tags = container_utils.list_image_tags()
expected_tag = container_utils.get_expected_tag()
"""Check if an update is available and install it if necessary."""
# XXX Do this only if users have opted in to auto-updates

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}'"
if False: # Comment this for now, just as an exemple of this can be implemented
# # Load the image tarball into the container runtime.
update_available, image_digest = updater.is_update_available(
container_utils.CONTAINER_NAME
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping a note here to revisit this method once we nail the UX.

)
for tag in old_tags:
container_utils.delete_image_tag(tag)
else:
return True
if update_available and image_digest:
updater.upgrade_container_image(
container_utils.CONTAINER_NAME,
image_digest,
updater.DEFAULT_PUBKEY_LOCATION,
)

# 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"
)
updater.verify_local_image(
container_utils.CONTAINER_NAME, updater.DEFAULT_PUBKEY_LOCATION
)

return True

Expand Down Expand Up @@ -193,6 +176,14 @@ def exec_container(
name: str,
) -> subprocess.Popen:
container_runtime = container_utils.get_runtime()

image_digest = container_utils.get_local_image_digest(
container_utils.CONTAINER_NAME
)
updater.verify_local_image(
container_utils.CONTAINER_NAME,
updater.DEFAULT_PUBKEY_LOCATION,
)
security_args = self.get_runtime_security_args()
debug_args = []
if self.debug:
Expand All @@ -201,9 +192,7 @@ def exec_container(
enable_stdin = ["-i"]
set_name = ["--name", name]
prevent_leakage_args = ["--rm"]
image_name = [
container_utils.CONTAINER_NAME + ":" + container_utils.get_expected_tag()
]
image_name = [container_utils.CONTAINER_NAME + "@sha256:" + image_digest]
args = (
["run"]
+ security_args
Expand Down
Loading
Loading