mirror of
https://opendev.org/openstack/ironic.git
synced 2026-01-11 19:57:20 +00:00
Merge "OCI: Add an option to fallback to HTTP"
This commit is contained in:
commit
50aa083013
4 changed files with 524 additions and 27 deletions
|
|
@ -357,6 +357,10 @@ class OciClient(object):
|
|||
# directly handle credentials to IPA.
|
||||
_cached_auth = None
|
||||
|
||||
# An override protocol scheme for use on the connection class if
|
||||
# the client class instance needs to fall back to utilizing HTTP.
|
||||
_override_protocol_scheme = None
|
||||
|
||||
def __init__(self, verify):
|
||||
"""Initialize the OCI container registry client class.
|
||||
|
||||
|
|
@ -380,18 +384,31 @@ class OciClient(object):
|
|||
registry requires authentication but we do not have a
|
||||
credentials.
|
||||
"""
|
||||
url = self._image_to_url(image_url)
|
||||
image, tag = self._image_tag_from_url(url)
|
||||
parsed_url = self._image_to_url(image_url)
|
||||
image, tag = self._image_tag_from_url(parsed_url)
|
||||
scope = 'repository:%s:pull' % image[1:]
|
||||
|
||||
url = self._build_url(url, path='/')
|
||||
url = self._build_url(parsed_url, path='/')
|
||||
|
||||
# If authenticate is called an additional time....
|
||||
# clear the authorization in the client.
|
||||
if self.session:
|
||||
self.session.headers.pop('Authorization', None)
|
||||
try:
|
||||
r = self.session.get(url,
|
||||
timeout=CONF.webserver_connection_timeout)
|
||||
except requests.exceptions.SSLError as e:
|
||||
if CONF.oci.permit_fallback_to_http_transport:
|
||||
LOG.error("Error encountered while attempting to access an "
|
||||
"OCI registry utilizing HTTPS. Will retry with "
|
||||
"HTTP. Error: %s", e)
|
||||
url = self._build_url(parsed_url, path='/', scheme='http')
|
||||
else:
|
||||
raise
|
||||
# Retry without https if permitted by configuration.
|
||||
r = self.session.get(url,
|
||||
timeout=CONF.webserver_connection_timeout)
|
||||
|
||||
r = self.session.get(url, timeout=CONF.webserver_connection_timeout)
|
||||
LOG.debug('%s status code %s', url, r.status_code)
|
||||
if r.status_code == 200:
|
||||
# "Auth" was successful, returning.
|
||||
|
|
@ -499,18 +516,26 @@ class OciClient(object):
|
|||
resource['dockerContentDigest'] = contentDigest
|
||||
return resource
|
||||
|
||||
@classmethod
|
||||
def _build_url(cls, url, path):
|
||||
def _build_url(self, url, path, scheme='https'):
|
||||
"""Build an HTTPS URL from the input urlparse data.
|
||||
|
||||
:param url: The urlparse result object with the netloc object which
|
||||
is extracted and used by this method.
|
||||
:param path: The path in the form of a string which is then assembled
|
||||
into an HTTPS URL to be used for access.
|
||||
:param scheme: The scheme to utilize for the protocol, defaults to
|
||||
'https'. If an alternative scheme is passed,
|
||||
such as 'http', then that scheme is saved for future
|
||||
use by this method because it signifies the remote
|
||||
registry is utilizing a protocol which is not HTTPS.
|
||||
:returns: A fully formed url in the form of https://ur.
|
||||
"""
|
||||
if not self._override_protocol_scheme and scheme != 'https':
|
||||
self._override_protocol_scheme = scheme
|
||||
elif self._override_protocol_scheme:
|
||||
scheme = self._override_protocol_scheme
|
||||
|
||||
netloc = url.netloc
|
||||
scheme = 'https'
|
||||
return '%s://%s/v2%s' % (scheme, netloc, path)
|
||||
|
||||
def _get_manifest(self, image_url, digest=None):
|
||||
|
|
@ -528,12 +553,32 @@ class OciClient(object):
|
|||
# Explicitly ask for the OCI artifact index
|
||||
manifest_headers = {'Accept': ", ".join([MEDIA_OCI_MANIFEST_V1])}
|
||||
try:
|
||||
manifest_r = RegistrySessionHelper.get(
|
||||
self.session,
|
||||
manifest_url,
|
||||
headers=manifest_headers,
|
||||
timeout=CONF.webserver_connection_timeout
|
||||
)
|
||||
try:
|
||||
manifest_r = RegistrySessionHelper.get(
|
||||
self.session,
|
||||
manifest_url,
|
||||
headers=manifest_headers,
|
||||
timeout=CONF.webserver_connection_timeout
|
||||
)
|
||||
except requests.exceptions.SSLError as e:
|
||||
if CONF.oci.permit_fallback_to_http_transport:
|
||||
LOG.error("Error encountered while attempting to access "
|
||||
"an OCI registry utilizing HTTPS. Will retry "
|
||||
"with HTTP. Error: %s", e)
|
||||
manifest_url = self._build_url(
|
||||
image_url,
|
||||
CALL_MANIFEST % {'image': image_path,
|
||||
'tag': digest},
|
||||
scheme='http')
|
||||
else:
|
||||
raise
|
||||
# Retry without SSL if permitted by configuration.
|
||||
manifest_r = RegistrySessionHelper.get(
|
||||
self.session,
|
||||
manifest_url,
|
||||
headers=manifest_headers,
|
||||
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',
|
||||
|
|
@ -563,12 +608,30 @@ class OciClient(object):
|
|||
MEDIA_OCI_MANIFEST_V1])}
|
||||
|
||||
try:
|
||||
index_r = RegistrySessionHelper.get(
|
||||
self.session,
|
||||
index_url,
|
||||
headers=index_headers,
|
||||
timeout=CONF.webserver_connection_timeout
|
||||
)
|
||||
try:
|
||||
index_r = RegistrySessionHelper.get(
|
||||
self.session,
|
||||
index_url,
|
||||
headers=index_headers,
|
||||
timeout=CONF.webserver_connection_timeout
|
||||
)
|
||||
except requests.exceptions.SSLError as e:
|
||||
if CONF.oci.permit_fallback_to_http_transport:
|
||||
LOG.error("Error encountered while attempting to access "
|
||||
"an OCI registry utilizing HTTPS. Will retry "
|
||||
"with HTTP. Error: %s", e)
|
||||
index_url = self._build_url(
|
||||
image_url, CALL_MANIFEST % parts, scheme='http'
|
||||
)
|
||||
else:
|
||||
raise
|
||||
index_r = RegistrySessionHelper.get(
|
||||
self.session,
|
||||
index_url,
|
||||
headers=index_headers,
|
||||
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',
|
||||
|
|
@ -601,10 +664,22 @@ class OciClient(object):
|
|||
)
|
||||
tag_headers = {'Accept': ", ".join([MEDIA_OCI_INDEX_V1])}
|
||||
try:
|
||||
tags_r = RegistrySessionHelper.get(
|
||||
self.session, tags_url,
|
||||
headers=tag_headers,
|
||||
timeout=CONF.webserver_connection_timeout)
|
||||
try:
|
||||
tags_r = RegistrySessionHelper.get(
|
||||
self.session, tags_url,
|
||||
headers=tag_headers,
|
||||
timeout=CONF.webserver_connection_timeout)
|
||||
except requests.exceptions.SSLError:
|
||||
if CONF.oci.permit_fallback_to_http_transport:
|
||||
tags_url = self._build_url(
|
||||
image_url, CALL_TAGS % parts, scheme='http'
|
||||
)
|
||||
else:
|
||||
raise
|
||||
tags_r = RegistrySessionHelper.get(
|
||||
self.session, tags_url,
|
||||
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',
|
||||
|
|
@ -620,10 +695,24 @@ class OciClient(object):
|
|||
while 'next' in tags_r.links:
|
||||
next_url = parse.urljoin(tags_url, tags_r.links['next']['url'])
|
||||
try:
|
||||
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.SSLError as e:
|
||||
if CONF.oci.permit_fallback_to_http_transport:
|
||||
LOG.error("Error encountered while attempting to "
|
||||
"access an OCI registry utilizing HTTPS. "
|
||||
"Will retry with HTTP. Error: %s", e)
|
||||
# Replace https with http in the next_url
|
||||
next_url = next_url.replace('https://', 'http://')
|
||||
else:
|
||||
raise
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -55,6 +55,18 @@ opts = [
|
|||
'and the file can be updated as Ironic operates '
|
||||
'in the event pre-shared tokens need to be '
|
||||
'regenerated.')),
|
||||
cfg.BoolOpt('permit_fallback_to_http_transport',
|
||||
default=False,
|
||||
mutable=False,
|
||||
help=_('Security-Insecure: By default, the OCI client code '
|
||||
'expects all OCI registry interactions to take place '
|
||||
'utilizing HTTPS as the underlying transport '
|
||||
'mechanism to communicate with the remote registry. '
|
||||
'In reality, that is not always the case in testing '
|
||||
'environments, and as such this setting may be '
|
||||
'utilized to allow the internal OCI mechanism to '
|
||||
'fallback to HTTP if the remote endpoint lacks '
|
||||
'support for HTTPS.')),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -472,6 +472,122 @@ class OciClientRequestTestCase(base.TestCase):
|
|||
timeout=60)
|
||||
get_mock.assert_has_calls([call_mock])
|
||||
|
||||
def test__resolve_tag_ssl_error_with_http_fallback_enabled(
|
||||
self, get_mock):
|
||||
"""Test tag resolution falls back to HTTP on SSLError when enabled."""
|
||||
CONF.set_override('permit_fallback_to_http_transport', True,
|
||||
group='oci')
|
||||
ssl_error = requests.exceptions.SSLError('SSL handshake failed')
|
||||
response_http = mock.Mock()
|
||||
response_http.json.return_value = {'tags': ['latest', 'foo']}
|
||||
response_http.status_code = 200
|
||||
response_http.links = {}
|
||||
get_mock.side_effect = iter([ssl_error, response_http])
|
||||
|
||||
res = self.client._resolve_tag(
|
||||
parse.urlparse('oci://localhost/local'))
|
||||
|
||||
self.assertDictEqual({'image': '/local', 'tag': 'latest'}, res)
|
||||
self.assertEqual(2, get_mock.call_count)
|
||||
# First call should be HTTPS
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY,
|
||||
'https://localhost/v2/local/tags/list',
|
||||
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
|
||||
timeout=60)
|
||||
# Second call should be HTTP
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY,
|
||||
'http://localhost/v2/local/tags/list',
|
||||
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
|
||||
timeout=60)
|
||||
|
||||
def test__resolve_tag_ssl_error_with_http_fallback_disabled(
|
||||
self, get_mock):
|
||||
"""Test SSLError is raised when tag resolution fallback disabled."""
|
||||
CONF.set_override('permit_fallback_to_http_transport', False,
|
||||
group='oci')
|
||||
ssl_error = requests.exceptions.SSLError('SSL handshake failed')
|
||||
get_mock.side_effect = ssl_error
|
||||
|
||||
self.assertRaises(
|
||||
requests.exceptions.SSLError,
|
||||
self.client._resolve_tag,
|
||||
parse.urlparse('oci://localhost/local'))
|
||||
|
||||
# Only one HTTPS call should be made
|
||||
self.assertEqual(1, get_mock.call_count)
|
||||
get_mock.assert_called_once_with(
|
||||
mock.ANY,
|
||||
'https://localhost/v2/local/tags/list',
|
||||
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
|
||||
timeout=60)
|
||||
|
||||
def test__resolve_tag_follows_links_with_ssl_error_fallback(
|
||||
self, get_mock):
|
||||
"""Test paginated tag list falls back to HTTP on SSLError."""
|
||||
CONF.set_override('permit_fallback_to_http_transport', True,
|
||||
group='oci')
|
||||
# First request succeeds with HTTPS
|
||||
response1 = mock.Mock()
|
||||
response1.json.return_value = {'tags': ['foo', 'bar']}
|
||||
response1.status_code = 200
|
||||
response1.links = {'next': {'url': 'list2'}}
|
||||
|
||||
# Second (paginated) request fails with SSL, then succeeds with HTTP
|
||||
ssl_error = requests.exceptions.SSLError('SSL handshake failed')
|
||||
response2 = mock.Mock()
|
||||
response2.json.return_value = {'tags': ['zoo']}
|
||||
response2.status_code = 200
|
||||
response2.links = {}
|
||||
|
||||
get_mock.side_effect = iter([response1, ssl_error, response2])
|
||||
|
||||
res = self.client._resolve_tag(
|
||||
parse.urlparse('oci://localhost/local:zoo'))
|
||||
|
||||
self.assertDictEqual({'image': '/local', 'tag': 'zoo'}, res)
|
||||
self.assertEqual(3, get_mock.call_count)
|
||||
# First call for initial tag list
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY,
|
||||
'https://localhost/v2/local/tags/list',
|
||||
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
|
||||
timeout=60)
|
||||
# Second call for paginated list (HTTPS fails)
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY,
|
||||
'https://localhost/v2/local/tags/list2',
|
||||
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
|
||||
timeout=60)
|
||||
# Third call for paginated list (HTTP succeeds)
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY,
|
||||
'http://localhost/v2/local/tags/list2',
|
||||
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
|
||||
timeout=60)
|
||||
|
||||
def test__resolve_tag_after_previous_http_fallback(self, get_mock):
|
||||
"""Test tag resolution uses HTTP after override scheme is set."""
|
||||
# Simulate previous fallback
|
||||
self.client._override_protocol_scheme = 'http'
|
||||
response = mock.Mock()
|
||||
response.json.return_value = {'tags': ['latest', 'foo']}
|
||||
response.status_code = 200
|
||||
response.links = {}
|
||||
get_mock.return_value = response
|
||||
|
||||
res = self.client._resolve_tag(
|
||||
parse.urlparse('oci://localhost/local'))
|
||||
|
||||
self.assertDictEqual({'image': '/local', 'tag': 'latest'}, res)
|
||||
# Should use HTTP directly
|
||||
get_mock.assert_called_once_with(
|
||||
mock.ANY,
|
||||
'http://localhost/v2/local/tags/list',
|
||||
headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
|
||||
timeout=60)
|
||||
|
||||
def test_authenticate_noop(self, get_mock):
|
||||
"""Test authentication when the remote endpoint doesn't require it."""
|
||||
response = mock.Mock()
|
||||
|
|
@ -900,6 +1016,279 @@ class OciClientRequestTestCase(base.TestCase):
|
|||
mock_file.write.assert_not_called()
|
||||
self.assertEqual(2, get_mock.call_count)
|
||||
|
||||
def test_authenticate_ssl_error_with_http_fallback_enabled(
|
||||
self, get_mock):
|
||||
"""Test authentication falls back to HTTP on SSLError when enabled."""
|
||||
CONF.set_override('permit_fallback_to_http_transport', True,
|
||||
group='oci')
|
||||
response_https = mock.Mock()
|
||||
response_https.status_code = 200
|
||||
ssl_error = requests.exceptions.SSLError('SSL handshake failed')
|
||||
response_http = mock.Mock()
|
||||
response_http.status_code = 200
|
||||
get_mock.side_effect = iter([ssl_error, response_http])
|
||||
|
||||
self.client.authenticate('oci://localhost/foo/bar:latest')
|
||||
|
||||
self.assertEqual(2, get_mock.call_count)
|
||||
# First call should be HTTPS
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY, 'https://localhost/v2/', timeout=60)
|
||||
# Second call should be HTTP
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY, 'http://localhost/v2/', timeout=60)
|
||||
# Verify override scheme is set
|
||||
self.assertEqual('http', self.client._override_protocol_scheme)
|
||||
|
||||
def test_authenticate_ssl_error_with_http_fallback_disabled(
|
||||
self, get_mock):
|
||||
"""Test SSLError is raised when HTTP fallback is disabled."""
|
||||
CONF.set_override('permit_fallback_to_http_transport', False,
|
||||
group='oci')
|
||||
ssl_error = requests.exceptions.SSLError('SSL handshake failed')
|
||||
get_mock.side_effect = ssl_error
|
||||
|
||||
self.assertRaises(
|
||||
requests.exceptions.SSLError,
|
||||
self.client.authenticate,
|
||||
'oci://localhost/foo/bar:latest')
|
||||
|
||||
# Only one HTTPS call should be made
|
||||
self.assertEqual(1, get_mock.call_count)
|
||||
get_mock.assert_called_once_with(
|
||||
mock.ANY, 'https://localhost/v2/', timeout=60)
|
||||
|
||||
def test_authenticate_successful_https_no_fallback(self, get_mock):
|
||||
"""Test normal authentication without needing fallback."""
|
||||
response = mock.Mock()
|
||||
response.status_code = 200
|
||||
get_mock.return_value = response
|
||||
|
||||
self.client.authenticate('oci://localhost/foo/bar:latest')
|
||||
|
||||
# Only HTTPS call should be made
|
||||
get_mock.assert_called_once_with(
|
||||
mock.ANY, 'https://localhost/v2/', timeout=60)
|
||||
# Override scheme should not be set
|
||||
self.assertIsNone(self.client._override_protocol_scheme)
|
||||
|
||||
def test_get_manifest_ssl_error_with_http_fallback_enabled(
|
||||
self, get_mock):
|
||||
"""Test manifest retrieval falls back to HTTP on SSLError."""
|
||||
CONF.set_override('permit_fallback_to_http_transport', True,
|
||||
group='oci')
|
||||
csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
|
||||
'60f61caaff8a')
|
||||
|
||||
ssl_error = requests.exceptions.SSLError('SSL handshake failed')
|
||||
response_http = mock.Mock()
|
||||
response_http.status_code = 200
|
||||
response_http.text = '{}'
|
||||
response_http.headers = {}
|
||||
get_mock.side_effect = iter([ssl_error, response_http])
|
||||
|
||||
res = self.client.get_manifest(
|
||||
'oci://localhost/local@sha256:' + csum)
|
||||
|
||||
self.assertEqual({}, res)
|
||||
self.assertEqual(2, get_mock.call_count)
|
||||
# First call HTTPS
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY,
|
||||
'https://localhost/v2/local/manifests/sha256:' + csum,
|
||||
headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
|
||||
timeout=60)
|
||||
# Second call HTTP
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY,
|
||||
'http://localhost/v2/local/manifests/sha256:' + csum,
|
||||
headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
|
||||
timeout=60)
|
||||
|
||||
def test_get_manifest_ssl_error_with_http_fallback_disabled(
|
||||
self, get_mock):
|
||||
"""Test SSLError is raised when manifest fallback is disabled."""
|
||||
CONF.set_override('permit_fallback_to_http_transport', False,
|
||||
group='oci')
|
||||
csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
|
||||
'60f61caaff8a')
|
||||
|
||||
ssl_error = requests.exceptions.SSLError('SSL handshake failed')
|
||||
get_mock.side_effect = ssl_error
|
||||
|
||||
self.assertRaises(
|
||||
requests.exceptions.SSLError,
|
||||
self.client.get_manifest,
|
||||
'oci://localhost/local@sha256:' + csum)
|
||||
|
||||
self.assertEqual(1, get_mock.call_count)
|
||||
|
||||
def test_get_manifest_after_previous_http_fallback(self, get_mock):
|
||||
"""Test manifest uses HTTP after override scheme is set."""
|
||||
# Simulate previous fallback
|
||||
self.client._override_protocol_scheme = 'http'
|
||||
csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
|
||||
'60f61caaff8a')
|
||||
|
||||
response = mock.Mock()
|
||||
response.status_code = 200
|
||||
response.text = '{}'
|
||||
response.headers = {}
|
||||
get_mock.return_value = response
|
||||
|
||||
res = self.client.get_manifest(
|
||||
'oci://localhost/local@sha256:' + csum)
|
||||
|
||||
self.assertEqual({}, res)
|
||||
# Should use HTTP directly
|
||||
get_mock.assert_called_once_with(
|
||||
mock.ANY,
|
||||
'http://localhost/v2/local/manifests/sha256:' + csum,
|
||||
headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
|
||||
timeout=60)
|
||||
|
||||
@mock.patch.object(oci_registry.OciClient, '_resolve_tag',
|
||||
autospec=True)
|
||||
def test_get_artifact_index_ssl_error_with_http_fallback_enabled(
|
||||
self, resolve_tag_mock, get_mock):
|
||||
"""Test index retrieval falls back to HTTP on SSLError."""
|
||||
CONF.set_override('permit_fallback_to_http_transport', True,
|
||||
group='oci')
|
||||
resolve_tag_mock.return_value = {
|
||||
'image': '/local',
|
||||
'tag': 'tag'
|
||||
}
|
||||
|
||||
ssl_error = requests.exceptions.SSLError('SSL handshake failed')
|
||||
response_http = mock.Mock()
|
||||
response_http.status_code = 200
|
||||
response_http.text = '{}'
|
||||
response_http.headers = {}
|
||||
get_mock.side_effect = iter([ssl_error, response_http])
|
||||
|
||||
res = self.client.get_artifact_index('oci://localhost/local:tag')
|
||||
|
||||
self.assertEqual({}, res)
|
||||
self.assertEqual(2, get_mock.call_count)
|
||||
# First call HTTPS
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY,
|
||||
'https://localhost/v2/local/manifests/tag',
|
||||
headers={'Accept': ('application/vnd.oci.image.index.v1+json, '
|
||||
'application/vnd.oci.image.manifest.v1+json')},
|
||||
timeout=60)
|
||||
# Second call HTTP
|
||||
get_mock.assert_any_call(
|
||||
mock.ANY,
|
||||
'http://localhost/v2/local/manifests/tag',
|
||||
headers={'Accept': ('application/vnd.oci.image.index.v1+json, '
|
||||
'application/vnd.oci.image.manifest.v1+json')},
|
||||
timeout=60)
|
||||
|
||||
@mock.patch.object(oci_registry.OciClient, '_resolve_tag',
|
||||
autospec=True)
|
||||
def test_get_artifact_index_ssl_error_with_http_fallback_disabled(
|
||||
self, resolve_tag_mock, get_mock):
|
||||
"""Test SSLError is raised when index fallback is disabled."""
|
||||
CONF.set_override('permit_fallback_to_http_transport', False,
|
||||
group='oci')
|
||||
resolve_tag_mock.return_value = {
|
||||
'image': '/local',
|
||||
'tag': 'tag'
|
||||
}
|
||||
|
||||
ssl_error = requests.exceptions.SSLError('SSL handshake failed')
|
||||
get_mock.side_effect = ssl_error
|
||||
|
||||
self.assertRaises(
|
||||
requests.exceptions.SSLError,
|
||||
self.client.get_artifact_index,
|
||||
'oci://localhost/local:tag')
|
||||
|
||||
self.assertEqual(1, get_mock.call_count)
|
||||
|
||||
@mock.patch.object(oci_registry.OciClient, '_resolve_tag',
|
||||
autospec=True)
|
||||
def test_get_artifact_index_after_previous_http_fallback(
|
||||
self, resolve_tag_mock, get_mock):
|
||||
"""Test index uses HTTP after override scheme is set."""
|
||||
# Simulate previous fallback
|
||||
self.client._override_protocol_scheme = 'http'
|
||||
resolve_tag_mock.return_value = {
|
||||
'image': '/local',
|
||||
'tag': 'tag'
|
||||
}
|
||||
|
||||
response = mock.Mock()
|
||||
response.status_code = 200
|
||||
response.text = '{}'
|
||||
response.headers = {}
|
||||
get_mock.return_value = response
|
||||
|
||||
res = self.client.get_artifact_index('oci://localhost/local:tag')
|
||||
|
||||
self.assertEqual({}, res)
|
||||
# Should use HTTP directly
|
||||
get_mock.assert_called_once_with(
|
||||
mock.ANY,
|
||||
'http://localhost/v2/local/manifests/tag',
|
||||
headers={'Accept': ('application/vnd.oci.image.index.v1+json, '
|
||||
'application/vnd.oci.image.manifest.v1+json')},
|
||||
timeout=60)
|
||||
|
||||
|
||||
class OciClientBuildUrlTestCase(base.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Create an instance for testing
|
||||
self.client = oci_registry.OciClient(verify=True)
|
||||
|
||||
def test_build_url_default_https_scheme(self):
|
||||
"""Test _build_url uses HTTPS by default."""
|
||||
url = parse.urlparse('oci://localhost/foo/bar')
|
||||
result = self.client._build_url(url, '/test')
|
||||
|
||||
self.assertEqual('https://localhost/v2/test', result)
|
||||
self.assertIsNone(self.client._override_protocol_scheme)
|
||||
|
||||
def test_build_url_with_http_scheme(self):
|
||||
"""Test _build_url with HTTP scheme sets override."""
|
||||
url = parse.urlparse('oci://localhost/foo/bar')
|
||||
result = self.client._build_url(url, '/test', scheme='http')
|
||||
|
||||
self.assertEqual('http://localhost/v2/test', result)
|
||||
self.assertEqual('http', self.client._override_protocol_scheme)
|
||||
|
||||
def test_build_url_uses_cached_override_scheme(self):
|
||||
"""Test _build_url uses cached override for subsequent calls."""
|
||||
self.client._override_protocol_scheme = 'http'
|
||||
url = parse.urlparse('oci://localhost/foo/bar')
|
||||
|
||||
# Call with default scheme, should use cached http
|
||||
result = self.client._build_url(url, '/test')
|
||||
|
||||
self.assertEqual('http://localhost/v2/test', result)
|
||||
self.assertEqual('http', self.client._override_protocol_scheme)
|
||||
|
||||
def test_build_url_override_persists(self):
|
||||
"""Test override scheme persists across multiple calls."""
|
||||
url = parse.urlparse('oci://localhost/foo/bar')
|
||||
|
||||
# First call with http
|
||||
result1 = self.client._build_url(
|
||||
url, '/test1', scheme='http')
|
||||
self.assertEqual('http://localhost/v2/test1', result1)
|
||||
|
||||
# Second call without specifying scheme
|
||||
result2 = self.client._build_url(url, '/test2')
|
||||
self.assertEqual('http://localhost/v2/test2', result2)
|
||||
|
||||
# Third call explicitly with https, should still use http
|
||||
result3 = self.client._build_url(
|
||||
url, '/test3', scheme='https')
|
||||
self.assertEqual('http://localhost/v2/test3', result3)
|
||||
|
||||
|
||||
class TestRegistrySessionHelper(base.TestCase):
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
features:
|
||||
- |
|
||||
Adds a capability to permit OCI URL support to be automatically able to
|
||||
fallback to using HTTP instead of the expected HTTPS for transport.
|
||||
By default, this option is disabled but can be enabled via the
|
||||
``[oci]permit_fallback_to_http_transport`` configuration option.
|
||||
Loading…
Add table
Reference in a new issue