diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 79e0f5df5c..033d7ac0e4 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -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. " diff --git a/ironic/common/image_service.py b/ironic/common/image_service.py index c06956e0e9..e9d24f7800 100644 --- a/ironic/common/image_service.py +++ b/ironic/common/image_service.py @@ -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, } diff --git a/ironic/common/oci_registry.py b/ironic/common/oci_registry.py index c435cea32d..66ff01a77e 100644 --- a/ironic/common/oci_registry.py +++ b/ironic/common/oci_registry.py @@ -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)) diff --git a/ironic/tests/unit/common/test_image_service.py b/ironic/tests/unit/common/test_image_service.py index 5f460df60a..6304933d31 100644 --- a/ironic/tests/unit/common/test_image_service.py +++ b/ironic/tests/unit/common/test_image_service.py @@ -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' diff --git a/ironic/tests/unit/common/test_oci_registry.py b/ironic/tests/unit/common/test_oci_registry.py index b1f0fa1a81..e74c9cc4e1 100644 --- a/ironic/tests/unit/common/test_oci_registry.py +++ b/ironic/tests/unit/common/test_oci_registry.py @@ -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( diff --git a/releasenotes/notes/oci-fixes-bbbcc633394252f6.yaml b/releasenotes/notes/oci-fixes-bbbcc633394252f6.yaml new file mode 100644 index 0000000000..ff10d2716b --- /dev/null +++ b/releasenotes/notes/oci-fixes-bbbcc633394252f6.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + Fixes deploying OCI artifacts uploaded by ORAS to Quay.io (and potentially + other registries) as a single manifest.