Skip to content

Commit

Permalink
Make image archive/save idempotent, using image id as key
Browse files Browse the repository at this point in the history
  • Loading branch information
iamjpotts committed Nov 22, 2022
1 parent f17e6d5 commit 4a73414
Show file tree
Hide file tree
Showing 6 changed files with 26,026 additions and 4 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,6 @@ dmypy.json

# Pyre type checker
.pyre/

# PyCharm
.idea
100 changes: 96 additions & 4 deletions plugins/modules/docker_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@
import errno
import json
import os
import tarfile
import traceback

from ansible.module_utils.common.text.converters import to_native
Expand Down Expand Up @@ -549,9 +550,26 @@ def archive_image(self, name, tag):
self.log("archive image: image %s not found" % image_name)
return

# Will have a 'sha256:' prefix
new_image_id = image['Id']

try:
archived_id = archived_image_id(self.archive_path)

if archived_id:
self.results['actions'].append('Previous image ID was %s' % archived_id)
else:
self.results['actions'].append('No previous image')
except ImageArchiveInvalidException as exc:
self.results['actions'].append('Previous image ID not available; %s' % exc)
archived_id = None

changed = (new_image_id != ('sha256:%s' % archived_id))

self.results['actions'].append('Archived image %s to %s' % (image_name, self.archive_path))
self.results['changed'] = True
if not self.check_mode:
self.results['changed'] = changed

if (not self.check_mode) and changed:
self.log("Getting archive of image %s" % image_name)
try:
saved_image = self.client._stream_raw_result(
Expand All @@ -569,8 +587,8 @@ def archive_image(self, name, tag):
except Exception as exc:
self.fail("Error writing image archive %s - %s" % (self.archive_path, to_native(exc)))

if image:
self.results['image'] = image
self.results['old_image_id'] = archived_id
self.results['image'] = image

def push_image(self, name, tag=None):
'''
Expand Down Expand Up @@ -879,6 +897,80 @@ def load_image(self):
return self.client.find_image(self.name, self.tag)


class ImageArchiveInvalidException(Exception):
pass


def archived_image_id(archive_path):
"""
Attempts to get Image.Id from meta data stored in the image archive tar file.
The tar should contain a file "manifest.json" with an array with a single entry,
and the entry should have a Config field with the image ID in its file name.
Raises:
ImageArchiveInvalidException if a file already exists at archive_path, but
we could not extract an image ID from it.
Returns:
Either None, if no file exists at archive_path, or the extracted image ID.
The extracted ID will not have a sha256: prefix.
:return str
"""

try:
# FileNotFoundError does not exist in Python 2
if not os.path.isfile(archive_path):
return None

tf = tarfile.open(archive_path, 'r')
try:
try:
ef = tf.extractfile('manifest.json')

if hasattr(ef, '__exit__'):
# Python 3
with ef:
try:
text = ef.read().decode('utf-8')
manifest = json.loads(text)
except Exception as exc:
raise ImageArchiveInvalidException("Failed to deserialize manifest: %s" % to_native(exc))
else:
# Python 2.7 or 2.6
manifest = json.load(ef)

try:
config_file = manifest[0]['Config']
except Exception as exc:
raise ImageArchiveInvalidException(
"Failed to get Config entry from manifest.json: %s" % to_native(exc)
)

# Returns hash without 'sha256:' prefix
try:
# Strip off .json filename extension, leaving just the hash.
return os.path.splitext(config_file)[0]
except Exception as exc:
raise ImageArchiveInvalidException(
"Failed to extract image id from config file name %s: %s" % (config_file, to_native(exc))
)
except Exception as exc:
raise ImageArchiveInvalidException(
"Failed to extract manifest.json from tar file %s: %s" % (archive_path, to_native(exc))
)

finally:
# In Python 2.6, TarFile does not have exit
if hasattr(tf, '__exit__'):
with tf:
pass

except Exception as exc:
raise ImageArchiveInvalidException("Failed to open tar file %s: %s" % (archive_path, to_native(exc)))


def main():
argument_spec = dict(
source=dict(type='str', choices=['build', 'load', 'pull', 'local']),
Expand Down
15 changes: 15 additions & 0 deletions tests/integration/targets/docker_image/tasks/tests/options.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,21 @@
source: pull
register: archive_image

- assert:
that:
- archive_image is changed

- name: Archive image again (idempotent)
docker_image:
name: "{{ docker_test_image_hello_world }}"
archive_path: "{{ remote_tmp_dir }}/image.tar"
source: pull
register: archive_image

- assert:
that:
- archive_image is not changed

- name: Archive image by ID
docker_image:
name: "{{ archive_image.image.Id }}"
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/plugins/modules/test_docker_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Copyright 2022 Red Hat | Ansible
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

import os.path
import pytest
import tempfile

from ansible_collections.community.docker.plugins.modules.docker_image import archived_image_id

from .test_resources.docker_image_load.busybox_latest import busybox_latest_tar_bytes
from .test_resources.docker_image_load.hello_world_latest import hello_world_latest_tar_bytes


@pytest.mark.parametrize('fn, expected_id', [
(
busybox_latest_tar_bytes,
'9d5226e6ce3fb6aee2822206a5ef85f38c303d2b37bfc894b419fca2c0501269'
),
(
hello_world_latest_tar_bytes,
'feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412'
),
])
def test_archived_image_id_extracts(fn, expected_id):
tar_bytes = fn()

file_name = tempfile.mkstemp(suffix='.tar')[1]

with open(file_name, 'wb') as f:
f.write(tar_bytes)

image_id = archived_image_id(file_name)

assert image_id == expected_id

os.remove(file_name)


def test_archived_image_id_extracts_nothing_when_file_not_present():
full_name = os.path.join(os.path.split(__file__)[0], 'does-not-exist.tar')

image_id = archived_image_id(full_name)

assert image_id is None


def test_archived_image_id_raises_when_file_not_a_tar():
from ansible_collections.community.docker.plugins.modules.docker_image import ImageArchiveInvalidException

try:
archived_image_id(__file__)
raise AssertionError("Should have failed")
except ImageArchiveInvalidException:
pass
Loading

0 comments on commit 4a73414

Please sign in to comment.