-
Notifications
You must be signed in to change notification settings - Fork 184
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
base: main
Are you sure you want to change the base?
Changes from 31 commits
81ee267
3d28ae2
197325b
f60c43f
af55d26
5c9a38d
27647cc
f6562ae
7002ab8
6aff845
db33038
5001328
22d235c
5a4ddb1
1e9e468
379c9f8
d667c28
431f0cb
aac6c63
ccae6c5
5202d79
9889710
668ee71
0724f86
537d23e
5acb302
e078e9b
60674ea
835970b
a540fc5
0f2d81d
b4818ce
35704b8
b37815a
c9c301d
df3efa8
a4fa6aa
ecb3d87
fb89f00
83418f0
3e861cc
b5bfbb5
ab51a71
43cb02b
ec4028b
4621902
cf7a3db
9bf663f
01f7b37
0c063b5
7baddd0
8381b2f
7e28319
d5d3038
33ee158
7f83505
4073a62
30ec1f1
2476ed6
4a4bf7c
bba427d
43f6d89
d93c99f
7e4cd66
22d01a4
49c4cee
356d848
3d579c8
f175739
3ea4917
760948b
c313c6d
53a7028
f00f962
3f6c134
49b54aa
a82ba28
2aeb53a
cff3ac2
c405eb9
f1dac59
7eb54c3
264f1d1
052c352
5bd5157
bbac103
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 }} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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]: | ||||||
|
@@ -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: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ✂️ . There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"], | ||||||
|
@@ -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}"] | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do we want to achieve with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We rely on 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" | ||||||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
||
|
@@ -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: | ||
|
@@ -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 | ||
|
There was a problem hiding this comment.
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.