Fix OCI artifacts pointing to a single manifest

When using ORAS to upload a file to Quay, the tag points directly to
the manifest, not to the index of manifests. Currently, Ironic is not
capable of handling the former.

First, GET API to a manifest does not accept
application/vnd.oci.image.index.v1+json, only
application/vnd.oci.image.manifest.v1+json, so indicate that we
understand both.

Second, the logic in the OCI image service needs to be adjusted to this
case. If the index is a manifest, it is now treated the same as an index
with only that manifest.

Third, the manifest's body does not contain its own digest. Instead, it
can be fetched from the docker-content-digest header, so get it and
store as a virtual field dockerContentDigest.

As part of the change, I had to refactor identify_specific_image since
it exceeded the allowed complexity with my changes.

Also fix get_blob_url to work with image URLs without oci://. This is
not possible to trigger through the API but ensures internal
consistency.

Also provide more specific error messages instead of piling everything
under the very generic ImageNotFound and provide more logging.

Change-Id: Iba84bbe5da541700d20a445818a4a0d584f1eca8
Signed-off-by: Dmitry Tantsur <dtantsur@protonmail.com>
This commit is contained in:
Dmitry Tantsur
2025-09-09 17:41:39 +02:00
parent 7681e2216d
commit 4b8a3733f6
6 changed files with 309 additions and 142 deletions

View File

