OCI: Add an option to fallback to HTTP

While working on trying to get OCI support in CI, I realized that the
default pattern setup with Bifrost was to setup a registry *without*
HTTPS.

This is different from the common practice and expectation of operational
OCI registries always utilizing HTTPS as the underlying transport mechanism.

The net result is an idea of offering the ability to "fall back" to HTTP
automatically, and make it a configuration option which needs to be
chosen by an operator.

The code pattern is such that the invocation of the client code paths
automatically identify the SSLError, and then attempt to fallback
to HTTP, while also saving the fallback on the class instance so the
additional URL generation calls for the underlying HTTP(S) client
gets an appropriate URL.

By default, this new option is disabled.

Claude helped with the tests, which was nice of it.

Assisted-By: Claude Code - Claude Sonnet 4.5
Change-Id: I3f28c8d6debe25b63ca836d488bc9fd8541b04d9
Signed-off-by: Julia Kreger <juliaashleykreger@gmail.com>
This commit is contained in:
Julia Kreger 2025-12-02 09:18:09 -08:00
parent 9c00256b3b
commit 595258da9f
4 changed files with 524 additions and 27 deletions

View file

@ -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',

View file

@ -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.')),
]

View file

@ -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):

View file

@ -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.