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:
@@ -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. "
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
5
releasenotes/notes/oci-fixes-bbbcc633394252f6.yaml
Normal file
5
releasenotes/notes/oci-fixes-bbbcc633394252f6.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
fixes:
|
||||
- |
|
||||
Fixes deploying OCI artifacts uploaded by ORAS to Quay.io (and potentially
|
||||
other registries) as a single manifest.
|
||||
Reference in New Issue
Block a user