@@ -1100,6 +1100,11 @@ class OciImageNotSpecific(InvalidImage):
"utilized for the file download.")
class OciImageTagNotFound(ImageNotFound):
_msg_fmt = _("Image tag %(image_url)s could not be found, "
"available tags are %(tags)s")
class ImageServiceAuthenticationRequired(ImageUnacceptable):
_msg_fmt = _("The requested image %(image_ref)s requires "
"authentication which has not been provided. "

View File

@@ -377,7 +377,7 @@ class OciImageService(BaseImageService):
if the URL is specific to to a specific manifest,
or is otherwise generalized and needs to be
identified.
:raises: OciImageNotSpecifc if the supplied image_href lacks a
:raises: OciImageNotSpecific if the supplied image_href lacks a
required manifest digest value, or if the digest value
is not understood.
:raises: ImageRefValidationFailed if the supplied image_href
@@ -535,6 +535,73 @@ class OciImageService(BaseImageService):
password = None
self._client.authenticate(image_url, username, password)
def _filter_manifests(self, manifests, image_download_source=None,
cpu_arch=None):
# Determine our preferences for matching
if image_download_source == 'local':
# When the image is served from the conductor, it makes sense to
# download the smaller variant (i.e. qcow2) and convert locally.
# Raw images are used when compressed ones are unavailable.
disk_format_priority = {'qcow2': 1,
'qemu': 2,
'raw': 3,
'applehv': 4}
else:
# When IPA downloads the image directly, it is preferred to let it
# download a raw image because it enables direct streaming to the
# target block device. Compressed images are also possible.
# Note: applehv appears to be a raw image.
disk_format_priority = {'qcow2': 3,
'qemu': 4,
'raw': 1,
'applehv': 2}
# First thing to do, filter by disk types
# and assign a selection priority... since Ironic can handle
# several different formats without issue.
new_manifests = []
for manifest in manifests:
# First evaluate the architecture because ironic can operated in
# an architecture agnostic mode... and we *can* match on it, but
# it is one of the most constraining factors.
if cpu_arch:
# NOTE(TheJulia): amd64 is the noted standard format in the
# API for x86_64. One thing, at least observing quay.io hosted
# artifacts is that there is heavy use of x86_64 as instead
# of amd64 as expected by the specification. This same sort
# of pattern extends to arm64/aarch64.
if cpu_arch in ['x86_64', 'amd64']:
possible_cpu_arch = ['x86_64', 'amd64']
elif cpu_arch in ['arm64', 'aarch64']:
possible_cpu_arch = ['aarch64', 'arm64']
else:
possible_cpu_arch = [cpu_arch]
# Extract what the architecture is noted for the image, from
# the platform field.
architecture = manifest.get('platform', {}).get('architecture')
if architecture and architecture not in possible_cpu_arch:
# skip onward, we don't have a localized match
continue
disktype = manifest.get('annotations', {}).get('disktype')
if disktype in disk_format_priority:
manifest['_priority'] = disk_format_priority[disktype]
elif not disktype:
manifest['_priority'] = 100
else:
continue
# Normalize and cache the disk type for further processing
if disktype == 'applehv':
disktype = 'raw'
elif disktype and disktype != 'raw':
disktype = 'qcow2'
manifest['_disktype'] = disktype
new_manifests.append(manifest)
return sorted(new_manifests, key=itemgetter('_priority'))
def identify_specific_image(self, image_href, image_download_source=None,
cpu_arch=None):
"""Identify a specific OCI Registry Artifact.
@@ -640,132 +707,87 @@ class OciImageService(BaseImageService):
artifact_index = self._client.get_artifact_index(image_href)
manifests = artifact_index.get('manifests', [])
if len(manifests) < 1:
# This is likely not going to happen, but we have nothing
# to identify and deploy based upon, so nothing found
# for user consistency.
raise exception.ImageNotFound(image_id=image_href)
mediaType = artifact_index.get('mediaType') or 'unknown'
if mediaType == oci_registry.MEDIA_OCI_MANIFEST_V1:
LOG.debug('The artifact index for image %s is a single '
'manifest, using its layers')
manifests = [artifact_index]
else:
LOG.error('Cannot use image %s: the artifact index of type %s '
'does not contain a list of manifests: %s',
image_href, mediaType, artifact_index)
# This is likely not going to happen, but we have nothing
# to identify and deploy based upon, so nothing found
# for user consistency.
raise exception.InvalidImageRef(image_href=image_href)
if image_download_source == 'swift':
raise exception.InvalidParameterValue(
err="An image_download_source of swift is incompatible with "
"retrieval of artifacts from an OCI container registry.")
# Determine our preferences for matching
if image_download_source == 'local':
# These types are qcow2 images, we can download these and convert
# them, but it is okay for us to match a raw appearing image
# if we don't have a qcow available.
disk_format_priority = {'qcow2': 1,
'qemu': 2,
'raw': 3,
'applehv': 4}
else:
# applehv appears to be a raw image,
# raw is the Ironic community preference.
disk_format_priority = {'qcow2': 3,
'qemu': 4,
'raw': 1,
'applehv': 2}
# First thing to do, filter by disk types
# and assign a selection priority... since Ironic can handle
# several different formats without issue.
new_manifests = []
for manifest in manifests:
artifact_format = manifest.get('annotations', {}).get('disktype')
if artifact_format in disk_format_priority.keys():
manifest['_priority'] = disk_format_priority[artifact_format]
else:
manifest['_priority'] = 100
new_manifests.append(manifest)
sorted_manifests = sorted(new_manifests, key=itemgetter('_priority'))
sorted_manifests = self._filter_manifests(
manifests, image_download_source, cpu_arch)
LOG.debug('Using manifests %s for image %s',
sorted_manifests, image_href)
# Iterate through the entries of manifests and evaluate them
# one by one to identify a likely item.
for manifest in sorted_manifests:
# First evaluate the architecture because ironic can operated in
# an architecture agnostic mode... and we *can* match on it, but
# it is one of the most constraining factors.
if cpu_arch:
# NOTE(TheJulia): amd64 is the noted standard format in the
# API for x86_64. One thing, at least observing quay.io hosted
# artifacts is that there is heavy use of x86_64 as instead
# of amd64 as expected by the specification. This same sort
# of pattern extends to arm64/aarch64.
if cpu_arch in ['x86_64', 'amd64']:
possible_cpu_arch = ['x86_64', 'amd64']
elif cpu_arch in ['arm64', 'aarch64']:
possible_cpu_arch = ['aarch64', 'arm64']
else:
possible_cpu_arch = [cpu_arch]
# Extract what the architecture is noted for the image, from
# the platform field.
architecture = manifest.get('platform', {}).get('architecture')
if architecture and architecture not in possible_cpu_arch:
# skip onward, we don't have a localized match
continue
if not manifest['_disktype']:
# If we got here, it means that the disk type is not set, and
# we need to detect it down the road.
LOG.warning('Image %s does not have a suitable disk type set '
'on any of its manifests, will use the first '
'manifest without a disk type', image_href)
# One thing podman is doing, and an ORAS client can set for
# upload, is annotations. This is ultimately the first point
# where we can identify likely artifacts.
# We also pre-sorted on disktype earlier, so in theory based upon
# preference, we should have the desired result as our first
# matching hint which meets the criteria.
disktype = manifest.get('annotations', {}).get('disktype')
if disktype:
if disktype in disk_format_priority.keys():
identified_manifest_digest = manifest.get('digest')
blob_manifest = self._client.get_manifest(
image_href, identified_manifest_digest)
layers = blob_manifest.get('layers', [])
if len(layers) != 1:
# This is a *multilayer* artifact, meaning a container
# construction, not a blob artifact in the OCI
# container registry. Odds are we're at the end of
# the references for what the user has requested
# consideration of as well, so it is good to log here.
LOG.info('Skipping consideration of container '
'registry manifest %s as it has multiple'
'layers.',
identified_manifest_digest)
continue
# NOTE(TheJulia): The resulting layer contents, has a
# mandatory mediaType value, which may be something like
# application/zstd or application/octet-stream and the
# an optional org.opencontainers.image.title annotation
# which would contain the filename the file was stored
# with in alignment with OARS annotations. Furthermore,
# there is an optional artifactType value with OCI
# distribution spec 1.1 (mid-2024) which could have
# been stored when the artifact was uploaded,
# but is optional. In any event, this is only available
# on the manifest contents, not further up unless we have
# the newer referrers API available. As of late 2024,
# quay.io did not offer the referrers API.
chosen_layer = layers[0]
blob_digest = chosen_layer.get('digest')
# Use the client helper to assemble a blob url, so we
# have consistency with what we expect and what we parse.
image_url = self._client.get_blob_url(image_href,
blob_digest)
image_size = chosen_layer.get('size')
chosen_original_filename = chosen_layer.get(
'annotations', {}).get(
'org.opencontainers.image.title')
manifest_digest = manifest.get('digest')
media_type = chosen_layer.get('mediaType')
is_raw_image = disktype in ['raw', 'applehv']
break
else:
# The case of there being no disk type in the entry.
# The only option here is to query the manifest contents out
# and based decisions upon that. :\
# We could look at the layers, count them, and maybe look at
# artifact types.
identified_manifest_digest = manifest.get('digest')
layers = manifest.get('layers')
if not layers:
blob_manifest = self._client.get_manifest(
image_href, identified_manifest_digest)
layers = blob_manifest.get('layers', [])
if len(layers) != 1:
# This is a *multilayer* artifact, meaning a container
# construction, not a blob artifact in the OCI
# container registry. Odds are we're at the end of
# the references for what the user has requested
# consideration of as well, so it is good to log here.
LOG.info('Skipping consideration of container '
'registry manifest %s for image %s as it has '
'multiple layers',
identified_manifest_digest, image_href)
continue
# NOTE(TheJulia): The resulting layer contents, has a
# mandatory mediaType value, which may be something like
# application/zstd or application/octet-stream and the
# an optional org.opencontainers.image.title annotation
# which would contain the filename the file was stored
# with in alignment with OARS annotations. Furthermore,
# there is an optional artifactType value with OCI
# distribution spec 1.1 (mid-2024) which could have
# been stored when the artifact was uploaded,
# but is optional. In any event, this is only available
# on the manifest contents, not further up unless we have
# the newer referrers API available. As of late 2024,
# quay.io did not offer the referrers API.
chosen_layer = layers[0]
blob_digest = chosen_layer.get('digest')
# Use the client helper to assemble a blob url, so we
# have consistency with what we expect and what we parse.
image_url = self._client.get_blob_url(image_href, blob_digest)
image_size = chosen_layer.get('size')
chosen_original_filename = chosen_layer.get(
'annotations', {}).get(
'org.opencontainers.image.title')
manifest_digest = (manifest.get('digest')
or manifest.get('dockerContentDigest'))
media_type = chosen_layer.get('mediaType')
disktype = manifest['_disktype']
break
if image_url:
# NOTE(TheJulia): Doing the final return dict generation as a
# last step in order to leave the door open to handling other
@@ -788,7 +810,10 @@ class OciImageService(BaseImageService):
url = urlparse.urlparse(image_href)
# Drop any trailing content indicating a tag
image_path = url.path.split(':')[0]
manifest = f'{url.scheme}://{url.netloc}{image_path}@{manifest_digest}' # noqa
if manifest_digest:
manifest = f'{url.scheme}://{url.netloc}{image_path}@{manifest_digest}' # noqa
else:
manifest = None
return {
'image_url': image_url,
'image_size': image_size,
@@ -797,7 +822,7 @@ class OciImageService(BaseImageService):
'image_container_manifest_digest': manifest_digest,
'image_media_type': media_type,
'image_compression_type': compression_type,
'image_disk_format': 'raw' if is_raw_image else 'qcow2',
'image_disk_format': disktype,
'image_request_authorization_secret': cached_auth,
'oci_image_manifest_url': manifest,
}

View File

@@ -29,6 +29,7 @@ from oslo_log import log as logging
from ironic.common import checksum_utils
from ironic.common import exception
from ironic.common.i18n import _
from ironic.conf import CONF
LOG = logging.getLogger(__name__)
@@ -471,6 +472,33 @@ class OciClient(object):
response.encoding = encoding
return response.text
@staticmethod
def _get_response_json(response, encoding='utf-8', force_encoding=False,
validate_digest=None):
"""Return request response as JSON.
We need to set the encoding for the response other wise it
will attempt to detect the encoding which is very time consuming.
See https://github.com/psf/requests/issues/4235 for additional
context.
The docker-content-digest header is added as a dockerContentDigest
field to the response.
:param: response: requests Respoinse object
:param: encoding: encoding to set if not currently set
:param: force_encoding: set response encoding always
:param: validate_digest: checksum to validate the text against
"""
text = OciClient._get_response_text(response, encoding, force_encoding)
if validate_digest:
checksum_utils.validate_text_checksum(text, validate_digest)
resource = json.loads(text)
contentDigest = response.headers.get('docker-content-digest')
if contentDigest:
resource['dockerContentDigest'] = contentDigest
return resource
@classmethod
def _build_url(cls, url, path):
"""Build an HTTPS URL from the input urlparse data.
@@ -507,6 +535,9 @@ class OciClient(object):
timeout=CONF.webserver_connection_timeout
)
except requests.exceptions.HTTPError as e:
LOG.error('Encountered error while attempting to download '
'manifest %s for image %s: %s',
manifest_url, image_url, e)
if e.response.status_code == 401:
# Authorization Required.
raise exception.ImageServiceAuthenticationRequired(
@@ -517,9 +548,8 @@ class OciClient(object):
if e.response.status_code >= 500:
raise exception.TemporaryFailure()
raise
manifest_str = self._get_response_text(manifest_r)
checksum_utils.validate_text_checksum(manifest_str, digest)
return json.loads(manifest_str)
return self._get_response_json(
manifest_r, validate_digest=digest)
def _get_artifact_index(self, image_url):
LOG.debug('Attempting to get the artifact index for: %s',
@@ -528,8 +558,9 @@ class OciClient(object):
index_url = self._build_url(
image_url, CALL_MANIFEST % parts
)
# Explicitly ask for the OCI artifact index
index_headers = {'Accept': ", ".join([MEDIA_OCI_INDEX_V1])}
# Explicitly ask for the OCI artifact index, fall back to manifest
index_headers = {'Accept': ", ".join([MEDIA_OCI_INDEX_V1,
MEDIA_OCI_MANIFEST_V1])}
try:
index_r = RegistrySessionHelper.get(
@@ -539,6 +570,9 @@ class OciClient(object):
timeout=CONF.webserver_connection_timeout
)
except requests.exceptions.HTTPError as e:
LOG.error('Encountered error while attempting to download '
'artifact index %s for image %s: %s',
index_url, image_url, e)
if e.response.status_code == 401:
# Authorization Required.
raise exception.ImageServiceAuthenticationRequired(
@@ -549,10 +583,9 @@ class OciClient(object):
if e.response.status_code >= 500:
raise exception.TemporaryFailure()
raise
index_str = self._get_response_text(index_r)
# Return a dictionary to the caller so it can house the
# filtering/sorting application logic.
return json.loads(index_str)
return self._get_response_json(index_r)
def _resolve_tag(self, image_url):
"""Attempts to resolve tags from a container URL."""
@@ -573,6 +606,9 @@ class OciClient(object):
headers=tag_headers,
timeout=CONF.webserver_connection_timeout)
except requests.exceptions.HTTPError as e:
LOG.error('Encountered error while attempting to download '
'the tag list %s for image %s: %s',
tags_url, image_url, e)
if e.response.status_code == 401:
# Authorization Required.
raise exception.ImageServiceAuthenticationRequired(
@@ -583,14 +619,24 @@ class OciClient(object):
tags = tags_r.json()['tags']
while 'next' in tags_r.links:
next_url = parse.urljoin(tags_url, tags_r.links['next']['url'])
tags_r = RegistrySessionHelper.get(
self.session, next_url,
headers=tag_headers,
timeout=CONF.webserver_connection_timeout)
try:
tags_r = RegistrySessionHelper.get(
self.session, next_url,
headers=tag_headers,
timeout=CONF.webserver_connection_timeout)
except requests.exceptions.HTTPError as e:
LOG.error('Encountered error while attempting to download '
'the next tag list %s for image %s: %s',
next_url, image_url, e)
raise
tags.extend(tags_r.json()['tags'])
if not tags:
raise exception.InvalidImageRef(
_("Image %s does not have any tags") % image_url.geturl())
if tag not in tags:
raise exception.ImageNotFound(
image_id=image_url.geturl())
raise exception.OciImageTagNotFound(
image_url=image_url.geturl(),
tags=', '.join(tags))
return parts
def get_artifact_index(self, image):
@@ -649,15 +695,16 @@ class OciClient(object):
"""
if not blob_digest and '@' in image:
split_url = image.split('@')
image_url = parse.urlparse(split_url[0])
image_url = split_url[0]
blob_digest = split_url[1]
elif blob_digest and '@' in image:
split_url = image.split('@')
image_url = parse.urlparse(split_url[0])
image_url = split_url[0]
# The caller likely has a bug or bad pattern
# which needs to be fixed
else:
image_url = parse.urlparse(image)
image_url = image
image_url = self._image_to_url(image_url)
# just in caes, split out the tag since it is not
# used for a blob manifest lookup.
image_path = image_url.path.split(':')[0]
@@ -738,8 +785,8 @@ class OciClient(object):
raise exception.ImageChecksumError()
except requests.exceptions.HTTPError as e:
LOG.debug('Encountered error while attempting to download %s',
blob_url)
LOG.error('Encountered error while attempting to download %s: %s',
blob_url, e)
# Stream changes the behavior, so odds of hitting
# this area area a bit low unless an actual exception
# is raised.
@@ -755,6 +802,8 @@ class OciClient(object):
except (OSError, requests.ConnectionError, requests.RequestException,
IOError) as e:
LOG.error('Encountered error while attempting to download %s: %s',
blob_url, e)
raise exception.ImageDownloadFailed(image_href=blob_url,
reason=str(e))

View File

@@ -845,6 +845,20 @@ class OciImageServiceTestCase(base.TestCase):
'mediaType': 'application/vnd.oci.image.index.v1+json',
'manifests': []
}
self.single_manifest = {
'mediaType': 'application/vnd.oci.image.manifest.v1+json',
'artifactType': 'application/vnd.unknown.artifact.v1',
'layers': [
{'mediaType': 'application/vnd.oci.image.layer.v1.tar',
'digest': 'sha256:7d6355852aeb6dbcd191bcda7cd74f1536cfe5cbf'
'8a10495a7283a8396e4b75b',
'size': 21692416,
'annotations': {
'org.opencontainers.image.title':
'cirros-0.6.3-x86_64-disk.img'
}},
]
}
@mock.patch.object(ociclient, 'get_manifest', autospec=True)
@mock.patch.object(ociclient, 'get_artifact_index',
@@ -1062,7 +1076,7 @@ class OciImageServiceTestCase(base.TestCase):
mock_get_artifact_index,
mock_get_manifest):
mock_get_artifact_index.return_value = self.empty_artifact_index
self.assertRaises(exception.ImageNotFound,
self.assertRaises(exception.InvalidImageRef,
self.service.identify_specific_image,
self.href)
mock_get_artifact_index.assert_called_once_with(mock.ANY, self.href)
@@ -1164,6 +1178,32 @@ class OciImageServiceTestCase(base.TestCase):
self.assertEqual('sha256:' + csum,
self.service.transfer_verified_checksum)
@mock.patch.object(ociclient, 'get_artifact_index', autospec=True)
def test_identify_specific_image_single_manifest(
self, mock_get_artifact_index):
self.single_manifest['dockerContentDigest'] = \
'sha256:9d046091b3dbeda26e1f4364a116ca8d94284000f103da7310e3a4703df1d3e4' # noqa
mock_get_artifact_index.return_value = self.single_manifest
expected_data = {
'image_checksum': '7d6355852aeb6dbcd191bcda7cd74f1536cfe5cbf8a10495a7283a8396e4b75b', # noqa
'image_compression_type': None,
'image_container_manifest_digest': 'sha256:9d046091b3dbeda26e1f4364a116ca8d94284000f103da7310e3a4703df1d3e4', # noqa
'image_filename': 'cirros-0.6.3-x86_64-disk.img',
'image_disk_format': None,
'image_media_type': 'application/vnd.oci.image.layer.v1.tar',
'image_request_authorization_secret': None,
'image_size': 21692416,
'image_url': 'https://localhost/v2/podman/machine-os/blobs/sha256:7d6355852aeb6dbcd191bcda7cd74f1536cfe5cbf8a10495a7283a8396e4b75b', # noqa
'oci_image_manifest_url': 'oci://localhost/podman/machine-os@sha256:9d046091b3dbeda26e1f4364a116ca8d94284000f103da7310e3a4703df1d3e4' # noqa
}
img_data = self.service.identify_specific_image(
self.href, cpu_arch='amd64')
self.assertEqual(expected_data, img_data)
mock_get_artifact_index.assert_called_once_with(mock.ANY, self.href)
@mock.patch.object(ociclient, '_get_manifest', autospec=True)
def test_show(self, mock_get_manifest):
layer_csum = ('96f33f01d5347424f947e43ff05634915f422debc'

View File

@@ -28,6 +28,12 @@ from ironic.tests import base
CONF = cfg.CONF
ACCEPT_INDEX_OR_MANIFEST = (
'application/vnd.oci.image.index.v1+json, '
'application/vnd.oci.image.manifest.v1+json'
)
class OciClientTestCase(base.TestCase):
def setUp(self):
@@ -110,6 +116,7 @@ class OciClientRequestTestCase(base.TestCase):
'60f61caaff8a')
get_mock.return_value.status_code = 200
get_mock.return_value.text = '{}'
get_mock.return_value.headers = {}
res = self.client.get_manifest(
'oci://localhost/local@sha256:' + csum)
self.assertEqual({}, res)
@@ -122,6 +129,24 @@ class OciClientRequestTestCase(base.TestCase):
headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
timeout=60)
def test_get_manifest_with_content_digest(self, get_mock):
csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
'60f61caaff8a')
get_mock.return_value.status_code = 200
get_mock.return_value.text = '{}'
get_mock.return_value.headers = {'docker-content-digest': 'abcd'}
res = self.client.get_manifest(
'oci://localhost/local@sha256:' + csum)
self.assertEqual({'dockerContentDigest': 'abcd'}, res)
get_mock.return_value.assert_has_calls([
mock.call.raise_for_status(),
mock.call.encoding.__bool__()])
get_mock.assert_called_once_with(
mock.ANY,
'https://localhost/v2/local/manifests/sha256:' + csum,
headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
timeout=60)
def test_get_manifest_auth_required(self, get_mock):
fake_csum = 'f' * 64
response = mock.Mock()
@@ -208,6 +233,7 @@ class OciClientRequestTestCase(base.TestCase):
}
get_mock.return_value.status_code = 200
get_mock.return_value.text = '{}'
get_mock.return_value.headers = {}
res = self.client.get_artifact_index(
'oci://localhost/local:tag')
self.assertEqual({}, res)
@@ -220,7 +246,7 @@ class OciClientRequestTestCase(base.TestCase):
get_mock.assert_called_once_with(
mock.ANY,
'https://localhost/v2/local/manifests/tag',
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
timeout=60)
@mock.patch.object(oci_registry.OciClient, '_resolve_tag',
@@ -245,7 +271,7 @@ class OciClientRequestTestCase(base.TestCase):
call_mock = mock.call(
mock.ANY,
'https://localhost/v2/local/manifests/tag',
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
timeout=60)
get_mock.assert_has_calls([call_mock])
@@ -272,7 +298,7 @@ class OciClientRequestTestCase(base.TestCase):
call_mock = mock.call(
mock.ANY,
'https://localhost/v2/local/manifests/tag',
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
timeout=60)
# Automatic retry to authenticate
get_mock.assert_has_calls([call_mock, call_mock])
@@ -300,7 +326,7 @@ class OciClientRequestTestCase(base.TestCase):
call_mock = mock.call(
mock.ANY,
'https://localhost/v2/local/manifests/tag',
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
timeout=60)
get_mock.assert_has_calls([call_mock])
@@ -327,7 +353,7 @@ class OciClientRequestTestCase(base.TestCase):
call_mock = mock.call(
mock.ANY,
'https://localhost/v2/local/manifests/tag',
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
timeout=60)
get_mock.assert_has_calls([call_mock])
@@ -354,7 +380,24 @@ class OciClientRequestTestCase(base.TestCase):
response.links = {}
get_mock.return_value = response
self.assertRaises(
exception.ImageNotFound,
exception.OciImageTagNotFound,
self.client._resolve_tag,
parse.urlparse('oci://localhost/local'))
call_mock = mock.call(
mock.ANY,
'https://localhost/v2/local/tags/list',
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
timeout=60)
get_mock.assert_has_calls([call_mock])
def test__resolve_tag_if_no_tags(self, get_mock):
response = mock.Mock()
response.json.return_value = {'tags': []}
response.status_code = 200
response.links = {}
get_mock.return_value = response
self.assertRaises(
exception.InvalidImageRef,
self.client._resolve_tag,
parse.urlparse('oci://localhost/local'))
call_mock = mock.call(

View File

@@ -0,0 +1,5 @@
---
fixes:
- |
Fixes deploying OCI artifacts uploaded by ORAS to Quay.io (and potentially
other registries) as a single manifest.